Coverage for pygeoapi/api/itemtypes.py: 71%
601 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# 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#
10# Copyright (c) 2026 Tom Kralidis
11# Copyright (c) 2025 Francesco Bartoli
12# Copyright (c) 2022 John A Stevenson and Colin Blackburn
13# Copyright (c) 2023 Ricardo Garcia Silva
14#
15# Permission is hereby granted, free of charge, to any person
16# obtaining a copy of this software and associated documentation
17# files (the "Software"), to deal in the Software without
18# restriction, including without limitation the rights to use,
19# copy, modify, merge, publish, distribute, sublicense, and/or sell
20# copies of the Software, and to permit persons to whom the
21# Software is furnished to do so, subject to the following
22# conditions:
23#
24# The above copyright notice and this permission notice shall be
25# included in all copies or substantial portions of the Software.
26#
27# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
28# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
29# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
30# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
31# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
32# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
33# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
34# OTHER DEALINGS IN THE SOFTWARE.
35#
36# =================================================================
38from collections import ChainMap
39from copy import deepcopy
40from datetime import datetime, UTC
41from http import HTTPStatus
42import logging
43from typing import Any, Tuple, Union
44import urllib.parse
46from pygeofilter.parsers.ecql import parse as parse_ecql_text
47from pygeofilter.parsers.cql2_json import parse as parse_cql2_json
48from pyproj.exceptions import CRSError
50from pygeoapi import l10n
51from pygeoapi.api import evaluate_limit
52from pygeoapi.api.pubsub import publish_message
53from pygeoapi.crs import (DEFAULT_CRS, DEFAULT_STORAGE_CRS,
54 create_crs_transform_spec, get_supported_crs_list,
55 modify_pygeofilter, transform_bbox,
56 set_content_crs_header)
57from pygeoapi.formats import F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD
58from pygeoapi.formatter.base import FormatterSerializationError
59from pygeoapi.linked_data import geojson2jsonld
60from pygeoapi.openapi import get_oas_30_parameters
61from pygeoapi.plugin import load_plugin, PLUGINS
62from pygeoapi.provider import filter_providers_by_type, get_provider_by_type
63from pygeoapi.provider.base import (
64 ProviderGenericError, ProviderItemNotFoundError,
65 ProviderTypeError, SchemaType)
67from pygeoapi.util import (to_json, filter_dict_by_key_value, str2bool,
68 render_j2_template, get_dataset_formatters)
70from . import APIRequest, API, SYSTEM_LOCALE, validate_bbox, validate_datetime
72LOGGER = logging.getLogger(__name__)
74OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0'
77CONFORMANCE_CLASSES_FEATURES = [
78 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/core',
79 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/oas30',
80 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/html',
81 'http://www.opengis.net/spec/ogcapi-features-1/1.0/conf/geojson',
82 'http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs',
83 'http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/queryables',
84 'http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/queryables-query-parameters', # noqa
85 'http://www.opengis.net/spec/ogcapi-features-4/1.0/conf/create-replace-delete', # noqa
86 'http://www.opengis.net/spec/ogcapi-features-5/1.0/conf/schemas',
87 'http://www.opengis.net/spec/ogcapi-features-5/1.0/conf/core-roles-features' # noqa
88]
90CONFORMANCE_CLASSES_RECORDS = [
91 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/core',
92 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/sorting',
93 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/opensearch',
94 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/json',
95 'http://www.opengis.net/spec/ogcapi-records-1/1.0/conf/html'
96]
99def get_collection_queryables(api: API, request: Union[APIRequest, Any],
100 dataset: str | None = None
101 ) -> Tuple[dict, int, str]:
102 """
103 Provide collection queryables
105 :param request: A request object
106 :param dataset: name of collection
108 :returns: tuple of headers, status code, content
109 """
111 domains = {}
112 headers = request.get_response_headers(**api.api_headers)
114 if any([dataset is None, 114 ↛ 117line 114 didn't jump to line 117 because the condition on line 114 was never true
115 dataset not in api.config['resources'].keys()]):
117 msg = 'Collection not found'
118 return api.get_exception(
119 HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
121 LOGGER.debug('Creating collection queryables')
123 p = None
124 for pt in ['feature', 'coverage', 'record']: 124 ↛ 133line 124 didn't jump to line 133 because the loop on line 124 didn't complete
125 try:
126 LOGGER.debug(f'Loading {pt} provider')
127 p = load_plugin('provider', get_provider_by_type(
128 api.config['resources'][dataset]['providers'], pt))
129 break
130 except ProviderTypeError:
131 LOGGER.debug(f'Providing type {pt} not found')
133 if p is None: 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true
134 msg = 'queryables not available for this collection'
135 return api.get_exception(
137 HTTPStatus.BAD_REQUEST, headers, request.format,
138 'NoApplicableError', msg)
140 LOGGER.debug('Processing profile')
141 profile = request.params.get('profile', '')
143 LOGGER.debug('Processing properties')
144 val = request.params.get('properties')
145 if val is not None:
146 properties = [x for x in val.split(',') if x]
147 properties_to_check = set(p.properties) | set(p.fields.keys())
149 if len(list(set(properties) - set(properties_to_check))) > 0:
150 msg = 'unknown properties specified'
151 return api.get_exception(
152 HTTPStatus.BAD_REQUEST, headers, request.format,
153 'InvalidParameterValue', msg)
154 else:
155 properties = []
157 queryables_id = f'{api.get_collections_url()}/{dataset}/queryables'
159 if request.params:
160 queryables_id += '?' + urllib.parse.urlencode(request.params)
162 queryables = {
163 'type': 'object',
164 'title': l10n.translate(
165 api.config['resources'][dataset]['title'], request.locale),
166 'properties': {},
167 '$schema': 'http://json-schema.org/draft/2019-09/schema',
168 '$id': queryables_id
169 }
171 if p.fields: 171 ↛ 177line 171 didn't jump to line 177 because the condition on line 171 was always true
172 queryables['properties']['geometry'] = {
173 'format': 'geometry-any',
174 'x-ogc-role': 'primary-geometry'
175 }
177 if profile == 'actual-domain':
178 try:
179 domains, _ = p.get_domains(properties)
180 except NotImplementedError:
181 LOGGER.debug('Domains are not suported by this provider')
182 domains = {}
184 for k, v in p.fields.items():
185 show_field = False
186 if properties and k not in properties:
187 continue
188 if p.properties: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 if k in p.properties:
190 show_field = True
191 else:
192 show_field = True
194 if show_field: 194 ↛ 184line 194 didn't jump to line 184 because the condition on line 194 was always true
195 queryables['properties'][k] = {
196 'title': k,
197 'type': v['type']
198 }
199 if v['type'] == 'float': 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 queryables['properties'][k]['type'] = 'number'
201 if v.get('format') is not None: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true
202 queryables['properties'][k]['format'] = v['format']
203 if 'values' in v: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 queryables['properties'][k]['enum'] = v['values']
206 if k == p.id_field:
207 queryables['properties'][k]['x-ogc-role'] = 'id'
208 if k == p.time_field:
209 queryables['properties'][k]['x-ogc-role'] = 'primary-instant' # noqa
210 if domains.get(k): 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 queryables['properties'][k]['enum'] = domains[k]
213 if request.format == F_HTML: # render
214 tpl_config = api.get_dataset_templates(dataset)
216 queryables['title'] = l10n.translate(
217 api.config['resources'][dataset]['title'], request.locale)
219 queryables['collections_path'] = api.get_collections_url()
220 queryables['dataset_path'] = f'{api.get_collections_url()}/{dataset}'
222 content = render_j2_template(api.tpl_config, tpl_config,
223 'collections/queryables.html',
224 queryables, request.locale)
226 return headers, HTTPStatus.OK, content
228 headers['Content-Type'] = 'application/schema+json'
230 return headers, HTTPStatus.OK, to_json(queryables, api.pretty_print)
233def get_collection_items(
234 api: API, request: Union[APIRequest, Any],
235 dataset: str | None = None) -> Tuple[dict, int, str]:
236 """
237 Queries collection
239 :param request: A request object
240 :param dataset: dataset name
242 :returns: tuple of headers, status code, content
243 """
245 # Set Content-Language to system locale until provider locale
246 # has been determined
247 headers = request.get_response_headers(SYSTEM_LOCALE,
248 **api.api_headers)
250 properties = []
251 reserved_fieldnames = ['bbox', 'bbox-crs', 'crs', 'f', 'lang', 'limit',
252 'offset', 'resulttype', 'datetime', 'sortby',
253 'properties', 'skipGeometry', 'q',
254 'filter', 'filter-lang', 'filter-crs']
256 collections = filter_dict_by_key_value(api.config['resources'],
257 'type', 'collection')
259 if dataset not in collections.keys(): 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true
260 msg = 'Collection not found'
261 return api.get_exception(
262 HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
264 LOGGER.debug('Processing query parameters')
266 LOGGER.debug('Processing offset parameter')
267 try:
268 offset = int(request.params.get('offset'))
269 if offset < 0:
270 msg = 'offset value should be positive or zero'
271 return api.get_exception(
272 HTTPStatus.BAD_REQUEST, headers, request.format,
273 'InvalidParameterValue', msg)
274 except ValueError:
275 msg = 'offset value should be an integer'
276 return api.get_exception(
277 HTTPStatus.BAD_REQUEST, headers, request.format,
278 'InvalidParameterValue', msg)
279 except TypeError as err:
280 LOGGER.warning(err)
281 offset = 0
283 LOGGER.debug('Processing limit parameter')
284 if api.config['server'].get('limit') is not None: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true
285 msg = ('server.limit is no longer supported! '
286 'Please use limits at the server or collection '
287 'level (RFC5)')
288 LOGGER.warning(msg)
289 try:
290 limit = evaluate_limit(request.params.get('limit'),
291 api.config['server'].get('limits', {}),
292 collections[dataset].get('limits', {}))
293 except ValueError as err:
294 return api.get_exception(
295 HTTPStatus.BAD_REQUEST, headers, request.format,
296 'InvalidParameterValue', str(err))
298 resulttype = request.params.get('resulttype') or 'results'
300 LOGGER.debug('Processing bbox parameter')
302 bbox = request.params.get('bbox')
304 if bbox is None:
305 bbox = []
306 else:
307 try:
308 bbox = validate_bbox(bbox)
309 except ValueError as err:
310 msg = str(err)
311 return api.get_exception(
312 HTTPStatus.BAD_REQUEST, headers, request.format,
313 'InvalidParameterValue', msg)
315 LOGGER.debug('Processing datetime parameter')
316 datetime_ = request.params.get('datetime')
317 try:
318 datetime_ = validate_datetime(collections[dataset]['extents'],
319 datetime_)
320 except ValueError as err:
321 msg = str(err)
322 return api.get_exception(
323 HTTPStatus.BAD_REQUEST, headers, request.format,
324 'InvalidParameterValue', msg)
326 LOGGER.debug('processing q parameter')
327 q = request.params.get('q') or None
329 LOGGER.debug('Loading provider')
331 provider_def = None
332 try:
333 provider_type = 'feature'
334 provider_def = get_provider_by_type(
335 collections[dataset]['providers'], provider_type)
336 p = load_plugin('provider', provider_def)
337 except ProviderTypeError:
338 try:
339 provider_type = 'record'
340 provider_def = get_provider_by_type(
341 collections[dataset]['providers'], provider_type)
342 p = load_plugin('provider', provider_def)
343 except ProviderTypeError:
344 msg = 'Invalid provider type'
345 return api.get_exception(
346 HTTPStatus.BAD_REQUEST, headers, request.format,
347 'NoApplicableCode', msg)
348 except ProviderGenericError as err:
349 return api.get_exception(
350 err.http_status_code, headers, request.format,
351 err.ogc_exception_code, err.message)
353 LOGGER.debug('Validating requested format')
354 dataset_formatters = get_dataset_formatters(collections[dataset])
356 if dataset_formatters: 356 ↛ 364line 356 didn't jump to line 364 because the condition on line 356 was always true
357 LOGGER.debug(f'Dataset formatters: {dataset_formatters}')
358 request._format = request._get_format(
359 request.get_request_headers(request.headers),
360 {v.f: v.mimetype for v in dataset_formatters.values()})
362 LOGGER.debug(f'Request format: {request.format}')
364 if not request.is_valid(dataset_formatters.keys()):
365 return api.get_format_exception(request)
367 crs_transform_spec = None
368 if provider_type == 'feature': 368 ↛ 384line 368 didn't jump to line 384 because the condition on line 368 was always true
369 # crs query parameter is only available for OGC API - Features
370 # right now, not for OGC API - Records.
371 LOGGER.debug('Processing crs parameter')
372 query_crs_uri = request.params.get('crs')
373 try:
374 crs_transform_spec = create_crs_transform_spec(
375 provider_def, query_crs_uri
376 )
377 except (ValueError, CRSError) as err:
378 msg = str(err)
379 return api.get_exception(
380 HTTPStatus.BAD_REQUEST, headers, request.format,
381 'InvalidParameterValue', msg)
382 set_content_crs_header(headers, provider_def, query_crs_uri)
384 LOGGER.debug('Processing bbox-crs parameter')
385 bbox_crs = request.params.get('bbox-crs')
386 if bbox_crs is not None:
387 # Validate bbox-crs parameter
388 if len(bbox) == 0:
389 msg = 'bbox-crs specified without bbox parameter'
390 return api.get_exception(
391 HTTPStatus.BAD_REQUEST, headers, request.format,
392 'NoApplicableCode', msg)
394 if len(bbox_crs) == 0: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true
395 msg = 'bbox-crs specified but is empty'
396 return api.get_exception(
397 HTTPStatus.BAD_REQUEST, headers, request.format,
398 'NoApplicableCode', msg)
400 supported_crs_list = get_supported_crs_list(provider_def)
401 if bbox_crs not in supported_crs_list: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true
402 msg = f'bbox-crs {bbox_crs} not supported for this collection'
403 return api.get_exception(
404 HTTPStatus.BAD_REQUEST, headers, request.format,
405 'NoApplicableCode', msg)
406 elif len(bbox) > 0: 406 ↛ 408line 406 didn't jump to line 408 because the condition on line 406 was never true
407 # bbox but no bbox-crs param: assume bbox is in default CRS
408 bbox_crs = DEFAULT_STORAGE_CRS
410 # Transform bbox to storage_crs
411 # when bbox-crs different from storage_crs.
412 if len(bbox) > 0:
413 try:
414 # Get a pyproj CRS instance for the Collection's Storage CRS
415 storage_crs = provider_def.get('storage_crs', DEFAULT_STORAGE_CRS)
417 # Do the (optional) Transform to the Storage CRS
418 bbox = transform_bbox(bbox, bbox_crs, storage_crs)
419 except CRSError as e:
420 return api.get_exception(
421 HTTPStatus.BAD_REQUEST, headers, request.format,
422 'NoApplicableCode', str(e))
424 LOGGER.debug('processing property parameters')
425 for k, v in request.params.items():
426 include_query_param = False
427 if k not in reserved_fieldnames:
428 if k in list(p.fields.keys()) or p.include_extra_query_parameters:
429 include_query_param = True
431 if include_query_param:
432 LOGGER.debug(f'Including query parameter {k}={v}')
433 properties.append((k, v))
434 else:
435 LOGGER.debug(f'Discarding query parameter {k}={v}')
437 LOGGER.debug('processing sort parameter')
438 val = request.params.get('sortby')
440 if val is not None:
441 sortby = []
442 sorts = val.split(',')
443 for s in sorts: 443 ↛ 460line 443 didn't jump to line 460 because the loop on line 443 didn't complete
444 prop = s
445 order = '+'
446 if s and s[0] in ['+', '-']:
447 order = s[0]
448 prop = s[1:]
450 if prop not in p.fields.keys(): 450 ↛ 456line 450 didn't jump to line 456 because the condition on line 450 was always true
451 msg = 'bad sortby property'
452 return api.get_exception(
453 HTTPStatus.BAD_REQUEST, headers, request.format,
454 'InvalidParameterValue', msg)
456 sortby.append({'property': prop, 'order': order})
457 else:
458 sortby = []
460 LOGGER.debug('processing properties parameter')
461 val = request.params.get('properties')
463 if val is not None:
464 select_properties = val.split(',')
465 properties_to_check = set(p.properties) | set(p.fields.keys())
467 if (len(list(set(select_properties) -
468 set(properties_to_check))) > 0):
469 msg = 'unknown properties specified'
470 return api.get_exception(
471 HTTPStatus.BAD_REQUEST, headers, request.format,
472 'InvalidParameterValue', msg)
473 else:
474 select_properties = []
476 LOGGER.debug('processing skipGeometry parameter')
477 val = request.params.get('skipGeometry')
478 if val is not None:
479 skip_geometry = str2bool(val)
480 else:
481 skip_geometry = False
483 LOGGER.debug('Processing filter-crs parameter')
484 filter_crs_uri = request.params.get('filter-crs', DEFAULT_CRS)
486 LOGGER.debug('processing filter parameter')
487 cql_text = request.params.get('filter')
489 if cql_text is not None: 489 ↛ 490line 489 didn't jump to line 490 because the condition on line 489 was never true
490 try:
491 filter_ = parse_ecql_text(cql_text)
492 filter_ = modify_pygeofilter(
493 filter_,
494 filter_crs_uri=filter_crs_uri,
495 storage_crs_uri=provider_def.get('storage_crs'),
496 geometry_column_name=provider_def.get('geom_field'),
497 )
498 except Exception:
499 msg = 'Bad CQL text'
500 LOGGER.error(f'{msg}: {cql_text}')
501 return api.get_exception(
502 HTTPStatus.BAD_REQUEST, headers, request.format,
503 'InvalidParameterValue', msg)
504 elif request.data:
505 try:
506 request_data = request.data.decode()
507 filter_ = parse_cql2_json(request_data)
508 filter_ = modify_pygeofilter(
509 filter_,
510 filter_crs_uri=filter_crs_uri,
511 storage_crs_uri=provider_def.get('storage_crs'),
512 geometry_column_name=provider_def.get('geom_field'),
513 )
514 except Exception:
515 msg = 'Bad CQL JSON'
516 LOGGER.error(f'{msg}: {request_data}')
517 return api.get_exception(
518 HTTPStatus.BAD_REQUEST, headers, request.format,
519 'InvalidParameterValue', msg)
520 else:
521 filter_ = None
523 LOGGER.debug('Processing filter-lang parameter')
524 filter_lang = request.params.get('filter-lang')
525 # Currently only cql-text is handled, but it is optional
526 if filter_lang not in [None, 'cql-json', 'cql-text']: 526 ↛ 527line 526 didn't jump to line 527 because the condition on line 526 was never true
527 msg = 'Invalid filter language'
528 return api.get_exception(
529 HTTPStatus.BAD_REQUEST, headers, request.format,
530 'InvalidParameterValue', msg)
531 # Get provider locale (if any)
532 prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale)
534 LOGGER.debug('Querying provider')
535 LOGGER.debug(f'offset: {offset}')
536 LOGGER.debug(f'limit: {limit}')
537 LOGGER.debug(f'resulttype: {resulttype}')
538 LOGGER.debug(f'sortby: {sortby}')
539 LOGGER.debug(f'bbox: {bbox}')
540 if provider_type == 'feature': 540 ↛ 542line 540 didn't jump to line 542 because the condition on line 540 was always true
541 LOGGER.debug(f'crs: {query_crs_uri}')
542 LOGGER.debug(f'datetime: {datetime_}')
543 LOGGER.debug(f'properties: {properties}')
544 LOGGER.debug(f'select properties: {select_properties}')
545 LOGGER.debug(f'skipGeometry: {skip_geometry}')
546 LOGGER.debug(f'language: {prv_locale}')
547 LOGGER.debug(f'q: {q}')
548 LOGGER.debug(f'cql_text: {cql_text}')
549 LOGGER.debug(f'filter_: {filter_}')
550 LOGGER.debug(f'filter-lang: {filter_lang}')
551 LOGGER.debug(f'filter-crs: {filter_crs_uri}')
553 try:
554 content = p.query(offset=offset, limit=limit,
555 resulttype=resulttype, bbox=bbox,
556 datetime_=datetime_, properties=properties,
557 sortby=sortby, skip_geometry=skip_geometry,
558 select_properties=select_properties,
559 crs_transform_spec=crs_transform_spec,
560 q=q, language=prv_locale, filterq=filter_)
561 except ProviderGenericError as err:
562 return api.get_exception(
563 err.http_status_code, headers, request.format,
564 err.ogc_exception_code, err.message)
566 serialized_query_params = ''
567 for k, v in request.params.items():
568 if k not in ('f', 'offset'):
569 serialized_query_params += '&'
570 serialized_query_params += urllib.parse.quote(k, safe='')
571 serialized_query_params += '='
572 serialized_query_params += urllib.parse.quote(str(v), safe=',')
574 if 'links' not in content: 574 ↛ 578line 574 didn't jump to line 578 because the condition on line 574 was always true
575 content['links'] = []
577 # TODO: translate titles
578 uri = f'{api.get_collections_url()}/{dataset}/items'
579 content['links'].extend([{
580 'type': 'application/geo+json',
581 'rel': request.get_linkrel(F_JSON),
582 'title': l10n.translate('This document as GeoJSON', request.locale),
583 'href': f'{uri}?f={F_JSON}{serialized_query_params}'
584 }, {
585 'rel': request.get_linkrel(F_JSONLD),
586 'type': FORMAT_TYPES[F_JSONLD],
587 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa
588 'href': f'{uri}?f={F_JSONLD}{serialized_query_params}'
589 }, {
590 'type': FORMAT_TYPES[F_HTML],
591 'rel': request.get_linkrel(F_HTML),
592 'title': l10n.translate('This document as HTML', request.locale),
593 'href': f'{uri}?f={F_HTML}{serialized_query_params}'
594 }])
596 for key, value in dataset_formatters.items():
597 content['links'].append({
598 'type': value.mimetype,
599 'rel': 'alternate',
600 'title': f'This document as {key}',
601 'href': f'{uri}?f={value.name}{serialized_query_params}'
602 })
604 next_link = False
605 prev_link = False
607 if 'next' in [link['rel'] for link in content['links']]: 607 ↛ 608line 607 didn't jump to line 608 because the condition on line 607 was never true
608 LOGGER.debug('Using next link from provider')
609 else:
610 if content.get('numberMatched', -1) > (limit + offset):
611 next_link = True
612 elif len(content['features']) == limit:
613 next_link = True
615 if offset > 0:
616 prev_link = True
618 print(request.format)
619 if prev_link:
620 prev = max(0, offset - limit)
621 url = f'{uri}?offset={prev}{serialized_query_params}'
622 if request.format is not None:
623 url = f'{uri}?f={request.format}&offset={prev}{serialized_query_params}' # noqa
625 content['links'].append(
626 {
627 'type': 'application/geo+json',
628 'rel': 'prev',
629 'title': l10n.translate('Items (prev)', request.locale),
630 'href': url
631 })
633 if next_link:
634 next_ = offset + limit
635 next_href = f'{uri}?offset={next_}{serialized_query_params}'
636 if request.format is not None:
637 next_href = f'{uri}?f={request.format}&offset={next_}{serialized_query_params}' # noqa
638 content['links'].append(
639 {
640 'type': 'application/geo+json',
641 'rel': 'next',
642 'title': l10n.translate('Items (next)', request.locale),
643 'href': next_href
644 })
646 content['links'].append(
647 {
648 'type': FORMAT_TYPES[F_JSON],
649 'title': l10n.translate(
650 collections[dataset]['title'], request.locale),
651 'rel': 'collection',
652 'href': '/'.join(uri.split('/')[:-1])
653 })
655 content['timeStamp'] = datetime.now(UTC).strftime(
656 '%Y-%m-%dT%H:%M:%S.%fZ')
658 # Set response language to requested provider locale
659 # (if it supports language) and/or otherwise the requested pygeoapi
660 # locale (or fallback default locale)
661 l10n.set_response_language(headers, prv_locale, request.locale)
663 if request.format == F_HTML: # render
664 tpl_config = api.get_dataset_templates(dataset)
665 # For constructing proper URIs to items
667 content['itemtype'] = p.type
668 content['items_path'] = uri
669 content['dataset_path'] = '/'.join(uri.split('/')[:-1])
670 content['collections_path'] = api.get_collections_url()
672 content['offset'] = offset
674 content['id_field'] = p.id_field
675 if p.uri_field is not None: 675 ↛ 676line 675 didn't jump to line 676 because the condition on line 675 was never true
676 content['uri_field'] = p.uri_field
677 if p.title_field is not None: 677 ↛ 678line 677 didn't jump to line 678 because the condition on line 677 was never true
678 content['title_field'] = l10n.translate(p.title_field,
679 request.locale)
680 # If title exists, use it as id in html templates
681 content['id_field'] = content['title_field']
682 content = render_j2_template(api.tpl_config, tpl_config,
683 'collections/items/index.html',
684 content, request.locale)
685 return headers, HTTPStatus.OK, content
686 elif request.format in [df.f for df in dataset_formatters.values()]:
687 formatter = [v for v in dataset_formatters.values() if
688 v.f == request.format][0]
690 try:
691 content = formatter.write(
692 data=content,
693 options={
694 'provider_def': get_provider_by_type(
695 collections[dataset]['providers'],
696 'feature')
697 }
698 )
699 except FormatterSerializationError:
700 msg = 'Error serializing output'
701 return api.get_exception(
702 HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format,
703 'NoApplicableCode', msg)
705 headers['Content-Type'] = formatter.mimetype
707 if formatter.attachment: 707 ↛ 708line 707 didn't jump to line 708 because the condition on line 707 was never true
708 if p.filename is None:
709 filename = f'{dataset}.{formatter.extension}'
710 else:
711 filename = f'{p.filename}'
713 cd = f'attachment; filename="{filename}"'
714 headers['Content-Disposition'] = cd
716 return headers, HTTPStatus.OK, content
718 elif request.format == F_JSONLD:
719 content = geojson2jsonld(
720 api, content, dataset, id_field=(p.uri_field or 'id')
721 )
723 return headers, HTTPStatus.OK, content
725 return headers, HTTPStatus.OK, to_json(content, api.pretty_print)
728def manage_collection_item(
729 api: API, request: APIRequest,
730 action: str, dataset: str,
731 identifier: str | None = None) -> Tuple[dict, int, str]:
732 """
733 Adds an item to a collection
735 :param request: A request object
736 :param action: an action among 'create', 'update', 'delete', 'options'
737 :param dataset: dataset name
739 :returns: tuple of headers, status code, content
740 """
742 if not request.is_valid(PLUGINS['formatter'].keys()): 742 ↛ 743line 742 didn't jump to line 743 because the condition on line 742 was never true
743 return api.get_format_exception(request)
745 # Set Content-Language to system locale until provider locale
746 # has been determined
747 headers = request.get_response_headers(SYSTEM_LOCALE, **api.api_headers)
749 collections = filter_dict_by_key_value(api.config['resources'],
750 'type', 'collection')
752 http_status = HTTPStatus.OK
753 payload = None
755 if dataset not in collections.keys(): 755 ↛ 756line 755 didn't jump to line 756 because the condition on line 755 was never true
756 msg = 'Collection not found'
757 return api.get_exception(
758 HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
760 LOGGER.debug('Loading provider')
761 try:
762 provider_def = get_provider_by_type(
763 collections[dataset]['providers'], 'feature')
764 p = load_plugin('provider', provider_def)
765 except ProviderTypeError:
766 try:
767 provider_def = get_provider_by_type(
768 collections[dataset]['providers'], 'record')
769 p = load_plugin('provider', provider_def)
770 except ProviderTypeError:
771 msg = 'Invalid provider type'
772 return api.get_exception(
773 HTTPStatus.BAD_REQUEST, headers, request.format,
774 'InvalidParameterValue', msg)
776 if action == 'options':
777 headers['Allow'] = 'HEAD, GET'
778 if p.editable: 778 ↛ 779line 778 didn't jump to line 779 because the condition on line 778 was never true
779 if identifier is None:
780 headers['Allow'] += ', POST'
781 else:
782 headers['Allow'] += ', PUT, DELETE'
783 return headers, HTTPStatus.OK, ''
785 if not p.editable: 785 ↛ 791line 785 didn't jump to line 791 because the condition on line 785 was always true
786 msg = 'Collection is not editable'
787 return api.get_exception(
788 HTTPStatus.BAD_REQUEST, headers, request.format,
789 'InvalidParameterValue', msg)
791 if action in ['create', 'update'] and not request.data:
792 msg = 'No data found'
793 return api.get_exception(
794 HTTPStatus.BAD_REQUEST, headers, request.format,
795 'InvalidParameterValue', msg)
797 if action == 'create':
798 LOGGER.debug('Creating item')
799 try:
800 payload = request.data
801 identifier = p.create(payload)
802 except TypeError as err:
803 msg = str(err)
804 return api.get_exception(
805 HTTPStatus.BAD_REQUEST, headers, request.format,
806 'InvalidParameterValue', msg)
807 except ProviderGenericError as err:
808 return api.get_exception(
809 err.http_status_code, headers, request.format,
810 err.ogc_exception_code, err.message)
812 headers['Location'] = f'{api.get_collections_url()}/{dataset}/items/{identifier}' # noqa
814 http_status = HTTPStatus.CREATED
816 if action == 'update':
817 LOGGER.debug('Updating item')
818 try:
819 payload = request.data
820 _ = p.update(identifier, payload)
821 except TypeError as err:
822 msg = str(err)
823 return api.get_exception(
824 HTTPStatus.BAD_REQUEST, headers, request.format,
825 'InvalidParameterValue', msg)
826 except ProviderGenericError as err:
827 return api.get_exception(
828 err.http_status_code, headers, request.format,
829 err.ogc_exception_code, err.message)
831 http_status = HTTPStatus.NO_CONTENT
833 if action == 'delete':
834 LOGGER.debug('Deleting item')
835 try:
836 _ = p.get(identifier)
837 except ProviderItemNotFoundError as err:
838 return api.get_exception(
839 err.http_status_code, headers, request.format,
840 err.ogc_exception_code, err.message)
842 try:
843 _ = p.delete(identifier)
844 except ProviderGenericError as err:
845 return api.get_exception(
846 err.http_status_code, headers, request.format,
847 err.ogc_exception_code, err.message)
849 http_status = HTTPStatus.OK
851 if api.pubsub_client is not None:
852 LOGGER.debug('Publishing message')
853 publish_message(api.pubsub_client, api.base_url, action, dataset,
854 identifier, payload)
856 return headers, http_status, ''
859def get_collection_item(api: API, request: APIRequest,
860 dataset: str, identifier: str
861 ) -> Tuple[dict, int, str]:
862 """
863 Get a single collection item
865 :param request: A request object
866 :param dataset: dataset name
867 :param identifier: item identifier
869 :returns: tuple of headers, status code, content
870 """
872 # Set Content-Language to system locale until provider locale
873 # has been determined
874 headers = request.get_response_headers(SYSTEM_LOCALE, **api.api_headers)
876 LOGGER.debug('Processing query parameters')
878 collections = filter_dict_by_key_value(api.config['resources'],
879 'type', 'collection')
881 if dataset not in collections.keys(): 881 ↛ 882line 881 didn't jump to line 882 because the condition on line 881 was never true
882 msg = 'Collection not found'
883 return api.get_exception(
884 HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
886 LOGGER.debug('Loading provider')
888 try:
889 provider_type = 'feature'
890 provider_def = get_provider_by_type(
891 collections[dataset]['providers'], provider_type)
892 p = load_plugin('provider', provider_def)
893 except ProviderTypeError:
894 try:
895 provider_type = 'record'
896 provider_def = get_provider_by_type(
897 collections[dataset]['providers'], provider_type)
898 p = load_plugin('provider', provider_def)
899 except ProviderTypeError:
900 msg = 'Invalid provider type'
901 return api.get_exception(
902 HTTPStatus.BAD_REQUEST, headers, request.format,
903 'InvalidParameterValue', msg)
904 except ProviderGenericError as err:
905 return api.get_exception(
906 err.http_status_code, headers, request.format,
907 err.ogc_exception_code, err.message)
909 crs_transform_spec = None
910 if provider_type == 'feature': 910 ↛ 927line 910 didn't jump to line 927 because the condition on line 910 was always true
911 # crs query parameter is only available for OGC API - Features
912 # right now, not for OGC API - Records.
913 LOGGER.debug('Processing crs parameter')
914 query_crs_uri = request.params.get('crs')
915 try:
916 crs_transform_spec = create_crs_transform_spec(
917 provider_def, query_crs_uri
918 )
919 except (ValueError, CRSError) as err:
920 msg = str(err)
921 return api.get_exception(
922 HTTPStatus.BAD_REQUEST, headers, request.format,
923 'InvalidParameterValue', msg)
924 set_content_crs_header(headers, provider_def, query_crs_uri)
926 # Get provider language (if any)
927 prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale)
929 try:
930 LOGGER.debug(f'Fetching id {identifier}')
931 content = p.get(
932 identifier,
933 language=prv_locale,
934 crs_transform_spec=crs_transform_spec,
935 )
936 except ProviderGenericError as err:
937 return api.get_exception(
938 err.http_status_code, headers, request.format,
939 err.ogc_exception_code, err.message)
941 if content is None: 941 ↛ 942line 941 didn't jump to line 942 because the condition on line 941 was never true
942 msg = 'identifier not found'
943 return api.get_exception(HTTPStatus.BAD_REQUEST, headers,
944 request.format, 'NotFound', msg)
946 uri = content['properties'].get(p.uri_field) if p.uri_field else \
947 f'{api.get_collections_url()}/{dataset}/items/{identifier}'
949 if 'links' not in content: 949 ↛ 952line 949 didn't jump to line 952 because the condition on line 949 was always true
950 content['links'] = []
952 content['links'].extend([{
953 'type': FORMAT_TYPES[F_JSON],
954 'rel': 'root',
955 'title': l10n.translate('The landing page of this server as JSON', request.locale), # noqa
956 'href': f"{api.base_url}?f={F_JSON}"
957 }, {
958 'type': FORMAT_TYPES[F_HTML],
959 'rel': 'root',
960 'title': l10n.translate('The landing page of this server as HTML', request.locale), # noqa
961 'href': f"{api.base_url}?f={F_HTML}"
962 }, {
963 'rel': request.get_linkrel(F_JSON),
964 'type': 'application/geo+json',
965 'title': l10n.translate('This document as JSON', request.locale),
966 'href': f'{uri}?f={F_JSON}'
967 }, {
968 'rel': request.get_linkrel(F_JSONLD),
969 'type': FORMAT_TYPES[F_JSONLD],
970 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa
971 'href': f'{uri}?f={F_JSONLD}'
972 }, {
973 'rel': request.get_linkrel(F_HTML),
974 'type': FORMAT_TYPES[F_HTML],
975 'title': l10n.translate('This document as HTML', request.locale),
976 'href': f'{uri}?f={F_HTML}'
977 }, {
978 'rel': 'collection',
979 'type': FORMAT_TYPES[F_JSON],
980 'title': l10n.translate(collections[dataset]['title'],
981 request.locale),
982 'href': f'{api.get_collections_url()}/{dataset}'
983 }])
985 link_request_format = (
986 request.format if request.format is not None else F_JSON
987 )
988 if 'prev' in content: 988 ↛ 989line 988 didn't jump to line 989 because the condition on line 988 was never true
989 content['links'].append({
990 'rel': 'prev',
991 'type': FORMAT_TYPES[link_request_format],
992 'href': f"{api.get_collections_url()}/{dataset}/items/{content['prev']}?f={link_request_format}" # noqa
993 })
994 if 'next' in content: 994 ↛ 995line 994 didn't jump to line 995 because the condition on line 994 was never true
995 content['links'].append({
996 'rel': 'next',
997 'type': FORMAT_TYPES[link_request_format],
998 'href': f"{api.get_collections_url()}/{dataset}/items/{content['next']}?f={link_request_format}" # noqa
999 })
1001 # Set response language to requested provider locale
1002 # (if it supports language) and/or otherwise the requested pygeoapi
1003 # locale (or fallback default locale)
1004 l10n.set_response_language(headers, prv_locale, request.locale)
1006 if request.format == F_HTML: # render
1007 tpl_config = api.get_dataset_templates(dataset)
1008 content['title'] = l10n.translate(collections[dataset]['title'],
1009 request.locale)
1010 content['id_field'] = p.id_field
1011 if p.uri_field is not None: 1011 ↛ 1012line 1011 didn't jump to line 1012 because the condition on line 1011 was never true
1012 content['uri_field'] = p.uri_field
1013 if p.title_field is not None: 1013 ↛ 1014line 1013 didn't jump to line 1014 because the condition on line 1013 was never true
1014 content['title_field'] = l10n.translate(p.title_field,
1015 request.locale)
1016 content['collections_path'] = api.get_collections_url()
1018 content = render_j2_template(api.tpl_config, tpl_config,
1019 'collections/items/item.html',
1020 content, request.locale)
1021 return headers, HTTPStatus.OK, content
1023 elif request.format == F_JSONLD:
1024 content = geojson2jsonld(
1025 api, content, dataset, uri, (p.uri_field or 'id')
1026 )
1028 return headers, HTTPStatus.OK, content
1030 return headers, HTTPStatus.OK, to_json(content, api.pretty_print)
1033def get_oas_30(cfg: dict, locale: str) -> tuple[list[dict[str, str]], dict[str, dict]]: # noqa
1034 """
1035 Get OpenAPI fragments
1037 :param cfg: `dict` of configuration
1038 :param locale: `str` of locale
1040 :returns: `tuple` of `list` of tag objects, and `dict` of path objects
1041 """
1043 from pygeoapi.openapi import OPENAPI_YAML, get_visible_collections
1045 limit = {
1046 'name': 'limit',
1047 'in': 'query',
1048 'description': 'The optional limit parameter limits the number of items that are presented in the response document', # noqa
1049 'required': False,
1050 'schema': {
1051 'type': 'integer',
1052 'minimum': 1,
1053 'maximum': 10000,
1054 'default': 100
1055 },
1056 'style': 'form',
1057 'explode': False
1058 }
1060 profile = {
1061 'name': 'profile',
1062 'in': 'query',
1063 'description': 'The profile to be applied to a given request',
1064 'required': False,
1065 'style': 'form',
1066 'explode': False,
1067 'schema': {
1068 'type': 'string',
1069 'enum': ['actual-domain', 'valid-domain']
1070 }
1071 }
1073 LOGGER.debug('setting up collection endpoints')
1074 paths = {}
1076 collections = filter_dict_by_key_value(cfg['resources'],
1077 'type', 'collection')
1079 for k, v in get_visible_collections(cfg).items():
1080 try:
1081 ptype = None
1083 if filter_providers_by_type( 1083 ↛ 1087line 1083 didn't jump to line 1087 because the condition on line 1083 was always true
1084 collections[k]['providers'], 'feature'):
1085 ptype = 'feature'
1087 if filter_providers_by_type( 1087 ↛ 1089line 1087 didn't jump to line 1089 because the condition on line 1087 was never true
1088 collections[k]['providers'], 'record'):
1089 ptype = 'record'
1091 p = load_plugin('provider', get_provider_by_type(
1092 collections[k]['providers'], ptype))
1094 collection_name_path = f'/collections/{k}'
1095 items_path = f'/collections/{k}/items'
1096 title = l10n.translate(v['title'], locale)
1097 description = l10n.translate(v['description'], locale)
1099 oas_30_parameters = get_oas_30_parameters(cfg, locale)
1101 coll_properties = deepcopy(oas_30_parameters)['properties']
1102 coll_properties['schema']['items']['enum'] = list(p.fields.keys())
1104 coll_limit = _derive_limit(
1105 deepcopy(limit), cfg['server'].get('limits', {}),
1106 v.get('limits', {})
1107 )
1109 dataset_formatters = get_dataset_formatters(v)
1110 coll_f_parameter = deepcopy(oas_30_parameters)['f']
1111 for key, value in dataset_formatters.items():
1112 coll_f_parameter['schema']['enum'].append(value.f)
1114 paths[items_path] = {
1115 'get': {
1116 'summary': f'Get {title} items',
1117 'description': description,
1118 'tags': [k],
1119 'operationId': f'get{k.capitalize()}Features',
1120 'parameters': [
1121 coll_f_parameter,
1122 {'$ref': '#/components/parameters/lang'},
1123 {'$ref': '#/components/parameters/bbox'},
1124 coll_limit,
1125 {'$ref': '#/components/parameters/crs'}, # noqa
1126 {'$ref': '#/components/parameters/bbox-crs'},
1127 coll_properties,
1128 {'$ref': '#/components/parameters/vendorSpecificParameters'}, # noqa
1129 {'$ref': '#/components/parameters/skipGeometry'},
1130 {'$ref': f"{OPENAPI_YAML['oapir']}/parameters/sortby.yaml"}, # noqa
1131 {'$ref': '#/components/parameters/offset'}
1132 ],
1133 'responses': {
1134 '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa
1135 '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
1136 '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
1137 '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
1138 }
1139 },
1140 'options': {
1141 'summary': f'Options for {title} items',
1142 'description': description,
1143 'tags': [k],
1144 'operationId': f'options{k.capitalize()}Features',
1145 'responses': {
1146 '200': {'description': 'options response'}
1147 }
1148 }
1149 }
1151 # TODO: update feature POSTs once updated in OGC API - Features
1152 # https://github.com/opengeospatial/ogcapi-features/issues/771
1153 paths[items_path]['post'] = {
1154 'summary': f'Get {title} items with CQL2',
1155 'description': description,
1156 'tags': [k],
1157 'operationId': f'getCQL2{k.capitalize()}Features',
1158 'requestBody': {
1159 'description': 'Get items with CQL2',
1160 'content': {
1161 'application/json': { # CQL2
1162 'schema': {
1163 '$ref': OPENAPI_YAML['cql2']
1164 }
1165 }
1166 },
1167 'required': True
1168 },
1169 'responses': {
1170 '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Features"}, # noqa
1171 '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
1172 '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
1173 }
1174 }
1176 if p.editable: 1176 ↛ 1177line 1176 didn't jump to line 1177 because the condition on line 1176 was never true
1177 LOGGER.debug('Provider is editable; adding post')
1179 paths[items_path]['post']['operationId'] = f'getCQL2OrAdd{k.capitalize()}Features' # noqa
1181 val = paths[items_path]['post']['summary']
1182 paths[items_path]['post']['summary'] = f'{val} or add item (see media type)' # noqa
1184 val = paths[items_path]['post']['requestBody']['description']
1185 paths[items_path]['post']['requestBody']['description'] = f'{val} or add item to collection' # noqa
1187 paths[items_path]['post']['requestBody']['content']['application/geo+json'] = { # noqa
1188 'schema': {}
1189 }
1191 paths[items_path]['post']['responses']['201'] = {
1192 'description': 'Successful creation'
1193 }
1195 try:
1196 schema_ref = p.get_schema(SchemaType.create)
1197 paths[items_path]['post']['requestBody']['content'][schema_ref[0]] = { # noqa
1198 'schema': schema_ref[1]
1199 }
1200 except Exception as err:
1201 LOGGER.debug(err)
1203 if ptype == 'record': 1203 ↛ 1204line 1203 didn't jump to line 1204 because the condition on line 1203 was never true
1204 paths[items_path]['get']['parameters'].append(
1205 {'$ref': f"{OPENAPI_YAML['oapir']}/parameters/q.yaml"})
1206 if p.fields: 1206 ↛ 1251line 1206 didn't jump to line 1251 because the condition on line 1206 was always true
1207 schema_path = f'{collection_name_path}/schema'
1209 paths[schema_path] = {
1210 'get': {
1211 'summary': f'Get {title} schema',
1212 'description': description,
1213 'tags': [k],
1214 'operationId': f'get{k.capitalize()}Schema',
1215 'parameters': [
1216 {'$ref': '#/components/parameters/f'},
1217 {'$ref': '#/components/parameters/lang'}
1218 ],
1219 'responses': {
1220 '200': {'$ref': '#/components/responses/Queryables'}, # noqa
1221 '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
1222 '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
1223 '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"}, # noqa
1224 }
1225 }
1226 }
1228 queryables_path = f'{collection_name_path}/queryables'
1230 paths[queryables_path] = {
1231 'get': {
1232 'summary': f'Get {title} queryables',
1233 'description': description,
1234 'tags': [k],
1235 'operationId': f'get{k.capitalize()}Queryables',
1236 'parameters': [
1237 coll_properties,
1238 {'$ref': '#/components/parameters/f'},
1239 profile,
1240 {'$ref': '#/components/parameters/lang'}
1241 ],
1242 'responses': {
1243 '200': {'$ref': '#/components/responses/Queryables'}, # noqa
1244 '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
1245 '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
1246 '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"}, # noqa
1247 }
1248 }
1249 }
1251 if p.time_field is not None:
1252 paths[items_path]['get']['parameters'].append(
1253 {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/datetime"}) # noqa
1255 for field, type_ in p.fields.items():
1257 if p.properties and field not in p.properties: 1257 ↛ 1258line 1257 didn't jump to line 1258 because the condition on line 1257 was never true
1258 LOGGER.debug('Provider specified not to advertise property') # noqa
1259 continue
1261 if field == 'q' and ptype == 'record': 1261 ↛ 1262line 1261 didn't jump to line 1262 because the condition on line 1261 was never true
1262 LOGGER.debug('q parameter already declared, skipping')
1263 continue
1265 if type_ == 'date': 1265 ↛ 1266line 1265 didn't jump to line 1266 because the condition on line 1265 was never true
1266 schema = {
1267 'type': 'string',
1268 'format': 'date'
1269 }
1270 elif type_ == 'float': 1270 ↛ 1271line 1270 didn't jump to line 1271 because the condition on line 1270 was never true
1271 schema = {
1272 'type': 'number',
1273 'format': 'float'
1274 }
1275 elif type_ == 'long': 1275 ↛ 1276line 1275 didn't jump to line 1276 because the condition on line 1275 was never true
1276 schema = {
1277 'type': 'integer',
1278 'format': 'int64'
1279 }
1280 else:
1281 schema = type_
1283 if schema.get('format') is None: 1283 ↛ 1286line 1283 didn't jump to line 1286 because the condition on line 1283 was always true
1284 schema.pop('format', None)
1286 path_ = f'{collection_name_path}/items'
1287 paths[path_]['get']['parameters'].append({
1288 'name': field,
1289 'in': 'query',
1290 'required': False,
1291 'schema': schema,
1292 'style': 'form',
1293 'explode': False
1294 })
1296 paths[f'{collection_name_path}/items/{{featureId}}'] = {
1297 'get': {
1298 'summary': f'Get {title} item by id',
1299 'description': description,
1300 'tags': [k],
1301 'operationId': f'get{k.capitalize()}Feature',
1302 'parameters': [
1303 {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"}, # noqa
1304 {'$ref': '#/components/parameters/crs'}, # noqa
1305 {'$ref': '#/components/parameters/f'},
1306 {'$ref': '#/components/parameters/lang'}
1307 ],
1308 'responses': {
1309 '200': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/Feature"}, # noqa
1310 '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
1311 '404': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/NotFound"}, # noqa
1312 '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
1313 }
1314 },
1315 'options': {
1316 'summary': f'Options for {title} item by id',
1317 'description': description,
1318 'tags': [k],
1319 'operationId': f'options{k.capitalize()}Feature',
1320 'parameters': [
1321 {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"} # noqa
1322 ],
1323 'responses': {
1324 '200': {'description': 'options response'}
1325 }
1326 }
1327 }
1329 try:
1330 schema_ref = p.get_schema()
1331 paths[f'{collection_name_path}/items/{{featureId}}']['get']['responses']['200'] = { # noqa
1332 'content': {
1333 schema_ref[0]: {
1334 'schema': schema_ref[1]
1335 }
1336 }
1337 }
1338 except Exception as err:
1339 LOGGER.debug(err)
1341 if p.editable: 1341 ↛ 1342line 1341 didn't jump to line 1342 because the condition on line 1341 was never true
1342 LOGGER.debug('Provider is editable; adding put/delete')
1343 put_path = f'{collection_name_path}/items/{{featureId}}' # noqa
1344 paths[put_path]['put'] = { # noqa
1345 'summary': f'Update {title} items',
1346 'description': description,
1347 'tags': [k],
1348 'operationId': f'update{k.capitalize()}Features',
1349 'parameters': [
1350 {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"} # noqa
1351 ],
1352 'requestBody': {
1353 'description': 'Updates item in collection',
1354 'content': {
1355 'application/geo+json': {
1356 'schema': {}
1357 }
1358 },
1359 'required': True
1360 },
1361 'responses': {
1362 '204': {'$ref': '#/components/responses/204'},
1363 '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
1364 '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
1365 }
1366 }
1368 try:
1369 schema_ref = p.get_schema(SchemaType.replace)
1370 paths[put_path]['put']['requestBody']['content'][schema_ref[0]] = { # noqa
1371 'schema': schema_ref[1]
1372 }
1373 except Exception as err:
1374 LOGGER.debug(err)
1376 paths[f'{collection_name_path}/items/{{featureId}}']['delete'] = { # noqa
1377 'summary': f'Delete {title} items',
1378 'description': description,
1379 'tags': [k],
1380 'operationId': f'delete{k.capitalize()}Features',
1381 'parameters': [
1382 {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/parameters/featureId"}, # noqa
1383 ],
1384 'responses': {
1385 '200': {'description': 'Successful delete'},
1386 '400': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/InvalidParameter"}, # noqa
1387 '500': {'$ref': f"{OPENAPI_YAML['oapif-1']}#/components/responses/ServerError"} # noqa
1388 }
1389 }
1391 except ProviderTypeError:
1392 LOGGER.debug('collection is not feature/item based')
1394 return [{'name': 'records'}, {'name': 'features'}], {'paths': paths}
1397def _derive_limit(limit_object, server_limits, collection_limits) -> dict:
1398 """
1399 Helper function to derive a limit object for a given collection
1401 :param limit_object: OpenAPI limit parameter
1402 :param server_limits: server level limits configuration
1403 :param collection_limits: collection level limits configuration
1405 :returns: updated limit object
1406 """
1408 effective_limits = ChainMap(collection_limits, server_limits)
1410 default_limit = effective_limits.get('default_items', 10)
1411 max_limit = effective_limits.get('max_items', 10)
1413 limit_object['schema']['default'] = default_limit
1414 limit_object['schema']['maximum'] = max_limit
1416 text = f' (maximum={max_limit}, default={default_limit}).'
1417 limit_object['description'] += text
1419 return limit_object