Skip to content

Commit 9af8336

Browse files
authored
fix support for custom formatters (#1997) (#2144)
* fix support for custom formatters (#1997) * fix typo * add formatters for EDR
1 parent 21b0f11 commit 9af8336

13 files changed

Lines changed: 235 additions & 57 deletions

File tree

docs/source/configuration.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ default.
257257
storage_crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 # optional CRS in which data is stored, default: as 'crs' field
258258
storage_crs_coordinate_epoch: 2017.23 # optional, if storage_crs is a dynamic coordinate reference system
259259
always_xy: false # optional should CRS respect axis ordering
260+
formatters: # list of 1..n formatter definitions
261+
- name: path.to.formatter # Python path of formatter definition
262+
attachment: true # whether or not to provide as an attachment or normal response
263+
geom: false # whether or not to include geometry
260264
261265
hello-world: # name of process
262266
type: process # REQUIRED (collection, process, or stac-collection)

docs/source/plugins.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,10 @@ The below template provides a minimal example (let's call the file ``mycooljsonf
435435
"""Inherit from parent class"""
436436
437437
super().__init__({'name': 'cooljson', 'geom': None})
438-
self.mimetype = 'application/json; subtype:mycooljson'
438+
self.f = 'cooljson' # f= value
439+
self.mimetype = 'application/json; subtype:mycooljson' # response media type
440+
self.attachment = False # whether to provide as an attachment (default False)
441+
self.extension = 'cooljson' # filename extension if providing as an attachment
439442
440443
def write(self, options={}, data=None):
441444
"""custom writer"""

pygeoapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def decorator(click_group):
6868
try:
6969
click_group.add_command(entry_point.load())
7070
except Exception as err:
71-
print(err)
71+
click.echo(err)
7272
return click_group
7373

7474
return decorator

pygeoapi/api/__init__.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@
6868
TEMPLATESDIR, UrlPrefetcher, dategetter,
6969
filter_dict_by_key_value, filter_providers_by_type, get_api_rules,
7070
get_base_url, get_provider_by_type, get_provider_default, get_typed_value,
71-
render_j2_template, to_json, get_choice_from_headers, get_from_headers
71+
render_j2_template, to_json, get_choice_from_headers, get_from_headers,
72+
get_dataset_formatters
7273
)
7374

7475
LOGGER = logging.getLogger(__name__)
@@ -319,11 +320,14 @@ def _get_locale(self, headers: dict,
319320

320321
return raw, default_locale
321322

322-
def _get_format(self, headers: dict) -> Union[str, None]:
323+
def _get_format(self, headers: dict,
324+
extra_formats: dict = {}) -> Union[str, None]:
323325
"""
324326
Get `Request` format type from query parameters or headers.
325327
326328
:param headers: Dict of Request headers
329+
:param extra_formats: Dict of extra dataset specific formats
330+
327331
:returns: format value or None if not found/specified
328332
"""
329333

@@ -339,10 +343,14 @@ def _get_format(self, headers: dict) -> Union[str, None]:
339343
if types_ is None:
340344
return
341345

342-
(fmts, mimes) = zip(*FORMAT_TYPES.items())
346+
merged_format_types = FORMAT_TYPES | extra_formats
347+
348+
(fmts, mimes) = zip(*merged_format_types.items())
349+
mimes2 = [m.split(';')[0] for m in mimes]
350+
343351
for type_ in types_:
344-
if type_ in mimes:
345-
idx_ = mimes.index(type_)
352+
if type_ in mimes2:
353+
idx_ = mimes2.index(type_)
346354
return fmts[idx_]
347355

348356
@property
@@ -1042,6 +1050,14 @@ def describe_collections(api: API, request: APIRequest,
10421050
'href': f'{api.get_collections_url()}/{k}/items?f={F_HTML}' # noqa
10431051
})
10441052

1053+
for key, value in get_dataset_formatters(v).items():
1054+
collection['links'].append({
1055+
'type': value.mimetype,
1056+
'rel': 'items',
1057+
'title': l10n.translate(f'Items as {key}', request.locale), # noqa
1058+
'href': f'{api.get_collections_url()}/{k}/items?f={value.f}' # noqa
1059+
})
1060+
10451061
# OAPIF Part 2 - list supported CRSs and StorageCRS
10461062
if collection_data_type in ['edr', 'feature']:
10471063
collection['crs'] = get_supported_crs_list(collection_data)
@@ -1221,6 +1237,15 @@ def describe_collections(api: API, request: APIRequest,
12211237
'href': f'{api.get_collections_url()}/{k}/{qt}?f={F_HTML}'
12221238
})
12231239

1240+
for key, value in get_dataset_formatters(v).items():
1241+
title3 = f'{qt} query for this collection as {key}'
1242+
collection['links'].append({
1243+
'type': value.mimetype,
1244+
'rel': 'data',
1245+
'title': title3,
1246+
'href': f'{api.get_collections_url()}/{k}/{qt}?f={value.f}' # noqa
1247+
})
1248+
12241249
if dataset is not None and k == dataset:
12251250
fcm = collection
12261251
break

pygeoapi/api/environmental_data_retrieval.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it>
99
# Bernhard Mallinger <bernhard.mallinger@eox.at>
1010
#
11-
# Copyright (c) 2025 Tom Kralidis
11+
# Copyright (c) 2026 Tom Kralidis
1212
# Copyright (c) 2025 Francesco Bartoli
1313
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
1414
# Copyright (c) 2023 Ricardo Garcia Silva
@@ -37,7 +37,7 @@
3737
#
3838
# =================================================================
3939

40-
40+
from copy import deepcopy
4141
from http import HTTPStatus
4242
import logging
4343
from typing import Tuple
@@ -49,13 +49,15 @@
4949

5050
from pygeoapi import l10n
5151
from pygeoapi.api import evaluate_limit
52+
from pygeoapi.formatter.base import FormatterSerializationError
5253
from pygeoapi.crs import (create_crs_transform_spec, set_content_crs_header)
54+
from pygeoapi.openapi import get_oas_30_parameters
5355
from pygeoapi.plugin import load_plugin, PLUGINS
5456
from pygeoapi.provider.base import (
5557
ProviderGenericError, ProviderItemNotFoundError)
5658
from pygeoapi.util import (
57-
filter_providers_by_type, get_provider_by_type, get_typed_value,
58-
render_j2_template, to_json, filter_dict_by_key_value
59+
filter_providers_by_type, get_dataset_formatters, get_provider_by_type,
60+
get_typed_value, render_j2_template, to_json, filter_dict_by_key_value
5961
)
6062

6163
from . import (APIRequest, API, F_COVERAGEJSON, F_HTML, F_JSON, F_JSONLD,
@@ -253,8 +255,6 @@ def get_collection_edr_query(api: API, request: APIRequest,
253255
:returns: tuple of headers, status code, content
254256
"""
255257

256-
if not request.is_valid(PLUGINS['formatter'].keys()):
257-
return api.get_format_exception(request)
258258
headers = request.get_response_headers(api.default_locale,
259259
**api.api_headers)
260260
collections = filter_dict_by_key_value(api.config['resources'],
@@ -287,6 +287,20 @@ def get_collection_edr_query(api: API, request: APIRequest,
287287
HTTPStatus.BAD_REQUEST, headers, request.format,
288288
'InvalidParameterValue', msg)
289289

290+
LOGGER.debug('Validating requested format')
291+
dataset_formatters = get_dataset_formatters(collections[dataset])
292+
293+
if dataset_formatters:
294+
LOGGER.debug(f'Dataset formatters: {dataset_formatters}')
295+
request._format = request._get_format(
296+
request.get_request_headers(request.headers),
297+
{v.f: v.mimetype for v in dataset_formatters.values()})
298+
299+
LOGGER.debug(f'Request format: {request.format}')
300+
301+
if not request.is_valid(dataset_formatters.keys()):
302+
return api.get_format_exception(request)
303+
290304
crs_transform_spec = None
291305
query_crs_uri = request.params.get('crs')
292306
if query_crs_uri is not None:
@@ -442,6 +456,30 @@ def get_collection_edr_query(api: API, request: APIRequest,
442456
content = render_j2_template(api.tpl_config, tpl_config,
443457
'collections/edr/query.html', data,
444458
api.default_locale)
459+
elif request.format in [df.f for df in dataset_formatters.values()]:
460+
formatter = [v for v in dataset_formatters.values() if
461+
v.f == request.format][0]
462+
463+
try:
464+
content = formatter.write(
465+
data=data,
466+
options={
467+
'provider_def': get_provider_by_type(
468+
collections[dataset]['providers'],
469+
'edr')
470+
}
471+
)
472+
except FormatterSerializationError:
473+
msg = 'Error serializing output'
474+
return api.get_exception(
475+
HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format,
476+
'NoApplicableCode', msg)
477+
478+
if formatter.attachment:
479+
filename = f'{dataset}.{formatter.extension}'
480+
cd = f'attachment; filename="{filename}"'
481+
headers['Content-Disposition'] = cd
482+
445483
else:
446484
content = to_json(data, api.pretty_print)
447485

@@ -515,6 +553,12 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str,
515553
spatial_parameter = {
516554
'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/{eqe['qt']}Coords.yaml" # noqa
517555
}
556+
557+
dataset_formatters = get_dataset_formatters(v)
558+
coll_f_parameter = deepcopy(get_oas_30_parameters(cfg, locale))['f'] # noqa
559+
for key, value in dataset_formatters.items():
560+
coll_f_parameter['schema']['enum'].append(value.f)
561+
518562
paths[eqe['path']] = {
519563
'get': {
520564
'summary': f"query {description} by {eqe['qt']}",
@@ -527,7 +571,7 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str,
527571
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/parameter-name.yaml"}, # noqa
528572
{'$ref': f"{OPENAPI_YAML['oaedr']}/parameters/z.yaml"}, # noqa
529573
{'$ref': '#/components/parameters/crs'},
530-
{'$ref': '#/components/parameters/f'}
574+
coll_f_parameter,
531575
],
532576
'responses': {
533577
'200': {

pygeoapi/api/itemtypes.py

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
# Colin Blackburn <colb@bgs.ac.uk>
88
# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it>
99
#
10-
# Copyright (c) 2025 Tom Kralidis
10+
# Copyright (c) 2026 Tom Kralidis
1111
# Copyright (c) 2025 Francesco Bartoli
1212
# Copyright (c) 2022 John A Stevenson and Colin Blackburn
1313
# Copyright (c) 2023 Ricardo Garcia Silva
@@ -55,13 +55,15 @@
5555
set_content_crs_header)
5656
from pygeoapi.formatter.base import FormatterSerializationError
5757
from pygeoapi.linked_data import geojson2jsonld
58+
from pygeoapi.openapi import get_oas_30_parameters
5859
from pygeoapi.plugin import load_plugin, PLUGINS
5960
from pygeoapi.provider.base import (
6061
ProviderGenericError, ProviderTypeError, SchemaType)
6162

6263
from pygeoapi.util import (filter_providers_by_type, to_json,
6364
filter_dict_by_key_value, str2bool,
64-
get_provider_by_type, render_j2_template)
65+
get_provider_by_type, render_j2_template,
66+
get_dataset_formatters)
6567

6668
from . import (
6769
APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD,
@@ -241,9 +243,6 @@ def get_collection_items(
241243
:returns: tuple of headers, status code, content
242244
"""
243245

244-
if not request.is_valid(PLUGINS['formatter'].keys()):
245-
return api.get_format_exception(request)
246-
247246
# Set Content-Language to system locale until provider locale
248247
# has been determined
249248
headers = request.get_response_headers(SYSTEM_LOCALE,
@@ -352,6 +351,20 @@ def get_collection_items(
352351
err.http_status_code, headers, request.format,
353352
err.ogc_exception_code, err.message)
354353

354+
LOGGER.debug('Validating requested format')
355+
dataset_formatters = get_dataset_formatters(collections[dataset])
356+
357+
if dataset_formatters:
358+
LOGGER.debug(f'Dataset formatters: {dataset_formatters}')
359+
request._format = request._get_format(
360+
request.get_request_headers(request.headers),
361+
{v.f: v.mimetype for v in dataset_formatters.values()})
362+
363+
LOGGER.debug(f'Request format: {request.format}')
364+
365+
if not request.is_valid(dataset_formatters.keys()):
366+
return api.get_format_exception(request)
367+
355368
crs_transform_spec = None
356369
if provider_type == 'feature':
357370
# crs query parameter is only available for OGC API - Features
@@ -581,6 +594,14 @@ def get_collection_items(
581594
'href': f'{uri}?f={F_HTML}{serialized_query_params}'
582595
}])
583596

597+
for key, value in dataset_formatters.items():
598+
content['links'].append({
599+
'type': value.mimetype,
600+
'rel': 'alternate',
601+
'title': f'This document as {key}',
602+
'href': f'{uri}?f={value.name}{serialized_query_params}'
603+
})
604+
584605
next_link = False
585606
prev_link = False
586607

@@ -656,9 +677,9 @@ def get_collection_items(
656677
'collections/items/index.html',
657678
content, request.locale)
658679
return headers, HTTPStatus.OK, content
659-
elif request.format == 'csv': # render
660-
formatter = load_plugin('formatter',
661-
{'name': 'CSV', 'geom': True})
680+
elif request.format in [df.f for df in dataset_formatters.values()]:
681+
formatter = [v for v in dataset_formatters.values() if
682+
v.f == request.format][0]
662683

663684
try:
664685
content = formatter.write(
@@ -677,13 +698,14 @@ def get_collection_items(
677698

678699
headers['Content-Type'] = formatter.mimetype
679700

680-
if p.filename is None:
681-
filename = f'{dataset}.csv'
682-
else:
683-
filename = f'{p.filename}'
701+
if formatter.attachment:
702+
if p.filename is None:
703+
filename = f'{dataset}.{formatter.extension}'
704+
else:
705+
filename = f'{p.filename}'
684706

685-
cd = f'attachment; filename="{filename}"'
686-
headers['Content-Disposition'] = cd
707+
cd = f'attachment; filename="{filename}"'
708+
headers['Content-Disposition'] = cd
687709

688710
return headers, HTTPStatus.OK, content
689711

@@ -1073,14 +1095,19 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str,
10731095
v.get('limits', {})
10741096
)
10751097

1098+
dataset_formatters = get_dataset_formatters(v)
1099+
coll_f_parameter = deepcopy(get_oas_30_parameters(cfg, locale))['f'] # noqa
1100+
for key, value in dataset_formatters.items():
1101+
coll_f_parameter['schema']['enum'].append(value.f)
1102+
10761103
paths[items_path] = {
10771104
'get': {
10781105
'summary': f'Get {title} items',
10791106
'description': description,
10801107
'tags': [k],
10811108
'operationId': f'get{k.capitalize()}Features',
10821109
'parameters': [
1083-
{'$ref': '#/components/parameters/f'},
1110+
coll_f_parameter,
10841111
{'$ref': '#/components/parameters/lang'},
10851112
{'$ref': '#/components/parameters/bbox'},
10861113
coll_limit,

pygeoapi/formatter/base.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# Authors: Tom Kralidis <tomkralidis@gmail.com>
44
#
5-
# Copyright (c) 2022 Tom Kralidis
5+
# Copyright (c) 2026 Tom Kralidis
66
#
77
# Permission is hereby granted, free of charge, to any person
88
# obtaining a copy of this software and associated documentation
@@ -39,23 +39,28 @@ def __init__(self, formatter_def: dict):
3939
"""
4040
Initialize object
4141
42-
:param formatter_def: formatter definition
42+
param formatter_def: formatter definition
4343
4444
:returns: pygeoapi.formatter.base.BaseFormatter
4545
"""
4646

47+
self.extension = None
48+
self.f = None
4749
self.mimetype = None
48-
self.geom = False
4950

50-
self.name = formatter_def['name']
51-
if 'geom' in formatter_def:
52-
self.geom = formatter_def['geom']
51+
try:
52+
self.name = formatter_def['name']
53+
except KeyError:
54+
raise RuntimeError('name is required')
55+
56+
self.geom = formatter_def.get('geom', False)
57+
self.attachment = formatter_def.get('attachment', False)
5358

5459
def write(self, options: dict = {}, data: dict | None = None) -> str:
5560
"""
5661
Generate data in specified format
5762
58-
:param options: CSV formatting options
63+
:param options: formatting options
5964
:param data: dict representation of GeoJSON object
6065
6166
:returns: string representation of format

0 commit comments

Comments
 (0)