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 extends APIParam> 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 extends APIParam> 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 extends APIParam> 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 extends APIParam> 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 extends APIParam> 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