Skip to content

Commit 4ae51b6

Browse files
committed
fix support for custom formatters (#1997)
1 parent 3a7b835 commit 4ae51b6

11 files changed

Lines changed: 124 additions & 42 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
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: 10 additions & 1 deletion
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__)
@@ -1042,6 +1043,14 @@ def describe_collections(api: API, request: APIRequest,
10421043
'href': f'{api.get_collections_url()}/{k}/items?f={F_HTML}' # noqa
10431044
})
10441045

1046+
for key, value in get_dataset_formatters(v).items():
1047+
collection['links'].append({
1048+
'type': value.mimetype,
1049+
'rel': 'items',
1050+
'title': l10n.translate(f'Items as {key}', request.locale), # noqa
1051+
'href': f'{api.get_collections_url()}/{k}/items?f={value.f}' # noqa
1052+
})
1053+
10451054
# OAPIF Part 2 - list supported CRSs and StorageCRS
10461055
if collection_data_type in ['edr', 'feature']:
10471056
collection['crs'] = get_supported_crs_list(collection_data)

pygeoapi/api/itemtypes.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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,12 @@ 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 not request.is_valid(dataset_formatters.keys()):
358+
return api.get_format_exception(request)
359+
355360
crs_transform_spec = None
356361
if provider_type == 'feature':
357362
# crs query parameter is only available for OGC API - Features
@@ -581,6 +586,14 @@ def get_collection_items(
581586
'href': f'{uri}?f={F_HTML}{serialized_query_params}'
582587
}])
583588

589+
for key, value in dataset_formatters.items():
590+
content['links'].append({
591+
'type': value.mimetype,
592+
'rel': 'alternate',
593+
'title': f'This document as {key}',
594+
'href': f'{uri}?f={value.name}{serialized_query_params}'
595+
})
596+
584597
next_link = False
585598
prev_link = False
586599

@@ -656,9 +669,9 @@ def get_collection_items(
656669
'collections/items/index.html',
657670
content, request.locale)
658671
return headers, HTTPStatus.OK, content
659-
elif request.format == 'csv': # render
660-
formatter = load_plugin('formatter',
661-
{'name': 'CSV', 'geom': True})
672+
elif request.format in [df.f for df in dataset_formatters.values()]:
673+
formatter = [v for k, v in dataset_formatters.items() if
674+
v.f == request.format][0]
662675

663676
try:
664677
content = formatter.write(
@@ -677,13 +690,14 @@ def get_collection_items(
677690

678691
headers['Content-Type'] = formatter.mimetype
679692

680-
if p.filename is None:
681-
filename = f'{dataset}.csv'
682-
else:
683-
filename = f'{p.filename}'
693+
if formatter.attachment:
694+
if p.filename is None:
695+
filename = f'{dataset}.{formatter.extension}'
696+
else:
697+
filename = f'{p.filename}'
684698

685-
cd = f'attachment; filename="{filename}"'
686-
headers['Content-Disposition'] = cd
699+
cd = f'attachment; filename="{filename}"'
700+
headers['Content-Disposition'] = cd
687701

688702
return headers, HTTPStatus.OK, content
689703

@@ -1073,14 +1087,19 @@ def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str,
10731087
v.get('limits', {})
10741088
)
10751089

1090+
dataset_formatters = get_dataset_formatters(v)
1091+
coll_f_parameter = deepcopy(get_oas_30_parameters(cfg, locale))['f'] # noqa
1092+
for key, value in dataset_formatters.items():
1093+
coll_f_parameter['schema']['enum'].append(value.f)
1094+
10761095
paths[items_path] = {
10771096
'get': {
10781097
'summary': f'Get {title} items',
10791098
'description': description,
10801099
'tags': [k],
10811100
'operationId': f'get{k.capitalize()}Features',
10821101
'parameters': [
1083-
{'$ref': '#/components/parameters/f'},
1102+
coll_f_parameter,
10841103
{'$ref': '#/components/parameters/lang'},
10851104
{'$ref': '#/components/parameters/bbox'},
10861105
coll_limit,

pygeoapi/formatter/base.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,23 @@ 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
4748
self.mimetype = None
48-
self.geom = False
4949

5050
self.name = formatter_def['name']
51-
if 'geom' in formatter_def:
52-
self.geom = formatter_def['geom']
51+
self.geom = formatter_def.get('geom', False)
52+
self.attachment = formatter_def.get('attachment', False)
5353

5454
def write(self, options: dict = {}, data: dict | None = None) -> str:
5555
"""
5656
Generate data in specified format
5757
58-
:param options: CSV formatting options
58+
:param options: formatting options
5959
:param data: dict representation of GeoJSON object
6060
6161
:returns: string representation of format

pygeoapi/formatter/csv_.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ def __init__(self, formatter_def: dict):
4848
:returns: `pygeoapi.formatter.csv_.CSVFormatter`
4949
"""
5050

51-
geom = False
52-
if 'geom' in formatter_def:
53-
geom = formatter_def['geom']
51+
geom = formatter_def.get('geom', False)
5452

5553
super().__init__({'name': 'csv', 'geom': geom})
5654
self.mimetype = 'text/csv; charset=utf-8'
55+
self.f = 'csv'
56+
self.extension = 'csv'
5757

5858
def write(self, options: dict = {}, data: dict = None) -> str:
5959
"""

pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,25 @@ properties:
583583
- type
584584
- name
585585
- data
586+
formatters:
587+
type: array
588+
description: custom formatters to apply to output
589+
items:
590+
type: object
591+
properties:
592+
name:
593+
type: string
594+
description: name of formatter
595+
geom:
596+
type: boolean
597+
default: true
598+
description: whether to include geometry
599+
attachment:
600+
type: boolean
601+
default: false
602+
description: whether to provide as an attachment
603+
required:
604+
- name
586605
required:
587606
- type
588607
- title

pygeoapi/util.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
from pygeoapi import __version__
6565
from pygeoapi import l10n
6666
from pygeoapi.models import config as config_models
67+
from pygeoapi.plugin import load_plugin, PLUGINS
6768
from pygeoapi.provider.base import ProviderTypeError
6869

6970

@@ -764,3 +765,24 @@ def get_choice_from_headers(headers: dict,
764765

765766
# Return one or all choices
766767
return sorted_choices if all else sorted_choices[0]
768+
769+
770+
def get_dataset_formatters(dataset: dict) -> dict:
771+
"""
772+
Helper function to derive all formatters for an itemtype
773+
774+
:param dataset: `dict` of dataset resource definition
775+
776+
:returns: `dict` of formatters
777+
"""
778+
779+
dataset_formatters = {}
780+
781+
for key, value in PLUGINS['formatter'].items():
782+
df2 = load_plugin('formatter', {'name': key})
783+
dataset_formatters[key] = df2
784+
for df in dataset.get('formatters', []):
785+
df2 = load_plugin('formatter', df)
786+
dataset_formatters[df2.name] = df2
787+
788+
return dataset_formatters

tests/api/test_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,7 @@ def test_describe_collections(config, api_):
591591
assert collection['id'] == 'obs'
592592
assert collection['title'] == 'Observations'
593593
assert collection['description'] == 'My cool observations'
594-
assert len(collection['links']) == 14
594+
assert len(collection['links']) == 15
595595
assert collection['extent'] == {
596596
'spatial': {
597597
'bbox': [[-180, -90, 180, 90]],
@@ -682,7 +682,7 @@ def test_describe_collections_json_ld(config, api_):
682682
assert len(expanded['http://schema.org/dataset']) == 1
683683
dataset = expanded['http://schema.org/dataset'][0]
684684
assert dataset['@type'][0] == 'http://schema.org/Dataset'
685-
assert len(dataset['http://schema.org/distribution']) == 14
685+
assert len(dataset['http://schema.org/distribution']) == 15
686686
assert all(dist['@type'][0] == 'http://schema.org/DataDownload'
687687
for dist in dataset['http://schema.org/distribution'])
688688

0 commit comments

Comments
 (0)