diff --git a/CHANGELOG.md b/CHANGELOG.md index 03e994af..ea064b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ - CSV: WKT geometries with Z or ZM coordinates now separate their coordinates with commas (`LINESTRING Z(1 2 3,4 5 6)`, previously `LINESTRING Z(1 2 34 5 6)`). - `hakunapi-source-gpkg` reads rows one at a time into a reused container instead of batches of 250 (`BufferedResultSet` is removed from this module; `hakunapi-source-postgis` has its own copy). Geometry blobs are read through SQLite's own buffer instead of a `byte[]` per feature, and NULL checks are asked of SQLite by storage class. Output is unchanged. - New community module `hakunapi-tiles-core`: the OGC API - Tiles model (tilesets, tile matrix sets, tile and layer types, the `VectorTileSource` and `MapTileSource` extension points), `tiles.*` configuration parsing and a `zoom-routed-vector` source that picks a backing source by zoom. Nothing serves tiles yet. `ConformanceClass` gains the OGC API - Tiles and tile matrix set URIs, and `HakunaConfigParser` a `getProperties()` accessor. +- New community module `hakunapi-tiles-servlet-jakarta`: serves OGC API - Tiles (tile matrix sets, vector tilesets and tiles of a collection or the dataset, map tilesets and tiles) through an `ApiExtensionFactory`, so it is enabled by being on the classpath and publishes nothing when no tile layer is configured. No tile source implements the SPIs yet. +- `hakunapi-simple-servlet-jakarta` supports pre-encoded responses: an entity of `PreEncodedBytes` or `PreEncodedOutput` is sent as is, skipped by the gzip interceptor, and `byte[]` is described as a binary string in OpenAPI. +- A service no longer needs a `collections` property: with none, it starts as a collectionless service, serving the common conformance classes and whatever API extensions publish. ## 2.0.0 diff --git a/docs/overview/modules.md b/docs/overview/modules.md index b40b8b70..a103f486 100644 --- a/docs/overview/modules.md +++ b/docs/overview/modules.md @@ -283,6 +283,12 @@ Key differences between PostGIS and GeoPackage support in Hakunapi: [`hakunapi-bytes`](../../src-community/hakunapi-bytes/README.md) *(community module)* is a shared layer through which file-backed sources read their bytes: a local file (pread by default, mmap or read whole into memory opt-in) or a remote one over HTTP Range requests, with a shared block cache and adaptive read-ahead. It also reads an entry of a SOZip (seek-optimized ZIP) archive in its uncompressed form, with random access, over either. Reads copy into a buffer the caller owns; a memory-mapped or in-memory file additionally offers zero-copy access. No source uses it yet. It requires Java 22+ (the FFM API) and is built only on a 22+ JDK; its only dependency is `slf4j-api`. +## OGC API - Tiles (community) + +[`hakunapi-tiles-core`](../../src-community/hakunapi-tiles-core/README.md) *(community module)* holds the OGC API - Tiles model, the `tiles.*` configuration parsing and the `VectorTileSource` and `MapTileSource` extension points. + +[`hakunapi-tiles-servlet-jakarta`](../../src-community/hakunapi-tiles-servlet-jakarta/README.md) *(community module)* serves the tiles over HTTP. It plugs into `hakunapi-simple-servlet-jakarta` through the `ApiExtensionFactory` extension point, so it is enabled by being on the classpath, and publishes nothing when no tile layer is configured. It depends on `hakunapi-tiles-core`, `hakunapi-simple-servlet-jakarta`, `swagger-models` and `slf4j-api`. + ## Output data formats (stable) ### GeoJSON diff --git a/pom.xml b/pom.xml index e58f5d14..18cdfb4b 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,11 @@ hakunapi-tiles-core ${project.version} + + fi.nls.hakunapi + hakunapi-tiles-servlet-jakarta + ${project.version} + fi.nls.hakunapi hakunapi-config-test diff --git a/src-community/hakunapi-tiles-servlet-jakarta/LICENSE b/src-community/hakunapi-tiles-servlet-jakarta/LICENSE new file mode 100644 index 00000000..0eb4c375 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Janne Heikkilä + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src-community/hakunapi-tiles-servlet-jakarta/README.md b/src-community/hakunapi-tiles-servlet-jakarta/README.md new file mode 100644 index 00000000..06023649 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/README.md @@ -0,0 +1,31 @@ +# hakunapi-tiles-servlet-jakarta + +Community module serving [OGC API - Tiles](https://ogcapi.ogc.org/tiles/) over +HTTP: the JAX-RS resources, and the `ApiExtension` that plugs them into +`hakunapi-simple-servlet-jakarta`. + +The extension is found through `ServiceLoader`, so adding the module to the +classpath is all a deployment declares. With no tile layer configured it +publishes nothing and the service stays a plain OGC API - Features service. +Tile sources are separate modules that implement the SPIs of +[hakunapi-tiles-core](../hakunapi-tiles-core/README.md), which also documents +the `tiles.*` configuration. + +## Resources + +| path | what | +| --- | --- | +| `/tileMatrixSets`, `/tileMatrixSets/{tileMatrixSetId}` | tile matrix sets | +| `/tiles`, `/tiles/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}` | dataset vector tilesets and tiles | +| `/collections/{collectionId}/tiles`, `.../{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}` | vector tilesets and tiles of a collection | +| `/collections/{collectionId}/map/tiles`, `.../{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}` | map tilesets and tiles of a collection | + +Resources are registered only for the kinds of layer configured. Each map tile +layer is published as a collection of its own, with no `items`. + +## Status and support + +Community module under `src-community/`: built and tested with the core +modules, not covered by the support the National Land Survey of Finland +provides for core modules. See [SUBMITTERS](SUBMITTERS) and +[CONTRIBUTING.md](../../CONTRIBUTING.md). diff --git a/src-community/hakunapi-tiles-servlet-jakarta/SUBMITTERS b/src-community/hakunapi-tiles-servlet-jakarta/SUBMITTERS new file mode 100644 index 00000000..46d30fd3 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/SUBMITTERS @@ -0,0 +1,3 @@ +Submitted and maintained by: + +jampukka diff --git a/src-community/hakunapi-tiles-servlet-jakarta/pom.xml b/src-community/hakunapi-tiles-servlet-jakarta/pom.xml new file mode 100644 index 00000000..d4f18917 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/pom.xml @@ -0,0 +1,41 @@ + + 4.0.0 + + fi.nls.hakunapi + src-community + 2.1.0-SNAPSHOT + + hakunapi-tiles-servlet-jakarta + + + 6.0.0 + + + + + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet-api.version} + provided + + + fi.nls.hakunapi + hakunapi-tiles-core + + + fi.nls.hakunapi + hakunapi-simple-servlet-jakarta + ${project.version} + + + org.slf4j + slf4j-api + + + io.swagger.core.v3 + swagger-models + + + + diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionMapTileOperation.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionMapTileOperation.java new file mode 100644 index 00000000..4ec7f498 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionMapTileOperation.java @@ -0,0 +1,114 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriInfo; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.extension.ApiExtension; +import fi.nls.hakunapi.core.operation.DynamicPathOperation; +import fi.nls.hakunapi.core.operation.DynamicResponseOperation; +import fi.nls.hakunapi.core.param.APIParam; +import fi.nls.hakunapi.simple.servlet.jakarta.ResponseUtil; +import fi.nls.hakunapi.tiles.MapTileLayer; +import fi.nls.hakunapi.tiles.Tile; +import fi.nls.hakunapi.tiles.TilesServiceConfig; + +@Path("/collections/{collectionId}/map/tiles") +public class GetCollectionMapTileOperation implements DynamicPathOperation, DynamicResponseOperation { + private static final Logger LOG = LoggerFactory.getLogger(GetCollectionMapTileOperation.class); + + static final String PNG = "image/png"; + + @Inject + private TilesServiceConfig tiles; + + @Override + public List getValidPaths(FeatureServiceConfig service) { + List paths = new ArrayList<>(); + for (ApiExtension extension : service.getApiExtensions()) { + for (String id : extension.getCollectionIds(TilesApiExtension.MAP_TILES)) { + paths.add("/collections/" + id + "/map/tiles/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}"); + } + } + return paths; + } + + @Override + public List getParameters(String path, FeatureServiceConfig service) { + return List.of(); + } + + @Override + public Map> getResponsesByContentType(FeatureServiceConfig service) { + Map> map = new HashMap<>(); + map.put(PNG, byte[].class); + return map; + } + + @GET + @Path("/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}") + public Response handle( + @PathParam("collectionId") String collectionId, + @PathParam("tileMatrixSetId") String tileMatrixSetId, + @PathParam("tileMatrix") String tileMatrix, + @PathParam("tileRow") long tileRow, + @PathParam("tileCol") long tileCol, + @Context UriInfo uriInfo) { + MapTileLayer layer = tiles.getMapLayer(collectionId).orElse(null); + if (layer == null) { + return ResponseUtil.exception(Status.NOT_FOUND, "Collection has no map tiles"); + } + if (!layer.supportsTileMatrixSet(tileMatrixSetId)) { + return ResponseUtil.exception(Status.NOT_FOUND, "Unsupported tileMatrixSetId"); + } + String mediaType = layer.getMediaTypes().isEmpty() ? PNG : layer.getMediaTypes().get(0); + try { + Map requestParams = whitelistedParams(layer, uriInfo); + Tile tile = layer.getSource() + .getTile(layer, tileMatrixSetId, tileMatrix, tileRow, tileCol, mediaType, requestParams) + .orElse(null); + if (tile == null) { + return ResponseUtil.exception(Status.NOT_FOUND, "Tile not found"); + } + return TileResponse.ok(tile, mediaType); + } catch (Exception e) { + LOG.warn(e.getMessage(), e); + return ResponseUtil.exception(Status.INTERNAL_SERVER_ERROR, + "Error occured, message: " + e.getMessage()); + } + } + + private static Map whitelistedParams(MapTileLayer layer, UriInfo uriInfo) { + Set allowed = layer.getSource().getPassthroughParams(layer); + if (allowed.isEmpty()) { + return Map.of(); + } + MultivaluedMap query = uriInfo.getQueryParameters(); + Map out = new LinkedHashMap<>(); + for (String name : allowed) { + String value = query.getFirst(name); + if (value != null) { + out.put(name, value); + } + } + return out; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionMapTilesetsImpl.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionMapTilesetsImpl.java new file mode 100644 index 00000000..5bb834b0 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionMapTilesetsImpl.java @@ -0,0 +1,128 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + +import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.extension.ApiExtension; +import fi.nls.hakunapi.core.operation.DynamicPathOperation; +import fi.nls.hakunapi.core.operation.DynamicResponseOperation; +import fi.nls.hakunapi.core.param.APIParam; +import fi.nls.hakunapi.core.schemas.Link; +import fi.nls.hakunapi.simple.servlet.jakarta.ResponseUtil; +import fi.nls.hakunapi.tiles.MapTileLayer; +import fi.nls.hakunapi.tiles.TileMatrixSet; +import fi.nls.hakunapi.tiles.TilesLinkRel; +import fi.nls.hakunapi.tiles.TilesServiceConfig; +import fi.nls.hakunapi.tiles.schemas.TileSet; +import fi.nls.hakunapi.tiles.schemas.TileSetItem; +import fi.nls.hakunapi.tiles.schemas.TileSetsList; + +@Path("/collections/{collectionId}/map/tiles") +public class GetCollectionMapTilesetsImpl implements DynamicPathOperation, DynamicResponseOperation { + static final String PNG = "image/png"; + static final String TMS_URI_PREFIX = "http://www.opengis.net/def/tilematrixset/OGC/1.0/"; + + @Inject + private FeatureServiceConfig service; + + @Inject + private TilesServiceConfig tiles; + + @Override + public List getValidPaths(FeatureServiceConfig service) { + List paths = new ArrayList<>(); + for (ApiExtension extension : service.getApiExtensions()) { + for (String id : extension.getCollectionIds(TilesApiExtension.MAP_TILES)) { + paths.add("/collections/" + id + "/map/tiles"); + } + } + return paths; + } + + @Override + public List getParameters(String path, FeatureServiceConfig service) { + return List.of(); + } + + @Override + public Map> getResponsesByContentType(FeatureServiceConfig service) { + Map> map = new HashMap<>(); + map.put(MediaType.APPLICATION_JSON, TileSetsList.class); + return map; + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response list(@PathParam("collectionId") String collectionId, @Context HttpHeaders headers) { + MapTileLayer layer = tiles.getMapLayer(collectionId).orElse(null); + if (layer == null) { + return ResponseUtil.exception(Status.NOT_FOUND, "Collection has no map tiles"); + } + String base = service.getCurrentServerURL(headers::getHeaderString); + String tilesPath = base + "/collections/" + collectionId + "/map/tiles"; + + List links = new ArrayList<>(); + links.add(new Link(tilesPath, TilesLinkRel.SELF, MediaType.APPLICATION_JSON, "This document")); + + List tilesets = new ArrayList<>(); + for (String tmsId : layer.getTileMatrixSetIds()) { + TileMatrixSet tms = tiles.getTileMatrixSets().get(tmsId).orElse(null); + if (tms == null) { + continue; + } + String tilesetPath = tilesPath + "/" + tmsId; + List tsLinks = new ArrayList<>(); + tsLinks.add(new Link(tilesetPath, TilesLinkRel.SELF, MediaType.APPLICATION_JSON, + "Tileset " + tmsId)); + tsLinks.add(new Link(base + "/tileMatrixSets/" + tmsId, TilesLinkRel.TILING_SCHEME, + MediaType.APPLICATION_JSON, "Definition of " + tmsId)); + tilesets.add(new TileSetItem(layer.getTitle(), "map", tms.getCrs(), tmsId, + TMS_URI_PREFIX + tmsId, tsLinks)); + } + return Response.ok(new TileSetsList(links, tilesets)).build(); + } + + @GET + @Path("/{tileMatrixSetId}") + @Produces(MediaType.APPLICATION_JSON) + public Response tileset(@PathParam("collectionId") String collectionId, + @PathParam("tileMatrixSetId") String tileMatrixSetId, @Context HttpHeaders headers) { + MapTileLayer layer = tiles.getMapLayer(collectionId).orElse(null); + if (layer == null || !layer.supportsTileMatrixSet(tileMatrixSetId)) { + return ResponseUtil.exception(Status.NOT_FOUND, "No such tileset"); + } + TileMatrixSet tms = tiles.getTileMatrixSets().get(tileMatrixSetId).orElse(null); + if (tms == null) { + return ResponseUtil.exception(Status.NOT_FOUND, "Unknown tileMatrixSetId"); + } + String base = service.getCurrentServerURL(headers::getHeaderString); + String tilesetPath = base + "/collections/" + collectionId + "/map/tiles/" + tileMatrixSetId; + String mediaType = layer.getMediaTypes().isEmpty() ? PNG : layer.getMediaTypes().get(0); + + List links = new ArrayList<>(); + links.add(new Link(tilesetPath, TilesLinkRel.SELF, MediaType.APPLICATION_JSON, "This document")); + links.add(new Link(base + "/tileMatrixSets/" + tileMatrixSetId, TilesLinkRel.TILING_SCHEME, + MediaType.APPLICATION_JSON, "Definition of " + tileMatrixSetId)); + String tileTemplate = tilesetPath + "/{tileMatrix}/{tileRow}/{tileCol}"; + links.add(new Link(tileTemplate, TilesLinkRel.ITEM, mediaType, "Tiles")); + + TileSet tileSet = new TileSet(layer.getTitle(), "map", tms.getCrs(), + TMS_URI_PREFIX + tileMatrixSetId, links); + return Response.ok(tileSet).build(); + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionTileOperation.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionTileOperation.java new file mode 100644 index 00000000..3f268197 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionTileOperation.java @@ -0,0 +1,133 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriInfo; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.extension.ApiExtension; +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.operation.DynamicPathOperation; +import fi.nls.hakunapi.core.operation.DynamicResponseOperation; +import fi.nls.hakunapi.core.param.APIParam; +import fi.nls.hakunapi.simple.servlet.jakarta.ResponseUtil; +import fi.nls.hakunapi.tiles.Tile; +import fi.nls.hakunapi.tiles.TilesServiceConfig; +import fi.nls.hakunapi.tiles.VectorTileLayer; + +@Path("/collections/{collectionId}/tiles") +public class GetCollectionTileOperation implements DynamicPathOperation, DynamicResponseOperation { + private static final Logger LOG = LoggerFactory.getLogger(GetCollectionTileOperation.class); + + static final String VECTOR_TILE = "application/vnd.mapbox-vector-tile"; + + @Inject + private FeatureServiceConfig service; + + @Inject + private TilesServiceConfig tiles; + + @Override + public List getValidPaths(FeatureServiceConfig service) { + List paths = new ArrayList<>(); + for (ApiExtension extension : service.getApiExtensions()) { + for (String id : extension.getCollectionIds(TilesApiExtension.VECTOR_TILES)) { + paths.add("/collections/" + id + "/tiles/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}"); + } + } + return paths; + } + + @Override + public List getParameters(String path, FeatureServiceConfig service) { + return List.of(); + } + + @Override + public Map> getResponsesByContentType(FeatureServiceConfig service) { + Map> map = new HashMap<>(); + map.put(VECTOR_TILE, byte[].class); + return map; + } + + @GET + @Path("/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}") + public Response handle( + @PathParam("collectionId") String collectionId, + @PathParam("tileMatrixSetId") String tileMatrixSetId, + @PathParam("tileMatrix") String tileMatrix, + @PathParam("tileRow") long tileRow, + @PathParam("tileCol") long tileCol, + @Context UriInfo uriInfo) { + VectorTileLayer layer = tiles.getVectorLayer(collectionId).orElse(null); + if (layer == null || !layer.isCollectionTileset()) { + return ResponseUtil.exception(Status.NOT_FOUND, "Collection has no tiles"); + } + if (!layer.supportsTileMatrixSet(tileMatrixSetId)) { + return ResponseUtil.exception(Status.NOT_FOUND, "Unsupported tileMatrixSetId"); + } + String mediaType = layer.getMediaTypes().isEmpty() ? VECTOR_TILE : layer.getMediaTypes().get(0); + try { + List collections = resolveCollections(layer); + + Map requestParams = whitelistedParams(layer, uriInfo); + Tile tile = layer.getSource() + .getTile(layer, tileMatrixSetId, tileMatrix, tileRow, tileCol, collections, + mediaType, requestParams) + .orElse(null); + if (tile == null) { + return ResponseUtil.exception(Status.NOT_FOUND, "Tile not found"); + } + return TileResponse.ok(tile, mediaType); + } catch (Exception e) { + LOG.warn(e.getMessage(), e); + return ResponseUtil.exception(Status.INTERNAL_SERVER_ERROR, + "Error occured, message: " + e.getMessage()); + } + } + + private List resolveCollections(VectorTileLayer layer) { + List ids = layer.getCollectionIds(); + List collections = new ArrayList<>(ids.size()); + for (String id : ids) { + FeatureType ft = service.getCollection(id); + if (ft != null) { + collections.add(ft); + } + } + return collections; + } + + private static Map whitelistedParams(VectorTileLayer layer, UriInfo uriInfo) { + Set allowed = layer.getSource().getPassthroughParams(layer); + if (allowed.isEmpty()) { + return Map.of(); + } + MultivaluedMap query = uriInfo.getQueryParameters(); + Map out = new LinkedHashMap<>(); + for (String name : allowed) { + String value = query.getFirst(name); + if (value != null) { + out.put(name, value); + } + } + return out; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionTilesetsImpl.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionTilesetsImpl.java new file mode 100644 index 00000000..51fc88cb --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetCollectionTilesetsImpl.java @@ -0,0 +1,156 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + +import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.extension.ApiExtension; +import fi.nls.hakunapi.core.operation.DynamicPathOperation; +import fi.nls.hakunapi.core.operation.DynamicResponseOperation; +import fi.nls.hakunapi.core.param.APIParam; +import fi.nls.hakunapi.core.schemas.Link; +import fi.nls.hakunapi.simple.servlet.jakarta.ResponseUtil; +import fi.nls.hakunapi.simple.servlet.jakarta.operation.ResponseClass; +import fi.nls.hakunapi.tiles.TileMatrixSet; +import fi.nls.hakunapi.tiles.TilesLinkRel; +import fi.nls.hakunapi.tiles.TilesServiceConfig; +import fi.nls.hakunapi.tiles.VectorTileLayer; +import fi.nls.hakunapi.tiles.schemas.TileJSON; +import fi.nls.hakunapi.tiles.schemas.TileSet; +import fi.nls.hakunapi.tiles.schemas.TileSetItem; +import fi.nls.hakunapi.tiles.schemas.TileSetsList; + +@Path("/collections/{collectionId}/tiles") +public class GetCollectionTilesetsImpl implements DynamicPathOperation, DynamicResponseOperation { + static final String VECTOR_TILE = "application/vnd.mapbox-vector-tile"; + static final String TMS_URI_PREFIX = "http://www.opengis.net/def/tilematrixset/OGC/1.0/"; + + @Inject + private FeatureServiceConfig service; + + @Inject + private TilesServiceConfig tiles; + + @Override + public List getValidPaths(FeatureServiceConfig service) { + List paths = new ArrayList<>(); + for (ApiExtension extension : service.getApiExtensions()) { + for (String id : extension.getCollectionIds(TilesApiExtension.VECTOR_TILES)) { + paths.add("/collections/" + id + "/tiles"); + } + } + return paths; + } + + @Override + public List getParameters(String path, FeatureServiceConfig service) { + return List.of(); + } + + @Override + public Map> getResponsesByContentType(FeatureServiceConfig service) { + Map> map = new HashMap<>(); + map.put(MediaType.APPLICATION_JSON, TileSetsList.class); + return map; + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response list(@PathParam("collectionId") String collectionId, @Context HttpHeaders headers) { + VectorTileLayer layer = tiles.getVectorLayer(collectionId).orElse(null); + if (layer == null || !layer.isCollectionTileset()) { + return ResponseUtil.exception(Status.NOT_FOUND, "Collection has no tiles"); + } + String base = service.getCurrentServerURL(headers::getHeaderString); + String tilesPath = base + "/collections/" + collectionId + "/tiles"; + + List links = new ArrayList<>(); + links.add(new Link(tilesPath, TilesLinkRel.SELF, MediaType.APPLICATION_JSON, "This document")); + + List tilesets = new ArrayList<>(); + for (String tmsId : layer.getTileMatrixSetIds()) { + TileMatrixSet tms = tiles.getTileMatrixSets().get(tmsId).orElse(null); + if (tms == null) { + continue; + } + String tilesetPath = tilesPath + "/" + tmsId; + List tsLinks = new ArrayList<>(); + tsLinks.add(new Link(tilesetPath, TilesLinkRel.SELF, MediaType.APPLICATION_JSON, + "Tileset " + tmsId)); + tsLinks.add(new Link(base + "/tileMatrixSets/" + tmsId, TilesLinkRel.TILING_SCHEME, + MediaType.APPLICATION_JSON, "Definition of " + tmsId)); + tilesets.add(new TileSetItem(layer.getTitle(), "vector", tms.getCrs(), tmsId, + TMS_URI_PREFIX + tmsId, tsLinks)); + } + return Response.ok(new TileSetsList(links, tilesets)).build(); + } + + @GET + @Path("/{tileMatrixSetId}") + @Produces(MediaType.APPLICATION_JSON) + public Response tileset(@PathParam("collectionId") String collectionId, + @PathParam("tileMatrixSetId") String tileMatrixSetId, @Context HttpHeaders headers) { + VectorTileLayer layer = tiles.getVectorLayer(collectionId).orElse(null); + if (layer == null || !layer.isCollectionTileset() + || !layer.supportsTileMatrixSet(tileMatrixSetId)) { + return ResponseUtil.exception(Status.NOT_FOUND, "No such tileset"); + } + TileMatrixSet tms = tiles.getTileMatrixSets().get(tileMatrixSetId).orElse(null); + if (tms == null) { + return ResponseUtil.exception(Status.NOT_FOUND, "Unknown tileMatrixSetId"); + } + String base = service.getCurrentServerURL(headers::getHeaderString); + String tilesetPath = base + "/collections/" + collectionId + "/tiles/" + tileMatrixSetId; + + List links = new ArrayList<>(); + links.add(new Link(tilesetPath, TilesLinkRel.SELF, MediaType.APPLICATION_JSON, "This document")); + links.add(new Link(base + "/tileMatrixSets/" + tileMatrixSetId, TilesLinkRel.TILING_SCHEME, + MediaType.APPLICATION_JSON, "Definition of " + tileMatrixSetId)); + + String tileTemplate = tilesetPath + "/{tileMatrix}/{tileRow}/{tileCol}"; + links.add(new Link(tileTemplate, TilesLinkRel.ITEM, VECTOR_TILE, "Tiles")); + links.add(new Link(tilesetPath + "/tilejson.json", TilesLinkRel.ALTERNATE, + MediaType.APPLICATION_JSON, "TileJSON description of this tileset")); + + TileSet tileSet = new TileSet(layer.getTitle(), "vector", tms.getCrs(), + TMS_URI_PREFIX + tileMatrixSetId, links); + return Response.ok(tileSet).build(); + } + + @GET + @Path("/{tileMatrixSetId}/tilejson.json") + @Produces(MediaType.APPLICATION_JSON) + @ResponseClass(TileJSON.class) + public Response tilejson(@PathParam("collectionId") String collectionId, + @PathParam("tileMatrixSetId") String tileMatrixSetId, @Context HttpHeaders headers) { + VectorTileLayer layer = tiles.getVectorLayer(collectionId).orElse(null); + if (layer == null || !layer.isCollectionTileset() + || !layer.supportsTileMatrixSet(tileMatrixSetId)) { + return ResponseUtil.exception(Status.NOT_FOUND, "No such tileset"); + } + TileMatrixSet tms = tiles.getTileMatrixSets().get(tileMatrixSetId).orElse(null); + FeatureType ft = service.getCollection(collectionId); + if (tms == null || ft == null) { + return ResponseUtil.exception(Status.NOT_FOUND, "No such tileset"); + } + String base = service.getCurrentServerURL(headers::getHeaderString); + String tilesetPath = base + "/collections/" + collectionId + "/tiles/" + tileMatrixSetId; + return Response.ok(TileJSONBuilder.build(layer.getTitle(), layer.getDescription(), + tilesetPath, tms, List.of(ft))).build(); + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTileImpl.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTileImpl.java new file mode 100644 index 00000000..abef24b0 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTileImpl.java @@ -0,0 +1,305 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.CacheControl; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.EntityTag; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriInfo; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.operation.DynamicResponseOperation; +import fi.nls.hakunapi.simple.servlet.jakarta.ResponseUtil; +import fi.nls.hakunapi.tiles.AcceleratedTilesets; +import fi.nls.hakunapi.tiles.Tile; +import fi.nls.hakunapi.tiles.TileLocator; +import fi.nls.hakunapi.tiles.TilesServiceConfig; +import fi.nls.hakunapi.tiles.VectorTileLayer; + +@Path("/tiles") +public class GetDatasetTileImpl implements DynamicResponseOperation { + private static final Logger LOG = LoggerFactory.getLogger(GetDatasetTileImpl.class); + + public static final String X_ACCEL_REDIRECT = "X-Accel-Redirect"; + + private static final String SENDFILE_FILENAME = "org.apache.tomcat.sendfile.filename"; + private static final String SENDFILE_START = "org.apache.tomcat.sendfile.start"; + private static final String SENDFILE_END = "org.apache.tomcat.sendfile.end"; + + private static final CacheControl IMMUTABLE = immutable(); + + private static CacheControl immutable() { + CacheControl cc = new CacheControl(); + cc.setNoTransform(false); + cc.setMaxAge(604800); + + cc.getCacheExtension().put("immutable", null); + return cc; + } + + @Context + private HttpServletRequest request; + + @Inject + private FeatureServiceConfig service; + + @Inject + private TilesServiceConfig tiles; + + @Inject + private AcceleratedTilesets accelerated; + + @Override + public Map> getResponsesByContentType(FeatureServiceConfig service) { + Map> map = new HashMap<>(); + map.put(GetCollectionTileOperation.VECTOR_TILE, byte[].class); + return map; + } + + @GET + @Path("/{tileMatrixSetId}/{tileMatrix}/{tileRow}/{tileCol}") + public Response handle( + @PathParam("tileMatrixSetId") String tileMatrixSetId, + @PathParam("tileMatrix") String tileMatrix, + @PathParam("tileRow") long tileRow, + @PathParam("tileCol") long tileCol, + @QueryParam("collections") String collectionsParam, + @Context UriInfo uriInfo) { + List layers = participatingLayers(tileMatrixSetId); + if (layers.isEmpty()) { + return ResponseUtil.exception(Status.NOT_FOUND, "Unsupported tileMatrixSetId"); + } + Set requested = parseCollections(collectionsParam); + + if (requested != null) { + VectorTileLayer stored = firstStoredLayer(layers, requested); + if (stored != null) { + return ResponseUtil.exception(Status.BAD_REQUEST, "Tileset " + stored.getId() + + " serves stored tiles and cannot be narrowed by collections"); + } + } else if (layers.size() == 1) { + AcceleratedTilesets.Entry entry = accelerated.get(layers.get(0).getId()); + if (entry != null) { + Response range = range(entry, tileMatrix, tileRow, tileCol); + if (range != null) { + return range; + } + } + } + + String mediaType = layers.get(0).getMediaTypes().isEmpty() + ? GetCollectionTileOperation.VECTOR_TILE : layers.get(0).getMediaTypes().get(0); + try { + Set rendered = new LinkedHashSet<>(); + List parts = new ArrayList<>(layers.size()); + for (int i = 0; i < layers.size(); i++) { + VectorTileLayer layer = layers.get(i); + if (requested != null && !contributes(layer, requested)) { + continue; + } + List collections = resolveCollections(layer, rendered, requested); + if (collections.isEmpty() && layer.getSource().needsCollections(layer)) { + continue; + } + Map requestParams = whitelistedParams(layer, uriInfo); + layer.getSource() + .getTile(layer, tileMatrixSetId, tileMatrix, tileRow, tileCol, collections, + mediaType, requestParams) + .ifPresent(parts::add); + } + if (parts.isEmpty()) { + return ResponseUtil.exception(Status.NOT_FOUND, "Tile not found"); + } + String encoding = parts.size() > 1 ? firstEncoding(parts) : null; + if (encoding != null) { + return ResponseUtil.exception(Status.CONFLICT, "This dataset composes a tile from" + + " several tilesets, one of which serves " + encoding + "-encoded tiles" + + " that cannot be composed"); + } + return TileResponse.ok(parts, mediaType); + } catch (Exception e) { + LOG.warn(e.getMessage(), e); + return ResponseUtil.exception(Status.INTERNAL_SERVER_ERROR, + "Error occured, message: " + e.getMessage()); + } + } + + private Response range(AcceleratedTilesets.Entry entry, String tileMatrix, long tileRow, long tileCol) { + int z; + try { + z = Integer.parseInt(tileMatrix); + } catch (NumberFormatException e) { + return ResponseUtil.exception(Status.NOT_FOUND, "Tile not found"); + } + + long packed; + try { + packed = entry.locator().find(z, tileCol, tileRow); + } catch (IllegalArgumentException e) { + return ResponseUtil.exception(Status.NOT_FOUND, "Tile not found"); + } + if (packed == TileLocator.NOT_FOUND) { + return ResponseUtil.exception(Status.NOT_FOUND, "Tile not found"); + } + + long offset = TileLocator.offset(packed); + int length = TileLocator.length(packed); + String encoding = entry.contentEncoding(); + + EntityTag etag = new EntityTag(offset + "-" + length); + + if (entry.sendfilePath() != null && request != null) { + sendfile(entry, offset, length); + Response.ResponseBuilder sf = Response.ok() + .type(entry.mediaType()) + .header(HttpHeaders.CONTENT_LENGTH, length) + .tag(etag) + .cacheControl(IMMUTABLE); + if (encoding != null) { + sf.header(HttpHeaders.CONTENT_ENCODING, encoding); + } + return sf.build(); + } + + if (entry.internalLocation() == null) { + return null; + } + + Response.ResponseBuilder rb = Response.ok() + .type(entry.mediaType()) + .tag(etag) + .cacheControl(IMMUTABLE) + .header(X_ACCEL_REDIRECT, entry.redirectUri(offset, length, encoding)); + + if (encoding != null) { + rb.header(HttpHeaders.CONTENT_ENCODING, encoding); + } + if (LOG.isDebugEnabled()) { + LOG.debug("range {}/{}/{} -> {} +{}", tileMatrix, tileRow, tileCol, offset, length); + } + return rb.build(); + } + + private void sendfile(AcceleratedTilesets.Entry entry, long offset, int length) { + request.setAttribute(SENDFILE_FILENAME, entry.sendfilePath()); + request.setAttribute(SENDFILE_START, offset); + request.setAttribute(SENDFILE_END, offset + length); + } + + private static Set parseCollections(String value) { + if (value == null) { + return null; + } + Set ids = new LinkedHashSet<>(); + int from = 0; + while (from <= value.length()) { + int comma = value.indexOf(',', from); + int to = comma < 0 ? value.length() : comma; + String id = value.substring(from, to).trim(); + if (!id.isEmpty()) { + ids.add(id); + } + if (comma < 0) { + break; + } + from = comma + 1; + } + return ids; + } + + private static VectorTileLayer firstStoredLayer(List layers, + Set requested) { + for (int i = 0; i < layers.size(); i++) { + VectorTileLayer layer = layers.get(i); + if (layer.isStoredTileset() && contributes(layer, requested)) { + return layer; + } + } + return null; + } + + private static boolean contributes(VectorTileLayer layer, Set requested) { + List ids = layer.getCollectionIds(); + for (int i = 0; i < ids.size(); i++) { + if (requested.contains(ids.get(i))) { + return true; + } + } + return false; + } + + private static String firstEncoding(List parts) { + for (int i = 0; i < parts.size(); i++) { + if (parts.get(i).getContentEncoding() != null) { + return parts.get(i).getContentEncoding(); + } + } + return null; + } + + private List participatingLayers(String tileMatrixSetId) { + List layers = new ArrayList<>(); + for (VectorTileLayer layer : tiles.getVectorLayers()) { + if (layer.supportsTileMatrixSet(tileMatrixSetId)) { + layers.add(layer); + } + } + return layers; + } + + private List resolveCollections(VectorTileLayer layer, Set rendered, + Set requested) { + List ids = layer.getCollectionIds(); + List collections = new ArrayList<>(ids.size()); + for (int i = 0; i < ids.size(); i++) { + String id = ids.get(i); + if (requested != null && !requested.contains(id)) { + continue; + } + if (!rendered.add(id)) { + continue; + } + FeatureType ft = service.getCollection(id); + if (ft != null) { + collections.add(ft); + } + } + return collections; + } + + private static Map whitelistedParams(VectorTileLayer layer, UriInfo uriInfo) { + Set allowed = layer.getSource().getPassthroughParams(layer); + if (allowed.isEmpty()) { + return Map.of(); + } + MultivaluedMap query = uriInfo.getQueryParameters(); + Map out = new LinkedHashMap<>(); + for (String name : allowed) { + String value = query.getFirst(name); + if (value != null) { + out.put(name, value); + } + } + return out; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTileOperation.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTileOperation.java new file mode 100644 index 00000000..bc62e2e0 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTileOperation.java @@ -0,0 +1,34 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import fi.nls.hakunapi.core.operation.ApiOperation; +import fi.nls.hakunapi.core.operation.ApiTag; + +public class GetDatasetTileOperation implements ApiOperation { + @Override + public String getSummary() { + return "retrieve a tile of the dataset"; + } + + @Override + public String getDescription() { + return "Retrieves a single tile of the dataset at the requested tile matrix set, " + + "tile matrix (zoom level), row and column, carrying one layer per collection. " + + "The optional collections parameter narrows the tile to the named collections, " + + "which a tileset serving stored tiles does not support."; + } + + @Override + public String get200Description() { + return "The tile at the requested address."; + } + + @Override + public String getOperationId() { + return "getDatasetTile"; + } + + @Override + public ApiTag getTag() { + return ApiTag.Tiles; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTilesetsImpl.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTilesetsImpl.java new file mode 100644 index 00000000..68485124 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTilesetsImpl.java @@ -0,0 +1,160 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + +import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.schemas.Link; +import fi.nls.hakunapi.simple.servlet.jakarta.ResponseUtil; +import fi.nls.hakunapi.simple.servlet.jakarta.operation.ResponseClass; +import fi.nls.hakunapi.tiles.TileMatrixSet; +import fi.nls.hakunapi.tiles.TilesLinkRel; +import fi.nls.hakunapi.tiles.TilesServiceConfig; +import fi.nls.hakunapi.tiles.VectorTileLayer; +import fi.nls.hakunapi.tiles.schemas.GeospatialData; +import fi.nls.hakunapi.tiles.schemas.TileJSON; +import fi.nls.hakunapi.tiles.schemas.TileSet; +import fi.nls.hakunapi.tiles.schemas.TileSetItem; +import fi.nls.hakunapi.tiles.schemas.TileSetsList; + +@Path("/tiles") +public class GetDatasetTilesetsImpl { + static final String TMS_URI_PREFIX = "http://www.opengis.net/def/tilematrixset/OGC/1.0/"; + + @Inject + private FeatureServiceConfig service; + + @Inject + private TilesServiceConfig tiles; + + @GET + @Produces(MediaType.APPLICATION_JSON) + @ResponseClass(TileSetsList.class) + public Response list(@Context HttpHeaders headers) { + String base = service.getCurrentServerURL(headers::getHeaderString); + String tilesPath = base + "/tiles"; + + List links = new ArrayList<>(); + links.add(new Link(tilesPath, TilesLinkRel.SELF, MediaType.APPLICATION_JSON, "This document")); + + List tilesets = new ArrayList<>(); + for (String tmsId : datasetTileMatrixSetIds()) { + TileMatrixSet tms = tiles.getTileMatrixSets().get(tmsId).orElse(null); + if (tms == null) { + continue; + } + String tilesetPath = tilesPath + "/" + tmsId; + List tsLinks = new ArrayList<>(); + tsLinks.add(new Link(tilesetPath, TilesLinkRel.SELF, MediaType.APPLICATION_JSON, + "Tileset " + tmsId)); + tsLinks.add(new Link(base + "/tileMatrixSets/" + tmsId, TilesLinkRel.TILING_SCHEME, + MediaType.APPLICATION_JSON, "Definition of " + tmsId)); + tilesets.add(new TileSetItem(service.getTitle(), "vector", tms.getCrs(), tmsId, + TMS_URI_PREFIX + tmsId, tsLinks)); + } + return Response.ok(new TileSetsList(links, tilesets)).build(); + } + + @GET + @Path("/{tileMatrixSetId}") + @Produces(MediaType.APPLICATION_JSON) + @ResponseClass(TileSet.class) + public Response tileset(@PathParam("tileMatrixSetId") String tileMatrixSetId, + @Context HttpHeaders headers) { + TileMatrixSet tms = datasetTileMatrixSetIds().contains(tileMatrixSetId) + ? tiles.getTileMatrixSets().get(tileMatrixSetId).orElse(null) : null; + if (tms == null) { + return ResponseUtil.exception(Status.NOT_FOUND, "No such tileset"); + } + String base = service.getCurrentServerURL(headers::getHeaderString); + String tilesetPath = base + "/tiles/" + tileMatrixSetId; + + List links = new ArrayList<>(); + links.add(new Link(tilesetPath, TilesLinkRel.SELF, MediaType.APPLICATION_JSON, "This document")); + links.add(new Link(base + "/tileMatrixSets/" + tileMatrixSetId, TilesLinkRel.TILING_SCHEME, + MediaType.APPLICATION_JSON, "Definition of " + tileMatrixSetId)); + String tileTemplate = tilesetPath + "/{tileMatrix}/{tileRow}/{tileCol}"; + links.add(new Link(tileTemplate, TilesLinkRel.ITEM, + GetCollectionTileOperation.VECTOR_TILE, "Tiles")); + links.add(new Link(tilesetPath + "/tilejson.json", TilesLinkRel.ALTERNATE, + MediaType.APPLICATION_JSON, "TileJSON description of this tileset")); + + TileSet tileSet = new TileSet(service.getTitle(), "vector", tms.getCrs(), + TMS_URI_PREFIX + tileMatrixSetId, links, layers(tileMatrixSetId)); + return Response.ok(tileSet).build(); + } + + @GET + @Path("/{tileMatrixSetId}/tilejson.json") + @Produces(MediaType.APPLICATION_JSON) + @ResponseClass(TileJSON.class) + public Response tilejson(@PathParam("tileMatrixSetId") String tileMatrixSetId, + @Context HttpHeaders headers) { + TileMatrixSet tms = datasetTileMatrixSetIds().contains(tileMatrixSetId) + ? tiles.getTileMatrixSets().get(tileMatrixSetId).orElse(null) : null; + if (tms == null) { + return ResponseUtil.exception(Status.NOT_FOUND, "No such tileset"); + } + String base = service.getCurrentServerURL(headers::getHeaderString); + String tilesetPath = base + "/tiles/" + tileMatrixSetId; + return Response.ok(TileJSONBuilder.build(service.getTitle(), service.getDescription(), + tilesetPath, tms, datasetCollectionIds(tileMatrixSetId), + datasetCollections(tileMatrixSetId))).build(); + } + + private Set datasetTileMatrixSetIds() { + Set ids = new LinkedHashSet<>(); + for (VectorTileLayer layer : tiles.getVectorLayers()) { + ids.addAll(layer.getTileMatrixSetIds()); + } + return ids; + } + + private List datasetCollectionIds(String tileMatrixSetId) { + Set ids = new LinkedHashSet<>(); + for (VectorTileLayer layer : tiles.getVectorLayers()) { + if (!layer.supportsTileMatrixSet(tileMatrixSetId)) { + continue; + } + ids.addAll(layer.getCollectionIds()); + } + return new ArrayList<>(ids); + } + + private List datasetCollections(String tileMatrixSetId) { + List ids = datasetCollectionIds(tileMatrixSetId); + List collections = new ArrayList<>(ids.size()); + for (int i = 0; i < ids.size(); i++) { + FeatureType ft = service.getCollection(ids.get(i)); + if (ft != null) { + collections.add(ft); + } + } + return collections; + } + + private List layers(String tileMatrixSetId) { + List ids = datasetCollectionIds(tileMatrixSetId); + List layers = new ArrayList<>(ids.size()); + for (int i = 0; i < ids.size(); i++) { + String id = ids.get(i); + FeatureType ft = service.getCollection(id); + layers.add(new GeospatialData(id, ft == null ? null : ft.getTitle(), "vector")); + } + return layers; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTilesetsListOperation.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTilesetsListOperation.java new file mode 100644 index 00000000..22546a75 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetDatasetTilesetsListOperation.java @@ -0,0 +1,32 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import fi.nls.hakunapi.core.operation.ApiOperation; +import fi.nls.hakunapi.core.operation.ApiTag; + +public class GetDatasetTilesetsListOperation implements ApiOperation { + @Override + public String getSummary() { + return "retrieve the list of dataset tilesets"; + } + + @Override + public String getDescription() { + return "Lists the tilesets of the dataset as a whole. A dataset tile carries one " + + "layer per collection, unlike a geodata tileset of a single collection."; + } + + @Override + public String get200Description() { + return "The list of dataset tilesets."; + } + + @Override + public String getOperationId() { + return "getDatasetTilesetsList"; + } + + @Override + public ApiTag getTag() { + return ApiTag.Tiles; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetMapTileOperation.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetMapTileOperation.java new file mode 100644 index 00000000..addc52c0 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetMapTileOperation.java @@ -0,0 +1,32 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import fi.nls.hakunapi.core.operation.ApiOperation; +import fi.nls.hakunapi.core.operation.ApiTag; + +public class GetMapTileOperation implements ApiOperation { + @Override + public String getSummary() { + return "retrieve a map tile of a collection"; + } + + @Override + public String getDescription() { + return "Retrieves a single map (raster) tile of the collection at the requested tile matrix set, " + + "tile matrix (zoom level), row and column."; + } + + @Override + public String get200Description() { + return "The map tile at the requested address."; + } + + @Override + public String getOperationId() { + return "getCollectionMapTile"; + } + + @Override + public ApiTag getTag() { + return ApiTag.Tiles; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetMapTilesetsListOperation.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetMapTilesetsListOperation.java new file mode 100644 index 00000000..e0efffc1 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetMapTilesetsListOperation.java @@ -0,0 +1,31 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import fi.nls.hakunapi.core.operation.ApiOperation; +import fi.nls.hakunapi.core.operation.ApiTag; + +public class GetMapTilesetsListOperation implements ApiOperation { + @Override + public String getSummary() { + return "retrieve the list of map tilesets for a collection"; + } + + @Override + public String getDescription() { + return "Lists the map (raster) tilesets (one per tile matrix set) available for the collection."; + } + + @Override + public String get200Description() { + return "The list of map tilesets available for the collection."; + } + + @Override + public String getOperationId() { + return "getCollectionMapTilesetsList"; + } + + @Override + public ApiTag getTag() { + return ApiTag.Tiles; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetTileMatrixSetsOperation.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetTileMatrixSetsOperation.java new file mode 100644 index 00000000..43f383a5 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetTileMatrixSetsOperation.java @@ -0,0 +1,31 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import fi.nls.hakunapi.core.operation.ApiOperation; +import fi.nls.hakunapi.core.operation.ApiTag; + +public class GetTileMatrixSetsOperation implements ApiOperation { + @Override + public String getSummary() { + return "retrieve the list of available tile matrix sets"; + } + + @Override + public String getDescription() { + return "Lists the tile matrix sets (tiling schemes) supported by this API."; + } + + @Override + public String get200Description() { + return "The list of supported tile matrix sets."; + } + + @Override + public String getOperationId() { + return "getTileMatrixSets"; + } + + @Override + public ApiTag getTag() { + return ApiTag.Tiles; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetTileOperation.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetTileOperation.java new file mode 100644 index 00000000..d71f07b6 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetTileOperation.java @@ -0,0 +1,32 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import fi.nls.hakunapi.core.operation.ApiOperation; +import fi.nls.hakunapi.core.operation.ApiTag; + +public class GetTileOperation implements ApiOperation { + @Override + public String getSummary() { + return "retrieve a tile of a collection"; + } + + @Override + public String getDescription() { + return "Retrieves a single tile of the collection at the requested tile matrix set, " + + "tile matrix (zoom level), row and column."; + } + + @Override + public String get200Description() { + return "The tile at the requested address."; + } + + @Override + public String getOperationId() { + return "getCollectionTile"; + } + + @Override + public ApiTag getTag() { + return ApiTag.Tiles; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetTilesetsListOperation.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetTilesetsListOperation.java new file mode 100644 index 00000000..139c4499 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/GetTilesetsListOperation.java @@ -0,0 +1,31 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import fi.nls.hakunapi.core.operation.ApiOperation; +import fi.nls.hakunapi.core.operation.ApiTag; + +public class GetTilesetsListOperation implements ApiOperation { + @Override + public String getSummary() { + return "retrieve the list of tilesets for a collection"; + } + + @Override + public String getDescription() { + return "Lists the tilesets (one per tile matrix set) available for the collection."; + } + + @Override + public String get200Description() { + return "The list of tilesets available for the collection."; + } + + @Override + public String getOperationId() { + return "getCollectionTilesetsList"; + } + + @Override + public ApiTag getTag() { + return ApiTag.Tiles; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TileJSONBuilder.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TileJSONBuilder.java new file mode 100644 index 00000000..f7e70130 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TileJSONBuilder.java @@ -0,0 +1,122 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.property.HakunaProperty; +import fi.nls.hakunapi.core.property.HakunaPropertyType; +import fi.nls.hakunapi.tiles.TileMatrix; +import fi.nls.hakunapi.tiles.TileMatrixSet; +import fi.nls.hakunapi.tiles.schemas.TileJSON; +import fi.nls.hakunapi.tiles.schemas.VectorLayerJSON; + +public final class TileJSONBuilder { + private static final String NUMBER = "Number"; + private static final String STRING = "String"; + private static final String BOOLEAN = "Boolean"; + + private TileJSONBuilder() {} + + public static TileJSON build(String name, String description, String tilesetPath, + TileMatrixSet tms, List collections) { + List layerIds = new ArrayList<>(collections.size()); + for (int i = 0; i < collections.size(); i++) { + layerIds.add(collections.get(i).getName()); + } + return build(name, description, tilesetPath, tms, layerIds, collections); + } + + public static TileJSON build(String name, String description, String tilesetPath, + TileMatrixSet tms, List layerIds, List collections) { + List matrices = tms.getTileMatrices(); + int minzoom = 0; + int maxzoom = 0; + if (!matrices.isEmpty()) { + minzoom = zoomOf(matrices.get(0)); + maxzoom = zoomOf(matrices.get(matrices.size() - 1)); + } + + List tiles = List.of(tilesetPath + "/{z}/{y}/{x}"); + + List layers = new ArrayList<>(layerIds.size()); + for (int i = 0; i < layerIds.size(); i++) { + String layerId = layerIds.get(i); + FeatureType ft = named(collections, layerId); + layers.add(ft == null ? new VectorLayerJSON(layerId, null, Map.of()) + : new VectorLayerJSON(ft.getName(), ft.getDescription(), fields(ft))); + } + return new TileJSON(name, description, tiles, minzoom, maxzoom, + bounds(collections), layers); + } + + private static FeatureType named(List collections, String name) { + for (int i = 0; i < collections.size(); i++) { + FeatureType ft = collections.get(i); + if (name.equals(ft.getName())) { + return ft; + } + } + return null; + } + + private static int zoomOf(TileMatrix matrix) { + try { + return Integer.parseInt(matrix.getId()); + } catch (NumberFormatException ignore) { + return 0; + } + } + + private static double[] bounds(List collections) { + double minX = Double.POSITIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + double maxY = Double.NEGATIVE_INFINITY; + for (int i = 0; i < collections.size(); i++) { + double[] e = collections.get(i).getSpatialExtent(); + if (e == null || e.length < 4) { + continue; + } + minX = Math.min(minX, e[0]); + minY = Math.min(minY, e[1]); + maxX = Math.max(maxX, e[2]); + maxY = Math.max(maxY, e[3]); + } + if (minX > maxX || minY > maxY) { + return TileJSON.WORLD; + } + return new double[] { minX, minY, maxX, maxY }; + } + + private static Map fields(FeatureType ft) { + Map fields = new LinkedHashMap<>(); + HakunaProperty geom = ft.getGeom(); + HakunaProperty id = ft.getId(); + List props = ft.getProperties(); + for (int i = 0; i < props.size(); i++) { + HakunaProperty p = props.get(i); + if (p == geom || p == id) { + continue; + } + fields.put(p.getName(), fieldType(p.getType())); + } + return fields; + } + + private static String fieldType(HakunaPropertyType type) { + switch (type) { + case INT: + case LONG: + case FLOAT: + case DOUBLE: + return NUMBER; + case BOOLEAN: + return BOOLEAN; + default: + return STRING; + } + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TileMatrixSetsImpl.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TileMatrixSetsImpl.java new file mode 100644 index 00000000..f9f26ad0 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TileMatrixSetsImpl.java @@ -0,0 +1,59 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import java.util.ArrayList; +import java.util.List; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + +import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.schemas.Link; +import fi.nls.hakunapi.simple.servlet.jakarta.ResponseUtil; +import fi.nls.hakunapi.simple.servlet.jakarta.operation.ResponseClass; +import fi.nls.hakunapi.tiles.TileMatrixSet; +import fi.nls.hakunapi.tiles.TilesServiceConfig; +import fi.nls.hakunapi.tiles.schemas.TileMatrixSetItem; +import fi.nls.hakunapi.tiles.schemas.TileMatrixSetJSON; +import fi.nls.hakunapi.tiles.schemas.TileMatrixSets; + +@Path("/tileMatrixSets") +public class TileMatrixSetsImpl { + @Inject + private FeatureServiceConfig service; + + @Inject + private TilesServiceConfig tiles; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public TileMatrixSets list(@Context HttpHeaders headers) { + String base = service.getCurrentServerURL(headers::getHeaderString); + List items = new ArrayList<>(); + for (TileMatrixSet tms : tiles.getTileMatrixSets().list()) { + String self = base + "/tileMatrixSets/" + tms.getId(); + List links = new ArrayList<>(); + links.add(new Link(self, "self", MediaType.APPLICATION_JSON, tms.getTitle())); + items.add(new TileMatrixSetItem(tms.getId(), tms.getTitle(), + "http://www.opengis.net/def/tilematrixset/OGC/1.0/" + tms.getId(), links)); + } + return new TileMatrixSets(items); + } + + @GET + @Path("/{tileMatrixSetId}") + @Produces(MediaType.APPLICATION_JSON) + @ResponseClass(TileMatrixSetJSON.class) + public Response byId(@PathParam("tileMatrixSetId") String tileMatrixSetId) { + return tiles.getTileMatrixSets().get(tileMatrixSetId) + .map(tms -> Response.ok(new TileMatrixSetJSON(tms)).build()) + .orElseGet(() -> ResponseUtil.exception(Status.NOT_FOUND, "Unknown tileMatrixSetId")); + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TileResponse.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TileResponse.java new file mode 100644 index 00000000..c8bb4f5e --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TileResponse.java @@ -0,0 +1,106 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import java.io.IOException; +import java.util.List; + +import jakarta.ws.rs.core.CacheControl; +import jakarta.ws.rs.core.EntityTag; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.StreamingOutput; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import fi.nls.hakunapi.simple.servlet.jakarta.PreEncodedBytes; +import fi.nls.hakunapi.simple.servlet.jakarta.PreEncodedOutput; +import fi.nls.hakunapi.simple.servlet.jakarta.ResponseUtil; +import fi.nls.hakunapi.tiles.Tile; + +/** Streams a {@link Tile} as the HTTP response. */ +public final class TileResponse { + private static final Logger LOG = LoggerFactory.getLogger(TileResponse.class); + + private static final CacheControl IMMUTABLE = immutable(); + + private static CacheControl immutable() { + CacheControl cc = new CacheControl(); + cc.setNoTransform(false); + cc.setMaxAge(604800); + + cc.getCacheExtension().put("immutable", null); + return cc; + } + + private TileResponse() {} + + public static Response ok(Tile tile, String defaultMediaType) { + boolean preEncoded = tile.getContentEncoding() != null; + String mediaType = tile.getMediaType() != null ? tile.getMediaType() : defaultMediaType; + if (tile.getLength() >= 0) { + return precomputed(tile, mediaType, preEncoded); + } + StreamingOutput write = out -> { + try { + tile.writeTo(out); + } catch (Exception e) { + LOG.warn("Failed to write tile", e); + throw new IOException(e); + } + }; + + StreamingOutput body = preEncoded ? (PreEncodedOutput) write::write : write; + + Response.ResponseBuilder rb = Response.ok(body).type(mediaType); + if (preEncoded) { + rb.header("Content-Encoding", tile.getContentEncoding()); + } + return rb.build(); + } + + private static Response precomputed(Tile tile, String mediaType, boolean preEncoded) { + byte[] bytes; + try { + bytes = tile.getBytes(); + } catch (Exception e) { + LOG.warn("Failed to read tile", e); + return ResponseUtil.exception(Status.INTERNAL_SERVER_ERROR, "Failed to read tile"); + } + + Object body = preEncoded ? new PreEncodedBytes(bytes) : bytes; + Response.ResponseBuilder rb = Response.ok(body).type(mediaType); + + rb.header(jakarta.ws.rs.core.HttpHeaders.CONTENT_LENGTH, bytes.length); + if (preEncoded) { + rb.header("Content-Encoding", tile.getContentEncoding()); + } + rb.tag(new EntityTag(tile.getTileMatrix() + '-' + tile.getTileRow() + '-' + + tile.getTileCol() + '-' + bytes.length)); + rb.cacheControl(IMMUTABLE); + return rb.build(); + } + + public static Response ok(List parts, String mediaType) { + if (parts.size() == 1) { + return ok(parts.get(0), mediaType); + } + for (int i = 0; i < parts.size(); i++) { + String encoding = parts.get(i).getContentEncoding(); + if (encoding != null) { + throw new IllegalArgumentException("Cannot compose a tile from a " + encoding + + "-encoded part; a compressed tile layer is served on its own"); + } + } + StreamingOutput body = out -> { + try { + for (int i = 0; i < parts.size(); i++) { + parts.get(i).writeTo(out); + } + } catch (Exception e) { + LOG.warn("Failed to write tile", e); + throw new IOException(e); + } + }; + return Response.ok(body).type(mediaType).build(); + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TilesApiExtension.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TilesApiExtension.java new file mode 100644 index 00000000..132577c8 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TilesApiExtension.java @@ -0,0 +1,236 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.ConformanceClass; +import fi.nls.hakunapi.core.extension.ApiExtension; +import fi.nls.hakunapi.core.operation.OperationImpl; +import fi.nls.hakunapi.core.schemas.CollectionInfo; +import fi.nls.hakunapi.core.schemas.Crs; +import fi.nls.hakunapi.core.schemas.Extent; +import fi.nls.hakunapi.core.schemas.Link; +import fi.nls.hakunapi.core.schemas.SpatialExtent; +import fi.nls.hakunapi.core.util.U; +import fi.nls.hakunapi.tiles.MapTileLayer; +import fi.nls.hakunapi.tiles.TilesLinkRel; +import fi.nls.hakunapi.tiles.TilesServiceConfig; +import fi.nls.hakunapi.tiles.AcceleratedTilesets; +import fi.nls.hakunapi.tiles.VectorTileLayer; + +/** Adds the OGC API - Tiles links, conformance classes and map tile collections to the Features service. */ +public class TilesApiExtension implements ApiExtension { + public static final String VECTOR_TILES = "tiles"; + + public static final String MAP_TILES = "map/tiles"; + + private static final String JSON = "application/json"; + + private static final String MVT = "mvt"; + + private final TilesServiceConfig tiles; + + private final Set collectionlessVectorLayers; + + private final AcceleratedTilesets accelerated; + + public TilesApiExtension(TilesServiceConfig tiles) { + this(tiles, Set.of(), AcceleratedTilesets.NONE); + } + + public TilesApiExtension(TilesServiceConfig tiles, Set collectionlessVectorLayers) { + this(tiles, collectionlessVectorLayers, AcceleratedTilesets.NONE); + } + + public TilesApiExtension(TilesServiceConfig tiles, Set collectionlessVectorLayers, + AcceleratedTilesets accelerated) { + if (!tiles.hasVectorLayers() && !tiles.hasMapLayers()) { + throw new IllegalArgumentException("No tile layers, do not publish OGC API - Tiles!"); + } + this.tiles = tiles; + this.collectionlessVectorLayers = Set.copyOf(collectionlessVectorLayers); + this.accelerated = accelerated; + } + + @Override + public String getName() { + return "OGC API - Tiles"; + } + + @Override + public List getConformanceClasses() { + List classes = new ArrayList<>(); + classes.add(ConformanceClass.TILES_CORE); + classes.add(ConformanceClass.TILES_TILESET); + classes.add(ConformanceClass.TILES_TILESETS_LIST); + + if (hasGeodataTilesets()) { + classes.add(ConformanceClass.TILES_GEODATA_TILESETS); + } + if (tiles.hasVectorLayers()) { + classes.add(ConformanceClass.TILES_DATASET_TILESETS); + } + classes.add(ConformanceClass.TILES_OAS30); + classes.add(ConformanceClass.TILEMATRIXSET); + + if (servesMvt()) { + classes.add(ConformanceClass.TILES_MVT); + } + return classes; + } + + @Override + public List getOperations() { + List operations = new ArrayList<>(); + operations.add(new OperationImpl(new GetTileMatrixSetsOperation(), TileMatrixSetsImpl.class)); + if (tiles.hasVectorLayers()) { + operations.add(new OperationImpl(new GetDatasetTilesetsListOperation(), GetDatasetTilesetsImpl.class)); + operations.add(new OperationImpl(new GetDatasetTileOperation(), GetDatasetTileImpl.class)); + } + + if (hasCollectionVectorTilesets()) { + operations.add(new OperationImpl(new GetTilesetsListOperation(), GetCollectionTilesetsImpl.class)); + operations.add(new OperationImpl(new GetTileOperation(), GetCollectionTileOperation.class)); + } + if (tiles.hasMapLayers()) { + operations.add(new OperationImpl(new GetMapTilesetsListOperation(), GetCollectionMapTilesetsImpl.class)); + operations.add(new OperationImpl(new GetMapTileOperation(), GetCollectionMapTileOperation.class)); + } + return operations; + } + + @Override + public Map, Object> getInjectables() { + return Map.of(TilesServiceConfig.class, tiles, + AcceleratedTilesets.class, accelerated); + } + + @Override + public List getLandingPageLinks(String baseUrl, Map queryParams) { + String query = U.toQuery(queryParams); + List links = new ArrayList<>(); + links.add(new Link(baseUrl + "/tileMatrixSets" + query, TilesLinkRel.TILING_SCHEMES, JSON, + "The tile matrix sets supported by this service")); + if (tiles.hasVectorLayers()) { + links.add(new Link(baseUrl + "/tiles" + query, TilesLinkRel.TILESETS_VECTOR, JSON, + "Vector tilesets of the dataset")); + } + return links; + } + + @Override + public List getCollectionLinks(FeatureType ft, String baseUrl, Map queryParams) { + if (!hasVectorLayer(ft.getName())) { + return List.of(); + } + String href = baseUrl + "/collections/" + ft.getName() + "/tiles" + U.toQuery(queryParams); + return List.of(new Link(href, TilesLinkRel.TILESETS_VECTOR, JSON, + "Vector tilesets for " + ft.getName())); + } + + @Override + public List getCollections(String baseUrl, Map queryParams) { + List collections = new ArrayList<>(); + for (MapTileLayer layer : tiles.getMapLayers()) { + collections.add(toCollectionInfo(layer, baseUrl)); + } + + for (VectorTileLayer layer : tiles.getVectorLayers()) { + if (collectionlessVectorLayers.contains(layer.getId())) { + collections.add(toCollectionInfo(layer, baseUrl)); + } + } + return collections; + } + + @Override + public List getCollectionIds(String resource) { + List ids; + switch (resource) { + case VECTOR_TILES: + ids = new ArrayList<>(); + for (VectorTileLayer layer : tiles.getVectorLayers()) { + if (layer.isCollectionTileset()) { + ids.add(layer.getId()); + } + } + return ids; + case MAP_TILES: + ids = new ArrayList<>(); + for (MapTileLayer layer : tiles.getMapLayers()) { + ids.add(layer.getId()); + } + return ids; + default: + return List.of(); + } + } + + @Override + public Map> getFormatMediaTypes() { + if (!servesMvt()) { + return Map.of(); + } + return Map.of(MVT, List.of(GetCollectionTileOperation.VECTOR_TILE)); + } + + @Override + public void close() { + } + + private boolean hasGeodataTilesets() { + return hasCollectionVectorTilesets() || tiles.hasMapLayers(); + } + + private boolean hasCollectionVectorTilesets() { + for (VectorTileLayer layer : tiles.getVectorLayers()) { + if (layer.isCollectionTileset()) { + return true; + } + } + return false; + } + + private boolean servesMvt() { + for (VectorTileLayer layer : tiles.getVectorLayers()) { + if (layer.getMediaTypes().contains(GetCollectionTileOperation.VECTOR_TILE)) { + return true; + } + } + return false; + } + + private boolean hasVectorLayer(String collectionId) { + VectorTileLayer layer = tiles.getVectorLayer(collectionId).orElse(null); + return layer != null && layer.isCollectionTileset(); + } + + private CollectionInfo toCollectionInfo(MapTileLayer layer, String baseUrl) { + return toCollectionInfo(layer.getId(), layer.getTitle(), layer.getDescription(), + layer.getBbox(), baseUrl, "map/tiles", TilesLinkRel.TILESETS_MAP, "Map", "map"); + } + + private CollectionInfo toCollectionInfo(VectorTileLayer layer, String baseUrl) { + return toCollectionInfo(layer.getId(), layer.getTitle(), layer.getDescription(), + layer.getBbox(), baseUrl, "tiles", TilesLinkRel.TILESETS_VECTOR, "Vector", "feature"); + } + + private CollectionInfo toCollectionInfo(String id, String title, String description, + double[] bbox, String baseUrl, String tilesPath, String tilesRel, + String tilesLabel, String itemType) { + List links = new ArrayList<>(); + links.add(new Link(baseUrl + "/collections/" + id, "self", JSON, "This document")); + links.add(new Link(baseUrl + "/collections/" + id + "/" + tilesPath, tilesRel, + JSON, tilesLabel + " tilesets for " + id)); + + SpatialExtent spatialExtent = bbox == null ? null : new SpatialExtent(bbox, Crs.CRS84); + Extent extent = new Extent(spatialExtent, null); + + CollectionInfo ci = new CollectionInfo(id, title, description, links, extent, null, null); + ci.setItemType(itemType); + return ci; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TilesApiExtensionFactory.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TilesApiExtensionFactory.java new file mode 100644 index 00000000..e0c2d8c5 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TilesApiExtensionFactory.java @@ -0,0 +1,167 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.config.HakunaConfigParser; +import fi.nls.hakunapi.core.extension.ApiExtension; +import fi.nls.hakunapi.core.extension.ApiExtensionContext; +import fi.nls.hakunapi.core.extension.ApiExtensionFactory; +import fi.nls.hakunapi.tiles.AcceleratedTileLayer; +import fi.nls.hakunapi.tiles.AcceleratedTilesets; +import fi.nls.hakunapi.tiles.TileLocator; +import fi.nls.hakunapi.tiles.VectorTileSource; +import fi.nls.hakunapi.tiles.MapTileLayer; +import fi.nls.hakunapi.tiles.TileMatrixSetRegistry; +import fi.nls.hakunapi.tiles.MapTileSource; +import fi.nls.hakunapi.tiles.TilesServiceConfig; +import fi.nls.hakunapi.tiles.VectorTileLayer; +import fi.nls.hakunapi.tiles.config.TilesConfigParser; + +/** Builds {@link TilesApiExtension} from the tiles configuration; publishes nothing when no layer is configured. */ +public class TilesApiExtensionFactory implements ApiExtensionFactory { + private static final Logger LOG = LoggerFactory.getLogger(TilesApiExtensionFactory.class); + + private static final Set AUTO_PUBLISHABLE = Set.of("vectortile-generator"); + + @Override + public ApiExtension create(ApiExtensionContext context) throws Exception { + HakunaConfigParser config = context.config(); + Path configPath = context.configPath(); + Map collections = context.collections(); + TilesConfigParser parser = new TilesConfigParser(config); + if (!parser.hasTileLayers() && !parser.isVectorAutoPublishEnabled()) { + return null; + } + + Map mapSourcesByType = new HashMap<>(); + Map vectorSourcesByType = new HashMap<>(); + for (MapTileSource source : ServiceLoader.load(MapTileSource.class)) { + LOG.info("MapTileSource (map): {} -> {}", source.getType(), source); + mapSourcesByType.put(source.getType(), source); + } + for (VectorTileSource source : ServiceLoader.load(VectorTileSource.class)) { + LOG.info("MapTileSource (vector): {} -> {}", source.getType(), source); + vectorSourcesByType.put(source.getType(), source); + } + + TileMatrixSetRegistry tileMatrixSets = parser.readTileMatrixSets(); + List vectorLayers = new ArrayList<>(); + List mapLayers = new ArrayList<>(); + for (String layerId : parser.readTileLayerIds()) { + String type = parser.readTileLayerType(layerId); + boolean isVector = vectorSourcesByType.size() == 1 && mapSourcesByType.isEmpty() + || (type != null && vectorSourcesByType.containsKey(type)); + boolean isMap = mapSourcesByType.size() == 1 && vectorSourcesByType.isEmpty() + || (type != null && mapSourcesByType.containsKey(type)); + if (isVector) { + vectorLayers.add(parser.readVectorTileLayer(configPath, vectorSourcesByType, + tileMatrixSets, layerId)); + } else if (isMap) { + mapLayers.add(parser.readMapTileLayer(configPath, mapSourcesByType, + tileMatrixSets, layerId)); + } else { + throw new IllegalArgumentException("Unknown or missing tile source type for layer: " + + layerId + " (type=" + type + ")"); + } + } + + if (parser.isVectorAutoPublishEnabled()) { + addAutoPublishedLayers(parser, configPath, vectorSourcesByType, tileMatrixSets, + collections, vectorLayers); + } + + if (vectorLayers.isEmpty() && mapLayers.isEmpty()) { + LOG.info("No tile layers configured, OGC API - Tiles not published"); + return null; + } + + Set collectionless = new HashSet<>(); + for (VectorTileLayer layer : vectorLayers) { + if (layer.isCollectionTileset() && !collections.containsKey(layer.getId())) { + collectionless.add(layer.getId()); + } + } + + AcceleratedTilesets accelerated = readAcceleratedTilesets(vectorLayers); + + LOG.info("OGC API - Tiles: {} vector layer(s) ({} tiles-only), {} map layer(s), {} sent as a range", + vectorLayers.size(), collectionless.size(), mapLayers.size(), + accelerated.getTilesetIds().size()); + return new TilesApiExtension( + new TilesServiceConfig(vectorLayers, mapLayers, tileMatrixSets), collectionless, + accelerated); + } + + private AcceleratedTilesets readAcceleratedTilesets(List layers) { + AcceleratedTilesets.Builder builder = AcceleratedTilesets.builder(); + for (VectorTileLayer layer : layers) { + if (!(layer instanceof AcceleratedTileLayer accelerable)) { + continue; + } + TileLocator locator = accelerable.getTileLocator(); + if (locator == null) { + continue; + } + String internal = accelerable.getInternalLocation(); + + java.nio.file.Path file = accelerable.getTileFile(); + if (file != null && !java.nio.file.Files.isReadable(file)) { + LOG.warn("Tileset {}: sendfile was asked for but {} is not readable;" + + " serving it the ordinary way", layer.getId(), file); + file = null; + } + if (file == null && internal == null) { + LOG.warn("Tileset {} is indexed but has neither sendfile nor a front-end" + + " location; serving it the ordinary way", layer.getId()); + continue; + } + LOG.info("Tileset {} is served from its index: {}", layer.getId(), + file != null && internal != null ? "sendfile, or a front end at " + internal + : file != null ? "sendfile" : "a front end at " + internal); + builder.add(layer.getId(), locator, internal, file); + } + return builder.build(); + } + + private void addAutoPublishedLayers(TilesConfigParser parser, Path configPath, + Map vectorSourcesByType, TileMatrixSetRegistry tileMatrixSets, + Map collections, List vectorLayers) throws Exception { + Map candidates = new LinkedHashMap<>(vectorSourcesByType); + candidates.keySet().removeIf(type -> !AUTO_PUBLISHABLE.contains(type)); + + if (candidates.isEmpty()) { + throw new IllegalArgumentException("tiles.vector=true requires a vector tile source that" + + " renders from feature collections; on the classpath: " + + vectorSourcesByType.keySet()); + } + if (candidates.size() != 1 && parser.readDefaultTileLayerType() == null) { + throw new IllegalArgumentException("tiles.vector=true with " + candidates.size() + + " renderable vector tile sources on the classpath (" + candidates.keySet() + + ") requires default.tiles.layers.type to name the one that renders" + + " auto-published layers"); + } + Set explicit = new HashSet<>(); + for (VectorTileLayer layer : vectorLayers) { + explicit.add(layer.getId()); + } + for (FeatureType ft : collections.values()) { + if (ft.getGeom() == null || explicit.contains(ft.getName())) { + continue; + } + vectorLayers.add(parser.readVectorTileLayer(configPath, candidates, + tileMatrixSets, ft.getName())); + } + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TilesOpenAPISchemaContributor.java b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TilesOpenAPISchemaContributor.java new file mode 100644 index 00000000..d28b73ac --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/java/fi/nls/hakunapi/tiles/servlet/jakarta/TilesOpenAPISchemaContributor.java @@ -0,0 +1,17 @@ +package fi.nls.hakunapi.tiles.servlet.jakarta; + +import fi.nls.hakunapi.core.extension.OpenAPISchemaContributor; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; + +public class TilesOpenAPISchemaContributor implements OpenAPISchemaContributor { + private static final String SCHEMAS_PACKAGE = "fi.nls.hakunapi.tiles.schemas."; + + @Override + public Schema getSchema(Class clazz) { + if (clazz.getName().startsWith(SCHEMAS_PACKAGE)) { + return new ObjectSchema(); + } + return null; + } +} diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/resources/META-INF/services/fi.nls.hakunapi.core.extension.ApiExtensionFactory b/src-community/hakunapi-tiles-servlet-jakarta/src/main/resources/META-INF/services/fi.nls.hakunapi.core.extension.ApiExtensionFactory new file mode 100644 index 00000000..27cbab21 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/resources/META-INF/services/fi.nls.hakunapi.core.extension.ApiExtensionFactory @@ -0,0 +1 @@ +fi.nls.hakunapi.tiles.servlet.jakarta.TilesApiExtensionFactory diff --git a/src-community/hakunapi-tiles-servlet-jakarta/src/main/resources/META-INF/services/fi.nls.hakunapi.core.extension.OpenAPISchemaContributor b/src-community/hakunapi-tiles-servlet-jakarta/src/main/resources/META-INF/services/fi.nls.hakunapi.core.extension.OpenAPISchemaContributor new file mode 100644 index 00000000..b2855ca7 --- /dev/null +++ b/src-community/hakunapi-tiles-servlet-jakarta/src/main/resources/META-INF/services/fi.nls.hakunapi.core.extension.OpenAPISchemaContributor @@ -0,0 +1 @@ +fi.nls.hakunapi.tiles.servlet.jakarta.TilesOpenAPISchemaContributor diff --git a/src-community/pom.xml b/src-community/pom.xml index 8325c88c..10cd0da8 100644 --- a/src-community/pom.xml +++ b/src-community/pom.xml @@ -15,6 +15,7 @@ hakunapi-smile hakunapi-esbulk hakunapi-tiles-core + hakunapi-tiles-servlet-jakarta diff --git a/src/hakunapi-config-test/src/main/java/fi/nls/hakunapi/config/test/ConfigTest.java b/src/hakunapi-config-test/src/main/java/fi/nls/hakunapi/config/test/ConfigTest.java index 2196eab4..efcdb732 100644 --- a/src/hakunapi-config-test/src/main/java/fi/nls/hakunapi/config/test/ConfigTest.java +++ b/src/hakunapi-config-test/src/main/java/fi/nls/hakunapi/config/test/ConfigTest.java @@ -108,6 +108,9 @@ public static void main(String[] args) { if (verbose) { System.out.println("collectionIds :" + Arrays.toString(collectionIds)); } + if (collectionIds.length == 0) { + System.err.println("WARNING: no collections configured"); + } for (String id : collectionIds) { try { if (verbose) { diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/ConformanceClass.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/ConformanceClass.java index e822d905..1e4f4322 100644 --- a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/ConformanceClass.java +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/ConformanceClass.java @@ -5,6 +5,13 @@ public enum ConformanceClass { + // OGC API - Common + COMMON_CORE("http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core"), + COMMON_JSON("http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json"), + COMMON_HTML("http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/html"), + COMMON_OAS30("http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30"), + COMMON_COLLECTIONS("http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections"), + // OGC API - Features - Part 1: Core Core("http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core"), OpenAPI30("http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30"), diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/config/HakunaConfigParser.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/config/HakunaConfigParser.java index d0d65cc8..d4b68ce1 100644 --- a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/config/HakunaConfigParser.java +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/config/HakunaConfigParser.java @@ -310,11 +310,7 @@ public ValueTransformer instantiateTransformer(String transformerClass) { public String[] readCollectionIds() { String rawCollectionId = get("collections"); LOG.info("collectionIds "+rawCollectionId); - String[] collectionsIds = getMultiple("collections"); - if (collectionsIds.length == 0) { - throw new IllegalArgumentException("Missing required config property 'collections'"); - } - return collectionsIds; + return getMultiple("collections"); } public FeatureType readCollection(Path path, Map sourcesByType, String collectionId) throws Exception { diff --git a/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/config/HakunaConfigParserTest.java b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/config/HakunaConfigParserTest.java index 8e320477..9ca19bde 100644 --- a/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/config/HakunaConfigParserTest.java +++ b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/config/HakunaConfigParserTest.java @@ -42,4 +42,19 @@ public void testExternalizedProperties() { } } + @Test + public void testReadCollectionIdsMissingIsEmpty() { + HakunaConfigParser parser = new HakunaConfigParser(new Properties()); + + assertArrayEquals(new String[0], parser.readCollectionIds()); + } + + @Test + public void testReadCollectionIds() { + Properties cfg = new Properties(); + cfg.setProperty("collections", "a, b"); + + assertArrayEquals(new String[] { "a", "b" }, new HakunaConfigParser(cfg).readCollectionIds()); + } + } diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/GunzipInterceptor.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/GunzipInterceptor.java new file mode 100644 index 00000000..cb52c0f6 --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/GunzipInterceptor.java @@ -0,0 +1,89 @@ +package fi.nls.hakunapi.simple.servlet.jakarta; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.zip.GZIPInputStream; + +import jakarta.annotation.Priority; +import jakarta.inject.Inject; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.ext.Provider; +import jakarta.ws.rs.ext.WriterInterceptor; +import jakarta.ws.rs.ext.WriterInterceptorContext; + +@Provider +@Priority(Priorities.ENTITY_CODER - 1) +public class GunzipInterceptor implements WriterInterceptor { + + @Context + private HttpHeaders requestHeaders; + + @Override + public void aroundWriteTo(WriterInterceptorContext context) + throws IOException, WebApplicationException { + Object entity = context.getEntity(); + boolean storedCompressed = entity instanceof PreEncodedOutput + || entity instanceof PreEncodedBytes; + if (!storedCompressed + || !"gzip".equals(context.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING)) + || acceptsGzip()) { + context.proceed(); + return; + } + + OutputStream out = context.getOutputStream(); + ByteArrayOutputStream compressed = new ByteArrayOutputStream(16 * 1024); + context.setOutputStream(compressed); + context.getHeaders().remove(HttpHeaders.CONTENT_ENCODING); + context.getHeaders().remove(HttpHeaders.CONTENT_LENGTH); + context.proceed(); + + try (InputStream in = new GZIPInputStream( + new java.io.ByteArrayInputStream(compressed.toByteArray()))) { + in.transferTo(out); + } + context.setOutputStream(out); + } + + private boolean acceptsGzip() { + if (requestHeaders == null) { + return true; + } + List accepted = requestHeaders.getRequestHeader(HttpHeaders.ACCEPT_ENCODING); + if (accepted == null || accepted.isEmpty()) { + return false; + } + for (String value : accepted) { + if (value == null) { + continue; + } + for (String token : value.split(",")) { + String[] parts = token.split(";"); + if (!"gzip".equalsIgnoreCase(parts[0].trim())) { + continue; + } + boolean rejected = false; + for (int i = 1; i < parts.length; i++) { + String param = parts[i].trim(); + if (param.regionMatches(true, 0, "q=", 0, 2)) { + try { + rejected = Double.parseDouble(param.substring(2).trim()) == 0; + } catch (NumberFormatException ignore) { + } + } + } + if (!rejected) { + return true; + } + } + } + return false; + } + +} diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/GzipInterceptor.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/GzipInterceptor.java index 52f9e4ff..9e8c997b 100644 --- a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/GzipInterceptor.java +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/GzipInterceptor.java @@ -21,7 +21,10 @@ public class GzipInterceptor implements WriterInterceptor { public void aroundWriteTo(WriterInterceptorContext context) throws IOException, WebApplicationException { String contentEncoding = (String) context.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING); - if ("gzip".equals(contentEncoding)) { + Object entity = context.getEntity(); + boolean alreadyEncoded = entity instanceof PreEncodedOutput + || entity instanceof PreEncodedBytes; + if ("gzip".equals(contentEncoding) && !alreadyEncoded) { context.setOutputStream(new GzipOutputStream(context.getOutputStream(), new Deflater(5, true), 8192)); } context.proceed(); diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/PreEncodedBytes.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/PreEncodedBytes.java new file mode 100644 index 00000000..a7fef006 --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/PreEncodedBytes.java @@ -0,0 +1,15 @@ +package fi.nls.hakunapi.simple.servlet.jakarta; + +public final class PreEncodedBytes { + + private final byte[] bytes; + + public PreEncodedBytes(byte[] bytes) { + this.bytes = bytes; + } + + public byte[] getBytes() { + return bytes; + } + +} diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/PreEncodedBytesWriter.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/PreEncodedBytesWriter.java new file mode 100644 index 00000000..30f24a4a --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/PreEncodedBytesWriter.java @@ -0,0 +1,37 @@ +package fi.nls.hakunapi.simple.servlet.jakarta; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.ext.MessageBodyWriter; +import jakarta.ws.rs.ext.Provider; + +@Provider +public class PreEncodedBytesWriter implements MessageBodyWriter { + + @Override + public boolean isWriteable(Class type, Type genericType, Annotation[] annotations, + MediaType mediaType) { + return PreEncodedBytes.class == type; + } + + @Override + public long getSize(PreEncodedBytes body, Class type, Type genericType, + Annotation[] annotations, MediaType mediaType) { + return body.getBytes().length; + } + + @Override + public void writeTo(PreEncodedBytes body, Class type, Type genericType, + Annotation[] annotations, MediaType mediaType, + MultivaluedMap headers, OutputStream out) + throws IOException, WebApplicationException { + out.write(body.getBytes()); + } + +} diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/PreEncodedOutput.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/PreEncodedOutput.java new file mode 100644 index 00000000..4af64c87 --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/PreEncodedOutput.java @@ -0,0 +1,7 @@ +package fi.nls.hakunapi.simple.servlet.jakarta; + +import jakarta.ws.rs.core.StreamingOutput; + +/** Entity whose bytes already carry the declared Content-Encoding. */ +public interface PreEncodedOutput extends StreamingOutput { +} diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/GetCollectionQueryablesImpl.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/GetCollectionQueryablesImpl.java index 531483fc..15736233 100644 --- a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/GetCollectionQueryablesImpl.java +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/GetCollectionQueryablesImpl.java @@ -1,5 +1,9 @@ package fi.nls.hakunapi.simple.servlet.jakarta.operation; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; @@ -13,18 +17,35 @@ import fi.nls.hakunapi.core.FeatureType; import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.operation.DynamicPathOperation; +import fi.nls.hakunapi.core.param.APIParam; import fi.nls.hakunapi.core.property.HakunaProperty; import fi.nls.hakunapi.core.schemas.Queryables; import fi.nls.hakunapi.html.model.HTMLContext; import fi.nls.hakunapi.simple.servlet.jakarta.MediaTypes; @Path("/collections/{collectionId}/queryables") -public class GetCollectionQueryablesImpl { +public class GetCollectionQueryablesImpl implements DynamicPathOperation { @Inject private FeatureServiceConfig service; + @Override + public List getValidPaths(FeatureServiceConfig service) { + Collection collections = service.getCollections(); + List paths = new ArrayList<>(collections.size()); + for (FeatureType collection : collections) { + paths.add("/collections/" + collection.getName() + "/queryables"); + } + return paths; + } + + @Override + public List getParameters(String path, FeatureServiceConfig service) { + return List.of(); + } + @GET @Produces(MediaTypes.APPLICATION_SCHEMA) public Queryables handle( diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/OpenAPI30Generator.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/OpenAPI30Generator.java index db78dc4c..4b993702 100644 --- a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/OpenAPI30Generator.java +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/OpenAPI30Generator.java @@ -356,6 +356,8 @@ private Schema getSchema(Class clazz) { } } else if (HTMLContext.class.equals(clazz)) { return new StringSchema(); + } else if (byte[].class.equals(clazz)) { + return new StringSchema().format("binary"); } else { Schema contributed = OpenAPISchemaContributors.getSchema(clazz); if (contributed != null) { diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/test/java/fi/nls/hakunapi/simple/servlet/jakarta/EncodingInterceptorTest.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/test/java/fi/nls/hakunapi/simple/servlet/jakarta/EncodingInterceptorTest.java new file mode 100644 index 00000000..c3a96a99 --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/test/java/fi/nls/hakunapi/simple/servlet/jakarta/EncodingInterceptorTest.java @@ -0,0 +1,99 @@ +package fi.nls.hakunapi.simple.servlet.jakarta; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNull; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +import org.junit.Test; + +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.ext.WriterInterceptorContext; + +public class EncodingInterceptorTest { + + private static final byte[] PLAIN = "hello hello hello hello".getBytes(); + + private static class Ctx { + final Object entity; + final MultivaluedMap headers = new MultivaluedHashMap<>(); + OutputStream out = new ByteArrayOutputStream(); + final ByteArrayOutputStream sink = (ByteArrayOutputStream) out; + byte[] payload; + + Ctx(Object entity, byte[] payload) { + this.entity = entity; + this.payload = payload; + headers.putSingle(HttpHeaders.CONTENT_ENCODING, "gzip"); + headers.putSingle(HttpHeaders.CONTENT_LENGTH, payload.length); + } + + WriterInterceptorContext proxy() { + return (WriterInterceptorContext) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[] { WriterInterceptorContext.class }, + (p, m, a) -> { + switch (m.getName()) { + case "getEntity": return entity; + case "getHeaders": return headers; + case "getOutputStream": return out; + case "setOutputStream": out = (OutputStream) a[0]; return null; + case "proceed": out.write(payload); out.close(); return null; + default: throw new UnsupportedOperationException(m.getName()); + } + }); + } + } + + private static byte[] gzip(byte[] b) throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (GZIPOutputStream g = new GZIPOutputStream(bos)) { + g.write(b); + } + return bos.toByteArray(); + } + + private static byte[] gunzip(byte[] b) throws IOException { + return new GZIPInputStream(new ByteArrayInputStream(b)).readAllBytes(); + } + + private static GunzipInterceptor gunzipFor(String acceptEncoding) throws Exception { + GunzipInterceptor i = new GunzipInterceptor(); + HttpHeaders h = (HttpHeaders) Proxy.newProxyInstance( + EncodingInterceptorTest.class.getClassLoader(), new Class[] { HttpHeaders.class }, + (p, m, a) -> acceptEncoding == null ? List.of() : List.of(acceptEncoding)); + Field f = GunzipInterceptor.class.getDeclaredField("requestHeaders"); + f.setAccessible(true); + f.set(i, h); + return i; + } + + @Test + public void gzipLeavesPreEncodedBytesAlone() throws Exception { + byte[] stored = gzip(PLAIN); + Ctx c = new Ctx(new PreEncodedBytes(stored), stored); + new GzipInterceptor().aroundWriteTo(c.proxy()); + assertArrayEquals(stored, c.sink.toByteArray()); + } + + @Test + public void gunzipInflatesWhenClientDoesNotAcceptGzip() throws Exception { + byte[] stored = gzip(PLAIN); + for (String acceptEncoding : new String[] { null, "gzip;q=0" }) { + Ctx c = new Ctx(new PreEncodedBytes(stored), stored); + gunzipFor(acceptEncoding).aroundWriteTo(c.proxy()); + assertArrayEquals(PLAIN, c.sink.toByteArray()); + assertNull(c.headers.getFirst(HttpHeaders.CONTENT_ENCODING)); + assertNull(c.headers.getFirst(HttpHeaders.CONTENT_LENGTH)); + } + } +} diff --git a/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/HakunaContextListener.java b/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/HakunaContextListener.java index eec56dd1..628683a8 100644 --- a/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/HakunaContextListener.java +++ b/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/HakunaContextListener.java @@ -116,29 +116,40 @@ public void contextInitialized(ServletContextEvent sce) { } List extensions = loadApiExtensions(parser, configPath, collections); + if (collections.isEmpty() && extensions.isEmpty()) { + LOG.warn("No collections configured and no API extension published anything"); + } List conformsTo = new ArrayList<>(); - conformsTo.add(ConformanceClass.Core); - conformsTo.add(ConformanceClass.OpenAPI30); - conformsTo.add(ConformanceClass.GeoJSON); - conformsTo.add(ConformanceClass.HTML); - conformsTo.add(ConformanceClass.CRS); - conformsTo.add(ConformanceClass.GPKG); - - conformsTo.add(ConformanceClass.QUERYABLES); - conformsTo.add(ConformanceClass.QUERYABLES_QUERY_PARAMETERS); - conformsTo.add(ConformanceClass.FILTER); - conformsTo.add(ConformanceClass.FEATURES_FILTER); - - conformsTo.add(ConformanceClass.CQL2_TEXT); - // conformsTo.add(ConformanceClass.CQL2_JSON_ENCODING); - conformsTo.add(ConformanceClass.BASIC_CQL2); - conformsTo.add(ConformanceClass.ADVANCED_COMPARISON_OPERATORS); - conformsTo.add(ConformanceClass.CASE_INSENSITIVE_COMPARISON); - conformsTo.add(ConformanceClass.BASIC_SPATIAL_FUNCTIONS); - conformsTo.add(ConformanceClass.BAISC_SPATIAL_FUNCTIONS_PLUS); - conformsTo.add(ConformanceClass.SPATIAL_FUNCTIONS); - conformsTo.add(ConformanceClass.FUNCTIONS); + conformsTo.add(ConformanceClass.COMMON_CORE); + conformsTo.add(ConformanceClass.COMMON_JSON); + conformsTo.add(ConformanceClass.COMMON_HTML); + conformsTo.add(ConformanceClass.COMMON_OAS30); + conformsTo.add(ConformanceClass.COMMON_COLLECTIONS); + + if (!collections.isEmpty()) { + conformsTo.add(ConformanceClass.Core); + conformsTo.add(ConformanceClass.OpenAPI30); + conformsTo.add(ConformanceClass.GeoJSON); + conformsTo.add(ConformanceClass.HTML); + conformsTo.add(ConformanceClass.CRS); + conformsTo.add(ConformanceClass.GPKG); + + conformsTo.add(ConformanceClass.QUERYABLES); + conformsTo.add(ConformanceClass.QUERYABLES_QUERY_PARAMETERS); + conformsTo.add(ConformanceClass.FILTER); + conformsTo.add(ConformanceClass.FEATURES_FILTER); + + conformsTo.add(ConformanceClass.CQL2_TEXT); + // conformsTo.add(ConformanceClass.CQL2_JSON_ENCODING); + conformsTo.add(ConformanceClass.BASIC_CQL2); + conformsTo.add(ConformanceClass.ADVANCED_COMPARISON_OPERATORS); + conformsTo.add(ConformanceClass.CASE_INSENSITIVE_COMPARISON); + conformsTo.add(ConformanceClass.BASIC_SPATIAL_FUNCTIONS); + conformsTo.add(ConformanceClass.BAISC_SPATIAL_FUNCTIONS_PLUS); + conformsTo.add(ConformanceClass.SPATIAL_FUNCTIONS); + conformsTo.add(ConformanceClass.FUNCTIONS); + } for (ApiExtension extension : extensions) { List extensionClasses = extension.getConformanceClasses(); diff --git a/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/SimpleFeaturesApplication.java b/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/SimpleFeaturesApplication.java index 812a8625..84c34062 100644 --- a/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/SimpleFeaturesApplication.java +++ b/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/SimpleFeaturesApplication.java @@ -39,7 +39,9 @@ import fi.nls.hakunapi.simple.servlet.jakarta.CorsFilter; import fi.nls.hakunapi.simple.servlet.jakarta.GlobalFQueryParamFilter; import fi.nls.hakunapi.simple.servlet.jakarta.GzipFilter; +import fi.nls.hakunapi.simple.servlet.jakarta.GunzipInterceptor; import fi.nls.hakunapi.simple.servlet.jakarta.GzipInterceptor; +import fi.nls.hakunapi.simple.servlet.jakarta.PreEncodedBytesWriter; import fi.nls.hakunapi.simple.servlet.jakarta.NotAcceptableExceptionMapper; import fi.nls.hakunapi.simple.servlet.jakarta.NotFoundExceptionMapper; import fi.nls.hakunapi.simple.servlet.jakarta.ObjectMapperProvider; @@ -108,6 +110,8 @@ public SimpleFeaturesApplication(@Context ServletContext servletContext) { if (Boolean.parseBoolean(parser.get("hakuna.gzip", "true"))) { register(GzipFilter.class); register(GzipInterceptor.class); + register(PreEncodedBytesWriter.class); + register(GunzipInterceptor.class); } final OpenAPI30ApiOperation api = new OpenAPI30ApiOperation(service, opToImpl); diff --git a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/pom.xml b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/pom.xml index 4a6bef1d..4adb86dc 100644 --- a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/pom.xml +++ b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/pom.xml @@ -38,6 +38,12 @@ fi.nls.hakunapi hakunapi-simple-servlet-jakarta + + fi.nls.hakunapi + hakunapi-tiles-servlet-jakarta + ${project.version} + test + fi.nls.hakunapi hakunapi-simple-webapp-jakarta diff --git a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/CollectionlessServiceTest.java b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/CollectionlessServiceTest.java new file mode 100644 index 00000000..1aaf52a0 --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/CollectionlessServiceTest.java @@ -0,0 +1,55 @@ +package fi.nls.hakunapi.simple.webapp.jakarta.features; + +import static com.jayway.jsonassert.JsonAssert.with; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.hasItem; + +import java.io.File; + +import jakarta.servlet.ServletContextEvent; +import jakarta.ws.rs.core.Application; + +import org.glassfish.jersey.test.JerseyTest; +import org.junit.Test; + +import fi.nls.hakunapi.simple.webapp.jakarta.HakunaContextListener; +import fi.nls.hakunapi.simple.webapp.jakarta.HakunaTestServletContext; +import fi.nls.hakunapi.simple.webapp.jakarta.SimpleFeaturesApplication; + +public class CollectionlessServiceTest extends JerseyTest { + + @Override + protected Application configure() { + File file = new File(getClass().getClassLoader().getResource("hakuna_collectionless/hakuna.properties").getFile()); + System.setProperty("hakuna.config.path", file.getParentFile().getAbsolutePath() + "/"); + + HakunaTestServletContext sc = new HakunaTestServletContext(); + new HakunaContextListener().contextInitialized(new ServletContextEvent(sc)); + return new SimpleFeaturesApplication(sc); + } + + @Test + public void testConformanceHasOnlyCommonClasses() { + String response = target("/conformance").request().get(String.class); + + with(response) + .assertThat("$.conformsTo", hasItems( + equalTo("http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core"), + equalTo("http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json"), + equalTo("http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/html"), + equalTo("http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30"), + equalTo("http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections"))) + .assertThat("$.conformsTo", not(hasItem(equalTo("http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core")))) + .assertThat("$.conformsTo", not(hasItem(equalTo("http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/queryables")))) + .assertThat("$.conformsTo", not(hasItem(equalTo("http://www.opengis.net/spec/cql2/1.0/conf/cql2-text")))); + } + + @Test + public void testApiHasNoQueryablesPath() { + String response = target("/api").request().get(String.class); + + org.junit.Assert.assertFalse(response.contains("queryables")); + } +} diff --git a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/tiles/ApiExtensionDiscoveryTest.java b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/tiles/ApiExtensionDiscoveryTest.java new file mode 100644 index 00000000..98463c18 --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/tiles/ApiExtensionDiscoveryTest.java @@ -0,0 +1,53 @@ +package fi.nls.hakunapi.simple.webapp.jakarta.tiles; + +import static com.jayway.jsonassert.JsonAssert.with; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; + +import java.io.File; + +import jakarta.servlet.ServletContextEvent; +import jakarta.ws.rs.core.Application; + +import org.glassfish.jersey.test.JerseyTest; +import org.junit.Test; + +import fi.nls.hakunapi.simple.webapp.jakarta.HakunaContextListener; +import fi.nls.hakunapi.simple.webapp.jakarta.HakunaTestServletContext; +import fi.nls.hakunapi.simple.webapp.jakarta.SimpleFeaturesApplication; + +public class ApiExtensionDiscoveryTest extends JerseyTest { + + private static final String TILES_CORE = + "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core"; + + @Override + protected Application configure() { + ClassLoader classLoader = getClass().getClassLoader(); + File file = new File(classLoader.getResource("hakuna/hakuna.properties").getFile()); + System.setProperty("hakuna.config.path", file.getParentFile().getAbsolutePath() + "/"); + + final HakunaTestServletContext sc = new HakunaTestServletContext(); + final ServletContextEvent sce = new ServletContextEvent(sc); + new HakunaContextListener().contextInitialized(sce); + + return new SimpleFeaturesApplication(sc); + } + + @Test + public void testUnconfiguredExtensionPublishesNothing() throws Exception { + final String conformance = target("/conformance").request().get(String.class); + with(conformance) + .assertThat("$.conformsTo", + hasItems(equalTo("http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core"))) + .assertThat("$.conformsTo", not(hasItems(equalTo(TILES_CORE)))); + + final String landingPage = target("/").request().get(String.class); + with(landingPage).assertThat( + "$.links[?(@.rel == 'http://www.opengis.net/def/rel/ogc/1.0/tiling-schemes')]", + hasSize(0)); + } + +} diff --git a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_collectionless/hakuna.properties b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_collectionless/hakuna.properties new file mode 100644 index 00000000..4cf5c47b --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_collectionless/hakuna.properties @@ -0,0 +1,11 @@ +$config.version=2 + +api.title=Collectionless +api.version=1.0 +api.description=A service without feature collections. + +servers=dev +servers.dev.url=https://localhost/hakuna + +db.classes=fi.nls.hakunapi.source.HakunaTestSource +db=db