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