/* (c) 2017 Open Source Geospatial Foundation - all rights reserved * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.opensearch.eo.response; import static org.geoserver.opensearch.eo.store.OpenSearchAccess.EO_NAMESPACE; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.BiConsumer; import java.util.stream.Collectors; import javax.xml.namespace.QName; import org.geoserver.config.GeoServerInfo; import org.geoserver.opensearch.eo.MetadataRequest; import org.geoserver.opensearch.eo.OSEOInfo; import org.geoserver.opensearch.eo.OpenSearchParameters; import org.geoserver.opensearch.eo.SearchRequest; import org.geoserver.opensearch.eo.SearchResults; import org.geoserver.opensearch.eo.store.JDBCOpenSearchAccess; import org.geoserver.opensearch.eo.store.OpenSearchAccess; import org.geoserver.ows.URLMangler.URLType; import org.geoserver.ows.util.ResponseUtils; import org.geoserver.platform.OWS20Exception; import org.geotools.data.Parameter; import org.geotools.data.Query; import org.geotools.feature.FeatureCollection; import org.geotools.feature.FeatureIterator; import org.geotools.feature.NameImpl; import org.geotools.geometry.jts.JTS; import org.geotools.gml3.GMLConfiguration; import org.geotools.referencing.CRS; import org.geotools.xml.Encoder; import org.geotools.xml.transform.Translator; import org.opengis.feature.Feature; import org.opengis.feature.Property; import org.opengis.feature.simple.SimpleFeature; import org.opengis.feature.type.FeatureType; import org.opengis.feature.type.GeometryDescriptor; import org.opengis.geometry.MismatchedDimensionException; import org.opengis.referencing.FactoryException; import org.opengis.referencing.crs.CoordinateReferenceSystem; import org.opengis.referencing.operation.TransformException; import org.springframework.http.MediaType; import org.xml.sax.Attributes; import org.xml.sax.ContentHandler; import org.xml.sax.SAXException; import com.vividsolutions.jts.geom.Geometry; import com.vividsolutions.jts.geom.MultiPoint; import com.vividsolutions.jts.geom.Polygon; /** * Transforms results into ATOM documents * * @author Andrea Aime - GeoSolutions */ public class AtomResultsTransformer extends LambdaTransformerBase { static final String QUICKLOOK_URL_KEY = "${QUICKLOOK_URL}"; static final String THUMB_URL_KEY = "${THUMB_URL}"; static final String ATOM_URL_KEY = "${ATOM_URL}"; static final String OM_METADATA_KEY = "${OM_METADATA_URL}"; static final String ISO_METADATA_KEY = "${ISO_METADATA_LINK}"; static final String BASE_URL_KEY = "${BASE_URL}"; static final GMLConfiguration GML_CONFIGURATION = new GMLConfiguration(); private OSEOInfo info; private GeoServerInfo gs; public AtomResultsTransformer(GeoServerInfo gs, OSEOInfo info) { this.info = info; this.gs = gs; } @Override public Translator createTranslator(ContentHandler handler) { return new ResultsTranslator(handler); } class ResultsTranslator extends LambdaTranslatorSupport { public ResultsTranslator(ContentHandler contentHandler) { super(contentHandler); } @Override public void encode(Object o) throws IllegalArgumentException { SearchResults results = (SearchResults) o; // xmlns:ical="http://www.w3.org/2002/12/cal/ical#" // xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" // xmlns:semantic="http://a9.com/-/opensearch/extensions/semantic/1.0/" // xmlns:sru="http://a9.com/-/opensearch/extensions/sru/2.0/" mapNamespacePrefix("", "http://www.w3.org/2005/Atom"); mapNamespacePrefix("gml", "http://www.opengis.net/gml"); mapNamespacePrefix("dc", "http://purl.org/dc/elements/1.1/"); mapNamespacePrefix("dct", "http://purl.org/dc/terms/"); mapNamespacePrefix("geo", "http://a9.com/-/opensearch/extensions/geo/1.0/"); mapNamespacePrefix("time", "http://a9.com/-/opensearch/extensions/time/1.0"); mapNamespacePrefix("eo", "http://a9.com/-/opensearch/extensions/eo/1.0/"); mapNamespacePrefix("os", "http://a9.com/-/spec/opensearch/1.1/"); mapNamespacePrefix("georss", "http://www.georss.org/georss"); mapNamespacePrefix("xlink", "http://www.w3.org/1999/xlink"); mapNamespacePrefix("xs", "http://www.w3.org/2001/XMLSchema"); mapNamespacePrefix("sch", "http://www.ascc.net/xml/schematron"); mapNamespacePrefix("owc", "http://www.opengis.net/owc/1.0"); mapNamespacePrefix("media", "http://search.yahoo.com/mrss/"); for (OpenSearchAccess.ProductClass pc : OpenSearchAccess.ProductClass.values()) { mapNamespacePrefix(pc.getPrefix(), pc.getNamespace()); } element("feed", () -> feedContents(results)); } private void mapNamespacePrefix(String prefix, String namespaceURI) { try { contentHandler.startPrefixMapping(prefix, namespaceURI); contentHandler.endPrefixMapping(prefix); } catch (SAXException e) { throw new RuntimeException(e); } } private void feedContents(SearchResults results) { final SearchRequest request = results.getRequest(); element("os:totalResults", "" + results.getTotalResults()); Integer startIndex = getQueryStartIndex(results) + 1; element("os:startIndex", "" + startIndex); element("os:itemsPerPage", "" + request.getQuery().getMaxFeatures()); element("os:Query", NO_CONTENTS, getQueryAttributes(request)); String organization = gs.getSettings().getContact().getContactOrganization(); if (organization != null) { element("author", () -> { element("name", organization); }); } String title = info.getTitle(); if (title != null) { element("title", title); } String updated = DateTimeFormatter.ISO_INSTANT.format(Instant.now()); element("updated", updated); buildPaginationLinks(results); buildSearchLink(results.getRequest()); encodeEntries(results.getResults(), results.getRequest()); } private void buildSearchLink(SearchRequest request) { Map<String, String> kvp = null; if(request.getParentId() != null) { kvp = Collections.singletonMap("parentId", request.getParentId()); } String href = ResponseUtils.buildURL(request.getBaseUrl(), "oseo/search/description", kvp, URLType.SERVICE); element("link", NO_CONTENTS, attributes("rel", "search", "href", href, "type", DescriptionResponse.OS_DESCRIPTION_MIME)); } private int getQueryStartIndex(SearchResults results) { Integer startIndex = results.getRequest().getQuery().getStartIndex(); if (startIndex == null) { startIndex = 0; } return startIndex; } private void buildPaginationLinks(SearchResults results) { final SearchRequest request = results.getRequest(); int total = results.getTotalResults(); int startIndex = getQueryStartIndex(results) + 1; int itemsPerPage = request.getQuery().getMaxFeatures(); // warning, opensearch is 1-based, geotools is 0 based encodePaginationLink("self", startIndex, itemsPerPage, request); encodePaginationLink("first", 1, itemsPerPage, request); if (startIndex > 1) { encodePaginationLink("previous", Math.max(startIndex - itemsPerPage, 1), itemsPerPage, request); } if (startIndex + itemsPerPage <= total) { encodePaginationLink("next", startIndex + itemsPerPage, itemsPerPage, request); } encodePaginationLink("last", getLastPageStart(total, itemsPerPage), itemsPerPage, request); } private void encodeEntries(FeatureCollection results, SearchRequest request) { FeatureType schema = results.getSchema(); final String schemaName = schema.getName().getLocalPart(); BiConsumer<Feature, SearchRequest> entryEncoder; if (JDBCOpenSearchAccess.COLLECTION.equals(schemaName)) { entryEncoder = this::encodeCollectionEntry; } else if (JDBCOpenSearchAccess.PRODUCT.equals(schemaName)) { entryEncoder = this::encodeProductEntry; } else { throw new IllegalArgumentException("Unrecognized feature type " + schemaName); } try (FeatureIterator<Feature> fi = results.features()) { while (fi.hasNext()) { Feature feature = fi.next(); element("entry", () -> entryEncoder.accept(feature, request)); } } } private void encodeCollectionEntry(Feature feature, SearchRequest request) { final String identifier = (String) value(feature, EO_NAMESPACE, "identifier"); // build links and description replacement variables String identifierLink = buildCollectionIdentifierLink(identifier, request); String metadataLink = buildMetadataLink(null, identifier, MetadataRequest.ISO_METADATA, request); Map<String, String> descriptionVariables = new HashMap<>(); descriptionVariables.put(ISO_METADATA_KEY, metadataLink); descriptionVariables.put(ATOM_URL_KEY, identifierLink); // generic contents encodeGenericEntryContents(feature, identifier, identifierLink, descriptionVariables); // build links to the metadata element("link", NO_CONTENTS, attributes("rel", "alternate", "href", metadataLink, "type", MetadataRequest.ISO_METADATA, "title", "ISO metadata")); // OGC links encodeOgcLinksFromFeature(feature, request); } private void mediaContent(String quicklookLink) { element("media:content", () -> { element("media:category", "THUMBNAIL", attributes("scheme", "http://www.opengis.net/spec/EOMPOM/1.0")); }, attributes("medium", "image", "type", "image/jpeg", "url", quicklookLink)); } private void encodeOgcLinksFromFeature(Feature feature, SearchRequest request) { // build ogc links if available Collection<Property> linkProperties = feature .getProperties(OpenSearchAccess.OGC_LINKS_PROPERTY_NAME); if (linkProperties != null) { Map<String, List<SimpleFeature>> linksByOffering = linkProperties.stream() .map(p -> (SimpleFeature) p).sorted(LinkFeatureComparator.INSTANCE) .collect(Collectors.groupingBy(f -> (String) f.getAttribute("offering"))); String hrefBase = getHRefBase(request); encodeOgcLinks(linksByOffering, hrefBase); } } private String getHRefBase(SearchRequest request) { String baseURL = request.getBaseUrl(); String hrefBase = ResponseUtils.buildURL(baseURL, null, null, URLType.SERVICE); if (hrefBase.endsWith("/")) { hrefBase = hrefBase.substring(0, hrefBase.length() - 1); } return hrefBase; } private void encodeOgcLinks(Map<String, List<SimpleFeature>> linksByOffering, String hrefBase) { linksByOffering.forEach((offering, links) -> { element("owc:offering", () -> { for (SimpleFeature link : links) { encodeOgcLink(link, hrefBase); } }, attributes("code", offering)); }); } private void encodeOgcLink(SimpleFeature link, String hrefBase) { String method = (String) link.getAttribute("method"); String code = (String) link.getAttribute("code"); String type = (String) link.getAttribute("type"); String href = (String) link.getAttribute("href"); String hrefExpanded = QuickTemplate.replaceVariables(href, Collections.singletonMap(BASE_URL_KEY, hrefBase)); element("owc:operation", NO_CONTENTS, attributes("method", method, "code", code, "href", hrefExpanded, "type", type)); } private void encodeProductEntry(Feature feature, SearchRequest request) { final String identifier = (String) value(feature, OpenSearchAccess.ProductClass.EOP_GENERIC.getNamespace(), "identifier"); // encode the generic contents String productIdentifierLink = buildProductIdentifierLink(identifier, request); String metadataLink = buildMetadataLink(request.getParentId(), identifier, MetadataRequest.OM_METADATA, request); String quicklookLink = buildQuicklookLink(identifier, request); Map<String, String> descriptionVariables = new HashMap<>(); descriptionVariables.put(QUICKLOOK_URL_KEY, quicklookLink); descriptionVariables.put(THUMB_URL_KEY, quicklookLink); descriptionVariables.put(ATOM_URL_KEY, productIdentifierLink); descriptionVariables.put(OM_METADATA_KEY, metadataLink); encodeGenericEntryContents(feature, identifier, productIdentifierLink, descriptionVariables); // build links to the metadata element("link", NO_CONTENTS, attributes("rel", "alternate", "href", metadataLink, "type", MetadataRequest.OM_METADATA, "title", "O&M metadata")); // and a quicklook as a link and as media if(quicklookLink != null) { element("link", NO_CONTENTS, attributes("rel", "icon", "href", quicklookLink, "type", "image/jpeg", "title", "Quicklook")); element("media:group", () -> mediaContent(quicklookLink)); } encodeOgcLinksFromFeature(feature, request); encodeDownloadLink(feature, request); } private void encodeDownloadLink(Feature feature, SearchRequest request) { String location = (String) value(feature, null, OpenSearchAccess.ORIGINAL_PACKAGE_LOCATION); if(location != null) { String type = (String) value(feature, null, OpenSearchAccess.ORIGINAL_PACKAGE_TYPE); if(type == null) { type = MediaType.APPLICATION_OCTET_STREAM_VALUE; } String hrefBase = getHRefBase(request); String locationExpanded = QuickTemplate.replaceVariables(location, Collections.singletonMap(BASE_URL_KEY, hrefBase)); element("link", NO_CONTENTS, attributes("rel", "enclosure", "href", locationExpanded, "type", type, "title", "Source package download")); } } private void encodeGenericEntryContents(Feature feature, String name, final String identifierLink, Map<String, String> descriptionVariables) { element("id", identifierLink); element("title", name); element("dc:identifier", name); Date start = (Date) value(feature, "timeStart"); Date end = (Date) value(feature, "timeEnd"); if(start != null || end != null) { // TODO: need an actual update column Date updated = end == null ? start : end; String formattedUpdated = DateTimeFormatter.ISO_INSTANT.format(updated.toInstant()); element("updated", formattedUpdated); // dc:date, can be a range String spec; if(start != null && end != null && start.equals(end)) { spec = DateTimeFormatter.ISO_INSTANT.format(start.toInstant()); } else { spec = start != null ? DateTimeFormatter.ISO_INSTANT.format(start.toInstant()) : ""; spec += "/"; spec += end != null ? DateTimeFormatter.ISO_INSTANT.format(end.toInstant()) : ""; } element("dc:date", spec); } Geometry footprint = (Geometry) value(feature, "footprint"); if (footprint != null) { element("georss:where", () -> encodeGmlRssGeometry(footprint)); } String htmlDescription = (String) value(feature, "htmlDescription"); if (htmlDescription != null) { String expanded = QuickTemplate.replaceVariables(htmlDescription, descriptionVariables); element("summary", () -> cdata(expanded), attributes("type", "html")); } // self link element("link", NO_CONTENTS, attributes("rel", "self", "href", identifierLink, "type", AtomSearchResponse.MIME, "title", "self")); } private void encodeGmlRssGeometry(Geometry g) { try { // get the proper element name QName elementName = null; if (g instanceof Polygon) { elementName = org.geotools.gml2.GML.Polygon; } else if (g instanceof MultiPoint) { elementName = org.geotools.gml2.GML.MultiPoint; } else { elementName = org.geotools.gml2.GML._Geometry; } // encode in GML3 Encoder encoder = new Encoder(GML_CONFIGURATION); encoder.setInline(true); encoder.setIndenting(true); encoder.encode(g, elementName, contentHandler); } catch (Exception e) { throw new RuntimeException("Cannot transform the specified geometry in GML", e); } } private String buildCollectionIdentifierLink(Object identifier, SearchRequest request) { String baseURL = request.getBaseUrl(); Map<String, String> kvp = new LinkedHashMap<String, String>(); kvp.put("uid", String.valueOf(identifier)); kvp.put("httpAccept", AtomSearchResponse.MIME); String href = ResponseUtils.buildURL(baseURL, "oseo/search", kvp, URLType.SERVICE); return href; } private String buildProductIdentifierLink(Object identifier, SearchRequest request) { String baseURL = request.getBaseUrl(); Map<String, String> kvp = new LinkedHashMap<String, String>(); kvp.put("parentId", request.getParentId()); kvp.put("uid", String.valueOf(identifier)); kvp.put("httpAccept", AtomSearchResponse.MIME); String href = ResponseUtils.buildURL(baseURL, "oseo/search", kvp, URLType.SERVICE); return href; } private String buildQuicklookLink(String identifier, SearchRequest request) { String baseURL = request.getBaseUrl(); Map<String, String> kvp = new LinkedHashMap<String, String>(); kvp.put("parentId", request.getParentId()); kvp.put("uid", String.valueOf(identifier)); String href = ResponseUtils.buildURL(baseURL, "oseo/quicklook", kvp, URLType.SERVICE); return href; } private String buildMetadataLink(String parentIdentifier, Object identifier, String mimeType, SearchRequest request) { String baseURL = request.getBaseUrl(); Map<String, String> kvp = new LinkedHashMap<String, String>(); if (parentIdentifier != null) { kvp.put("parentId", String.valueOf(parentIdentifier)); } kvp.put("uid", String.valueOf(identifier)); if (mimeType != null) { kvp.put("httpAccept", mimeType); } String href = ResponseUtils.buildURL(baseURL, "oseo/metadata", kvp, URLType.SERVICE); return href; } private Object value(Feature feature, String attribute) { String prefix = feature.getType().getName().getNamespaceURI(); return value(feature, prefix, attribute); } private Object value(Feature feature, String prefix, String attribute) { Property property; if(prefix != null) { property = feature.getProperty(new NameImpl(prefix, attribute)); } else { property = feature.getProperty(attribute); } if (property == null) { return null; } else { Object value = property.getValue(); if (value instanceof Geometry) { // cheap reprojection support since there is no reprojecting collection // wrapper for complex features CoordinateReferenceSystem nativeCRS = ((GeometryDescriptor) property .getDescriptor()).getCoordinateReferenceSystem(); if (nativeCRS != null && !CRS.equalsIgnoreMetadata(nativeCRS, OpenSearchParameters.OUTPUT_CRS)) { Geometry g = (Geometry) value; try { return JTS.transform(g, CRS.findMathTransform(nativeCRS, OpenSearchParameters.OUTPUT_CRS)); } catch (MismatchedDimensionException | TransformException | FactoryException e) { throw new OWS20Exception( "Failed to reproject geometry to EPSG:4326 lat/lon", e); } } } return value; } } private int getLastPageStart(int total, int itemsPerPage) { // all in one page? if (total <= itemsPerPage || itemsPerPage == 0) { return 1; } // check how many items in the last page, is the last page partial or full? int lastPageItems = total % itemsPerPage; if (lastPageItems == 0) { lastPageItems = itemsPerPage; } return total - lastPageItems + 1; } private void encodePaginationLink(String rel, int startIndex, int itemsPerPage, SearchRequest request) { String baseURL = request.getBaseUrl(); Map<String, String> kvp = new LinkedHashMap<String, String>(); for (Map.Entry<Parameter, String> entry : request.getSearchParameters().entrySet()) { Parameter parameter = entry.getKey(); String value = entry.getValue(); String key = OpenSearchParameters.getQualifiedParamName(parameter, false); kvp.put(key, value); } kvp.put("startIndex", "" + startIndex); kvp.put("count", "" + itemsPerPage); kvp.put("httpAccept", AtomSearchResponse.MIME); String href = ResponseUtils.buildURL(baseURL, "oseo/search", kvp, URLType.SERVICE); element("link", NO_CONTENTS, attributes("rel", rel, "href", href, "type", AtomSearchResponse.MIME)); } public Attributes getQueryAttributes(SearchRequest request) { // turn each request parameter into an attribute for os:Query Map<String, String> parameters = new LinkedHashMap<>(); for (Map.Entry<Parameter, String> entry : request.getSearchParameters().entrySet()) { Parameter parameter = entry.getKey(); String value = entry.getValue(); String key = OpenSearchParameters.getQualifiedParamName(parameter, false); parameters.put(key, value); } // fill in defaults final Query query = request.getQuery(); if (parameters.get("count") == null) { parameters.put("count", "" + query.getMaxFeatures()); } if (parameters.get("startIndex") == null) { Integer startIndex = query.getStartIndex(); if (startIndex == null) { startIndex = 1; } parameters.put("startIndex", "" + startIndex); } parameters.put("role", "request"); return attributes(parameters); } } }