001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.io.IOException;
007import java.io.InputStream;
008import java.io.Reader;
009import java.lang.reflect.Field;
010import java.lang.reflect.Method;
011import java.lang.reflect.Modifier;
012import java.util.HashMap;
013import java.util.Iterator;
014import java.util.LinkedList;
015import java.util.List;
016import java.util.Locale;
017import java.util.Map;
018import java.util.Stack;
019
020import javax.xml.parsers.ParserConfigurationException;
021import javax.xml.transform.stream.StreamSource;
022import javax.xml.validation.Schema;
023import javax.xml.validation.SchemaFactory;
024import javax.xml.validation.ValidatorHandler;
025
026import org.openstreetmap.josm.io.CachedFile;
027import org.xml.sax.Attributes;
028import org.xml.sax.ContentHandler;
029import org.xml.sax.InputSource;
030import org.xml.sax.Locator;
031import org.xml.sax.SAXException;
032import org.xml.sax.SAXParseException;
033import org.xml.sax.XMLReader;
034import org.xml.sax.helpers.DefaultHandler;
035import org.xml.sax.helpers.XMLFilterImpl;
036
037/**
038 * An helper class that reads from a XML stream into specific objects.
039 *
040 * @author Imi
041 */
042public class XmlObjectParser implements Iterable<Object> {
043    /**
044     * The language prefix to use
045     */
046    public static final String lang = LanguageInfo.getLanguageCodeXML();
047
048    private static class AddNamespaceFilter extends XMLFilterImpl {
049
050        private final String namespace;
051
052        AddNamespaceFilter(String namespace) {
053            this.namespace = namespace;
054        }
055
056        @Override
057        public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException {
058            if ("".equals(uri)) {
059                super.startElement(namespace, localName, qName, atts);
060            } else {
061                super.startElement(uri, localName, qName, atts);
062            }
063        }
064    }
065
066    private class Parser extends DefaultHandler {
067        private final Stack<Object> current = new Stack<>();
068        private StringBuilder characters = new StringBuilder(64);
069
070        private Locator locator;
071
072        @Override
073        public void setDocumentLocator(Locator locator) {
074            this.locator = locator;
075        }
076
077        protected void throwException(Exception e) throws XmlParsingException {
078            throw new XmlParsingException(e).rememberLocation(locator);
079        }
080
081        @Override
082        public void startElement(String ns, String lname, String qname, Attributes a) throws SAXException {
083            if (mapping.containsKey(qname)) {
084                Class<?> klass = mapping.get(qname).klass;
085                try {
086                    current.push(klass.getConstructor().newInstance());
087                } catch (ReflectiveOperationException e) {
088                    throwException(e);
089                }
090                for (int i = 0; i < a.getLength(); ++i) {
091                    setValue(mapping.get(qname), a.getQName(i), a.getValue(i));
092                }
093                if (mapping.get(qname).onStart) {
094                    report();
095                }
096                if (mapping.get(qname).both) {
097                    queue.add(current.peek());
098                }
099            }
100        }
101
102        @Override
103        public void endElement(String ns, String lname, String qname) throws SAXException {
104            if (mapping.containsKey(qname) && !mapping.get(qname).onStart) {
105                report();
106            } else if (mapping.containsKey(qname) && characters != null && !current.isEmpty()) {
107                setValue(mapping.get(qname), qname, characters.toString().trim());
108                characters = new StringBuilder(64);
109            }
110        }
111
112        @Override
113        public void characters(char[] ch, int start, int length) {
114            characters.append(ch, start, length);
115        }
116
117        private void report() {
118            queue.add(current.pop());
119            characters = new StringBuilder(64);
120        }
121
122        private Object getValueForClass(Class<?> klass, String value) {
123            if (boolean.class.equals(klass))
124                return parseBoolean(value);
125            else if (Integer.class.equals(klass))
126                return Integer.valueOf(value);
127            else if (Long.class.equals(klass))
128                return Long.valueOf(value);
129            else if (Float.class.equals(klass))
130                return Float.valueOf(value);
131            else if (Double.class.equals(klass))
132                return Double.valueOf(value);
133            return value;
134        }
135
136        private void setValue(Entry entry, String fieldName, String value) throws SAXException {
137            CheckParameterUtil.ensureParameterNotNull(entry, "entry");
138            if ("class".equals(fieldName) || "default".equals(fieldName) || "throw".equals(fieldName) ||
139                    "new".equals(fieldName) || "null".equals(fieldName)) {
140                fieldName += '_';
141            }
142            fieldName = fieldName.replace(':', '_');
143            try {
144                Object c = current.peek();
145                Field f = entry.getField(fieldName);
146                if (f == null && fieldName.startsWith(lang)) {
147                    f = entry.getField("locale_" + fieldName.substring(lang.length()));
148                }
149                if (f != null && Modifier.isPublic(f.getModifiers()) && (
150                        String.class.equals(f.getType()) || boolean.class.equals(f.getType()) ||
151                        Float.class.equals(f.getType()) || Double.class.equals(f.getType()) ||
152                        Long.class.equals(f.getType()) || Integer.class.equals(f.getType()))) {
153                    f.set(c, getValueForClass(f.getType(), value));
154                } else {
155                    String setter;
156                    if (fieldName.startsWith(lang)) {
157                        int l = lang.length();
158                        setter = "set" + fieldName.substring(l, l + 1).toUpperCase(Locale.ENGLISH) + fieldName.substring(l + 1);
159                    } else {
160                        setter = "set" + fieldName.substring(0, 1).toUpperCase(Locale.ENGLISH) + fieldName.substring(1);
161                    }
162                    Method m = entry.getMethod(setter);
163                    if (m != null) {
164                        m.invoke(c, getValueForClass(m.getParameterTypes()[0], value));
165                    }
166                }
167            } catch (ReflectiveOperationException | IllegalArgumentException e) {
168                Logging.error(e); // SAXException does not dump inner exceptions.
169                throwException(e);
170            }
171        }
172
173        private boolean parseBoolean(String s) {
174            return s != null
175                    && !"0".equals(s)
176                    && !s.startsWith("off")
177                    && !s.startsWith("false")
178                    && !s.startsWith("no");
179        }
180
181        @Override
182        public void error(SAXParseException e) throws SAXException {
183            throwException(e);
184        }
185
186        @Override
187        public void fatalError(SAXParseException e) throws SAXException {
188            throwException(e);
189        }
190    }
191
192    private static class Entry {
193        private final Class<?> klass;
194        private final boolean onStart;
195        private final boolean both;
196        private final Map<String, Field> fields = new HashMap<>();
197        private final Map<String, Method> methods = new HashMap<>();
198
199        Entry(Class<?> klass, boolean onStart, boolean both) {
200            this.klass = klass;
201            this.onStart = onStart;
202            this.both = both;
203        }
204
205        Field getField(String s) {
206            if (fields.containsKey(s)) {
207                return fields.get(s);
208            } else {
209                try {
210                    Field f = klass.getField(s);
211                    fields.put(s, f);
212                    return f;
213                } catch (NoSuchFieldException ex) {
214                    Logging.trace(ex);
215                    fields.put(s, null);
216                    return null;
217                }
218            }
219        }
220
221        Method getMethod(String s) {
222            if (methods.containsKey(s)) {
223                return methods.get(s);
224            } else {
225                for (Method m : klass.getMethods()) {
226                    if (m.getName().equals(s) && m.getParameterTypes().length == 1) {
227                        methods.put(s, m);
228                        return m;
229                    }
230                }
231                methods.put(s, null);
232                return null;
233            }
234        }
235    }
236
237    private final Map<String, Entry> mapping = new HashMap<>();
238    private final DefaultHandler parser;
239
240    /**
241     * The queue of already parsed items from the parsing thread.
242     */
243    private final List<Object> queue = new LinkedList<>();
244    private Iterator<Object> queueIterator;
245
246    /**
247     * Constructs a new {@code XmlObjectParser}.
248     */
249    public XmlObjectParser() {
250        parser = new Parser();
251    }
252
253    private Iterable<Object> start(final Reader in, final ContentHandler contentHandler) throws SAXException, IOException {
254        try {
255            XMLReader reader = XmlUtils.newSafeSAXParser().getXMLReader();
256            reader.setContentHandler(contentHandler);
257            try {
258                // Do not load external DTDs (fix #8191)
259                reader.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
260            } catch (SAXException e) {
261                // Exception very unlikely to happen, so no need to translate this
262                Logging.log(Logging.LEVEL_ERROR, "Cannot disable 'load-external-dtd' feature:", e);
263            }
264            reader.parse(new InputSource(in));
265            queueIterator = queue.iterator();
266            return this;
267        } catch (ParserConfigurationException e) {
268            throw new JosmRuntimeException(e);
269        }
270    }
271
272    /**
273     * Starts parsing from the given input reader, without validation.
274     * @param in The input reader
275     * @return iterable collection of objects
276     * @throws SAXException if any XML or I/O error occurs
277     */
278    public Iterable<Object> start(final Reader in) throws SAXException {
279        try {
280            return start(in, parser);
281        } catch (IOException e) {
282            throw new SAXException(e);
283        }
284    }
285
286    /**
287     * Starts parsing from the given input reader, with XSD validation.
288     * @param in The input reader
289     * @param namespace default namespace
290     * @param schemaSource XSD schema
291     * @return iterable collection of objects
292     * @throws SAXException if any XML or I/O error occurs
293     */
294    public Iterable<Object> startWithValidation(final Reader in, String namespace, String schemaSource) throws SAXException {
295        SchemaFactory factory = XmlUtils.newXmlSchemaFactory();
296        try (CachedFile cf = new CachedFile(schemaSource); InputStream mis = cf.getInputStream()) {
297            Schema schema = factory.newSchema(new StreamSource(mis));
298            ValidatorHandler validator = schema.newValidatorHandler();
299            validator.setContentHandler(parser);
300            validator.setErrorHandler(parser);
301
302            AddNamespaceFilter filter = new AddNamespaceFilter(namespace);
303            filter.setContentHandler(validator);
304            return start(in, filter);
305        } catch (IOException e) {
306            throw new SAXException(tr("Failed to load XML schema."), e);
307        }
308    }
309
310    /**
311     * Add a new tag name to class type mapping
312     * @param tagName The tag name that should be converted to that class
313     * @param klass The class the XML elements should be converted to.
314     */
315    public void map(String tagName, Class<?> klass) {
316        mapping.put(tagName, new Entry(klass, false, false));
317    }
318
319    public void mapOnStart(String tagName, Class<?> klass) {
320        mapping.put(tagName, new Entry(klass, true, false));
321    }
322
323    public void mapBoth(String tagName, Class<?> klass) {
324        mapping.put(tagName, new Entry(klass, false, true));
325    }
326
327    /**
328     * Get the next element that was parsed
329     * @return The next object
330     */
331    public Object next() {
332        return queueIterator.next();
333    }
334
335    /**
336     * Check if there is a next parsed object available
337     * @return <code>true</code> if there is a next object
338     */
339    public boolean hasNext() {
340        return queueIterator.hasNext();
341    }
342
343    @Override
344    public Iterator<Object> iterator() {
345        return queue.iterator();
346    }
347}