From 6e26b964b34288a9f5477e821ab0d4c7f944ea00 Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Wed, 22 Oct 2025 13:54:53 +0200 Subject: [PATCH 01/18] Update readme with new env variables --- api/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/api/README.md b/api/README.md index b318be44..98285b6a 100644 --- a/api/README.md +++ b/api/README.md @@ -12,6 +12,26 @@ Environment variables that can be used to configure the container or the environ | GUNICORN_CMD_ARGS | Command-line arguments for configuring Gunicorn, a Python WSGI HTTP Server. | ☐ | | CORS_ORIGINS | Indicates whether the response can be shared with requesting code from the given origins (passed as a comma separated string) | ☐ | | CORS_HEADERS | Indicates what headers should be supported with cross-origin requests (passed as a comma separated string) | ☐ | +| JINJA2_TEMPLATES | Path to a folder with jinja2 templates to override the default templates used by the API. See template section for details | ☐ | +| OPENAPI_METADATA_PATH | Path to an alternative OpenAPI metadata json file. It need to have the same fields as the openapi/openapi_metadata.py file. | ☐ | + +## JINJA2_TEMPLATES + +The jinja2 folder must container the following files: + +- dataset_metadata_template.j2: this is the metadata template for the observation collection. + +### dataset_metadata_template.j2 + +The current jinja2 filters will be replaced. It's important to just the json j2 filter when inserting these strings. + +- spatial_extent +- temporal_extent +- url_base +- url_conformance +- url_docs + +All url fields are dynamically generated based on the request url base. ## Prerequisites of running locally From 6e1c55d4ad139721f3a243796a3711c099ef43bc Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Wed, 22 Oct 2025 13:55:39 +0200 Subject: [PATCH 02/18] Add configurable openapi metadata and j2 dataset metadata template --- api/openapi/openapi_metadata.py | 48 ++++++++++++++++++++++----------- api/routers/feature.py | 36 +++++++++++++++++++------ 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/api/openapi/openapi_metadata.py b/api/openapi/openapi_metadata.py index 52a1fff9..6bfbb14a 100644 --- a/api/openapi/openapi_metadata.py +++ b/api/openapi/openapi_metadata.py @@ -1,16 +1,32 @@ -openapi_metadata = { - "title": "EDR Observations API Europe EUMETNET", - "description": ( - "OGC EDR API data service for European meteorological observations from EUMETNET," - " co-funded by the European Union." - ), - "contact": { - "name": "EUMETNET", - "url": "https://www.eumetnet.eu/about-us/", - "email": "eucos@metoffice.gov.uk", - }, - "license_info": { - "name": "CC-BY-4.0", - "url": "https://creativecommons.org/licenses/by/4.0/", - }, -} +import os +import json + +from functools import cache + + +@cache +def get_openapi_metadata(): + with open(os.environ["OPENAPI_METADATA_PATH"], "r") as f: + openapi_metadata = json.load(f) + return openapi_metadata + + +if "OPENAPI_METADATA_PATH" in os.environ: + openapi_metadata = get_openapi_metadata() +else: + openapi_metadata = { + "title": "EDR Observations API Europe EUMETNET", + "description": ( + "OGC EDR API data service for European meteorological observations from EUMETNET," + " co-funded by the European Union." + ), + "contact": { + "name": "EUMETNET", + "url": "https://www.eumetnet.eu/about-us/", + "email": "eucos@metoffice.gov.uk", + }, + "license_info": { + "name": "CC-BY-4.0", + "url": "https://creativecommons.org/licenses/by/4.0/", + }, + } diff --git a/api/routers/feature.py b/api/routers/feature.py index 4a941284..b9858c8a 100644 --- a/api/routers/feature.py +++ b/api/routers/feature.py @@ -1,4 +1,5 @@ import json +import os from typing import Annotated import datastore_pb2 as dstore @@ -23,7 +24,10 @@ router = APIRouter(prefix="/collections/observations") -env = Environment(loader=FileSystemLoader("templates"), autoescape=select_autoescape()) +env = Environment( + loader=FileSystemLoader(os.getenv("JINJA2_TEMPLATES", "templates")), + autoescape=select_autoescape(), +) @router.get( @@ -60,21 +64,32 @@ async def search_timeseries( ] = None, institution: Annotated[ str | None, - Query(description="Institution that published the data", openapi_examples=openapi_examples.institution), + Query( + description="Institution that published the data", + openapi_examples=openapi_examples.institution, + ), ] = None, platform: Annotated[ str | None, - Query(description="Platform ID, WIGOS or WIGOS equivalent.", openapi_examples=openapi_examples.wigos_id), + Query( + description="Platform ID, WIGOS or WIGOS equivalent.", + openapi_examples=openapi_examples.wigos_id, + ), ] = None, standard_name: Annotated[ str | None, Query( - alias="standard-name", description="CF 1.9 standard name", openapi_examples=openapi_examples.standard_name + alias="standard-name", + description="CF 1.9 standard name", + openapi_examples=openapi_examples.standard_name, ), ] = None, unit: Annotated[ str | None, - Query(description="Unit of observed physical property", openapi_examples=openapi_examples.unit), + Query( + description="Unit of observed physical property", + openapi_examples=openapi_examples.unit, + ), ] = None, instrument: Annotated[str | None, Query(description="Instrument Id")] = None, level: Annotated[ @@ -86,12 +101,16 @@ async def search_timeseries( ] = None, period: Annotated[ str | None, - Query(description="Duration of collection period in ISO8601", openapi_examples=openapi_examples.period), + Query( + description="Duration of collection period in ISO8601", + openapi_examples=openapi_examples.period, + ), ] = None, method: Annotated[ str | None, Query( - description="Aggregation method used to sample observed property", openapi_examples=openapi_examples.method + description="Aggregation method used to sample observed property", + openapi_examples=openapi_examples.method, ), ] = None, f: Annotated[ @@ -149,7 +168,8 @@ async def get_time_series_by_id( ] = formatters.Metadata_Formats.geojson, ): obs_request = dstore.GetObsRequest( - filter=dict(timeseries_id=dstore.Strings(values=[item_id])), temporal_latest=True + filter=dict(timeseries_id=dstore.Strings(values=[item_id])), + temporal_latest=True, ) time_series = await get_obs_request(obs_request) From ff523cd27b828d470069663f3595c264f917b9e0 Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Mon, 27 Oct 2025 13:48:51 +0100 Subject: [PATCH 03/18] Explain how and where to mount new files --- api/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/README.md b/api/README.md index 98285b6a..0196e7e8 100644 --- a/api/README.md +++ b/api/README.md @@ -15,6 +15,10 @@ Environment variables that can be used to configure the container or the environ | JINJA2_TEMPLATES | Path to a folder with jinja2 templates to override the default templates used by the API. See template section for details | ☐ | | OPENAPI_METADATA_PATH | Path to an alternative OpenAPI metadata json file. It need to have the same fields as the openapi/openapi_metadata.py file. | ☐ | +## OpenAPI metadata + +To load your own metadata file, mount your new openapi metadata json file, with the same structure and key as the default one, found in openapi_default_files, and point the environment variable `OPENAPI_METADATA_PATH` to the new file. + ## JINJA2_TEMPLATES The jinja2 folder must container the following files: @@ -33,6 +37,8 @@ The current jinja2 filters will be replaced. It's important to just the json j2 All url fields are dynamically generated based on the request url base. +To load a custom template, mount a folder with your new template in to the container and set the `JINJA2_TEMPLATES` environment variable to point your new template. + ## Prerequisites of running locally ### QUDT From 5ae54dcb82932b2c9f74cd1782317f45e88945ec Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Mon, 27 Oct 2025 13:49:50 +0100 Subject: [PATCH 04/18] Move default openapi_metadata to a file, and just load the file as default --- api/openapi/openapi_metadata.py | 34 +++---------------- .../openapi_metadata.json | 13 +++++++ 2 files changed, 18 insertions(+), 29 deletions(-) create mode 100644 api/openapi_default_files/openapi_metadata.json diff --git a/api/openapi/openapi_metadata.py b/api/openapi/openapi_metadata.py index 6bfbb14a..b88e8cc2 100644 --- a/api/openapi/openapi_metadata.py +++ b/api/openapi/openapi_metadata.py @@ -1,32 +1,8 @@ import os import json -from functools import cache - - -@cache -def get_openapi_metadata(): - with open(os.environ["OPENAPI_METADATA_PATH"], "r") as f: - openapi_metadata = json.load(f) - return openapi_metadata - - -if "OPENAPI_METADATA_PATH" in os.environ: - openapi_metadata = get_openapi_metadata() -else: - openapi_metadata = { - "title": "EDR Observations API Europe EUMETNET", - "description": ( - "OGC EDR API data service for European meteorological observations from EUMETNET," - " co-funded by the European Union." - ), - "contact": { - "name": "EUMETNET", - "url": "https://www.eumetnet.eu/about-us/", - "email": "eucos@metoffice.gov.uk", - }, - "license_info": { - "name": "CC-BY-4.0", - "url": "https://creativecommons.org/licenses/by/4.0/", - }, - } +with open( + os.getenv("OPENAPI_METADATA_PATH", "./openapi_default_files/openapi_metadata.json"), + "r", +) as file: + openapi_metadata = json.load(file) diff --git a/api/openapi_default_files/openapi_metadata.json b/api/openapi_default_files/openapi_metadata.json new file mode 100644 index 00000000..a1131887 --- /dev/null +++ b/api/openapi_default_files/openapi_metadata.json @@ -0,0 +1,13 @@ +{ + "title": "EDR Observations API Europe EUMETNET", + "description": "OGC EDR API data service for European meteorological observations from EUMETNET, co-funded by the European Union.", + "contact": { + "name": "EUMETNET", + "url": "https://www.eumetnet.eu/about-us/", + "email": "eucos@metoffice.gov.uk" + }, + "license_info": { + "name": "CC-BY-4.0", + "url": "https://creativecommons.org/licenses/by/4.0/" + } +} From 0251c789960c0847dd879ea4e8541e8dc9cb6bcc Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Mon, 27 Oct 2025 14:25:40 +0100 Subject: [PATCH 05/18] Explain how and where to mount new files --- api/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/README.md b/api/README.md index 0196e7e8..54bbddfc 100644 --- a/api/README.md +++ b/api/README.md @@ -27,7 +27,7 @@ The jinja2 folder must container the following files: ### dataset_metadata_template.j2 -The current jinja2 filters will be replaced. It's important to just the json j2 filter when inserting these strings. +The current jinja2 filters will be replaced. It's important to use the json j2 filter when inserting these strings. - spatial_extent - temporal_extent @@ -37,7 +37,7 @@ The current jinja2 filters will be replaced. It's important to just the json j2 All url fields are dynamically generated based on the request url base. -To load a custom template, mount a folder with your new template in to the container and set the `JINJA2_TEMPLATES` environment variable to point your new template. +To load a custom template, mount a folder with your new template in to the container and set the `JINJA2_TEMPLATES` environment variable to point your new folder. ## Prerequisites of running locally From e667144bfe6709e153059fb1008e07b0350ed431 Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Mon, 27 Oct 2025 14:26:14 +0100 Subject: [PATCH 06/18] Add absolute path as default --- api/openapi/openapi_metadata.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/openapi/openapi_metadata.py b/api/openapi/openapi_metadata.py index b88e8cc2..3a8b5a87 100644 --- a/api/openapi/openapi_metadata.py +++ b/api/openapi/openapi_metadata.py @@ -2,7 +2,8 @@ import json with open( - os.getenv("OPENAPI_METADATA_PATH", "./openapi_default_files/openapi_metadata.json"), + os.getenv("OPENAPI_METADATA_PATH", "/app/openapi_default_files/openapi_metadata.json"), "r", ) as file: openapi_metadata = json.load(file) + print(openapi_metadata) From 65181ce372b364fe6aaf26c8ead04561e0fe0edb Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Mon, 27 Oct 2025 14:33:17 +0100 Subject: [PATCH 07/18] Update API README --- api/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/README.md b/api/README.md index 54bbddfc..21c5fb92 100644 --- a/api/README.md +++ b/api/README.md @@ -17,7 +17,7 @@ Environment variables that can be used to configure the container or the environ ## OpenAPI metadata -To load your own metadata file, mount your new openapi metadata json file, with the same structure and key as the default one, found in openapi_default_files, and point the environment variable `OPENAPI_METADATA_PATH` to the new file. +To load your own metadata file, mount your new openapi metadata json file, with the same structure and key as the default one, found in openapi_default_files, and point the environment variable `OPENAPI_METADATA_PATH` to the new file. If you mount your folder to the same as the default one, make sure to not overwrite files you have not replaced. ## JINJA2_TEMPLATES From 3a4f6f15e1c648ea4647c9731b88e35eedb0c239 Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Mon, 27 Oct 2025 14:40:25 +0100 Subject: [PATCH 08/18] Fix formatting --- api/routers/feature.py | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/api/routers/feature.py b/api/routers/feature.py index b9858c8a..25991d27 100644 --- a/api/routers/feature.py +++ b/api/routers/feature.py @@ -24,10 +24,7 @@ router = APIRouter(prefix="/collections/observations") -env = Environment( - loader=FileSystemLoader(os.getenv("JINJA2_TEMPLATES", "templates")), - autoescape=select_autoescape(), -) +env = Environment(loader=FileSystemLoader(os.getenv("JINJA2_TEMPLATES", "templates")), autoescape=select_autoescape()) @router.get( @@ -64,32 +61,21 @@ async def search_timeseries( ] = None, institution: Annotated[ str | None, - Query( - description="Institution that published the data", - openapi_examples=openapi_examples.institution, - ), + Query(description="Institution that published the data", openapi_examples=openapi_examples.institution), ] = None, platform: Annotated[ str | None, - Query( - description="Platform ID, WIGOS or WIGOS equivalent.", - openapi_examples=openapi_examples.wigos_id, - ), + Query(description="Platform ID, WIGOS or WIGOS equivalent.", openapi_examples=openapi_examples.wigos_id), ] = None, standard_name: Annotated[ str | None, Query( - alias="standard-name", - description="CF 1.9 standard name", - openapi_examples=openapi_examples.standard_name, + alias="standard-name", description="CF 1.9 standard name", openapi_examples=openapi_examples.standard_name ), ] = None, unit: Annotated[ str | None, - Query( - description="Unit of observed physical property", - openapi_examples=openapi_examples.unit, - ), + Query(description="Unit of observed physical property", openapi_examples=openapi_examples.unit), ] = None, instrument: Annotated[str | None, Query(description="Instrument Id")] = None, level: Annotated[ @@ -101,16 +87,12 @@ async def search_timeseries( ] = None, period: Annotated[ str | None, - Query( - description="Duration of collection period in ISO8601", - openapi_examples=openapi_examples.period, - ), + Query(description="Duration of collection period in ISO8601", openapi_examples=openapi_examples.period), ] = None, method: Annotated[ str | None, Query( - description="Aggregation method used to sample observed property", - openapi_examples=openapi_examples.method, + description="Aggregation method used to sample observed property", openapi_examples=openapi_examples.method ), ] = None, f: Annotated[ @@ -168,8 +150,7 @@ async def get_time_series_by_id( ] = formatters.Metadata_Formats.geojson, ): obs_request = dstore.GetObsRequest( - filter=dict(timeseries_id=dstore.Strings(values=[item_id])), - temporal_latest=True, + filter=dict(timeseries_id=dstore.Strings(values=[item_id])), temporal_latest=True ) time_series = await get_obs_request(obs_request) From 2a6800e531c37971d42dc7ebb002b95ff0ea0662 Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Tue, 2 Dec 2025 13:38:43 +0100 Subject: [PATCH 09/18] Update default j2 dataset template --- api/routers/feature.py | 37 +++- api/templates/dataset_metadata_template.j2 | 231 ++++++++++++--------- 2 files changed, 159 insertions(+), 109 deletions(-) diff --git a/api/routers/feature.py b/api/routers/feature.py index 25991d27..739f04be 100644 --- a/api/routers/feature.py +++ b/api/routers/feature.py @@ -24,7 +24,10 @@ router = APIRouter(prefix="/collections/observations") -env = Environment(loader=FileSystemLoader(os.getenv("JINJA2_TEMPLATES", "templates")), autoescape=select_autoescape()) +env = Environment( + loader=FileSystemLoader(os.getenv("JINJA2_TEMPLATES", "templates")), + autoescape=select_autoescape(), +) @router.get( @@ -61,21 +64,32 @@ async def search_timeseries( ] = None, institution: Annotated[ str | None, - Query(description="Institution that published the data", openapi_examples=openapi_examples.institution), + Query( + description="Institution that published the data", + openapi_examples=openapi_examples.institution, + ), ] = None, platform: Annotated[ str | None, - Query(description="Platform ID, WIGOS or WIGOS equivalent.", openapi_examples=openapi_examples.wigos_id), + Query( + description="Platform ID, WIGOS or WIGOS equivalent.", + openapi_examples=openapi_examples.wigos_id, + ), ] = None, standard_name: Annotated[ str | None, Query( - alias="standard-name", description="CF 1.9 standard name", openapi_examples=openapi_examples.standard_name + alias="standard-name", + description="CF 1.9 standard name", + openapi_examples=openapi_examples.standard_name, ), ] = None, unit: Annotated[ str | None, - Query(description="Unit of observed physical property", openapi_examples=openapi_examples.unit), + Query( + description="Unit of observed physical property", + openapi_examples=openapi_examples.unit, + ), ] = None, instrument: Annotated[str | None, Query(description="Instrument Id")] = None, level: Annotated[ @@ -87,12 +101,16 @@ async def search_timeseries( ] = None, period: Annotated[ str | None, - Query(description="Duration of collection period in ISO8601", openapi_examples=openapi_examples.period), + Query( + description="Duration of collection period in ISO8601", + openapi_examples=openapi_examples.period, + ), ] = None, method: Annotated[ str | None, Query( - description="Aggregation method used to sample observed property", openapi_examples=openapi_examples.method + description="Aggregation method used to sample observed property", + openapi_examples=openapi_examples.method, ), ] = None, f: Annotated[ @@ -150,7 +168,8 @@ async def get_time_series_by_id( ] = formatters.Metadata_Formats.geojson, ): obs_request = dstore.GetObsRequest( - filter=dict(timeseries_id=dstore.Strings(values=[item_id])), temporal_latest=True + filter=dict(timeseries_id=dstore.Strings(values=[item_id])), + temporal_latest=True, ) time_series = await get_obs_request(obs_request) @@ -177,7 +196,7 @@ async def get_dataset_metadata(request: Request): "temporal_extents": [ [ f"{extent.temporal_extent.start.ToDatetime().strftime('%Y-%m-%dT%H:%M:%SZ')}", - f"{extent.temporal_extent.end.ToDatetime().strftime('%Y-%m-%dT%H:%M:%SZ')}", + "..", ], ], "url_base": base_url, diff --git a/api/templates/dataset_metadata_template.j2 b/api/templates/dataset_metadata_template.j2 index aab0e420..f9b74aae 100644 --- a/api/templates/dataset_metadata_template.j2 +++ b/api/templates/dataset_metadata_template.j2 @@ -1,9 +1,9 @@ { - "id": "urn:wmo:md:eu-eumetnet-observations:swob-realtime", + "id": "urn:wmo:md:eu-eumetnet-surface-observations:land-station-observations", "conformsTo": [ - "http://wis.wmo.int/spec/wcmp/2/conf/core" - ], - "type": "Feature", + "http://wis.wmo.int/spec/wcmp/2/conf/core" + ], + "type": "Feature", "geometry": { "type": "Polygon", "coordinates": {{ spatial_extents }} @@ -12,105 +12,136 @@ "interval": {{ temporal_extents|tojson }}, "resolution": "PT10M" }, - "properties": { - "title": "Land surface weather observations", - "description": "Land surface observations measured at automatic and manual stations of EUMETNET Members (last 24 hours)", - "themes": [ - { - "concepts": [ - { - "id": "weather" - } - ], - "scheme": "https://codes.wmo.int/wis/topic-hierarchy/earth-system-discipline" - }, - { - "concepts": [ - { - "id": "surface-based-observations" - } - ], - "scheme": "https://codes.wmo.int/wis/topic-hierarchy/earth-system-discipline/_weather" - } +"properties": { + "title": "Land surface weather observations", + "description":"Land surface observations measured at automatic and manual weather stations of EUMETNET Members and thier trusted partners (last 24 hours only)", + "contacts": [ + { + "name": "Met Norway", + "organization": "National Meteorological service of Norway, Met Norway", + "phones": [ + { + "value": "+4722963000" + } ], - "language": "en", - "type": "dataset", - "created": "2023-01-01T00:00:00Z", - "updated": "2024-09-19T00:00:00Z", - "contacts": [ - { - "organization": "EUMETNET", - "addresses": [ - { - "deliveryPoint": [ - "Avenue Circulaire 3" - ], - "city": "Bruxelles", - "postalCode": "1180", - "country": "Belgique" - } - ], - "links": [ - { - "href": "https://www.eumetnet.eu/about-us/", - "rel": "about", - "type": "text/html" - } - ], - "roles": [ - "host" - ] - } + "emails": [ + { + "value": "post@met.no" + } ], - "keywords": [ - "surface weather", - "observations", - "meteorology" + "addresses": [ + { + "deliveryPoint": [ + "https://www.met.no/en/contact-us" + ], + "name": "Met Norway", + "city": "Oslo", + "postalCode": "0313", + "country": "Norway" + } ], - "wmo:dataPolicy": "core" - }, - "links": [ - { - "href": "E-SOH dataset mqtt stream", - "rel": "items", - "title": "E-SOH dataset data notifications", - "type": "application/json" - }, - { - "href": "E-SOH time series mqtt stream", - "rel": "items", - "title": "E-SOH time series data notifications", - "type": "application/json" - }, - { - "href": {{ url_base|tojson }}, - "rel": "data", - "title": "E-SOH EDR API landing page", - "type": "application/json" - }, - { - "href": {{ url_docs|tojson }}, - "rel": "related", - "title": "E-SOH API documentation", - "type": "application/json" - }, - { - "href": {{ url_conformance|tojson }}, - "rel": "conformance", - "title": "E-SOH Conformance Declaration", - "type": "application/json" - }, - { - "href": "https://www.eumetnet.eu/wp-content/uploads/2018/03/List-of-current-Members-as-pdf.pdf", + "links": [ + { "rel": "about", - "title": "EUMETNET Members", - "type": "text/html" + "type": "text/html", + "href": "https://www.eumetnet.eu/about-us" + } + ], + "roles": ["host"] + } + ], + "keywords": [ + "weather", + "surface-based observations", + "observations", + "meteorology", + "surface weather", + "Norway", + "Finalnd", + "the Netherlands" + ], + "themes": [ + { "concepts": [ + { + "id":"weather", + "title": "Weather", + "url": "https://codes.wmo.int/wis/topic-hierarchy/earth-system-discipline/weather" + } + ], + "scheme":"https://codes.wmo.int/wis/topic-hierarchy/earth-system-discipline" + }, + { "concepts": [ + { + "id":"surface-based-observations" + } + ], + "scheme":"https://codes.wmo.int/wis/topic-hierarchy/earth-system-discipline/weather/surface-based-observations" }, - { - "href": "https://creativecommons.org/licenses/by/4.0/", - "rel": "license", - "title": "Creative Commons BY 4.0 licence", - "type": "text/html" - } - ] + { + "concepts": [ + { + "id": "air_temperature", + "title": "Air temperature", + "url": "http://vocab.nerc.ac.uk/standard_name/air_temperature/" + }, + { + "id": "wind_speed", + "title": "Wind Speed", + "url": "http://vocab.nerc.ac.uk/standard_name/wind_speed/" + }, + { + "id": "wind_to_direction", + "title": "Wind to diection", + "url": "http://vocab.nerc.ac.uk/standard_name/wind_to_direction/" + } + ], + "scheme": "https://vocab.nerc.ac.uk/standard_name" + } + ], + "language":"en", + "created": "2025-06-04T14:00:00Z", + "updated": "2025-06-04T14:00:00Z", + "rights": "Users are granted free and unrestricted access to this data, without charge and with no conditions on use. Users are requested to attribute the producer of this data. WMO Unified Data Policy (Resolution 1 (Cg-Ext 2021))", + "licence": "CC BY 4.0 Creative Commons license", + "type": "dataset", + "wmo:dataPolicy": "recommended" + }, + "links": [ + { + "href": {{ url_base|tojson }}, + "rel": "data", + "title": "E-SOH EDR API landing page", + "type": "application/json" + }, + { + "href": {{ url_docs|tojson }}, + "rel": "related", + "title": "E-SOH API documentation", + "type": "application/json" + }, + { + "href": {{ url_conformance|tojson }}, + "rel": "conformance", + "title": "E-SOH Conformance Declaration", + "type": "application/json" + }, + { + "rel": "related", + "href": "https://www.eumetnet.eu/observations/observations-data-sharing-2/", + "type": "text/html", + "title": "Documentation related to EUMETNET data sharing" + }, + { + "rel": "related", + "href": "https://www.eumetnet.eu/about-us-2/members-partners/", + "type": "text/html", + "title": "List of EUMETNET Members" + }, + { + "rel": "license", + "href": "https://creativecommons.org/licenses/by/4.0/", + "title": "Creative Commons BY 4.0 licence", + "type": "text/html" + } + ] } From 56a9e365efead84609dc948b184c2ad1154dda85 Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Tue, 2 Dec 2025 13:47:29 +0100 Subject: [PATCH 10/18] Fix line length --- api/routers/feature.py | 40 +++++++++------------------------------- 1 file changed, 9 insertions(+), 31 deletions(-) diff --git a/api/routers/feature.py b/api/routers/feature.py index 739f04be..e0afc149 100644 --- a/api/routers/feature.py +++ b/api/routers/feature.py @@ -24,10 +24,7 @@ router = APIRouter(prefix="/collections/observations") -env = Environment( - loader=FileSystemLoader(os.getenv("JINJA2_TEMPLATES", "templates")), - autoescape=select_autoescape(), -) +env = Environment(loader=FileSystemLoader(os.getenv("JINJA2_TEMPLATES", "templates")), autoescape=select_autoescape()) @router.get( @@ -64,32 +61,21 @@ async def search_timeseries( ] = None, institution: Annotated[ str | None, - Query( - description="Institution that published the data", - openapi_examples=openapi_examples.institution, - ), + Query(description="Institution that published the data", openapi_examples=openapi_examples.institution), ] = None, platform: Annotated[ str | None, - Query( - description="Platform ID, WIGOS or WIGOS equivalent.", - openapi_examples=openapi_examples.wigos_id, - ), + Query(description="Platform ID, WIGOS or WIGOS equivalent.", openapi_examples=openapi_examples.wigos_id), ] = None, standard_name: Annotated[ str | None, Query( - alias="standard-name", - description="CF 1.9 standard name", - openapi_examples=openapi_examples.standard_name, + alias="standard-name", description="CF 1.9 standard name", openapi_examples=openapi_examples.standard_name ), ] = None, unit: Annotated[ str | None, - Query( - description="Unit of observed physical property", - openapi_examples=openapi_examples.unit, - ), + Query(description="Unit of observed physical property", openapi_examples=openapi_examples.unit), ] = None, instrument: Annotated[str | None, Query(description="Instrument Id")] = None, level: Annotated[ @@ -101,16 +87,12 @@ async def search_timeseries( ] = None, period: Annotated[ str | None, - Query( - description="Duration of collection period in ISO8601", - openapi_examples=openapi_examples.period, - ), + Query(description="Duration of collection period in ISO8601", openapi_examples=openapi_examples.period), ] = None, method: Annotated[ str | None, Query( - description="Aggregation method used to sample observed property", - openapi_examples=openapi_examples.method, + description="Aggregation method used to sample observed property", openapi_examples=openapi_examples.method ), ] = None, f: Annotated[ @@ -168,8 +150,7 @@ async def get_time_series_by_id( ] = formatters.Metadata_Formats.geojson, ): obs_request = dstore.GetObsRequest( - filter=dict(timeseries_id=dstore.Strings(values=[item_id])), - temporal_latest=True, + filter=dict(timeseries_id=dstore.Strings(values=[item_id])), temporal_latest=True ) time_series = await get_obs_request(obs_request) @@ -194,10 +175,7 @@ async def get_dataset_metadata(request: Request): ] ], "temporal_extents": [ - [ - f"{extent.temporal_extent.start.ToDatetime().strftime('%Y-%m-%dT%H:%M:%SZ')}", - "..", - ], + [f"{extent.temporal_extent.start.ToDatetime().strftime('%Y-%m-%dT%H:%M:%SZ')}", ".."], ], "url_base": base_url, "url_conformance": base_url + "conformance", From 7550eaaa4641918a6451f5efe78206d2ddc6f2e0 Mon Sep 17 00:00:00 2001 From: Amund Isaksen <31344542+Teddy-1000@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:42:45 +0100 Subject: [PATCH 11/18] Update api/templates/dataset_metadata_template.j2 Co-authored-by: Lukas Phaf --- api/templates/dataset_metadata_template.j2 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/templates/dataset_metadata_template.j2 b/api/templates/dataset_metadata_template.j2 index f9b74aae..455e93ab 100644 --- a/api/templates/dataset_metadata_template.j2 +++ b/api/templates/dataset_metadata_template.j2 @@ -57,8 +57,8 @@ "meteorology", "surface weather", "Norway", - "Finalnd", - "the Netherlands" + "Finland", + "The Netherlands" ], "themes": [ { "concepts": [ From 5fe4eeb2b104735bf37d450c891ee5c8fc7fc04d Mon Sep 17 00:00:00 2001 From: Amund Isaksen <31344542+Teddy-1000@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:43:04 +0100 Subject: [PATCH 12/18] Update api/templates/dataset_metadata_template.j2 Co-authored-by: Lukas Phaf --- api/templates/dataset_metadata_template.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/templates/dataset_metadata_template.j2 b/api/templates/dataset_metadata_template.j2 index 455e93ab..48455276 100644 --- a/api/templates/dataset_metadata_template.j2 +++ b/api/templates/dataset_metadata_template.j2 @@ -14,7 +14,7 @@ }, "properties": { "title": "Land surface weather observations", - "description":"Land surface observations measured at automatic and manual weather stations of EUMETNET Members and thier trusted partners (last 24 hours only)", + "description":"Land surface observations measured at automatic and manual weather stations of EUMETNET Members and their trusted partners (last 24 hours only)", "contacts": [ { "name": "Met Norway", From 383f43b488dccbedad0b899838e59b944f4ddf5c Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Mon, 8 Dec 2025 15:48:41 +0100 Subject: [PATCH 13/18] Remove print --- api/openapi/openapi_metadata.py | 1 - 1 file changed, 1 deletion(-) diff --git a/api/openapi/openapi_metadata.py b/api/openapi/openapi_metadata.py index 3a8b5a87..22155593 100644 --- a/api/openapi/openapi_metadata.py +++ b/api/openapi/openapi_metadata.py @@ -6,4 +6,3 @@ "r", ) as file: openapi_metadata = json.load(file) - print(openapi_metadata) From 45d96f2dd2afdf6511d990e20a23f3281245d865 Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Mon, 8 Dec 2025 15:49:59 +0100 Subject: [PATCH 14/18] Clearify that the openapi metadata is also used for the landing page --- api/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/README.md b/api/README.md index 21c5fb92..0f5fb402 100644 --- a/api/README.md +++ b/api/README.md @@ -15,10 +15,12 @@ Environment variables that can be used to configure the container or the environ | JINJA2_TEMPLATES | Path to a folder with jinja2 templates to override the default templates used by the API. See template section for details | ☐ | | OPENAPI_METADATA_PATH | Path to an alternative OpenAPI metadata json file. It need to have the same fields as the openapi/openapi_metadata.py file. | ☐ | -## OpenAPI metadata +## OpenAPI and EDR landing page metadata To load your own metadata file, mount your new openapi metadata json file, with the same structure and key as the default one, found in openapi_default_files, and point the environment variable `OPENAPI_METADATA_PATH` to the new file. If you mount your folder to the same as the default one, make sure to not overwrite files you have not replaced. +This Metdata is used both for the OpenAPI specification and the EDR landing page. + ## JINJA2_TEMPLATES The jinja2 folder must container the following files: From dba41cb79075a68e0a44540b030d455f14c7dc15 Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Wed, 14 Jan 2026 13:26:13 +0100 Subject: [PATCH 15/18] Use fastAPI openapi wrapper to mitigate passing arbitrary arguemnts to fastAPI constructor --- api/main.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/api/main.py b/api/main.py index c84cf13e..86614a9a 100644 --- a/api/main.py +++ b/api/main.py @@ -12,6 +12,7 @@ from export_metrics import add_metrics from fastapi import FastAPI from fastapi import Request +from fastapi.openapi.utils import get_openapi from openapi.collections_metadata import collections_metadata from openapi.openapi_metadata import openapi_metadata from routers import edr @@ -39,7 +40,6 @@ def setup_logging(): app = FastAPI( swagger_ui_parameters={"tryItOutEnabled": True}, root_path=os.getenv("FASTAPI_ROOT_PATH", ""), - **openapi_metadata, ) app.add_middleware(BrotliMiddleware) @@ -97,6 +97,42 @@ async def collection_metadata(request: Request) -> Collection: return collection_metadata +def custom_openapi_wrapper(): + """ + Available fields for openapi configuration in openapi_metadata: + title, + version, + summary, + description, + terms_of_service, + contact, + license_info, + tags, + servers, + external_docs, + + Hidden fields, not available to be set in openapi_metadata: + openapi_version + routes + webhooks + separate_input_output_schemas + """ + if app.openapi_schema: + return app.openapi_schema + + # Version is mandatory in fastAPI, se we check that it is set + if "version" not in openapi_metadata: + openapi_metadata["version"] = "0.0.1" + + openapi_schema = get_openapi( + routes=app.routes, + **openapi_metadata, + ) + + app.openapi_schema = openapi_schema + return app.openapi_schema + + # Create dynamic routes for each collection for collection_id in all_collections: app.add_api_route( @@ -112,3 +148,5 @@ async def collection_metadata(request: Request) -> Collection: # Include all routes app.include_router(edr.router) app.include_router(feature.router) + +app.openapi = custom_openapi_wrapper From 84e15f5db661bab9b991eed85275fa7efbc7ec62 Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Wed, 14 Jan 2026 13:27:03 +0100 Subject: [PATCH 16/18] Update readme about custom openapi contents --- api/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/README.md b/api/README.md index 0f5fb402..383435a5 100644 --- a/api/README.md +++ b/api/README.md @@ -21,6 +21,8 @@ To load your own metadata file, mount your new openapi metadata json file, with This Metdata is used both for the OpenAPI specification and the EDR landing page. +The available fields are: title, version, summary, description, terms_of_service, contact, license_info, tags, external_docs. + ## JINJA2_TEMPLATES The jinja2 folder must container the following files: From fa8cbdfc37904f5ee562f699914bba1fae3d1edc Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Fri, 16 Jan 2026 08:47:48 +0100 Subject: [PATCH 17/18] Revert "Use fastAPI openapi wrapper to mitigate passing arbitrary arguemnts to fastAPI constructor" This reverts commit dba41cb79075a68e0a44540b030d455f14c7dc15. --- api/main.py | 40 +--------------------------------------- 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/api/main.py b/api/main.py index 86614a9a..c84cf13e 100644 --- a/api/main.py +++ b/api/main.py @@ -12,7 +12,6 @@ from export_metrics import add_metrics from fastapi import FastAPI from fastapi import Request -from fastapi.openapi.utils import get_openapi from openapi.collections_metadata import collections_metadata from openapi.openapi_metadata import openapi_metadata from routers import edr @@ -40,6 +39,7 @@ def setup_logging(): app = FastAPI( swagger_ui_parameters={"tryItOutEnabled": True}, root_path=os.getenv("FASTAPI_ROOT_PATH", ""), + **openapi_metadata, ) app.add_middleware(BrotliMiddleware) @@ -97,42 +97,6 @@ async def collection_metadata(request: Request) -> Collection: return collection_metadata -def custom_openapi_wrapper(): - """ - Available fields for openapi configuration in openapi_metadata: - title, - version, - summary, - description, - terms_of_service, - contact, - license_info, - tags, - servers, - external_docs, - - Hidden fields, not available to be set in openapi_metadata: - openapi_version - routes - webhooks - separate_input_output_schemas - """ - if app.openapi_schema: - return app.openapi_schema - - # Version is mandatory in fastAPI, se we check that it is set - if "version" not in openapi_metadata: - openapi_metadata["version"] = "0.0.1" - - openapi_schema = get_openapi( - routes=app.routes, - **openapi_metadata, - ) - - app.openapi_schema = openapi_schema - return app.openapi_schema - - # Create dynamic routes for each collection for collection_id in all_collections: app.add_api_route( @@ -148,5 +112,3 @@ def custom_openapi_wrapper(): # Include all routes app.include_router(edr.router) app.include_router(feature.router) - -app.openapi = custom_openapi_wrapper From a53775f8c1e744a266c097816b5981d5cc19a443 Mon Sep 17 00:00:00 2001 From: Amund Isaksen Date: Fri, 16 Jan 2026 09:18:37 +0100 Subject: [PATCH 18/18] Make list of acceptable parameters to take from openapi_metadata.json --- api/openapi/openapi_metadata.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/api/openapi/openapi_metadata.py b/api/openapi/openapi_metadata.py index 22155593..1cd329e0 100644 --- a/api/openapi/openapi_metadata.py +++ b/api/openapi/openapi_metadata.py @@ -6,3 +6,16 @@ "r", ) as file: openapi_metadata = json.load(file) + valid_keys = [ + "title", + "version", + "summary", + "description", + "terms_of_service", + "contact", + "license_info", + "openapi_tags", + ] + unwanted = set(openapi_metadata) - set(valid_keys) + for unwanted_key in unwanted: + del openapi_metadata[unwanted_key]