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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### Functional changes

- An unknown `crs`, `bbox-crs` or `filter-crs` SRID returns 400 instead of 500, and a collection `srid` missing from the global `srid` fails startup with a message naming both.
- New extension point `ApiExtensionFactory` (`fi.nls.hakunapi.core.extension`), found with `ServiceLoader`: a module on the classpath can add conformance classes, operations, landing page and collection links, collections not backed by a feature type, `f` values and OpenAPI schemas to the reference webapp without subclassing `HakunaContextListener`. A factory returns `null` when nothing is configured for it; one that throws fails startup. Extensions are `Closeable` and closed with the context. `CollectionInfo` gains `setItemType` so an extension can publish a collection that is not a feature collection. Nothing in this repository implements it yet.
- The `f` query parameter error (400) now lists every accepted value: metadata formats, configured output formats and those added by API extensions, not only the metadata formats.
- Fixed CSV output (`f=csv`) failing with a NullPointerException on the first geometry or floating-point value: no number formatter was ever set. CSV now uses the GeoJSON defaults: coordinates with 3 decimals for metres and 7 for degrees, double properties with up to 8 and float properties with up to 5.
- New community module `hakunapi-bytes` (Java 22+): a shared byte access layer for file-backed sources, covering local files (pread, or mmap or a whole-file in-memory copy as an opt-in) and HTTP Range with a shared block cache and adaptive read-ahead. It also reads an entry of a SOZip (seek-optimized ZIP) archive with random access, locally or over HTTP, and opens a location written in GDAL's `/vsizip/` / `/vsicurl/` syntax. No source uses it yet. It is built only on a 22+ JDK, via the new `ffm` profile in `src-community/pom.xml`.
- GeoJSON date and timestamp properties are written straight into the output buffer instead of via an intermediate `String`, cutting ~17% off the write time of a date-bearing feature collection. Output bytes are unchanged.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import fi.nls.hakunapi.core.extension.ApiExtension;
import fi.nls.hakunapi.core.schemas.ConformanceClasses;
import fi.nls.hakunapi.core.schemas.FunctionsContent;
import fi.nls.hakunapi.core.schemas.Link;
Expand Down Expand Up @@ -157,6 +158,10 @@ public String getCurrentServerURL(Function<String, String> dynamicValues) {

public abstract FeatureType getCollection(String name);

public List<ApiExtension> getApiExtensions() {
return List.of();
}

public abstract OutputFormat getOutputFormat(String f);

public abstract Collection<OutputFormat> getOutputFormats();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package fi.nls.hakunapi.core.extension;

import java.io.Closeable;
import java.io.IOException;
import java.util.List;
import java.util.Map;

import fi.nls.hakunapi.core.ConformanceClass;
import fi.nls.hakunapi.core.FeatureType;
import fi.nls.hakunapi.core.operation.OperationImpl;
import fi.nls.hakunapi.core.schemas.CollectionInfo;
import fi.nls.hakunapi.core.schemas.Link;

public interface ApiExtension extends Closeable {

public static final ApiExtension NOP = new ApiExtension() {

@Override
public String getName() {
return "nop";
}

@Override
public List<ConformanceClass> getConformanceClasses() {
return List.of();
}

@Override
public List<OperationImpl> getOperations() {
return List.of();
}

@Override
public Map<Class<?>, Object> getInjectables() {
return Map.of();
}

@Override
public List<Link> getLandingPageLinks(String baseUrl, Map<String, String> queryParams) {
return List.of();
}

@Override
public List<Link> getCollectionLinks(FeatureType ft, String baseUrl, Map<String, String> queryParams) {
return List.of();
}

@Override
public List<CollectionInfo> getCollections(String baseUrl, Map<String, String> queryParams) {
return List.of();
}

@Override
public List<String> getCollectionIds(String resource) {
return List.of();
}

@Override
public Map<String, List<String>> getFormatMediaTypes() {
return Map.of();
}

@Override
public void close() {
// NOP
}

};

public String getName();

public List<ConformanceClass> getConformanceClasses();

public List<OperationImpl> getOperations();

public Map<Class<?>, Object> getInjectables();

/** baseUrl is without trailing slash; queryParams may be modified during the call but must be restored before returning. */
public List<Link> getLandingPageLinks(String baseUrl, Map<String, String> queryParams);

public List<Link> getCollectionLinks(FeatureType ft, String baseUrl, Map<String, String> queryParams);

/** Non-feature collections, listed in /collections and resolvable at /collections/{collectionId}. */
public List<CollectionInfo> getCollections(String baseUrl, Map<String, String> queryParams);

/** Collection ids for which this extension serves the named resource, used to expand DynamicPathOperation paths. */
public List<String> getCollectionIds(String resource);

/** Maps extra f values to media types, most preferred first; must not collide with MetadataFormat or output format ids. */
public Map<String, List<String>> getFormatMediaTypes();

@Override
public void close() throws IOException;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package fi.nls.hakunapi.core.extension;

import java.nio.file.Path;
import java.util.Map;

import fi.nls.hakunapi.core.FeatureType;
import fi.nls.hakunapi.core.config.HakunaConfigParser;

public record ApiExtensionContext(HakunaConfigParser config, Path configPath,
Map<String, FeatureType> collections) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package fi.nls.hakunapi.core.extension;

@FunctionalInterface
public interface ApiExtensionFactory {

public ApiExtension create(ApiExtensionContext context) throws Exception;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package fi.nls.hakunapi.core.extension;

import io.swagger.v3.oas.models.media.Schema;

public interface OpenAPISchemaContributor {

public Schema<?> getSchema(Class<?> clazz);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package fi.nls.hakunapi.core.extension;

import java.util.ServiceLoader;

import io.swagger.v3.oas.models.media.Schema;

public class OpenAPISchemaContributors {

private static final ServiceLoader<OpenAPISchemaContributor> LOADER =
ServiceLoader.load(OpenAPISchemaContributor.class);

private OpenAPISchemaContributors() {}

public static Schema<?> getSchema(Class<?> clazz) {
synchronized (LOADER) {
for (OpenAPISchemaContributor contributor : LOADER) {
Schema<?> schema = contributor.getSchema(clazz);
if (schema != null) {
return schema;
}
}
}
return null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class CollectionInfo extends ExtendableComponent {
private final String description;
private final List<Link> links;
private final Extent extent;
private final String itemType = "feature";
private String itemType = "feature";
private final String[] crs;
private final String storageCrs;

Expand Down Expand Up @@ -93,6 +93,10 @@ public String getItemType() {
return itemType;
}

public void setItemType(String itemType) {
this.itemType = itemType;
}

public String[] getCrs() {
return crs;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package fi.nls.hakunapi.simple.servlet.jakarta;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
Expand All @@ -14,6 +15,8 @@

import fi.nls.hakunapi.core.FeatureServiceConfig;
import fi.nls.hakunapi.core.MetadataFormat;
import fi.nls.hakunapi.core.OutputFormat;
import fi.nls.hakunapi.core.extension.ApiExtension;

@Provider
@PreMatching
Expand Down Expand Up @@ -52,18 +55,49 @@ public void filter(ContainerRequestContext req) throws IOException {
.orElseGet(() -> service.getOutputFormat(f) != null ? List.of(service.getOutputFormat(f).getMimeType()) : null);

if (mediaTypeToPrefer == null) {
String expected = Arrays.stream(MetadataFormat.values())
.map(it -> it.id)
.collect(Collectors.joining(",", "[", "]"));
mediaTypeToPrefer = extensionMediaTypes(f);
}

if (mediaTypeToPrefer == null) {
req.abortWith(ResponseUtil.exception(
Status.BAD_REQUEST,
"Invalid value for param '" + F_QUERY_PARAM + "', expected one of " + expected));
"Invalid value for param '" + F_QUERY_PARAM + "', expected one of " + expectedValues()));
} else {
String modified = modifyAcceptHeader(accept, mediaTypeToPrefer);
req.getHeaders().put("accept", List.of(modified));
}
}

private List<String> extensionMediaTypes(String f) {
for (ApiExtension extension : service.getApiExtensions()) {
List<String> mediaTypes = extension.getFormatMediaTypes().get(f);
if (mediaTypes != null && !mediaTypes.isEmpty()) {
return mediaTypes;
}
}
return null;
}

private String expectedValues() {
List<String> ids = new ArrayList<>();
for (MetadataFormat format : MetadataFormat.values()) {
ids.add(format.id);
}
for (OutputFormat format : service.getOutputFormats()) {
if (!ids.contains(format.getId())) {
ids.add(format.getId());
}
}
for (ApiExtension extension : service.getApiExtensions()) {
for (String id : extension.getFormatMediaTypes().keySet()) {
if (!ids.contains(id)) {
ids.add(id);
}
}
}
return ids.stream().collect(Collectors.joining(",", "[", "]"));
}

private String modifyAcceptHeader(String accept, List<String> mediaTypeToPrefer) {
String a = mediaTypeToPrefer.stream().collect(Collectors.joining(", "));
return accept == null || accept.isBlank() ? a : a + ", " + accept;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import fi.nls.hakunapi.core.FeatureType;
import fi.nls.hakunapi.core.FeatureServiceConfig;
import fi.nls.hakunapi.core.extension.ApiExtension;
import fi.nls.hakunapi.core.schemas.CollectionInfo;
import fi.nls.hakunapi.html.model.HTMLContext;

Expand All @@ -31,12 +32,16 @@ public CollectionInfo handle(
@PathParam("collectionId") String collectionId,
@Context UriInfo uriInfo,
@Context HttpHeaders headers) {
Map<String, String> queryParams = OperationUtil.getQueryParams(service, uriInfo);
FeatureType ft = service.getCollection(collectionId);
if (ft == null) {
throw new NotFoundException("Unknown collection");
if (ft != null) {
return CollectionMetadataUtil.toCollectionInfo(headers, service, ft, queryParams);
}
Map<String, String> queryParams = OperationUtil.getQueryParams(service, uriInfo);
return CollectionMetadataUtil.toCollectionInfo(headers, service, ft, queryParams);
CollectionInfo extra = findExtraCollection(headers, collectionId, queryParams);
if (extra != null) {
return extra;
}
throw new NotFoundException("Unknown collection");
}

@GET
Expand All @@ -50,4 +55,17 @@ public HTMLContext<CollectionInfo> handleHTML(
return new HTMLContext<>(service, basePath, handle(collectionId, uriInfo, headers));
}

private CollectionInfo findExtraCollection(HttpHeaders headers, String collectionId,
Map<String, String> queryParams) {
String baseUrl = service.getCurrentServerURL(headers::getHeaderString);
for (ApiExtension extension : service.getApiExtensions()) {
for (CollectionInfo info : extension.getCollections(baseUrl, queryParams)) {
if (collectionId.equals(info.getId())) {
return info;
}
}
}
return null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import fi.nls.hakunapi.core.schemas.TemporalExtent;
import fi.nls.hakunapi.core.schemas.Trs;
import fi.nls.hakunapi.core.util.CrsUtil;
import fi.nls.hakunapi.core.extension.ApiExtension;
import fi.nls.hakunapi.core.util.U;

public class CollectionMetadataUtil {
Expand Down Expand Up @@ -45,6 +46,11 @@ public static CollectionInfo toCollectionInfo(HttpHeaders headers, FeatureServic
links.add(getDescribedByLink(headers, service, queryParams, ft));
links.add(getQueryablesLinks(headers, service, queryParams, ft));

String baseUrl = service.getCurrentServerURL(headers::getHeaderString);
for (ApiExtension extension : service.getApiExtensions()) {
links.addAll(extension.getCollectionLinks(ft, baseUrl, queryParams));
}

// Add configured additional links
links.addAll(ft.getAdditionalLinks());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import fi.nls.hakunapi.core.schemas.CollectionsContent;
import fi.nls.hakunapi.core.schemas.Link;
import fi.nls.hakunapi.core.util.Links;
import fi.nls.hakunapi.core.extension.ApiExtension;
import fi.nls.hakunapi.html.model.HTMLContext;

@Path("/collections")
Expand Down Expand Up @@ -55,6 +56,10 @@ private CollectionsContent handle(UriInfo uriInfo, HttpHeaders headers, String c
CollectionInfo info = CollectionMetadataUtil.toCollectionInfo(headers, service, ft, queryParams);
collections.add(info);
}
String baseUrl = service.getCurrentServerURL(headers::getHeaderString);
for (ApiExtension extension : service.getApiExtensions()) {
collections.addAll(extension.getCollections(baseUrl, queryParams));
}

return new CollectionsContent(links, collections);
}
Expand Down
Loading
Loading