SimpleWFSHelper.java

/*
 * Copyright (C) 2023 B3Partners B.V.
 *
 * SPDX-License-Identifier: MIT
 */
package org.tailormap.api.geotools.wfs;

import static org.tailormap.api.util.HttpProxyUtil.setHttpBasicAuthenticationHeader;

import java.io.InputStream;
import java.lang.invoke.MethodHandles;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;
import org.geotools.http.HTTPClient;
import org.geotools.http.SimpleHttpClient;
import org.geotools.ows.wms.LayerDescription;
import org.geotools.ows.wms.WMS1_1_1;
import org.geotools.ows.wms.WebMapServer;
import org.geotools.ows.wms.request.DescribeLayerRequest;
import org.geotools.ows.wms.response.DescribeLayerResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.xml.DomUtils;
import org.springframework.util.xml.SimpleNamespaceContext;
import org.springframework.web.util.UriComponentsBuilder;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

public class SimpleWFSHelper {
  private static final Logger logger =
      LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
  public static final int TIMEOUT = 5000;

  public static final String DEFAULT_WFS_VERSION = "1.1.0";

  private static HttpClient getDefaultHttpClient() {
    return HttpClient.newBuilder()
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();
  }

  public static URI getWFSRequestURL(String wfsUrl, String request) {
    return getWFSRequestURL(wfsUrl, request, null);
  }

  public static URI getWFSRequestURL(String wfsUrl, String request, MultiValueMap<String, String> parameters) {
    return getWFSRequestURL(wfsUrl, request, DEFAULT_WFS_VERSION, parameters);
  }

  public static URI getWFSRequestURL(
      String wfsUrl, String request, String version, MultiValueMap<String, String> parameters) {
    return getOGCRequestURL(wfsUrl, "WFS", version, request, parameters);
  }

  public static URI getOGCRequestURL(
      String url, String service, String version, String request, MultiValueMap<String, String> parameters) {

    final MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
    params.add("SERVICE", service);
    params.add("VERSION", version);
    params.add("REQUEST", request);
    if (parameters != null) {
      // We need to encode the parameters manually because UriComponentsBuilder annoyingly does not
      // encode '+' as used in mime types for output formats, see
      // https://stackoverflow.com/questions/18138011
      parameters.replaceAll((key, values) -> values.stream()
          .map(s -> URLEncoder.encode(s, StandardCharsets.UTF_8))
          .collect(Collectors.toList()));
      params.addAll(parameters);
    }
    return UriComponentsBuilder.fromUriString(url)
        .replaceQueryParams(params)
        .build(true)
        .toUri();
  }

  /**
   * Get a list of GetFeature output formats for a WFS feature type.
   *
   * <p>If there are no specific output formats for the type, the generally supported output formats are returned.
   * Requests WFS 1.1.0 but also handles WFS 2.0.0 responses.
   *
   * <p>Uses a 'lightweight' WFS implementation parsing only the XML WFS capabilities to extract the output formats
   * using XPath, instead of using a heavyweight GeoTools WFS DataStore which is much slower.
   */
  public static List<String> getOutputFormats(String wfsUrl, String typeName, String username, String password)
      throws Exception {
    return getOutputFormats(wfsUrl, typeName, username, password, getDefaultHttpClient());
  }

  public static List<String> getOutputFormats(
      String wfsUrl, String typeName, String username, String password, HttpClient httpClient) throws Exception {

    URI wfsGetCapabilities = getWFSRequestURL(wfsUrl, "GetCapabilities");

    HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(wfsGetCapabilities);

    setHttpBasicAuthenticationHeader(requestBuilder, username, password);

    HttpResponse<InputStream> response =
        httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());

    // Parse capabilities in DOM

    DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
    documentBuilderFactory.setNamespaceAware(true);
    documentBuilderFactory.setExpandEntityReferences(false);
    documentBuilderFactory.setValidating(false);
    documentBuilderFactory.setXIncludeAware(false);
    documentBuilderFactory.setCoalescing(true);
    documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
    DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
    Document doc = documentBuilder.parse(response.body());

    // WFS 1.1.0 and WFS 2.0.0 use different namespaces, but the same local element names
    boolean wfs2 = "2.0.0".equals(doc.getDocumentElement().getAttribute("version"));
    XPath xPath = XPathFactory.newInstance().newXPath();
    SimpleNamespaceContext namespaceContext = new SimpleNamespaceContext();
    namespaceContext.bindNamespaceUri("ows", "http://www.opengis.net/ows" + (wfs2 ? "/1.1" : ""));
    namespaceContext.bindNamespaceUri("wfs", "http://www.opengis.net/wfs" + (wfs2 ? "/2.0" : ""));
    xPath.setNamespaceContext(namespaceContext);

    List<String> outputFormats = null;

    // Find all feature types and loop through to find the typeName. We can't use XPath with the
    // typeName parameter in it to find the exact FeatureType because escaping characters like '
    // in the typeName is not possible.
    NodeList featureTypes =
        (NodeList) xPath.compile("/wfs:WFS_Capabilities" + "/wfs:FeatureTypeList" + "/wfs:FeatureType")
            .evaluate(doc, XPathConstants.NODESET);

    for (int i = 0; i < featureTypes.getLength(); i++) {
      Element n = (Element) featureTypes.item(i);
      Element name = DomUtils.getChildElementByTagName(n, "Name");
      if (name != null && typeName.equals(DomUtils.getTextValue(name))) {
        Element formatsNode = DomUtils.getChildElementByTagName(n, "OutputFormats");
        if (formatsNode != null) {
          outputFormats = DomUtils.getChildElementsByTagName(formatsNode, "Format").stream()
              .map(DomUtils::getTextValue)
              .collect(Collectors.toList());
        }
        break;
      }
    }

    // No output formats found (or maybe not even the featureType...), return output formats from
    // OperationsMetadata
    if (outputFormats == null) {
      String xpathExpr = "/wfs:WFS_Capabilities"
          + "/ows:OperationsMetadata"
          + "/ows:Operation[@name='GetFeature']"
          + "/ows:Parameter[@name='outputFormat']"
          + "//ows:Value";
      NodeList nodes = (NodeList) xPath.compile(xpathExpr).evaluate(doc, XPathConstants.NODESET);

      outputFormats = new ArrayList<>();
      for (int i = 0; i < nodes.getLength(); i++) {
        outputFormats.add(DomUtils.getTextValue((Element) nodes.item(i)));
      }
    }

    return outputFormats;
  }

  public static SimpleWFSLayerDescription describeWMSLayer(
      String url, String username, String password, String layerName) {
    return describeWMSLayers(url, username, password, List.of(layerName)).get(layerName);
  }

  public static Map<String, SimpleWFSLayerDescription> describeWMSLayers(
      String url, String username, String password, List<String> layers) {
    try {
      HTTPClient client = new SimpleHttpClient();
      client.setUser(username);
      client.setPassword(password);
      client.setConnectTimeout(TIMEOUT);
      client.setReadTimeout(TIMEOUT);
      WebMapServer wms = new WebMapServer(new URI(url).toURL(), client);
      // Directly create WMS 1.1.1 request. Creating it from WebMapServer errors with GeoServer
      // about unsupported request in capabilities unless we override WebMapServer to set up
      // specifications.
      DescribeLayerRequest describeLayerRequest = new WMS1_1_1().createDescribeLayerRequest(new URI(url).toURL());
      // XXX Otherwise GeoTools will send VERSION=1.1.0...
      describeLayerRequest.setProperty("VERSION", "1.1.1");
      describeLayerRequest.setLayers(String.join(",", layers));
      // GeoTools will throw a ClassCastException when a WMS ServiceException is returned
      DescribeLayerResponse describeLayerResponse = wms.issueRequest(describeLayerRequest);

      Map<String, SimpleWFSLayerDescription> descriptions = new HashMap<>();
      for (LayerDescription ld : describeLayerResponse.getLayerDescs()) {
        String wfsUrl = getWfsUrl(ld, wms);

        if (wfsUrl != null && ld.getQueries() != null && ld.getQueries().length != 0) {
          descriptions.put(ld.getName(), new SimpleWFSLayerDescription(wfsUrl, List.of(ld.getQueries())));
        }
      }
      return Collections.unmodifiableMap(descriptions);
    } catch (Exception e) {
      String msg =
          "Error in DescribeLayer request to WMS \"%s\": %s: %s".formatted(url, e.getClass(), e.getMessage());
      if (logger.isTraceEnabled()) {
        logger.trace(msg, e);
      } else {
        logger.debug("{}. Set log level to TRACE for stacktrace.", msg);
      }
    }

    return Collections.emptyMap();
  }

  private static String getWfsUrl(LayerDescription ld, WebMapServer wms) {
    String wfsUrl = (ld.getWfs() != null) ? ld.getWfs().toString() : null;
    if (wfsUrl == null && "WFS".equalsIgnoreCase(ld.getOwsType())) {
      wfsUrl = ld.getOwsURL().toString();
    }
    // OGC 02-070 Annex B says the wfs/owsURL attributed are not required but implied. Some
    // Deegree instance encountered has all attributes empty, and apparently the meaning is that
    // the WFS URL is the same as the WMS URL (not explicitly defined in the spec).
    if (wfsUrl == null) {
      wfsUrl = wms.getInfo().getSource().toString();
    }
    return wfsUrl;
  }
}