Coverage for pygeoapi/api/__init__.py: 67%
508 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) 2026 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# =================================================================
38"""
39Root level code of pygeoapi, parsing content provided by web framework.
40Returns content from plugins and sets responses.
41"""
43from collections import ChainMap
44from copy import deepcopy
45from datetime import datetime
46from functools import partial
47from gzip import compress
48from http import HTTPStatus
49import logging
50import re
51import sys
52from typing import Any, Tuple, Union, Self
54from babel import Locale
55from dateutil.parser import parse as dateparse
56import pytz
58from pygeoapi import __version__, l10n
59from pygeoapi.api.collection import gen_collection, OGC_RELTYPES_BASE
60from pygeoapi.formats import FORMAT_TYPES, F_GZIP, F_HTML, F_JSON, F_JSONLD
61from pygeoapi.linked_data import jsonldify, jsonldify_collection
62from pygeoapi.log import setup_logger
63from pygeoapi.plugin import load_plugin
64from pygeoapi.process.manager.base import get_manager
65from pygeoapi.provider import filter_providers_by_type, get_provider_by_type
66from pygeoapi.provider.base import ProviderGenericError, ProviderTypeError
68from pygeoapi.util import (
69 TEMPLATESDIR, UrlPrefetcher, filter_dict_by_key_value, get_api_rules,
70 get_base_url, get_typed_value, render_j2_template, to_json,
71 get_choice_from_headers, get_from_headers
72)
74LOGGER = logging.getLogger(__name__)
76#: Return headers for requests (e.g:X-Powered-By)
77HEADERS = {
78 'Content-Type': 'application/json',
79 'X-Powered-By': f'pygeoapi {__version__}'
80}
82CHARSET = ['utf-8']
84#: Locale used for system responses (e.g. exceptions)
85SYSTEM_LOCALE = l10n.Locale('en', 'US')
87CONFORMANCE_CLASSES = [
88 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core',
89 'http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections',
90 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/landing-page',
91 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json',
92 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/html',
93 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30'
94]
97def all_apis() -> dict:
98 """
99 Return all supported API modules
101 NOTE: this is a function and not a constant to avoid import loops
103 :returns: `dict` of API provider type, API module
104 """
106 from . import (coverages, environmental_data_retrieval, itemtypes, maps,
107 processes, pubsub, tiles, stac)
109 return {
110 'coverage': coverages,
111 'edr': environmental_data_retrieval,
112 'itemtypes': itemtypes,
113 'map': maps,
114 'process': processes,
115 'pubsub': pubsub,
116 'tile': tiles,
117 'stac': stac
118 }
121def apply_gzip(headers: dict, content: Union[str, bytes]) -> Union[str, bytes]:
122 """
123 Compress content if requested in header.
124 """
125 charset = CHARSET[0]
127 if F_GZIP in get_from_headers(headers, 'content-encoding'): 127 ↛ 128line 127 didn't jump to line 128 because the condition on line 127 was never true
128 try:
129 if isinstance(content, bytes):
130 # bytes means Content-Type needs to be set upstream
131 content = compress(content)
132 else:
133 headers['Content-Type'] = \
134 f"{headers['Content-Type']}; charset={charset}"
135 content = compress(content.encode(charset))
136 except TypeError as err:
137 headers.pop('Content-Encoding')
138 LOGGER.error(f'Error in compression: {err}')
139 return content
142class APIRequest:
143 """
144 Transforms an incoming server-specific Request into an object
145 with some generic helper methods and properties.
147 This allows writing straightforward API functions supporting all
148 web platforms such as with this example:
150 .. code-block:: python
152 def example_method(api: API, request: APIRequest, custom_arg):
153 headers = request.get_response_headers()
155 # generate response_body here
157 return headers, HTTPStatus.OK, response_body
160 Basic request validation is done automatically by web platform specific
161 adapters such as ``execute_from_flask`` in flask_app.py . If you want to
162 support custom formats (e.g. ``f=xml``), it needs to be registered with
163 `skip_valid_check=True` and you can use the following code for custom
164 validation. If `xml` was requested, we set the `Content-Type` ourselves.
165 For the standard formats, the `APIRequest` object sets the `Content-Type`.
167 .. code-block:: python
169 def example_method(api: API, request: APIRequest, custom_arg):
170 if not request.is_valid(['xml']):
171 return api.get_format_exception(request)
173 content_type = 'application/xml' if request.format == 'xml' else None
174 headers = request.get_response_headers(content_type)
176 # generate response_body here
178 return headers, HTTPStatus.OK, response_body
180 Note that you don't *have* to call :meth:`is_valid`, but that you can also
181 perform a custom check on the requested output format by looking at the
182 :attr:`format` property.
183 Other query parameters are available through the :attr:`params` property as
184 a `dict`. The request body is available through the :attr:`data` property.
186 :param request: The web platform specific Request instance.
187 :param supported_locales: List or set of supported Locale instances.
188 """
190 def __init__(self, request, supported_locales):
191 # Set default request data
192 self._data = b''
194 # Copy request query parameters
195 self._args = self._get_params(request)
197 # Get path info
198 if hasattr(request, 'scope'): 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true
199 self._path_info = request.scope['path'].strip('/')
200 elif hasattr(request.headers, 'environ'): 200 ↛ 202line 200 didn't jump to line 202 because the condition on line 200 was always true
201 self._path_info = request.headers.environ['PATH_INFO'].strip('/')
202 elif hasattr(request, 'path_info'):
203 self._path_info = request.path_info
205 # Extract locale from params or headers
206 self._raw_locale, self._locale = self._get_locale(request.headers,
207 supported_locales)
209 # Determine format
210 self._format = self._get_format(request.headers)
212 # Get received headers
213 self._headers = self.get_request_headers(request.headers)
215 @classmethod
216 def from_flask(cls, request, supported_locales) -> 'APIRequest':
217 """Factory class similar to with_data, but only for flask requests"""
218 api_req = cls(request, supported_locales)
219 api_req._data = request.data
220 return api_req
222 @classmethod
223 async def from_starlette(cls, request, supported_locales) -> 'APIRequest':
224 """Factory class similar to with_data, but only for starlette requests
225 """
226 api_req = cls(request, supported_locales)
227 api_req._data = await request.body()
228 return api_req
230 @classmethod
231 def from_django(cls, request, supported_locales) -> 'APIRequest':
232 """Factory class similar to with_data, but only for django requests"""
233 api_req = cls(request, supported_locales)
234 api_req._data = request.body
235 return api_req
237 @staticmethod
238 def _get_params(request):
239 """
240 Extracts the query parameters from the `Request` object.
242 :param request: A Flask or Starlette Request instance
243 :returns: `ImmutableMultiDict` or empty `dict`
244 """
246 if hasattr(request, 'args'): 246 ↛ 249line 246 didn't jump to line 249 because the condition on line 246 was always true
247 # Return ImmutableMultiDict from Flask request
248 return request.args
249 elif hasattr(request, 'query_params'):
250 # Return ImmutableMultiDict from Starlette request
251 return request.query_params
252 elif hasattr(request, 'GET'):
253 # Return QueryDict from Django GET request
254 return request.GET
255 elif hasattr(request, 'POST'):
256 # Return QueryDict from Django GET request
257 return request.POST
258 LOGGER.debug('No query parameters found')
259 return {}
261 def _get_locale(self, headers: dict,
262 supported_locales: Union[list, Locale]) -> tuple:
263 """
264 Detects locale from "lang=<language>" param or `Accept-Language`
265 header. Returns a tuple of (raw, locale) if found in params or headers.
266 Returns a tuple of (raw default, default locale) if not found.
268 :param headers: A dict with Request headers
269 :param supported_locales: List or set of supported Locale instances
270 :returns: A tuple of (str, Locale)
271 """
273 raw = None
274 try:
275 default_locale = l10n.str2locale(supported_locales[0])
276 except (TypeError, IndexError, l10n.LocaleError) as err:
277 # This should normally not happen, since the API class already
278 # loads the supported languages from the config, which raises
279 # a LocaleError if any of these languages are invalid.
280 LOGGER.error(err)
281 raise ValueError(f"{self.__class__.__name__} must be initialized"
282 f"with a list of valid supported locales")
284 for field, mapping in ((l10n.QUERY_PARAM, self._args),
285 ('accept-language', headers)):
287 loc_strs = get_choice_from_headers(mapping, field, all=True)
288 if loc_strs:
289 if raw is None: 289 ↛ 294line 289 didn't jump to line 294 because the condition on line 289 was always true
290 # This is the first-found locale string: set as raw
291 raw = get_from_headers(mapping, field)
293 # Check if locale string is a good match for the UI
294 loc = l10n.best_match(loc_strs, supported_locales)
295 is_override = field is l10n.QUERY_PARAM
296 if loc != default_locale or is_override: 296 ↛ 284line 296 didn't jump to line 284 because the condition on line 296 was always true
297 return raw, loc
299 return raw, default_locale
301 def _get_format(self, headers: dict,
302 extra_formats: dict = {}) -> Union[str, None]:
303 """
304 Get `Request` format type from query parameters or headers.
306 :param headers: Dict of Request headers
307 :param extra_formats: Dict of extra dataset specific formats
309 :returns: format value or None if not found/specified
310 """
312 # Optional f=html or f=json query param
313 # Overrides Accept header and might differ from FORMAT_TYPES
314 format_ = (self._args.get('f') or '').strip()
315 if format_:
316 return format_
318 # Format not specified: get from Accept headers (MIME types)
319 # e.g. Accept: 'text/html;q=0.5,application/ld+json'
320 types_ = get_choice_from_headers(headers, 'accept', all=True)
321 if types_ is None: 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true
322 return
324 merged_format_types = FORMAT_TYPES | extra_formats
326 (fmts, mimes) = zip(*merged_format_types.items())
327 mimes2 = [m.split(';')[0] for m in mimes]
329 for type_ in types_:
330 if type_ in mimes2: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 idx_ = mimes2.index(type_)
332 return fmts[idx_]
334 @property
335 def data(self) -> bytes:
336 """Returns the additional data send with the Request (bytes)"""
337 return self._data
339 @property
340 def params(self) -> dict:
341 """Returns the Request query parameters dict"""
342 return self._args
344 @property
345 def path_info(self) -> str:
346 """Returns the web server request path info part"""
347 return self._path_info
349 @property
350 def locale(self) -> l10n.Locale:
351 """
352 Returns the user-defined locale from the request object.
353 If no locale has been defined or if it is invalid,
354 the default server locale is returned.
356 .. note:: The locale here determines the language in which pygeoapi
357 should return its responses. This may not be the language
358 that the user requested. It may also not be the language
359 that is supported by a collection provider, for example.
360 For this reason, you should pass the `raw_locale` property
361 to the :func:`l10n.get_plugin_locale` function, so that
362 the best match for the provider can be determined.
364 :returns: babel.core.Locale
365 """
367 return self._locale
369 @property
370 def raw_locale(self) -> Union[str, None]:
371 """
372 Returns the raw locale string from the `Request` object.
373 If no "lang" query parameter or `Accept-Language` header was found,
374 `None` is returned.
375 Pass this value to the :func:`l10n.get_plugin_locale` function to let
376 the provider determine a best match for the locale, which may be
377 different from the locale used by pygeoapi's UI.
379 :returns: a locale string or None
380 """
382 return self._raw_locale
384 @property
385 def format(self) -> Union[str, None]:
386 """
387 Returns the content type format from the
388 request query parameters or headers.
390 :returns: Format name or None
391 """
393 return self._format
395 @property
396 def headers(self) -> dict:
397 """
398 Returns the dictionary of the headers from
399 the request.
401 :returns: Request headers dictionary
402 """
404 return self._headers
406 def get_linkrel(self, format_: str) -> str:
407 """
408 Returns the hyperlink relationship (rel) attribute value for
409 the given API format string.
411 The string is compared against the request format and if it matches,
412 the value 'self' is returned. Otherwise, 'alternate' is returned.
413 However, if `format_` is 'json' and *no* request format was found,
414 the relationship 'self' is returned as well (JSON is the default).
416 :param format_: The format to compare the request format against.
417 :returns: A string 'self' or 'alternate'.
418 """
420 fmt = format_.lower()
421 if fmt == self._format or (fmt == F_JSON and not self._format):
422 return 'self'
423 return 'alternate'
425 def is_valid(self, additional_formats=None) -> bool:
426 """
427 Returns True if:
428 - the format is not set (None)
429 - the requested format is supported
430 - the requested format exists in a list if additional formats
432 .. note:: Format names are matched in a case-insensitive manner.
434 :param additional_formats: Optional additional supported formats list
436 :returns: bool
437 """
439 if not self._format:
440 return True
441 if self._format in FORMAT_TYPES.keys():
442 return True
443 if self._format in (f.lower() for f in (additional_formats or ())):
444 return True
445 return False
447 def get_response_headers(self, force_lang: l10n.Locale | None = None,
448 force_type: str | None = None,
449 force_encoding: str | None = None,
450 **custom_headers) -> dict:
451 """
452 Prepares and returns a dictionary with Response object headers.
454 This method always adds a 'Content-Language' header, where the value
455 is determined by the 'lang' query parameter or 'Accept-Language'
456 header from the request.
457 If no language was requested, the default pygeoapi language is used,
458 unless a `force_lang` override was specified (see notes below).
460 A 'Content-Type' header is also always added to the response.
461 If the user does not specify `force_type`, the header is based on
462 the `format` APIRequest property. If that is invalid, the default MIME
463 type `application/json` is used.
465 ..note:: If a `force_lang` override is applied, that language
466 is always set as the 'Content-Language', regardless of
467 a 'lang' query parameter or 'Accept-Language' header.
468 If an API response always needs to be in the same
469 language, 'force_lang' should be set to that language.
471 :param force_lang: An optional Content-Language header override.
472 :param force_type: An optional Content-Type header override.
473 :param force_encoding: An optional Content-Encoding header override.
474 :returns: A header dict
475 """
477 headers = HEADERS.copy()
478 headers.update(**custom_headers)
479 l10n.set_response_language(headers, force_lang or self._locale)
480 if force_type: 480 ↛ 482line 480 didn't jump to line 482 because the condition on line 480 was never true
481 # Set custom MIME type if specified
482 headers['Content-Type'] = force_type
483 elif self.is_valid() and self._format:
484 # Set MIME type for valid formats
485 headers['Content-Type'] = FORMAT_TYPES[self._format]
487 if F_GZIP in FORMAT_TYPES: 487 ↛ 488line 487 didn't jump to line 488 because the condition on line 487 was never true
488 if force_encoding:
489 headers['Content-Encoding'] = force_encoding
490 elif F_GZIP in get_from_headers(self._headers, 'accept-encoding'): # noqa
491 headers['Content-Encoding'] = F_GZIP
493 return headers
495 def get_request_headers(self, headers: dict) -> dict:
496 """
497 Obtains and returns a dictionary with Request object headers.
499 This method adds the headers of the original request and
500 makes them available to the API object.
502 :returns: A header dict
503 """
505 headers_ = {item[0]: item[1] for item in headers.items()}
506 return headers_
509class API:
510 """API object"""
512 def __init__(self, config: dict, openapi: dict,
513 asyncapi: dict = {}) -> Self | None:
514 """
515 constructor
517 :param config: configuration dict
518 :param openapi: openapi dict
519 :param asyncapi: asyncapi dict
521 :returns: `pygeoapi.API` instance
522 """
524 self.config = config
525 self.openapi = openapi
526 self.asyncapi = asyncapi
527 self.api_headers = get_api_rules(self.config).response_headers
528 self.base_url = get_base_url(self.config)
529 self.prefetcher = UrlPrefetcher()
530 self.pubsub_client = None
532 CHARSET[0] = config['server'].get('encoding', 'utf-8')
533 if config['server'].get('gzip'): 533 ↛ 534line 533 didn't jump to line 534 because the condition on line 533 was never true
534 FORMAT_TYPES[F_GZIP] = 'application/gzip'
535 FORMAT_TYPES.move_to_end(F_JSON)
537 # Process language settings (first locale is default!)
538 self.locales = l10n.get_locales(config)
539 self.default_locale = self.locales[0]
541 if 'templates' not in self.config['server']: 541 ↛ 544line 541 didn't jump to line 544 because the condition on line 541 was always true
542 self.config['server']['templates'] = {'path': TEMPLATESDIR}
544 if 'pretty_print' not in self.config['server']: 544 ↛ 545line 544 didn't jump to line 545 because the condition on line 544 was never true
545 self.config['server']['pretty_print'] = False
547 self.pretty_print = self.config['server']['pretty_print']
549 setup_logger(self.config['logging'])
551 # Create config clone for HTML templating with modified base URL
552 self.tpl_config = deepcopy(self.config)
553 self.tpl_config['server']['url'] = self.base_url
555 self.manager = get_manager(self.config)
556 LOGGER.info('Process manager plugin loaded')
558 if self.config.get('pubsub') is not None: 558 ↛ 559line 558 didn't jump to line 559 because the condition on line 558 was never true
559 LOGGER.debug('Loading PubSub client')
560 self.pubsub_client = load_plugin('pubsub', self.config['pubsub'])
562 def get_exception(self, status: int, headers: dict, format_: str | None,
563 code: str, description: str) -> Tuple[dict, int, str]:
564 """
565 Exception handler
567 :param status: HTTP status code
568 :param headers: dict of HTTP response headers
569 :param format_: format string
570 :param code: OGC API exception code
571 :param description: OGC API exception code
573 :returns: tuple of headers, status, and message
574 """
576 exception_info = sys.exc_info()
578 LOGGER.error(
579 description,
580 exc_info=exception_info if exception_info[0] is not None else None
581 )
582 exception = {
583 'code': code,
584 'type': code,
585 'description': description
586 }
588 if format_ == F_HTML:
589 headers['Content-Type'] = FORMAT_TYPES[F_HTML]
590 content = render_j2_template(
591 self.tpl_config, self.config['server']['templates'],
592 'exception.html', exception, SYSTEM_LOCALE)
593 else:
594 content = to_json(exception, self.pretty_print)
596 if status == HTTPStatus.NO_CONTENT: 596 ↛ 597line 596 didn't jump to line 597 because the condition on line 596 was never true
597 LOGGER.error('HTTP 204 detected, suppressing content')
598 content = ''
600 return headers, status, content
602 def get_format_exception(self,
603 request: APIRequest) -> Tuple[dict, int, str]:
604 """
605 Returns a format exception.
607 :param request: An APIRequest instance.
609 :returns: tuple of (headers, status, message)
610 """
612 # Content-Language is in the system locale (ignore language settings)
613 headers = request.get_response_headers(SYSTEM_LOCALE,
614 **self.api_headers)
615 msg = 'Invalid format requested'
616 LOGGER.error(f'{msg}: {request.format}')
617 return self.get_exception(
618 HTTPStatus.BAD_REQUEST, headers,
619 request.format, 'InvalidParameterValue', msg)
621 def get_collections_url(self) -> str:
622 return f"{self.base_url}/collections"
624 def get_dataset_templates(self, dataset: str) -> dict:
625 templates = self.config['resources'][dataset].get('templates')
627 return templates or self.tpl_config['server']['templates']
630@jsonldify
631def landing_page(api: API,
632 request: APIRequest) -> Tuple[dict, int, str]:
633 """
634 Provide API landing page
636 :param request: A request object
638 :returns: tuple of headers, status code, content
639 """
641 fcm = {
642 'links': [],
643 'title': l10n.translate(
644 api.config['metadata']['identification']['title'],
645 request.locale),
646 'description':
647 l10n.translate(
648 api.config['metadata']['identification']['description'],
649 request.locale)
650 }
652 LOGGER.debug('Creating links')
653 # TODO: put title text in config or translatable files?
654 fcm['links'] = [{
655 'rel': 'about',
656 'type': 'text/html',
657 'title': l10n.translate(
658 api.config['metadata']['identification']['title'],
659 request.locale),
660 'href': api.config['metadata']['identification']['url']
661 }, {
662 'rel': request.get_linkrel(F_JSON),
663 'type': FORMAT_TYPES[F_JSON],
664 'title': l10n.translate('This document as JSON', request.locale),
665 'href': f"{api.base_url}?f={F_JSON}"
666 }, {
667 'rel': request.get_linkrel(F_JSONLD),
668 'type': FORMAT_TYPES[F_JSONLD],
669 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa
670 'href': f"{api.base_url}?f={F_JSONLD}"
671 }, {
672 'rel': request.get_linkrel(F_HTML),
673 'type': FORMAT_TYPES[F_HTML],
674 'title': l10n.translate('This document as HTML', request.locale),
675 'href': f"{api.base_url}?f={F_HTML}",
676 'hreflang': api.default_locale
677 }, {
678 'rel': 'service-desc',
679 'type': 'application/vnd.oai.openapi+json;version=3.0',
680 'title': l10n.translate('The OpenAPI definition as JSON', request.locale), # noqa
681 'href': f"{api.base_url}/openapi"
682 }, {
683 'rel': 'service-doc',
684 'type': FORMAT_TYPES[F_HTML],
685 'title': l10n.translate('The OpenAPI definition as HTML', request.locale), # noqa
686 'href': f"{api.base_url}/openapi?f={F_HTML}",
687 'hreflang': api.default_locale
688 }, {
689 'rel': 'conformance',
690 'type': FORMAT_TYPES[F_JSON],
691 'title': l10n.translate('Conformance', request.locale),
692 'href': f"{api.base_url}/conformance"
693 }, {
694 'rel': 'data',
695 'type': FORMAT_TYPES[F_JSON],
696 'title': l10n.translate('Collections', request.locale),
697 'href': api.get_collections_url()
698 }, {
699 'rel': f'{OGC_RELTYPES_BASE}/processes',
700 'type': FORMAT_TYPES[F_JSON],
701 'title': l10n.translate('Processes', request.locale),
702 'href': f"{api.base_url}/processes"
703 }, {
704 'rel': f'{OGC_RELTYPES_BASE}/job-list',
705 'type': FORMAT_TYPES[F_JSON],
706 'title': l10n.translate('Jobs', request.locale),
707 'href': f"{api.base_url}/jobs"
708 }, {
709 'rel': f'{OGC_RELTYPES_BASE}/tiling-schemes',
710 'type': FORMAT_TYPES[F_JSON],
711 'title': l10n.translate('The list of supported tiling schemes as JSON', request.locale), # noqa
712 'href': f"{api.base_url}/TileMatrixSets?f=json"
713 }, {
714 'rel': f'{OGC_RELTYPES_BASE}/tiling-schemes',
715 'type': FORMAT_TYPES[F_HTML],
716 'title': l10n.translate('The list of supported tiling schemes as HTML', request.locale), # noqa
717 'href': f"{api.base_url}/TileMatrixSets?f=html"
718 }]
720 if api.pubsub_client is not None and not api.pubsub_client.hidden: 720 ↛ 721line 720 didn't jump to line 721 because the condition on line 720 was never true
721 LOGGER.debug('Adding PubSub broker link')
722 pubsub_link = {
723 'rel': 'hub',
724 'type': 'application/json',
725 'title': l10n.translate('Pub/Sub broker', request.locale),
726 'href': api.pubsub_client.broker_safe_url
727 }
728 if api.pubsub_client.channel is not None:
729 pubsub_link['channel'] = api.pubsub_client.channel
731 fcm['links'].append(pubsub_link)
733 if api.asyncapi: 733 ↛ 734line 733 didn't jump to line 734 because the condition on line 733 was never true
734 fcm['links'].append({
735 'rel': 'service-doc',
736 'type': 'text/html',
737 'title': l10n.translate('The AsyncAPI definition as HTML', request.locale), # noqa
738 'href': f'{api.base_url}/asyncapi?f=html'
739 })
740 fcm['links'].append({
741 'rel': 'service-desc',
742 'type': 'application/asyncapi+json',
743 'title': l10n.translate('The AsyncAPI definition as JSON', request.locale), # noqa
744 'href': f'{api.base_url}/asyncapi?f=json'
745 })
747 headers = request.get_response_headers(**api.api_headers)
748 if request.format == F_HTML: # render
750 for resource_type in ['collection', 'process', 'stac-collection']:
751 fcm[resource_type] = False
753 found = filter_dict_by_key_value(api.config['resources'],
754 'type', resource_type)
755 if found:
756 fcm[resource_type] = True
757 if resource_type == 'collection': # check for tiles
758 for key, value in found.items():
759 if filter_providers_by_type(value['providers'], 759 ↛ 761line 759 didn't jump to line 761 because the condition on line 759 was never true
760 'tile'):
761 fcm['tile'] = True
763 if api.pubsub_client is not None and not api.pubsub_client.hidden: 763 ↛ 764line 763 didn't jump to line 764 because the condition on line 763 was never true
764 fcm['pubsub'] = {
765 'name': api.pubsub_client.name,
766 'url': api.pubsub_client.broker_safe_url,
767 'channel': api.pubsub_client.channel,
768 'asyncapi': api.asyncapi
769 }
771 content = render_j2_template(
772 api.tpl_config, api.config['server']['templates'],
773 'landing_page.html', fcm, request.locale)
775 return headers, HTTPStatus.OK, content
777 if request.format == F_JSONLD:
778 return headers, HTTPStatus.OK, to_json(
779 api.fcmld, api.pretty_print)
781 return headers, HTTPStatus.OK, to_json(fcm, api.pretty_print)
784def openapi_(api: API, request: APIRequest) -> Tuple[dict, int, str]:
785 """
786 Provide OpenAPI document
788 :param request: A request object
789 :param openapi: dict of OpenAPI definition
791 :returns: tuple of headers, status code, content
792 """
793 headers = request.get_response_headers(**api.api_headers)
795 if request.format == F_HTML: 795 ↛ 796line 795 didn't jump to line 796 because the condition on line 795 was never true
796 template = 'openapi/swagger.html'
797 if request._args.get('ui') == 'redoc':
798 template = 'openapi/redoc.html'
800 path = f'{api.base_url}/openapi'
801 data = {
802 'openapi-document-path': path
803 }
804 content = render_j2_template(
805 api.tpl_config, api.config['server']['templates'], template, data,
806 request.locale)
808 return headers, HTTPStatus.OK, content
810 headers['Content-Type'] = 'application/vnd.oai.openapi+json;version=3.0' # noqa
812 if isinstance(api.openapi, dict): 812 ↛ 816line 812 didn't jump to line 816 because the condition on line 812 was always true
813 return headers, HTTPStatus.OK, to_json(api.openapi,
814 api.pretty_print)
815 else:
816 return headers, HTTPStatus.OK, api.openapi
819def asyncapi_(api: API, request: APIRequest) -> Tuple[dict, int, str]:
820 """
821 Provide AsyncAPI document
823 :param request: A request object
825 :returns: tuple of headers, status code, content
826 """
828 headers = request.get_response_headers(**api.api_headers)
830 if not api.asyncapi:
831 msg = 'AsyncAPI not supported/configured'
832 return api.get_exception(
833 HTTPStatus.NOT_IMPLEMENTED, headers, request.format,
834 'NoApplicableCode', msg)
836 if request.format == F_HTML:
837 template = 'asyncapi.html'
839 path = f'{api.base_url}/asyncapi'
840 data = {
841 'asyncapi-document-path': path
842 }
843 content = render_j2_template(
844 api.tpl_config, api.config['server']['templates'], template, data,
845 request.locale)
847 return headers, HTTPStatus.OK, content
849 headers['Content-Type'] = 'application/asyncapi+json'
851 return headers, HTTPStatus.OK, to_json(api.asyncapi, api.pretty_print)
854def conformance(api: API, request: APIRequest) -> Tuple[dict, int, str]:
855 """
856 Provide conformance definition
858 :param request: A request object
860 :returns: tuple of headers, status code, content
861 """
863 apis_dict = all_apis()
865 conformance_list = list(CONFORMANCE_CLASSES)
867 for key, value in api.config['resources'].items():
868 if value['type'] == 'process':
869 conformance_list.extend(
870 apis_dict['process'].CONFORMANCE_CLASSES)
871 else:
872 for provider in value['providers']:
873 if provider['type'] in apis_dict: 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true
874 conformance_list.extend(
875 apis_dict[provider['type']].CONFORMANCE_CLASSES)
876 if provider['type'] == 'feature': 876 ↛ 879line 876 didn't jump to line 879 because the condition on line 876 was always true
877 conformance_list.extend(
878 apis_dict['itemtypes'].CONFORMANCE_CLASSES_FEATURES) # noqa
879 if provider['type'] == 'record': 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true
880 conformance_list.extend(
881 apis_dict['itemtypes'].CONFORMANCE_CLASSES_RECORDS)
883 if api.pubsub_client is not None: 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true
884 conformance_list.extend(apis_dict['pubsub'].CONFORMANCE_CLASSES)
886 conformance = {
887 'conformsTo': sorted(list(set(conformance_list)))
888 }
890 headers = request.get_response_headers(**api.api_headers)
891 if request.format == F_HTML: # render
892 content = render_j2_template(
893 api.tpl_config, api.config['server']['templates'],
894 'conformance.html', conformance, request.locale)
896 return headers, HTTPStatus.OK, content
898 return headers, HTTPStatus.OK, to_json(conformance, api.pretty_print)
901@jsonldify
902def describe_collections(api: API, request: APIRequest,
903 dataset: str | None = None) -> Tuple[dict, int, str]:
904 """
905 Provide collection metadata
907 :param request: A request object
908 :param dataset: name of collection
910 :returns: tuple of headers, status code, content
911 """
913 headers = request.get_response_headers(**api.api_headers)
915 fcm = {
916 'collections': [],
917 'links': []
918 }
920 collections = filter_dict_by_key_value(api.config['resources'],
921 'type', 'collection')
923 if all([dataset is not None, dataset not in collections.keys()]): 923 ↛ 924line 923 didn't jump to line 924 because the condition on line 923 was never true
924 msg = 'Collection not found'
925 return api.get_exception(
926 HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
928 if dataset is not None:
929 collections_dict = {dataset: api.config['resources'][dataset]}
930 else:
931 collections_dict = collections
933 LOGGER.debug('Creating collections')
934 for k, v in collections_dict.items():
935 if v.get('visibility', 'default') == 'hidden': 935 ↛ 936line 935 didn't jump to line 936 because the condition on line 935 was never true
936 LOGGER.debug(f'Skipping hidden layer: {k}')
937 continue
939 try:
940 fcm['collections'].append(
941 gen_collection(api, request, k, request.locale))
942 except Exception as err:
943 LOGGER.warning(f'Error generating collection {k}: {err}')
944 if dataset is None:
945 LOGGER.debug('Skipping failed dataset')
946 else:
947 return api.get_exception(
948 HTTPStatus.INTERNAL_SERVER_ERROR, headers, request.format,
949 'NoApplicableCode', 'Error generating collection')
951 if dataset is not None:
952 fcm = fcm['collections'][0]
954 if dataset is None:
955 # TODO: translate
956 fcm['links'].append({
957 'type': FORMAT_TYPES[F_JSON],
958 'rel': request.get_linkrel(F_JSON),
959 'title': l10n.translate('This document as JSON', request.locale), # noqa
960 'href': f'{api.get_collections_url()}?f={F_JSON}'
961 })
962 fcm['links'].append({
963 'type': FORMAT_TYPES[F_JSONLD],
964 'rel': request.get_linkrel(F_JSONLD),
965 'title': l10n.translate('This document as RDF (JSON-LD)', request.locale), # noqa
966 'href': f'{api.get_collections_url()}?f={F_JSONLD}'
967 })
968 fcm['links'].append({
969 'type': FORMAT_TYPES[F_HTML],
970 'rel': request.get_linkrel(F_HTML),
971 'title': l10n.translate('This document as HTML', request.locale), # noqa
972 'href': f'{api.get_collections_url()}?f={F_HTML}'
973 })
975 if request.format == F_HTML: # render
976 fcm['base_url'] = api.base_url
977 fcm['collections_path'] = api.get_collections_url()
978 if dataset is not None:
979 tpl_config = api.get_dataset_templates(dataset)
980 content = render_j2_template(api.tpl_config, tpl_config,
981 'collections/collection.html',
982 fcm, request.locale)
983 else:
984 content = render_j2_template(
985 api.tpl_config, api.config['server']['templates'],
986 'collections/index.html', fcm, request.locale)
988 return headers, HTTPStatus.OK, content
990 if request.format == F_JSONLD:
991 jsonld = api.fcmld.copy()
992 if dataset is not None:
993 jsonld['dataset'] = jsonldify_collection(api, fcm,
994 request.locale)
995 else:
996 jsonld['dataset'] = [
997 jsonldify_collection(api, c, request.locale)
998 for c in fcm.get('collections', [])
999 ]
1000 return headers, HTTPStatus.OK, to_json(jsonld, api.pretty_print)
1002 return headers, HTTPStatus.OK, to_json(fcm, api.pretty_print)
1005def get_collection_schema(api: API, request: Union[APIRequest, Any],
1006 dataset: str) -> Tuple[dict, int, str]:
1007 """
1008 Returns a collection schema
1010 :param request: A request object
1011 :param dataset: dataset name
1013 :returns: tuple of headers, status code, content
1014 """
1016 headers = request.get_response_headers(**api.api_headers)
1018 if any([dataset is None, 1018 ↛ 1021line 1018 didn't jump to line 1021 because the condition on line 1018 was never true
1019 dataset not in api.config['resources'].keys()]):
1021 msg = 'Collection not found'
1022 return api.get_exception(
1023 HTTPStatus.NOT_FOUND, headers, request.format, 'NotFound', msg)
1025 LOGGER.debug('Creating collection schema')
1026 try:
1027 LOGGER.debug('Loading feature provider')
1028 p = load_plugin('provider', get_provider_by_type(
1029 api.config['resources'][dataset]['providers'], 'feature'))
1030 except ProviderTypeError:
1031 try:
1032 LOGGER.debug('Loading coverage provider')
1033 p = load_plugin('provider', get_provider_by_type(
1034 api.config['resources'][dataset]['providers'], 'coverage')) # noqa
1035 except ProviderTypeError:
1036 try:
1037 LOGGER.debug('Loading record provider')
1038 p = load_plugin('provider', get_provider_by_type(
1039 api.config['resources'][dataset]['providers'], 'record'))
1040 except ProviderTypeError:
1041 LOGGER.debug('Loading edr provider')
1042 p = load_plugin('provider', get_provider_by_type(
1043 api.config['resources'][dataset]['providers'], 'edr'))
1044 except ProviderGenericError as err:
1045 LOGGER.error(err)
1046 return api.get_exception(
1047 err.http_status_code, headers, request.format,
1048 err.ogc_exception_code, err.message)
1050 schema = {
1051 'type': 'object',
1052 'title': l10n.translate(
1053 api.config['resources'][dataset]['title'], request.locale),
1054 'properties': {},
1055 '$schema': 'http://json-schema.org/draft/2019-09/schema',
1056 '$id': f'{api.get_collections_url()}/{dataset}/schema'
1057 }
1059 if p.type != 'coverage': 1059 ↛ 1065line 1059 didn't jump to line 1065 because the condition on line 1059 was always true
1060 schema['properties']['geometry'] = {
1061 'format': 'geometry-any',
1062 'x-ogc-role': 'primary-geometry'
1063 }
1065 for k, v in p.fields.items():
1066 schema['properties'][k] = v
1067 if v['type'] == 'float': 1067 ↛ 1068line 1067 didn't jump to line 1068 because the condition on line 1067 was never true
1068 schema['properties'][k]['type'] = 'number'
1069 if v.get('format') is None: 1069 ↛ 1072line 1069 didn't jump to line 1072 because the condition on line 1069 was always true
1070 schema['properties'][k].pop('format', None)
1072 if k == p.id_field:
1073 schema['properties'][k]['x-ogc-role'] = 'id'
1074 if k == p.time_field:
1075 schema['properties'][k]['x-ogc-role'] = 'primary-instant'
1077 if request.format == F_HTML: # render
1078 tpl_config = api.get_dataset_templates(dataset)
1079 schema['title'] = l10n.translate(
1080 api.config['resources'][dataset]['title'], request.locale)
1082 schema['collections_path'] = api.get_collections_url()
1083 schema['dataset_path'] = f'{api.get_collections_url()}/{dataset}'
1085 content = render_j2_template(api.tpl_config, tpl_config,
1086 'collections/schema.html',
1087 schema, request.locale)
1089 return headers, HTTPStatus.OK, content
1091 headers['Content-Type'] = 'application/schema+json'
1093 return headers, HTTPStatus.OK, to_json(schema, api.pretty_print)
1096def validate_bbox(value: list | None = None) -> list:
1097 """
1098 Helper function to validate bbox parameter
1100 :param value: `list` of minx, miny, maxx, maxy
1102 :returns: bbox as `list` of `float` values
1103 """
1105 if value is None: 1105 ↛ 1106line 1105 didn't jump to line 1106 because the condition on line 1105 was never true
1106 LOGGER.debug('bbox is empty')
1107 return []
1109 bbox = value.split(',')
1111 if len(bbox) not in [4, 6]:
1112 msg = 'bbox should be either 4 values (minx,miny,maxx,maxy) ' \
1113 'or 6 values (minx,miny,minz,maxx,maxy,maxz)'
1114 LOGGER.debug(msg)
1115 raise ValueError(msg)
1117 try:
1118 bbox = [float(c) for c in bbox]
1119 except ValueError as err:
1120 msg = 'bbox values must be numbers'
1121 err.args = (msg,)
1122 LOGGER.debug(msg)
1123 raise
1125 if (len(bbox) == 4 and bbox[1] > bbox[3]) \
1126 or (len(bbox) == 6 and bbox[1] > bbox[4]):
1127 msg = 'miny should be less than maxy'
1128 LOGGER.debug(msg)
1129 raise ValueError(msg)
1131 if (len(bbox) == 4 and bbox[0] > bbox[2]) \
1132 or (len(bbox) == 6 and bbox[0] > bbox[3]):
1133 msg = 'minx is greater than maxx (possibly antimeridian bbox)'
1134 LOGGER.debug(msg)
1136 if len(bbox) == 6 and bbox[2] > bbox[5]:
1137 msg = 'minz should be less than maxz'
1138 LOGGER.debug(msg)
1139 raise ValueError(msg)
1141 return bbox
1144def validate_datetime(resource_def: dict,
1145 datetime_: str | None = None) -> str:
1146 """
1147 Helper function to validate temporal parameter
1149 :param resource_def: `dict` of configuration resource definition
1150 :param datetime_: `str` of datetime parameter
1152 :returns: `str` of datetime input, if valid
1153 """
1155 # TODO: pass datetime to query as a `datetime` object
1156 # we would need to ensure partial dates work accordingly
1157 # as well as setting '..' values to `None` so that underlying
1158 # providers can just assume a `datetime.datetime` object
1159 #
1160 # NOTE: needs testing when passing partials from API to backend
1162 datetime_invalid = False
1164 if datetime_ is not None and 'temporal' in resource_def:
1166 dateparse_begin = partial(dateparse, default=datetime.min)
1167 dateparse_end = partial(dateparse, default=datetime.max)
1168 unix_epoch = datetime(1970, 1, 1, 0, 0, 0)
1169 dateparse_ = partial(dateparse, default=unix_epoch)
1171 te = resource_def['temporal']
1173 try:
1174 if te['begin'] is not None and te['begin'].tzinfo is None: 1174 ↛ 1175line 1174 didn't jump to line 1175 because the condition on line 1174 was never true
1175 te['begin'] = te['begin'].replace(tzinfo=pytz.UTC)
1176 if te['end'] is not None and te['end'].tzinfo is None: 1176 ↛ 1177line 1176 didn't jump to line 1177 because the condition on line 1176 was never true
1177 te['end'] = te['end'].replace(tzinfo=pytz.UTC)
1178 except AttributeError:
1179 msg = 'Configured times should be RFC3339'
1180 LOGGER.error(msg)
1181 raise ValueError(msg)
1183 if '/' in datetime_: # envelope
1184 LOGGER.debug('detected time range')
1185 LOGGER.debug('Validating time windows')
1187 # normalize "" to ".." (actually changes datetime_)
1188 datetime_ = re.sub(r'^/', '../', datetime_)
1189 datetime_ = re.sub(r'/$', '/..', datetime_)
1191 datetime_begin, datetime_end = datetime_.split('/')
1192 if datetime_begin != '..': 1192 ↛ 1198line 1192 didn't jump to line 1198 because the condition on line 1192 was always true
1193 datetime_begin = dateparse_begin(datetime_begin)
1194 if datetime_begin.tzinfo is None:
1195 datetime_begin = datetime_begin.replace(
1196 tzinfo=pytz.UTC)
1198 if datetime_end != '..':
1199 datetime_end = dateparse_end(datetime_end)
1200 if datetime_end.tzinfo is None:
1201 datetime_end = datetime_end.replace(tzinfo=pytz.UTC)
1203 datetime_invalid = any([
1204 (te['end'] is not None and datetime_begin != '..' and
1205 datetime_begin > te['end']),
1206 (te['begin'] is not None and datetime_end != '..' and
1207 datetime_end < te['begin'])
1208 ])
1210 else: # time instant
1211 LOGGER.debug('detected time instant')
1212 datetime__ = dateparse_(datetime_)
1213 if datetime__ != '..':
1214 if datetime__.tzinfo is None:
1215 datetime__ = datetime__.replace(tzinfo=pytz.UTC)
1216 datetime_invalid = any([
1217 (te['begin'] is not None and datetime__ != '..' and
1218 datetime__ < te['begin']),
1219 (te['end'] is not None and datetime__ != '..' and
1220 datetime__ > te['end'])
1221 ])
1223 if datetime_invalid: 1223 ↛ 1224line 1223 didn't jump to line 1224 because the condition on line 1223 was never true
1224 msg = 'datetime parameter out of range'
1225 LOGGER.debug(msg)
1226 raise ValueError(msg)
1228 return datetime_
1231def validate_subset(value: str) -> dict:
1232 """
1233 Helper function to validate subset parameter
1235 :param value: `subset` parameter
1237 :returns: dict of axis/values
1238 """
1240 subsets = {}
1242 for s in value.split(','):
1243 LOGGER.debug(f'Processing subset {s}')
1244 m = re.search(r'(.*)\((.*)\)', s)
1245 subset_name, values = m.group(1, 2)
1247 if '"' in values:
1248 LOGGER.debug('Values are strings')
1249 if values.count('"') % 2 != 0:
1250 msg = 'Invalid format: subset should be like axis("min"[:"max"])' # noqa
1251 LOGGER.error(msg)
1252 raise ValueError(msg)
1253 try:
1254 LOGGER.debug('Value is an interval')
1255 m = re.search(r'"(\S+)":"(\S+)"', values)
1256 values = list(m.group(1, 2))
1257 except AttributeError:
1258 LOGGER.debug('Value is point')
1259 m = re.search(r'"(.*)"', values)
1260 values = [m.group(1)]
1261 else:
1262 LOGGER.debug('Values are numbers')
1263 try:
1264 LOGGER.debug('Value is an interval')
1265 m = re.search(r'(\S+):(\S+)', values)
1266 values = list(m.group(1, 2))
1267 except AttributeError:
1268 LOGGER.debug('Value is point')
1269 values = [values]
1271 subsets[subset_name] = list(map(get_typed_value, values))
1273 return subsets
1276def evaluate_limit(requested: Union[None, int], server_limits: dict,
1277 collection_limits: dict) -> int:
1278 """
1279 Helper function to evaluate limit parameter
1281 :param requested: the limit requested by the client
1282 :param server_limits: `dict` of server limits
1283 :param collection_limits: `dict` of collection limits
1285 :returns: `int` of evaluated limit
1286 """
1288 effective_limits = ChainMap(collection_limits, server_limits)
1290 default = effective_limits.get('default_items', 10)
1291 max_ = effective_limits.get('max_items', 10)
1292 on_exceed = effective_limits.get('on_exceed', 'throttle')
1294 LOGGER.debug(f'Requested limit: {requested}')
1295 LOGGER.debug(f'Default limit: {default}')
1296 LOGGER.debug(f'Maximum limit: {max_}')
1297 LOGGER.debug(f'On exceed: {on_exceed}')
1299 if requested is None:
1300 LOGGER.debug('no limit requested; returning default')
1301 return default
1303 if isinstance(requested, int): 1303 ↛ 1304line 1303 didn't jump to line 1304 because the condition on line 1303 was never true
1304 requested2 = requested
1305 else:
1306 requested2 = get_typed_value(requested)
1308 if not isinstance(requested2, int):
1309 raise ValueError('limit value should be an integer')
1311 if requested2 <= 0:
1312 raise ValueError('limit value should be strictly positive')
1313 elif requested2 > max_ and on_exceed == 'error': 1313 ↛ 1314line 1313 didn't jump to line 1314 because the condition on line 1313 was never true
1314 raise ValueError(f'Limit of {max_} exceeded')
1315 else:
1316 LOGGER.debug('limit requested')
1317 return min(requested2, max_)