From bb13cb61e18732d62bc0d6e608766e62907fc0bc Mon Sep 17 00:00:00 2001 From: jampukka Date: Fri, 28 Aug 2026 14:06:38 +0300 Subject: [PATCH 1/3] Language negotiation for service and collection metadata --- CHANGELOG.md | 54 +++ docker/cfg/addresses.properties | 37 ++ docker/cfg/messages_fi.properties | 15 + .../hakunapi/core/FeatureServiceConfig.java | 38 ++ .../fi/nls/hakunapi/core/FeatureType.java | 5 + .../nls/hakunapi/core/SimpleFeatureType.java | 3 +- .../core/config/HakunaConfigParser.java | 103 +++++- .../hakunapi/core/i18n/LangNegotiation.java | 75 ++++ .../hakunapi/core/i18n/LocalizableKeys.java | 89 +++++ .../nls/hakunapi/core/i18n/Localization.java | 96 +++++ .../i18n/LocalizedFeatureServiceConfig.java | 303 ++++++++++++++++ .../core/i18n/LocalizedFeatureType.java | 228 ++++++++++++ .../fi/nls/hakunapi/core/param/LangParam.java | 48 +++ .../fi/nls/hakunapi/core/schemas/Root.java | 16 +- .../java/fi/nls/hakunapi/core/util/Links.java | 51 +++ .../core/config/ReadLocalizationTest.java | 224 ++++++++++++ .../core/i18n/LangNegotiationTest.java | 81 +++++ .../core/i18n/LocalizableKeysTest.java | 52 +++ .../hakunapi/core/i18n/LocalizationTest.java | 147 ++++++++ .../LocalizedFeatureServiceConfigTest.java | 330 ++++++++++++++++++ .../core/i18n/LocalizedFeatureTypeTest.java | 286 +++++++++++++++ .../html/HTMLFeatureCollectionWriter.java | 4 +- .../hakunapi/html/HTMLFeatureWriterBase.java | 26 ++ .../html/HTMLSingleFeatureWriter.java | 4 +- .../nls/hakunapi/html/model/HTMLContext.java | 42 +++ .../nls/hakunapi/html/model/HTMLFeature.java | 38 ++ .../html/model/HTMLFeatureCollection.java | 37 ++ .../html/model/HTMLLanguageContext.java | 54 +++ .../resources/fi/nls/hakunapi/html/api.ftl | 2 +- .../fi/nls/hakunapi/html/collectionInfo.ftl | 43 ++- .../fi/nls/hakunapi/html/collections.ftl | 27 +- .../fi/nls/hakunapi/html/conformance.ftl | 25 +- .../fi/nls/hakunapi/html/feature.ftl | 36 +- .../nls/hakunapi/html/featureCollection.ftl | 34 +- .../fi/nls/hakunapi/html/queryables.ftl | 34 +- .../resources/fi/nls/hakunapi/html/root.ftl | 29 +- .../operation/CollectionMetadataImpl.java | 101 +++++- .../operation/CollectionMetadataUtil.java | 42 ++- .../operation/CollectionsMetadataImpl.java | 83 ++++- .../jakarta/operation/ConformanceImpl.java | 16 +- .../GetCollectionItemByIdOperation.java | 16 + .../GetCollectionItemsOperation.java | 37 ++ .../GetCollectionQueryablesImpl.java | 49 ++- .../operation/GetCollectionSchemaImpl.java | 41 +-- .../jakarta/operation/LandingPageImpl.java | 99 +++++- .../jakarta/operation/OperationUtil.java | 26 ++ .../webapp/jakarta/HakunaContextListener.java | 3 + .../features/BadCatalogStartupTest.java | 58 +++ .../features/CollectionMetadataI18nTest.java | 210 +++++++++++ .../webapp/jakarta/features/HtmlI18nTest.java | 280 +++++++++++++++ .../jakarta/features/LandingPageI18nTest.java | 182 ++++++++++ .../features/LangOnUnlocalizedTest.java | 58 +++ .../features/OpenApiLangParamTest.java | 132 +++++++ .../resources/hakuna_i18n/hakuna.properties | 60 ++++ .../hakuna_i18n/messages_fi.properties | 6 + .../hakuna_i18n/messages_sv.properties | 2 + .../resources/hakuna_i18n/test_schema.json | 46 +++ .../hakuna_i18n_bad/hakuna.properties | 50 +++ .../hakuna_i18n_bad/messages_fi.properties | 5 + .../hakuna_i18n_bad/test_schema.json | 46 +++ 60 files changed, 4245 insertions(+), 119 deletions(-) create mode 100644 docker/cfg/messages_fi.properties create mode 100644 src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LangNegotiation.java create mode 100644 src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizableKeys.java create mode 100644 src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/Localization.java create mode 100644 src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureServiceConfig.java create mode 100644 src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureType.java create mode 100644 src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/param/LangParam.java create mode 100644 src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/config/ReadLocalizationTest.java create mode 100644 src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LangNegotiationTest.java create mode 100644 src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizableKeysTest.java create mode 100644 src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizationTest.java create mode 100644 src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureServiceConfigTest.java create mode 100644 src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureTypeTest.java create mode 100644 src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLLanguageContext.java create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/BadCatalogStartupTest.java create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/CollectionMetadataI18nTest.java create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/HtmlI18nTest.java create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/LandingPageI18nTest.java create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/LangOnUnlocalizedTest.java create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/java/fi/nls/hakunapi/simple/webapp/jakarta/features/OpenApiLangParamTest.java create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_i18n/hakuna.properties create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_i18n/messages_fi.properties create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_i18n/messages_sv.properties create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_i18n/test_schema.json create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_i18n_bad/hakuna.properties create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_i18n_bad/messages_fi.properties create mode 100644 webapp-jakarta/hakunapi-simple-webapp-test-jakarta/src/test/resources/hakuna_i18n_bad/test_schema.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d2feed..397fd8ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ ## Unreleased +### Internationalization / Localization + +- **Language negotiation for descriptive elements.** The landing page, + `/collections` and `/collections/{collectionId}` honour a `lang` query + parameter and the `Accept-Language` header, and report `Content-Language`. + `lang` wins over `Accept-Language`; an unsupported value falls back to the + first declared language. Titles and descriptions come from optional + per-language catalogs declared with `locale=`: + + ```properties + locale=en,fi + locale.fi.path=messages_fi.properties + ``` + + A catalog is a sparse UTF-8 overlay on the main config, resolved per key, so + partial translations are fine and a declared language needs no catalog at all. + Only `api.title`, `api.description`, `collections..title` and + `collections..description` may be translated; any other key fails at + startup naming the key. Link titles are not localizable — declare one link per + language instead, each with its own `api.links..hreflang`, which is now + supported. See `docker/cfg/addresses.properties` for a worked example. + + Multi-language resources emit `rel=alternate` links with `hreflang`, and + `lang` is propagated into generated links. `Link.hreflang` is now populated + (previously never set). `/collections` advertises the union of its + collections' languages. + + Configuring one language or none is unchanged behaviour. Not yet localizable: + hakunapi's own English strings in JSON and HTML chrome, and the OpenAPI + document at `/api`. + +- Behaviour change to `/collections/{collectionId}/schema`: an unmatched `lang` + parameter previously fell through to the non-localized defaults. It now falls + back to the first configured language, as an unmatched `Accept-Language` + already did. + +- When `locale=` is configured, every `schema=` entry must resolve to a declared + language or startup fails naming the schema. The language is + `schema..lang`, defaulting to the schema's name, so `schema=en,fi` + alongside `locale=en,fi` needs no change while a schema named for anything + else must now declare its language: + + ```properties + schema=my_schema + schema.my_schema.path=my_schema.json + schema.my_schema.lang=fi + ``` + + Services with no `locale=` are unaffected. + ### 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. @@ -23,6 +73,10 @@ - New community module `hakunapi-tiles-source-gpkg`: a `MapTileSource` and a `VectorTileSource` serving the tiles stored in a GeoPackage tile pyramid, read through `sqlite-jdbc`. Not part of the reference webapp. - New community module `hakunapi-tiles-source-pmtiles`: a `MapTileSource` and a `VectorTileSource` serving the tiles of a PMTiles v3 archive, read in place from a local file or over HTTP range requests. Not part of the reference webapp. - New community module `hakunapi-tiles-generator`: the seed tool `TilesGenerator` writes a vector tile pyramid into a GeoPackage (`hakunapi-tiles-source-gpkg` can serve it), and `PMTilesConvert` converts such a pyramid into a PMTiles v3 archive. Not part of the reference webapp. +- `collectionInfo.ftl` shows the collection id alongside its title and + description, and links to the collection's schema. `collections.ftl` lists + entries as `collection_id (Title)`. + ## 2.0.0 diff --git a/docker/cfg/addresses.properties b/docker/cfg/addresses.properties index 50b4938b..e1d99dbb 100644 --- a/docker/cfg/addresses.properties +++ b/docker/cfg/addresses.properties @@ -63,6 +63,43 @@ schema=en,fi schema.en.path=addresses_schema_en.json schema.fi.path=addresses_schema_fi.json +# Language negotiation for descriptive elements on the landing page, +# /collections and /collections/{collectionId}. Selected with the lang query +# parameter or the Accept-Language header; lang wins. +# +# The first declared language is the default and the fallback. A language +# needs no catalog: without one it is served entirely from the keys in this +# file, which is how the base representation's language is declared so that +# Content-Language and hreflang can be stated rather than guessed. Here "en" +# is that base language and only "fi" has a catalog. +# +# A catalog is a sparse overlay: it holds only the keys that differ, and +# resolution is per key, so a partial translation is fine. Catalogs are read +# as UTF-8. Only these keys may appear in one, and anything else fails at +# startup naming the offending key: +# +# api.title +# api.description +# collections..title +# collections..description +# +# Link titles are not among them. An additional link points at a resource this +# service does not control, so a translated title would promise a language the +# target may not have. Declare one link per language instead, each stating the +# language of what it points at: +# +# api.links=metadata-en,metadata-fi +# api.links.metadata-fi.href=https://example.org/metatiedot +# api.links.metadata-fi.hreflang=fi +# api.links.metadata-fi.title=Aineiston metatiedot +# +# locale= and schema= are independent: a collection can be localized through +# its schema files without the landing page being localized, and each +# resource advertises only the languages it can actually serve. +locale=en,fi +locale.fi.path=messages_fi.properties +#locale.fi.lang=fi-FI + # Collections and their configurations ## Required, comma-separated list of all collections diff --git a/docker/cfg/messages_fi.properties b/docker/cfg/messages_fi.properties new file mode 100644 index 00000000..3c633feb --- /dev/null +++ b/docker/cfg/messages_fi.properties @@ -0,0 +1,15 @@ +# Finnish localization catalog for addresses.properties +# +# A sparse overlay: only keys that differ from the base configuration need to +# be present, and resolution is per key. Read as UTF-8. +# +# For collection titles and descriptions a schema file entry +# (addresses_schema_fi.json) is consulted when this catalog has no value, so a +# key here is only needed to override the schema, or where no schema exists. + +api.title=INSPIRE-osoitteet, Suomi +api.description=Esimerkkitoteutus OGC API -rajapinnoista hakunapilla. INSPIRE Simple Addresses -aineisto Suomesta. + +collections.addresses.title=Osoitteet +collections.addresses.description=Osoitepisteet koko Suomesta +collections.addresses_view.title=Osoitteet (näkymä) 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 0ec1c857..90473e55 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 @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.stream.Collectors; @@ -13,6 +14,8 @@ import org.slf4j.LoggerFactory; import fi.nls.hakunapi.core.extension.ApiExtension; +import fi.nls.hakunapi.core.i18n.LocalizedFeatureServiceConfig; +import fi.nls.hakunapi.core.i18n.Localization; import fi.nls.hakunapi.core.schemas.ConformanceClasses; import fi.nls.hakunapi.core.schemas.FunctionsContent; import fi.nls.hakunapi.core.schemas.Link; @@ -42,6 +45,9 @@ public abstract class FeatureServiceConfig { protected List knownSrids; protected ServiceTelemetry telemetry = ServiceTelemetry.NOP; protected List additionalLinks; + protected Localization localization; + + private final Map localizedCache = new ConcurrentHashMap<>(); public int getLimitDefault() { return limitDefault; @@ -238,4 +244,36 @@ public void setAdditionalLinks(List additionalLinks) { this.additionalLinks = additionalLinks; } + public Localization getLocalization() { + return localization == null ? Localization.EMPTY : localization; + } + + public void setLocalization(Localization localization) { + this.localization = localization; + // Wrappers close over the old Localization, so drop them + this.localizedCache.clear(); + } + + /** + * @return declared languages in declared order, first is the default; empty + * if this service is not localized + */ + public List getLanguages() { + return getLocalization().getLanguages(); + } + + /** + * Returns a localized view of this service. + * + * @param lang language to localize in + * @return a localized view, or this if nothing here can serve lang + * @see LocalizedFeatureServiceConfig + */ + public FeatureServiceConfig localized(String lang) { + if (lang == null || !getLanguages().contains(lang)) { + return this; + } + return localizedCache.computeIfAbsent(lang, l -> new LocalizedFeatureServiceConfig(this, l)); + } + } diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/FeatureType.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/FeatureType.java index d4a07cb6..988dea9d 100644 --- a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/FeatureType.java +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/FeatureType.java @@ -11,6 +11,7 @@ import fi.nls.hakunapi.core.property.HakunaProperty; import fi.nls.hakunapi.core.property.simple.HakunaPropertyGeometry; import fi.nls.hakunapi.core.schemas.Link; +import io.swagger.v3.oas.models.media.Schema; public interface FeatureType { @@ -62,4 +63,8 @@ public default List getAdditionalLinks() { return Collections.emptyList(); } + public default Map> getLangToSchema() { + return Collections.emptyMap(); + } + } diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/SimpleFeatureType.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/SimpleFeatureType.java index 253406ec..44576f85 100644 --- a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/SimpleFeatureType.java +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/SimpleFeatureType.java @@ -215,8 +215,9 @@ public void setAdditionalLinks(List additionalLinks) { this.additionalLinks = additionalLinks; } + @Override public Map> getLangToSchema() { - return langToSchema; + return langToSchema == null ? Collections.emptyMap() : langToSchema; } public void setLangToSchema(Map> langToSchema) { 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 d4b68ce1..23006d2e 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 @@ -1,6 +1,10 @@ package fi.nls.hakunapi.core.config; import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; @@ -39,6 +43,8 @@ import fi.nls.hakunapi.core.SimpleFeatureType; import fi.nls.hakunapi.core.SimpleSource; import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.i18n.LocalizableKeys; +import fi.nls.hakunapi.core.i18n.Localization; import fi.nls.hakunapi.core.param.GetFeatureParam; import fi.nls.hakunapi.core.projection.ProjectionTransformerFactory; import fi.nls.hakunapi.core.property.HakunaProperty; @@ -159,7 +165,8 @@ private List readLinksWithPrefix(String keyPrefix) { String rel = getRequired(prefix + "rel"); String type = getRequired(prefix + "type"); String title = getRequired(prefix + "title"); - links.add(new Link(href, rel, type, title)); + String hreflang = get(prefix + "hreflang"); + links.add(new Link(href, rel, type, title, hreflang)); } return links; } @@ -729,7 +736,7 @@ public HakunaProperty getProperty(FeatureType sft, String prop) { * schema.fi.path=path/to/file_fi.json * schema.sv.path=path/to/file_sv.json * - * Returns a map from normalized lang to root Schema. + * @return root schemas keyed by normalized lang */ public Map> readSchemas(Path configPath) { String[] schemaNames = getMultiple("schema"); @@ -757,6 +764,98 @@ public Map> readSchemas(Path configPath) { return result; } + /** + * Reads localization catalogs from config: + * locale=fi,sv + * locale.fi.path=messages_fi.properties + * locale.sv.path=messages_sv.properties + * locale.sv.lang=sv-FI (optional, defaults to the name) + * + * Catalogs are sparse overlays on the base config; a declared language with + * no catalog path is served entirely from it. The first declared language is + * the default, ordered by the locale= list. + * + * @return Localization with catalogs keyed by normalized lang, never null + */ + public Localization readLocalization(Path configPath) { + String[] localeNames = getMultiple("locale"); + if (localeNames.length == 0) { + return Localization.EMPTY; + } + + + List languages = new ArrayList<>(localeNames.length); + Map> catalogs = new LinkedHashMap<>(); + for (String name : localeNames) { + String lang = get("locale." + name + ".lang", name).toLowerCase(Locale.ROOT); + if (languages.contains(lang)) { + throw new IllegalArgumentException( + "Duplicate language '" + lang + "' declared in locale="); + } + languages.add(lang); + + String filePath = get("locale." + name + ".path"); + if (filePath == null) { + // A language with no catalog is served from the base config + LOG.info("Locale '{}' has no catalog (locale.{}.path=...), serving it from the base config", + name, name); + continue; + } + + File file = new File(filePath); + Path path = file.isAbsolute() ? file.toPath() : configPath.getParent().resolve(filePath); + catalogs.put(lang, readCatalog(path)); + } + + checkSchemaLanguages(languages); + + return new Localization(languages, catalogs, this::get); + } + + /** + * A schema's language must also be declared in locale=, otherwise it is + * never negotiated and the schema file sits unread. + */ + private void checkSchemaLanguages(List languages) { + for (String name : getMultiple("schema")) { + String lang = get("schema." + name + ".lang", name).toLowerCase(Locale.ROOT); + if (!languages.contains(lang)) { + throw new IllegalArgumentException(String.format( + "Schema '%s' resolves to language '%s', which is not declared in locale=%s. " + + "Set schema.%s.lang to one of them.", + name, lang, String.join(",", languages), name)); + } + } + } + + /** + * Read through an explicit UTF-8 Reader: Properties.load(InputStream) is + * Latin-1 and would mangle Finnish and Swedish. + */ + private Map readCatalog(Path path) { + Properties props = new Properties(); + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + props.load(reader); + } catch (IOException e) { + throw new IllegalArgumentException("Could not read localization catalog " + path, e); + } + + Map catalog = new LinkedHashMap<>(); + for (String key : props.stringPropertyNames()) { + if (!LocalizableKeys.isLocalizable(key)) { + throw new IllegalArgumentException(String.format( + "Localization catalog %s contains key '%s', which is not localizable. " + + "Localizable keys are: %s", + path, key, LocalizableKeys.describe())); + } + String value = props.getProperty(key); + if (value != null && !value.isBlank()) { + catalog.put(key, value.trim()); + } + } + return catalog; + } + public FeatureType applyJsonSchema(FeatureType ft, Map> schemas) { if (schemas == null || schemas.isEmpty() || !(ft instanceof SimpleFeatureType)) { return ft; diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LangNegotiation.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LangNegotiation.java new file mode 100644 index 00000000..23405dad --- /dev/null +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LangNegotiation.java @@ -0,0 +1,75 @@ +package fi.nls.hakunapi.core.i18n; + +import java.util.List; +import java.util.Locale; + +/** + * Language negotiation per OGC API Features Part 1 Core 7.10, plus an explicit + * lang query parameter that takes precedence over Accept-Language. + * + * Available tags are expected in lower case, as HakunaConfigParser normalizes + * them. + */ +public final class LangNegotiation { + + private LangNegotiation() { + } + + /** + * @param available in declared order, first is the default + * @return the language to serve, or null if none are available + */ + public static String resolve(List available, String langParam, List acceptLanguageTags) { + if (available == null || available.isEmpty()) { + return null; + } + + if (langParam != null && !langParam.isBlank()) { + String match = match(available, langParam); + return match != null ? match : available.get(0); + } + + if (acceptLanguageTags != null) { + for (String tag : acceptLanguageTags) { + String match = match(available, tag); + if (match != null) { + return match; + } + } + } + + return available.get(0); + } + + /** @return the available language matching tag exactly or by primary subtag, or null */ + public static String match(List available, String tag) { + if (tag == null || tag.isBlank()) { + return null; + } + + String normalized = tag.trim().toLowerCase(Locale.ROOT); + // Locale.ROOT round-trips to the wildcard "und", which asserts nothing + if ("und".equals(normalized) || "*".equals(normalized)) { + return null; + } + + for (String lang : available) { + if (lang.equals(normalized)) { + return lang; + } + } + + int dash = normalized.indexOf('-'); + if (dash > 0) { + String primary = normalized.substring(0, dash); + for (String lang : available) { + if (lang.equals(primary)) { + return lang; + } + } + } + + return null; + } + +} diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizableKeys.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizableKeys.java new file mode 100644 index 00000000..11445ba7 --- /dev/null +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizableKeys.java @@ -0,0 +1,89 @@ +package fi.nls.hakunapi.core.i18n; + +import java.util.List; +import java.util.regex.Pattern; + +/** + * The whitelist of config keys a localization catalog may override. + * + * A catalog shares the main config keyspace, so without a whitelist it could + * override unrelated configuration such as db.url. Keys outside the whitelist + * are rejected at startup rather than ignored. + * + * Adding a key is backwards compatible, removing one is not. + */ +public final class LocalizableKeys { + + /** + * The whitelist, in the readable form used in error messages. A + * placeholder in angle brackets stands for one config name segment. + */ + private static final List KEYS = List.of( + // service metadata + "api.title", + "api.description", + // collection metadata + "collections..title", + "collections..description"); + + private static final List PATTERNS = KEYS.stream() + .map(LocalizableKeys::toPattern) + .toList(); + + private LocalizableKeys() { + } + + /** Quotes the literal segments and turns each placeholder into [^.]+ */ + private static Pattern toPattern(String key) { + StringBuilder regex = new StringBuilder(key.length() + 16); + for (String segment : key.split("\\.", -1)) { + if (regex.length() > 0) { + regex.append("\\."); + } + if (segment.startsWith("<") && segment.endsWith(">")) { + regex.append("[^.]+"); + } else { + regex.append(Pattern.quote(segment)); + } + } + return Pattern.compile(regex.toString()); + } + + public static boolean isLocalizable(String key) { + if (key == null) { + return false; + } + for (Pattern p : PATTERNS) { + if (p.matcher(key).matches()) { + return true; + } + } + return false; + } + + /** + * @return the whitelist as a human readable list, for error messages + */ + public static String describe() { + return String.join(", ", KEYS); + } + + /* Key builders */ + + public static String apiTitle() { + return "api.title"; + } + + public static String apiDescription() { + return "api.description"; + } + + public static String collectionTitle(String collectionId) { + return "collections." + collectionId + ".title"; + } + + public static String collectionDescription(String collectionId) { + return "collections." + collectionId + ".description"; + } + +} diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/Localization.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/Localization.java new file mode 100644 index 00000000..f91feb13 --- /dev/null +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/Localization.java @@ -0,0 +1,96 @@ +package fi.nls.hakunapi.core.i18n; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Localized values for descriptive elements, keyed by language and config key. + * + * Resolution is per key, so catalogs may be arbitrarily sparse -- a catalog + * holds only what differs from the base config. A language declared with no + * catalog is served from the base config, which is how the base + * representation's language is stated. + * + * Order comes from the configured list, not from map iteration, because + * java.util.Properties is unordered. The first language is the default. + */ +public class Localization { + + public static final Localization EMPTY = new Localization(Collections.emptyList(), + Collections.emptyMap(), key -> null); + + private final List languages; + private final Map> catalogs; + private final Function base; + + /** + * @param catalogs lang to (config key to localized value); a language may be absent + * @param base lookup into the base config, used when a catalog has no value + */ + public Localization(List languages, Map> catalogs, + Function base) { + this.languages = List.copyOf(languages); + Map> copy = new LinkedHashMap<>(); + catalogs.forEach((lang, catalog) -> copy.put(lang, Map.copyOf(catalog))); + this.catalogs = Collections.unmodifiableMap(copy); + this.base = base; + } + + public List getLanguages() { + return languages; + } + + /** @return the default language, or null if none are configured */ + public String getDefaultLanguage() { + return languages.isEmpty() ? null : languages.get(0); + } + + public boolean isEmpty() { + return languages.isEmpty(); + } + + /** + * Resolves one key: catalog, then base config, then null. + * + * @param lang may be null to read the base config only + */ + public String get(String lang, String key) { + if (lang != null) { + Map catalog = catalogs.get(lang); + if (catalog != null) { + String value = catalog.get(key); + if (value != null) { + return value; + } + } + } + return base.apply(key); + } + + /** + * Resolves one key, falling back to an explicit default rather than the base + * config lookup, for callers already holding the base value. + */ + public String get(String lang, String key, String fallback) { + if (lang != null) { + Map catalog = catalogs.get(lang); + if (catalog != null) { + String value = catalog.get(key); + if (value != null) { + return value; + } + } + } + return fallback; + } + + /** @return true if a catalog for lang holds an explicit value for key */ + public boolean hasExplicit(String lang, String key) { + Map catalog = lang == null ? null : catalogs.get(lang); + return catalog != null && catalog.containsKey(key); + } + +} 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 new file mode 100644 index 00000000..805fcfe6 --- /dev/null +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureServiceConfig.java @@ -0,0 +1,303 @@ +package fi.nls.hakunapi.core.i18n; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import fi.nls.hakunapi.core.ConformanceClass; +import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.FilterParser; +import fi.nls.hakunapi.core.MetadataFormat; +import fi.nls.hakunapi.core.OutputFormat; +import fi.nls.hakunapi.core.SRIDCode; +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; +import fi.nls.hakunapi.core.telemetry.ServiceTelemetry; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; + +/** + * A FeatureServiceConfig whose descriptive elements resolve in one language. + * + * @see FeatureServiceConfig#localized(String) + */ +public class LocalizedFeatureServiceConfig extends FeatureServiceConfig { + + private final FeatureServiceConfig service; + private final String lang; + private final Localization l10n; + + public LocalizedFeatureServiceConfig(FeatureServiceConfig service, String lang) { + this.service = service; + this.lang = lang; + this.l10n = service.getLocalization(); + } + + public FeatureServiceConfig unwrap() { + return service; + } + + public String getLang() { + return lang; + } + + /* Localized */ + + @Override + public String getTitle() { + return l10n.get(lang, LocalizableKeys.apiTitle(), service.getTitle()); + } + + @Override + public String getDescription() { + return l10n.get(lang, LocalizableKeys.apiDescription(), service.getDescription()); + } + + @Override + public Collection getCollections() { + Collection collections = service.getCollections(); + List localized = new ArrayList<>(collections.size()); + for (FeatureType ft : collections) { + localized.add(new LocalizedFeatureType(ft, lang, l10n)); + } + return localized; + } + + @Override + public List getApiExtensions() { + return service.getApiExtensions(); + } + + @Override + public FeatureType getCollection(String name) { + FeatureType ft = service.getCollection(name); + return ft == null ? null : new LocalizedFeatureType(ft, lang, l10n); + } + + /** + * @return a sibling view rather than a wrapper stacked on this one + */ + @Override + public FeatureServiceConfig localized(String lang) { + return service.localized(lang); + } + + @Override + public Localization getLocalization() { + return service.getLocalization(); + } + + @Override + public void setLocalization(Localization localization) { + service.setLocalization(localization); + } + + @Override + public List getLanguages() { + return service.getLanguages(); + } + + /* Delegated */ + + @Override + public int getLimitDefault() { + return service.getLimitDefault(); + } + + @Override + public void setLimitDefault(int limitDefault) { + service.setLimitDefault(limitDefault); + } + + @Override + public int getLimitMaximum() { + return service.getLimitMaximum(); + } + + @Override + public void setLimitMaximum(int limitMaximum) { + service.setLimitMaximum(limitMaximum); + } + + @Override + public Info getInfo() { + return service.getInfo(); + } + + @Override + public void setInfo(Info info) { + service.setInfo(info); + } + + @Override + public Map> getSchemaExtensions() { + return service.getSchemaExtensions(); + } + + @Override + public void setSchemaExtensions(Map> schemaExtensions) { + service.setSchemaExtensions(schemaExtensions); + } + + @Override + public void setServers(List servers) { + service.setServers(servers); + } + + @Override + public Server getServer() { + return service.getServer(); + } + + @Override + public void setServer(Server server) { + service.setServer(server); + } + + @Override + @Deprecated + public ConformanceClasses getConformance() { + return service.getConformance(); + } + + @Override + @Deprecated + public void setConformance(ConformanceClasses conformance) { + service.setConformance(conformance); + } + + @Override + public List getConformanceClasses() { + return service.getConformanceClasses(); + } + + @Override + public void setConformanceClasses(List conformsTo) { + service.setConformanceClasses(conformsTo); + } + + @Override + public boolean conformsTo(ConformanceClass c) { + return service.conformsTo(c); + } + + @Override + public Map getSecuritySchemes() { + return service.getSecuritySchemes(); + } + + @Override + public void setSecuritySchemes(Map securitySchemes) { + service.setSecuritySchemes(securitySchemes); + } + + @Override + public List getSecurityRequirements() { + return service.getSecurityRequirements(); + } + + @Override + public void setSecurityRequirements(List securityRequirements) { + service.setSecurityRequirements(securityRequirements); + } + + @Override + @Deprecated + public String getCurrentServerURL() { + return service.getCurrentServerURL(); + } + + @Override + public String getCurrentServerURL(Function dynamicValues) { + return service.getCurrentServerURL(dynamicValues); + } + + @Override + public OutputFormat getOutputFormat(String f) { + return service.getOutputFormat(f); + } + + @Override + public Collection getOutputFormats() { + return service.getOutputFormats(); + } + + @Override + public FunctionsContent getFunctions() { + return service.getFunctions(); + } + + @Override + public void setFunctions(FunctionsContent functionsContent) { + service.setFunctions(functionsContent); + } + + @Override + public List getMetadataFormats() { + return service.getMetadataFormats(); + } + + @Override + public void setMetadataFormats(List metadataFormats) { + service.setMetadataFormats(metadataFormats); + } + + @Override + public void setKnownSrids(List knownSrids) { + service.setKnownSrids(knownSrids); + } + + @Override + public Optional getSridCode(int srid) { + return service.getSridCode(srid); + } + + @Override + public boolean isCrsLatLon(int srid) { + return service.isCrsLatLon(srid); + } + + @Override + public Collection getFilterParsers() { + return service.getFilterParsers(); + } + + @Override + public FilterParser getFilterParser(String filterLang) { + return service.getFilterParser(filterLang); + } + + @Override + public String getApiKeyQueryParam() { + return service.getApiKeyQueryParam(); + } + + @Override + public ServiceTelemetry getTelemetry() { + return service.getTelemetry(); + } + + @Override + public void setTelemetry(ServiceTelemetry usage) { + service.setTelemetry(usage); + } + + @Override + public List getAdditionalLinks() { + return service.getAdditionalLinks(); + } + + @Override + public void setAdditionalLinks(List additionalLinks) { + service.setAdditionalLinks(additionalLinks); + } + +} diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureType.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureType.java new file mode 100644 index 00000000..824706e1 --- /dev/null +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureType.java @@ -0,0 +1,228 @@ +package fi.nls.hakunapi.core.i18n; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import fi.nls.hakunapi.core.CacheSettings; +import fi.nls.hakunapi.core.DatetimeProperty; +import fi.nls.hakunapi.core.FeatureProducer; +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.OrderBy; +import fi.nls.hakunapi.core.PaginationStrategy; +import fi.nls.hakunapi.core.param.GetFeatureParam; +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.projection.ProjectionTransformerFactory; +import fi.nls.hakunapi.core.property.HakunaProperty; +import fi.nls.hakunapi.core.property.simple.HakunaPropertyGeometry; +import fi.nls.hakunapi.core.schemas.Link; +import io.swagger.v3.oas.models.media.Schema; + +/** + * A FeatureType decorated with localized descriptive elements. + */ +public class LocalizedFeatureType implements FeatureType { + + private final FeatureType wrapped; + private final String lang; + private final Localization l10n; + + public LocalizedFeatureType(FeatureType wrapped, String lang, Localization l10n) { + this.wrapped = wrapped; + this.lang = lang; + this.l10n = l10n; + } + + public FeatureType unwrap() { + return wrapped; + } + + public String getLang() { + return lang; + } + + /* Localized */ + + @Override + public String getTitle() { + String explicit = l10n.get(lang, LocalizableKeys.collectionTitle(getName()), null); + if (explicit != null) { + return explicit; + } + Schema schema = getSchema(); + if (schema != null && schema.getTitle() != null) { + return schema.getTitle(); + } + return wrapped.getTitle(); + } + + @Override + public String getDescription() { + String explicit = l10n.get(lang, LocalizableKeys.collectionDescription(getName()), null); + if (explicit != null) { + return explicit; + } + Schema schema = getSchema(); + if (schema != null && schema.getDescription() != null) { + return schema.getDescription(); + } + return wrapped.getDescription(); + } + + private Schema getSchema() { + Map> langToSchema = getLangToSchema(); + if (langToSchema.isEmpty()) { + return null; + } + String schemaLang = LangNegotiation.match(new ArrayList<>(langToSchema.keySet()), lang); + return schemaLang == null ? null : langToSchema.get(schemaLang); + } + + /* Delegated */ + + @Override + public List getAdditionalLinks() { + return wrapped.getAdditionalLinks(); + } + + @Override + public String getName() { + return wrapped.getName(); + } + + @Override + public String getNS() { + return wrapped.getNS(); + } + + @Override + public String getSchemaLocation() { + return wrapped.getSchemaLocation(); + } + + @Override + public Map getMetadata() { + return wrapped.getMetadata(); + } + + @Override + public HakunaProperty getId() { + return wrapped.getId(); + } + + @Override + public HakunaPropertyGeometry getGeom() { + return wrapped.getGeom(); + } + + @Override + public List getProperties() { + return wrapped.getProperties(); + } + + @Override + public List getSchemaProperties() { + return wrapped.getSchemaProperties(); + } + + @Override + public List getQueryableProperties() { + return wrapped.getQueryableProperties(); + } + + @Override + public List getDatetimeProperties() { + return wrapped.getDatetimeProperties(); + } + + @Override + @SuppressWarnings("deprecation") + public boolean isWriteNullProperties() { + return wrapped.isWriteNullProperties(); + } + + @Override + public double[] getSpatialExtent() { + return wrapped.getSpatialExtent(); + } + + @Override + public Instant[] getTemporalExtent() { + return wrapped.getTemporalExtent(); + } + + @Override + public List getParameters() { + return wrapped.getParameters(); + } + + @Override + public List getConformanceParams(List conformanceSpecificParams) { + return wrapped.getConformanceParams(conformanceSpecificParams); + } + + @Override + public List getStaticFilters() { + return wrapped.getStaticFilters(); + } + + @Override + public ProjectionTransformerFactory getProjectionTransformerFactory() { + return wrapped.getProjectionTransformerFactory(); + } + + @Override + public boolean isSourceWillProject() { + return wrapped.isSourceWillProject(); + } + + @Override + public PaginationStrategy getPaginationStrategy() { + return wrapped.getPaginationStrategy(); + } + + @Override + public List getDefaultOrderBy() { + return wrapped.getDefaultOrderBy(); + } + + @Override + public FeatureProducer getFeatureProducer() { + return wrapped.getFeatureProducer(); + } + + @Override + public CacheSettings getCacheSettings() { + return wrapped.getCacheSettings(); + } + + @Override + public Map> getLangToSchema() { + return wrapped.getLangToSchema(); + } + + /* Identity follows the wrapped instance */ + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj instanceof LocalizedFeatureType) { + return wrapped.equals(((LocalizedFeatureType) obj).wrapped); + } + return wrapped.equals(obj); + } + + @Override + public int hashCode() { + return wrapped.hashCode(); + } + + @Override + public String toString() { + return wrapped.toString(); + } + +} diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/param/LangParam.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/param/LangParam.java new file mode 100644 index 00000000..2d55861d --- /dev/null +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/param/LangParam.java @@ -0,0 +1,48 @@ +package fi.nls.hakunapi.core.param; + +import java.util.List; + +import fi.nls.hakunapi.core.FeatureServiceConfig; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.parameters.QueryParameter; + +/** + * Documents the lang query parameter for the OpenAPI document. + * + * Without this the generator still picks up @QueryParam("lang") reflectively, + * but produces a bare string parameter with no description and no enumeration + * of the languages the service actually serves. + * + * The parameter is common: it appears on the landing page, /collections, + * /collections/{collectionId} and /collections/{collectionId}/schema. + */ +public class LangParam implements APIParam { + + public static final String PARAM_NAME = "lang"; + + @Override + public String getParamName() { + return PARAM_NAME; + } + + @Override + public Parameter toParameter(FeatureServiceConfig service) { + StringSchema schema = new StringSchema(); + + List languages = service.getLanguages(); + if (!languages.isEmpty()) { + languages.forEach(schema::addEnumItemObject); + schema.setDefault(languages.get(0)); + } + + return new QueryParameter() + .name(PARAM_NAME) + .description("Language to use for the descriptive elements of this resource. " + + "Overrides the Accept-Language header. An unsupported value falls back " + + "to the default language.") + .required(false) + .schema(schema); + } + +} diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/schemas/Root.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/schemas/Root.java index d179d324..d42269f1 100644 --- a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/schemas/Root.java +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/schemas/Root.java @@ -65,27 +65,39 @@ public static class Builder { private final String description; private final String base; private final String query; + private final String lang; private final List links; public Builder(String title, String description, String url, String query, String contentType) { + this(title, description, url, query, contentType, null); + } + + /** + * @param lang the language this document is being served in, or null when + * the service is not localized. It is emitted as hreflang on the + * links that represent this document, so a client can tell which + * representation it received. + */ + public Builder(String title, String description, String url, String query, String contentType, String lang) { this.title = title; this.description = description; this.base = url; this.query = query; + this.lang = lang; this.links = new ArrayList<>(); String href = base + "/" + query; String rel = "self"; String linkTitle = "This document"; - this.links.add(new Link(href, rel, contentType, linkTitle)); + this.links.add(new Link(href, rel, contentType, linkTitle, lang)); } public Builder alternate(String contentType) { String href = base + "/" + query; String rel = "alternate"; String title = "This document"; - links.add(new Link(href, rel, contentType, title)); + links.add(new Link(href, rel, contentType, title, lang)); return this; } diff --git a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/util/Links.java b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/util/Links.java index 975bc7db..4a268c3e 100644 --- a/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/util/Links.java +++ b/src/hakunapi-core/src/main/java/fi/nls/hakunapi/core/util/Links.java @@ -3,10 +3,14 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import fi.nls.hakunapi.core.param.LangParam; import fi.nls.hakunapi.core.schemas.Link; public class Links { @@ -39,6 +43,53 @@ public static Link getAlternateLink(String path, Map queryParams return new Link(href, rel, mimeType, "This document as " + mimeTypeHuman); } + /** + * @param lang emitted both as the lang query parameter and as hreflang + */ + public static Link getAlternateLangLink(String path, Map queryParams, String mimeType, + String lang) { + Map withLang = new LinkedHashMap<>(); + if (queryParams != null) { + withLang.putAll(queryParams); + } + withLang.put(LangParam.PARAM_NAME, lang); + String href = addQueryParams(path, withLang); + return new Link(href, "alternate", mimeType, "This document in " + lang, lang); + } + + /** + * One rel=alternate hreflang link per language other than lang, so a client + * can find the other representations of this resource. + * + * @param available every language this resource can be served in, lang included + * @return empty if there is nothing to switch to + */ + public static List getAlternateLangLinks(String path, Map queryParams, String mimeType, + List available, String lang) { + if (lang == null || available.size() < 2) { + return Collections.emptyList(); + } + + Map params = new LinkedHashMap<>(queryParams); + params.remove(LangParam.PARAM_NAME); + List links = new ArrayList<>(available.size() - 1); + for (String other : available) { + if (!other.equals(lang)) { + links.add(getAlternateLangLink(path, params, mimeType, other)); + } + } + return links; + } + + /** + * Populating hreflang on language-dependent links is what lets a client tell + * which representation it got (OGC API Features Part 1 Core 7.10). + */ + public static Link getSelfLink(String path, Map queryParams, String mimeType, String lang) { + String href = addQueryParams(path, queryParams); + return new Link(href, "self", mimeType, "This document", lang); + } + private static String addQueryParams(String path, Map queryParams) { if (queryParams == null || queryParams.isEmpty()) { return path; diff --git a/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/config/ReadLocalizationTest.java b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/config/ReadLocalizationTest.java new file mode 100644 index 00000000..9f5f6efc --- /dev/null +++ b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/config/ReadLocalizationTest.java @@ -0,0 +1,224 @@ +package fi.nls.hakunapi.core.config; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.Properties; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import fi.nls.hakunapi.core.i18n.Localization; + +public class ReadLocalizationTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void testNoLocaleConfigured() throws IOException { + Path config = writeConfig("api.title=Base\n"); + Localization l10n = read(load(config), config); + + assertTrue(l10n.isEmpty()); + assertEquals(Collections.emptyList(), l10n.getLanguages()); + assertNull(l10n.getDefaultLanguage()); + } + + @Test + public void testDeclaredOrderAndDefaultLanguage() throws IOException { + Path config = writeConfig("locale=en,fi,sv\napi.title=Base\n"); + Localization l10n = read(load(config), config); + + assertEquals(Arrays.asList("en", "fi", "sv"), l10n.getLanguages()); + assertEquals("en", l10n.getDefaultLanguage()); + } + + + @Test + public void testLangOverridesNameAndIsNormalized() throws IOException { + Path config = writeConfig("locale=finnish\nlocale.finnish.lang=fi-FI\n"); + Localization l10n = read(load(config), config); + + assertEquals(Arrays.asList("fi-fi"), l10n.getLanguages()); + } + + @Test + public void testSparseCatalogFallsBackPerKey() throws IOException { + write("messages_fi.properties", "api.title=Otsikko\n"); + Path config = writeConfig("locale=fi\nlocale.fi.path=messages_fi.properties\n" + + "api.title=Base title\napi.description=Base description\n"); + Localization l10n = read(load(config), config); + + assertEquals("Otsikko", l10n.get("fi", "api.title")); + assertEquals("Base description", l10n.get("fi", "api.description")); + assertNull(l10n.get("fi", "api.nonexistent")); + assertTrue(l10n.hasExplicit("fi", "api.title")); + assertTrue(!l10n.hasExplicit("fi", "api.description")); + } + + /** + * Catalogs must be read through an explicit UTF-8 Reader; + * Properties.load(InputStream) is Latin-1 and would mangle these. + */ + @Test + public void testUtf8RoundTrip() throws IOException { + write("messages_fi.properties", + "api.description=Ääkkösiä ja åäö, Åland\n" + + "collections.osoitteet.title=Osoitepisteet — koko Suomi\n" + + "collections.osoitteet.description=Ruotsiksi: Adresspunkter i hela Finland\n"); + Path config = writeConfig("locale=fi\nlocale.fi.path=messages_fi.properties\n"); + Localization l10n = read(load(config), config); + + assertEquals("Ääkkösiä ja åäö, Åland", l10n.get("fi", "api.description")); + assertEquals("Osoitepisteet — koko Suomi", l10n.get("fi", "collections.osoitteet.title")); + assertEquals("Ruotsiksi: Adresspunkter i hela Finland", + l10n.get("fi", "collections.osoitteet.description")); + } + + + /** + * A silently dropped translation key is precisely the failure the catalog + * exists to make auditable, so a non-whitelisted key must fail at startup + * and name the offending key. It also stops a catalog from overriding + * unrelated config. + */ + @Test + public void testNonWhitelistedKeyFailsFastNamingTheKey() throws IOException { + write("messages_fi.properties", "api.title=Otsikko\ndb.url=jdbc:evil\n"); + Path config = writeConfig("locale=fi\nlocale.fi.path=messages_fi.properties\n"); + + try { + read(load(config), config); + fail("Expected a non-whitelisted catalog key to fail"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage(), e.getMessage().contains("db.url")); + assertTrue(e.getMessage(), e.getMessage().contains("messages_fi.properties")); + } + } + + @Test + public void testNonLinguisticApiKeysAreRejected() throws IOException { + for (String key : Arrays.asList("api.version", "api.contact.name", "api.license.url", + "api.license.name", "getfeatures.limit.max")) { + write("messages_fi.properties", key + "=whatever\n"); + Path config = writeConfig("locale=fi\nlocale.fi.path=messages_fi.properties\n"); + try { + read(load(config), config); + fail("Expected " + key + " to be rejected"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage(), e.getMessage().contains(key)); + } + } + } + + @Test + public void testMissingCatalogFileFails() throws IOException { + Path config = writeConfig("locale=fi\nlocale.fi.path=absent.properties\n"); + + try { + read(load(config), config); + fail("Expected a missing catalog file to fail"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage(), e.getMessage().contains("absent.properties")); + } + } + + @Test + public void testDuplicateLanguageFails() throws IOException { + Path config = writeConfig("locale=fi,finnish\nlocale.finnish.lang=fi\n"); + + try { + read(load(config), config); + fail("Expected a duplicate language to fail"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage(), e.getMessage().contains("fi")); + } + } + + + /** + * The name is not required to be a language: schema..lang is what is + * validated, so a schema named for what it is passes as long as it says + * which language it holds. + */ + @Test + public void testSchemaNamedForItselfIsAcceptedWhenItDeclaresItsLanguage() throws IOException { + Path config = writeConfig("locale=en,fi\nschema=my_schema\n" + + "schema.my_schema.path=my.json\nschema.my_schema.lang=fi\n"); + + assertEquals(Arrays.asList("en", "fi"), read(load(config), config).getLanguages()); + } + + @Test + public void testSchemaLanguageOutsideLocaleFails() throws IOException { + Path config = writeConfig("locale=en,fi\nschema=sv\nschema.sv.path=sv.json\n"); + + try { + read(load(config), config); + fail("Expected a schema language outside locale= to fail"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage(), e.getMessage().contains("sv")); + } + } + + /** + * A schema named for what it is and given no lang defaults to its name, + * which is not a declared language. + */ + @Test + public void testSchemaWithoutLanguageFails() throws IOException { + Path config = writeConfig("locale=en,fi\nschema=my_schema\nschema.my_schema.path=my.json\n"); + + try { + read(load(config), config); + fail("Expected a schema with no declared language to fail"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage(), e.getMessage().contains("my_schema")); + } + } + + /** + * Schemas predate localization, so a service with no locale= keeps working + * with whatever schema names it already has. + */ + @Test + public void testSchemasAreNotValidatedWithoutLocale() throws IOException { + Path config = writeConfig("schema=my_schema\nschema.my_schema.path=my.json\n"); + + assertTrue(read(load(config), config).isEmpty()); + } + + private Localization read(Properties cfg, Path configPath) { + return new HakunaConfigParser(cfg).readLocalization(configPath); + } + + private Path writeConfig(String content) throws IOException { + return write("hakuna.properties", content); + } + + private Path write(String name, String content) throws IOException { + Path path = tmp.getRoot().toPath().resolve(name); + Files.writeString(path, content, StandardCharsets.UTF_8); + return path; + } + + private static Properties load(Path path) throws IOException { + Properties p = new Properties(); + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + p.load(reader); + } + return p; + } + +} diff --git a/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LangNegotiationTest.java b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LangNegotiationTest.java new file mode 100644 index 00000000..bac48675 --- /dev/null +++ b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LangNegotiationTest.java @@ -0,0 +1,81 @@ +package fi.nls.hakunapi.core.i18n; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; + +public class LangNegotiationTest { + + private static final List AVAILABLE = Arrays.asList("en", "fi", "sv"); + + @Test + public void testNoLanguagesAvailable() { + assertNull(LangNegotiation.resolve(Collections.emptyList(), "fi", null)); + assertNull(LangNegotiation.resolve(null, "fi", null)); + } + + + @Test + public void testLangParamIsCaseInsensitive() { + assertEquals("fi", LangNegotiation.resolve(AVAILABLE, "FI", null)); + assertEquals("fi", LangNegotiation.resolve(AVAILABLE, "Fi-FI", null)); + } + + + @Test + public void testLangParamBeatsAcceptLanguage() { + assertEquals("sv", LangNegotiation.resolve(AVAILABLE, "sv", Arrays.asList("fi", "en"))); + } + + /** + * Behaviour change vs. the previous GetCollectionSchemaImpl.resolveLang: + * an unmatched lang param used to yield null (falling through to the + * non-localized defaults), whereas an unmatched Accept-Language fell back to + * the first configured language. Both now fall back to first-declared. + */ + @Test + public void testUnmatchedLangParamFallsBackToFirstDeclared() { + assertEquals("en", LangNegotiation.resolve(AVAILABLE, "de", null)); + assertEquals("en", LangNegotiation.resolve(AVAILABLE, "zz-ZZ", null)); + } + + @Test + public void testBlankLangParamIsIgnored() { + assertEquals("fi", LangNegotiation.resolve(AVAILABLE, " ", Arrays.asList("fi"))); + assertEquals("fi", LangNegotiation.resolve(AVAILABLE, null, Arrays.asList("fi"))); + } + + @Test + public void testAcceptLanguageInPreferenceOrder() { + assertEquals("sv", LangNegotiation.resolve(AVAILABLE, null, Arrays.asList("de", "sv", "fi"))); + } + + @Test + public void testAcceptLanguagePrimarySubtagFallback() { + assertEquals("sv", LangNegotiation.resolve(AVAILABLE, null, Arrays.asList("sv-FI"))); + } + + @Test + public void testUnmatchedAcceptLanguageFallsBackToFirstDeclared() { + assertEquals("en", LangNegotiation.resolve(AVAILABLE, null, Arrays.asList("de", "fr"))); + assertEquals("en", LangNegotiation.resolve(AVAILABLE, null, Collections.emptyList())); + } + + /** + * JAX-RS maps a wildcard Accept-Language to Locale.ROOT, whose language tag + * is "und". It expresses no preference and must not match anything. + */ + @Test + public void testWildcardAcceptLanguageMatchesNothing() { + assertNull(LangNegotiation.match(AVAILABLE, "und")); + assertNull(LangNegotiation.match(AVAILABLE, "*")); + assertEquals("en", LangNegotiation.resolve(AVAILABLE, null, Arrays.asList("und"))); + } + + +} diff --git a/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizableKeysTest.java b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizableKeysTest.java new file mode 100644 index 00000000..f9894fb7 --- /dev/null +++ b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizableKeysTest.java @@ -0,0 +1,52 @@ +package fi.nls.hakunapi.core.i18n; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class LocalizableKeysTest { + + /** + * The key builders and the whitelist are separate lists of the same keys, + * and nothing but this test ties them together. A builder for a key the + * whitelist rejects would fail only at runtime, by silently serving the + * untranslated fallback. + */ + @Test + public void testEveryBuiltKeyIsLocalizable() { + assertTrue(LocalizableKeys.isLocalizable(LocalizableKeys.apiTitle())); + assertTrue(LocalizableKeys.isLocalizable(LocalizableKeys.apiDescription())); + assertTrue(LocalizableKeys.isLocalizable(LocalizableKeys.collectionTitle("mun"))); + assertTrue(LocalizableKeys.isLocalizable(LocalizableKeys.collectionDescription("mun"))); + } + + @Test + public void testLinkTitlesAreNotLocalizable() { + assertFalse(LocalizableKeys.isLocalizable("api.links.metadata.title")); + assertFalse(LocalizableKeys.isLocalizable("collections.mun.links.legend.title")); + assertFalse(LocalizableKeys.isLocalizable("default.collections.links.legend.title")); + } + + @Test + public void testUnrelatedConfigIsRejected() { + assertFalse(LocalizableKeys.isLocalizable("db.url")); + assertFalse(LocalizableKeys.isLocalizable("getfeatures.limit.max")); + assertFalse(LocalizableKeys.isLocalizable("api.version")); + assertFalse(LocalizableKeys.isLocalizable(null)); + assertFalse(LocalizableKeys.isLocalizable("")); + } + + /** + * A placeholder stands for exactly one non-empty segment, and the key must + * match wholly: neither a dotted nor an empty collection id, nor a prefix or + * suffix of a whitelisted key, is localizable. + */ + @Test + public void testKeyMustMatchWhollyWithOneNonEmptySegmentPerPlaceholder() { + assertFalse(LocalizableKeys.isLocalizable("collections.a.b.title")); + assertFalse(LocalizableKeys.isLocalizable("collections..title")); + assertFalse(LocalizableKeys.isLocalizable("collections.mun.title.extra")); + assertFalse(LocalizableKeys.isLocalizable("xapi.title")); + } +} diff --git a/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizationTest.java b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizationTest.java new file mode 100644 index 00000000..40a54229 --- /dev/null +++ b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizationTest.java @@ -0,0 +1,147 @@ +package fi.nls.hakunapi.core.i18n; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +public class LocalizationTest { + + private static Localization l10n() { + Map> catalogs = new LinkedHashMap<>(); + catalogs.put("fi", Map.of("api.title", "Otsikko")); + // sv is declared with no catalog at all + return new Localization(Arrays.asList("en", "fi", "sv"), catalogs, LocalizationTest::base); + } + + private static String base(String key) { + switch (key) { + case "api.title": + return "Base title"; + case "api.description": + return "Base description"; + default: + return null; + } + } + + @Test + public void testDeclaredOrderIsPreservedAndFirstIsDefault() { + Localization l = l10n(); + + assertEquals(Arrays.asList("en", "fi", "sv"), l.getLanguages()); + assertEquals("en", l.getDefaultLanguage()); + assertFalse(l.isEmpty()); + } + + @Test + public void testCatalogWins() { + assertEquals("Otsikko", l10n().get("fi", "api.title")); + } + + @Test + public void testFallsBackToBasePerKey() { + // fi has a catalog but no entry for this key + assertEquals("Base description", l10n().get("fi", "api.description")); + } + + /** + * A declared language with no catalog is served entirely from the base + * config. That is how the base representation's language is made explicit so + * Content-Language and hreflang can be stated rather than guessed. + */ + @Test + public void testLanguageWithNoCatalogReadsTheBaseConfig() { + Localization l = l10n(); + + assertEquals("Base title", l.get("en", "api.title")); + assertEquals("Base title", l.get("sv", "api.title")); + } + + @Test + public void testUnknownKeyIsAbsent() { + assertNull(l10n().get("fi", "api.nonexistent")); + } + + @Test + public void testNullLangReadsTheBaseConfigOnly() { + assertEquals("Base title", l10n().get(null, "api.title")); + } + + @Test + public void testUnknownLangReadsTheBaseConfigOnly() { + assertEquals("Base title", l10n().get("de", "api.title")); + } + + /** + * The explicit-fallback overload is for callers that already hold the base + * value, such as a title read off a FeatureType, and must not consult the + * base config lookup. + */ + @Test + public void testExplicitFallbackOverloadIgnoresTheBaseConfig() { + Localization l = l10n(); + + assertEquals("Otsikko", l.get("fi", "api.title", "caller default")); + assertEquals("caller default", l.get("fi", "api.description", "caller default")); + assertNull(l.get("fi", "api.description", null)); + assertEquals("caller default", l.get(null, "api.title", "caller default")); + } + + @Test + public void testHasExplicitDistinguishesCatalogFromFallback() { + Localization l = l10n(); + + assertTrue(l.hasExplicit("fi", "api.title")); + assertFalse(l.hasExplicit("fi", "api.description")); + assertFalse(l.hasExplicit("en", "api.title")); + assertFalse(l.hasExplicit(null, "api.title")); + assertFalse(l.hasExplicit("de", "api.title")); + } + + @Test + public void testEmpty() { + assertTrue(Localization.EMPTY.isEmpty()); + assertEquals(Collections.emptyList(), Localization.EMPTY.getLanguages()); + assertNull(Localization.EMPTY.getDefaultLanguage()); + assertNull(Localization.EMPTY.get("fi", "api.title")); + } + + /** + * Defensive copies: a Localization handed to a running service must not be + * mutable through the maps the caller kept. + */ + @Test + public void testDefensivelyCopiesItsInputs() { + List langs = new java.util.ArrayList<>(List.of("fi")); + Map catalog = new LinkedHashMap<>(Map.of("api.title", "Otsikko")); + Map> catalogs = new LinkedHashMap<>(); + catalogs.put("fi", catalog); + + Localization l = new Localization(langs, catalogs, LocalizationTest::base); + + langs.add("sv"); + catalog.put("api.title", "Muutettu"); + catalogs.remove("fi"); + + assertEquals(Arrays.asList("fi"), l.getLanguages()); + assertEquals("Otsikko", l.get("fi", "api.title")); + + try { + l.getLanguages().add("de"); + fail("Expected the returned language list to be immutable"); + } catch (UnsupportedOperationException expected) { + // as intended + } + } + +} diff --git a/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureServiceConfigTest.java b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureServiceConfigTest.java new file mode 100644 index 00000000..e8eb988d --- /dev/null +++ b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureServiceConfigTest.java @@ -0,0 +1,330 @@ +package fi.nls.hakunapi.core.i18n; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; +import java.util.Set; + +import org.junit.Test; + +import java.time.Instant; + +import fi.nls.hakunapi.core.DatetimeProperty; +import fi.nls.hakunapi.core.FeatureProducer; +import fi.nls.hakunapi.core.FeatureServiceConfig; +import fi.nls.hakunapi.core.PaginationStrategy; +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.param.GetFeatureParam; +import fi.nls.hakunapi.core.projection.ProjectionTransformerFactory; +import fi.nls.hakunapi.core.property.HakunaProperty; +import fi.nls.hakunapi.core.property.simple.HakunaPropertyGeometry; +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.OutputFormat; +import io.swagger.v3.oas.models.info.Info; + +public class LocalizedFeatureServiceConfigTest { + + /** + * The wrapper must delegate every inherited method explicitly and copy no + * protected fields. A method left undelegated would read the wrapper's own + * always-null field instead of the real service's value, which fails + * silently rather than loudly, so it is checked mechanically here. + */ + @Test + public void testEveryInheritedConcreteMethodIsOverridden() { + Set overridden = new TreeSet<>(); + for (Method m : LocalizedFeatureServiceConfig.class.getDeclaredMethods()) { + overridden.add(signature(m)); + } + + List missing = new ArrayList<>(); + for (Method m : FeatureServiceConfig.class.getDeclaredMethods()) { + int mod = m.getModifiers(); + if (Modifier.isStatic(mod) || Modifier.isAbstract(mod)) { + continue; + } + if (!Modifier.isPublic(mod) && !Modifier.isProtected(mod)) { + continue; + } + if (!overridden.contains(signature(m))) { + missing.add(signature(m)); + } + } + + assertTrue("LocalizedFeatureServiceConfig does not delegate: " + missing, missing.isEmpty()); + } + + private static String signature(Method m) { + StringBuilder sb = new StringBuilder(m.getName()).append('('); + Class[] params = m.getParameterTypes(); + for (int i = 0; i < params.length; i++) { + if (i > 0) { + sb.append(','); + } + sb.append(params[i].getName()); + } + return sb.append(')').toString(); + } + + @Test + public void testNotLocalizedWithoutLocalization() { + TestServiceConfig service = service(); + assertSame(service, service.localized("fi")); + assertSame(service, service.localized(null)); + assertEquals(Collections.emptyList(), service.getLanguages()); + } + + @Test + public void testUnknownLanguageReturnsBaseService() { + TestServiceConfig service = service(); + service.setLocalization(localization(Map.of("api.title", "Otsikko"))); + assertSame(service, service.localized("de")); + assertSame(service, service.localized(null)); + } + + @Test + public void testLocalizedTitleAndDescription() { + TestServiceConfig service = service(); + service.setLocalization(localization(Map.of("api.title", "Otsikko"))); + + FeatureServiceConfig fi = service.localized("fi"); + assertEquals("Otsikko", fi.getTitle()); + // sparse catalog: description falls back to the base config + assertEquals("Base description", fi.getDescription()); + // the base service is untouched + assertEquals("Base title", service.getTitle()); + } + + + @Test + public void testLocalizedDoesNotStackWrappers() { + TestServiceConfig service = service(); + service.setLocalization(new Localization(Arrays.asList("en", "fi"), + Map.of("fi", Map.of("api.title", "Otsikko")), service::baseValue)); + + FeatureServiceConfig fi = service.localized("fi"); + FeatureServiceConfig en = fi.localized("en"); + assertSame(service.localized("en"), en); + assertEquals("Base title", en.getTitle()); + } + + @Test + public void testSetLocalizationInvalidatesCache() { + TestServiceConfig service = service(); + service.setLocalization(localization(Map.of("api.title", "Otsikko"))); + FeatureServiceConfig before = service.localized("fi"); + assertEquals("Otsikko", before.getTitle()); + + service.setLocalization(localization(Map.of("api.title", "Uusi"))); + FeatureServiceConfig after = service.localized("fi"); + assertEquals("Uusi", after.getTitle()); + } + + @Test + public void testDelegatedMethodReadsRealServiceState() { + TestServiceConfig service = service(); + service.setLimitDefault(123); + service.setLocalization(localization(Map.of("api.title", "Otsikko"))); + + // Would return 0 off the wrapper's own field if delegation were missing + assertEquals(123, service.localized("fi").getLimitDefault()); + } + + @Test + public void testCollectionsAreWrapped() { + TestServiceConfig service = service(); + service.setLocalization(localization(Map.of("collections.test.title", "Testi"))); + + FeatureServiceConfig fi = service.localized("fi"); + FeatureType ft = fi.getCollection("test"); + assertNotNull(ft); + assertEquals("Testi", ft.getTitle()); + assertEquals("test", ft.getName()); + assertEquals(1, fi.getCollections().size()); + assertEquals("Testi", fi.getCollections().iterator().next().getTitle()); + + assertNull(fi.getCollection("nope")); + // base service unaffected + assertEquals("Base collection title", service.getCollection("test").getTitle()); + } + + /** + * Identity comparisons must survive a wrapper reaching a call site that is + * about feature-type identity rather than display. + */ + @Test + public void testWrappedFeatureTypeEqualsUnwrapped() { + TestServiceConfig service = service(); + service.setLocalization(localization(Map.of("collections.test.title", "Testi"))); + + FeatureType raw = service.getCollection("test"); + FeatureType wrapped = service.localized("fi").getCollection("test"); + + assertEquals(wrapped, raw); + assertEquals(raw.hashCode(), wrapped.hashCode()); + assertEquals(wrapped, service.localized("fi").getCollection("test")); + } + + private static TestServiceConfig service() { + TestServiceConfig service = new TestServiceConfig(); + Info info = new Info(); + info.setTitle("Base title"); + info.setDescription("Base description"); + service.setInfo(info); + return service; + } + + private static Localization localization(Map fiCatalog) { + return new Localization(Arrays.asList("fi"), Map.of("fi", fiCatalog), + key -> baseConfig(key)); + } + + private static String baseConfig(String key) { + switch (key) { + case "api.title": + return "Base title"; + case "api.description": + return "Base description"; + default: + return null; + } + } + + private static class TestServiceConfig extends FeatureServiceConfig { + + private final FeatureType collection = new TestFeatureType(); + + String baseValue(String key) { + return baseConfig(key); + } + + @Override + public Collection getCollections() { + return List.of(collection); + } + + @Override + public FeatureType getCollection(String name) { + return "test".equals(name) ? collection : null; + } + + @Override + public OutputFormat getOutputFormat(String f) { + return null; + } + + @Override + public Collection getOutputFormats() { + return Collections.emptyList(); + } + + } + + /** Minimal FeatureType; only the display-related methods are exercised. */ + private static class TestFeatureType implements FeatureType { + + @Override + public String getName() { + return "test"; + } + + @Override + public String getNS() { + return null; + } + + @Override + public String getSchemaLocation() { + return null; + } + + @Override + public String getTitle() { + return "Base collection title"; + } + + @Override + public String getDescription() { + return "Base collection description"; + } + + @Override + public Map getMetadata() { + return Collections.emptyMap(); + } + + @Override + public HakunaProperty getId() { + return null; + } + + @Override + public HakunaPropertyGeometry getGeom() { + return null; + } + + @Override + public List getProperties() { + return Collections.emptyList(); + } + + @Override + public List getQueryableProperties() { + return Collections.emptyList(); + } + + @Override + public List getDatetimeProperties() { + return Collections.emptyList(); + } + + @Override + public double[] getSpatialExtent() { + return null; + } + + @Override + public Instant[] getTemporalExtent() { + return null; + } + + @Override + public List getParameters() { + return Collections.emptyList(); + } + + @Override + public List getStaticFilters() { + return Collections.emptyList(); + } + + @Override + public ProjectionTransformerFactory getProjectionTransformerFactory() { + return null; + } + + @Override + public PaginationStrategy getPaginationStrategy() { + return null; + } + + @Override + public FeatureProducer getFeatureProducer() { + return null; + } + + } + +} diff --git a/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureTypeTest.java b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureTypeTest.java new file mode 100644 index 00000000..886586f3 --- /dev/null +++ b/src/hakunapi-core/src/test/java/fi/nls/hakunapi/core/i18n/LocalizedFeatureTypeTest.java @@ -0,0 +1,286 @@ +package fi.nls.hakunapi.core.i18n; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import fi.nls.hakunapi.core.DatetimeProperty; +import fi.nls.hakunapi.core.FeatureProducer; +import fi.nls.hakunapi.core.FeatureType; +import fi.nls.hakunapi.core.PaginationStrategy; +import fi.nls.hakunapi.core.filter.Filter; +import fi.nls.hakunapi.core.param.GetFeatureParam; +import fi.nls.hakunapi.core.projection.ProjectionTransformerFactory; +import fi.nls.hakunapi.core.property.HakunaProperty; +import fi.nls.hakunapi.core.property.simple.HakunaPropertyGeometry; +import fi.nls.hakunapi.core.schemas.Link; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; + +public class LocalizedFeatureTypeTest { + + private static final String COLLECTION = "addresses"; + + /** + * Title and description resolve catalog, then schema file, then base config: + * an explicit localization beats metadata derived from a schema, and a + * schema for another language is not picked up at all. + */ + @Test + public void testTitleAndDescriptionResolutionOrder() { + FeatureType catalogWins = wrap("fi", + catalog("collections.addresses.title", "Osoitteet"), + schema("fi", "Osoitteet schemasta", "Kuvaus schemasta")); + assertEquals("Osoitteet", catalogWins.getTitle()); + // no catalog entry for the description, so the schema file wins + assertEquals("Kuvaus schemasta", catalogWins.getDescription()); + + FeatureType schemaWins = wrap("fi", catalog(), schema("fi", "Osoitteet schemasta", null)); + assertEquals("Osoitteet schemasta", schemaWins.getTitle()); + // neither catalog nor schema has a description, so the base value stands + assertEquals("Base description", schemaWins.getDescription()); + + FeatureType noSource = wrap("fi", catalog(), Collections.emptyMap()); + assertEquals("Base title", noSource.getTitle()); + assertEquals("Base description", noSource.getDescription()); + + FeatureType otherLang = wrap("fi", catalog(), schema("sv", "Adresser", "Beskrivning")); + assertEquals("Base title", otherLang.getTitle()); + assertEquals("Base description", otherLang.getDescription()); + } + + /** + * A schema is matched the same way a request's language is: exactly, then by + * primary subtag, so a "sv" schema serves a locale declared as "sv-fi". + */ + @Test + public void testSchemaMatchesByPrimarySubtag() { + FeatureType ft = wrap("sv-fi", catalog(), schema("sv", "Adresser", "Beskrivning")); + + assertEquals("Adresser", ft.getTitle()); + assertEquals("Beskrivning", ft.getDescription()); + } + + /** + * Link titles are not localized: a translated title on a link whose href + * points elsewhere would promise a language the target may not have. A + * config declares one link per language instead, with its own hreflang. + */ + @Test + public void testLinksArePassedThroughUntouched() { + TestFeatureType base = new TestFeatureType(); + base.links = List.of(new Link("http://example.org/legend", "describedby", + "application/xml", "Legend", "en")); + + FeatureType ft = wrap("fi", base, + catalog("collections.addresses.links.legend.title", "Karttaselite"), + Collections.emptyMap()); + + List links = ft.getAdditionalLinks(); + assertSame(base.links, links); + assertEquals("Legend", links.get(0).getTitle()); + } + + /* Delegation and identity */ + + /** + * Only title, description and link titles are localized; getLangToSchema in + * particular passes the map through rather than resolving it. + */ + @Test + public void testEverythingElseDelegates() { + TestFeatureType base = new TestFeatureType(); + FeatureType ft = wrap("fi", base, catalog("collections.addresses.title", "Osoitteet"), + Collections.emptyMap()); + + assertEquals(COLLECTION, ft.getName()); + assertEquals(base.getMetadata(), ft.getMetadata()); + assertEquals(base.getProperties(), ft.getProperties()); + assertEquals(base.getQueryableProperties(), ft.getQueryableProperties()); + assertNull(ft.getGeom()); + assertNull(ft.getNS()); + assertTrue(ft.getAdditionalLinks().isEmpty()); + + Map> schemas = schema("fi", "T", "D"); + assertEquals(schemas.keySet(), wrap("fi", catalog(), schemas).getLangToSchema().keySet()); + } + + /** + * Nothing in the codebase compares feature types by identity, but a wrapper + * reaching such a call site must not break it. + */ + @Test + public void testUnwrapEqualsAndHashCodeFollowTheWrappedInstance() { + TestFeatureType base = new TestFeatureType(); + Localization l10n = localization(catalog(), key -> null); + LocalizedFeatureType a = new LocalizedFeatureType(base, "fi", l10n); + LocalizedFeatureType b = new LocalizedFeatureType(base, "sv", l10n); + + assertSame(base, a.unwrap()); + assertEquals("fi", a.getLang()); + + assertEquals(base.hashCode(), a.hashCode()); + assertEquals(a, base); + assertEquals(a, b); + assertEquals(a, a); + assertEquals(base.toString(), a.toString()); + } + + /* Helpers */ + + private static FeatureType wrap(String lang, Map catalog, Map> schemas) { + TestFeatureType base = new TestFeatureType(); + base.langToSchema = schemas; + return wrap(lang, base, catalog, schemas); + } + + private static FeatureType wrap(String lang, TestFeatureType base, Map catalog, + Map> schemas) { + base.langToSchema = schemas; + return new LocalizedFeatureType(base, lang, localization(catalog, LocalizedFeatureTypeTest::baseConfig)); + } + + private static Localization localization(Map catalog, + java.util.function.Function base) { + return new Localization(Arrays.asList("fi"), Map.of("fi", catalog), base); + } + + private static String baseConfig(String key) { + return null; + } + + private static Map catalog(String... kvp) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kvp.length; i += 2) { + m.put(kvp[i], kvp[i + 1]); + } + return m; + } + + private static Map> schema(String lang, String title, String description) { + ObjectSchema s = new ObjectSchema(); + s.setTitle(title); + s.setDescription(description); + return Map.of(lang, s); + } + + /** Minimal FeatureType; only the display-related methods matter here. */ + private static class TestFeatureType implements FeatureType { + + private List links = Collections.emptyList(); + private Map> langToSchema = Collections.emptyMap(); + + @Override + public String getName() { + return COLLECTION; + } + + @Override + public String getNS() { + return null; + } + + @Override + public String getSchemaLocation() { + return null; + } + + @Override + public String getTitle() { + return "Base title"; + } + + @Override + public String getDescription() { + return "Base description"; + } + + @Override + public Map getMetadata() { + return Map.of("k", "v"); + } + + @Override + public HakunaProperty getId() { + return null; + } + + @Override + public HakunaPropertyGeometry getGeom() { + return null; + } + + @Override + public List getProperties() { + return Collections.emptyList(); + } + + @Override + public List getQueryableProperties() { + return Collections.emptyList(); + } + + @Override + public List getDatetimeProperties() { + return Collections.emptyList(); + } + + @Override + public double[] getSpatialExtent() { + return null; + } + + @Override + public Instant[] getTemporalExtent() { + return null; + } + + @Override + public List getParameters() { + return Collections.emptyList(); + } + + @Override + public List getStaticFilters() { + return Collections.emptyList(); + } + + @Override + public ProjectionTransformerFactory getProjectionTransformerFactory() { + return null; + } + + @Override + public PaginationStrategy getPaginationStrategy() { + return null; + } + + @Override + public FeatureProducer getFeatureProducer() { + return null; + } + + @Override + public List getAdditionalLinks() { + return links; + } + + @Override + public Map> getLangToSchema() { + return langToSchema; + } + + } + +} diff --git a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLFeatureCollectionWriter.java b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLFeatureCollectionWriter.java index 471e9b2f..b1d1287c 100644 --- a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLFeatureCollectionWriter.java +++ b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLFeatureCollectionWriter.java @@ -56,7 +56,9 @@ public void startFeatureCollection(FeatureType ft, String layername) throws Exce this.model = new HTMLFeatureCollection(); model.setSrid(srid); model.setSridCode(sridCode); - model.setFeatureType(ft); + model.setFeatureType(forDisplay(ft)); + model.setLang(lang); + model.setAvailableLanguages(availableLanguages); OutputFormatHTMLSettings sridSpecific = sridSettings.getOrDefault(srid, settings); model.setSettings(sridSpecific); Template template = getTemplate(ft, srid); diff --git a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLFeatureWriterBase.java b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLFeatureWriterBase.java index 63648abe..1b26f64b 100644 --- a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLFeatureWriterBase.java +++ b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLFeatureWriterBase.java @@ -6,8 +6,11 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.LocalDate; +import java.util.Collections; import java.util.List; +import java.util.function.Function; +import fi.nls.hakunapi.core.FeatureType; import fi.nls.hakunapi.core.FeatureWriter; import fi.nls.hakunapi.core.FloatingPointFormatter; import fi.nls.hakunapi.core.GeometryWriter; @@ -43,6 +46,10 @@ public abstract class HTMLFeatureWriterBase implements FeatureWriter { protected int srid; protected SRIDCode sridCode; + protected String lang; + protected List availableLanguages = Collections.emptyList(); + protected Function localizer = ft -> ft; + @Deprecated public HTMLFeatureWriterBase(Configuration configuration) { this(configuration, new OutputFormatHTMLSettings()); @@ -53,6 +60,25 @@ public HTMLFeatureWriterBase(Configuration configuration, OutputFormatHTMLSettin this.settings = settings; } + public void setLanguage(String lang, List availableLanguages) { + setLanguage(lang, availableLanguages, ft -> ft); + } + + public void setLanguage(String lang, List availableLanguages, Function localizer) { + this.lang = lang; + this.availableLanguages = availableLanguages == null ? Collections.emptyList() + : List.copyOf(availableLanguages); + this.localizer = localizer == null ? ft -> ft : localizer; + } + + /** + * @return the FeatureType to display, localized when a resolver was supplied + */ + protected FeatureType forDisplay(FeatureType ft) { + FeatureType localized = localizer.apply(ft); + return localized == null ? ft : localized; + } + @Override public void init(OutputStream out, SRIDCode srid) { this.srid = srid.getSrid(); diff --git a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLSingleFeatureWriter.java b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLSingleFeatureWriter.java index d4e4a9e0..2383df48 100644 --- a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLSingleFeatureWriter.java +++ b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/HTMLSingleFeatureWriter.java @@ -59,7 +59,9 @@ public void startFeature(FeatureType ft, String layername, long fid) throws Exce public void startFeature(FeatureType ft, String layername, String fid) throws Exception { this.feature = new HTMLFeature(); this.feature.setId(fid); - this.feature.setFeatureType(ft); + this.feature.setFeatureType(forDisplay(ft)); + this.feature.setLang(lang); + this.feature.setAvailableLanguages(availableLanguages); this.feature.setSrid(srid); this.feature.setSridCode(sridCode); OutputFormatHTMLSettings sridSpecific = sridSettings.getOrDefault(srid, settings); diff --git a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLContext.java b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLContext.java index 7b385836..79e2eb53 100644 --- a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLContext.java +++ b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLContext.java @@ -1,5 +1,8 @@ package fi.nls.hakunapi.html.model; +import java.util.Collections; +import java.util.List; + import fi.nls.hakunapi.core.FeatureServiceConfig; public class HTMLContext { @@ -8,12 +11,26 @@ public class HTMLContext { private final String basePath; private final String basePathTrailingSlash; private final T model; + private final HTMLLanguageContext language; public HTMLContext(FeatureServiceConfig service, String basePath, T model) { + this(service, basePath, model, null, Collections.emptyList()); + } + + /** + * @param lang resolved language, null if this resource is not localized + * @param availableLanguages every language this resource can be served in, + * including lang; templates render the others as alternates + */ + public HTMLContext(FeatureServiceConfig service, String basePath, T model, String lang, + List availableLanguages) { this.service = service; this.basePath = basePath; this.basePathTrailingSlash = basePath + (basePath.endsWith("/") ? "" : "/"); this.model = model; + this.language = new HTMLLanguageContext(); + this.language.setLang(lang); + this.language.setAvailableLanguages(availableLanguages); } public FeatureServiceConfig getService() { @@ -32,4 +49,29 @@ public T getModel() { return model; } + /** + * @return the language chrome for the templates: resolved language, + * switchable languages and the query fragment carrying it + */ + public HTMLLanguageContext getLanguage() { + return language; + } + + /** @return shorthand for getLanguage().getLang(), for the html lang attribute */ + public String getLang() { + return language.getLang(); + } + + public List getAvailableLanguages() { + return language.getAvailableLanguages(); + } + + public List getAlternateLanguages() { + return language.getAlternateLanguages(); + } + + public String getLangQuery() { + return language.getLangQuery(); + } + } diff --git a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLFeature.java b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLFeature.java index e0bcec11..468de7b7 100644 --- a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLFeature.java +++ b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLFeature.java @@ -1,6 +1,8 @@ package fi.nls.hakunapi.html.model; import java.util.LinkedHashMap; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -104,4 +106,40 @@ public void setSettings(OutputFormatHTMLSettings settings) { this.settings = settings; } + + private final HTMLLanguageContext language = new HTMLLanguageContext(); + + /** + * @return the language chrome for the templates: resolved language, + * switchable languages and the query fragment carrying it + */ + public HTMLLanguageContext getLanguage() { + return language; + } + + public void setLang(String lang) { + language.setLang(lang); + } + + public void setAvailableLanguages(List availableLanguages) { + language.setAvailableLanguages(availableLanguages); + } + + /** @return shorthand for getLanguage().getLang(), for the html lang attribute */ + public String getLang() { + return language.getLang(); + } + + public List getAvailableLanguages() { + return language.getAvailableLanguages(); + } + + public List getAlternateLanguages() { + return language.getAlternateLanguages(); + } + + public String getLangQuery() { + return language.getLangQuery(); + } + } diff --git a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLFeatureCollection.java b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLFeatureCollection.java index dbc88322..71d309cd 100644 --- a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLFeatureCollection.java +++ b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLFeatureCollection.java @@ -1,6 +1,7 @@ package fi.nls.hakunapi.html.model; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import fi.nls.hakunapi.core.FeatureType; @@ -88,4 +89,40 @@ public void setSettings(OutputFormatHTMLSettings settings) { this.settings = settings; } + + private final HTMLLanguageContext language = new HTMLLanguageContext(); + + /** + * @return the language chrome for the templates: resolved language, + * switchable languages and the query fragment carrying it + */ + public HTMLLanguageContext getLanguage() { + return language; + } + + public void setLang(String lang) { + language.setLang(lang); + } + + public void setAvailableLanguages(List availableLanguages) { + language.setAvailableLanguages(availableLanguages); + } + + /** @return shorthand for getLanguage().getLang(), for the html lang attribute */ + public String getLang() { + return language.getLang(); + } + + public List getAvailableLanguages() { + return language.getAvailableLanguages(); + } + + public List getAlternateLanguages() { + return language.getAlternateLanguages(); + } + + public String getLangQuery() { + return language.getLangQuery(); + } + } diff --git a/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLLanguageContext.java b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLLanguageContext.java new file mode 100644 index 00000000..68623571 --- /dev/null +++ b/src/hakunapi-html/src/main/java/fi/nls/hakunapi/html/model/HTMLLanguageContext.java @@ -0,0 +1,54 @@ +package fi.nls.hakunapi.html.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import fi.nls.hakunapi.core.param.LangParam; + +/** + * The language chrome a template needs. Shared by HTMLContext and the feature + * models, whose templates read the model at top level rather than through + * ${model.} and so need these accessors themselves. + */ +public class HTMLLanguageContext { + + private String lang; + private List availableLanguages = Collections.emptyList(); + + /** @return the resolved language, or null when the service is not localized */ + public String getLang() { + return lang; + } + + public void setLang(String lang) { + this.lang = lang; + } + + /** @return every language this resource can be served in, in declared order */ + public List getAvailableLanguages() { + return availableLanguages; + } + + public void setAvailableLanguages(List availableLanguages) { + this.availableLanguages = availableLanguages == null ? Collections.emptyList() + : List.copyOf(availableLanguages); + } + + /** @return the other languages this resource can be served in */ + public List getAlternateLanguages() { + List alternates = new ArrayList<>(); + for (String available : availableLanguages) { + if (!available.equals(lang)) { + alternates.add(available); + } + } + return alternates; + } + + /** @return "?lang=fi", or "" when the service is not localized */ + public String getLangQuery() { + return lang == null ? "" : "?" + LangParam.PARAM_NAME + "=" + lang; + } + +} diff --git a/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/api.ftl b/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/api.ftl index 4f84d7be..ec8354c1 100644 --- a/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/api.ftl +++ b/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/api.ftl @@ -1,5 +1,5 @@ - + Swagger UI diff --git a/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/collectionInfo.ftl b/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/collectionInfo.ftl index fe6a919f..b7583a40 100644 --- a/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/collectionInfo.ftl +++ b/src/hakunapi-html/src/main/resources/fi/nls/hakunapi/html/collectionInfo.ftl @@ -1,5 +1,5 @@ - + @@ -12,11 +12,21 @@
-

${(model.title)!(model.id)}

-

${model.description!""}

+

${(model.title)!(model.id)}

+ ${model.id} +

${model.description!""}

Items

-

Browse features in the collection

+

Browse features in the collection

<#if model.crs??>

Supported Coordinate Reference Systems

@@ -51,9 +62,14 @@

Queryable properties

-

Find out properties usable in filters

+

Find out properties usable in filters

+ +

Schema

+

JSON Schema describing the features

<#assign additionalLinks = model.links?filter(link -> + link.rel != "alternate" && + link.rel != "self" && !link.href?split("?")?first?ends_with("/items") && !link.href?split("?")?first?ends_with("/schema") && !link.href?split("?")?first?ends_with("/queryables")) /> @@ -71,6 +87,17 @@ const url = new URL(window.location.href); url.searchParams.set('f', 'json'); document.getElementById("json-link").href = url.toString(); + +// Navigating from the picker keeps every other query parameter, so switching +// language does not drop f, bbox, limit and the like +const langSelect = document.getElementById("lang-select"); +if (langSelect) { + langSelect.addEventListener("change", function () { + const target = new URL(window.location.href); + target.searchParams.set('lang', this.value); + window.location.assign(target.toString()); + }); +} 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 61fdf291..f2e38730 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 @@ -1,5 +1,5 @@ - + @@ -12,10 +12,20 @@