Skip to content

Commit 9d60950

Browse files
authoredMar 11, 2025··
Support GeoJSON-LD templating (#1927)
* Support JSON-LD template for items * Create `item_list.jsonld` template Create `item_list.jsonld` template to backport current /items json-ld content offerings * Reduce number of times templated geojsonld is serialized * Add test for linked_data.py * Use json serializer in test * Run test in CI * Add test for single item * Use single quotes * Fix flake8 * Use collection level template for GeoJSON-LD * Un-nest JSON-LD context from resource config * Respond to PR feedback * Update test_linked_data.py * Revert "Un-nest JSON-LD context from resource config" This reverts commit 5b371b2. * Update documentation
1 parent 3a9d853 commit 9d60950

13 files changed

+242
-46
lines changed
 

‎.github/workflows/main.yml

+1
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ jobs:
128128
pytest tests/test_esri_provider.py
129129
pytest tests/test_filesystem_provider.py
130130
pytest tests/test_geojson_provider.py
131+
pytest tests/test_linked_data.py
131132
pytest tests/test_mongo_provider.py
132133
pytest tests/test_ogr_csv_provider.py
133134
pytest tests/test_ogr_esrijson_provider.py

‎docs/source/configuration.rst

+5-10
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,6 @@ default.
197197
- observations
198198
- monitoring
199199
linked-data: # linked data configuration (see Linked Data section)
200-
item_template: tests/data/base.jsonld
201200
context:
202201
- datetime: https://schema.org/DateTime
203202
- vocab: https://example.com/vocab#
@@ -646,23 +645,19 @@ This relationship can further be maintained in the JSON-LD structured data using
646645
ssn: "http://www.w3.org/ns/ssn/"
647646
Datastream: sosa:isMemberOf
648647
649-
Sometimes, the JSON-LD desired for an individual feature in a collection is more complicated than can be achieved by
650-
aliasing properties using a context. In this case, it is possible to specify a Jinja2 template. When ``item_template``
651-
is defined for a feature collection, the json-ld prepared by pygeoapi will be used to render the Jinja2 template
652-
specified by the path. The path specified can be absolute or relative to pygeoapi's template folder. For even more
653-
deployment flexibility, the path can be specified with string interpolation of environment variables.
648+
Sometimes, the JSON-LD desired for an individual feature in a collection is more complicated than can
649+
be achieved by aliasing properties using a context. In this case, it is possible to implement a custom
650+
Jinja2 template. GeoJSON-LD is rendered using the Jinja2 templates defined in ``collections/items/item.jsonld``
651+
and ``collections/items/index.jsonld``. A pygeoapi collection requiring custom GeoJSON-LD can overwrite these
652+
templates using dataset level templating. To learn more about Jinja2 templates, see :ref:`html-templating`.
654653

655654

656655
.. code-block:: yaml
657656
658657
linked-data:
659-
item_template: tests/data/base.jsonld
660658
context:
661659
- datetime: https://schema.org/DateTime
662660
663-
.. note::
664-
The template ``tests/data/base.jsonld`` renders the unmodified JSON-LD. For more information on the capacities
665-
of Jinja2 templates, see :ref:`html-templating`.
666661
667662
Validating the configuration
668663
----------------------------

‎pygeoapi/api/itemtypes.py

+4
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,8 @@ def get_collection_items(
648648
api, content, dataset, id_field=(p.uri_field or 'id')
649649
)
650650

651+
return headers, HTTPStatus.OK, content
652+
651653
return headers, HTTPStatus.OK, to_json(content, api.pretty_print)
652654

653655

@@ -930,6 +932,8 @@ def get_collection_item(api: API, request: APIRequest,
930932
api, content, dataset, uri, (p.uri_field or 'id')
931933
)
932934

935+
return headers, HTTPStatus.OK, content
936+
933937
return headers, HTTPStatus.OK, to_json(content, api.pretty_print)
934938

935939

‎pygeoapi/linked_data.py

+32-24
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,10 @@
3131
Returns content as linked data representations
3232
"""
3333

34-
import json
3534
import logging
3635
from typing import Callable
3736

38-
from pygeoapi.util import is_url, render_j2_template
37+
from pygeoapi.util import is_url, render_j2_template, url_join
3938
from pygeoapi import l10n
4039
from shapely.geometry import shape
4140
from shapely.ops import unary_union
@@ -189,30 +188,22 @@ def geojson2jsonld(cls, data: dict, dataset: str,
189188
:returns: string of rendered JSON (GeoJSON-LD)
190189
"""
191190

192-
LOGGER.debug('Fetching context and template from resource configuration')
193-
jsonld = cls.config['resources'][dataset].get('linked-data', {})
194-
ds_url = f"{cls.get_collections_url()}/{dataset}"
195-
196-
context = jsonld.get('context', []).copy()
197-
template = jsonld.get('item_template', None)
191+
LOGGER.debug('Fetching context from resource configuration')
192+
context = cls.config['resources'][dataset].get('context', []).copy()
193+
templates = cls.get_dataset_templates(dataset)
198194

199195
defaultVocabulary = {
200196
'schema': 'https://schema.org/',
197+
'gsp': 'http://www.opengis.net/ont/geosparql#',
201198
'type': '@type'
202199
}
203200

204201
if identifier:
205-
# Single jsonld
206-
defaultVocabulary.update({
207-
'gsp': 'http://www.opengis.net/ont/geosparql#'
208-
})
209-
210202
# Expand properties block
211203
data.update(data.pop('properties'))
212204

213205
# Include multiple geometry encodings
214206
if (data.get('geometry') is not None):
215-
data['type'] = 'schema:Place'
216207
jsonldify_geometry(data)
217208

218209
data['@id'] = identifier
@@ -224,6 +215,7 @@ def geojson2jsonld(cls, data: dict, dataset: str,
224215
'FeatureCollection': 'schema:itemList'
225216
})
226217

218+
ds_url = url_join(cls.get_collections_url(), dataset)
227219
data['@id'] = ds_url
228220

229221
for i, feature in enumerate(data['features']):
@@ -233,9 +225,15 @@ def geojson2jsonld(cls, data: dict, dataset: str,
233225
if not is_url(str(identifier_)):
234226
identifier_ = f"{ds_url}/items/{feature['id']}" # noqa
235227

228+
# Include multiple geometry encodings
229+
if feature.get('geometry') is not None:
230+
jsonldify_geometry(feature)
231+
236232
data['features'][i] = {
237233
'@id': identifier_,
238-
'type': 'schema:Place'
234+
'type': 'schema:Place',
235+
**feature.pop('properties'),
236+
**feature
239237
}
240238

241239
if data.get('timeStamp', False):
@@ -248,17 +246,21 @@ def geojson2jsonld(cls, data: dict, dataset: str,
248246
**data
249247
}
250248

251-
if None in (template, identifier):
252-
return ldjsonData
249+
if identifier:
250+
# Render jsonld template for single item
251+
LOGGER.debug('Rendering JSON-LD item template')
252+
content = render_j2_template(
253+
cls.tpl_config, templates,
254+
'collections/items/item.jsonld', ldjsonData)
255+
253256
else:
254-
# Render jsonld template for single item with template configured
255-
LOGGER.debug(f'Rendering JSON-LD template: {template}')
257+
# Render jsonld template for /items
258+
LOGGER.debug('Rendering JSON-LD items template')
256259
content = render_j2_template(
257-
cls.config, cls.config['server']['templates'],
258-
template, ldjsonData)
260+
cls.tpl_config, templates,
261+
'collections/items/index.jsonld', ldjsonData)
259262

260-
ldjsonData = json.loads(content)
261-
return ldjsonData
263+
return content
262264

263265

264266
def jsonldify_geometry(feature: dict) -> None:
@@ -271,6 +273,8 @@ def jsonldify_geometry(feature: dict) -> None:
271273
:returns: None
272274
"""
273275

276+
feature['type'] = 'schema:Place'
277+
274278
geo = feature.get('geometry')
275279
geom = shape(geo)
276280

@@ -287,7 +291,11 @@ def jsonldify_geometry(feature: dict) -> None:
287291
}
288292

289293
# Schema geometry
290-
feature['schema:geo'] = geom2schemageo(geom)
294+
try:
295+
feature['schema:geo'] = geom2schemageo(geom)
296+
except AttributeError:
297+
msg = f'Unable to parse schema geometry for {feature["id"]}'
298+
LOGGER.warning(msg)
291299

292300

293301
def geom2schemageo(geom: shape) -> dict:

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

-3
Original file line numberDiff line numberDiff line change
@@ -366,9 +366,6 @@ properties:
366366
type: object
367367
description: linked data configuration
368368
properties:
369-
item_template:
370-
type: string
371-
description: path to JSON-LD Jinja2 template
372369
context:
373370
type: array
374371
description: additional JSON-LD context
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"@context": [
3+
{
4+
"schema": "https://schema.org/",
5+
"type": "@type",
6+
"features": "schema:itemListElement",
7+
"FeatureCollection": "schema:itemList"
8+
}
9+
],
10+
"type": "FeatureCollection",
11+
"@id": "{{ data["@id"] }}",
12+
{%- if data.features %}
13+
"features": [
14+
{%- for ft in data.features %}
15+
{
16+
"@type": "{{ ft.type }}",
17+
"@id": "{{ ft["@id"] }}"
18+
}
19+
{%- if not loop.last -%},{%- endif -%}
20+
{%- endfor %}
21+
]
22+
{%- endif %}
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{ to_json(data, config.server.pretty_print) | safe }}

‎tests/api/test_itemtypes.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -548,8 +548,8 @@ def test_get_collection_items_json_ld(config, api_):
548548
assert '@context' in collection
549549
assert all((f in collection['@context'][0] for
550550
f in ('schema', 'type', 'features', 'FeatureCollection')))
551-
assert len(collection['@context']) > 1
552-
assert collection['@context'][1]['schema'] == 'https://schema.org/'
551+
assert len(collection['@context']) == 1
552+
assert collection['@context'][0]['schema'] == 'https://schema.org/'
553553
expanded = jsonld.expand(collection)[0]
554554
featuresUri = 'https://schema.org/itemListElement'
555555
assert len(expanded[featuresUri]) == 2

‎tests/data/base.jsonld

-1
This file was deleted.

‎tests/pygeoapi-test-config-apirules.yml

-2
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,6 @@ resources:
293293
title: data source
294294
href: https://en.wikipedia.org/wiki/GeoJSON
295295
hreflang: en-US
296-
linked-data:
297-
item_template: tests/data/base.jsonld
298296
extents:
299297
spatial:
300298
bbox: [-180,-90,180,90]

‎tests/pygeoapi-test-config-enclosure.yml

-2
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,6 @@ resources:
125125
rel: enclosure
126126
title: download link 3
127127
href: https://github.com/geopython/pygeoapi/raw/4a18393662583e53b8c7d591130246d9cd2c3f3f/pygeoapi/static/img/pygeoapi.png
128-
linked-data:
129-
item_template: tests/data/base.jsonld
130128
extents:
131129
spatial:
132130
bbox: [-180,-90,180,90]

‎tests/pygeoapi-test-config.yml

-2
Original file line numberDiff line numberDiff line change
@@ -356,8 +356,6 @@ resources:
356356
title: data source
357357
href: https://en.wikipedia.org/wiki/GeoJSON
358358
hreflang: en-US
359-
linked-data:
360-
item_template: tests/data/base.jsonld
361359
extents:
362360
spatial:
363361
bbox: [-180,-90,180,90]

‎tests/test_linked_data.py

+174
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# =================================================================
2+
#
3+
# Authors: Benjamin Webb <bwebb@lincolninst.edu>
4+
#
5+
# Copyright (c) 2025 Benjamin Webb
6+
#
7+
# Permission is hereby granted, free of charge, to any person
8+
# obtaining a copy of this software and associated documentation
9+
# files (the "Software"), to deal in the Software without
10+
# restriction, including without limitation the rights to use,
11+
# copy, modify, merge, publish, distribute, sublicense, and/or sell
12+
# copies of the Software, and to permit persons to whom the
13+
# Software is furnished to do so, subject to the following
14+
# conditions:
15+
#
16+
# The above copyright notice and this permission notice shall be
17+
# included in all copies or substantial portions of the Software.
18+
#
19+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
21+
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
23+
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
24+
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
25+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
26+
# OTHER DEALINGS IN THE SOFTWARE.
27+
#
28+
# =================================================================
29+
30+
from copy import deepcopy
31+
import json
32+
import logging
33+
34+
import pytest
35+
from shapely.geometry import (Point, MultiPoint, Polygon,
36+
MultiPolygon, LineString, MultiLineString)
37+
38+
from pygeoapi.linked_data import (
39+
geojson2jsonld,
40+
geom2schemageo,
41+
jsonldify_geometry
42+
)
43+
44+
45+
LOGGER = logging.getLogger(__name__)
46+
47+
48+
@pytest.fixture
49+
def feature():
50+
return {
51+
'type': 'Feature',
52+
'geometry': {
53+
'type': 'Point',
54+
'coordinates': [125.6, 10.1]
55+
},
56+
'properties': {
57+
'name': 'Test Point'
58+
},
59+
'id': 'test1',
60+
'links': []
61+
}
62+
63+
64+
def test_geojson2jsonld_single_feature(api_, feature):
65+
"""Test conversion of single GeoJSON feature to JSON-LD"""
66+
67+
result = geojson2jsonld(api_, feature,
68+
'obs', 'http://example.org/feature/1')
69+
result_dict = json.loads(result)
70+
71+
assert '@context' in result_dict
72+
assert result_dict['@id'] == 'http://example.org/feature/1'
73+
assert 'schema:geo' in result_dict
74+
75+
76+
def test_geom2schemageo():
77+
"""Test conversion of various geometry types to schema.org geometry"""
78+
79+
# Test Point
80+
point = Point(125.6, 10.1)
81+
point_result = geom2schemageo(point)
82+
assert point_result['@type'] == 'schema:GeoCoordinates'
83+
assert point_result['schema:longitude'] == 125.6
84+
assert point_result['schema:latitude'] == 10.1
85+
86+
# Test LineString
87+
line = LineString([(0, 0), (1, 1)])
88+
line_result = geom2schemageo(line)
89+
assert line_result['@type'] == 'schema:GeoShape'
90+
assert 'schema:line' in line_result
91+
92+
# Test Polygon
93+
polygon = Polygon([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)])
94+
poly_result = geom2schemageo(polygon)
95+
assert poly_result['@type'] == 'schema:GeoShape'
96+
assert 'schema:polygon' in poly_result
97+
98+
99+
def test_jsonldify_geometry(feature):
100+
"""Test addition of multiple geometry encodings to a feature"""
101+
102+
jsonldify_geometry(feature)
103+
104+
assert feature['type'] == 'schema:Place'
105+
assert 'gsp:hasGeometry' in feature
106+
assert 'schema:geo' in feature
107+
assert feature['schema:geo']['@type'] == 'schema:GeoCoordinates'
108+
109+
110+
def test_jsonldify_invalid_geometry(feature):
111+
"""Test invalid geometry encodings to a feature"""
112+
feature['geometry']['type'] = 'MultiPolygon'
113+
feature['geometry']['coordinates'] = []
114+
jsonldify_geometry(feature)
115+
116+
assert feature['type'] == 'schema:Place'
117+
assert 'schema:geo' not in feature
118+
119+
120+
@pytest.mark.parametrize('geom_type,coords', [
121+
('Point', [125.6, 10.1]),
122+
('LineString', [(0, 0), (1, 1)]),
123+
('Polygon', [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]),
124+
('MultiPoint', [(0, 0), (1, 1)]),
125+
('MultiLineString', [[(0, 0), (1, 1)], [(2, 2), (3, 3)]]),
126+
('MultiPolygon', [[(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)],
127+
[(2, 2), (3, 2), (3, 3), (2, 3), (2, 2)]])
128+
])
129+
def test_geometry_conversions(geom_type, coords):
130+
"""Test conversion of different geometry types"""
131+
if geom_type == 'Point':
132+
geom = Point(coords)
133+
elif geom_type == 'LineString':
134+
geom = LineString(coords)
135+
elif geom_type == 'Polygon':
136+
geom = Polygon(coords)
137+
elif geom_type == 'MultiPoint':
138+
geom = MultiPoint(coords)
139+
elif geom_type == 'MultiLineString':
140+
geom = MultiLineString(coords)
141+
elif geom_type == 'MultiPolygon':
142+
geom = MultiPolygon([Polygon(poly) for poly in coords])
143+
144+
result = geom2schemageo(geom)
145+
assert result['@type'] in ['schema:GeoCoordinates', 'schema:GeoShape']
146+
147+
148+
def test_render_item_template(api_, feature):
149+
"""Test conversion rendering of item template"""
150+
151+
# Use 'objects' collection which has item json-ld template
152+
result = geojson2jsonld(api_, deepcopy(feature),
153+
'objects', 'http://example.org/feature/1')
154+
155+
# Ensure item template is renderable
156+
assert json.loads(result)
157+
158+
159+
def test_render_items_template(api_, feature):
160+
"""Test conversion rendering of items template"""
161+
162+
fc = {
163+
'features': [deepcopy(feature) for _ in range(5)],
164+
'links': []
165+
}
166+
167+
result = geojson2jsonld(api_, fc, 'objects')
168+
feature_list = json.loads(result)
169+
170+
assert len(feature_list['features']) == len(fc['features'])
171+
172+
for fld, f in zip(feature_list['features'], fc['features']):
173+
assert ['@type', '@id'] == list(fld.keys())
174+
assert f['id'] in fld['@id']

0 commit comments

Comments
 (0)
Please sign in to comment.