Coverage for pygeoapi/linked_data.py: 79%
118 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 08:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 08:15 +0000
1# =================================================================
2#
3# Authors: Tom Kralidis <tomkralidis@gmail.com>
4#
5# Copyright (c) 2022 Tom Kralidis
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# =================================================================
30""" Linked data capabilities
31Returns content as linked data representations
32"""
34import logging
35from typing import Callable
37from pygeoapi.util import is_url, render_j2_template, url_join
38from pygeoapi import l10n
39from shapely import GeometryType
40from shapely.geometry import shape
41from shapely.ops import unary_union
43LOGGER = logging.getLogger(__name__)
46def jsonldify(func: Callable) -> Callable:
47 """
48 Decorator that transforms app configuration\
49 to include a JSON-LD representation
51 :param func: decorated function
53 :returns: `func`
54 """
56 def inner(*args, **kwargs):
57 apireq = args[1]
58 format_ = getattr(apireq, 'format')
59 if not format_ == 'jsonld':
60 return func(*args, **kwargs)
61 # Function args have been pre-processed, so get locale from APIRequest
62 locale_ = getattr(apireq, 'locale')
63 LOGGER.debug('Creating JSON-LD representation')
64 cls = args[0]
65 cfg = cls.config
66 meta = cfg.get('metadata', {})
67 contact = meta.get('contact', {})
68 provider = meta.get('provider', {})
69 ident = meta.get('identification', {})
70 fcmld = {
71 "@context": "https://schema.org/docs/jsonldcontext.jsonld",
72 "@type": "DataCatalog",
73 "@id": cfg.get('server', {}).get('url'),
74 "url": cfg.get('server', {}).get('url'),
75 "name": l10n.translate(ident.get('title'), locale_),
76 "description": l10n.translate(
77 ident.get('description'), locale_),
78 "keywords": l10n.translate(
79 ident.get('keywords'), locale_),
80 "termsOfService": l10n.translate(
81 ident.get('terms_of_service'), locale_),
82 "license": meta.get('license', {}).get('url'),
83 "provider": {
84 "@type": "Organization",
85 "name": l10n.translate(provider.get('name'), locale_),
86 "url": provider.get('url'),
87 "address": {
88 "@type": "PostalAddress",
89 "streetAddress": contact.get('address'),
90 "postalCode": contact.get('postalcode'),
91 "addressLocality": contact.get('city'),
92 "addressRegion": contact.get('stateorprovince'),
93 "addressCountry": contact.get('country')
94 },
95 "contactPoint": {
96 "@type": "Contactpoint",
97 "email": contact.get('email'),
98 "telephone": contact.get('phone'),
99 "faxNumber": contact.get('fax'),
100 "url": contact.get('url'),
101 "hoursAvailable": {
102 "opens": contact.get('hours'),
103 "description": l10n.translate(
104 contact.get('instructions'), locale_)
105 },
106 "contactType": l10n.translate(
107 contact.get('role'), locale_),
108 "description": l10n.translate(
109 contact.get('position'), locale_)
110 }
111 }
112 }
113 cls.fcmld = fcmld
114 return func(cls, *args[1:], **kwargs)
115 return inner
118def jsonldify_collection(cls, collection: dict, locale_: str) -> dict:
119 """
120 Transforms collection into a JSON-LD representation
122 :param cls: API object
123 :param collection: `collection` as prepared for non-LD JSON
124 representation
125 :param locale_: The locale to use for translations (if supported)
127 :returns: `collection` a dictionary, mapped into JSON-LD, of
128 type schema:Dataset
129 """
130 temporal_extent = collection.get('extent', {}).get('temporal', {})
131 interval = temporal_extent.get('interval')
132 if interval is not None: 132 ↛ 135line 132 didn't jump to line 135 because the condition on line 132 was always true
133 interval = f'{interval[0][0]}/{interval[0][1]}'
135 spatial_extent = collection.get('extent', {}).get('spatial', {})
136 bbox = spatial_extent.get('bbox')
137 crs = spatial_extent.get('crs', '')
138 hascrs84 = crs.endswith('CRS84')
140 dataset = {
141 "@type": "Dataset",
142 "@id": f"{cls.base_url}/collections/{collection['id']}",
143 "name": l10n.translate(collection['title'], locale_),
144 "description": l10n.translate(collection['description'], locale_),
145 "license": cls.fcmld['license'],
146 "keywords": l10n.translate(collection.get('keywords'), locale_),
147 "spatial": None if (not hascrs84 or not bbox) else [{
148 "@type": "Place",
149 "geo": {
150 "@type": "GeoShape",
151 "box": f'{_bbox[0]},{_bbox[1]} {_bbox[2]},{_bbox[3]}'
152 }
153 } for _bbox in bbox],
154 "temporalCoverage": interval
155 }
156 dataset['url'] = dataset['@id']
158 links = collection.get('links', [])
159 if links: 159 ↛ 173line 159 didn't jump to line 173 because the condition on line 159 was always true
160 dataset['distribution'] = list(map(lambda link: {k: v for k, v in {
161 "@type": "DataDownload",
162 "contentURL": link['href'],
163 "encodingFormat": link['type'],
164 "description": l10n.translate(link['title'], locale_),
165 "inLanguage": link.get(
166 'hreflang', l10n.locale2str(cls.default_locale)
167 ),
168 "author": link['rel'] if link.get(
169 'rel', None
170 ) == 'author' else None
171 }.items() if v is not None}, links))
173 return dataset
176def geojson2jsonld(cls, data: dict, dataset: str,
177 identifier: str | None = None,
178 id_field: str = 'id') -> str:
179 """
180 Render GeoJSON-LD from a GeoJSON base. Inserts a @context that can be
181 read from, and extended by, the pygeoapi configuration for a particular
182 dataset.
184 :param cls: API object
185 :param data: dict of data:
186 :param dataset: dataset identifier
187 :param identifier: item identifier (optional)
188 :param id_field: item identifier_field (optional)
190 :returns: string of rendered JSON (GeoJSON-LD)
191 """
193 LOGGER.debug('Fetching context from resource configuration')
194 linked_data = cls.config['resources'][dataset].get('linked-data', {})
195 context = linked_data.get('context', []).copy()
196 templates = cls.get_dataset_templates(dataset)
198 defaultVocabulary = {
199 'schema': 'https://schema.org/',
200 'gsp': 'http://www.opengis.net/ont/geosparql#',
201 'type': '@type'
202 }
204 if identifier:
205 # Expand properties block
206 data.update(data.pop('properties'))
208 # Include multiple geometry encodings
209 if (data.get('geometry') is not None): 209 ↛ 212line 209 didn't jump to line 212 because the condition on line 209 was always true
210 jsonldify_geometry(data)
212 data['@id'] = identifier
214 else:
215 # Collection of jsonld
216 defaultVocabulary.update({
217 'features': 'schema:itemListElement',
218 'FeatureCollection': 'schema:itemList'
219 })
221 ds_url = url_join(cls.get_collections_url(), dataset)
222 data['@id'] = ds_url
224 for i, feature in enumerate(data['features']):
225 # Get URI for each feature
226 identifier_ = feature.get(id_field,
227 feature['properties'].get(id_field, ''))
228 if not is_url(str(identifier_)): 228 ↛ 232line 228 didn't jump to line 232 because the condition on line 228 was always true
229 identifier_ = f"{ds_url}/items/{feature['id']}" # noqa
231 # Include multiple geometry encodings
232 if feature.get('geometry') is not None: 232 ↛ 235line 232 didn't jump to line 235 because the condition on line 232 was always true
233 jsonldify_geometry(feature)
235 data['features'][i] = {
236 '@id': identifier_,
237 'type': 'schema:Place',
238 **feature.pop('properties'),
239 **feature
240 }
242 if data.get('timeStamp', False):
243 data['https://schema.org/sdDatePublished'] = data.pop('timeStamp')
245 data['links'] = data.pop('links')
247 ldjsonData = {
248 '@context': [defaultVocabulary, *(context or [])],
249 **data
250 }
252 if identifier:
253 # Render jsonld template for single item
254 LOGGER.debug('Rendering JSON-LD item template')
255 content = render_j2_template(
256 cls.tpl_config, templates,
257 'collections/items/item.jsonld', ldjsonData)
259 else:
260 # Render jsonld template for /items
261 LOGGER.debug('Rendering JSON-LD items template')
262 content = render_j2_template(
263 cls.tpl_config, templates,
264 'collections/items/index.jsonld', ldjsonData)
266 return content
269def jsonldify_geometry(feature: dict) -> None:
270 """
271 Render JSON-LD for feature with GeoJSON, Geosparql/WKT, and
272 schema geometry encodings.
274 :param feature: feature body to with GeoJSON geometry
276 :returns: None
277 """
279 feature['type'] = 'schema:Place'
281 geo = feature.get('geometry')
282 geom = shape(geo)
284 # GeoJSON geometry
285 feature['geometry'] = feature.pop('geometry')
287 # Geosparql geometry
288 feature['gsp:hasGeometry'] = {
289 '@type': f'http://www.opengis.net/ont/sf#{geom.geom_type}',
290 'gsp:asWKT': {
291 '@type': 'http://www.opengis.net/ont/geosparql#wktLiteral',
292 '@value': f'{geom.wkt}'
293 }
294 }
296 # Schema geometry
297 try:
298 feature['schema:geo'] = geom2schemageo(geom)
299 except AttributeError:
300 msg = f'Unable to parse schema geometry for {feature["id"]}'
301 LOGGER.warning(msg)
304def geom2schemageo(geom: GeometryType) -> dict:
305 """
306 Render Schema Geometry from a GeoJSON base.
308 :param geom: shapely geom of feature
310 :returns: dict of rendered schema:geo geometry
311 """
312 f = {'@type': 'schema:GeoShape'}
313 if geom.geom_type == 'Point':
314 return {
315 '@type': 'schema:GeoCoordinates',
316 'schema:longitude': geom.x,
317 'schema:latitude': geom.y
318 }
320 elif geom.geom_type == 'LineString': 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true
321 points = [f'{x},{y}' for (x, y, *_) in geom.coords[:]]
322 f['schema:line'] = ' '.join(points)
323 return f
325 elif geom.geom_type == 'MultiLineString': 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true
326 points = list()
327 for line in geom.geoms:
328 points.extend([f'{x},{y}' for (x, y, *_) in line.coords[:]])
329 f['schema:line'] = ' '.join(points)
330 return f
332 elif geom.geom_type == 'MultiPoint': 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true
333 points = [(x, y) for pt in geom.geoms for (x, y, *_) in pt.coords]
334 points.append(points[0])
336 elif geom.geom_type == 'Polygon':
337 points = geom.exterior.coords[:]
339 elif geom.geom_type == 'MultiPolygon': 339 ↛ 349line 339 didn't jump to line 349 because the condition on line 339 was always true
340 # MultiPolygon to Polygon (buffer of 0 helps ensure manifold polygon)
341 poly = unary_union(geom.buffer(0))
342 if poly.geom_type.startswith('Multi') or not poly.is_valid:
343 LOGGER.debug(f'Invalid MultiPolygon: {poly.geom_type}')
344 poly = poly.convex_hull
345 LOGGER.debug(f'New MultiPolygon: {poly.geom_type}')
346 points = poly.exterior.coords[:]
348 else:
349 points = list()
350 for p in geom.geoms:
351 try:
352 points.extend(p.coords[:])
353 except NotImplementedError:
354 points.extend(p.exterior.coords[:])
356 schema_polygon = [f'{x},{y}' for (x, y, *_) in points]
358 f['schema:polygon'] = ' '.join(schema_polygon)
360 return f