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

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# ================================================================= 

29 

30""" Linked data capabilities 

31Returns content as linked data representations 

32""" 

33 

34import logging 

35from typing import Callable 

36 

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 

42 

43LOGGER = logging.getLogger(__name__) 

44 

45 

46def jsonldify(func: Callable) -> Callable: 

47 """ 

48 Decorator that transforms app configuration\ 

49 to include a JSON-LD representation 

50 

51 :param func: decorated function 

52 

53 :returns: `func` 

54 """ 

55 

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 

116 

117 

118def jsonldify_collection(cls, collection: dict, locale_: str) -> dict: 

119 """ 

120 Transforms collection into a JSON-LD representation 

121 

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) 

126 

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]}' 

134 

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') 

139 

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'] 

157 

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)) 

172 

173 return dataset 

174 

175 

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. 

183 

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) 

189 

190 :returns: string of rendered JSON (GeoJSON-LD) 

191 """ 

192 

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) 

197 

198 defaultVocabulary = { 

199 'schema': 'https://schema.org/', 

200 'gsp': 'http://www.opengis.net/ont/geosparql#', 

201 'type': '@type' 

202 } 

203 

204 if identifier: 

205 # Expand properties block 

206 data.update(data.pop('properties')) 

207 

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) 

211 

212 data['@id'] = identifier 

213 

214 else: 

215 # Collection of jsonld 

216 defaultVocabulary.update({ 

217 'features': 'schema:itemListElement', 

218 'FeatureCollection': 'schema:itemList' 

219 }) 

220 

221 ds_url = url_join(cls.get_collections_url(), dataset) 

222 data['@id'] = ds_url 

223 

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 

230 

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) 

234 

235 data['features'][i] = { 

236 '@id': identifier_, 

237 'type': 'schema:Place', 

238 **feature.pop('properties'), 

239 **feature 

240 } 

241 

242 if data.get('timeStamp', False): 

243 data['https://schema.org/sdDatePublished'] = data.pop('timeStamp') 

244 

245 data['links'] = data.pop('links') 

246 

247 ldjsonData = { 

248 '@context': [defaultVocabulary, *(context or [])], 

249 **data 

250 } 

251 

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) 

258 

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) 

265 

266 return content 

267 

268 

269def jsonldify_geometry(feature: dict) -> None: 

270 """ 

271 Render JSON-LD for feature with GeoJSON, Geosparql/WKT, and 

272 schema geometry encodings. 

273 

274 :param feature: feature body to with GeoJSON geometry 

275 

276 :returns: None 

277 """ 

278 

279 feature['type'] = 'schema:Place' 

280 

281 geo = feature.get('geometry') 

282 geom = shape(geo) 

283 

284 # GeoJSON geometry 

285 feature['geometry'] = feature.pop('geometry') 

286 

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 } 

295 

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) 

302 

303 

304def geom2schemageo(geom: GeometryType) -> dict: 

305 """ 

306 Render Schema Geometry from a GeoJSON base. 

307 

308 :param geom: shapely geom of feature 

309 

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 } 

319 

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 

324 

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 

331 

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]) 

335 

336 elif geom.geom_type == 'Polygon': 

337 points = geom.exterior.coords[:] 

338 

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[:] 

347 

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[:]) 

355 

356 schema_polygon = [f'{x},{y}' for (x, y, *_) in points] 

357 

358 f['schema:polygon'] = ' '.join(schema_polygon) 

359 

360 return f