Coverage for pygeoapi/openapi.py: 69%
232 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# 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# =================================================================
34from copy import deepcopy
35import io
36import json
37import logging
38import os
39from pathlib import Path
40from typing import Union
42import click
43from jsonschema import validate as jsonschema_validate
44import yaml
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)
52LOGGER = logging.getLogger(__name__)
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}
68def get_ogc_schemas_location(server_config: dict) -> str:
69 """
70 Determine OGC schemas location
72 :param server_config: `dict` of server configuration
74 :returns: `str` of OGC schemas location
75 """
77 osl = server_config.get('ogc_schemas_location')
79 value = 'https://schemas.opengis.net'
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'
88 return value
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
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
100 :returns: `dict` of media type object
101 """
103 ref = f'{OPENAPI_YAML[api_type]}/{path}'
105 content = {
106 media_type: {
107 'schema': {
108 '$ref': ref
109 }
110 }
111 }
113 return content
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
122 :param description: text description of response
123 :param media_type: MIME type
124 :param api_type: OGC API type
126 :returns: `dict` of response object
127 """
129 response = {
130 'description': description,
131 'content': gen_media_type_object(media_type, api_type, path)
132 }
134 return response
137def gen_contact(cfg: dict) -> dict:
138 """
139 Generates an OpenAPI contact object with OGC extensions
140 based on OGC API - Records contact
142 :param cfg: `dict` of configuration
144 :returns: `dict` of OpenAPI contact object
145 """
147 has_addresses = False
148 has_phones = False
150 contact = {
151 'name': cfg['metadata']['provider']['name']
152 }
154 for key in ['url', 'email']:
155 if key in cfg['metadata']['provider']:
156 contact[key] = cfg['metadata']['provider'][key]
158 contact['x-ogc-serviceContact'] = {
159 'name': cfg['metadata']['contact']['name'],
160 'addresses': []
161 }
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
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
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']]
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']
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
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
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
186 contact['x-ogc-serviceContact']['addresses'].append(address)
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'] = []
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 })
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 })
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 }]
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 }]
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
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
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
223 return contact
226def get_oas_30(cfg: dict, fail_on_invalid_collection: bool = True) -> dict:
227 """
228 Generates an OpenAPI 3.0 Document
230 :param cfg: configuration object
231 :param fail_on_invalid_collection: `bool` of whether to fail on an invalid
232 collection
234 :returns: dict of OpenAPI definition
235 """
237 paths = {}
239 # TODO: make openapi multilingual (default language only for now)
240 locale_ = l10n.get_locales(cfg)[0]
242 api_rules = get_api_rules(cfg)
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
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
268 oas['servers'] = [{
269 'url': get_base_url(cfg),
270 'description': l10n.translate(cfg['metadata']['identification']['description'], locale_) # noqa
271 }]
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 }
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 }
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 }
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 }
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 )
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 }
441 items_f = deepcopy(oas['components']['parameters']['f'])
442 items_f['schema']['enum'].append('csv')
444 LOGGER.debug('setting up datasets')
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']
464 oas['tags'].append(tag)
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 }
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 )
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 )
534 oas['paths'] = paths
536 for api_name, api_module in all_apis().items():
537 LOGGER.debug(f'Adding OpenAPI definitions for {api_name}')
539 try:
540 sub_tags, sub_paths = api_module.get_oas_30(cfg, locale_)
542 if not sub_tags and not sub_paths:
543 LOGGER.debug('Empty content from {api_name}; skipping')
544 continue
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}')
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))
560 return oas
563def get_oas_30_parameters(cfg: dict, locale_: str):
564 server_locales = l10n.get_locales(cfg)
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
716def get_visible_collections(cfg: dict) -> dict:
717 collections = filter_dict_by_key_value(cfg['resources'],
718 'type', 'collection')
720 return {
721 k: v
722 for k, v in collections.items()
723 if v.get('visibility', 'default') != 'hidden'
724 }
727def get_config_schema():
728 schema_file = SCHEMASDIR / 'config' / 'pygeoapi-config-0.x.yml'
730 with schema_file.open() as fh2:
731 return yaml_load(fh2)
734def get_admin(cfg: dict) -> dict:
736 schema_dict = get_config_schema()
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'
771 res_eg[res_eg_key]['patch_example'] = {
772 res_eg_eg_key: res_eg[res_eg_key][res_eg_eg_key]
773 }
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 }
968 return paths
971def get_oas(cfg: dict, fail_on_invalid_collection: bool = True,
972 version='3.0') -> dict:
973 """
974 Stub to generate OpenAPI Document
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)
981 :returns: `dict` of OpenAPI definition
982 """
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')
991def validate_openapi_document(instance_dict: dict) -> bool:
992 """
993 Validate an OpenAPI document against the OpenAPI schema
995 :param instance_dict: dict of OpenAPI instance
997 :returns: `bool` of validation
998 """
1000 schema_file = SCHEMASDIR / 'openapi' / 'openapi-3.0.x.json'
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)
1007 return True
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
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
1022 :returns: `str` of the OpenAPI document in the output format requested
1023 """
1025 LOGGER.debug(f'Loading configuration {cfg_file}')
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)
1033 pretty_print = s['server'].get('pretty_print', False)
1035 oas = get_oas(s, fail_on_invalid_collection=fail_on_invalid_collection)
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
1044def load_openapi_document() -> dict:
1045 """
1046 Open OpenAPI document from `PYGEOAPI_OPENAPI` environment variable
1048 :returns: `dict` of OpenAPI document
1049 """
1051 pygeoapi_openapi = os.environ.get('PYGEOAPI_OPENAPI')
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)
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)
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()
1070 return openapi_
1073@click.group()
1074def openapi():
1075 """OpenAPI management"""
1076 pass
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"""
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')
1095 content = generate_openapi_document(
1096 config_file, format_, fail_on_invalid_collection)
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')
1106@click.command()
1107@click.pass_context
1108@click.argument('openapi_file', type=click.File())
1109def validate(ctx, openapi_file):
1110 """Validate OpenAPI Document"""
1112 if openapi_file is None:
1113 raise click.ClickException('--openapi/-o required')
1115 click.echo(f'Validating {openapi_file}')
1116 instance = yaml_load(openapi_file)
1117 validate_openapi_document(instance)
1118 click.echo('Valid OpenAPI document')
1121openapi.add_command(generate)
1122openapi.add_command(validate)