Coverage for pygeoapi/api/tiles.py: 15%

164 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# Francesco Bartoli <xbartolone@gmail.com> 

5# Sander Schaminee <sander.schaminee@geocat.net> 

6# John A Stevenson <jostev@bgs.ac.uk> 

7# Colin Blackburn <colb@bgs.ac.uk> 

8# Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it> 

9# Bernhard Mallinger <bernhard.mallinger@eox.at> 

10# 

11# Copyright (c) 2026 Tom Kralidis 

12# Copyright (c) 2025 Francesco Bartoli 

13# Copyright (c) 2022 John A Stevenson and Colin Blackburn 

14# Copyright (c) 2023 Ricardo Garcia Silva 

15# Copyright (c) 2024 Bernhard Mallinger 

16# 

17# Permission is hereby granted, free of charge, to any person 

18# obtaining a copy of this software and associated documentation 

19# files (the "Software"), to deal in the Software without 

20# restriction, including without limitation the rights to use, 

21# copy, modify, merge, publish, distribute, sublicense, and/or sell 

22# copies of the Software, and to permit persons to whom the 

23# Software is furnished to do so, subject to the following 

24# conditions: 

25# 

26# The above copyright notice and this permission notice shall be 

27# included in all copies or substantial portions of the Software. 

28# 

29# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 

30# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 

31# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 

32# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 

33# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 

34# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 

35# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 

36# OTHER DEALINGS IN THE SOFTWARE. 

37# 

38# ================================================================= 

39 

40 

41import logging 

42from http import HTTPStatus 

43from typing import Tuple 

44 

45from pygeoapi import l10n 

46from pygeoapi.formats import FORMAT_TYPES, F_JSON, F_HTML, F_JSONLD 

47from pygeoapi.plugin import load_plugin 

48from pygeoapi.models.provider.base import (TilesMetadataFormat, 

49 TileMatrixSetEnum) 

50from pygeoapi.provider import get_provider_by_type, filter_providers_by_type 

51from pygeoapi.provider.base import ( 

52 ProviderGenericError, ProviderTypeError 

53) 

54from pygeoapi.provider.tile import ProviderTileNotFoundError 

55 

56from pygeoapi.util import to_json, filter_dict_by_key_value, render_j2_template 

57 

58from . import APIRequest, API, SYSTEM_LOCALE 

59 

60LOGGER = logging.getLogger(__name__) 

61 

62CONFORMANCE_CLASSES = [ 

63 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core', 

64 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/mvt', 

65 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tileset', 

66 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tilesets-list', 

67 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/oas30', 

68 'http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/geodata-tilesets' 

69] 

70 

71 

72def get_collection_tiles(api: API, request: APIRequest, 

73 dataset: str | None = None) -> Tuple[dict, int, str]: 

74 """ 

75 Provide collection tiles 

76 

77 :param request: A request object 

78 :param dataset: name of collection 

79 

80 :returns: tuple of headers, status code, content 

81 """ 

82 

83 headers = request.get_response_headers(SYSTEM_LOCALE, 

84 **api.api_headers) 

85 if any([dataset is None, 

86 dataset not in api.config['resources'].keys()]): 

87 

88 msg = 'Collection not found' 

89 return api.get_exception( 

90 HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg) 

91 

92 LOGGER.debug('Creating collection tiles') 

93 LOGGER.debug('Loading provider') 

94 try: 

95 t = get_provider_by_type( 

96 api.config['resources'][dataset]['providers'], 'tile') 

97 p = load_plugin('provider', t) 

98 except (KeyError, ProviderTypeError): 

99 msg = 'Invalid collection tiles' 

100 return api.get_exception( 

101 HTTPStatus.BAD_REQUEST, headers, request.format, 

102 'InvalidParameterValue', msg) 

103 except ProviderGenericError as err: 

104 return api.get_exception( 

105 err.http_status_code, headers, request.format, 

106 err.ogc_exception_code, err.message) 

107 

108 tiles = { 

109 'links': [], 

110 'tilesets': [] 

111 } 

112 

113 tiles['links'].append({ 

114 'type': FORMAT_TYPES[F_JSON], 

115 'rel': request.get_linkrel(F_JSON), 

116 'title': l10n.translate('This document as JSON', request.locale), 

117 'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_JSON}' 

118 }) 

119 tiles['links'].append({ 

120 'type': FORMAT_TYPES[F_JSONLD], 

121 'rel': request.get_linkrel(F_JSONLD), 

122 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa 

123 'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_JSONLD}' 

124 }) 

125 tiles['links'].append({ 

126 'type': FORMAT_TYPES[F_HTML], 

127 'rel': request.get_linkrel(F_HTML), 

128 'title': l10n.translate('This document as HTML', request.locale), 

129 'href': f'{api.get_collections_url()}/{dataset}/tiles?f={F_HTML}' 

130 }) 

131 

132 tile_services = p.get_tiles_service( 

133 baseurl=api.base_url, 

134 servicepath=f'{api.get_collections_url()}/{dataset}/tiles/{{tileMatrixSetId}}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f={p.format_type}' # noqa 

135 ) 

136 

137 for service in tile_services['links']: 

138 tiles['links'].append(service) 

139 

140 tiling_schemes = p.get_tiling_schemes() 

141 

142 datatype = 'vector' 

143 

144 if t['format']['mimetype'].startswith('image'): 

145 datatype = 'map' 

146 

147 for matrix in tiling_schemes: 

148 tile_matrix = { 

149 'title': dataset, 

150 'tileMatrixSetURI': matrix.tileMatrixSetURI, 

151 'crs': matrix.crs, 

152 'dataType': datatype, 

153 'links': [] 

154 } 

155 tile_matrix['links'].append({ 

156 'type': FORMAT_TYPES[F_JSON], 

157 'rel': 'http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme', 

158 'title': l10n.translate('TileMatrixSet definition in JSON', request.locale), # noqa 

159 'href': f'{api.base_url}/TileMatrixSets/{matrix.tileMatrixSet}?f={F_JSON}' # noqa 

160 }) 

161 tile_matrix['links'].append({ 

162 'type': FORMAT_TYPES[F_JSON], 

163 'rel': request.get_linkrel(F_JSON), 

164 'title': f'{dataset} - {matrix.tileMatrixSet} - {F_JSON}', 

165 'href': f'{api.get_collections_url()}/{dataset}/tiles/{matrix.tileMatrixSet}?f={F_JSON}' # noqa 

166 }) 

167 tile_matrix['links'].append({ 

168 'type': FORMAT_TYPES[F_HTML], 

169 'rel': request.get_linkrel(F_HTML), 

170 'title': f'{dataset} - {matrix.tileMatrixSet} - {F_HTML}', 

171 'href': f'{api.get_collections_url()}/{dataset}/tiles/{matrix.tileMatrixSet}?f={F_HTML}' # noqa 

172 }) 

173 

174 tiles['tilesets'].append(tile_matrix) 

175 

176 if request.format == F_HTML: # render 

177 tpl_config = api.get_dataset_templates(dataset) 

178 tiles['id'] = dataset 

179 tiles['title'] = l10n.translate( 

180 api.config['resources'][dataset]['title'], SYSTEM_LOCALE) 

181 tiles['tilesets'] = [ 

182 scheme.tileMatrixSet for scheme in p.get_tiling_schemes()] 

183 tiles['bounds'] = \ 

184 api.config['resources'][dataset]['extents']['spatial']['bbox'] 

185 tiles['minzoom'] = p.options['zoom']['min'] 

186 tiles['maxzoom'] = p.options['zoom']['max'] 

187 tiles['collections_path'] = api.get_collections_url() 

188 tiles['tile_type'] = p.tile_type 

189 

190 content = render_j2_template(api.tpl_config, tpl_config, 

191 'collections/tiles/index.html', tiles, 

192 request.locale) 

193 

194 return headers, HTTPStatus.OK, content 

195 

196 return headers, HTTPStatus.OK, to_json(tiles, api.pretty_print) 

197 

198 

199def get_collection_tiles_data( 

200 api: API, request: APIRequest, 

201 dataset: str | None = None, 

202 matrix_id: str | None = None, 

203 z_idx: int | None = None, 

204 y_idx: int | None = None, 

205 x_idx: int | None = None) -> Tuple[dict, int, str]: 

206 """ 

207 Get collection items tiles 

208 

209 :param request: A request object 

210 :param dataset: dataset name 

211 :param matrix_id: matrix identifier 

212 :param z_idx: z index 

213 :param y_idx: y index 

214 :param x_idx: x index 

215 

216 :returns: tuple of headers, status code, content 

217 """ 

218 

219 format_ = request.format 

220 if not format_: 

221 return api.get_format_exception(request) 

222 headers = request.get_response_headers(SYSTEM_LOCALE, 

223 **api.api_headers) 

224 LOGGER.debug('Processing tiles') 

225 

226 collections = filter_dict_by_key_value(api.config['resources'], 

227 'type', 'collection') 

228 

229 if dataset not in collections.keys(): 

230 msg = 'Collection not found' 

231 return api.get_exception( 

232 HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg) 

233 

234 LOGGER.debug('Loading tile provider') 

235 try: 

236 t = get_provider_by_type( 

237 api.config['resources'][dataset]['providers'], 'tile') 

238 p = load_plugin('provider', t) 

239 

240 format_ = p.format_type 

241 headers['Content-Type'] = t['format']['mimetype'] 

242 

243 LOGGER.debug(f'Fetching tileset id {matrix_id} and tile {z_idx}/{y_idx}/{x_idx}') # noqa 

244 content = p.get_tiles(layer=p.get_layer(), tileset=matrix_id, 

245 z=z_idx, y=y_idx, x=x_idx, format_=format_) 

246 if content is None: 

247 msg = 'identifier not found' 

248 return api.get_exception( 

249 HTTPStatus.NO_CONTENT, headers, format_, 'NoContent', msg) 

250 else: 

251 return headers, HTTPStatus.OK, content 

252 

253 # @TODO: figure out if the spec requires to return json errors 

254 except KeyError: 

255 msg = 'Invalid collection tiles' 

256 return api.get_exception( 

257 HTTPStatus.BAD_REQUEST, headers, format_, 

258 'InvalidParameterValue', msg) 

259 except ProviderTileNotFoundError: 

260 msg = 'Tile not found' 

261 LOGGER.info(msg) 

262 return headers, HTTPStatus.NOT_FOUND, msg 

263 except ProviderGenericError as err: 

264 return api.get_exception( 

265 err.http_status_code, headers, request.format, 

266 err.ogc_exception_code, err.message) 

267 

268 

269def get_collection_tiles_metadata(api: API, request: APIRequest, 

270 dataset: str | None = None, 

271 matrix_id: str | None = None 

272 ) -> Tuple[dict, int, str]: 

273 """ 

274 Get collection items tiles 

275 

276 :param request: A request object 

277 :param dataset: dataset name 

278 :param matrix_id: matrix identifier 

279 

280 :returns: tuple of headers, status code, content 

281 """ 

282 

283 if not request.is_valid([TilesMetadataFormat.TILEJSON]): 

284 return api.get_format_exception(request) 

285 headers = request.get_response_headers(**api.api_headers) 

286 

287 if any([dataset is None, 

288 dataset not in api.config['resources'].keys()]): 

289 

290 msg = 'Collection not found' 

291 return api.get_exception( 

292 HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg) 

293 

294 LOGGER.debug('Creating collection tiles') 

295 LOGGER.debug('Loading provider') 

296 try: 

297 t = get_provider_by_type( 

298 api.config['resources'][dataset]['providers'], 'tile') 

299 p = load_plugin('provider', t) 

300 except KeyError: 

301 msg = 'Invalid collection tiles' 

302 return api.get_exception( 

303 HTTPStatus.BAD_REQUEST, headers, request.format, 

304 'InvalidParameterValue', msg) 

305 except ProviderGenericError as err: 

306 return api.get_exception( 

307 err.http_status_code, headers, request.format, 

308 err.ogc_exception_code, err.message) 

309 

310 # Get provider language (if any) 

311 prv_locale = l10n.get_plugin_locale(t, request.raw_locale) 

312 

313 # Set response language to requested provider locale 

314 # (if it supports language) and/or otherwise the requested pygeoapi 

315 # locale (or fallback default locale) 

316 l10n.set_response_language(headers, prv_locale, request.locale) 

317 

318 tiles_metadata = p.get_metadata( 

319 dataset=dataset, server_url=api.base_url, 

320 layer=p.get_layer(), tileset=matrix_id, 

321 metadata_format=request._format, title=l10n.translate( 

322 api.config['resources'][dataset]['title'], 

323 request.locale), 

324 description=l10n.translate( 

325 api.config['resources'][dataset]['description'], 

326 request.locale), 

327 language=prv_locale) 

328 

329 if request.format == F_HTML: # render 

330 tpl_config = api.get_dataset_templates(dataset) 

331 content = render_j2_template(api.tpl_config, tpl_config, 

332 'collections/tiles/metadata.html', 

333 tiles_metadata, request.locale) 

334 

335 else: 

336 content = to_json(tiles_metadata, api.pretty_print) 

337 

338 return headers, HTTPStatus.OK, content 

339 

340 

341def tilematrixsets(api: API, 

342 request: APIRequest) -> Tuple[dict, int, str]: 

343 """ 

344 Provide tileMatrixSets definition 

345 

346 :param request: A request object 

347 

348 :returns: tuple of headers, status code, content 

349 """ 

350 

351 headers = request.get_response_headers(**api.api_headers) 

352 

353 # Retrieve available TileMatrixSets 

354 enums = [e.value for e in TileMatrixSetEnum] 

355 

356 tms = {"tileMatrixSets": []} 

357 

358 for e in enums: 

359 tms['tileMatrixSets'].append({ 

360 "title": e.title, 

361 "id": e.tileMatrixSet, 

362 "uri": e.tileMatrixSetURI, 

363 "links": [ 

364 { 

365 "rel": "self", 

366 "type": "text/html", 

367 "title": f"The HTML representation of the {e.tileMatrixSet} tile matrix set", # noqa 

368 "href": f"{api.base_url}/TileMatrixSets/{e.tileMatrixSet}?f=html" # noqa 

369 }, 

370 { 

371 "rel": "self", 

372 "type": "application/json", 

373 "title": f"The JSON representation of the {e.tileMatrixSet} tile matrix set", # noqa 

374 "href": f"{api.base_url}/TileMatrixSets/{e.tileMatrixSet}?f=json" # noqa 

375 } 

376 ] 

377 }) 

378 

379 tms['links'] = [{ 

380 "rel": "alternate", 

381 "type": "text/html", 

382 "title": l10n.translate('This document as HTML', request.locale), 

383 "href": f"{api.base_url}/tileMatrixSets?f=html" 

384 }, { 

385 "rel": "self", 

386 "type": "application/json", 

387 "title": l10n.translate('This document as JSON', request.locale), 

388 "href": f"{api.base_url}/tileMatrixSets?f=json" 

389 }] 

390 

391 if request.format == F_HTML: # render 

392 content = render_j2_template(api.tpl_config, api.tpl_config, 

393 'tilematrixsets/index.html', 

394 tms, request.locale) 

395 return headers, HTTPStatus.OK, content 

396 

397 return headers, HTTPStatus.OK, to_json(tms, api.pretty_print) 

398 

399 

400def tilematrixset(api: API, 

401 request: APIRequest, 

402 tileMatrixSetId: str) -> Tuple[dict, int, str]: 

403 """ 

404 Provide tile matrix definition 

405 

406 :param request: A request object 

407 

408 :returns: tuple of headers, status code, content 

409 """ 

410 

411 headers = request.get_response_headers(**api.api_headers) 

412 

413 # Retrieve relevant TileMatrixSet 

414 enums = [e.value for e in TileMatrixSetEnum] 

415 enum = None 

416 

417 try: 

418 for e in enums: 

419 if tileMatrixSetId == e.tileMatrixSet: 

420 enum = e 

421 if not enum: 

422 raise ValueError('could not find this tilematrixset') 

423 except ValueError as err: 

424 return api.get_exception( 

425 HTTPStatus.BAD_REQUEST, headers, request.format, 

426 'InvalidParameterValue', str(err)) 

427 

428 tms = { 

429 "title": enum.tileMatrixSet, 

430 "crs": enum.crs, 

431 "id": enum.tileMatrixSet, 

432 "uri": enum.tileMatrixSetURI, 

433 "orderedAxes": enum.orderedAxes, 

434 "wellKnownScaleSet": enum.wellKnownScaleSet, 

435 "tileMatrices": enum.tileMatrices 

436 } 

437 

438 if request.format == F_HTML: # render 

439 content = render_j2_template(api.tpl_config, api.tpl_config, 

440 'tilematrixsets/tilematrixset.html', 

441 tms, request.locale) 

442 return headers, HTTPStatus.OK, content 

443 

444 return headers, HTTPStatus.OK, to_json(tms, api.pretty_print) 

445 

446def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa 

447 """ 

448 Get OpenAPI fragments 

449 

450 :param cfg: `dict` of configuration 

451 :param locale: `str` of locale 

452 

453 :returns: `tuple` of `list` of tag objects, and `dict` of path objects 

454 """ 

455 

456 from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections 

457 

458 paths = {} 

459 

460 LOGGER.debug('setting up tiles endpoints') 

461 collections = filter_dict_by_key_value(cfg['resources'], 

462 'type', 'collection') 

463 

464 for k, v in get_visible_collections(cfg).items(): 

465 tile_extension = filter_providers_by_type( 

466 collections[k]['providers'], 'tile') 

467 

468 if tile_extension: 468 ↛ 469line 468 didn't jump to line 469 because the condition on line 468 was never true

469 tp = load_plugin('provider', tile_extension) 

470 

471 tiles_path = f'/collections/{k}/tiles' 

472 title = l10n.translate(v['title'], locale) 

473 description = l10n.translate(v['description'], locale) 

474 

475 datatype = 'vector' 

476 

477 if tile_extension['format']['mimetype'].startswith('image'): 

478 datatype = 'map' 

479 

480 paths[tiles_path] = { 

481 'get': { 

482 'summary': f'Fetch a {title} tiles description', 

483 'description': description, 

484 'tags': [k], 

485 'operationId': f'describe{k.capitalize()}.collection.{datatype}.getTileSetsList', # noqa 

486 'parameters': [ 

487 {'$ref': '#/components/parameters/f'}, 

488 {'$ref': '#/components/parameters/lang'} 

489 ], 

490 'responses': { 

491 '200': {'$ref': '#/components/responses/Tiles'}, 

492 '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa 

493 '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa 

494 '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa 

495 } 

496 } 

497 } 

498 

499 tiles_data_path = f'{tiles_path}/{{tileMatrixSetId}}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}' # noqa 

500 

501 paths[tiles_data_path] = { 

502 'get': { 

503 'summary': f'Get a {title} tile', 

504 'description': description, 

505 'tags': [k], 

506 'operationId': f'get{k.capitalize()}.collection.{datatype}.getTile', # noqa 

507 'parameters': [ 

508 {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileMatrixSetId"}, # noqa 

509 {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileMatrix"}, # noqa 

510 {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileRow"}, # noqa 

511 {'$ref': f"{OPENAPI_YAML['oapit']}#/components/parameters/tileCol"}, # noqa 

512 { 

513 'name': 'f', 

514 'in': 'query', 

515 'description': 'The optional f parameter indicates the output format which the server shall provide as part of the response document.', # noqa 

516 'required': False, 

517 'schema': { 

518 'type': 'string', 

519 'enum': [tp.format_type], 

520 'default': tp.format_type 

521 }, 

522 'style': 'form', 

523 'explode': False 

524 } 

525 ], 

526 'responses': { 

527 '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa 

528 '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa 

529 '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa 

530 } 

531 } 

532 } 

533 mimetype = tile_extension['format']['mimetype'] 

534 paths[tiles_data_path]['get']['responses']['200'] = { 

535 'description': 'successful operation', 

536 'content': { 

537 mimetype: { 

538 'schema': { 

539 'type': 'string', 

540 'format': 'binary' 

541 } 

542 } 

543 } 

544 } 

545 

546 return [{'name': 'tiles'}], {'paths': paths}