Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions docs/overview/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@
<artifactId>hakunapi-tiles-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>fi.nls.hakunapi</groupId>
<artifactId>hakunapi-tiles-servlet-jakarta</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>fi.nls.hakunapi</groupId>
<artifactId>hakunapi-config-test</artifactId>
Expand Down
21 changes: 21 additions & 0 deletions src-community/hakunapi-tiles-servlet-jakarta/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions src-community/hakunapi-tiles-servlet-jakarta/README.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 3 additions & 0 deletions src-community/hakunapi-tiles-servlet-jakarta/SUBMITTERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Submitted and maintained by:

jampukka
41 changes: 41 additions & 0 deletions src-community/hakunapi-tiles-servlet-jakarta/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>fi.nls.hakunapi</groupId>
<artifactId>src-community</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>hakunapi-tiles-servlet-jakarta</artifactId>

<properties>
<jakarta.servlet-api.version>6.0.0</jakarta.servlet-api.version>
</properties>

<dependencies>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>${jakarta.servlet-api.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>fi.nls.hakunapi</groupId>
<artifactId>hakunapi-tiles-core</artifactId>
</dependency>
<dependency>
<groupId>fi.nls.hakunapi</groupId>
<artifactId>hakunapi-simple-servlet-jakarta</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-models</artifactId>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -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<String> getValidPaths(FeatureServiceConfig service) {
List<String> 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<String, Class<?>> getResponsesByContentType(FeatureServiceConfig service) {
Map<String, Class<?>> 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<String, String> 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<String, String> whitelistedParams(MapTileLayer layer, UriInfo uriInfo) {
Set<String> allowed = layer.getSource().getPassthroughParams(layer);
if (allowed.isEmpty()) {
return Map.of();
}
MultivaluedMap<String, String> query = uriInfo.getQueryParameters();
Map<String, String> out = new LinkedHashMap<>();
for (String name : allowed) {
String value = query.getFirst(name);
if (value != null) {
out.put(name, value);
}
}
return out;
}
}
Original file line number Diff line number Diff line change
@@ -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<String> getValidPaths(FeatureServiceConfig service) {
List<String> 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<String, Class<?>> getResponsesByContentType(FeatureServiceConfig service) {
Map<String, Class<?>> 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<Link> links = new ArrayList<>();
links.add(new Link(tilesPath, TilesLinkRel.SELF, MediaType.APPLICATION_JSON, "This document"));

List<TileSetItem> 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<Link> 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<Link> 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();
}
}
Loading
Loading