Coverage for pygeoapi/openapi.py: 69%

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

5# Authors: Ricardo Garcia Silva <ricardo.garcia.silva@geobeyond.it> 

6# 

7# Copyright (c) 2026 Tom Kralidis 

8# Copyright (c) 2025 Francesco Bartoli 

9# Copyright (c) 2023 Ricardo Garcia Silva 

10# 

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

12# obtaining a copy of this software and associated documentation 

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

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

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

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

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

18# conditions: 

19# 

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

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

22# 

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

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

25# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 

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

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

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

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

30# OTHER DEALINGS IN THE SOFTWARE. 

31# 

32# ================================================================= 

33 

34from copy import deepcopy 

35import io 

36import json 

37import logging 

38import os 

39from pathlib import Path 

40from typing import Union 

41 

42import click 

43from jsonschema import validate as jsonschema_validate 

44import yaml 

45 

46from pygeoapi import l10n 

47from pygeoapi.api import all_apis 

48from pygeoapi.models.openapi import OAPIFormat 

49from pygeoapi.util import (filter_dict_by_key_value, to_json, yaml_load, 

50 get_api_rules, get_base_url, SCHEMASDIR) 

51 

52LOGGER = logging.getLogger(__name__) 

53 

54OPENAPI_YAML = { 

55 'cql2': 'https://schemas.opengis.net/cql2/1.0/cql2.json', 

56 'oapif-1': 'https://schemas.opengis.net/ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml', # noqa 

57 'oapif-2': 'https://schemas.opengis.net/ogcapi/features/part2/1.0/openapi/ogcapi-features-2.yaml', # noqa 

58 'oapip': 'https://schemas.opengis.net/ogcapi/processes/part1/1.0/openapi', 

59 'oacov': 'https://raw.githubusercontent.com/opengeospatial/ogcapi-coverages/refs/heads/master/standard/openapi/ogcapi-coverages-1.yaml', # noqa 

60 'oamaps': 'https://schemas.opengis.net/ogcapi/maps/part1/1.0/openapi/ogcapi-maps-1.yaml', # noqa 

61 'oapir': 'https://raw.githubusercontent.com/opengeospatial/ogcapi-records/master/core/openapi', # noqa 

62 'oaedr': 'https://schemas.opengis.net/ogcapi/edr/1.0/openapi', # noqa 

63 'oapit': 'https://schemas.opengis.net/ogcapi/tiles/part1/1.0/openapi/ogcapi-tiles-1.yaml', # noqa 

64 'pygeoapi': 'https://raw.githubusercontent.com/geopython/pygeoapi/master/pygeoapi/schemas/config/pygeoapi-config-0.x.yml' # noqa 

65} 

66 

67 

68def get_ogc_schemas_location(server_config: dict) -> str: 

69 """ 

70 Determine OGC schemas location 

71 

72 :param server_config: `dict` of server configuration 

73 

74 :returns: `str` of OGC schemas location 

75 """ 

76 

77 osl = server_config.get('ogc_schemas_location') 

78 

79 value = 'https://schemas.opengis.net' 

80 

81 if osl is not None: 81 ↛ 88line 81 didn't jump to line 88 because the condition on line 81 was always true

82 if osl.startswith('http'): 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true

83 value = osl 

84 elif osl.startswith('/'): 84 ↛ 88line 84 didn't jump to line 88 because the condition on line 84 was always true

85 base_url = get_base_url({'server': server_config}) 

86 value = f'{base_url}/schemas' 

87 

88 return value 

89 

90 

91# TODO: remove this function once OGC API - Processing is final 

92def gen_media_type_object(media_type: str, api_type: str, path: str) -> dict: 

93 """ 

94 Generates an OpenAPI Media Type Object 

95 

96 :param media_type: MIME type 

97 :param api_type: OGC API type 

98 :param path: local path of OGC API parameter or schema definition 

99 

100 :returns: `dict` of media type object 

101 """ 

102 

103 ref = f'{OPENAPI_YAML[api_type]}/{path}' 

104 

105 content = { 

106 media_type: { 

107 'schema': { 

108 '$ref': ref 

109 } 

110 } 

111 } 

112 

113 return content 

114 

115 

116# TODO: remove this function once OGC API - Processing is final 

117def gen_response_object(description: str, media_type: str, 

118 api_type: str, path: str) -> dict: 

119 """ 

120 Generates an OpenAPI Response Object 

121 

122 :param description: text description of response 

123 :param media_type: MIME type 

124 :param api_type: OGC API type 

125 

126 :returns: `dict` of response object 

127 """ 

128 

129 response = { 

130 'description': description, 

131 'content': gen_media_type_object(media_type, api_type, path) 

132 } 

133 

134 return response 

135 

136 

137def gen_contact(cfg: dict) -> dict: 

138 """ 

139 Generates an OpenAPI contact object with OGC extensions 

140 based on OGC API - Records contact 

141 

142 :param cfg: `dict` of configuration 

143 

144 :returns: `dict` of OpenAPI contact object 

145 """ 

146 

147 has_addresses = False 

148 has_phones = False 

149 

150 contact = { 

151 'name': cfg['metadata']['provider']['name'] 

152 } 

153 

154 for key in ['url', 'email']: 

155 if key in cfg['metadata']['provider']: 

156 contact[key] = cfg['metadata']['provider'][key] 

157 

158 contact['x-ogc-serviceContact'] = { 

159 'name': cfg['metadata']['contact']['name'], 

160 'addresses': [] 

161 } 

162 

163 if 'position' in cfg['metadata']['contact']: 163 ↛ 166line 163 didn't jump to line 166 because the condition on line 163 was always true

164 contact['x-ogc-serviceContact']['position'] = cfg['metadata']['contact']['position'] # noqa 

165 

166 if any(address in ['address', 'city', 'stateorprovince', 'postalcode', 'country'] for address in cfg['metadata']['contact']): # noqa 166 ↛ 169line 166 didn't jump to line 169 because the condition on line 166 was always true

167 has_addresses = True 

168 

169 if has_addresses: 169 ↛ 188line 169 didn't jump to line 188 because the condition on line 169 was always true

170 address = {} 

171 if 'address' in cfg['metadata']['contact']: 171 ↛ 174line 171 didn't jump to line 174 because the condition on line 171 was always true

172 address['deliveryPoint'] = [cfg['metadata']['contact']['address']] 

173 

174 if 'city' in cfg['metadata']['contact']: 174 ↛ 177line 174 didn't jump to line 177 because the condition on line 174 was always true

175 address['city'] = cfg['metadata']['contact']['city'] 

176 

177 if 'stateorprovince' in cfg['metadata']['contact']: 177 ↛ 180line 177 didn't jump to line 180 because the condition on line 177 was always true

178 address['administrativeArea'] = cfg['metadata']['contact']['stateorprovince'] # noqa 

179 

180 if 'postalCode' in cfg['metadata']['contact']: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true

181 address['administrativeArea'] = cfg['metadata']['contact']['postalCode'] # noqa 

182 

183 if 'country' in cfg['metadata']['contact']: 183 ↛ 186line 183 didn't jump to line 186 because the condition on line 183 was always true

184 address['administrativeArea'] = cfg['metadata']['contact']['country'] # noqa 

185 

186 contact['x-ogc-serviceContact']['addresses'].append(address) 

187 

188 if any(phone in ['phone', 'fax'] for phone in cfg['metadata']['contact']): 188 ↛ 192line 188 didn't jump to line 192 because the condition on line 188 was always true

189 has_phones = True 

190 contact['x-ogc-serviceContact']['phones'] = [] 

191 

192 if has_phones: 192 ↛ 203line 192 didn't jump to line 203 because the condition on line 192 was always true

193 if 'phone' in cfg['metadata']['contact']: 193 ↛ 198line 193 didn't jump to line 198 because the condition on line 193 was always true

194 contact['x-ogc-serviceContact']['phones'].append({ 

195 'type': 'main', 'value': cfg['metadata']['contact']['phone'] 

196 }) 

197 

198 if 'fax' in cfg['metadata']['contact']: 198 ↛ 203line 198 didn't jump to line 203 because the condition on line 198 was always true

199 contact['x-ogc-serviceContact']['phones'].append({ 

200 'type': 'fax', 'value': cfg['metadata']['contact']['fax'] 

201 }) 

202 

203 if 'email' in cfg['metadata']['contact']: 203 ↛ 208line 203 didn't jump to line 208 because the condition on line 203 was always true

204 contact['x-ogc-serviceContact']['emails'] = [{ 

205 'value': cfg['metadata']['contact']['email'] 

206 }] 

207 

208 if 'url' in cfg['metadata']['contact']: 208 ↛ 214line 208 didn't jump to line 214 because the condition on line 208 was always true

209 contact['x-ogc-serviceContact']['links'] = [{ 

210 'type': 'text/html', 

211 'href': cfg['metadata']['contact']['url'] 

212 }] 

213 

214 if 'instructions' in cfg['metadata']['contact']: 214 ↛ 217line 214 didn't jump to line 217 because the condition on line 214 was always true

215 contact['x-ogc-serviceContact']['contactInstructions'] = cfg['metadata']['contact']['instructions'] # noqa 

216 

217 if 'hours' in cfg['metadata']['contact']: 217 ↛ 220line 217 didn't jump to line 220 because the condition on line 217 was always true

218 contact['x-ogc-serviceContact']['hoursOfService'] = cfg['metadata']['contact']['hours'] # noqa 

219 

220 if 'role' in cfg['metadata']['contact']: 220 ↛ 223line 220 didn't jump to line 223 because the condition on line 220 was always true

221 contact['x-ogc-serviceContact']['hoursOfService'] = cfg['metadata']['contact']['role'] # noqa 

222 

223 return contact 

224 

225 

226def get_oas_30(cfg: dict, fail_on_invalid_collection: bool = True) -> dict: 

227 """ 

228 Generates an OpenAPI 3.0 Document 

229 

230 :param cfg: configuration object 

231 :param fail_on_invalid_collection: `bool` of whether to fail on an invalid 

232 collection 

233 

234 :returns: dict of OpenAPI definition 

235 """ 

236 

237 paths = {} 

238 

239 # TODO: make openapi multilingual (default language only for now) 

240 locale_ = l10n.get_locales(cfg)[0] 

241 

242 api_rules = get_api_rules(cfg) 

243 

244 osl = get_ogc_schemas_location(cfg['server']) 

245 OPENAPI_YAML['oapif-1'] = os.path.join(osl, 'ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml') # noqa 

246 OPENAPI_YAML['oapif-2'] = os.path.join(osl, 'ogcapi/features/part2/1.0/openapi/ogcapi-features-2.yaml') # noqa 

247 

248 LOGGER.debug('setting up server info') 

249 oas = { 

250 'openapi': '3.0.2', 

251 'tags': [] 

252 } 

253 info = { 

254 'title': l10n.translate(cfg['metadata']['identification']['title'], locale_), # noqa 

255 'description': l10n.translate(cfg['metadata']['identification']['description'], locale_), # noqa 

256 'x-keywords': l10n.translate(cfg['metadata']['identification']['keywords'], locale_), # noqa 

257 'termsOfService': 

258 cfg['metadata']['identification']['terms_of_service'], 

259 'contact': gen_contact(cfg), 

260 'license': { 

261 'name': cfg['metadata']['license']['name'], 

262 'url': cfg['metadata']['license']['url'] 

263 }, 

264 'version': api_rules.api_version 

265 } 

266 oas['info'] = info 

267 

268 oas['servers'] = [{ 

269 'url': get_base_url(cfg), 

270 'description': l10n.translate(cfg['metadata']['identification']['description'], locale_) # noqa 

271 }] 

272 

273 paths['/'] = { 

274 'get': { 

275 'summary': 'Landing page', 

276 'description': 'Landing page', 

277 'tags': ['server'], 

278 'operationId': 'getLandingPage', 

279 'parameters': [ 

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

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

282 ], 

283 'responses': { 

284 '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/LandingPage"}, # noqa 

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

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

287 } 

288 } 

289 } 

290 

291 paths['/openapi'] = { 

292 'get': { 

293 'summary': 'This document', 

294 'description': 'This document', 

295 'tags': ['server'], 

296 'operationId': 'getOpenapi', 

297 'parameters': [ 

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

299 {'$ref': '#/components/parameters/lang'}, 

300 { 

301 'name': 'ui', 

302 'in': 'query', 

303 'description': 'UI to render the OpenAPI document', 

304 'required': False, 

305 'schema': { 

306 'type': 'string', 

307 'enum': ['swagger', 'redoc'], 

308 'default': 'swagger' 

309 }, 

310 'style': 'form', 

311 'explode': False 

312 }, 

313 ], 

314 'responses': { 

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

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

317 'default': {'$ref': '#/components/responses/default'} 

318 } 

319 } 

320 } 

321 

322 paths['/conformance'] = { 

323 'get': { 

324 'summary': 'API conformance definition', 

325 'description': 'API conformance definition', 

326 'tags': ['server'], 

327 'operationId': 'getConformanceDeclaration', 

328 'parameters': [ 

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

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

331 ], 

332 'responses': { 

333 '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/LandingPage"}, # noqa 

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

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

336 } 

337 } 

338 } 

339 

340 paths['/collections'] = { 

341 'get': { 

342 'summary': 'Collections', 

343 'description': 'Collections', 

344 'tags': ['server'], 

345 'operationId': 'getCollections', 

346 'parameters': [ 

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

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

349 ], 

350 'responses': { 

351 '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/LandingPage"}, # noqa 

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

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

354 } 

355 } 

356 } 

357 

358 oas['tags'].append({ 

359 'name': 'server', 

360 'description': l10n.translate(cfg['metadata']['identification']['description'], locale_), # noqa 

361 'externalDocs': { 

362 'description': 'information', 

363 'url': cfg['metadata']['identification']['url']} 

364 } 

365 ) 

366 

367 oas['components'] = { 

368 'responses': { 

369 '200': { 

370 'description': 'successful operation' 

371 }, 

372 '204': { 

373 'description': 'no content' 

374 }, 

375 'default': { 

376 'description': 'Unexpected error', 

377 'content': gen_media_type_object('application/json', 'oapip', 'schemas/exception.yaml') # noqa 

378 }, 

379 'Queryables': { 

380 'description': 'successful queryables operation', 

381 'content': { 

382 'application/json': { 

383 'schema': {'$ref': '#/components/schemas/queryables'} 

384 } 

385 } 

386 } 

387 }, 

388 'parameters': get_oas_30_parameters(cfg=cfg, locale_=locale_), 

389 'schemas': { 

390 # TODO: change this schema once OGC will definitively publish it 

391 'queryable': { 

392 'type': 'object', 

393 'required': [ 

394 'queryable', 

395 'type' 

396 ], 

397 'properties': { 

398 'queryable': { 

399 'description': 'the token that may be used in a CQL predicate', # noqa 

400 'type': 'string' 

401 }, 

402 'title': { 

403 'description': 'a human readable title for the queryable', # noqa 

404 'type': 'string' 

405 }, 

406 'description': { 

407 'description': 'a human-readable narrative describing the queryable', # noqa 

408 'type': 'string' 

409 }, 

410 'language': { 

411 'description': 'the language used for the title and description', # noqa 

412 'type': 'string', 

413 'default': 'en' 

414 }, 

415 'type': { 

416 'description': 'the data type of the queryable', # noqa 

417 'type': 'string' 

418 }, 

419 'type-ref': { 

420 'description': 'a reference to the formal definition of the type', # noqa 

421 'type': 'string', 

422 'format': 'url' 

423 } 

424 } 

425 }, 

426 'queryables': { 

427 'type': 'object', 

428 'required': [ 

429 'queryables' 

430 ], 

431 'properties': { 

432 'queryables': { 

433 'type': 'array', 

434 'items': {'$ref': '#/components/schemas/queryable'} 

435 } 

436 } 

437 } 

438 } 

439 } 

440 

441 items_f = deepcopy(oas['components']['parameters']['f']) 

442 items_f['schema']['enum'].append('csv') 

443 

444 LOGGER.debug('setting up datasets') 

445 

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

447 name = l10n.translate(k, locale_) 

448 title = l10n.translate(v['title'], locale_) 

449 desc = l10n.translate(v['description'], locale_) 

450 collection_name_path = f'/collections/{k}' 

451 tag = { 

452 'name': name, 

453 'description': desc, 

454 'externalDocs': {} 

455 } 

456 for link in l10n.translate(v.get('links', []), locale_): 

457 if link['type'] == 'information': 457 ↛ 458line 457 didn't jump to line 458 because the condition on line 457 was never true

458 tag['externalDocs']['description'] = link['type'] 

459 tag['externalDocs']['url'] = link['url'] 

460 break 

461 if len(tag['externalDocs']) == 0: 461 ↛ 464line 461 didn't jump to line 464 because the condition on line 461 was always true

462 del tag['externalDocs'] 

463 

464 oas['tags'].append(tag) 

465 

466 paths[collection_name_path] = { 

467 'get': { 

468 'summary': f'Get {title} metadata', 

469 'description': desc, 

470 'tags': [name], 

471 'operationId': f'describe{name.capitalize()}Collection', 

472 'parameters': [ 

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

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

475 ], 

476 'responses': { 

477 '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Collection"}, # noqa 

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

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

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

481 } 

482 } 

483 } 

484 

485 oas['components']['responses'].update({ 

486 'Tiles': { 

487 'description': 'Retrieves the tiles description for this collection', # noqa 

488 'content': { 

489 'application/json': { 

490 'schema': { 

491 '$ref': '#/components/schemas/tiles' 

492 } 

493 } 

494 } 

495 } 

496 } 

497 ) 

498 

499 oas['components']['schemas'].update({ 

500 'tilematrixsetlink': { 

501 'type': 'object', 

502 'required': ['tileMatrixSet'], 

503 'properties': { 

504 'tileMatrixSet': { 

505 'type': 'string' 

506 }, 

507 'tileMatrixSetURI': { 

508 'type': 'string' 

509 } 

510 } 

511 }, 

512 'tiles': { 

513 'type': 'object', 

514 'required': [ 

515 'tileMatrixSetLinks', 

516 'links' 

517 ], 

518 'properties': { 

519 'tileMatrixSetLinks': { 

520 'type': 'array', 

521 'items': { 

522 '$ref': '#/components/schemas/tilematrixsetlink' # noqa 

523 } 

524 }, 

525 'links': { 

526 'type': 'array', 

527 'items': {'$ref': f"{OPENAPI_YAML['oapit']}#/components/schemas/link"} # noqa 

528 } 

529 } 

530 } 

531 } 

532 ) 

533 

534 oas['paths'] = paths 

535 

536 for api_name, api_module in all_apis().items(): 

537 LOGGER.debug(f'Adding OpenAPI definitions for {api_name}') 

538 

539 try: 

540 sub_tags, sub_paths = api_module.get_oas_30(cfg, locale_) 

541 

542 if not sub_tags and not sub_paths: 

543 LOGGER.debug('Empty content from {api_name}; skipping') 

544 continue 

545 

546 oas['paths'].update(sub_paths['paths']) 

547 oas['tags'].extend(sub_tags) 

548 except Exception as err: 

549 if fail_on_invalid_collection: 

550 raise 

551 else: 

552 LOGGER.warning(f'Resource not added to OpenAPI: {err}') 

553 

554 if cfg['server'].get('admin', False): 554 ↛ 555line 554 didn't jump to line 555 because the condition on line 554 was never true

555 schema_dict = get_config_schema() 

556 oas['definitions'] = schema_dict['definitions'] 

557 LOGGER.debug('Adding admin endpoints') 

558 oas['paths'].update(get_admin(cfg)) 

559 

560 return oas 

561 

562 

563def get_oas_30_parameters(cfg: dict, locale_: str): 

564 server_locales = l10n.get_locales(cfg) 

565 

566 oas_30_parameters = { 

567 'f': { 

568 'name': 'f', 

569 'in': 'query', 

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

571 'required': False, 

572 'schema': { 

573 'type': 'string', 

574 'enum': ['json', 'html', 'jsonld'], 

575 'default': 'json' 

576 }, 

577 'style': 'form', 

578 'explode': False 

579 }, 

580 'lang': { 

581 'name': 'lang', 

582 'in': 'query', 

583 'description': 'The optional lang parameter instructs the server return a response in a certain language, if supported. If the language is not among the available values, the Accept-Language header language will be used if it is supported. If the header is missing, the default server language is used. Note that providers may only support a single language (or often no language at all), that can be different from the server language. Language strings can be written in a complex (e.g. "fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7"), simple (e.g. "de") or locale-like (e.g. "de-CH" or "fr_BE") fashion.', # noqa 

584 'required': False, 

585 'schema': { 

586 'type': 'string', 

587 'enum': [l10n.locale2str(sl) for sl in server_locales], 

588 'default': l10n.locale2str(locale_) 

589 } 

590 }, 

591 'skipGeometry': { 

592 'name': 'skipGeometry', 

593 'in': 'query', 

594 'description': 'This option can be used to skip response geometries for each feature.', # noqa 

595 'required': False, 

596 'style': 'form', 

597 'explode': False, 

598 'schema': { 

599 'type': 'boolean', 

600 'default': False 

601 } 

602 }, 

603 'crs': { 

604 'name': 'crs', 

605 'in': 'query', 

606 'description': 'Indicates the coordinate reference system for the results.', # noqa 

607 'style': 'form', 

608 'required': False, 

609 'explode': False, 

610 'schema': { 

611 'format': 'uri', 

612 'type': 'string' 

613 } 

614 }, 

615 'bbox': { 

616 'name': 'bbox', 

617 'in': 'query', 

618 'description': 'Only features that have a geometry that intersects the bounding box are selected.' # noqa 

619 'The bounding box is provided as four or six numbers, depending on whether the ' # noqa 

620 'coordinate reference system includes a vertical axis (height or depth).', # noqa 

621 'required': False, 

622 'style': 'form', 

623 'explode': False, 

624 'schema': { 

625 'type': 'array', 

626 'minItems': 4, 

627 'maxItems': 6, 

628 'items': { 

629 'type': 'number' 

630 } 

631 } 

632 }, 

633 'bbox-crs': { 

634 'name': 'bbox-crs', 

635 'in': 'query', 

636 'description': 'Indicates the coordinate reference system for the given bbox coordinates.', # noqa 

637 'style': 'form', 

638 'required': False, 

639 'explode': False, 

640 'schema': { 

641 'format': 'uri', 

642 'type': 'string' 

643 } 

644 }, 

645 # FIXME: This is not compatible with the bbox-crs definition in 

646 # OGCAPI Features Part 2! 

647 # We need to change the mapscript provider and 

648 # get_collection_map() method in the API! 

649 # So this is for de map-provider only. 

650 'bbox-crs-epsg': { 

651 'name': 'bbox-crs', 

652 'in': 'query', 

653 'description': 'Indicates the EPSG for the given bbox coordinates.', # noqa 

654 'required': False, 

655 'style': 'form', 

656 'explode': False, 

657 'schema': { 

658 'type': 'integer', 

659 'default': 4326 

660 } 

661 }, 

662 'offset': { 

663 'name': 'offset', 

664 'in': 'query', 

665 'description': 'The optional offset parameter indicates the index within the result set from which the server shall begin presenting results in the response document. The first element has an index of 0 (default).', # noqa 

666 'required': False, 

667 'schema': { 

668 'type': 'integer', 

669 'minimum': 0, 

670 'default': 0 

671 }, 

672 'style': 'form', 

673 'explode': False 

674 }, 

675 'properties': { 

676 'name': 'properties', 

677 'in': 'query', 

678 'description': 'The properties that should be included. The parameter value is a comma-separated list of property names.', # noqa 

679 'required': False, 

680 'style': 'form', 

681 'explode': False, 

682 'schema': { 

683 'type': 'array', 

684 'items': { 

685 'type': 'string' 

686 } 

687 } 

688 }, 

689 'vendorSpecificParameters': { 

690 'name': 'vendorSpecificParameters', 

691 'in': 'query', 

692 'description': 'Additional "free-form" parameters that are not explicitly defined', # noqa 

693 'schema': { 

694 'type': 'object', 

695 'additionalProperties': True 

696 }, 

697 'style': 'form' 

698 }, 

699 'resourceId': { 

700 'name': 'resourceId', 

701 'in': 'path', 

702 'description': 'Configuration resource identifier', 

703 'required': True, 

704 'schema': { 

705 'type': 'string' 

706 } 

707 } 

708 } 

709 if len(list(cfg['resources'].keys())) > 0: 709 ↛ 713line 709 didn't jump to line 713 because the condition on line 709 was always true

710 oas_30_parameters['resourceId']['schema']['default'] = list( 

711 cfg['resources'].keys() 

712 )[0] 

713 return oas_30_parameters 

714 

715 

716def get_visible_collections(cfg: dict) -> dict: 

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

718 'type', 'collection') 

719 

720 return { 

721 k: v 

722 for k, v in collections.items() 

723 if v.get('visibility', 'default') != 'hidden' 

724 } 

725 

726 

727def get_config_schema(): 

728 schema_file = SCHEMASDIR / 'config' / 'pygeoapi-config-0.x.yml' 

729 

730 with schema_file.open() as fh2: 

731 return yaml_load(fh2) 

732 

733 

734def get_admin(cfg: dict) -> dict: 

735 

736 schema_dict = get_config_schema() 

737 

738 paths = {} 

739 if cfg['resources']: 

740 res_eg_key = next(iter(cfg['resources'])) 

741 else: 

742 res_eg_key = 'example' 

743 res_eg = { 

744 res_eg_key: cfg['resources'][res_eg_key] 

745 } if cfg['resources'] else { 

746 'example': { 

747 'type': 'collection', 

748 'title': 'Example', 

749 'description': 'Example', 

750 'keywords': ['example'], 

751 'links': [], 

752 'linked-data': {}, 

753 'extents': { 

754 'spatial': { 

755 'bbox': [-180, -90, 180, 90], 

756 'crs': 'http://www.opengis.net/def/crs/OGC/1.3/CRS84' 

757 }, 

758 'temporal': { 

759 'begin': '2000-10-30T18:24:39Z', 

760 'end': '2007-10-30T08:57:29Z', 

761 'trs': 'http://www.opengis.net/def/uom/ISO-8601/0/Gregorian' # noqa 

762 } 

763 } 

764 } 

765 } 

766 if 'extents' in res_eg[res_eg_key]: 

767 res_eg_eg_key = 'extents' 

768 elif 'type' in res_eg[res_eg_key]: 

769 res_eg_eg_key = 'type' 

770 

771 res_eg[res_eg_key]['patch_example'] = { 

772 res_eg_eg_key: res_eg[res_eg_key][res_eg_eg_key] 

773 } 

774 

775 paths['/admin/config'] = { 

776 'get': { 

777 'summary': 'Get admin configuration', 

778 'description': 'Get admin configuration', 

779 'tags': ['admin'], 

780 'operationId': 'getAdminConfig', 

781 'parameters': [ 

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

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

784 ], 

785 'responses': { 

786 '200': { 

787 'description': 'Successful response', 

788 'content': { 

789 'application/json': { 

790 'schema': schema_dict 

791 } 

792 } 

793 } 

794 } 

795 }, 

796 'put': { 

797 'summary': 'Update admin configuration full', 

798 'description': 'Update admin configuration full', 

799 'tags': ['admin'], 

800 'operationId': 'putAdminConfig', 

801 'requestBody': { 

802 'description': 'Updates admin configuration', 

803 'content': { 

804 'application/json': { 

805 'example': cfg, 

806 'schema': schema_dict 

807 } 

808 }, 

809 'required': True 

810 }, 

811 'responses': { 

812 '204': {'$ref': '#/components/responses/204'}, 

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

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

815 } 

816 }, 

817 'patch': { 

818 'summary': 'Partially update admin configuration', 

819 'description': 'Partially update admin configuration', 

820 'tags': ['admin'], 

821 'operationId': 'patchAdminConfig', 

822 'requestBody': { 

823 'description': 'Updates admin configuration', 

824 'content': { 

825 'application/json': { 

826 'example': {'metadata': cfg['metadata']}, 

827 'schema': schema_dict 

828 } 

829 }, 

830 'required': True 

831 }, 

832 'responses': { 

833 '204': {'$ref': '#/components/responses/204'}, 

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

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

836 } 

837 } 

838 } 

839 paths['/admin/config/resources'] = { 

840 'get': { 

841 'summary': 'Get admin configuration resources', 

842 'description': 'Get admin configuration resources', 

843 'tags': ['admin'], 

844 'operationId': 'getAdminConfigResources', 

845 'parameters': [ 

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

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

848 ], 

849 'responses': { 

850 '200': { 

851 'description': 'Successful response', 

852 'content': { 

853 'application/json': { 

854 'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa 

855 } 

856 } 

857 } 

858 } 

859 }, 

860 'post': { 

861 'summary': 'Create admin configuration resource', 

862 'description': 'Create admin configuration resource', 

863 'tags': ['admin'], 

864 'operationId': 'postAdminConfigResource', 

865 'requestBody': { 

866 'description': 'Adds resource to configuration', 

867 'content': { 

868 'application/json': { 

869 'example': {'new-collection': cfg['resources'][res_eg_key] if cfg['resources'] else res_eg['example'] }, # noqa 

870 'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa 

871 } 

872 }, 

873 'required': True 

874 }, 

875 'responses': { 

876 '201': {'description': 'Successful creation'}, 

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

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

879 } 

880 }, 

881 } 

882 paths['/admin/config/resources/{resourceId}'] = { 

883 'get': { 

884 'summary': 'Get admin configuration resource', 

885 'description': 'Get admin configuration resource', 

886 'tags': ['admin'], 

887 'operationId': 'getAdminConfigResource', 

888 'parameters': [ 

889 {'$ref': '#/components/parameters/resourceId'}, 

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

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

892 ], 

893 'responses': { 

894 '200': { 

895 'description': 'Successful response', 

896 'content': { 

897 'application/json': { 

898 'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa 

899 } 

900 } 

901 } 

902 } 

903 }, 

904 'put': { 

905 'summary': 'Update admin configuration resource', 

906 'description': 'Update admin configuration resource', 

907 'tags': ['admin'], 

908 'operationId': 'putAdminConfigResource', 

909 'parameters': [ 

910 {'$ref': '#/components/parameters/resourceId'}, 

911 ], 

912 'requestBody': { 

913 'description': 'Updates admin configuration resource', 

914 'content': { 

915 'application/json': { 

916 'example': res_eg[res_eg_key], 

917 'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa 

918 } 

919 }, 

920 'required': True 

921 }, 

922 'responses': { 

923 '204': {'$ref': '#/components/responses/204'}, 

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

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

926 } 

927 }, 

928 'patch': { 

929 'summary': 'Partially update admin configuration resource', 

930 'description': 'Partially update admin configuration resource', 

931 'tags': ['admin'], 

932 'operationId': 'patchAdminConfigResource', 

933 'parameters': [ 

934 {'$ref': '#/components/parameters/resourceId'}, 

935 ], 

936 'requestBody': { 

937 'description': 'Updates admin configuration resource', 

938 'content': { 

939 'application/json': { 

940 'example': res_eg[res_eg_key]['patch_example'], 

941 'schema': schema_dict['properties']['resources']['patternProperties']['^.*$'] # noqa 

942 } 

943 }, 

944 'required': True 

945 }, 

946 'responses': { 

947 '204': {'$ref': '#/components/responses/204'}, 

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

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

950 } 

951 }, 

952 'delete': { 

953 'summary': 'Delete admin configuration resource', 

954 'description': 'Delete admin configuration resource', 

955 'tags': ['admin'], 

956 'operationId': 'deleteAdminConfigResource', 

957 'parameters': [ 

958 {'$ref': '#/components/parameters/resourceId'}, 

959 ], 

960 'responses': { 

961 '204': {'$ref': '#/components/responses/204'}, 

962 '404': {'$ref': f"{OPENAPI_YAML['oapip']}/responses/NotFound.yaml"}, # noqa 

963 'default': {'$ref': '#/components/responses/default'} # noqa 

964 } 

965 } 

966 } 

967 

968 return paths 

969 

970 

971def get_oas(cfg: dict, fail_on_invalid_collection: bool = True, 

972 version='3.0') -> dict: 

973 """ 

974 Stub to generate OpenAPI Document 

975 

976 :param cfg: `dict` configuration 

977 :param fail_on_invalid_collection: `bool` of whether to fail on an 

978 invalid collection 

979 :param version: version of OpenAPI (default 3.0) 

980 

981 :returns: `dict` of OpenAPI definition 

982 """ 

983 

984 if version == '3.0': 984 ↛ 988line 984 didn't jump to line 988 because the condition on line 984 was always true

985 return get_oas_30( 

986 cfg, fail_on_invalid_collection=fail_on_invalid_collection) 

987 else: 

988 raise RuntimeError('OpenAPI version not supported') 

989 

990 

991def validate_openapi_document(instance_dict: dict) -> bool: 

992 """ 

993 Validate an OpenAPI document against the OpenAPI schema 

994 

995 :param instance_dict: dict of OpenAPI instance 

996 

997 :returns: `bool` of validation 

998 """ 

999 

1000 schema_file = SCHEMASDIR / 'openapi' / 'openapi-3.0.x.json' 

1001 

1002 LOGGER.debug(f'Validating against {schema_file}') 

1003 with schema_file.open() as fh2: 

1004 schema_dict = json.load(fh2) 

1005 jsonschema_validate(instance_dict, schema_dict) 

1006 

1007 return True 

1008 

1009 

1010def generate_openapi_document(cfg_file: Union[Path, io.TextIOWrapper], 

1011 output_format: OAPIFormat, 

1012 fail_on_invalid_collection: bool = True) -> str: 

1013 """ 

1014 Generate an OpenAPI document from the configuration file 

1015 

1016 :param cfg_file: configuration Path instance (`str` of filepath 

1017 or parsed `dict`) 

1018 :param output_format: output format for OpenAPI document 

1019 :param fail_on_invalid_collection: `bool` of whether to fail on an 

1020 invalid collection 

1021 

1022 :returns: `str` of the OpenAPI document in the output format requested 

1023 """ 

1024 

1025 LOGGER.debug(f'Loading configuration {cfg_file}') 

1026 

1027 if isinstance(cfg_file, Path): 1027 ↛ 1028line 1027 didn't jump to line 1028 because the condition on line 1027 was never true

1028 with cfg_file.open(mode="r") as cf: 

1029 s = yaml_load(cf) 

1030 else: 

1031 s = yaml_load(cfg_file) 

1032 

1033 pretty_print = s['server'].get('pretty_print', False) 

1034 

1035 oas = get_oas(s, fail_on_invalid_collection=fail_on_invalid_collection) 

1036 

1037 if output_format == 'yaml': 1037 ↛ 1040line 1037 didn't jump to line 1040 because the condition on line 1037 was always true

1038 content = yaml.safe_dump(oas, default_flow_style=False) 

1039 else: 

1040 content = to_json(oas, pretty=pretty_print) 

1041 return content 

1042 

1043 

1044def load_openapi_document() -> dict: 

1045 """ 

1046 Open OpenAPI document from `PYGEOAPI_OPENAPI` environment variable 

1047 

1048 :returns: `dict` of OpenAPI document 

1049 """ 

1050 

1051 pygeoapi_openapi = os.environ.get('PYGEOAPI_OPENAPI') 

1052 

1053 if pygeoapi_openapi is None: 1053 ↛ 1054line 1053 didn't jump to line 1054 because the condition on line 1053 was never true

1054 msg = 'PYGEOAPI_OPENAPI environment not set' 

1055 LOGGER.error(msg) 

1056 raise RuntimeError(msg) 

1057 

1058 if not os.path.exists(pygeoapi_openapi): 1058 ↛ 1059line 1058 didn't jump to line 1059 because the condition on line 1058 was never true

1059 msg = (f'OpenAPI document {pygeoapi_openapi} does not exist. ' 

1060 'Please generate before starting pygeoapi') 

1061 LOGGER.error(msg) 

1062 raise RuntimeError(msg) 

1063 

1064 with open(pygeoapi_openapi, encoding='utf8') as ff: 

1065 if pygeoapi_openapi.endswith(('.yaml', '.yml')): 1065 ↛ 1068line 1065 didn't jump to line 1068 because the condition on line 1065 was always true

1066 openapi_ = yaml_load(ff) 

1067 else: # JSON string, do not transform 

1068 openapi_ = ff.read() 

1069 

1070 return openapi_ 

1071 

1072 

1073@click.group() 

1074def openapi(): 

1075 """OpenAPI management""" 

1076 pass 

1077 

1078 

1079@click.command() 

1080@click.pass_context 

1081@click.argument('config_file', type=click.File(encoding='utf-8')) 

1082@click.option('--fail-on-invalid-collection/--no-fail-on-invalid-collection', 

1083 '-fic', default=True, help='Fail on invalid collection') 

1084@click.option('--format', '-f', 'format_', type=click.Choice(['json', 'yaml']), 

1085 default='yaml', help='output format (json|yaml)') 

1086@click.option('--output-file', '-of', type=click.File('w', encoding='utf-8'), 

1087 help='Name of output file') 

1088def generate(ctx, config_file, output_file, format_='yaml', 

1089 fail_on_invalid_collection=True): 

1090 """Generate OpenAPI Document""" 

1091 

1092 if config_file is None: 1092 ↛ 1093line 1092 didn't jump to line 1093 because the condition on line 1092 was never true

1093 raise click.ClickException('--config/-c required') 

1094 

1095 content = generate_openapi_document( 

1096 config_file, format_, fail_on_invalid_collection) 

1097 

1098 if output_file is None: 1098 ↛ 1099line 1098 didn't jump to line 1099 because the condition on line 1098 was never true

1099 click.echo(content) 

1100 else: 

1101 click.echo(f'Generating {output_file.name}') 

1102 output_file.write(content) 

1103 click.echo('Done') 

1104 

1105 

1106@click.command() 

1107@click.pass_context 

1108@click.argument('openapi_file', type=click.File()) 

1109def validate(ctx, openapi_file): 

1110 """Validate OpenAPI Document""" 

1111 

1112 if openapi_file is None: 

1113 raise click.ClickException('--openapi/-o required') 

1114 

1115 click.echo(f'Validating {openapi_file}') 

1116 instance = yaml_load(openapi_file) 

1117 validate_openapi_document(instance) 

1118 click.echo('Valid OpenAPI document') 

1119 

1120 

1121openapi.add_command(generate) 

1122openapi.add_command(validate)