001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io.imagery;
003
004import java.awt.HeadlessException;
005import java.io.IOException;
006import java.io.InputStream;
007import java.io.StringReader;
008import java.io.StringWriter;
009import java.net.MalformedURLException;
010import java.net.URL;
011import java.util.ArrayList;
012import java.util.Collection;
013import java.util.Collections;
014import java.util.HashSet;
015import java.util.Iterator;
016import java.util.List;
017import java.util.Locale;
018import java.util.NoSuchElementException;
019import java.util.Set;
020import java.util.regex.Matcher;
021import java.util.regex.Pattern;
022import java.util.stream.Collectors;
023import java.util.stream.Stream;
024import java.util.stream.StreamSupport;
025
026import javax.imageio.ImageIO;
027import javax.xml.parsers.DocumentBuilder;
028import javax.xml.parsers.ParserConfigurationException;
029import javax.xml.transform.TransformerException;
030import javax.xml.transform.TransformerFactory;
031import javax.xml.transform.TransformerFactoryConfigurationError;
032import javax.xml.transform.dom.DOMSource;
033import javax.xml.transform.stream.StreamResult;
034
035import org.openstreetmap.josm.data.Bounds;
036import org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper;
037import org.openstreetmap.josm.data.imagery.ImageryInfo;
038import org.openstreetmap.josm.data.projection.Projections;
039import org.openstreetmap.josm.tools.HttpClient;
040import org.openstreetmap.josm.tools.HttpClient.Response;
041import org.openstreetmap.josm.tools.Logging;
042import org.openstreetmap.josm.tools.Utils;
043import org.w3c.dom.Document;
044import org.w3c.dom.Element;
045import org.w3c.dom.Node;
046import org.w3c.dom.NodeList;
047import org.xml.sax.InputSource;
048import org.xml.sax.SAXException;
049
050/**
051 * This class represents the capabilities of a WMS imagery server.
052 */
053public class WMSImagery {
054
055    private static final class ChildIterator implements Iterator<Element> {
056        private Element child;
057
058        ChildIterator(Element parent) {
059            child = advanceToElement(parent.getFirstChild());
060        }
061
062        private static Element advanceToElement(Node firstChild) {
063            Node node = firstChild;
064            while (node != null && !(node instanceof Element)) {
065                node = node.getNextSibling();
066            }
067            return (Element) node;
068        }
069
070        @Override
071        public boolean hasNext() {
072            return child != null;
073        }
074
075        @Override
076        public Element next() {
077            if (!hasNext()) {
078                throw new NoSuchElementException("No next sibling.");
079            }
080            Element next = child;
081            child = advanceToElement(child.getNextSibling());
082            return next;
083        }
084    }
085
086    /**
087     * An exception that is thrown if there was an error while getting the capabilities of the WMS server.
088     */
089    public static class WMSGetCapabilitiesException extends Exception {
090        private final String incomingData;
091
092        /**
093         * Constructs a new {@code WMSGetCapabilitiesException}
094         * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method)
095         * @param incomingData the answer from WMS server
096         */
097        public WMSGetCapabilitiesException(Throwable cause, String incomingData) {
098            super(cause);
099            this.incomingData = incomingData;
100        }
101
102        /**
103         * Constructs a new {@code WMSGetCapabilitiesException}
104         * @param message   the detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method
105         * @param incomingData the answer from the server
106         * @since 10520
107         */
108        public WMSGetCapabilitiesException(String message, String incomingData) {
109            super(message);
110            this.incomingData = incomingData;
111        }
112
113        /**
114         * The data that caused this exception.
115         * @return The server response to the capabilities request.
116         */
117        public String getIncomingData() {
118            return incomingData;
119        }
120    }
121
122    private List<LayerDetails> layers;
123    private URL serviceUrl;
124    private List<String> formats;
125    private String version = "1.1.1";
126
127    /**
128     * Returns the list of layers.
129     * @return the list of layers
130     */
131    public List<LayerDetails> getLayers() {
132        return Collections.unmodifiableList(layers);
133    }
134
135    /**
136     * Returns the service URL.
137     * @return the service URL
138     */
139    public URL getServiceUrl() {
140        return serviceUrl;
141    }
142
143    /**
144     * Returns the WMS version used.
145     * @return the WMS version used (1.1.1 or 1.3.0)
146     * @since 13358
147     */
148    public String getVersion() {
149        return version;
150    }
151
152    /**
153     * Returns the list of supported formats.
154     * @return the list of supported formats
155     */
156    public List<String> getFormats() {
157        return Collections.unmodifiableList(formats);
158    }
159
160    /**
161     * Gets the preffered format for this imagery layer.
162     * @return The preffered format as mime type.
163     */
164    public String getPreferredFormats() {
165        if (formats.contains("image/jpeg")) {
166            return "image/jpeg";
167        } else if (formats.contains("image/png")) {
168            return "image/png";
169        } else if (formats.isEmpty()) {
170            return null;
171        } else {
172            return formats.get(0);
173        }
174    }
175
176    String buildRootUrl() {
177        if (serviceUrl == null) {
178            return null;
179        }
180        StringBuilder a = new StringBuilder(serviceUrl.getProtocol());
181        a.append("://").append(serviceUrl.getHost());
182        if (serviceUrl.getPort() != -1) {
183            a.append(':').append(serviceUrl.getPort());
184        }
185        a.append(serviceUrl.getPath()).append('?');
186        if (serviceUrl.getQuery() != null) {
187            a.append(serviceUrl.getQuery());
188            if (!serviceUrl.getQuery().isEmpty() && !serviceUrl.getQuery().endsWith("&")) {
189                a.append('&');
190            }
191        }
192        return a.toString();
193    }
194
195    /**
196     * Returns the URL for the "GetMap" WMS request in JPEG format.
197     * @param selectedLayers the list of selected layers, matching the "LAYERS" WMS request argument
198     * @return the URL for the "GetMap" WMS request
199     */
200    public String buildGetMapUrl(Collection<LayerDetails> selectedLayers) {
201        return buildGetMapUrl(selectedLayers, "image/jpeg");
202    }
203
204    /**
205     * Returns the URL for the "GetMap" WMS request.
206     * @param selectedLayers the list of selected layers, matching the "LAYERS" WMS request argument
207     * @param format the requested image format, matching the "FORMAT" WMS request argument
208     * @return the URL for the "GetMap" WMS request
209     */
210    public String buildGetMapUrl(Collection<LayerDetails> selectedLayers, String format) {
211        return buildRootUrl() + "FORMAT=" + format + (imageFormatHasTransparency(format) ? "&TRANSPARENT=TRUE" : "")
212                + "&VERSION=" + version + "&SERVICE=WMS&REQUEST=GetMap&LAYERS="
213                + selectedLayers.stream().map(x -> x.ident).collect(Collectors.joining(","))
214                + "&STYLES=&" + ("1.3.0".equals(version) ? "CRS" : "SRS") + "={proj}&WIDTH={width}&HEIGHT={height}&BBOX={bbox}";
215    }
216
217    /**
218     * Attempts WMS "GetCapabilities" request and initializes internal variables if successful.
219     * @param serviceUrlStr WMS service URL
220     * @throws IOException if any I/O errors occurs
221     * @throws WMSGetCapabilitiesException if the WMS server replies a ServiceException
222     */
223    public void attemptGetCapabilities(String serviceUrlStr) throws IOException, WMSGetCapabilitiesException {
224        URL getCapabilitiesUrl = null;
225        try {
226            if (!Pattern.compile(".*GetCapabilities.*", Pattern.CASE_INSENSITIVE).matcher(serviceUrlStr).matches()) {
227                // If the url doesn't already have GetCapabilities, add it in
228                getCapabilitiesUrl = new URL(serviceUrlStr);
229                final String getCapabilitiesQuery = "VERSION=1.1.1&SERVICE=WMS&REQUEST=GetCapabilities";
230                if (getCapabilitiesUrl.getQuery() == null) {
231                    getCapabilitiesUrl = new URL(serviceUrlStr + '?' + getCapabilitiesQuery);
232                } else if (!getCapabilitiesUrl.getQuery().isEmpty() && !getCapabilitiesUrl.getQuery().endsWith("&")) {
233                    getCapabilitiesUrl = new URL(serviceUrlStr + '&' + getCapabilitiesQuery);
234                } else {
235                    getCapabilitiesUrl = new URL(serviceUrlStr + getCapabilitiesQuery);
236                }
237            } else {
238                // Otherwise assume it's a good URL and let the subsequent error
239                // handling systems deal with problems
240                getCapabilitiesUrl = new URL(serviceUrlStr);
241            }
242            // Make sure we don't keep GetCapabilities request in service URL
243            serviceUrl = new URL(serviceUrlStr.replace("REQUEST=GetCapabilities", "").replace("&&", "&"));
244        } catch (HeadlessException e) {
245            Logging.warn(e);
246            return;
247        }
248
249        doAttemptGetCapabilities(serviceUrlStr, getCapabilitiesUrl);
250    }
251
252    /**
253     * Attempts WMS GetCapabilities with version 1.1.1 first, then 1.3.0 in case of specific errors.
254     * @param serviceUrlStr WMS service URL
255     * @param getCapabilitiesUrl GetCapabilities URL
256     * @throws IOException if any I/O error occurs
257     * @throws WMSGetCapabilitiesException if any HTTP or parsing error occurs
258     */
259    private void doAttemptGetCapabilities(String serviceUrlStr, URL getCapabilitiesUrl)
260            throws IOException, WMSGetCapabilitiesException {
261        final String url = getCapabilitiesUrl.toExternalForm();
262        final Response response = HttpClient.create(getCapabilitiesUrl).connect();
263
264        // Is the HTTP connection successul ?
265        if (response.getResponseCode() >= 400) {
266            // HTTP error for servers handling only WMS 1.3.0 ?
267            String errorMessage = response.getResponseMessage();
268            String errorContent = response.fetchContent();
269            Matcher tomcat = HttpClient.getTomcatErrorMatcher(errorContent);
270            boolean messageAbout130 = errorMessage != null && errorMessage.contains("1.3.0");
271            boolean contentAbout130 = errorContent != null && tomcat != null && tomcat.matches() && tomcat.group(1).contains("1.3.0");
272            if (url.contains("VERSION=1.1.1") && (messageAbout130 || contentAbout130)) {
273                doAttemptGetCapabilities130(serviceUrlStr, url);
274                return;
275            }
276            throw new WMSGetCapabilitiesException(errorMessage, errorContent);
277        }
278
279        try {
280            // Parse XML capabilities sent by the server
281            parseCapabilities(serviceUrlStr, response.getContent());
282        } catch (WMSGetCapabilitiesException e) {
283            // ServiceException for servers handling only WMS 1.3.0 ?
284            if (e.getCause() == null && url.contains("VERSION=1.1.1")) {
285                doAttemptGetCapabilities130(serviceUrlStr, url);
286            } else {
287                throw e;
288            }
289        }
290    }
291
292    /**
293     * Attempts WMS GetCapabilities with version 1.3.0.
294     * @param serviceUrlStr WMS service URL
295     * @param url GetCapabilities URL
296     * @throws IOException if any I/O error occurs
297     * @throws WMSGetCapabilitiesException if any HTTP or parsing error occurs
298     * @throws MalformedURLException in case of invalid URL
299     */
300    private void doAttemptGetCapabilities130(String serviceUrlStr, final String url)
301            throws IOException, WMSGetCapabilitiesException {
302        doAttemptGetCapabilities(serviceUrlStr, new URL(url.replace("VERSION=1.1.1", "VERSION=1.3.0")));
303        if (serviceUrl.toExternalForm().contains("VERSION=1.1.1")) {
304            serviceUrl = new URL(serviceUrl.toExternalForm().replace("VERSION=1.1.1", "VERSION=1.3.0"));
305        }
306        version = "1.3.0";
307    }
308
309    void parseCapabilities(String serviceUrlStr, InputStream contentStream) throws IOException, WMSGetCapabilitiesException {
310        String incomingData = null;
311        try {
312            DocumentBuilder builder = Utils.newSafeDOMBuilder();
313            builder.setEntityResolver((publicId, systemId) -> {
314                Logging.info("Ignoring DTD " + publicId + ", " + systemId);
315                return new InputSource(new StringReader(""));
316            });
317            Document document = builder.parse(contentStream);
318            Element root = document.getDocumentElement();
319
320            try {
321                StringWriter writer = new StringWriter();
322                TransformerFactory.newInstance().newTransformer().transform(new DOMSource(document), new StreamResult(writer));
323                incomingData = writer.getBuffer().toString();
324                Logging.debug("Server response to Capabilities request:");
325                Logging.debug(incomingData);
326            } catch (TransformerFactoryConfigurationError | TransformerException e) {
327                Logging.warn(e);
328            }
329
330            // Check if the request resulted in ServiceException
331            if ("ServiceException".equals(root.getTagName())) {
332                throw new WMSGetCapabilitiesException(root.getTextContent(), incomingData);
333            }
334
335            // Some WMS service URLs specify a different base URL for their GetMap service
336            Element child = getChild(root, "Capability");
337            child = getChild(child, "Request");
338            child = getChild(child, "GetMap");
339
340            formats = getChildrenStream(child, "Format")
341                    .map(Node::getTextContent)
342                    .filter(WMSImagery::isImageFormatSupportedWarn)
343                    .collect(Collectors.toList());
344
345            child = getChild(child, "DCPType");
346            child = getChild(child, "HTTP");
347            child = getChild(child, "Get");
348            child = getChild(child, "OnlineResource");
349            if (child != null) {
350                String baseURL = child.getAttributeNS(GetCapabilitiesParseHelper.XLINK_NS_URL, "href");
351                if (!baseURL.equals(serviceUrlStr)) {
352                    URL newURL = new URL(baseURL);
353                    if (newURL.getAuthority() != null) {
354                        Logging.info("GetCapabilities specifies a different service URL: " + baseURL);
355                        serviceUrl = newURL;
356                    }
357                }
358            }
359
360            Element capabilityElem = getChild(root, "Capability");
361            List<Element> children = getChildren(capabilityElem, "Layer");
362            layers = parseLayers(children, new HashSet<String>());
363        } catch (MalformedURLException | ParserConfigurationException | SAXException e) {
364            throw new WMSGetCapabilitiesException(e, incomingData);
365        }
366    }
367
368    private static boolean isImageFormatSupportedWarn(String format) {
369        boolean isFormatSupported = isImageFormatSupported(format);
370        if (!isFormatSupported) {
371            Logging.info("Skipping unsupported image format {0}", format);
372        }
373        return isFormatSupported;
374    }
375
376    static boolean isImageFormatSupported(final String format) {
377        return ImageIO.getImageReadersByMIMEType(format).hasNext()
378                // handles image/tiff image/tiff8 image/geotiff image/geotiff8
379                || isImageFormatSupported(format, "tiff", "geotiff")
380                || isImageFormatSupported(format, "png")
381                || isImageFormatSupported(format, "svg")
382                || isImageFormatSupported(format, "bmp");
383    }
384
385    static boolean isImageFormatSupported(String format, String... mimeFormats) {
386        for (String mime : mimeFormats) {
387            if (format.startsWith("image/" + mime)) {
388                return ImageIO.getImageReadersBySuffix(mimeFormats[0]).hasNext();
389            }
390        }
391        return false;
392    }
393
394    static boolean imageFormatHasTransparency(final String format) {
395        return format != null && (format.startsWith("image/png") || format.startsWith("image/gif")
396                || format.startsWith("image/svg") || format.startsWith("image/tiff"));
397    }
398
399    /**
400     * Returns a new {@code ImageryInfo} describing the given service name and selected WMS layers.
401     * @param name service name
402     * @param selectedLayers selected WMS layers
403     * @return a new {@code ImageryInfo} describing the given service name and selected WMS layers
404     */
405    public ImageryInfo toImageryInfo(String name, Collection<LayerDetails> selectedLayers) {
406        ImageryInfo i = new ImageryInfo(name, buildGetMapUrl(selectedLayers));
407        if (selectedLayers != null) {
408            Set<String> proj = new HashSet<>();
409            for (WMSImagery.LayerDetails l : selectedLayers) {
410                proj.addAll(l.getProjections());
411            }
412            i.setServerProjections(proj);
413        }
414        return i;
415    }
416
417    private List<LayerDetails> parseLayers(List<Element> children, Set<String> parentCrs) {
418        List<LayerDetails> details = new ArrayList<>(children.size());
419        for (Element element : children) {
420            details.add(parseLayer(element, parentCrs));
421        }
422        return details;
423    }
424
425    private LayerDetails parseLayer(Element element, Set<String> parentCrs) {
426        String name = getChildContent(element, "Title", null, null);
427        String ident = getChildContent(element, "Name", null, null);
428        String abstr = getChildContent(element, "Abstract", null, null);
429
430        // The set of supported CRS/SRS for this layer
431        Set<String> crsList = new HashSet<>();
432        // ...including this layer's already-parsed parent projections
433        crsList.addAll(parentCrs);
434
435        // Parse the CRS/SRS pulled out of this layer's XML element
436        // I think CRS and SRS are the same at this point
437        getChildrenStream(element)
438            .filter(child -> "CRS".equals(child.getNodeName()) || "SRS".equals(child.getNodeName()))
439            .map(WMSImagery::getContent)
440            .filter(crs -> !crs.isEmpty())
441            .map(crs -> crs.trim().toUpperCase(Locale.ENGLISH))
442            .forEach(crsList::add);
443
444        // Check to see if any of the specified projections are supported by JOSM
445        boolean josmSupportsThisLayer = false;
446        for (String crs : crsList) {
447            josmSupportsThisLayer |= isProjSupported(crs);
448        }
449
450        Bounds bounds = null;
451        Element bboxElem = getChild(element, "EX_GeographicBoundingBox");
452        if (bboxElem != null) {
453            // Attempt to use EX_GeographicBoundingBox for bounding box
454            double left = Double.parseDouble(getChildContent(bboxElem, "westBoundLongitude", null, null));
455            double top = Double.parseDouble(getChildContent(bboxElem, "northBoundLatitude", null, null));
456            double right = Double.parseDouble(getChildContent(bboxElem, "eastBoundLongitude", null, null));
457            double bot = Double.parseDouble(getChildContent(bboxElem, "southBoundLatitude", null, null));
458            bounds = new Bounds(bot, left, top, right);
459        } else {
460            // If that's not available, try LatLonBoundingBox
461            bboxElem = getChild(element, "LatLonBoundingBox");
462            if (bboxElem != null) {
463                double left = getDecimalDegree(bboxElem, "minx");
464                double top = getDecimalDegree(bboxElem, "maxy");
465                double right = getDecimalDegree(bboxElem, "maxx");
466                double bot = getDecimalDegree(bboxElem, "miny");
467                bounds = new Bounds(bot, left, top, right);
468            }
469        }
470
471        List<Element> layerChildren = getChildren(element, "Layer");
472        List<LayerDetails> childLayers = parseLayers(layerChildren, crsList);
473
474        return new LayerDetails(name, ident, abstr, crsList, josmSupportsThisLayer, bounds, childLayers);
475    }
476
477    private static double getDecimalDegree(Element elem, String attr) {
478        // Some real-world WMS servers use a comma instead of a dot as decimal separator (seen in Polish WMS server)
479        return Double.parseDouble(elem.getAttribute(attr).replace(',', '.'));
480    }
481
482    private static boolean isProjSupported(String crs) {
483        return Projections.getProjectionByCode(crs) != null;
484    }
485
486    private static String getChildContent(Element parent, String name, String missing, String empty) {
487        Element child = getChild(parent, name);
488        if (child == null)
489            return missing;
490        else {
491            String content = getContent(child);
492            return (!content.isEmpty()) ? content : empty;
493        }
494    }
495
496    private static String getContent(Element element) {
497        NodeList nl = element.getChildNodes();
498        StringBuilder content = new StringBuilder();
499        for (int i = 0; i < nl.getLength(); i++) {
500            Node node = nl.item(i);
501            switch (node.getNodeType()) {
502                case Node.ELEMENT_NODE:
503                    content.append(getContent((Element) node));
504                    break;
505                case Node.CDATA_SECTION_NODE:
506                case Node.TEXT_NODE:
507                    content.append(node.getNodeValue());
508                    break;
509                default: // Do nothing
510            }
511        }
512        return content.toString().trim();
513    }
514
515    private static Stream<Element> getChildrenStream(Element parent) {
516        if (parent == null) {
517            // ignore missing elements
518            return Stream.empty();
519        } else {
520            Iterable<Element> it = () -> new ChildIterator(parent);
521            return StreamSupport.stream(it.spliterator(), false);
522        }
523    }
524
525    private static Stream<Element> getChildrenStream(Element parent, String name) {
526        return getChildrenStream(parent).filter(child -> name.equals(child.getNodeName()));
527    }
528
529    private static List<Element> getChildren(Element parent, String name) {
530        return getChildrenStream(parent, name).collect(Collectors.toList());
531    }
532
533    private static Element getChild(Element parent, String name) {
534        return getChildrenStream(parent, name).findFirst().orElse(null);
535    }
536
537    /**
538     * The details of a layer of this WMS server.
539     */
540    public static class LayerDetails {
541
542        /**
543         * The layer name (WMS {@code Title})
544         */
545        public final String name;
546        /**
547         * The layer ident (WMS {@code Name})
548         */
549        public final String ident;
550        /**
551         * The layer abstract (WMS {@code Abstract})
552         * @since 13199
553         */
554        public final String abstr;
555        /**
556         * The child layers of this layer
557         */
558        public final List<LayerDetails> children;
559        /**
560         * The bounds this layer can be used for
561         */
562        public final Bounds bounds;
563        /**
564         * the CRS/SRS pulled out of this layer's XML element
565         */
566        public final Set<String> crsList;
567        /**
568         * {@code true} if any of the specified projections are supported by JOSM
569         */
570        public final boolean supported;
571
572        /**
573         * Constructs a new {@code LayerDetails}.
574         * @param name The layer name (WMS {@code Title})
575         * @param ident The layer ident (WMS {@code Name})
576         * @param abstr The layer abstract (WMS {@code Abstract})
577         * @param crsList The CRS/SRS pulled out of this layer's XML element
578         * @param supportedLayer {@code true} if any of the specified projections are supported by JOSM
579         * @param bounds The bounds this layer can be used for
580         * @param childLayers The child layers of this layer
581         * @since 13199
582         */
583        public LayerDetails(String name, String ident, String abstr, Set<String> crsList, boolean supportedLayer, Bounds bounds,
584                List<LayerDetails> childLayers) {
585            this.name = name;
586            this.ident = ident;
587            this.abstr = abstr;
588            this.supported = supportedLayer;
589            this.children = childLayers;
590            this.bounds = bounds;
591            this.crsList = crsList;
592        }
593
594        /**
595         * Determines if any of the specified projections are supported by JOSM.
596         * @return {@code true} if any of the specified projections are supported by JOSM
597         */
598        public boolean isSupported() {
599            return this.supported;
600        }
601
602        /**
603         * Returns the CRS/SRS pulled out of this layer's XML element.
604         * @return the CRS/SRS pulled out of this layer's XML element
605         */
606        public Set<String> getProjections() {
607            return crsList;
608        }
609
610        @Override
611        public String toString() {
612            String baseName = (name == null || name.isEmpty()) ? ident : name;
613            return abstr == null || abstr.equalsIgnoreCase(baseName) ? baseName : baseName + " (" + abstr + ')';
614        }
615    }
616}