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