From c28d25078448a89d51dd615050415631c882c2d5 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Sat, 17 Jan 2026 14:45:01 -0500 Subject: [PATCH 1/2] update EDR and Maps to support MetOcean access and visualization workflows (#2213) --- docs/source/configuration.rst | 6 ++ docs/source/publishing/ogcapi-coverages.rst | 10 ++ docs/source/publishing/ogcapi-edr.rst | 7 +- docs/source/publishing/ogcapi-maps.rst | 4 + pygeoapi/api/__init__.py | 100 ++++++++++++++---- pygeoapi/api/coverages.py | 18 +++- pygeoapi/api/environmental_data_retrieval.py | 26 ++++- pygeoapi/api/itemtypes.py | 20 +--- pygeoapi/api/maps.py | 71 +++++++++++-- pygeoapi/openapi.py | 19 +++- pygeoapi/provider/base_edr.py | 13 ++- pygeoapi/provider/wms_facade.py | 4 +- pygeoapi/provider/xarray_.py | 13 ++- .../schemas/config/pygeoapi-config-0.x.yml | 25 ++++- tests/api/test_api.py | 23 ++++ .../api/test_environmental_data_retrieval.py | 3 +- tests/api/test_maps.py | 19 +++- tests/pygeoapi-test-config.yml | 5 + 18 files changed, 318 insertions(+), 68 deletions(-) diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 11c39255b..35ea9f20f 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -225,6 +225,12 @@ default. begin: 2000-10-30T18:24:39Z # start datetime in RFC3339 end: 2007-10-30T08:57:29Z # end datetime in RFC3339 trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian # TRS + # additional extents can be added as desired (1..n) + foo: + url: https://example.org/def # required URL of the extent + range: [0, 10] # required overall range/extent + units: °C # optional units + values: [0, 2, 5, 5, 10] # optional, enumeration of values providers: # list of 1..n required connections information - type: feature # underlying data geospatial type. Allowed values are: feature, coverage, record, tile, edr name: CSV # required: plugin name or import path. See Plugins section for more information. diff --git a/docs/source/publishing/ogcapi-coverages.rst b/docs/source/publishing/ogcapi-coverages.rst index f277f7c5b..d2016b22f 100644 --- a/docs/source/publishing/ogcapi-coverages.rst +++ b/docs/source/publishing/ogcapi-coverages.rst @@ -89,11 +89,20 @@ The `Xarray`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data. format: name: zarr mimetype: application/zip + options: + zarr: + consolidated: true + squeeze: true + .. note:: `Zarr`_ files are directories with files and subdirectories. Therefore a zip file is returned upon request for said format. +.. note:: + + ``options.zarr`` is a custom property that can be used to set `Zarr-specific open options`_. + .. note:: When referencing `NetCDF`_ or `Zarr`_ data stored in an S3 bucket, be sure to provide the full S3 URL. Any parameters required to open the dataset @@ -155,3 +164,4 @@ Data access examples .. _`Zarr`: https://zarr.readthedocs.io/en/stable .. _`GDAL raster driver short name`: https://gdal.org/drivers/raster/index.html .. _`pyproj.CRS.from_user_input`: https://pyproj4.github.io/pyproj/stable/api/crs/coordinate_system.html#pyproj.crs.CoordinateSystem.from_user_input +.. _`Zarr-specific open options`: https://docs.xarray.dev/en/stable/generated/xarray.open_zarr.html diff --git a/docs/source/publishing/ogcapi-edr.rst b/docs/source/publishing/ogcapi-edr.rst index edd205d17..faf7fb37d 100644 --- a/docs/source/publishing/ogcapi-edr.rst +++ b/docs/source/publishing/ogcapi-edr.rst @@ -90,11 +90,15 @@ The `xarray-edr`_ provider plugin reads and extracts `NetCDF`_ and `Zarr`_ data a zip file is returned upon request for said format. .. note:: + + ``options.zarr`` is a custom property that can be used to set `Zarr-specific open options`_. + +.. note:: + When referencing data stored in an S3 bucket, be sure to provide the full S3 URL. Any parameters required to open the dataset using fsspec can be added to the config file under `options` and `s3`, as shown above. - SensorThingsEDR ^^^^^^^^^^^^^^^ @@ -143,3 +147,4 @@ Data access examples .. _`NetCDF`: https://en.wikipedia.org/wiki/NetCDF .. _`Zarr`: https://zarr.readthedocs.io/en/stable .. _`OGC Environmental Data Retrieval (EDR) (API)`: https://ogcapi.ogc.org/edr +.. _`Zarr-specific open options`: https://docs.xarray.dev/en/stable/generated/xarray.open_zarr.html diff --git a/docs/source/publishing/ogcapi-maps.rst b/docs/source/publishing/ogcapi-maps.rst index 679e445c9..6924e3a39 100644 --- a/docs/source/publishing/ogcapi-maps.rst +++ b/docs/source/publishing/ogcapi-maps.rst @@ -136,5 +136,9 @@ Data visualization examples * http://localhost:5000/collections/foo/map?bbox-crs=http%3A%2F%2Fwww.opengis.net%2Fdef%2Fcrs%2FEPSG%2F0%2F3857&bbox=4.022369384765626%2C50.690447870569436%2C4.681549072265626%2C51.00260125274477&width=800&height=600&transparent +* map with vertical subset (``extents.vertical`` must be set in resource level config) + + * http://localhost:5000/collections/foo/map?bbox=-142,42,-52,84&subset=vertical(435) + .. _`OGC API - Maps`: https://ogcapi.ogc.org/maps .. _`see website`: https://mapserver.org/mapscript/index.html diff --git a/pygeoapi/api/__init__.py b/pygeoapi/api/__init__.py index 6b5468674..b3247f8a7 100644 --- a/pygeoapi/api/__init__.py +++ b/pygeoapi/api/__init__.py @@ -7,7 +7,7 @@ # Colin Blackburn # Ricardo Garcia Silva # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -588,6 +588,7 @@ def get_exception(self, status: int, headers: dict, format_: str | None, """ exception_info = sys.exc_info() + LOGGER.error( description, exc_info=exception_info if exception_info[0] is not None else None @@ -709,22 +710,22 @@ def landing_page(api: API, 'title': l10n.translate('Collections', request.locale), 'href': api.get_collections_url() }, { - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/processes', + 'rel': f'{OGC_RELTYPES_BASE}/processes', 'type': FORMAT_TYPES[F_JSON], 'title': l10n.translate('Processes', request.locale), 'href': f"{api.base_url}/processes" }, { - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/job-list', + 'rel': f'{OGC_RELTYPES_BASE}/job-list', 'type': FORMAT_TYPES[F_JSON], 'title': l10n.translate('Jobs', request.locale), 'href': f"{api.base_url}/jobs" }, { - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-schemes', + 'rel': f'{OGC_RELTYPES_BASE}/tiling-schemes', 'type': FORMAT_TYPES[F_JSON], 'title': l10n.translate('The list of supported tiling schemes as JSON', request.locale), # noqa 'href': f"{api.base_url}/TileMatrixSets?f=json" }, { - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-schemes', + 'rel': f'{OGC_RELTYPES_BASE}/tiling-schemes', 'type': FORMAT_TYPES[F_HTML], 'title': l10n.translate('The list of supported tiling schemes as HTML', request.locale), # noqa 'href': f"{api.base_url}/TileMatrixSets?f=html" @@ -897,7 +898,10 @@ def describe_collections(api: API, request: APIRequest, 'links': [] } - bbox = v['extents']['spatial']['bbox'] + extents = deepcopy(v['extents']) + + bbox = extents['spatial']['bbox'] + LOGGER.debug('Setting spatial extents from configuration') # The output should be an array of bbox, so if the user only # provided a single bbox, wrap it in a array. if not isinstance(bbox[0], list): @@ -907,12 +911,13 @@ def describe_collections(api: API, request: APIRequest, 'bbox': bbox } } - if 'crs' in v['extents']['spatial']: + if 'crs' in extents['spatial']: collection['extent']['spatial']['crs'] = \ - v['extents']['spatial']['crs'] + extents['spatial']['crs'] - t_ext = v.get('extents', {}).get('temporal', {}) + t_ext = extents.get('temporal', {}) if t_ext: + LOGGER.debug('Setting temporal extents from configuration') begins = dategetter('begin', t_ext) ends = dategetter('end', t_ext) collection['extent']['temporal'] = { @@ -921,6 +926,24 @@ def describe_collections(api: API, request: APIRequest, if 'trs' in t_ext: collection['extent']['temporal']['trs'] = t_ext['trs'] + _ = extents.pop('spatial', None) + _ = extents.pop('temporal', None) + + for ek, ev in extents.items(): + LOGGER.debug(f'Adding extent {ek}') + collection['extent'][ek] = { + 'definition': ev['url'], + 'interval': [ev['range']] + } + if 'units' in ev: + collection['extent'][ek]['unit'] = ev['units'] + + if 'values' in ev: + collection['extent'][ek]['grid'] = { + 'cellsCount': len(ev['values']), + 'coordinates': ev['values'] + } + LOGGER.debug('Processing configured collection links') for link in l10n.translate(v.get('links', []), request.locale): lnk = { @@ -990,13 +1013,13 @@ def describe_collections(api: API, request: APIRequest, if collection_data_type == 'record': collection['links'].append({ 'type': FORMAT_TYPES[F_JSON], - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/ogc-catalog', + 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', 'title': l10n.translate('Record catalogue as JSON', request.locale), # noqa 'href': f'{api.get_collections_url()}/{k}?f={F_JSON}' }) collection['links'].append({ 'type': FORMAT_TYPES[F_HTML], - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/ogc-catalog', + 'rel': f'{OGC_RELTYPES_BASE}/ogc-catalog', 'title': l10n.translate('Record catalogue as HTML', request.locale), # noqa 'href': f'{api.get_collections_url()}/{k}?f={F_HTML}' }) @@ -1021,13 +1044,13 @@ def describe_collections(api: API, request: APIRequest, LOGGER.debug('Adding feature/record based links') collection['links'].append({ 'type': 'application/schema+json', - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/queryables', + 'rel': f'{OGC_RELTYPES_BASE}/queryables', 'title': l10n.translate('Queryables for this collection as JSON', request.locale), # noqa 'href': f'{api.get_collections_url()}/{k}/queryables?f={F_JSON}' # noqa }) collection['links'].append({ 'type': FORMAT_TYPES[F_HTML], - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/queryables', + 'rel': f'{OGC_RELTYPES_BASE}/queryables', 'title': l10n.translate('Queryables for this collection as HTML', request.locale), # noqa 'href': f'{api.get_collections_url()}/{k}/queryables?f={F_HTML}' # noqa }) @@ -1135,19 +1158,20 @@ def describe_collections(api: API, request: APIRequest, LOGGER.debug('Adding tile links') collection['links'].append({ 'type': FORMAT_TYPES[F_JSON], - 'rel': f'http://www.opengis.net/def/rel/ogc/1.0/tilesets-{p.tile_type}', # noqa + 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', 'title': l10n.translate('Tiles as JSON', request.locale), - 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_JSON}' # noqa + 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_JSON}' }) collection['links'].append({ 'type': FORMAT_TYPES[F_HTML], - 'rel': f'http://www.opengis.net/def/rel/ogc/1.0/tilesets-{p.tile_type}', # noqa + 'rel': f'{OGC_RELTYPES_BASE}/tilesets-{p.tile_type}', 'title': l10n.translate('Tiles as HTML', request.locale), - 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_HTML}' # noqa + 'href': f'{api.get_collections_url()}/{k}/tiles?f={F_HTML}' }) try: map_ = get_provider_by_type(v['providers'], 'map') + p = load_plugin('provider', map_) except ProviderTypeError: map_ = None @@ -1158,15 +1182,36 @@ def describe_collections(api: API, request: APIRequest, map_format = map_['format']['name'] title_ = l10n.translate('Map as', request.locale) - title_ = f"{title_} {map_format}" + title_ = f'{title_} {map_format}' collection['links'].append({ 'type': map_mimetype, - 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/map', + 'rel': f'{OGC_RELTYPES_BASE}/map', 'title': title_, - 'href': f"{api.get_collections_url()}/{k}/map?f={map_format}" # noqa + 'href': f'{api.get_collections_url()}/{k}/map?f={map_format}' }) + if p._fields: + schema_reltype = f'{OGC_RELTYPES_BASE}/schema', + schema_links = [s for s in collection['links'] if + schema_reltype in s] + + if not schema_links: + title_ = l10n.translate('Schema of collection in JSON', request.locale) # noqa + collection['links'].append({ + 'type': 'application/schema+json', + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': title_, + 'href': f'{api.get_collections_url()}/{k}/schema?f=json' # noqa + }) + title_ = l10n.translate('Schema of collection in HTML', request.locale) # noqa + collection['links'].append({ + 'type': 'text/html', + 'rel': f'{OGC_RELTYPES_BASE}/schema', + 'title': title_, + 'href': f'{api.get_collections_url()}/{k}/schema?f=html' # noqa + }) + try: edr = get_provider_by_type(v['providers'], 'edr') p = load_plugin('provider', edr) @@ -1217,6 +1262,10 @@ def describe_collections(api: API, request: APIRequest, } } } + + if request.format is not None and request.format == 'json': + data_query['link']['type'] = 'application/vnd.cov+json' + collection['data_queries'][qt] = data_query title1 = l10n.translate('query for this collection as JSON', request.locale) # noqa @@ -1334,9 +1383,14 @@ def get_collection_schema(api: API, request: Union[APIRequest, Any], p = load_plugin('provider', get_provider_by_type( api.config['resources'][dataset]['providers'], 'coverage')) # noqa except ProviderTypeError: - LOGGER.debug('Loading record provider') - p = load_plugin('provider', get_provider_by_type( - api.config['resources'][dataset]['providers'], 'record')) + try: + LOGGER.debug('Loading record provider') + p = load_plugin('provider', get_provider_by_type( + api.config['resources'][dataset]['providers'], 'record')) + except ProviderTypeError: + LOGGER.debug('Loading edr provider') + p = load_plugin('provider', get_provider_by_type( + api.config['resources'][dataset]['providers'], 'edr')) except ProviderGenericError as err: LOGGER.error(err) return api.get_exception( diff --git a/pygeoapi/api/coverages.py b/pygeoapi/api/coverages.py index 38b66ef7c..b44a15fce 100644 --- a/pygeoapi/api/coverages.py +++ b/pygeoapi/api/coverages.py @@ -8,7 +8,7 @@ # Ricardo Garcia Silva # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -37,12 +37,13 @@ # # ================================================================= - +from copy import deepcopy import logging from http import HTTPStatus from typing import Tuple from pygeoapi import l10n +from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin from pygeoapi.provider.base import ProviderGenericError, ProviderTypeError from pygeoapi.util import ( @@ -216,8 +217,8 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, for k, v in get_visible_collections(cfg).items(): try: - load_plugin('provider', get_provider_by_type( - collections[k]['providers'], 'coverage')) + p = load_plugin('provider', get_provider_by_type( + collections[k]['providers'], 'coverage')) except ProviderTypeError: LOGGER.debug('collection is not coverage based') continue @@ -226,6 +227,11 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, title = l10n.translate(v['title'], locale) description = l10n.translate(v['description'], locale) + parameters = get_oas_30_parameters(cfg, locale) + + coll_properties = deepcopy(parameters)['properties'] + coll_properties['schema']['items']['enum'] = list(p.fields.keys()) + paths[coverage_path] = { 'get': { 'summary': f'Get {title} coverage', @@ -236,7 +242,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, {'$ref': '#/components/parameters/lang'}, {'$ref': '#/components/parameters/f'}, {'$ref': '#/components/parameters/bbox'}, - {'$ref': '#/components/parameters/bbox-crs'} + {'$ref': '#/components/parameters/bbox-crs'}, + {'$ref': f"{OPENAPI_YAML['oacov']}#/components/parameters/subset"}, # noqa + coll_properties ], 'responses': { '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa diff --git a/pygeoapi/api/environmental_data_retrieval.py b/pygeoapi/api/environmental_data_retrieval.py index 96eacb426..853559156 100644 --- a/pygeoapi/api/environmental_data_retrieval.py +++ b/pygeoapi/api/environmental_data_retrieval.py @@ -113,13 +113,14 @@ def get_collection_edr_instances(api: API, request: APIRequest, if instance_id is not None: try: - instances = [p.get_instance(instance_id)] + if p.get_instance(instance_id): + instances = [instance_id] except ProviderItemNotFoundError: msg = 'Instance not found' return api.get_exception( HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg) else: - instances = p.instances() + instances = p.get_instances() for instance in instances: instance_dict = { @@ -149,13 +150,18 @@ def get_collection_edr_instances(api: API, request: APIRequest, for qt in p.get_query_types(): if qt == 'instances': continue + data_query = { 'link': { - 'href': f'{uri}/instances/{instance}/{qt}', + 'href': f'{uri}/instances/{instance}/{qt}?f={request.format}', # noqa 'rel': 'data', 'title': f'{qt} query' } } + + if request.format is not None and request.format == 'json': + data_query['link']['type'] = 'application/vnd.cov+json' + instance_dict['data_queries'][qt] = data_query data['instances'].append(instance_dict) @@ -369,6 +375,15 @@ def get_collection_edr_query(api: API, request: APIRequest, within = request.params.get('within') within_units = request.params.get('within-units') + corridor_width = width_units = None + corridor_height = height_units = None + if query_type == 'corridor': + LOGGER.debug('Processing corridor width / height / units parameters') + corridor_width = request.params.get('corridor-width') + width_units = request.params.get('width-units') + corridor_height = request.params.get('corridor-height') + height_units = request.params.get('height-units') + LOGGER.debug('Processing z parameter') try: z = get_typed_value(request.params.get('z')) @@ -408,6 +423,10 @@ def get_collection_edr_query(api: API, request: APIRequest, bbox=bbox, within=within, within_units=within_units, + corridor_width=corridor_width, + width_units=width_units, + corridor_height=corridor_height, + height_units=height_units, limit=limit, location_id=location_id, crs_transform_spec=crs_transform_spec @@ -481,6 +500,7 @@ def get_collection_edr_query(api: API, request: APIRequest, headers['Content-Disposition'] = cd else: + headers['Content-Type'] = 'application/vnd.cov+json' content = to_json(data, api.pretty_print) return headers, HTTPStatus.OK, content diff --git a/pygeoapi/api/itemtypes.py b/pygeoapi/api/itemtypes.py index 20936f759..ca561b9b2 100644 --- a/pygeoapi/api/itemtypes.py +++ b/pygeoapi/api/itemtypes.py @@ -1024,21 +1024,6 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections - properties = { - 'name': 'properties', - 'in': 'query', - 'description': 'The properties that should be included for each feature. The parameter value is a comma-separated list of property names.', # noqa - 'required': False, - 'style': 'form', - 'explode': False, - 'schema': { - 'type': 'array', - 'items': { - 'type': 'string' - } - } - } - limit = { 'name': 'limit', 'in': 'query', @@ -1093,8 +1078,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, title = l10n.translate(v['title'], locale) description = l10n.translate(v['description'], locale) - coll_properties = deepcopy(properties) + oas_30_parameters = get_oas_30_parameters(cfg, locale) + coll_properties = deepcopy(oas_30_parameters)['properties'] coll_properties['schema']['items']['enum'] = list(p.fields.keys()) coll_limit = _derive_limit( @@ -1103,7 +1089,7 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, ) dataset_formatters = get_dataset_formatters(v) - coll_f_parameter = deepcopy(get_oas_30_parameters(cfg, locale))['f'] # noqa + coll_f_parameter = deepcopy(oas_30_parameters)['f'] for key, value in dataset_formatters.items(): coll_f_parameter['schema']['enum'].append(value.f) diff --git a/pygeoapi/api/maps.py b/pygeoapi/api/maps.py index d8df6d354..888073938 100644 --- a/pygeoapi/api/maps.py +++ b/pygeoapi/api/maps.py @@ -8,7 +8,7 @@ # Ricardo Garcia Silva # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2022 John A Stevenson and Colin Blackburn # Copyright (c) 2023 Ricardo Garcia Silva @@ -46,13 +46,17 @@ from pygeoapi.crs import transform_bbox from pygeoapi.openapi import get_oas_30_parameters from pygeoapi.plugin import load_plugin -from pygeoapi.provider.base import ProviderGenericError +from pygeoapi.provider.base import ( + ProviderGenericError, ProviderInvalidDataError +) from pygeoapi.util import ( get_provider_by_type, to_json, filter_providers_by_type, filter_dict_by_key_value ) -from . import APIRequest, API, validate_datetime +from . import ( + APIRequest, API, F_JSON, FORMAT_TYPES, validate_datetime, validate_subset +) LOGGER = logging.getLogger(__name__) @@ -68,7 +72,7 @@ def get_collection_map(api: API, request: APIRequest, dataset: str, style: str | None = None ) -> Tuple[dict, int, str]: """ - Returns a subset of a collection map + Returns an image of a collection map :param request: A request object :param dataset: dataset name @@ -167,10 +171,58 @@ def get_collection_map(api: API, request: APIRequest, HTTPStatus.BAD_REQUEST, headers, request.format, 'InvalidParameterValue', msg) + if 'subset' in request.params: + # TODO get subsets from provider + subsets = deepcopy(api.config['resources'][dataset]['extents']) + subsets.pop('spatial', None) # bbox + subsets.pop('temporal', None) # datetime + LOGGER.debug('Processing subset parameter') + try: + query_args['subsets'] = validate_subset( + request.params['subset'] or '') + except (AttributeError, ValueError) as err: + msg = f'Invalid subset: {err}' + return api.get_exception( + HTTPStatus.BAD_REQUEST, headers, format_, + 'InvalidParameterValue', msg) + + for sk in query_args['subsets'].keys(): + if sk not in subsets.keys(): + msg = f'Subset not found; valid values are {subsets}' + return api.get_exception( + HTTPStatus.BAD_REQUEST, headers, format_, + 'InvalidParameterValue', msg) + + if request.params.get('properties'): + try: + fields = p.get_fields() or {} + except NotImplementedError: + msg = 'No properties implemented' + headers['Content-Type'] = FORMAT_TYPES[F_JSON] + return api.get_exception( + HTTPStatus.NOT_IMPLEMENTED, headers, format_, + 'InvalidParameterValue', msg) + + LOGGER.debug('Processing properties parameter') + properties = request.params.get('properties') or [] + if isinstance(properties, str): + properties = properties.split(',') + + if properties and not any((fld in properties) + for fld in fields.keys()): + msg = f'Invalid property; valid property names are {list(fields.keys())}' # noqa + headers['Content-Type'] = FORMAT_TYPES[F_JSON] + return api.get_exception( + HTTPStatus.BAD_REQUEST, headers, request.format, + 'InvalidParameterValue', msg) + + query_args['select_properties'] = properties + LOGGER.debug('Generating map') try: data = p.query(**query_args) - except ProviderGenericError as err: + except (ProviderGenericError, ProviderInvalidDataError) as err: + headers['Content-Type'] = FORMAT_TYPES[F_JSON] return api.get_exception( err.http_status_code, headers, request.format, err.ogc_exception_code, err.message) @@ -197,7 +249,7 @@ def get_collection_map_legend(api: API, request: APIRequest, dataset: str, style: str | None = None ) -> Tuple[dict, int, str]: """ - Returns a subset of a collection map legend + Returns an image of a collection map legend :param request: A request object :param dataset: dataset name @@ -279,6 +331,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, if map_extension: mp = load_plugin('provider', map_extension) + coll_properties = deepcopy(parameters)['properties'] + coll_properties['schema']['items']['enum'] = list(mp.fields.keys()) + map_f = deepcopy(parameters['f']) map_f['schema']['enum'] = [map_extension['format']['name']] map_f['schema']['default'] = map_extension['format']['name'] @@ -293,6 +348,7 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, 'parameters': [ {'$ref': '#/components/parameters/bbox'}, {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}, # noqa + {'$ref': f"{OPENAPI_YAML['oamaps']}#/components/parameters/subset"}, # noqa { 'name': 'width', 'in': 'query', @@ -342,6 +398,9 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, } } } + if coll_properties['schema']['items']['enum']: + paths[pth]['get']['parameters'].append(coll_properties) + if mp.time_field is not None: paths[pth]['get']['parameters'].append( {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}) # noqa diff --git a/pygeoapi/openapi.py b/pygeoapi/openapi.py index c3016c828..594218a40 100644 --- a/pygeoapi/openapi.py +++ b/pygeoapi/openapi.py @@ -4,7 +4,7 @@ # Authors: Francesco Bartoli # Authors: Ricardo Garcia Silva # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2025 Francesco Bartoli # Copyright (c) 2023 Ricardo Garcia Silva # @@ -56,7 +56,8 @@ 'oapif-1': 'https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml', # noqa 'oapif-2': 'https://schemas.opengis.net/ogcapi/features/part2/1.0/openapi/ogcapi-features-2.yaml', # noqa 'oapip': 'https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi', - 'oacov': 'https://raw.githubusercontent.com/tomkralidis/ogcapi-coverages-1/fix-cis/yaml-unresolved', # noqa + 'oacov': 'https://raw.githubusercontent.com/opengeospatial/ogcapi-coverages/refs/heads/master/standard/openapi/ogcapi-coverages-1.yaml', # noqa + 'oamaps': 'https://schemas.opengis.net/ogcapi/maps/part1/1.0/openapi/ogcapi-maps-1.yaml', # noqa 'oapir': 'https://raw.githubusercontent.com/opengeospatial/ogcapi-records/master/core/openapi', # noqa 'oaedr': 'https://schemas.opengis.net/ogcapi/edr/1.0/openapi', # noqa 'oapit': 'https://schemas.opengis.net/ogcapi/tiles/part1/1.0/openapi/ogcapi-tiles-1.yaml', # noqa @@ -666,6 +667,20 @@ def get_oas_30_parameters(cfg: dict, locale_: str): 'style': 'form', 'explode': False }, + 'properties': { + 'name': 'properties', + 'in': 'query', + 'description': 'The properties that should be included. The parameter value is a comma-separated list of property names.', # noqa + 'required': False, + 'style': 'form', + 'explode': False, + 'schema': { + 'type': 'array', + 'items': { + 'type': 'string' + } + } + }, 'vendorSpecificParameters': { 'name': 'vendorSpecificParameters', 'in': 'query', diff --git a/pygeoapi/provider/base_edr.py b/pygeoapi/provider/base_edr.py index 69669fe6e..01b1602b6 100644 --- a/pygeoapi/provider/base_edr.py +++ b/pygeoapi/provider/base_edr.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2021 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -55,8 +55,6 @@ def __init__(self, provider_def): BaseProvider.__init__(self, provider_def) -# self.instances = [] - def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) @@ -80,6 +78,15 @@ def __init_subclass__(cls, **kwargs): 'but requests will be routed to a feature provider' ) + def get_instances(self): + """ + Get a list of instance identifiers + + :returns: `list` of instance identifiers + """ + + return NotImplementedError() + def get_instance(self, instance): """ Validate instance identifier diff --git a/pygeoapi/provider/wms_facade.py b/pygeoapi/provider/wms_facade.py index b4f2495f1..e96f3c244 100644 --- a/pygeoapi/provider/wms_facade.py +++ b/pygeoapi/provider/wms_facade.py @@ -2,7 +2,7 @@ # # Authors: Tom Kralidis # -# Copyright (c) 2022 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -66,7 +66,7 @@ def __init__(self, provider_def): def query(self, style=None, bbox=[-180, -90, 180, 90], width=500, height=300, crs=4326, datetime_=None, transparent=True, - bbox_crs=4326, format_='png'): + bbox_crs=4326, format_='png', **kwargs): """ Generate map diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index 6dbd9060f..4038c5b8c 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -4,7 +4,7 @@ # Authors: Tom Kralidis # # Copyright (c) 2020 Gregory Petrochenkov -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation @@ -61,9 +61,14 @@ def __init__(self, provider_def): super().__init__(provider_def) + open_options = {} + squeeze = provider_def.get('options', {}).get('squeeze', False) + zarr_options = provider_def.get('options', {}).get('zarr', {}) + try: if provider_def['data'].endswith('.zarr'): open_func = xarray.open_zarr + open_options = zarr_options else: if '*' in self.data: LOGGER.debug('Detected multi file dataset') @@ -84,7 +89,7 @@ def __init__(self, provider_def): data_to_open = self.data try: - self._data = open_func(data_to_open) + self._data = open_func(data_to_open, **open_options) except ValueError as err: # Manage non-cf-compliant time dimensions if 'time' in str(err): @@ -92,6 +97,10 @@ def __init__(self, provider_def): else: raise err + if squeeze: + LOGGER.debug('Squeezing data') + self._data = self._data.squeeze() + if provider_def.get('storage_crs') is None: self.storage_crs = self._parse_storage_crs() diff --git a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml index f48f615ff..8f0ae873e 100644 --- a/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml +++ b/pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml @@ -415,7 +415,7 @@ properties: - href extents: type: object - description: spatial and temporal extents + description: spatial and temporal extents. Note that adding custom named dimensions is also possible properties: spatial: type: object @@ -450,6 +450,28 @@ properties: type: string description: temporal reference system of features default: 'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian' + patternProperties: + "^(?!spatial$|temporal$).*": + type: object + description: additional custom dimensions + properties: + range: + type: array + description: The overall range of the dimension + minItems: 2 + url: + type: string + format: uri + description: A URI to a description of the dimension + units: + type: string + description: Units of the dimension + values: + type: array + description: enumerated list of values + required: + - url + - range required: - spatial limits: @@ -567,7 +589,6 @@ properties: http://www.opengis.net/def/crs/OGC/1.3/CRS84 storage_crs_coordinate_epoch: type: number - format: uri description: |- point in time at which coordinates in the spatial feature collection are referenced to the dynamic coordinate reference system in `storageCrs`, that may be used to retrieve features from a diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 3625eac94..0f8e785c1 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -592,6 +592,7 @@ def test_describe_collections(config, api_): assert collection['title'] == 'Observations' assert collection['description'] == 'My cool observations' assert len(collection['links']) == 15 + assert collection['extent'] == { 'spatial': { 'bbox': [[-180, -90, 180, 90]], @@ -656,6 +657,28 @@ def test_describe_collections(config, api_): assert collection['storageCrs'] == 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' # noqa assert collection['storageCrsCoordinateEpoch'] == 2017.23 + # test custom extents + rsp_headers, code, response = describe_collections( + api_, req, 'mapserver_world_map') + + collection = json.loads(response) + + assert collection['extent'] == { + 'spatial': { + 'bbox': [[-180, -90, 180, 90]], + 'crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' + }, + 'custom-extent': { + 'definition': 'https://example.org/custom-extent', + 'interval': [[0, 10]], + 'unit': '°C', + 'grid': { + 'cellsCount': 3, + 'coordinates': [0, 5, 10] + } + } + } + def test_describe_collections_hidden_resources( config_hidden_resources, api_hidden_resources): diff --git a/tests/api/test_environmental_data_retrieval.py b/tests/api/test_environmental_data_retrieval.py index 8b7d665e1..8d028a7c2 100644 --- a/tests/api/test_environmental_data_retrieval.py +++ b/tests/api/test_environmental_data_retrieval.py @@ -5,7 +5,7 @@ # Colin Blackburn # Bernhard Mallinger # -# Copyright (c) 2025 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # # Permission is hereby granted, free of charge, to any person @@ -81,6 +81,7 @@ def test_get_collection_edr_query(config, api_): rsp_headers, code, response = get_collection_edr_query( api_, req, 'icoads-sst', None, 'position') assert code == HTTPStatus.OK + assert rsp_headers['Content-Type'] == 'application/vnd.cov+json' data = json.loads(response) diff --git a/tests/api/test_maps.py b/tests/api/test_maps.py index fb043dfeb..b13228d2f 100644 --- a/tests/api/test_maps.py +++ b/tests/api/test_maps.py @@ -5,7 +5,7 @@ # Colin Blackburn # Bernhard Mallinger # -# Copyright (c) 2024 Tom Kralidis +# Copyright (c) 2026 Tom Kralidis # Copyright (c) 2022 John A Stevenson and Colin Blackburn # # Permission is hereby granted, free of charge, to any person @@ -51,6 +51,18 @@ def test_get_collection_map(config, api_): assert isinstance(response, bytes) assert response[1:4] == b'PNG' + req = mock_api_request({'subset': 'foo("bar")'}) + rsp_headers, code, response = get_collection_map( + api_, req, 'mapserver_world_map') + + assert code == HTTPStatus.BAD_REQUEST + + req = mock_api_request({'properties': 'foo,bar'}) + rsp_headers, code, response = get_collection_map( + api_, req, 'mapserver_world_map') + + assert code == HTTPStatus.NOT_IMPLEMENTED + def test_map_crs_transform(config, api_): # Florida in EPSG:4326 @@ -58,9 +70,11 @@ def test_map_crs_transform(config, api_): 'bbox': '-88.374023,24.826625,-78.112793,31.015279', # crs is 4326 by implicit since it is the default } + req = mock_api_request(params) _, code, floridaIn4326 = get_collection_map( api_, req, 'mapserver_world_map') + assert code == HTTPStatus.OK # Area that isn't florida in the ocean; used to make sure @@ -73,6 +87,7 @@ def test_map_crs_transform(config, api_): req = mock_api_request(params) _, code, florida4326InWrongCRS = get_collection_map( api_, req, 'mapserver_world_map') + assert code == HTTPStatus.OK assert florida4326InWrongCRS != floridaIn4326 @@ -82,8 +97,10 @@ def test_map_crs_transform(config, api_): 'bbox': '-9837751.2884,2854464.3843,-8695476.3377,3634733.5690', 'bbox-crs': 'http://www.opengis.net/def/crs/EPSG/0/3857' } + req = mock_api_request(params) _, code, floridaProjectedIn3857 = get_collection_map( api_, req, 'mapserver_world_map') + assert code == HTTPStatus.OK assert floridaIn4326 == floridaProjectedIn3857 diff --git a/tests/pygeoapi-test-config.yml b/tests/pygeoapi-test-config.yml index 6efe0312f..13dc63aa9 100644 --- a/tests/pygeoapi-test-config.yml +++ b/tests/pygeoapi-test-config.yml @@ -387,6 +387,11 @@ resources: spatial: bbox: [-180,-90,180,90] crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + custom-extent: + url: https://example.org/custom-extent + units: °C + range: [0, 10] + values: [0, 5, 10] providers: - type: map name: WMSFacade From 659b19420774635c87138c52fa499edcd5834529 Mon Sep 17 00:00:00 2001 From: Tom Kralidis Date: Mon, 19 Jan 2026 21:01:56 -0500 Subject: [PATCH 2/2] add EDR plugin documentation (#2162) --- docs/source/plugins.rst | 62 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 4d02699ca..4d7e52e59 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -30,10 +30,15 @@ The core pygeoapi plugin registry can be found in ``pygeoapi.plugin.PLUGINS``. Each plugin type implements its relevant base class as the API contract: -* data providers: ``pygeoapi.provider.base`` -* output formats: ``pygeoapi.formatter.base`` -* processes: ``pygeoapi.process.base`` -* process_manager: ``pygeoapi.process.manager.base`` +* data providers: + + * features/records/maps: ``pygeoapi.provider.base.BaseProvider`` + * edr: ``pygeoapi.provider.base_edr.BaseEDRProvider`` + * tiles: ``pygeoapi.provider.tile.BaseTileProvider`` + +* output formats: ``pygeoapi.formatter.base.BaseFormatter`` +* processes: ``pygeoapi.process.base.BaseProcessor`` +* process_manager: ``pygeoapi.process.manager.base.BaseManager`` .. todo:: link PLUGINS to API doc @@ -150,7 +155,7 @@ option 2 above). Example: custom pygeoapi vector data provider --------------------------------------------- -Lets consider the steps for a vector data provider plugin: +Let's consider the steps for a vector data provider plugin: Python code ^^^^^^^^^^^ @@ -223,7 +228,7 @@ Each base class documents the functions, arguments and return types required for Example: custom pygeoapi raster data provider --------------------------------------------- -Lets consider the steps for a raster data provider plugin: +Let's consider the steps for a raster data provider plugin: Python code ^^^^^^^^^^^ @@ -278,6 +283,51 @@ Each base class documents the functions, arguments and return types required for .. _example-custom-pygeoapi-processing-plugin: +Example: custom pygeoapi EDR data provider +------------------------------------------ + +Let's consider the steps for an EDR data provider plugin: + +Python code +^^^^^^^^^^^ + +The below template provides a minimal example (let's call the file ``mycooledrdata.py``: + +.. code-block:: python + + from pygeoapi.provider.base_edr import BaseEDRProvider + + class MyCoolEDRDataProvider(BaseEDRProvider): + + def __init__(self, provider_def): + """Inherit from the parent class""" + + super().__init__(provider_def) + + self.covjson = {...} + + def get_instances(self): + return ['foo', 'bar'] + + def get_instance(self, instance): + return instance in get_instances() + + def position(self, **kwargs): + return self.covjson + + def trajectory(self, **kwargs): + return self.covjson + + +For brevity, the ``position`` function returns ``self.covjson`` which is a +dictionary of a CoverageJSON representation. ``get_instances`` returns a list +of instances associated with the collection/plugin, and ``get_instance`` returns +a boolean of whether a given instance exists/is valid. EDR query types are subject +to the query functions defined in the plugin. In the example above, the plugin +implements ``position`` and ``trajectory`` queries, which will be advertised as +supported query types. + + Example: custom pygeoapi processing plugin ------------------------------------------