diff --git a/CHANGELOG.md b/CHANGELOG.md index 0555dfae..53a5158f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,7 +79,7 @@ entries as `collection_id (Title)`. - The HTML collection page links to the collection schema and shows the spatial and temporal extent when available (#185). - HTML templates: Bootstrap 5.0.0-beta3 -> 5.3.8, Leaflet 1.7.1 -> 1.9.4, proj4 2.7.5 -> 2.22.0 and swagger-ui 3.45.1 -> 5.29.1 (still loaded from CDNs, with updated SRI hashes). The Leaflet and proj4 imports now live in `mapLibs.ftl`, shared by `featureCollection.ftl` and `feature.ftl`; a custom template directory that overrides these two templates keeps its own versions (#199). - +- New config key `api.collections.links.*` adds links to the top-level `links` array of the `/collections` response, in both JSON and HTML. Parallel to the existing `api.links.*` (landing page) and `default.collections.links.*` / `collections..links.*` (per-collection entries). ## 2.0.0 diff --git a/docker/cfg/addresses.properties b/docker/cfg/addresses.properties index 603a1ae2..807935b0 100644 --- a/docker/cfg/addresses.properties +++ b/docker/cfg/addresses.properties @@ -9,6 +9,34 @@ api.description=Example implementation of OGC API Services with hakunapi. INSPIR api.license.name=Creative Commons Attribution 4.0 International (CC BY 4.0) - Syke (Finnish Environment Institute) api.license.url=https://www.syke.fi/fi/ymparistotieto/kayttolupa-ja-vastuut +# Additional links on the landing page +api.links=syke +api.links.syke.href=https://www.syke.fi/en-US +api.links.syke.rel=about +api.links.syke.type=text/html +api.links.syke.title=Finnish Environment Institute + +# Additional links on the /collections response. +# INSPIRE /req/pre-defined/spatial-data-set-metadata requires /collections to link the +# data set's metadata record as rel=describedby + type=application/xml, resolving to an +# ISO 19115 record; /rec/pre-defined/spatial-data-set-metadata-html recommends the same +# record as text/html. Both are configured below, pointing at the record for the Ryhti +# building address data in the national catalogue. Note the record id is brace-wrapped +# and the braces are part of it, hence %7B / %7D. +api.collections.links=metadata-xml,metadata-html,license +api.collections.links.metadata-xml.href=https://www.paikkatietohakemisto.fi/geonetwork/srv/fin/csw?service=CSW&version=2.0.2&request=GetRecordById&elementSetName=full&outputSchema=http%3A%2F%2Fwww.isotc211.org%2F2005%2Fgmd&id=%7BDBD610F4-3392-44CD-B601-BAE8FA547A57%7D +api.collections.links.metadata-xml.rel=describedby +api.collections.links.metadata-xml.type=application/xml +api.collections.links.metadata-xml.title=ISO 19115 metadata record for the building address data +api.collections.links.metadata-html.href=https://ckan.ymparisto.fi/dataset/%7BDBD610F4-3392-44CD-B601-BAE8FA547A57%7D +api.collections.links.metadata-html.rel=describedby +api.collections.links.metadata-html.type=text/html +api.collections.links.metadata-html.title=Metadata record for the building address data +api.collections.links.license.href=https://www.syke.fi/fi/ymparistotieto/kayttolupa-ja-vastuut +api.collections.links.license.rel=license +api.collections.links.license.type=text/html +api.collections.links.license.title=CC BY 4.0 licence terms + # List of servers ## Required, comma-separated list of server the API is servers=dev 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 90473e55..2e39618e 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 @@ -45,6 +45,7 @@ public abstract class FeatureServiceConfig { protected List knownSrids; protected ServiceTelemetry telemetry = ServiceTelemetry.NOP; protected List additionalLinks; + protected List collectionsAdditionalLinks; protected Localization localization; private final Map localizedCache = new ConcurrentHashMap<>(); @@ -244,6 +245,14 @@ public void setAdditionalLinks(List additionalLinks) { this.additionalLinks = additionalLinks; } + public List getCollectionsAdditionalLinks() { + return collectionsAdditionalLinks == null ? Collections.emptyList() : collectionsAdditionalLinks; + } + + public void setCollectionsAdditionalLinks(List collectionsAdditionalLinks) { + this.collectionsAdditionalLinks = collectionsAdditionalLinks; + } + public Localization getLocalization() { return localization == null ? Localization.EMPTY : localization; } 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 23006d2e..79bcc63f 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 @@ -156,6 +156,10 @@ public List readAdditionalLinks() { return readLinksWithPrefix("api.links"); } + public List readCollectionsAdditionalLinks() { + return readLinksWithPrefix("api.collections.links"); + } + private List readLinksWithPrefix(String keyPrefix) { String[] names = getMultiple(keyPrefix); List links = new ArrayList<>(); diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureServiceConfig.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureServiceConfig.java index 805fcfe6..b7cc01dd 100644 --- a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureServiceConfig.java +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureServiceConfig.java @@ -300,4 +300,14 @@ public void setAdditionalLinks(List additionalLinks) { service.setAdditionalLinks(additionalLinks); } + @Override + public List getCollectionsAdditionalLinks() { + return service.getCollectionsAdditionalLinks(); + } + + @Override + public void setCollectionsAdditionalLinks(List collectionsAdditionalLinks) { + service.setCollectionsAdditionalLinks(collectionsAdditionalLinks); + } + } 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 9ca19bde..7a7160c4 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 @@ -2,11 +2,16 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import java.util.List; import java.util.Properties; import org.junit.Test; +import fi.nls.hakunapi.core.schemas.Link; + public class HakunaConfigParserTest { @Test @@ -57,4 +62,60 @@ public void testReadCollectionIds() { assertArrayEquals(new String[] { "a", "b" }, new HakunaConfigParser(cfg).readCollectionIds()); } + @Test + public void testReadCollectionsAdditionalLinks() { + Properties cfg = new Properties(); + cfg.setProperty("api.links", "api"); + cfg.setProperty("api.links.api.href", "https://example.org/api"); + cfg.setProperty("api.links.api.rel", "describedby"); + cfg.setProperty("api.links.api.type", "application/xml"); + cfg.setProperty("api.links.api.title", "Service metadata"); + cfg.setProperty("api.collections.links", "metadata,license"); + cfg.setProperty("api.collections.links.metadata.href", "https://example.org/metadata"); + cfg.setProperty("api.collections.links.metadata.rel", "describedby"); + cfg.setProperty("api.collections.links.metadata.type", "application/xml"); + cfg.setProperty("api.collections.links.metadata.title", "Data set metadata"); + cfg.setProperty("api.collections.links.license.href", "https://example.org/license"); + cfg.setProperty("api.collections.links.license.rel", "license"); + cfg.setProperty("api.collections.links.license.type", "text/html"); + cfg.setProperty("api.collections.links.license.title", "Licence"); + + HakunaConfigParser parser = new HakunaConfigParser(cfg); + + List links = parser.readCollectionsAdditionalLinks(); + assertEquals(2, links.size()); + assertEquals("https://example.org/metadata", links.get(0).getHref()); + assertEquals("describedby", links.get(0).getRel()); + assertEquals("application/xml", links.get(0).getType()); + assertEquals("Data set metadata", links.get(0).getTitle()); + assertEquals("https://example.org/license", links.get(1).getHref()); + assertEquals("license", links.get(1).getRel()); + assertEquals("text/html", links.get(1).getType()); + assertEquals("Licence", links.get(1).getTitle()); + + // The two sources are independent + List apiLinks = parser.readAdditionalLinks(); + assertEquals(1, apiLinks.size()); + assertEquals("https://example.org/api", apiLinks.get(0).getHref()); + } + + @Test + public void testReadCollectionsAdditionalLinksUnconfigured() { + HakunaConfigParser parser = new HakunaConfigParser(new Properties()); + assertTrue(parser.readCollectionsAdditionalLinks().isEmpty()); + } + + @Test + public void testReadCollectionsAdditionalLinksMissingProperty() { + Properties cfg = new Properties(); + cfg.setProperty("api.collections.links", "metadata"); + cfg.setProperty("api.collections.links.metadata.href", "https://example.org/metadata"); + cfg.setProperty("api.collections.links.metadata.rel", "describedby"); + cfg.setProperty("api.collections.links.metadata.type", "application/xml"); + + HakunaConfigParser parser = new HakunaConfigParser(cfg); + + assertThrows(IllegalArgumentException.class, () -> parser.readCollectionsAdditionalLinks()); + } + } diff --git a/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/collections.ftl b/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/collections.ftl index 6dc7ad09..61ec5292 100644 --- a/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/collections.ftl +++ b/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/collections.ftl @@ -47,6 +47,17 @@
  • ${collection.id}<#if collection.title??> (${collection.title})
  • + +<#-- self and alternate are the only links this page generates itself --> +<#assign additionalLinks = model.links?filter(link -> link.rel != "self" && link.rel != "alternate") /> +<#if additionalLinks?size gt 0> +
    +

    Additional Resources

    +<#list additionalLinks as link> +

    ${link.title}

    + +
    +
    Powered by hakunapi
    diff --git a/src/hakunapi-html/src/test/java/fi/nls/hakunapi/html/CollectionsTemplateTest.java b/src/hakunapi-html/src/test/java/fi/nls/hakunapi/html/CollectionsTemplateTest.java new file mode 100644 index 00000000..a62d29c1 --- /dev/null +++ b/src/hakunapi-html/src/test/java/fi/nls/hakunapi/html/CollectionsTemplateTest.java @@ -0,0 +1,96 @@ +package fi.nls.hakunapi.html; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.StringWriter; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import org.junit.Test; + +import freemarker.cache.ClassTemplateLoader; +import freemarker.template.Configuration; +import freemarker.template.Template; +import io.swagger.v3.oas.models.info.Info; + +import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.OutputFormat; +import fi.nls.hakunapi.core.schemas.CollectionsContent; +import fi.nls.hakunapi.core.schemas.Link; +import fi.nls.hakunapi.html.model.HTMLContext; + +public class CollectionsTemplateTest { + + @Test + public void testAdditionalLinks() throws Exception { + List links = List.of( + new Link("https://example.org/collections", "self", "text/html", "This document"), + new Link("https://example.org/collections", "alternate", "application/json", "This document"), + new Link("https://example.org/metadata", "describedby", "application/xml", "Data set metadata"), + new Link("https://example.org/license", "license", "text/html", "Licence")); + + String html = render(new CollectionsContent(links, Collections.emptyList())); + + assertTrue(html.contains("Additional Resources")); + assertTrue(html.contains("https://example.org/metadata")); + assertTrue(html.contains("Data set metadata")); + assertTrue(html.contains("https://example.org/license")); + assertTrue(html.contains("Licence")); + // self and alternate are page navigation, not additional resources + assertFalse(html.contains("This document")); + } + + @Test + public void testNoAdditionalLinks() throws Exception { + List links = List.of(new Link("https://example.org/collections", "self", "text/html", "This document")); + + String html = render(new CollectionsContent(links, Collections.emptyList())); + + assertFalse(html.contains("Additional Resources")); + } + + private String render(CollectionsContent content) throws Exception { + Configuration cfg = new Configuration(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS); + cfg.setDefaultEncoding("UTF-8"); + cfg.setLocale(Locale.US); + cfg.setTemplateLoader(new ClassTemplateLoader(OutputFormatFactoryHTML.class, "")); + + Template template = cfg.getTemplate("collections.ftl"); + TestServiceConfig service = new TestServiceConfig(); + service.setInfo(new Info().title("Test service")); + HTMLContext ctx = new HTMLContext<>(service, "https://example.org", content); + + StringWriter out = new StringWriter(); + template.process(ctx, out); + return out.toString(); + } + + private static class TestServiceConfig extends FeatureServiceConfig { + + @Override + public Collection getCollections() { + return Collections.emptyList(); + } + + @Override + public FeatureType getCollection(String name) { + return null; + } + + @Override + public OutputFormat getOutputFormat(String f) { + return null; + } + + @Override + public Collection getOutputFormats() { + return Collections.emptyList(); + } + + } + +} 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 07788245..3f4315a7 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 @@ -99,6 +99,7 @@ private Localized localize(String langParam, UriInfo uriInfo, HttpHeaders header links.add(Links.getSelfLink(path, queryParams, contentType, lang)); links.addAll(getAlternateLinks(path, queryParams, contentType)); links.addAll(Links.getAlternateLangLinks(path, queryParams, contentType, available, lang)); + links.addAll(service.getCollectionsAdditionalLinks()); List collections = new ArrayList<>(); for (FeatureType ft : localizedService.getCollections()) { 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 bf913b4c..6d8278d1 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 @@ -172,6 +172,7 @@ public void contextInitialized(ServletContextEvent sce) { service.setServers(servers); service.setAdditionalLinks(parser.readAdditionalLinks()); service.setLocalization(localization); + service.setCollectionsAdditionalLinks(parser.readCollectionsAdditionalLinks()); service.setLimitDefault(PropertyUtil.getInt(config, "getfeatures.limit.default", 1000)); service.setLimitMaximum(PropertyUtil.getInt(config, "getfeatures.limit.max", 10000)); service.setConformanceClasses(conformsTo); diff --git a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/OgcApiFeaturesPart1CoreTest.java b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/OgcApiFeaturesPart1CoreTest.java index c1ced96f..dafd4326 100644 --- a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/OgcApiFeaturesPart1CoreTest.java +++ b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/OgcApiFeaturesPart1CoreTest.java @@ -110,6 +110,46 @@ public void testCollectionsOrder() { .assertThat("$.collections[1].id", equalTo("aallonmurtaja")); } + @SuppressWarnings("unchecked") + @Test + public void testCollectionsAdditionalLinks() { + final String response = target("/collections").request().get(String.class); + LOG.info(response); + + with(response) + // + .assertThat("$.links[0].rel", equalTo("self")) + .assertThat("$.links[?(@.rel == 'describedby')]", is(collectionWithSize(equalTo(1)))) + .assertThat("$.links[?(@.rel == 'describedby')].href", + hasItems(equalTo("https://example.org/dataset-metadata"))) + .assertThat("$.links[?(@.rel == 'describedby')].title", + hasItems(equalTo("Data set metadata record"))) + .assertThat("$.links[?(@.rel == 'license')].href", hasItems(equalTo("https://example.org/license"))) + .assertThat("$.links[?(@.rel == 'license')].type", hasItems(equalTo("text/html"))); + + // The landing page link source must not leak into /collections + with(response) + .assertThat("$.links[?(@.href == 'https://example.org/api-metadata')]", + is(collectionWithSize(equalTo(0)))); + + // Nor must the /collections links leak into the collection entries + with(response) + .assertThat("$.collections[*].links[?(@.rel == 'license')]", is(collectionWithSize(equalTo(0)))); + } + + @Test + public void testLandingPageAdditionalLinks() { + final String response = target("/").request().get(String.class); + LOG.info(response); + + with(response) + // + .assertThat("$.links[?(@.href == 'https://example.org/api-metadata')]", + is(collectionWithSize(equalTo(1)))) + .assertThat("$.links[?(@.href == 'https://example.org/dataset-metadata')]", + is(collectionWithSize(equalTo(0)))); + } + @SuppressWarnings("unchecked") @Test public void testCollectionsAallonmurtaja() { diff --git a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna/hakuna.properties b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna/hakuna.properties index e99897ad..7b226dec 100644 --- a/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna/hakuna.properties +++ b/webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna/hakuna.properties @@ -10,6 +10,22 @@ api.contact.url=https://www.maanmittauslaitos.fi/tietoa-maanmittauslaitoksesta/y api.license.name=Data accessed from the API is licensed by the National Land Survey open data CC 4.0 licence. api.license.url=https://www.maanmittauslaitos.fi/en/opendata-licence-cc40 +api.links=metadata +api.links.metadata.href=https://example.org/api-metadata +api.links.metadata.rel=describedby +api.links.metadata.type=application/xml +api.links.metadata.title=Service metadata record + +api.collections.links=metadata,license +api.collections.links.metadata.href=https://example.org/dataset-metadata +api.collections.links.metadata.rel=describedby +api.collections.links.metadata.type=application/xml +api.collections.links.metadata.title=Data set metadata record +api.collections.links.license.href=https://example.org/license +api.collections.links.license.rel=license +api.collections.links.license.type=text/html +api.collections.links.license.title=Licence of the data sets + servers=dev servers.dev.url=https://localhost/hakuna servers.dev.description=Pre-Production