diff --git a/CHANGELOG.md b/CHANGELOG.md index a1fbf3de..f8177def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/FeatureServiceConfig.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/FeatureServiceConfig.java index c40b5a30..0ec1c857 100644 --- a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/FeatureServiceConfig.java +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/FeatureServiceConfig.java @@ -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; @@ -157,6 +158,10 @@ public String getCurrentServerURL(Function dynamicValues) { public abstract FeatureType getCollection(String name); + public List getApiExtensions() { + return List.of(); + } + public abstract OutputFormat getOutputFormat(String f); public abstract Collection getOutputFormats(); diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/ApiExtension.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/ApiExtension.java new file mode 100644 index 00000000..944c0f84 --- /dev/null +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/ApiExtension.java @@ -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 getConformanceClasses() { + return List.of(); + } + + @Override + public List getOperations() { + return List.of(); + } + + @Override + public Map, Object> getInjectables() { + return Map.of(); + } + + @Override + public List getLandingPageLinks(String baseUrl, Map queryParams) { + return List.of(); + } + + @Override + public List getCollectionLinks(FeatureType ft, String baseUrl, Map queryParams) { + return List.of(); + } + + @Override + public List getCollections(String baseUrl, Map queryParams) { + return List.of(); + } + + @Override + public List getCollectionIds(String resource) { + return List.of(); + } + + @Override + public Map> getFormatMediaTypes() { + return Map.of(); + } + + @Override + public void close() { + // NOP + } + + }; + + public String getName(); + + public List getConformanceClasses(); + + public List getOperations(); + + public Map, Object> getInjectables(); + + /** baseUrl is without trailing slash; queryParams may be modified during the call but must be restored before returning. */ + public List getLandingPageLinks(String baseUrl, Map queryParams); + + public List getCollectionLinks(FeatureType ft, String baseUrl, Map queryParams); + + /** Non-feature collections, listed in /collections and resolvable at /collections/{collectionId}. */ + public List getCollections(String baseUrl, Map queryParams); + + /** Collection ids for which this extension serves the named resource, used to expand DynamicPathOperation paths. */ + public List getCollectionIds(String resource); + + /** Maps extra f values to media types, most preferred first; must not collide with MetadataFormat or output format ids. */ + public Map> getFormatMediaTypes(); + + @Override + public void close() throws IOException; + +} diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/ApiExtensionContext.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/ApiExtensionContext.java new file mode 100644 index 00000000..1dd3b47c --- /dev/null +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/ApiExtensionContext.java @@ -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 collections) { + +} diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/ApiExtensionFactory.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/ApiExtensionFactory.java new file mode 100644 index 00000000..775c9c37 --- /dev/null +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/ApiExtensionFactory.java @@ -0,0 +1,8 @@ +package fi.nls.hakunapi.core.extension; + +@FunctionalInterface +public interface ApiExtensionFactory { + + public ApiExtension create(ApiExtensionContext context) throws Exception; + +} diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/OpenAPISchemaContributor.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/OpenAPISchemaContributor.java new file mode 100644 index 00000000..a7ec41d4 --- /dev/null +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/OpenAPISchemaContributor.java @@ -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); + +} diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/OpenAPISchemaContributors.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/OpenAPISchemaContributors.java new file mode 100644 index 00000000..accd5cae --- /dev/null +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/extension/OpenAPISchemaContributors.java @@ -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 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; + } + +} diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/schemas/CollectionInfo.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/schemas/CollectionInfo.java index 1d304ec6..1e123a3a 100644 --- a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/schemas/CollectionInfo.java +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/schemas/CollectionInfo.java @@ -17,7 +17,7 @@ public class CollectionInfo extends ExtendableComponent { private final String description; private final List links; private final Extent extent; - private final String itemType = "feature"; + private String itemType = "feature"; private final String[] crs; private final String storageCrs; @@ -93,6 +93,10 @@ public String getItemType() { return itemType; } + public void setItemType(String itemType) { + this.itemType = itemType; + } + public String[] getCrs() { return crs; } diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/GlobalFQueryParamFilter.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/GlobalFQueryParamFilter.java index c35d5109..caff2806 100644 --- a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/GlobalFQueryParamFilter.java +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/GlobalFQueryParamFilter.java @@ -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; @@ -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 @@ -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 extensionMediaTypes(String f) { + for (ApiExtension extension : service.getApiExtensions()) { + List mediaTypes = extension.getFormatMediaTypes().get(f); + if (mediaTypes != null && !mediaTypes.isEmpty()) { + return mediaTypes; + } + } + return null; + } + + private String expectedValues() { + List 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 mediaTypeToPrefer) { String a = mediaTypeToPrefer.stream().collect(Collectors.joining(", ")); return accept == null || accept.isBlank() ? a : a + ", " + accept; diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionMetadataImpl.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionMetadataImpl.java index 276ede5b..156dedc3 100644 --- a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionMetadataImpl.java +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionMetadataImpl.java @@ -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; @@ -31,12 +32,16 @@ public CollectionInfo handle( @PathParam("collectionId") String collectionId, @Context UriInfo uriInfo, @Context HttpHeaders headers) { + Map 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 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 @@ -50,4 +55,17 @@ public HTMLContext handleHTML( return new HTMLContext<>(service, basePath, handle(collectionId, uriInfo, headers)); } + private CollectionInfo findExtraCollection(HttpHeaders headers, String collectionId, + Map 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; + } + } diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionMetadataUtil.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionMetadataUtil.java index 0982e3d5..682c023b 100644 --- a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionMetadataUtil.java +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionMetadataUtil.java @@ -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 { @@ -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()); diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionsMetadataImpl.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionsMetadataImpl.java index e50c3642..2bb4f41b 100644 --- a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionsMetadataImpl.java +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/CollectionsMetadataImpl.java @@ -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") @@ -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); } diff --git a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/LandingPageImpl.java b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/LandingPageImpl.java index 957e250b..2cfaa49b 100644 --- a/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/LandingPageImpl.java +++ b/webapp-jakarta/hakunapi-simple-servlet-jakarta/src/main/java/fi/nls/hakunapi/simple/servlet/jakarta/operation/LandingPageImpl.java @@ -1,5 +1,9 @@ package fi.nls.hakunapi.simple.servlet.jakarta.operation; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; @@ -10,7 +14,10 @@ import jakarta.ws.rs.core.UriInfo; import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.extension.ApiExtension; +import fi.nls.hakunapi.core.schemas.Link; import fi.nls.hakunapi.core.schemas.Root; +import fi.nls.hakunapi.core.util.U; import fi.nls.hakunapi.html.model.HTMLContext; @Path("/") @@ -36,8 +43,14 @@ private Root handle(UriInfo uriInfo, HttpHeaders headers, String contentType, St String title = service.getTitle(); String description = service.getDescription(); String url = service.getCurrentServerURL(headers::getHeaderString); - String query = OperationUtil.getQuery(service, uriInfo); - + Map queryParams = OperationUtil.getQueryParams(service, uriInfo); + String query = U.toQuery(queryParams); + + List additionalLinks = new ArrayList<>(service.getAdditionalLinks()); + for (ApiExtension extension : service.getApiExtensions()) { + additionalLinks.addAll(extension.getLandingPageLinks(url, queryParams)); + } + return new Root.Builder(title, description, url, query, contentType) .alternate(alternateContentType) .api("application/vnd.oai.openapi+json;version=3.0") @@ -45,7 +58,7 @@ private Root handle(UriInfo uriInfo, HttpHeaders headers, String contentType, St .collections(MediaType.TEXT_HTML) .conformance(MediaType.APPLICATION_JSON) .conformance(MediaType.TEXT_HTML) - .additionalLinks(service.getAdditionalLinks()) + .additionalLinks(additionalLinks) .build(); } 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 4dee064d..db78dc4c 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 @@ -18,6 +18,7 @@ import jakarta.ws.rs.core.Response; import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.extension.OpenAPISchemaContributors; import fi.nls.hakunapi.core.operation.ApiOperation; import fi.nls.hakunapi.core.operation.ApiTag; import fi.nls.hakunapi.core.operation.DynamicPathOperation; @@ -355,8 +356,11 @@ private Schema getSchema(Class clazz) { } } else if (HTMLContext.class.equals(clazz)) { return new StringSchema(); - } else { + Schema contributed = OpenAPISchemaContributors.getSchema(clazz); + if (contributed != null) { + return contributed; + } throw new IllegalArgumentException("Server error"); } } 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 10f79dfd..eec56dd1 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 @@ -19,6 +19,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Properties; +import java.util.ServiceLoader; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -45,6 +46,9 @@ import fi.nls.hakunapi.core.telemetry.ServiceTelemetry; import fi.nls.hakunapi.core.telemetry.TelemetryConfigParser; import fi.nls.hakunapi.core.telemetry.TelemetryProvider; +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.core.util.PropertyUtil; import fi.nls.hakunapi.cql2.function.CQL2Functions; import fi.nls.hakunapi.cql2.text.CQL2Text; @@ -111,6 +115,8 @@ public void contextInitialized(ServletContextEvent sce) { collections.put(collectionId, ft); } + List extensions = loadApiExtensions(parser, configPath, collections); + List conformsTo = new ArrayList<>(); conformsTo.add(ConformanceClass.Core); conformsTo.add(ConformanceClass.OpenAPI30); @@ -134,6 +140,13 @@ public void contextInitialized(ServletContextEvent sce) { conformsTo.add(ConformanceClass.SPATIAL_FUNCTIONS); conformsTo.add(ConformanceClass.FUNCTIONS); + for (ApiExtension extension : extensions) { + List extensionClasses = extension.getConformanceClasses(); + LOG.info("API extension {} conforms to {}", extension.getName(), extensionClasses); + conformsTo.addAll(extensionClasses); + } + + List outputFormats = getOutputFormats(parser); List filterParsers = Arrays.asList(CQL2Text.INSTANCE); @@ -141,6 +154,7 @@ public void contextInitialized(ServletContextEvent sce) { FunctionsContent functionsMetadata = CQL2Functions.INSTANCE.toFunctionsMetadata(); SimpleFeatureServiceConfig service = new SimpleFeatureServiceConfig(collections, outputFormats, filterParsers); + service.setApiExtensions(extensions); service.setInfo(info); service.setServers(servers); service.setAdditionalLinks(parser.readAdditionalLinks()); @@ -227,6 +241,24 @@ private static void checkSridsKnown(String collectionId, FeatureType ft, List loadApiExtensions(HakunaConfigParser config, Path configPath, + Map collections) throws Exception { + ApiExtensionContext context = new ApiExtensionContext(config, configPath, collections); + List extensions = new ArrayList<>(); + for (ApiExtensionFactory factory : ServiceLoader.load(ApiExtensionFactory.class)) { + // ServiceLoader gives no order; a null result means nothing is configured for the factory + ApiExtension extension = factory.create(context); + if (extension == null) { + LOG.info("API extension factory {} published nothing", factory.getClass().getName()); + continue; + } + // registered before the next factory runs so a later throw still closes earlier ones + toClose.add(extension); + extensions.add(extension); + } + return extensions; + } + private static List getKnownSrids(Properties p, HakunaConfigParser config) { List codes = config.getKnownSrids(); return CRSRegistryProvider.getCRSRegistry() diff --git a/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/SimpleFeatureServiceConfig.java b/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/SimpleFeatureServiceConfig.java index 47dbcbeb..b1f0aa60 100644 --- a/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/SimpleFeatureServiceConfig.java +++ b/webapp-jakarta/hakunapi-simple-webapp-jakarta/src/main/java/fi/nls/hakunapi/simple/webapp/jakarta/SimpleFeatureServiceConfig.java @@ -12,12 +12,14 @@ import fi.nls.hakunapi.core.FilterParser; import fi.nls.hakunapi.core.OutputFormat; import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.extension.ApiExtension; public class SimpleFeatureServiceConfig extends FeatureServiceConfig { private final Map collections; private final Map outputFormats; private final Map filterParsers; + private List apiExtensions = List.of(); public SimpleFeatureServiceConfig(Map collections, List outputFormats, @@ -50,6 +52,15 @@ public FeatureType getCollection(String name) { return collections.get(name); } + @Override + public List getApiExtensions() { + return apiExtensions; + } + + public void setApiExtensions(List apiExtensions) { + this.apiExtensions = apiExtensions; + } + @Override public Collection getOutputFormats() { return outputFormats.values(); 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 60639512..812a8625 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 @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import jakarta.servlet.ServletContext; import jakarta.ws.rs.ApplicationPath; @@ -25,6 +26,7 @@ import fi.nls.hakunapi.core.operation.GetQueryablesOperation; import fi.nls.hakunapi.core.operation.GetSchemaOperation; import fi.nls.hakunapi.core.operation.LandingPageOperation; +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.CollectionsContent; @@ -85,6 +87,10 @@ public SimpleFeaturesApplication(@Context ServletContext servletContext) { // Experimental opToImpl.add(new OperationImpl(new GetFeaturesOperation(), GetItemsOperation.class)); + for (ApiExtension extension : service.getApiExtensions()) { + opToImpl.addAll(extension.getOperations()); + } + for (OperationImpl p : opToImpl) { register(p.implementation); } @@ -117,6 +123,16 @@ protected void configure() { bind(api).to(OpenAPI30ApiOperation.class); bind(fParamFilter).to(GlobalFQueryParamFilter.class); bind(cacheManager).to(CacheManager.class); + for (ApiExtension extension : service.getApiExtensions()) { + for (Map.Entry, Object> injectable : extension.getInjectables().entrySet()) { + bindInjectable(injectable.getKey(), injectable.getValue()); + } + } + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void bindInjectable(Class type, Object instance) { + bind(instance).to((Class) type); } }); diff --git a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/ApiExtensionTest.java b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/ApiExtensionTest.java new file mode 100644 index 00000000..87b8b7b4 --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/ApiExtensionTest.java @@ -0,0 +1,134 @@ +package fi.nls.hakunapi.simple.webapp.jakarta; + +import static com.jayway.jsonassert.JsonAssert.collectionWithSize; +import static com.jayway.jsonassert.JsonAssert.with; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.File; + +import jakarta.servlet.ServletContextEvent; +import jakarta.ws.rs.core.Application; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import org.glassfish.jersey.test.JerseyTest; +import org.glassfish.jersey.test.TestProperties; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import fi.nls.hakunapi.core.ConformanceClass; + +public class ApiExtensionTest extends JerseyTest { + + private static final Logger LOG = LoggerFactory.getLogger(ApiExtensionTest.class); + + @Override + protected Application configure() { + enable(TestProperties.LOG_TRAFFIC); + enable(TestProperties.DUMP_ENTITY); + + return boot("hakuna_api_extension/hakuna.properties"); + } + + private static Application boot(String configResource) { + ClassLoader classLoader = ApiExtensionTest.class.getClassLoader(); + File file = new File(classLoader.getResource(configResource).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 testExtensionPublishedOnlyWhenConfigured() throws Exception { + String landing = target("/").request().get(String.class); + LOG.info(landing); + with(landing) + .assertThat("$.links[?(@.rel == '" + TestApiExtensionFactory.LINK_REL + "')]", + is(collectionWithSize(equalTo(1)))) + .assertThat("$.links[?(@.rel == '" + TestApiExtensionFactory.LINK_REL + "')].href", + hasItems(endsWith("/" + TestApiExtensionFactory.LINK_REL))); + + String conformance = target("/conformance").request().get(String.class); + LOG.info(conformance); + with(conformance).assertThat("$.conformsTo", hasItems(equalTo(TestApiExtensionFactory.CONFORMANCE.uri))); + + String collections = target("/collections").request().get(String.class); + LOG.info(collections); + with(collections).assertThat("$.collections[*].id", + hasItems(equalTo(TestApiExtensionFactory.COLLECTION_ID), equalTo("test_collection"))); + + Response extCollection = target("/collections/" + TestApiExtensionFactory.COLLECTION_ID).request().get(); + assertEquals(200, extCollection.getStatus()); + with(extCollection.readEntity(String.class)) + .assertThat("$.id", equalTo(TestApiExtensionFactory.COLLECTION_ID)) + .assertThat("$.itemType", equalTo(TestApiExtensionFactory.ITEM_TYPE)); + + Response unknownCollection = target("/collections/no-such-collection").request().get(); + assertEquals(404, unknownCollection.getStatus()); + unknownCollection.close(); + + String featureCollection = target("/collections/test_collection").request().get(String.class); + LOG.info(featureCollection); + with(featureCollection) + .assertThat("$.links[?(@.rel == '" + TestApiExtensionFactory.COLLECTION_LINK_REL + "')]", + is(collectionWithSize(equalTo(1)))); + + Response testfmt = target("/").queryParam("f", TestApiExtensionFactory.FORMAT_ID).request().get(); + assertEquals(200, testfmt.getStatus()); + assertTrue(testfmt.getMediaType().isCompatible(MediaType.APPLICATION_JSON_TYPE)); + testfmt.close(); + + Response nonsense = target("/").queryParam("f", "nonsense").request().get(); + assertEquals(400, nonsense.getStatus()); + with(nonsense.readEntity(String.class)) + .assertThat("$.description", containsString(TestApiExtensionFactory.FORMAT_ID)); + + JerseyTest plain = new JerseyTest() { + @Override + protected Application configure() { + enable(TestProperties.LOG_TRAFFIC); + enable(TestProperties.DUMP_ENTITY); + + return boot("hakuna/hakuna.properties"); + } + }; + plain.setUp(); + try { + landing = plain.target("/").request().get(String.class); + LOG.info(landing); + with(landing).assertThat("$.links[?(@.rel == '" + TestApiExtensionFactory.LINK_REL + "')]", + is(collectionWithSize(equalTo(0)))); + + conformance = plain.target("/conformance").request().get(String.class); + LOG.info(conformance); + with(conformance).assertThat("$.conformsTo", + not(hasItems(equalTo(TestApiExtensionFactory.CONFORMANCE.uri)))); + + Response plainCollection = plain.target("/collections/" + TestApiExtensionFactory.COLLECTION_ID) + .request().get(); + assertEquals(404, plainCollection.getStatus()); + plainCollection.close(); + + Response plainFormat = plain.target("/").queryParam("f", TestApiExtensionFactory.FORMAT_ID) + .request().get(); + assertEquals(400, plainFormat.getStatus()); + plainFormat.close(); + } finally { + plain.tearDown(); + } + } + +} diff --git a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/TestApiExtensionFactory.java b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/TestApiExtensionFactory.java new file mode 100644 index 00000000..e5d4b827 --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/TestApiExtensionFactory.java @@ -0,0 +1,94 @@ +package fi.nls.hakunapi.simple.webapp.jakarta; + +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.extension.ApiExtension; +import fi.nls.hakunapi.core.extension.ApiExtensionContext; +import fi.nls.hakunapi.core.extension.ApiExtensionFactory; +import fi.nls.hakunapi.core.operation.OperationImpl; +import fi.nls.hakunapi.core.schemas.CollectionInfo; +import fi.nls.hakunapi.core.schemas.Link; + +public class TestApiExtensionFactory implements ApiExtensionFactory { + + public static final String LINK_REL = "test-extension"; + + // Unused by the rest of the code base, so it does not change any other conformance output + public static final ConformanceClass CONFORMANCE = ConformanceClass.ARITHMETIC; + + public static final String ITEM_TYPE = "test-item"; + public static final String COLLECTION_ID = "test-ext-collection"; + public static final String COLLECTION_LINK_REL = "test-ext-collection-link"; + public static final String FORMAT_ID = "testfmt"; + + @Override + public ApiExtension create(ApiExtensionContext context) { + if (!"true".equals(context.config().get("test.apiextension"))) { + return null; + } + return new TestApiExtension(); + } + + static final class TestApiExtension implements ApiExtension { + + @Override + public String getName() { + return "test-extension"; + } + + @Override + public List getConformanceClasses() { + return List.of(CONFORMANCE); + } + + @Override + public List getOperations() { + return List.of(); + } + + @Override + public Map, Object> getInjectables() { + return Map.of(); + } + + @Override + public List getLandingPageLinks(String baseUrl, Map queryParams) { + return List.of(new Link(baseUrl + "/" + LINK_REL, LINK_REL, "application/json", "Test extension")); + } + + @Override + public List getCollectionLinks(FeatureType ft, String baseUrl, Map queryParams) { + return List.of(new Link(baseUrl + "/collections/" + ft.getName() + "/" + COLLECTION_LINK_REL, + COLLECTION_LINK_REL, "application/json", "Test extension link")); + } + + @Override + public List getCollections(String baseUrl, Map queryParams) { + String self = baseUrl + "/collections/" + COLLECTION_ID; + CollectionInfo info = new CollectionInfo(COLLECTION_ID, "Test extension collection", + "Non-feature collection published by the test extension", + List.of(new Link(self, "self", "application/json")), null, null, null); + info.setItemType(ITEM_TYPE); + return List.of(info); + } + + @Override + public List getCollectionIds(String resource) { + return List.of(); + } + + @Override + public Map> getFormatMediaTypes() { + return Map.of(FORMAT_ID, List.of("application/json")); + } + + @Override + public void close() { + } + + } + +} diff --git a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/META-INF/services/fi.nls.hakunapi.core.extension.ApiExtensionFactory b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/META-INF/services/fi.nls.hakunapi.core.extension.ApiExtensionFactory new file mode 100644 index 00000000..43efc784 --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/META-INF/services/fi.nls.hakunapi.core.extension.ApiExtensionFactory @@ -0,0 +1 @@ +fi.nls.hakunapi.simple.webapp.jakarta.TestApiExtensionFactory diff --git a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_api_extension/hakuna.properties b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_api_extension/hakuna.properties new file mode 100644 index 00000000..91756a17 --- /dev/null +++ b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_api_extension/hakuna.properties @@ -0,0 +1,35 @@ +$config.version=2 + +api.title=Maastotiedot OGC API Features +api.version=1.0 +api.description=Test API with a test-only API extension enabled. + +servers=dev +servers.dev.url=https://localhost/hakuna +servers.dev.description=Pre-Production + +getfeatures.limit.default=1000 +getfeatures.limit.max=10000 +srid=3067,4258,3046,3047,3048,3873,3874,3875,3876,3877,3878,3879,3880,3881,3882,3883,3884,3885 + +db.classes=fi.nls.hakunapi.source.HakunaTestSource +db=db + +# Read by TestApiExtensionFactory; the default test configuration does not set it. +test.apiextension=true + +collections=test_collection +collections.test_collection.type=test +collections.test_collection.db=db +collections.test_collection.table=test_table +collections.test_collection.extent.spatial.crs84=-110,-60,140,30 +collections.test_collection.title=Test Collection +collections.test_collection.description=Collection for testing purposes +collections.test_collection.srid=3067,4258,3046,3047,3048,3873,3874,3875,3876,3877,3878,3879,3880,3881,3882,3883,3884,3885 +collections.test_collection.writeNulls=true +collections.test_collection.id.mapping=fid +collections.test_collection.geometry.mapping=geom +collections.test_collection.geometry.type=POINT +collections.test_collection.properties=my_prop +collections.test_collection.testdata.1=1;POINT(1 1 100);11 +collections.test_collection.testdata.2=2;POINT(1 1 100);22