diff --git a/docs/source/index.rst b/docs/source/index.rst index 09dfce1..a784862 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -29,6 +29,7 @@ pygeoapi |release| documentation data-publishing/index plugins html-templating + language development ogc-compliance contributing diff --git a/docs/source/language.rst b/docs/source/language.rst new file mode 100644 index 0000000..60df06b --- /dev/null +++ b/docs/source/language.rst @@ -0,0 +1,233 @@ +.. _language: + +Multilingual support +==================== + +pygeoapi is language-aware and can handle multiple languages if these have been defined in pygeoapi's configuration (see `maintainer guide`_). +Providers can also handle multiple languages if configured. These may even be different from the languages that pygeoapi +supports. Out-of-the-box, pygeoapi "speaks" English. System messages and exceptions are always English only. + +The following sections provide more information how to use and set up languages in pygeoapi. + +End user guide +-------------- + +There are 2 ways to affect the language of the results returned by pygeoapi, both for the HTML and JSON(-LD) formats: + +1. After the requested pygeoapi URL, append a ``lang=`` query parameter, where ```` should be replaced by a well-known language code. + This can be an ISO 639-1 code (e.g. `de` for German), optionally accompanied by an ISO 3166-1 alpha-2 country code (e.g. `de-CH` for Swiss-German). + Please refer to this `W3C article `_ for more information or + this `list of language codes `_ for more examples. + Another option is to send a complex definition with quality weights (e.g. `de-CH, de;q=0.9, en;q=0.8, fr;q=0.7, \*;q=0.5`). + pygeoapi will then figure out the best match for the requested language. + + For example, to view the pygeoapi landing page in Canadian-French, you could use this URL: + + https://demo.pygeoapi.io/master?lang=fr-CA + +2. Alternatively, you can set an ``Accept-Language`` HTTP header for the requested pygeoapi URL. Language tags that are valid for + the ``lang`` query parameter are also valid for this header value. + Please note that if your client application (e.g. browser) is configured for a certain language, it will likely set this + header by default, so the returned response should be translated to the language of your client app. If you don't want this, + you can either change the language of your client app or append the ``lang`` parameter to the URL, which will override + any language defined in the ``Accept-Language`` header. + + +Notes +^^^^^ + +- If pygeoapi cannot find a good match to the requested language, the response is returned in the default language (US English mostly). + The default language is the *first* language defined in pygeoapi's server configuration YAML (see `maintainer guide`_). + +- Even if pygeoapi *itself* supports the requested language, provider plugins may not support that particular language or perhaps don't even + support any language at all. In that case the provider will reply in its own "unknown" language, which may not be the same language + as the default pygeoapi server language set in the ``Content-Language`` HTTP response header. + +- It is up to the creator of the provider to properly define at least 1 supported language in the provider configuration, as described + in the `developer guide`_. This will ensure that the ``Content-Language`` HTTP response header is always set properly. + +- If pygeoapi found a match to the requested language, the response will include a ``Content-Language`` HTTP header, + set to the best-matching server language code. This is the default behavior for most pygeoapi requests. However, note that some responses + (e.g. exceptions) always have a ``Content-Language: en-US`` header, regardless of the requested language. + +- For results returned by a **provider**, the ``Content-Language`` HTTP header will be set to the best-matching + provider language or the best-matching pygeoapi server language if the provider is not language-aware. + +- If the provider supports a requested language, but pygeoapi does *not* support that same language, the ``Content-Language`` + header will contain both the provider language *and* the best-matching pygeoapi server language. + +- Please note that the ``Content-Language`` HTTP response header only *indicates the language of the intended audience*. + It does not necessarily mean that the content is actually written in that particular language. + + +Maintainer guide +---------------- + +Every pygeoapi instance should support at least 1 language. In the server configuration, there must be a ``language`` +or a ``languages`` (note the `s`) property. The property can be set to a single language tag or a list of tags respectively. + +If you wish to set up a multilingual pygeoapi instance, you will have to add more than 1 language to the +server configuration YAML file (i.e. ``pygeoapi-config.yml``). First, you will have to add the supported language tags/codes +as a list. For example, if you wish to support American English and Canadian French, you could do: + +.. code-block:: yaml + + server: + bind: ... + url: ... + mimetype: ... + encoding: ... + languages: + - en-US + - fr-CA + +Next, you will have to provide translations for the configured languages. This involves 3 steps: + +1. `Add translations for configurable text values`_ in the server YAML file; + +2. Verify if there are any Jinja2 HTML template translations for the configured language(s); + +3. Make sure that the provider plugins you need can handle this language as well, if you have the ability to do so. + See the `developer guide`_ for more details. + + +Notes +^^^^^ + +- The **first** language you define in the configuration determines the default language, i.e. the language that pygeoapi will + use if no other language was requested or no best match for the requested language could be found. + +- It is not possible to **disable** language support in pygeoapi. The functionality is always on and a ``Content-Language`` + HTTP response header is always set. If results should be available in a single language, you'd have to set that language only + in the pygeoapi configuration. + +- Results returned from a provider may be in a different language than pygeoapi's own server language. The "raw" requested language + is always passed on to the provider, even if pygeoapi itself does not support it. For more information, see the `end user guide`_ + and the `developer guide`_. + + +Add translations for configurable text values +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +For most of the text values in pygeoapi's server configuration where it makes sense, you can add translations. +Consider the ``metadata`` section for example. The English-only version looks similar to this: + +.. code-block:: yaml + + metadata: + identification: + title: pygeoapi default instance + description: pygeoapi provides an API to geospatial data + keywords: + - geospatial + - data + - api + +If you wish to make these text values available in English and French, you could use the following language struct: + +.. code-block:: yaml + + metadata: + identification: + title: + en: pygeoapi default instance + fr: instance par défaut de pygeoapi + description: + en: pygeoapi provides an API to geospatial data + fr: pygeoapi fournit une API aux données géospatiales + keywords: + en: + - geospatial + - data + - api + fr: + - géospatiale + - données + - api + +In other words: each plain text value should be replaced by a dictionary, where the language code is the key and the translated text represents the matching value. +For lists, this can be applied as well (see ``keywords`` example above), as long as you nest the entire list under a language key instead of each list item. + +Note that the example above uses generic language tags, but you can also supply more localized tags (with a country code) if required. +pygeoapi should always be able find the best match to the requested language, i.e. if the user wants Swiss-French (`fr-CH`) but pygeoapi can only find `fr` tags, +those values will be returned. However, if a `fr-CH` tag can also be found, that value will be returned and not the `fr` value. + +.. todo:: Add docs on HTML templating. + + +Developer guide +--------------- + +If you are a developer who wishes to create a pygeoapi provider plugin that "speaks" a certain language, +you will have to fully implement this yourself. Needless to say, if your provider depends on some backend, it will only make sense to +implement language support if the backend can be queried in another language as well. + +You are free to set up the language support anyway you like, but there are a couple of steps you'll have to walk through: + +1. You will have to define the supported languages in the provider configuration YAML. This can be done in a similar fashion + as the ``languages`` configuration for pygeoapi itself, as described in the `maintainer guide`_ section above. + For example, a TinyDB records provider that supports English and French could be set up like: + + .. code-block:: yaml + + my-records: + type: collection + .. + providers: + - type: record + name: TinyDBCatalogue + data: .. + languages: + - en + - fr + +2. If your provider implements any of the ``query``, ``get`` or ``get_metadata`` methods of the base class and you wish + to make them language-aware, either add an implicit ``**kwargs`` parameter or an explicit ``language=None`` parameter + to the method signature. + +An example Python code block for a custom provider with a language-aware ``query`` method could look like this: + +.. code-block:: python + + class MyCoolVectorDataProvider(BaseProvider): + """My cool vector data provider""" + + def __init__(self, provider_def): + super().__init__(provider_def) + + def query(self, startindex=0, limit=10, resulttype='results', bbox=[], + datetime_=None, properties=[], sortby=[], select_properties=[], + skip_geometry=False, q=None, language=None): + LOGGER.debug(f'Provider queried in {language.english_name} language') + # Implement your logic here, returning JSON in the requested language + +Alternatively, you could also use ``**kwargs`` in the ``query`` method and get the ``language`` value: + +.. code-block:: python + + def query(self, **kwargs): + LOGGER.debug(f"Provider locale set to: {kwargs.get('language')}") + # Implement your logic here, returning JSON in the requested language + +This is all that is required. The pygeoapi API class will make sure that the correct HTTP ``Content-Language`` headers are set on the response object. + +Notes +^^^^^ + +- If your provider implements any of the aforementioned ``query``, ``get`` and ``get_metadata`` methods, + it **must** add a ``**kwargs`` or ``language=None`` parameter, even if it does not need to use the language parameter. + +- Contrary to the pygeoapi server configuration, adding a ``language`` or ``languages`` (both are supported) property to the + provider definition is **not** required and may be omitted. In that case, the passed-in ``language`` parameter language-aware provider methods + (``query``, ``get``, etc.) will be set to ``None``. This results in the following behavior: + + - HTML responses returned from the providers will have the ``Content-Language`` header set to the best-matching pygeoapi server language. + - JSON(-LD) responses returned from providers will **not** have a ``Content-Language`` header if ``language`` is ``None``. + +- If the provider supports a requested language, the passed-in ``language`` will be set to the best matching + `Babel Locale instance `_. + Note that this may be the provider default language if no proper match was found. + No matter the output format, API responses returned from providers will always contain a best-matching ``Content-Language`` + header if one ore more supported provider languages were defined. + +- For general information about building plugins, please visit the :ref:`plugins` page. diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst index 4307f4d..35ffa2a 100644 --- a/docs/source/plugins.rst +++ b/docs/source/plugins.rst @@ -70,7 +70,7 @@ The below template provides a minimal example (let's call the file ``mycoolvecto def query(self,startindex=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False): + select_properties=[], skip_geometry=False, **kwargs): # open data file (self.data) and process, return return { @@ -98,6 +98,9 @@ its base provider, all other functionality is left to the provider implementatio Each base class documents the functions, arguments and return types required for implementation. +.. note:: You can add language support to your plugin using :ref:`these guides`. + + Connecting to pygeoapi ^^^^^^^^^^^^^^^^^^^^^^ @@ -166,7 +169,7 @@ The below template provides a minimal example (let's call the file ``mycoolraste def get_coverage_rangetype(self): # return a CIS JSON RangeType - def query(self, bands=[], subsets={}, format_='json'): + def query(self, bands=[], subsets={}, format_='json', **kwargs): # process bands and subsets parameters # query/extract coverage data if format_ == 'json': diff --git a/pygeoapi-config.yml b/pygeoapi-config.yml index e008498..792671e 100644 --- a/pygeoapi-config.yml +++ b/pygeoapi-config.yml @@ -34,7 +34,10 @@ server: url: http://localhost:5000 mimetype: application/json; charset=UTF-8 encoding: utf-8 - language: en-US + languages: + # First language is the default language + - en-US + - fr-CA # cors: true pretty_print: true limit: 10 @@ -56,12 +59,21 @@ logging: metadata: identification: - title: pygeoapi default instance - description: pygeoapi provides an API to geospatial data + title: + en: pygeoapi default instance + fr: instance par défaut de pygeoapi + description: + en: pygeoapi provides an API to geospatial data + fr: pygeoapi fournit une API aux données géospatiales keywords: - - geospatial - - data - - api + en: + - geospatial + - data + - api + fr: + - géospatiale + - données + - api keywords_type: theme terms_of_service: https://creativecommons.org/licenses/by/4.0/ url: http://example.org @@ -129,10 +141,19 @@ resources: lakes: type: collection - title: Large Lakes - description: lakes of the world, public domain + title: + en: Large Lakes + fr: Grands Lacs + description: + en: lakes of the world, public domain + fr: lacs du monde, domaine public keywords: - - lakes + en: + - lakes + - water bodies + fr: + - lacs + - plans d'eau links: - type: text/html rel: canonical @@ -211,17 +232,30 @@ resources: canada-metadata: type: collection - title: Sample metadata records from open.canada.ca - description: Sample metadata records from open.canada.ca + title: + en: Open Canada sample data + fr: Exemple de donn\u00e9es Canada Ouvert + description: + en: Sample metadata records from open.canada.ca + fr: Exemples d'enregistrements de m\u00e9tadonn\u00e9es sur ouvert.canada.ca keywords: - - canada - - open data + en: + - canada + - open data + fr: + - canada + - donn\u00e9es ouvertes links: - type: text/html rel: canonical title: information href: https://open.canada.ca/en/open-data hreflang: en-CA + - type: text/html + rel: canonical + title: informations + href: https://ouvert.canada.ca/fr/donnees-ouvertes + hreflang: fr-CA extents: spatial: bbox: [-180,-90,180,90] diff --git a/pygeoapi/api.py b/pygeoapi/api.py index 277b120..319252e 100644 --- a/pygeoapi/api.py +++ b/pygeoapi/api.py @@ -2,6 +2,7 @@ # # Authors: Tom Kralidis # Francesco Bartoli +# Sander Schaminee # # Copyright (c) 2021 Tom Kralidis # Copyright (c) 2020 Francesco Bartoli @@ -28,8 +29,8 @@ # OTHER DEALINGS IN THE SOFTWARE. # # ================================================================= -""" Root level code of pygeoapi, parsing content provided by webframework. -Returns content from plugins and sets reponses +""" Root level code of pygeoapi, parsing content provided by web framework. +Returns content from plugins and sets responses. """ from datetime import datetime, timezone @@ -39,8 +40,11 @@ import logging import os import uuid import re +import asyncio import urllib.parse from copy import deepcopy +from typing import Union, Any +from collections import OrderedDict from dateutil.parser import parse as dateparse from shapely.wkt import loads as shapely_loads @@ -48,6 +52,7 @@ from shapely.errors import WKTReadingError import pytz from pygeoapi import __version__ +from pygeoapi import l10n from pygeoapi.linked_data import (geojson2geojsonld, jsonldify, jsonldify_collection) from pygeoapi.log import setup_logger @@ -77,8 +82,19 @@ HEADERS = { 'X-Powered-By': 'pygeoapi {}'.format(__version__) } -#: Formats allowed for ?f= requests -FORMATS = ['json', 'html', 'jsonld'] +F_JSON = 'json' +F_HTML = 'html' +F_JSONLD = 'jsonld' + +#: Formats allowed for ?f= requests (order matters for complex MIME types) +FORMAT_TYPES = OrderedDict(( + (F_HTML, 'text/html'), + (F_JSONLD, 'application/ld+json'), + (F_JSON, 'application/json'), +)) + +#: Locale used for system responses (e.g. exceptions) +SYSTEM_LOCALE = l10n.Locale('en', 'US') CONFORMANCE = [ 'http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core', @@ -103,28 +119,346 @@ OGC_RELTYPES_BASE = 'http://www.opengis.net/def/rel/ogc/1.0' def pre_process(func): - """ - Decorator performing header copy and format - checking before sending arguments to methods + """ Decorator that transforms an incoming Request instance specific to the + web framework (i.e. Flask or Starlette) into a generic :class:`APIRequest` + instance. - :param func: decorated function + :param func: decorated function - :returns: `func` + :returns: `func` """ - def inner(*args, **kwargs): - cls = args[0] - headers_ = HEADERS.copy() - format_ = check_format(args[2], args[1]) - if len(args) > 3: - args = args[3:] - return func(cls, headers_, format_, *args, **kwargs) + def inner(*args): + cls, req_in = args[:2] + req_out = APIRequest.with_data(req_in, getattr(cls, 'locales', set())) + if len(args) > 2: + return func(cls, req_out, *args[2:]) else: - return func(cls, headers_, format_) + return func(cls, req_out) return inner +class APIRequest: + """ Transforms an incoming server-specific Request into an object + with some generic helper methods and properties. + + .. note:: Typically, this instance is created automatically by the + :func:`pre_process` decorator. **Every** API method that has + been routed to a REST endpoint should be decorated by the + :func:`pre_process` function. + Therefore, **all** routed API methods should at least have 1 + argument that holds the (transformed) request. + + The following example API method will: + + - transform the incoming Flask/Starlette `Request` into an `APIRequest` + using the :func:`pre_process` decorator; + - call :meth:`is_valid` to check if the incoming request was valid, i.e. + that the user requested a valid output format or no format at all + (which means the default format); + - call :meth:`API.get_format_exception` if the requested format was + invalid; + - create a `dict` with the appropriate `Content-Type` header for the + requested format and a `Content-Language` header if any specific language + was requested. + + .. code-block:: python + + @pre_process + def example_method(self, request: Union[APIRequest, Any], custom_arg): + if not request.is_valid(): + return self.get_format_exception(request) + + headers = request.get_response_headers() + + # generate response_body here + + return headers, 200, response_body + + + The following example API method is similar as the one above, but will also + allow the user to request a non-standard format (e.g. ``f=xml``). + If `xml` was requested, we set the `Content-Type` ourselves. For the + standard formats, the `APIRequest` object sets the `Content-Type`. + + .. code-block:: python + + @pre_process + def example_method(self, request: Union[APIRequest, Any], custom_arg): + if not request.is_valid(['xml']): + return self.get_format_exception(request) + + force_type = 'application/xml' if request.format == 'xml' else None + headers = request.get_response_headers(force_type) + + # generate response_body here + + return headers, 200, response_body + + Note that you don't *have* to call :meth:`is_valid`, but that you can also + perform a custom check on the requested output format by looking at the + :attr:`format` property. + Other query parameters are available through the :attr:`params` property as + a `dict`. The request body is available through the :attr:`data` property. + + .. note:: If the request data (body) is important, **always** create a + new `APIRequest` instance using the :meth:`with_data` factory + method. + The :func:`pre_process` decorator will use this automatically. + + :param request: The web platform specific Request instance. + :param supported_locales: List or set of supported Locale instances. + """ + def __init__(self, request, supported_locales): + # Set default request data + self._data = b'' + + # Copy request query parameters + self._args = self._get_params(request) + + # Get path info + self._path_info = request.headers.environ['PATH_INFO'].strip('/') + + # Extract locale from params or headers + self._raw_locale, self._locale = self._get_locale(request.headers, + supported_locales) + + # Determine format + self._format = self._get_format(request.headers) + + @classmethod + def with_data(cls, request, supported_locales) -> 'APIRequest': + """ Factory class method to create an `APIRequest` instance with data. + + If the request body is required, an `APIRequest` should always be + instantiated using this class method. The reason for this is, that the + Starlette request body needs to be awaited (async), which cannot be + achieved in the :meth:`__init__` method of the `APIRequest`. + However, `APIRequest` can still be initialized using :meth:`__init__`, + but then the :attr:`data` property value will always be empty. + + :param request: The web platform specific Request instance. + :param supported_locales: List or set of supported Locale instances. + :returns: An `APIRequest` instance with data. + """ + api_req = cls(request, supported_locales) + if hasattr(request, 'data'): + # Set data from Flask request + api_req._data = request.data + elif hasattr(request, 'body'): + # Set data from Starlette request after async coroutine completion + # TODO: this now blocks, but once Flask v2 with async support + # has been implemented, with_data() can become async too + loop = asyncio.get_event_loop() + api_req._data = loop.run_until_complete(request.body()) + return api_req + + @staticmethod + def _get_params(request): + """ Extracts the query parameters from the `Request` object. + + :param request: A Flask or Starlette Request instance + :returns: `ImmutableMultiDict` or empty `dict` + """ + if hasattr(request, 'args'): + # Return ImmutableMultiDict from Flask request + return request.args + elif hasattr(request, 'query_params'): + # Return ImmutableMultiDict from Starlette request + return request.query_params + LOGGER.debug('No query parameters found') + return {} + + def _get_locale(self, headers, supported_locales): + """ Detects locale from "lang=" param or `Accept-Language` + header. Returns a tuple of (raw, locale) if found in params or headers. + Returns a tuple of (raw default, default locale) if not found. + + :param headers: A dict with Request headers + :param supported_locales: List or set of supported Locale instances + :returns: A tuple of (str, Locale) + """ + raw = None + try: + default_locale = l10n.str2locale(supported_locales[0]) + except (TypeError, IndexError, l10n.LocaleError) as err: + # This should normally not happen, since the API class already + # loads the supported languages from the config, which raises + # a LocaleError if any of these languages are invalid. + LOGGER.error(err) + raise ValueError(f"{self.__class__.__name__} must be initialized" + f"with a list of valid supported locales") + + for func, mapping in ((l10n.locale_from_params, self._args), + (l10n.locale_from_headers, headers)): + loc_str = func(mapping) + if loc_str: + if not raw: + # This is the first-found locale string: set as raw + raw = loc_str + # Check if locale string is a good match for the UI + loc = l10n.best_match(loc_str, supported_locales) + is_override = func is l10n.locale_from_params + if loc != default_locale or is_override: + return raw, loc + + return raw, default_locale + + def _get_format(self, headers) -> Union[str, None]: + """ + Get `Request` format type from query parameters or headers. + + :param headers: Dict of Request headers + :returns: format value or None if not found/specified + """ + + # Optional f=html or f=json query param + # Overrides Accept header and might differ from FORMAT_TYPES + format_ = (self._args.get('f') or '').strip() + if format_: + return format_ + + # Format not specified: get from Accept headers (MIME types) + # e.g. format_ = 'text/html' + for h in (v.strip() for k, v in headers.items() if k.lower() == 'accept'): # noqa + for fmt, mime in FORMAT_TYPES.items(): + # basic support for complex types (i.e. with "q=0.x") + types_ = (t.split(';')[0].strip() for t in h.split(',') if t) + if mime.strip() in types_: + format_ = fmt + break + + return format_ or None + + @property + def data(self) -> bytes: + """ Returns the additional data send with the Request (bytes). """ + return self._data + + @property + def params(self): + """ Returns the Request query parameters dict. """ + return self._args + + @property + def path_info(self): + """ Returns the web server request path info part. """ + return self._path_info + + @property + def locale(self) -> l10n.Locale: + """ Returns the user-defined locale from the request object. + If no locale has been defined or if it is invalid, + the default server locale is returned. + + .. note:: The locale here determines the language in which pygeoapi + should return its responses. This may not be the language + that the user requested. It may also not be the language + that is supported by a collection provider, for example. + For this reason, you should pass the `raw_locale` property + to the :func:`l10n.get_plugin_locale` function, so that + the best match for the provider can be determined. + + :returns: babel.core.Locale + """ + return self._locale + + @property + def raw_locale(self) -> Union[str, None]: + """ Returns the raw locale string from the `Request` object. + If no "lang" query parameter or `Accept-Language` header was found, + `None` is returned. + Pass this value to the :func:`l10n.get_plugin_locale` function to let + the provider determine a best match for the locale, which may be + different from the locale used by pygeoapi's UI. + + :returns: a locale string or None + """ + return self._raw_locale + + @property + def format(self) -> Union[str, None]: + """ Returns the content type format from the + request query parameters or headers. + + :returns: Format name or None + """ + return self._format + + def get_linkrel(self, format_: str) -> str: + """ Returns the hyperlink relationship (rel) attribute value for + the given API format string. + + The string is compared against the request format and if it matches, + the value 'self' is returned. Otherwise, 'alternate' is returned. + However, if `format_` is 'json' and *no* request format was found, + the relationship 'self' is returned as well (JSON is the default). + + :param format_: The format to compare the request format against. + :returns: A string 'self' or 'alternate'. + """ + fmt = format_.lower() + if fmt == self._format or (fmt == F_JSON and not self._format): + return 'self' + return 'alternate' + + def is_valid(self, additional_formats=None) -> bool: + """ Returns True if: + + - the format is not set (None) + - the requested format is supported + - the requested format exists in a list if additional formats + + .. note:: Format names are matched in a case-insensitive manner. + + :param additional_formats: Optional additional supported formats list + :returns: A boolean + """ + if not self._format: + return True + if self._format in FORMAT_TYPES.keys(): + return True + if self._format in (f.lower() for f in (additional_formats or ())): + return True + return False + + def get_response_headers(self, force_lang: l10n.Locale = None, + force_type: str = None) -> dict: + """ Prepares and returns a dictionary with Response object headers. + + This method always adds a 'Content-Language' header, where the value + is determined by the 'lang' query parameter or 'Accept-Language' + header from the request. + If no language was requested, the default pygeoapi language is used, + unless a `force_lang` override was specified (see notes below). + + A 'Content-Type' header is also always added to the response. + If the user does not specify `force_type`, the header is based on + the `format` APIRequest property. If that is invalid, the default MIME + type `application/json` is used. + + ..note:: If a `force_lang` override is applied, that language + is always set as the 'Content-Language', regardless of + a 'lang' query parameter or 'Accept-Language' header. + If an API response always needs to be in the same + language, 'force_lang' should be set to that language. + + :param force_lang: An optional Content-Language header override. + :param force_type: An optional Content-Type header override. + :returns: A header dict + """ + headers = HEADERS.copy() + l10n.set_response_language(headers, force_lang or self._locale) + if force_type: + # Set custom MIME type if specified + headers['Content-Type'] = force_type + elif self.is_valid() and self._format: + # Set MIME type for valid formats + headers['Content-Type'] = FORMAT_TYPES[self._format] + return headers + + class API: """API object""" @@ -140,6 +474,10 @@ class API: self.config = config self.config['server']['url'] = self.config['server']['url'].rstrip('/') + # Process language settings (first locale is default!) + self.locales = l10n.get_locales(config) + self.default_locale = self.locales[0] + if 'templates' not in self.config['server']: self.config['server']['templates'] = TEMPLATES @@ -167,73 +505,73 @@ class API: @pre_process @jsonldify - def landing_page(self, headers_, format_): + def landing_page(self, request: Union[APIRequest, Any]): """ Provide API - :param headers_: copy of HEADERS object - :param format_: format of requests, pre checked by - pre_process decorator + :param request: A request object :returns: tuple of headers, status code, content """ - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + if not request.is_valid(): + return self.get_format_exception(request) fcm = { 'links': [], - 'title': self.config['metadata']['identification']['title'], + 'title': l10n.translate( + self.config['metadata']['identification']['title'], + request.locale), 'description': - self.config['metadata']['identification']['description'] + l10n.translate( + self.config['metadata']['identification']['description'], + request.locale) } LOGGER.debug('Creating links') + # TODO: put title text in config or translatable files? fcm['links'] = [{ - 'rel': 'self' if not format_ or - format_ == 'json' else 'alternate', - 'type': 'application/json', - 'title': 'This document as JSON', - 'href': '{}?f=json'.format(self.config['server']['url']) - }, { - 'rel': 'self' if format_ == 'jsonld' else 'alternate', - 'type': 'application/ld+json', - 'title': 'This document as RDF (JSON-LD)', - 'href': '{}?f=jsonld'.format(self.config['server']['url']) - }, { - 'rel': 'self' if format_ == 'html' else 'alternate', - 'type': 'text/html', - 'title': 'This document as HTML', - 'href': '{}?f=html'.format(self.config['server']['url']), - 'hreflang': self.config['server']['language'] - }, { - 'rel': 'service-desc', - 'type': 'application/vnd.oai.openapi+json;version=3.0', - 'title': 'The OpenAPI definition as JSON', - 'href': '{}/openapi'.format(self.config['server']['url']) - }, { - 'rel': 'service-doc', - 'type': 'text/html', - 'title': 'The OpenAPI definition as HTML', - 'href': '{}/openapi?f=html'.format(self.config['server']['url']), - 'hreflang': self.config['server']['language'] - }, { - 'rel': 'conformance', - 'type': 'application/json', - 'title': 'Conformance', - 'href': '{}/conformance'.format(self.config['server']['url']) - }, { - 'rel': 'data', - 'type': 'application/json', - 'title': 'Collections', - 'href': '{}/collections'.format(self.config['server']['url']) - } - ] + 'rel': request.get_linkrel(F_JSON), + 'type': FORMAT_TYPES[F_JSON], + 'title': 'This document as JSON', + 'href': '{}?f={}'.format(self.config['server']['url'], F_JSON) + }, { + 'rel': request.get_linkrel(F_JSONLD), + 'type': FORMAT_TYPES[F_JSONLD], + 'title': 'This document as RDF (JSON-LD)', + 'href': '{}?f={}'.format(self.config['server']['url'], F_JSONLD) + }, { + 'rel': request.get_linkrel(F_HTML), + 'type': FORMAT_TYPES[F_HTML], + 'title': 'This document as HTML', + 'href': '{}?f={}'.format(self.config['server']['url'], F_HTML), + 'hreflang': self.default_locale + }, { + 'rel': 'service-desc', + 'type': 'application/vnd.oai.openapi+json;version=3.0', + 'title': 'The OpenAPI definition as JSON', + 'href': '{}/openapi'.format(self.config['server']['url']) + }, { + 'rel': 'service-doc', + 'type': FORMAT_TYPES[F_HTML], + 'title': 'The OpenAPI definition as HTML', + 'href': '{}/openapi?f={}'.format(self.config['server']['url'], + F_HTML), + 'hreflang': self.default_locale + }, { + 'rel': 'conformance', + 'type': FORMAT_TYPES[F_JSON], + 'title': 'Conformance', + 'href': '{}/conformance'.format(self.config['server']['url']) + }, { + 'rel': 'data', + 'type': FORMAT_TYPES[F_JSON], + 'title': 'Collections', + 'href': '{}/collections'.format(self.config['server']['url']) + }] - if format_ == 'html': # render - headers_['Content-Type'] = 'text/html' + headers = request.get_response_headers() + if request.format == F_HTML: # render fcm['processes'] = False fcm['stac'] = False @@ -246,99 +584,87 @@ class API: 'type', 'stac-collection'): fcm['stac'] = True - content = render_j2_template(self.config, 'landing_page.html', fcm) - return headers_, 200, content + content = render_j2_template(self.config, 'landing_page.html', fcm, + request.locale) + return headers, 200, content - if format_ == 'jsonld': - headers_['Content-Type'] = 'application/ld+json' - return headers_, 200, to_json(self.fcmld, self.pretty_print) + if request.format == F_JSONLD: + return headers, 200, to_json(self.fcmld, self.pretty_print) # noqa - return headers_, 200, to_json(fcm, self.pretty_print) + return headers, 200, to_json(fcm, self.pretty_print) @pre_process - def openapi(self, headers_, format_, openapi): + def openapi(self, request: Union[APIRequest, Any], openapi): """ Provide OpenAPI document - - :param headers_: copy of HEADERS object - :param format_: format of requests, pre checked by - pre_process decorator + :param request: A request object :param openapi: dict of OpenAPI definition :returns: tuple of headers, status code, content """ - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + if not request.is_valid(): + return self.get_format_exception(request) - if format_ == 'html': + headers = request.get_response_headers() + if request.format == F_HTML: path = '/'.join([self.config['server']['url'].rstrip('/'), 'openapi']) data = { 'openapi-document-path': path } - headers_['Content-Type'] = 'text/html' - content = render_j2_template(self.config, 'openapi.html', data) - return headers_, 200, content + content = render_j2_template(self.config, 'openapi.html', data, + request.locale) + return headers, 200, content - headers_['Content-Type'] = \ - 'application/vnd.oai.openapi+json;version=3.0' + headers['Content-Type'] = 'application/vnd.oai.openapi+json;version=3.0' # noqa if isinstance(openapi, dict): - return headers_, 200, to_json(openapi, self.pretty_print) + return headers, 200, to_json(openapi, self.pretty_print) else: - return headers_, 200, openapi.read() + return headers, 200, openapi.read() @pre_process - def conformance(self, headers_, format_): + def conformance(self, request: Union[APIRequest, Any]): """ Provide conformance definition - :param headers_: copy of HEADERS object - :param format_: format of requests, - pre checked by pre_process decorator + :param request: A request object :returns: tuple of headers, status code, content """ - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + if not request.is_valid(): + return self.get_format_exception(request) conformance = { 'conformsTo': CONFORMANCE } - if format_ == 'html': # render - headers_['Content-Type'] = 'text/html' + headers = request.get_response_headers() + if request.format == F_HTML: # render content = render_j2_template(self.config, 'conformance.html', - conformance) - return headers_, 200, content + conformance, request.locale) + return headers, 200, content - return headers_, 200, to_json(conformance, self.pretty_print) + return headers, 200, to_json(conformance, self.pretty_print) @pre_process @jsonldify - def describe_collections(self, headers_, format_, dataset=None): + def describe_collections(self, request: Union[APIRequest, Any], dataset=None): # noqa """ Provide collection metadata - :param headers_: copy of HEADERS object - :param format_: format of requests, - pre checked by pre_process decorator + :param request: A request object :param dataset: name of collection :returns: tuple of headers, status code, content """ - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + if not request.is_valid(): + return self.get_format_exception(request) + headers = request.get_response_headers() fcm = { 'collections': [], @@ -351,7 +677,7 @@ class API: if all([dataset is not None, dataset not in collections.keys()]): msg = 'Invalid collection' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Creating collections') for k, v in collections.items(): @@ -363,11 +689,13 @@ class API: if 'format' in collection_data: collection_data_format = collection_data['format'] - collection = {'links': []} - collection['id'] = k - collection['title'] = v['title'] - collection['description'] = v['description'] - collection['keywords'] = v['keywords'] + collection = { + 'id': k, + 'title': l10n.translate(v['title'], request.locale), + 'description': l10n.translate(v['description'], request.locale), # noqa + 'keywords': l10n.translate(v['keywords'], request.locale), + 'links': [] + } bbox = v['extents']['spatial']['bbox'] # The output should be an array of bbox, so if the user only @@ -393,7 +721,7 @@ class API: if 'trs' in t_ext: collection['extent']['temporal']['trs'] = t_ext['trs'] - for link in v['links']: + for link in l10n.translate(v['links'], request.locale): lnk = { 'type': link['type'], 'rel': link['rel'], @@ -405,118 +733,120 @@ class API: collection['links'].append(lnk) + # TODO: provide translations LOGGER.debug('Adding JSON and HTML link relations') collection['links'].append({ - 'type': 'application/json', - 'rel': 'self' if not format_ - or format_ == 'json' else 'alternate', + 'type': FORMAT_TYPES[F_JSON], + 'rel': request.get_linkrel(F_JSON), 'title': 'This document as JSON', - 'href': '{}/collections/{}?f=json'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}?f={}'.format( + self.config['server']['url'], k, F_JSON) }) collection['links'].append({ - 'type': 'application/ld+json', - 'rel': 'self' if format_ == 'jsonld' else 'alternate', + 'type': FORMAT_TYPES[F_JSONLD], + 'rel': request.get_linkrel(F_JSONLD), 'title': 'This document as RDF (JSON-LD)', - 'href': '{}/collections/{}?f=jsonld'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}?f={}'.format( + self.config['server']['url'], k, F_JSONLD) }) collection['links'].append({ - 'type': 'text/html', - 'rel': 'self' if format_ == 'html' else 'alternate', + 'type': FORMAT_TYPES[F_HTML], + 'rel': request.get_linkrel(F_HTML), 'title': 'This document as HTML', - 'href': '{}/collections/{}?f=html'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}?f={}'.format( + self.config['server']['url'], k, F_HTML) }) if collection_data_type in ['feature', 'record']: + # TODO: translate collection['itemType'] = collection_data_type LOGGER.debug('Adding feature/record based links') collection['links'].append({ - 'type': 'application/json', + 'type': FORMAT_TYPES[F_JSON], 'rel': 'queryables', 'title': 'Queryables for this collection as JSON', - 'href': '{}/collections/{}/queryables?f=json'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}/queryables?f={}'.format( + self.config['server']['url'], k, F_JSON) }) collection['links'].append({ - 'type': 'text/html', + 'type': FORMAT_TYPES[F_HTML], 'rel': 'queryables', 'title': 'Queryables for this collection as HTML', - 'href': '{}/collections/{}/queryables?f=html'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}/queryables?f={}'.format( + self.config['server']['url'], k, F_HTML) }) collection['links'].append({ 'type': 'application/geo+json', 'rel': 'items', 'title': 'items as GeoJSON', - 'href': '{}/collections/{}/items?f=json'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}/items?f={}'.format( + self.config['server']['url'], k, F_JSON) }) collection['links'].append({ - 'type': 'application/ld+json', + 'type': FORMAT_TYPES[F_JSONLD], 'rel': 'items', 'title': 'items as RDF (GeoJSON-LD)', - 'href': '{}/collections/{}/items?f=jsonld'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}/items?f={}'.format( + self.config['server']['url'], k, F_JSONLD) }) collection['links'].append({ - 'type': 'text/html', + 'type': FORMAT_TYPES[F_HTML], 'rel': 'items', 'title': 'Items as HTML', - 'href': '{}/collections/{}/items?f=html'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}/items?f={}'.format( + self.config['server']['url'], k, F_HTML) }) elif collection_data_type == 'coverage': + # TODO: translate LOGGER.debug('Adding coverage based links') collection['links'].append({ - 'type': 'application/json', + 'type': FORMAT_TYPES[F_JSON], 'rel': 'collection', 'title': 'Detailed Coverage metadata in JSON', - 'href': '{}/collections/{}?f=json'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}?f={}'.format( + self.config['server']['url'], k, F_JSON) }) collection['links'].append({ - 'type': 'text/html', + 'type': FORMAT_TYPES[F_HTML], 'rel': 'collection', 'title': 'Detailed Coverage metadata in HTML', - 'href': '{}/collections/{}?f=html'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}?f={}'.format( + self.config['server']['url'], k, F_HTML) }) coverage_url = '{}/collections/{}/coverage'.format( self.config['server']['url'], k) collection['links'].append({ - 'type': 'application/json', + 'type': FORMAT_TYPES[F_JSON], 'rel': '{}/coverage-domainset'.format(OGC_RELTYPES_BASE), 'title': 'Coverage domain set of collection in JSON', - 'href': '{}/domainset?f=json'.format(coverage_url) + 'href': '{}/domainset?f={}'.format(coverage_url, F_JSON) }) collection['links'].append({ - 'type': 'text/html', + 'type': FORMAT_TYPES[F_HTML], 'rel': '{}/coverage-domainset'.format(OGC_RELTYPES_BASE), 'title': 'Coverage domain set of collection in HTML', - 'href': '{}/domainset?f=html'.format(coverage_url) + 'href': '{}/domainset?f={}'.format(coverage_url, F_HTML) }) collection['links'].append({ - 'type': 'application/json', + 'type': FORMAT_TYPES[F_JSON], 'rel': '{}/coverage-rangetype'.format(OGC_RELTYPES_BASE), 'title': 'Coverage range type of collection in JSON', - 'href': '{}/rangetype?f=json'.format(coverage_url) + 'href': '{}/rangetype?f={}'.format(coverage_url, F_JSON) }) collection['links'].append({ - 'type': 'text/html', + 'type': FORMAT_TYPES[F_HTML], 'rel': '{}/coverage-rangetype'.format(OGC_RELTYPES_BASE), 'title': 'Coverage range type of collection in HTML', - 'href': '{}/rangetype?f=html'.format(coverage_url) + 'href': '{}/rangetype?f={}'.format(coverage_url, F_HTML) }) collection['links'].append({ 'type': 'application/prs.coverage+json', 'rel': '{}/coverage'.format(OGC_RELTYPES_BASE), 'title': 'Coverage data', - 'href': '{}/collections/{}/coverage?f=json'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}/coverage?f={}'.format( + self.config['server']['url'], k, F_JSON) }) if collection_data_format is not None: collection['links'].append({ @@ -531,18 +861,20 @@ class API: if dataset is not None: LOGGER.debug('Creating extended coverage metadata') try: - p = load_plugin('provider', get_provider_by_type( + provider_def = get_provider_by_type( self.config['resources'][k]['providers'], - 'coverage')) + 'coverage') + p = load_plugin('provider', provider_def) + except ProviderConnectionError: + msg = 'connection error (check logs)' + return self.get_exception(500, headers, request.format, + 'NoApplicableCode', msg) + except ProviderTypeError: + pass + else: collection['crs'] = [p.crs] collection['domainset'] = p.get_coverage_domainset() collection['rangetype'] = p.get_coverage_rangetype() - except ProviderConnectionError: - msg = 'connection error (check logs)' - return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) - except ProviderTypeError: - pass try: tile = get_provider_by_type(v['providers'], 'tile') @@ -550,20 +882,21 @@ class API: tile = None if tile: + # TODO: translate LOGGER.debug('Adding tile links') collection['links'].append({ - 'type': 'application/json', + 'type': FORMAT_TYPES[F_JSON], 'rel': 'tiles', 'title': 'Tiles as JSON', - 'href': '{}/collections/{}/tiles?f=json'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}/tiles?f={}'.format( + self.config['server']['url'], k, F_JSON) }) collection['links'].append({ - 'type': 'text/html', + 'type': FORMAT_TYPES[F_HTML], 'rel': 'tiles', 'title': 'Tiles as HTML', - 'href': '{}/collections/{}/tiles?f=html'.format( - self.config['server']['url'], k) + 'href': '{}/collections/{}/tiles?f={}'.format( + self.config['server']['url'], k, F_HTML) }) try: @@ -572,11 +905,11 @@ class API: edr = None if edr and dataset is not None: + # TODO: translate LOGGER.debug('Adding EDR links') try: p = load_plugin('provider', get_provider_by_type( - self.config['resources'][dataset]['providers'], - 'edr')) + self.config['resources'][dataset]['providers'], 'edr')) parameters = p.get_fields() if parameters: collection['parameter-names'] = {} @@ -588,20 +921,20 @@ class API: 'type': 'text/json', 'rel': 'data', 'title': '{} query for this collection as JSON'.format(qt), # noqa - 'href': '{}/collections/{}/{}?f=json'.format( - self.config['server']['url'], k, qt) + 'href': '{}/collections/{}/{}?f={}'.format( + self.config['server']['url'], k, qt, F_JSON) }) collection['links'].append({ - 'type': 'text/html', + 'type': FORMAT_TYPES[F_HTML], 'rel': 'data', 'title': '{} query for this collection as HTML'.format(qt), # noqa - 'href': '{}/collections/{}/{}?f=html'.format( - self.config['server']['url'], k, qt) + 'href': '{}/collections/{}/{}?f={}'.format( + self.config['server']['url'], k, qt, F_HTML) }) except ProviderConnectionError: msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) except ProviderTypeError: pass @@ -612,84 +945,77 @@ class API: fcm['collections'].append(collection) if dataset is None: + # TODO: translate fcm['links'].append({ - 'type': 'application/json', - 'rel': 'self' if not format - or format_ == 'json' else 'alternate', + 'type': FORMAT_TYPES[F_JSON], + 'rel': request.get_linkrel(F_JSON), 'title': 'This document as JSON', - 'href': '{}/collections?f=json'.format( - self.config['server']['url']) + 'href': '{}/collections?f={}'.format( + self.config['server']['url'], F_JSON) }) fcm['links'].append({ - 'type': 'application/ld+json', - 'rel': 'self' if format_ == 'jsonld' else 'alternate', + 'type': FORMAT_TYPES[F_JSONLD], + 'rel': request.get_linkrel(F_JSONLD), 'title': 'This document as RDF (JSON-LD)', - 'href': '{}/collections?f=jsonld'.format( - self.config['server']['url']) + 'href': '{}/collections?f={}'.format( + self.config['server']['url'], F_JSONLD) }) fcm['links'].append({ - 'type': 'text/html', - 'rel': 'self' if format_ == 'html' else 'alternate', + 'type': FORMAT_TYPES[F_HTML], + 'rel': request.get_linkrel(F_HTML), 'title': 'This document as HTML', - 'href': '{}/collections?f=html'.format( - self.config['server']['url']) + 'href': '{}/collections?f={}'.format( + self.config['server']['url'], F_HTML) }) - if format_ == 'html': # render - - headers_['Content-Type'] = 'text/html' + if request.format == F_HTML: # render if dataset is not None: content = render_j2_template(self.config, 'collections/collection.html', - fcm) + fcm, request.locale) else: content = render_j2_template(self.config, - 'collections/index.html', fcm) + 'collections/index.html', fcm, + request.locale) - return headers_, 200, content + return headers, 200, content - if format_ == 'jsonld': - jsonld = self.fcmld.copy() + if request.format == F_JSONLD: + jsonld = self.fcmld.copy() # noqa if dataset is not None: - jsonld['dataset'] = jsonldify_collection(self, fcm) + jsonld['dataset'] = jsonldify_collection(self, fcm, + request.locale) else: - jsonld['dataset'] = list( - map( - lambda collection: jsonldify_collection( - self, collection - ), fcm.get('collections', []) - ) - ) - headers_['Content-Type'] = 'application/ld+json' - return headers_, 200, to_json(jsonld, self.pretty_print) + jsonld['dataset'] = [ + jsonldify_collection(self, c, request.locale) + for c in fcm.get('collections', []) + ] + return headers, 200, to_json(jsonld, self.pretty_print) - return headers_, 200, to_json(fcm, self.pretty_print) + return headers, 200, to_json(fcm, self.pretty_print) @pre_process @jsonldify - def get_collection_queryables(self, headers_, format_, dataset=None): + def get_collection_queryables(self, request: Union[APIRequest, Any], dataset=None): # noqa """ Provide collection queryables - :param headers_: copy of HEADERS object - :param format_: format of requests, - pre checked by pre_process decorator + :param request: A request object :param dataset: name of collection :returns: tuple of headers, status code, content """ - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + if not request.is_valid(): + return self.get_format_exception(request) + headers = request.get_response_headers() if any([dataset is None, dataset not in self.config['resources'].keys()]): msg = 'Invalid collection' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Creating collection queryables') try: @@ -703,15 +1029,16 @@ class API: except ProviderConnectionError: msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) except ProviderQueryError: msg = 'query error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) queryables = { 'type': 'object', - 'title': self.config['resources'][dataset]['title'], + 'title': l10n.translate( + self.config['resources'][dataset]['title'], request.locale), 'properties': {}, '$schema': 'http://json-schema.org/draft/2019-09/schema', '$id': '{}/collections/{}/queryables'.format( @@ -734,92 +1061,88 @@ class API: if 'values' in v: queryables['properties'][k]['enum'] = v['values'] - if format_ == 'html': # render - queryables['title'] = self.config['resources'][dataset]['title'] - headers_['Content-Type'] = 'text/html' + if request.format == F_HTML: # render + queryables['title'] = l10n.translate( + self.config['resources'][dataset]['title'], request.locale) content = render_j2_template(self.config, 'collections/queryables.html', - queryables) + queryables, request.locale) - return headers_, 200, content + return headers, 200, content - return headers_, 200, to_json(queryables, self.pretty_print) + return headers, 200, to_json(queryables, self.pretty_print) - def get_collection_items(self, headers, args, dataset, pathinfo=None): + @pre_process + def get_collection_items(self, request: Union[APIRequest, Any], dataset, pathinfo=None): # noqa """ Queries collection - :param headers: dict of HTTP headers - :param args: dict of HTTP request parameters + :param request: A request object :param dataset: dataset name :param pathinfo: path location :returns: tuple of headers, status code, content """ - headers_ = HEADERS.copy() + if not request.is_valid(PLUGINS['formatter'].keys()): + return self.get_format_exception(request) + + # Set Content-Language to system locale until provider locale + # has been determined + headers = request.get_response_headers(SYSTEM_LOCALE) properties = [] - reserved_fieldnames = ['bbox', 'f', 'limit', 'startindex', + reserved_fieldnames = ['bbox', 'f', 'lang', 'limit', 'startindex', 'resulttype', 'datetime', 'sortby', 'properties', 'skipGeometry', 'q'] - formats = FORMATS - formats.extend(f.lower() for f in PLUGINS['formatter'].keys()) collections = filter_dict_by_key_value(self.config['resources'], 'type', 'collection') - format_ = check_format(args, headers) - - if format_ is not None and format_ not in formats: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) - if dataset not in collections.keys(): msg = 'Invalid collection' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Processing query parameters') LOGGER.debug('Processing startindex parameter') try: - startindex = int(args.get('startindex')) + startindex = int(request.params.get('startindex')) if startindex < 0: msg = 'startindex value should be positive or zero' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) except TypeError as err: LOGGER.warning(err) startindex = 0 except ValueError: msg = 'startindex value should be an integer' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Processing limit parameter') try: - limit = int(args.get('limit')) + limit = int(request.params.get('limit')) # TODO: We should do more validation, against the min and max - # allowed by the server configuration + # allowed by the server configuration if limit <= 0: msg = 'limit value should be strictly positive' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) except TypeError as err: LOGGER.warning(err) limit = int(self.config['server']['limit']) except ValueError: msg = 'limit value should be an integer' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) - resulttype = args.get('resulttype') or 'results' + resulttype = request.params.get('resulttype') or 'results' LOGGER.debug('Processing bbox parameter') - bbox = args.get('bbox') + bbox = request.params.get('bbox') if bbox is None: bbox = [] @@ -829,60 +1152,57 @@ class API: except ValueError as err: msg = str(err) return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Processing datetime parameter') - datetime_ = args.get('datetime') + datetime_ = request.params.get('datetime') try: datetime_ = validate_datetime(collections[dataset]['extents'], datetime_) except ValueError as err: msg = str(err) return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('processing q parameter') - val = args.get('q') - - if val is not None: - q = val - else: - q = None + q = request.params.get('q') or None LOGGER.debug('Loading provider') try: - p = load_plugin('provider', get_provider_by_type( - collections[dataset]['providers'], 'feature')) + provider_def = get_provider_by_type( + collections[dataset]['providers'], 'feature') + p = load_plugin('provider', provider_def) except ProviderTypeError: try: - p = load_plugin('provider', get_provider_by_type( - collections[dataset]['providers'], 'record')) + provider_def = get_provider_by_type( + collections[dataset]['providers'], 'record') + p = load_plugin('provider', provider_def) except ProviderTypeError: msg = 'Invalid provider type' return self.get_exception( - 400, headers_, format_, 'NoApplicableCode', msg) + 400, headers, request.format, 'NoApplicableCode', msg) except ProviderConnectionError: msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) except ProviderQueryError: msg = 'query error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) LOGGER.debug('processing property parameters') - for k, v in args.items(): + for k, v in request.params.items(): if k not in reserved_fieldnames and k not in p.fields.keys(): msg = 'unknown query parameter: {}'.format(k) return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) elif k not in reserved_fieldnames and k in p.fields.keys(): LOGGER.debug('Add property filter {}={}'.format(k, v)) properties.append((k, v)) LOGGER.debug('processing sort parameter') - val = args.get('sortby') + val = request.params.get('sortby') if val is not None: sortby = [] @@ -897,14 +1217,15 @@ class API: if prop not in p.fields.keys(): msg = 'bad sort property' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, + 'InvalidParameterValue', msg) sortby.append({'property': prop, 'order': order}) else: sortby = [] LOGGER.debug('processing properties parameter') - val = args.get('properties') + val = request.params.get('properties') if val is not None: select_properties = val.split(',') @@ -914,17 +1235,20 @@ class API: set(properties_to_check))) > 0): msg = 'unknown properties specified' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) else: select_properties = [] LOGGER.debug('processing skipGeometry parameter') - val = args.get('skipGeometry') + val = request.params.get('skipGeometry') if val is not None: skip_geometry = str2bool(val) else: skip_geometry = False + # Get provider locale (if any) + prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale) + LOGGER.debug('Querying provider') LOGGER.debug('startindex: {}'.format(startindex)) LOGGER.debug('limit: {}'.format(limit)) @@ -934,6 +1258,7 @@ class API: LOGGER.debug('datetime: {}'.format(datetime_)) LOGGER.debug('properties: {}'.format(select_properties)) LOGGER.debug('skipGeometry: {}'.format(skip_geometry)) + LOGGER.debug('language: {}'.format(prv_locale)) LOGGER.debug('q: {}'.format(q)) try: @@ -943,51 +1268,54 @@ class API: sortby=sortby, select_properties=select_properties, skip_geometry=skip_geometry, - q=q) + q=q, language=prv_locale) except ProviderConnectionError as err: LOGGER.error(err) msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) except ProviderQueryError as err: LOGGER.error(err) msg = 'query error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) except ProviderGenericError as err: LOGGER.error(err) msg = 'generic error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) serialized_query_params = '' - for k, v in args.items(): + for k, v in request.params.items(): if k not in ('f', 'startindex'): serialized_query_params += '&' serialized_query_params += urllib.parse.quote(k, safe='') serialized_query_params += '=' serialized_query_params += urllib.parse.quote(str(v), safe=',') + # TODO: translate titles content['links'] = [{ 'type': 'application/geo+json', - 'rel': 'self' if not format_ or format_ == 'json' else 'alternate', + 'rel': request.get_linkrel(F_JSON), 'title': 'This document as GeoJSON', - 'href': '{}/collections/{}/items?f=json{}'.format( - self.config['server']['url'], dataset, serialized_query_params) - }, { - 'rel': 'self' if format_ == 'jsonld' else 'alternate', - 'type': 'application/ld+json', + 'href': '{}/collections/{}/items?f={}{}'.format( + self.config['server']['url'], dataset, F_JSON, + serialized_query_params) + }, { + 'rel': request.get_linkrel(F_JSONLD), + 'type': FORMAT_TYPES[F_JSONLD], 'title': 'This document as RDF (JSON-LD)', - 'href': '{}/collections/{}/items?f=jsonld{}'.format( - self.config['server']['url'], dataset, serialized_query_params) - }, { - 'type': 'text/html', - 'rel': 'self' if format_ == 'html' else 'alternate', + 'href': '{}/collections/{}/items?f={}{}'.format( + self.config['server']['url'], dataset, F_JSONLD, + serialized_query_params) + }, { + 'type': FORMAT_TYPES[F_HTML], + 'rel': request.get_linkrel(F_HTML), 'title': 'This document as HTML', - 'href': '{}/collections/{}/items?f=html{}'.format( - self.config['server']['url'], dataset, serialized_query_params) - } - ] + 'href': '{}/collections/{}/items?f={}{}'.format( + self.config['server']['url'], dataset, F_HTML, + serialized_query_params) + }] if startindex > 0: prev = max(0, startindex - limit) @@ -1016,8 +1344,9 @@ class API: content['links'].append( { - 'type': 'application/json', - 'title': collections[dataset]['title'], + 'type': FORMAT_TYPES[F_JSON], + 'title': l10n.translate( + collections[dataset]['title'], request.locale), 'rel': 'collection', 'href': '{}/collections/{}'.format( self.config['server']['url'], dataset) @@ -1026,9 +1355,12 @@ class API: content['timeStamp'] = datetime.utcnow().strftime( '%Y-%m-%dT%H:%M:%S.%fZ') - if format_ == 'html': # render - headers_['Content-Type'] = 'text/html' + # Set response language to requested provider locale + # (if it supports language) and/or otherwise the requested pygeoapi + # locale (or fallback default locale) + l10n.set_response_language(headers, prv_locale, request.locale) + if request.format == F_HTML: # render # For constructing proper URIs to items if pathinfo: path_info = '/'.join([ @@ -1037,7 +1369,7 @@ class API: else: path_info = '/'.join([ self.config['server']['url'].rstrip('/'), - headers.environ['PATH_INFO'].strip('/')]) + request.path_info]) content['items_path'] = path_info content['dataset_path'] = '/'.join(path_info.split('/')[:-1]) @@ -1052,10 +1384,11 @@ class API: content = render_j2_template(self.config, 'collections/items/index.html', - content) - return headers_, 200, content - elif format_ == 'csv': # render - formatter = load_plugin('formatter', {'name': 'CSV', 'geom': True}) + content, request.locale) + return headers, 200, content + elif request.format == 'csv': # render + formatter = load_plugin('formatter', + {'name': 'CSV', 'geom': True}) content = formatter.write( data=content, @@ -1066,40 +1399,37 @@ class API: } ) - headers_['Content-Type'] = '{}; charset={}'.format( + headers['Content-Type'] = '{}; charset={}'.format( formatter.mimetype, self.config['server']['encoding']) cd = 'attachment; filename="{}.csv"'.format(dataset) - headers_['Content-Disposition'] = cd + headers['Content-Disposition'] = cd - return headers_, 200, content - elif format_ == 'jsonld': - headers_['Content-Type'] = 'application/ld+json' - content = geojson2geojsonld( - self.config, content, dataset, id_field=(p.uri_field or 'id') - ) - return headers_, 200, to_json(content, self.pretty_print) + return headers, 200, content - return headers_, 200, to_json(content, self.pretty_print) + elif request.format == F_JSONLD: + content = geojson2geojsonld(self.config, content, dataset) + + return headers, 200, to_json(content, self.pretty_print) @pre_process - def get_collection_item(self, headers_, format_, dataset, identifier): + def get_collection_item(self, request: Union[APIRequest, Any], dataset, identifier): # noqa """ Get a single collection item - :param headers_: copy of HEADERS object - :param format_: format of requests, - pre checked by pre_process decorator + :param request: A request object :param dataset: dataset name :param identifier: item identifier :returns: tuple of headers, status code, content """ - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + if not request.is_valid(): + return self.get_format_exception(request) + + # Set Content-Language to system locale until provider locale + # has been determined + headers = request.get_response_headers(SYSTEM_LOCALE) LOGGER.debug('Processing query parameters') @@ -1109,73 +1439,82 @@ class API: if dataset not in collections.keys(): msg = 'Invalid collection' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Loading provider') try: - p = load_plugin('provider', get_provider_by_type( - collections[dataset]['providers'], 'feature')) + provider_def = get_provider_by_type( + collections[dataset]['providers'], 'feature') + p = load_plugin('provider', provider_def) except ProviderTypeError: try: - p = load_plugin('provider', get_provider_by_type( - collections[dataset]['providers'], 'record')) + provider_def = get_provider_by_type( + collections[dataset]['providers'], 'record') + p = load_plugin('provider', provider_def) except ProviderTypeError: msg = 'Invalid provider type' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) + + # Get provider language (if any) + prv_locale = l10n.get_plugin_locale(provider_def, request.raw_locale) + try: LOGGER.debug('Fetching id {}'.format(identifier)) - content = p.get(identifier) + content = p.get(identifier, language=prv_locale) except ProviderConnectionError as err: LOGGER.error(err) msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) except ProviderItemNotFoundError: msg = 'identifier not found' - return self.get_exception(404, headers_, format_, 'NotFound', msg) + return self.get_exception(404, headers, request.format, + 'NotFound', msg) except ProviderQueryError as err: LOGGER.error(err) msg = 'query error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) except ProviderGenericError as err: LOGGER.error(err) msg = 'generic error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) if content is None: msg = 'identifier not found' - return self.get_exception(400, headers_, format_, 'NotFound', msg) + return self.get_exception(400, headers, request.format, + 'NotFound', msg) uri = content['properties'].get(p.uri_field) if p.uri_field else \ '{}/collections/{}/items/{}'.format( self.config['server']['url'], dataset, identifier) content['links'] = [{ - 'rel': 'self' if not format_ or format_ == 'json' else 'alternate', + 'rel': request.get_linkrel(F_JSON), 'type': 'application/geo+json', 'title': 'This document as GeoJSON', - 'href': '{}?f=json'.format(uri) + 'href': '{}?f={}'.format(uri, F_JSON) }, { - 'rel': 'self' if format_ == 'jsonld' else 'alternate', - 'type': 'application/ld+json', + 'rel': request.get_linkrel(F_JSONLD), + 'type': FORMAT_TYPES[F_JSONLD], 'title': 'This document as RDF (JSON-LD)', - 'href': '{}?f=jsonld'.format(uri) + 'href': '{}?f={}'.format(uri, F_JSONLD) }, { - 'rel': 'self' if format_ == 'html' else 'alternate', - 'type': 'text/html', + 'rel': request.get_linkrel(F_HTML), + 'type': FORMAT_TYPES[F_HTML], 'title': 'This document as HTML', - 'href': '{}?f=html'.format(uri) + 'href': '{}?f={}'.format(uri, F_HTML) }, { 'rel': 'collection', - 'type': 'application/json', - 'title': collections[dataset]['title'], + 'type': FORMAT_TYPES[F_JSON], + 'title': l10n.translate(collections[dataset]['title'], + request.locale), 'href': '{}/collections/{}'.format( self.config['server']['url'], dataset) - }, { + }, { 'rel': 'prev', 'type': 'application/geo+json', 'href': uri @@ -1186,10 +1525,14 @@ class API: } ] - if format_ == 'html': # render - headers_['Content-Type'] = 'text/html' + # Set response language to requested provider locale + # (if it supports language) and/or otherwise the requested pygeoapi + # locale (or fallback default locale) + l10n.set_response_language(headers, prv_locale, request.locale) - content['title'] = collections[dataset]['title'] + if request.format == F_HTML: # render + content['title'] = l10n.translate(collections[dataset]['title'], + request.locale) content['id_field'] = p.id_field if p.uri_field is not None: content['uri_field'] = p.uri_field @@ -1198,37 +1541,34 @@ class API: content = render_j2_template(self.config, 'collections/items/item.html', - content) - return headers_, 200, content - elif format_ == 'jsonld': - headers_['Content-Type'] = 'application/ld+json' + content, request.locale) + return headers, 200, content + + elif request.format == F_JSONLD: content = geojson2geojsonld( self.config, content, dataset, uri, (p.uri_field or 'id') ) - content = to_json(content, self.pretty_print) - return headers_, 200, content - return headers_, 200, to_json(content, self.pretty_print) + return headers, 200, to_json(content, self.pretty_print) + @pre_process @jsonldify - def get_collection_coverage(self, headers, args, dataset): + def get_collection_coverage(self, request: Union[APIRequest, Any], dataset): # noqa """ Returns a subset of a collection coverage - :param headers: dict of HTTP headers - :param args: dict of HTTP request parameters + :param request: A request object :param dataset: dataset name :returns: tuple of headers, status code, content """ - headers_ = HEADERS.copy() query_args = {} - format_ = 'json' + format_ = F_JSON - LOGGER.debug('Processing query parameters') - - subsets = {} + # Force content type and language (en-US only) headers + headers = request.get_response_headers(SYSTEM_LOCALE, + FORMAT_TYPES[F_JSON]) LOGGER.debug('Loading provider') try: @@ -1239,19 +1579,19 @@ class API: except KeyError: msg = 'collection does not exist' return self.get_exception( - 404, headers_, format_, 'InvalidParameterValue', msg) + 404, headers, format_, 'InvalidParameterValue', msg) except ProviderTypeError: msg = 'invalid provider type' return self.get_exception( - 400, headers_, format_, 'NoApplicableCode', msg) + 400, headers, format_, 'NoApplicableCode', msg) except ProviderConnectionError: msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, format_, 'NoApplicableCode', msg) LOGGER.debug('Processing bbox parameter') - bbox = args.get('bbox') + bbox = request.params.get('bbox') if bbox is None: bbox = [] @@ -1261,13 +1601,13 @@ class API: except ValueError as err: msg = str(err) return self.get_exception( - 500, headers_, format_, 'InvalidParameterValue', msg) + 500, headers, format_, 'InvalidParameterValue', msg) query_args['bbox'] = bbox LOGGER.debug('Processing datetime parameter') - datetime_ = args.get('datetime', None) + datetime_ = request.params.get('datetime', None) try: datetime_ = validate_datetime( @@ -1275,29 +1615,30 @@ class API: except ValueError as err: msg = str(err) return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, format_, 'InvalidParameterValue', msg) query_args['datetime_'] = datetime_ - if 'f' in args: - query_args['format_'] = format_ = args['f'] + if request.format: + query_args['format_'] = format_ = request.format - if 'rangeSubset' in args: + range_subset = request.params.get('rangeSubset') + if range_subset: LOGGER.debug('Processing rangeSubset parameter') - - query_args['range_subset'] = list( - filter(None, args['rangeSubset'].split(','))) + query_args['range_subset'] = [rs for + rs in range_subset.split(',') if rs] LOGGER.debug('Fields: {}'.format(query_args['range_subset'])) for a in query_args['range_subset']: if a not in p.fields: msg = 'Invalid field specified' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, format_, 'InvalidParameterValue', msg) - if 'subset' in args: + if 'subset' in request.params: + subsets = {} LOGGER.debug('Processing subset parameter') - for s in args['subset'].split(','): + for s in (request.params['subset'] or '').split(','): try: if '"' not in s: m = re.search(r'(.*)\((.*):(.*)\)', s) @@ -1309,7 +1650,7 @@ class API: if subset_name not in p.axes: msg = 'Invalid axis name' return self.get_exception( - 400, headers_, format_, + 400, headers, format_, 'InvalidParameterValue', msg) subsets[subset_name] = list(map( @@ -1317,7 +1658,7 @@ class API: except AttributeError: msg = 'subset should be like "axis(min:max)"' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, format_, 'InvalidParameterValue', msg) query_args['subsets'] = subsets LOGGER.debug('Subsets: {}'.format(query_args['subsets'])) @@ -1328,46 +1669,41 @@ class API: except ProviderInvalidQueryError as err: msg = 'query error: {}'.format(err) return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, format_, 'InvalidParameterValue', msg) except ProviderNoDataError: msg = 'No data found' return self.get_exception( - 204, headers_, format_, 'InvalidParameterValue', msg) + 204, headers, format_, 'InvalidParameterValue', msg) except ProviderQueryError: msg = 'query error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, format_, 'NoApplicableCode', msg) mt = collection_def['format']['name'] if format_ == mt: - headers_['Content-Type'] = collection_def['format']['mimetype'] - return headers_, 200, data - elif format_ == 'json': - headers_['Content-Type'] = 'application/prs.coverage+json' - return headers_, 200, to_json(data, self.pretty_print) + headers['Content-Type'] = collection_def['format']['mimetype'] + return headers, 200, data + elif format_ == F_JSON: + headers['Content-Type'] = 'application/prs.coverage+json' + return headers, 200, to_json(data, self.pretty_print) else: - msg = 'invalid format parameter' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + return self.get_format_exception(request) + @pre_process @jsonldify - def get_collection_coverage_domainset(self, headers, args, dataset): + def get_collection_coverage_domainset(self, request: Union[APIRequest, Any], dataset): # noqa """ Returns a collection coverage domainset - :param headers: dict of HTTP headers - :param args: dict of HTTP request parameters + :param request: A request object :param dataset: dataset name :returns: tuple of headers, status code, content """ - headers_ = HEADERS.copy() - - format_ = check_format(args, headers) - if format_ is None: - format_ = 'json' + format_ = request.format or F_JSON + headers = request.get_response_headers(self.default_locale) LOGGER.debug('Loading provider') try: @@ -1380,47 +1716,44 @@ class API: except KeyError: msg = 'collection does not exist' return self.get_exception( - 404, headers_, format_, 'InvalidParameterValue', msg) + 404, headers, format_, 'InvalidParameterValue', msg) except ProviderTypeError: msg = 'invalid provider type' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, format_, 'NoApplicableCode', msg) except ProviderConnectionError: msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, format_, 'NoApplicableCode', msg) - if format_ == 'json': - return headers_, 200, to_json(data, self.pretty_print) - elif format_ == 'html': + if format_ == F_JSON: + return headers, 200, to_json(data, self.pretty_print) + + elif format_ == F_HTML: data['id'] = dataset - data['title'] = self.config['resources'][dataset]['title'] + data['title'] = l10n.translate( + self.config['resources'][dataset]['title'], + self.default_locale) content = render_j2_template(self.config, 'collections/coverage/domainset.html', - data) - headers_['Content-Type'] = 'text/html' - return headers_, 200, content + data, self.default_locale) + return headers, 200, content else: - msg = 'invalid format parameter' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + return self.get_format_exception(request) + @pre_process @jsonldify - def get_collection_coverage_rangetype(self, headers, args, dataset): + def get_collection_coverage_rangetype(self, request: Union[APIRequest, Any], dataset): # noqa """ Returns a collection coverage rangetype - :param headers: dict of HTTP headers - :param args: dict of HTTP request parameters + :param request: A request object :param dataset: dataset name :returns: tuple of headers, status code, content """ - - headers_ = HEADERS.copy() - format_ = check_format(args, headers) - if format_ is None: - format_ = 'json' + format_ = request.format or F_JSON + headers = request.get_response_headers(self.default_locale) LOGGER.debug('Loading provider') try: @@ -1433,56 +1766,53 @@ class API: except KeyError: msg = 'collection does not exist' return self.get_exception( - 404, headers_, format_, 'InvalidParameterValue', msg) + 404, headers, format_, 'InvalidParameterValue', msg) except ProviderTypeError: msg = 'invalid provider type' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, format_, 'NoApplicableCode', msg) except ProviderConnectionError: msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, format_, 'NoApplicableCode', msg) - if format_ == 'json': - return (headers_, 200, to_json(data, self.pretty_print)) - elif format_ == 'html': + if format_ == F_JSON: + return headers, 200, to_json(data, self.pretty_print) + + elif format_ == F_HTML: data['id'] = dataset - data['title'] = self.config['resources'][dataset]['title'] + data['title'] = l10n.translate( + self.config['resources'][dataset]['title'], + self.default_locale) content = render_j2_template(self.config, 'collections/coverage/rangetype.html', - data) - headers_['Content-Type'] = 'text/html' - return headers_, 200, content + data, self.default_locale) + return headers, 200, content else: - msg = 'invalid format parameter' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + return self.get_format_exception(request) @pre_process @jsonldify - def get_collection_tiles(self, headers_, format_, dataset=None): + def get_collection_tiles(self, request: Union[APIRequest, Any], dataset=None): # noqa """ Provide collection tiles - :param headers_: copy of HEADERS object - :param format_: format of requests, - pre checked by pre_process decorator + :param request: A request object :param dataset: name of collection :returns: tuple of headers, status code, content """ - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + if not request.is_valid(): + return self.get_format_exception(request) + headers = request.get_response_headers(SYSTEM_LOCALE) if any([dataset is None, dataset not in self.config['resources'].keys()]): msg = 'Invalid collection' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Creating collection tiles') LOGGER.debug('Loading provider') @@ -1493,49 +1823,50 @@ class API: except (KeyError, ProviderTypeError): msg = 'Invalid collection tiles' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) except ProviderConnectionError: msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) except ProviderQueryError: msg = 'query error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) tiles = { 'title': dataset, - 'description': self.config['resources'][dataset]['description'], + 'description': l10n.translate( + self.config['resources'][dataset]['description'], + SYSTEM_LOCALE), 'links': [], 'tileMatrixSetLinks': [] } tiles['links'].append({ - 'type': 'application/json', - 'rel': 'self' if format_ == 'json' else 'alternate', + 'type': FORMAT_TYPES[F_JSON], + 'rel': request.get_linkrel(F_JSON), 'title': 'This document as JSON', - 'href': '{}/collections/{}/tiles?f=json'.format( - self.config['server']['url'], dataset) + 'href': '{}/collections/{}/tiles?f={}'.format( + self.config['server']['url'], dataset, F_JSON) }) tiles['links'].append({ - 'type': 'application/ld+json', - 'rel': 'self' if format_ == 'jsonld' else 'alternate', + 'type': FORMAT_TYPES[F_JSONLD], + 'rel': request.get_linkrel(F_JSONLD), 'title': 'This document as RDF (JSON-LD)', - 'href': '{}/collections/{}/tiles?f=jsonld'.format( - self.config['server']['url'], dataset) + 'href': '{}/collections/{}/tiles?f={}'.format( + self.config['server']['url'], dataset, F_JSONLD) }) tiles['links'].append({ - 'type': 'text/html', - 'rel': 'self' if format_ == 'html' else 'alternate', + 'type': FORMAT_TYPES[F_HTML], + 'rel': request.get_linkrel(F_HTML), 'title': 'This document as HTML', - 'href': '{}/collections/{}/tiles?f=html'.format( - self.config['server']['url'], dataset) + 'href': '{}/collections/{}/tiles?f={}'.format( + self.config['server']['url'], dataset, F_HTML) }) for service in p.get_tiles_service( baseurl=self.config['server']['url'], - servicepath='/collections/{}/\ -tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' + servicepath='/collections/{}/tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' # noqa .format(dataset, 'tileMatrixSetId', 'tileMatrix', 'tileRow', 'tileCol'))['links']: tiles['links'].append(service) @@ -1543,9 +1874,10 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' tiles['tileMatrixSetLinks'] = p.get_tiling_schemes() metadata_format = p.options['metadata_format'] - if format_ == 'html': # render + if request.format == F_HTML: # render tiles['id'] = dataset - tiles['title'] = self.config['resources'][dataset]['title'] + tiles['title'] = l10n.translate( + self.config['resources'][dataset]['title'], SYSTEM_LOCALE) tiles['tilesets'] = [ scheme['tileMatrixSet'] for scheme in p.get_tiling_schemes()] tiles['format'] = metadata_format @@ -1554,25 +1886,23 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' tiles['minzoom'] = p.options['zoom']['min'] tiles['maxzoom'] = p.options['zoom']['max'] - headers_['Content-Type'] = 'text/html' content = render_j2_template(self.config, - 'collections/tiles/index.html', tiles) + 'collections/tiles/index.html', tiles, + SYSTEM_LOCALE) - return headers_, 200, content + return headers, 200, content - return headers_, 200, to_json(tiles, self.pretty_print) + return headers, 200, to_json(tiles, self.pretty_print) @pre_process @jsonldify - def get_collection_tiles_data(self, headers, format_, dataset=None, - matrix_id=None, z_idx=None, y_idx=None, - x_idx=None): + def get_collection_tiles_data(self, request: Union[APIRequest, Any], + dataset=None, matrix_id=None, + z_idx=None, y_idx=None, x_idx=None): """ Get collection items tiles - :param headers: copy of HEADERS object - :param format_: format of requests, - pre checked by pre_process decorator + :param request: A request object :param dataset: dataset name :param matrix_id: matrix identifier :param z_idx: z index @@ -1582,13 +1912,10 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' :returns: tuple of headers, status code, content """ - headers_ = HEADERS.copy() -# format_ = check_format({}, headers) - - if format_ is None and format_ not in ['mvt']: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + format_ = request.format + if not format_: + return self.get_format_exception(request) + headers = request.get_response_headers(SYSTEM_LOCALE) LOGGER.debug('Processing tiles') @@ -1598,7 +1925,7 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' if dataset not in collections.keys(): msg = 'Invalid collection' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Loading tile provider') try: @@ -1607,7 +1934,7 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' p = load_plugin('provider', t) format_ = p.format_type - headers_['Content-Type'] = format_ + headers['Content-Type'] = format_ LOGGER.debug('Fetching tileset id {} and tile {}/{}/{}'.format( matrix_id, z_idx, y_idx, x_idx)) @@ -1616,66 +1943,64 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' if content is None: msg = 'identifier not found' return self.get_exception( - 404, headers_, format_, 'NotFound', msg) + 404, headers, format_, 'NotFound', msg) else: - return headers_, 202, content + return headers, 202, content + # @TODO: figure out if the spec requires to return json errors except KeyError: msg = 'Invalid collection tiles' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, format_, 'InvalidParameterValue', msg) except ProviderConnectionError as err: LOGGER.error(err) msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, format_, 'NoApplicableCode', msg) except ProviderTilesetIdNotFoundError: msg = 'Tileset id not found' return self.get_exception( - 404, headers_, format_, 'NotFound', msg) + 404, headers, format_, 'NotFound', msg) except ProviderTileQueryError as err: LOGGER.error(err) msg = 'Tile not found' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, format_, 'NoApplicableCode', msg) except ProviderTileNotFoundError as err: LOGGER.error(err) - msg = 'tile not found (check logs)' + msg = 'Tile not found (check logs)' return self.get_exception( - 404, headers_, format_, 'NoMatch', msg) + 404, headers, format_, 'NoMatch', msg) except ProviderGenericError as err: LOGGER.error(err) - msg = 'generic error (check logs)' + msg = 'Generic error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, format_, 'NoApplicableCode', msg) @pre_process @jsonldify - def get_collection_tiles_metadata(self, headers_, format_, dataset=None, - matrix_id=None): + def get_collection_tiles_metadata(self, request: Union[APIRequest, Any], + dataset=None, matrix_id=None): """ Get collection items tiles - :param headers_: copy of HEADERS object - :param format_: format of requests, - pre checked by pre_process decorator + :param request: A request object :param dataset: dataset name :param matrix_id: matrix identifier :returns: tuple of headers, status code, content """ - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + if not request.is_valid(): + return self.get_format_exception(request) + headers = request.get_response_headers() if any([dataset is None, dataset not in self.config['resources'].keys()]): msg = 'Invalid collection' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Creating collection tiles') LOGGER.debug('Loading provider') @@ -1686,52 +2011,60 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' except KeyError: msg = 'Invalid collection tiles' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) except ProviderConnectionError: msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'InvalidParameterValue', msg) + 500, headers, request.format, 'InvalidParameterValue', msg) except ProviderQueryError: msg = 'query error (check logs)' return self.get_exception( - 500, headers_, format_, 'InvalidParameterValue', msg) + 500, headers, request.format, 'InvalidParameterValue', msg) + + # Get provider language (if any) + prv_locale = l10n.get_plugin_locale(t, request.raw_locale) if matrix_id not in p.options['schemes']: msg = 'tileset not found' - return self.get_exception(404, headers_, format_, 'NotFound', msg) + return self.get_exception(404, headers, request.format, + 'NotFound', msg) metadata_format = p.options['metadata_format'] tilejson = True if (metadata_format == 'tilejson') else False tiles_metadata = p.get_metadata( dataset=dataset, server_url=self.config['server']['url'], - layer=p.get_layer(), tileset=matrix_id, tilejson=tilejson) + layer=p.get_layer(), tileset=matrix_id, tilejson=tilejson, + language=prv_locale) - if format_ == 'html': # render + # Set response language to requested provider locale + # (if it supports language) and/or otherwise the requested pygeoapi + # locale (or fallback default locale) + l10n.set_response_language(headers, prv_locale, request.locale) + + if request.format == F_HTML: # render metadata = dict(metadata=tiles_metadata) metadata['id'] = dataset - metadata['title'] = self.config['resources'][dataset]['title'] + metadata['title'] = l10n.translate( + self.config['resources'][dataset]['title'], request.locale) metadata['tileset'] = matrix_id metadata['format'] = metadata_format - headers_['Content-Type'] = 'text/html' content = render_j2_template(self.config, 'collections/tiles/metadata.html', - metadata) + metadata, request.locale) - return headers_, 200, content + return headers, 200, content - return headers_, 200, to_json(tiles_metadata, self.pretty_print) + return headers, 200, to_json(tiles_metadata, self.pretty_print) @pre_process @jsonldify - def describe_processes(self, headers_, format_, process=None): + def describe_processes(self, request: Union[APIRequest, Any], process=None): # noqa """ Provide processes metadata - :param headers: dict of HTTP headers - :param format_: format of requests, - pre checked by pre_process decorator + :param request: A request object :param process: process identifier, defaults to None to obtain information about all processes @@ -1740,10 +2073,9 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' processes = [] - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + if not request.is_valid(): + return self.get_format_exception(request) + headers = request.get_response_headers() processes_config = filter_dict_by_key_value(self.config['resources'], 'type', 'process') @@ -1752,7 +2084,7 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' if process not in processes_config.keys() or not processes_config: msg = 'Identifier not found' return self.get_exception( - 404, headers_, format_, 'NoSuchProcess', msg) + 404, headers, request.format, 'NoSuchProcess', msg) if processes_config: if process is not None: @@ -1764,7 +2096,8 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' p = load_plugin('process', processes_config[key]['processor']) - p2 = deepcopy(p.metadata) + p2 = l10n.translate_struct(deepcopy(p.metadata), + request.locale) p2['jobControlOptions'] = ['sync-execute'] if self.manager.is_async: @@ -1776,21 +2109,22 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' jobs_url = '{}/processes/{}/jobs'.format( self.config['server']['url'], key) + # TODO translation support link = { - 'type': 'text/html', + 'type': FORMAT_TYPES[F_HTML], 'rel': 'collection', - 'href': '{}?f=html'.format(jobs_url), + 'href': '{}?f={}'.format(jobs_url, F_HTML), 'title': 'jobs for this process as HTML', - 'hreflang': self.config['server'].get('language', None) + 'hreflang': self.default_locale } p2['links'].append(link) link = { - 'type': 'application/json', + 'type': FORMAT_TYPES[F_JSON], 'rel': 'collection', - 'href': '{}?f=json'.format(jobs_url), + 'href': '{}?f={}'.format(jobs_url, F_JSON), 'title': 'jobs for this process as JSON', - 'hreflang': self.config['server'].get('language', None) + 'hreflang': self.default_locale } p2['links'].append(link) @@ -1803,42 +2137,35 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' 'processes': processes } - if format_ == 'html': # render - headers_['Content-Type'] = 'text/html' + if request.format == F_HTML: # render if process is not None: response = render_j2_template(self.config, 'processes/process.html', - response) + response, request.locale) else: response = render_j2_template(self.config, - 'processes/index.html', response) + 'processes/index.html', response, + request.locale) - return headers_, 200, response + return headers, 200, response - return headers_, 200, to_json(response, self.pretty_print) + return headers, 200, to_json(response, self.pretty_print) - def get_process_jobs(self, headers, args, process_id, job_id=None): + @pre_process + def get_process_jobs(self, request: Union[APIRequest, Any], process_id, job_id=None): # noqa """ Get process jobs - :param headers: dict of HTTP headers - :param args: dict of HTTP request parameters + :param request: A request object :param process_id: id of process :param job_id: id of job :returns: tuple of headers, status code, content """ - format_ = check_format(args, headers) - - headers_ = HEADERS.copy() - - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) - - response = {} + if not request.is_valid(): + return self.get_format_exception(request) + headers = request.get_response_headers(SYSTEM_LOCALE) processes = filter_dict_by_key_value( self.config['resources'], 'type', 'process') @@ -1846,7 +2173,7 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' if process_id not in processes: msg = 'identifier not found' return self.get_exception( - 404, headers_, format_, 'NoSuchProcess', msg) + 404, headers, request.format, 'NoSuchProcess', msg) p = load_plugin('process', processes[process_id]['processor']) @@ -1873,26 +2200,28 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' 'job_end_datetime': job_['job_end_datetime'] } - if JobStatus[job_['status']] in [ - JobStatus.successful, JobStatus.running, JobStatus.accepted]: + # TODO: translate + if JobStatus[job_['status']] in ( + JobStatus.successful, JobStatus.running, JobStatus.accepted): job_result_url = '{}/processes/{}/jobs/{}/results'.format( self.config['server']['url'], process_id, job_['identifier']) job2['links'] = [{ - 'href': '{}?f=html'.format(job_result_url), + 'href': '{}?f={}'.format(job_result_url, F_HTML), 'rel': 'about', - 'type': 'text/html', + 'type': FORMAT_TYPES[F_HTML], 'title': 'results of job {} as HTML'.format(job_id) }, { - 'href': '{}?f=json'.format(job_result_url), + 'href': '{}?f={}'.format(job_result_url, F_JSON), 'rel': 'about', - 'type': 'application/json', + 'type': FORMAT_TYPES[F_JSON], 'title': 'results of job {} as JSON'.format(job_id) }] - if job_['mimetype'] not in ['application/json', 'text/html']: + if job_['mimetype'] not in (FORMAT_TYPES[F_JSON], + FORMAT_TYPES[F_HTML]): job2['links'].append({ 'href': job_result_url, 'rel': 'about', @@ -1909,43 +2238,38 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' serialized_jobs = serialized_jobs[0] j2_template = 'processes/jobs/job.html' - if format_ == 'html': - headers_['Content-Type'] = 'text/html' + if request.format == F_HTML: data = { 'process': { 'id': process_id, - 'title': p.metadata['title'] + 'title': l10n.translate(p.metadata['title'], + SYSTEM_LOCALE) }, 'jobs': serialized_jobs, 'now': datetime.now(timezone.utc).strftime(DATETIME_FORMAT) } - response = render_j2_template(self.config, j2_template, data) - return headers_, 200, response + response = render_j2_template(self.config, j2_template, data, + SYSTEM_LOCALE) + return headers, 200, response - return headers_, 200, to_json(serialized_jobs, self.pretty_print) + return headers, 200, to_json(serialized_jobs, self.pretty_print) - def execute_process(self, headers, args, data, process_id): + @pre_process + def execute_process(self, request: Union[APIRequest, Any], process_id): """ Execute process - :param headers: dict of HTTP headers - :param args: dict of HTTP request parameters - :param data: process data + :param request: A request object :param process_id: id of process :returns: tuple of headers, status code, content """ - format_ = check_format(args, headers) + if not request.is_valid(): + return self.get_format_exception(request) - headers_ = HEADERS.copy() - - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) - - response = {} + # Responses are always in US English only + headers = request.get_response_headers(SYSTEM_LOCALE) processes_config = filter_dict_by_key_value( self.config['resources'], 'type', 'process' @@ -1953,22 +2277,23 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' if process_id not in processes_config: msg = 'identifier not found' return self.get_exception( - 404, headers_, format_, 'NoSuchProcess', msg) + 404, headers, request.format, 'NoSuchProcess', msg) if not self.manager: msg = 'Process manager is undefined' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) process = load_plugin('process', processes_config[process_id]['processor']) + data = request.data if not data: - # TODO not all processes require input, e.g. time-depenendent or - # random value generators + # TODO not all processes require input, e.g. time-dependent or + # random value generators msg = 'missing request data' return self.get_exception( - 400, headers_, format_, 'MissingParameterValue', msg) + 400, headers, request.format, 'MissingParameterValue', msg) try: # Parse bytes data, if applicable @@ -1984,24 +2309,24 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' LOGGER.error(err) msg = 'invalid request data' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) try: data_dict = {} - for input in data.get('inputs', []): - id = input['id'] - value = input['value'] - if id not in data_dict: - data_dict[id] = value - elif id in data_dict and isinstance(data_dict[id], list): - data_dict[id].append(value) + for input_ in data.get('inputs', []): + id_ = input_['id'] + value = input_['value'] + if id_ not in data_dict: + data_dict[id_] = value + elif id_ in data_dict and isinstance(data_dict[id_], list): + data_dict[id_].append(value) else: - data_dict[id] = [data_dict[id], value] + data_dict[id_] = [data_dict[id_], value] except KeyError: # Return 4XX client error for missing 'id' or 'value' in an input msg = 'invalid request data' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) else: LOGGER.debug(data_dict) @@ -2009,11 +2334,9 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' url = '{}/processes/{}/jobs/{}'.format( self.config['server']['url'], process_id, job_id) - headers_['Location'] = url + headers['Location'] = url - outputs = status = None is_async = data.get('mode', 'auto') == 'async' - if is_async: LOGGER.debug('Asynchronous request mode detected') @@ -2025,14 +2348,15 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' LOGGER.error(err) msg = 'Processing error' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) + response = {} if status == JobStatus.failed: response = outputs if data.get('response', 'document') == 'raw': - headers_['Content-Type'] = mime_type - if 'json' in mime_type: + headers['Content-Type'] = mime_type + if F_JSON in mime_type: response = to_json(outputs) else: response = outputs @@ -2045,29 +2369,31 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' else: http_status = 200 - return headers_, http_status, to_json(response, self.pretty_print) + return headers, http_status, to_json(response, self.pretty_print) - def get_process_job_result(self, headers, args, process_id, job_id): + @pre_process + def get_process_job_result(self, request: Union[APIRequest, Any], process_id, job_id): # noqa """ Get result of job (instance of a process) - :param headers: dict of HTTP headers - :param args: dict of HTTP request parameters + :param request: A request object :param process_id: name of process :param job_id: ID of job :returns: tuple of headers, status code, content """ - headers_ = HEADERS.copy() - format_ = check_format(args, headers) + if not request.is_valid(): + return self.get_format_exception(request) + headers = request.get_response_headers(SYSTEM_LOCALE) + processes_config = filter_dict_by_key_value(self.config['resources'], 'type', 'process') if process_id not in processes_config: msg = 'identifier not found' return self.get_exception( - 404, headers_, format_, 'NoSuchProcess', msg) + 404, headers, request.format, 'NoSuchProcess', msg) process = load_plugin('process', processes_config[process_id]['processor']) @@ -2075,54 +2401,58 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' if not process: msg = 'identifier not found' return self.get_exception( - 404, headers_, format_, 'NoSuchProcess', msg) + 404, headers, request.format, 'NoSuchProcess', msg) job = self.manager.get_job(process_id, job_id) if not job: msg = 'job not found' - return self.get_exception(404, headers_, format_, 'NoSuchJob', msg) + return self.get_exception(404, headers, request.format, + 'NoSuchJob', msg) status = JobStatus[job['status']] if status == JobStatus.running: msg = 'job still running' return self.get_exception( - 404, headers_, format_, 'ResultNotReady', msg) + 404, headers, request.format, 'ResultNotReady', msg) elif status == JobStatus.accepted: # NOTE: this case is not mentioned in the specification msg = 'job accepted but not yet running' return self.get_exception( - 404, headers_, format_, 'ResultNotReady', msg) + 404, headers, request.format, 'ResultNotReady', msg) elif status == JobStatus.failed: msg = 'job failed' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) mimetype, job_output = self.manager.get_job_result(process_id, job_id) - if mimetype not in [None, 'application/json']: - headers_['Content-Type'] = mimetype + if mimetype not in (None, FORMAT_TYPES[F_JSON]): + headers['Content-Type'] = mimetype content = job_output else: - if format_ == 'json': + if request.format == F_JSON: content = json.dumps(job_output, sort_keys=True, indent=4, default=json_serial) else: - headers_['Content-Type'] = 'text/html' + # HTML data = { 'process': { - 'id': process_id, 'title': process.metadata['title'] + 'id': process_id, + 'title': l10n.translate(process.metadata['title'], + SYSTEM_LOCALE) }, 'job': {'id': job_id}, 'result': job_output } - content = render_j2_template( - self.config, 'processes/jobs/results/index.html', data) + content = render_j2_template(self.config, + 'processes/jobs/results/index.html', # noqa + data, SYSTEM_LOCALE) - return headers_, 200, content + return headers, 200, content def delete_process_job(self, process_id, job_id): """ @@ -2153,81 +2483,73 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' 'links': [{ 'href': jobs_url, 'rel': 'up', - 'type': 'application/json', + 'type': FORMAT_TYPES[F_JSON], 'title': 'The job list for the current process' }] } LOGGER.info(response) + # TODO: this response does not have any headers return {}, http_status, response - def get_collection_edr_query(self, headers, args, dataset, instance, - query_type): + @pre_process + def get_collection_edr_query(self, request: Union[APIRequest, Any], + dataset, instance, query_type): """ Queries collection EDR - :param headers: dict of HTTP headers - :param args: dict of HTTP request parameters + :param request: APIRequest instance with query params :param dataset: dataset name - :param dataset: instance name + :param instance: instance name :param query_type: EDR query type :returns: tuple of headers, status code, content """ - headers_ = HEADERS.copy() - - query_args = {} - formats = FORMATS - formats.extend(f.lower() for f in PLUGINS['formatter'].keys()) + if not request.is_valid(PLUGINS['formatter'].keys()): + return self.get_format_exception(request) + headers = request.get_response_headers(self.default_locale) collections = filter_dict_by_key_value(self.config['resources'], 'type', 'collection') - format_ = check_format(args, headers) - if dataset not in collections.keys(): msg = 'Invalid collection' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) - - if format_ is not None and format_ not in formats: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Processing query parameters') LOGGER.debug('Processing datetime parameter') - datetime_ = args.get('datetime') + datetime_ = request.params.get('datetime') try: datetime_ = validate_datetime(collections[dataset]['extents'], datetime_) except ValueError as err: msg = str(err) return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Processing parameter-name parameter') - parameternames = args.get('parameter-name', []) - if parameternames: + parameternames = request.params.get('parameter-name') or [] + if isinstance(parameternames, str): parameternames = parameternames.split(',') LOGGER.debug('Processing coords parameter') - wkt = args.get('coords', None) + wkt = request.params.get('coords', None) - if wkt is None: + if not wkt: msg = 'missing coords parameter' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) try: wkt = shapely_loads(wkt) except WKTReadingError: msg = 'invalid coords parameter' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) LOGGER.debug('Processing z parameter') - z = args.get('z') + z = request.params.get('z') LOGGER.debug('Loading provider') try: @@ -2236,41 +2558,36 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' except ProviderTypeError: msg = 'invalid provider type' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) except ProviderConnectionError: msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) except ProviderQueryError: msg = 'query error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) if instance is not None and not p.get_instance(instance): msg = 'Invalid instance identifier' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) if query_type not in p.get_query_types(): msg = 'Unsupported query type' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) - parametername_matches = list( - filter( - lambda p: p['id'] in parameternames, p.get_fields()['field'] - ) - ) - - if len(parametername_matches) < len(parameternames): + if parameternames and not any((fld['id'] in parameternames) + for fld in p.get_fields()['field']): msg = 'Invalid parameter-name' return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + 400, headers, request.format, 'InvalidParameterValue', msg) query_args = dict( query_type=query_type, instance=instance, - format_=format_, + format_=request.format, datetime_=datetime_, select_properties=parameternames, wkt=wkt, @@ -2282,29 +2599,28 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' except ProviderNoDataError: msg = 'No data found' return self.get_exception( - 204, headers_, format_, 'NoMatch', msg) + 204, headers, request.format, 'NoMatch', msg) except ProviderQueryError: msg = 'query error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) - if format_ == 'html': # render - headers_['Content-Type'] = 'text/html' - content = render_j2_template( - self.config, 'collections/edr/query.html', data) + if request.format == F_HTML: # render + content = render_j2_template(self.config, + 'collections/edr/query.html', data, + self.default_locale) else: content = to_json(data, self.pretty_print) - return headers_, 200, content + return headers, 200, content @pre_process @jsonldify - def get_stac_root(self, headers_, format_): + def get_stac_root(self, request: Union[APIRequest, Any]): - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + if not request.is_valid(): + return self.get_format_exception(request) + headers = request.get_response_headers() id_ = 'pygeoapi-stac' stac_version = '1.0.0-rc.2' @@ -2314,10 +2630,13 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' 'id': id_, 'type': 'Catalog', 'stac_version': stac_version, - 'title': self.config['metadata']['identification']['title'], - 'description': self.config['metadata']['identification']['description'], # noqa - 'links': [], - + 'title': l10n.translate( + self.config['metadata']['identification']['title'], + request.locale), + 'description': l10n.translate( + self.config['metadata']['identification']['description'], + request.locale), + 'links': [] } stac_collections = filter_dict_by_key_value(self.config['resources'], @@ -2326,32 +2645,31 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' for key, value in stac_collections.items(): content['links'].append({ 'rel': 'child', - 'href': '{}/{}?f=json'.format(stac_url, key), - 'type': 'application/json' + 'href': '{}/{}?f={}'.format(stac_url, key, F_JSON), + 'type': FORMAT_TYPES[F_JSON] }) content['links'].append({ 'rel': 'child', 'href': '{}/{}'.format(stac_url, key), - 'type': 'text/html' + 'type': FORMAT_TYPES[F_HTML] }) - if format_ == 'html': # render - headers_['Content-Type'] = 'text/html' + if request.format == F_HTML: # render content = render_j2_template(self.config, 'stac/collection.html', - content) - return headers_, 200, content + content, request.locale) + return headers, 200, content - return headers_, 200, to_json(content, self.pretty_print) + return headers, 200, to_json(content, self.pretty_print) @pre_process @jsonldify - def get_stac_path(self, headers_, format_, path): + def get_stac_path(self, request: Union[APIRequest, Any], path): - if format_ is not None and format_ not in FORMATS: - msg = 'Invalid format' - return self.get_exception( - 400, headers_, format_, 'InvalidParameterValue', msg) + if not request.is_valid(): + return self.get_format_exception(request) + headers = request.get_response_headers() + dataset = None LOGGER.debug('Path: {}'.format(path)) dir_tokens = path.split('/') if dir_tokens: @@ -2362,7 +2680,8 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' if dataset not in stac_collections: msg = 'collection not found' - return self.get_exception(404, headers_, format_, 'NotFound', msg) + return self.get_exception(404, headers, request.format, + 'NotFound', msg) LOGGER.debug('Loading provider') try: @@ -2372,17 +2691,17 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' LOGGER.error(err) msg = 'connection error (check logs)' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) id_ = '{}-stac'.format(dataset) stac_version = '1.0.0-rc.2' - description = stac_collections[dataset]['description'] content = { 'id': id_, 'type': 'Catalog', 'stac_version': stac_version, - 'description': description, + 'description': l10n.translate( + stac_collections[dataset]['description'], request.locale), 'links': [] } try: @@ -2394,36 +2713,36 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' except ProviderNotFoundError as err: LOGGER.error(err) msg = 'resource not found' - return self.get_exception(404, headers_, format_, 'NotFound', msg) + return self.get_exception(404, headers, request.format, + 'NotFound', msg) except Exception as err: LOGGER.error(err) msg = 'data query error' return self.get_exception( - 500, headers_, format_, 'NoApplicableCode', msg) + 500, headers, request.format, 'NoApplicableCode', msg) if isinstance(stac_data, dict): content.update(stac_data) content['links'].extend(stac_collections[dataset]['links']) - if format_ == 'html': # render - headers_['Content-Type'] = 'text/html' + if request.format == F_HTML: # render content['path'] = path if 'assets' in content: # item view content = render_j2_template(self.config, 'stac/item.html', - content) + content, request.locale) else: content = render_j2_template(self.config, 'stac/catalog.html', - content) + content, request.locale) - return headers_, 200, content + return headers, 200, content - return headers_, 200, to_json(content, self.pretty_print) + return headers, 200, to_json(content, self.pretty_print) else: # send back file - headers_.pop('Content-Type', None) - return headers_, 200, stac_data + headers.pop('Content-Type', None) + return headers, 200, stac_data def get_exception(self, status, headers, format_, code, description): """ @@ -2444,61 +2763,34 @@ tiles/{{{}}}/{{{}}}/{{{}}}/{{{}}}?f=mvt' 'description': description } - if format_ == 'json': - content = to_json(exception, self.pretty_print) - elif format_ == 'html': - headers['Content-Type'] = 'text/html' + if format_ == F_HTML: + headers['Content-Type'] = FORMAT_TYPES[F_HTML] content = render_j2_template( - self.config, 'exception.html', exception) + self.config, 'exception.html', exception, SYSTEM_LOCALE) else: content = to_json(exception, self.pretty_print) return headers, status, content + def get_format_exception(self, request): + """ Returns a format exception. -def check_format(args, headers): - """ - check format requested from arguments or headers + :param request: An APIRequest instance. - :param args: dict of request keyword value pairs - :param headers: dict of request headers - - :returns: format value - """ - - # Optional f=html or f=json query param - # overrides accept - format_ = args.get('f') - if format_: - return format_ - - # Format not specified: get from accept headers - # format_ = 'text/html' - headers_ = None - if 'accept' in headers.keys(): - headers_ = headers['accept'] - elif 'Accept' in headers.keys(): - headers_ = headers['Accept'] - - format_ = None - if headers_: - headers_ = headers_.split(',') - - if 'text/html' in headers_: - format_ = 'html' - elif 'application/ld+json' in headers_: - format_ = 'jsonld' - elif 'application/json' in headers_: - format_ = 'json' - - return format_ + :returns: tuple of (headers, status, message) + """ + # Content-Language is in the system locale (ignore language settings) + headers = request.get_response_headers(SYSTEM_LOCALE) + msg = f'Invalid format: {request.format}' + return self.get_exception( + 400, headers, F_JSON, 'InvalidParameterValue', msg) def validate_bbox(value=None): """ Helper function to validate bbox parameter - :param bbox: `list` of minx, miny, maxx, maxy + :param value: `list` of minx, miny, maxx, maxy :returns: bbox as `list` of `float` values """ @@ -2549,7 +2841,7 @@ def validate_datetime(resource_def, datetime_=None): datetime_invalid = False - if (datetime_ is not None and 'temporal' in resource_def): + if datetime_ is not None and 'temporal' in resource_def: dateparse_begin = partial(dateparse, default=datetime.min) dateparse_end = partial(dateparse, default=datetime.max) diff --git a/pygeoapi/flask_app.py b/pygeoapi/flask_app.py index 311bb71..9e052d3 100644 --- a/pygeoapi/flask_app.py +++ b/pygeoapi/flask_app.py @@ -96,6 +96,20 @@ if (OGC_SCHEMAS_LOCATION is not None and mimetype=get_mimetype(basename_)) +def get_response(result: tuple): + """ Creates a Flask Response object and updates matching headers. + + :param result: The result of the API call. + This should be a tuple of (headers, status, content). + :returns: A Response instance. + """ + headers, status, content = result + response = make_response(content, status) + if headers: + response.headers = headers + return response + + @BLUEPRINT.route('/') def landing_page(): """ @@ -103,15 +117,7 @@ def landing_page(): :returns: HTTP response """ - headers, status_code, content = api_.landing_page( - request.headers, request.args) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.landing_page(request)) @BLUEPRINT.route('/openapi') @@ -121,22 +127,13 @@ def openapi(): :returns: HTTP response """ - with open(os.environ.get('PYGEOAPI_OPENAPI'), encoding='utf8') as ff: if os.environ.get('PYGEOAPI_OPENAPI').endswith(('.yaml', '.yml')): - openapi = yaml_load(ff) + openapi_ = yaml_load(ff) else: # JSON file, do not transform - openapi = ff + openapi_ = ff - headers, status_code, content = api_.openapi( - request.headers, request.args, openapi) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.openapi(request, openapi_)) @BLUEPRINT.route('/conformance') @@ -146,16 +143,7 @@ def conformance(): :returns: HTTP response """ - - headers, status_code, content = api_.conformance(request.headers, - request.args) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.conformance(request)) @BLUEPRINT.route('/collections') @@ -168,16 +156,7 @@ def collections(collection_id=None): :returns: HTTP response """ - - headers, status_code, content = api_.describe_collections( - request.headers, request.args, collection_id) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.describe_collections(request, collection_id)) @BLUEPRINT.route('/collections//queryables') @@ -189,16 +168,7 @@ def collection_queryables(collection_id=None): :returns: HTTP response """ - - headers, status_code, content = api_.get_collection_queryables( - request.headers, request.args, collection_id) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_collection_queryables(request, collection_id)) @BLUEPRINT.route('/collections//items') @@ -212,20 +182,10 @@ def collection_items(collection_id, item_id=None): :returns: HTTP response """ - if item_id is None: - headers, status_code, content = api_.get_collection_items( - request.headers, request.args, collection_id) - else: - headers, status_code, content = api_.get_collection_item( - request.headers, request.args, collection_id, item_id) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_collection_items(request, collection_id)) + return get_response( + api_.get_collection_item(request, collection_id, item_id)) @BLUEPRINT.route('/collections//coverage') @@ -237,16 +197,7 @@ def collection_coverage(collection_id): :returns: HTTP response """ - - headers, status_code, content = api_.get_collection_coverage( - request.headers, request.args, collection_id) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_collection_coverage(request, collection_id)) @BLUEPRINT.route('/collections//coverage/domainset') @@ -258,16 +209,8 @@ def collection_coverage_domainset(collection_id): :returns: HTTP response """ - - headers, status_code, content = api_.get_collection_coverage_domainset( - request.headers, request.args, collection_id) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_collection_coverage_domainset( + request, collection_id)) @BLUEPRINT.route('/collections//coverage/rangetype') @@ -279,16 +222,8 @@ def collection_coverage_rangetype(collection_id): :returns: HTTP response """ - - headers, status_code, content = api_.get_collection_coverage_rangetype( - request.headers, request.args, collection_id) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_collection_coverage_rangetype( + request, collection_id)) @BLUEPRINT.route('/collections//tiles') @@ -300,16 +235,8 @@ def get_collection_tiles(collection_id=None): :returns: HTTP response """ - - headers, status_code, content = api_.get_collection_tiles( - request.headers, request.args, collection_id) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_collection_tiles( + request, collection_id)) @BLUEPRINT.route('/collections//tiles//metadata') # noqa @@ -322,16 +249,8 @@ def get_collection_tiles_metadata(collection_id=None, tileMatrixSetId=None): :returns: HTTP response """ - - headers, status_code, content = api_.get_collection_tiles_metadata( - request.headers, request.args, collection_id, tileMatrixSetId) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_collection_tiles_metadata( + request, collection_id, tileMatrixSetId)) @BLUEPRINT.route('/collections//tiles/\ @@ -349,17 +268,8 @@ def get_collection_tiles_data(collection_id=None, tileMatrixSetId=None, :returns: HTTP response """ - - headers, status_code, content = api_.get_collection_tiles_data( - request.headers, request.args, collection_id, - tileMatrixSetId, tileMatrix, tileRow, tileCol) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_collection_tiles_data( + request, collection_id, tileMatrixSetId, tileMatrix, tileRow, tileCol)) @BLUEPRINT.route('/processes') @@ -372,15 +282,7 @@ def get_processes(process_id=None): :returns: HTTP response """ - headers, status_code, content = api_.describe_processes( - request.headers, request.args, process_id) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.describe_processes(request, process_id)) @BLUEPRINT.route('/processes//jobs', methods=['GET', 'POST']) @@ -395,28 +297,17 @@ def get_process_jobs(process_id=None, job_id=None): :returns: HTTP response """ - if job_id is None: if request.method == 'GET': # list jobs - headers, status_code, content = api_.get_process_jobs( - request.headers, request.args, process_id) + return get_response(api_.get_process_jobs(request, process_id)) elif request.method == 'POST': # submit job - headers, status_code, content = api_.execute_process( - request.headers, request.args, request.data, process_id) + return get_response(api_.execute_process(request, process_id)) else: if request.method == 'DELETE': # dismiss job - headers, status_code, content = api_.delete_process_job( - process_id, job_id) + return get_response(api_.delete_process_job(process_id, job_id)) else: # Return status of a specific job - headers, status_code, content = api_.get_process_jobs( - request.headers, request.args, process_id, job_id) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_process_jobs( + request, process_id, job_id)) @BLUEPRINT.route('/processes//jobs//results', @@ -430,16 +321,8 @@ def get_process_job_result(process_id=None, job_id=None): :returns: HTTP response """ - - headers, status_code, content = api_.get_process_job_result( - request.headers, request.args, process_id, job_id) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_process_job_result( + request, process_id, job_id)) @BLUEPRINT.route('/processes//jobs//results/', @@ -454,16 +337,8 @@ def get_process_job_result_resource(process_id, job_id, resource): :returns: HTTP response """ - - headers, status_code, content = api_.get_process_job_result_resource( - request.headers, request.args, process_id, job_id, resource) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_process_job_result_resource( + request, process_id, job_id, resource)) @BLUEPRINT.route('/collections//position') @@ -485,18 +360,9 @@ def get_collection_edr_query(collection_id, instance_id=None): :returns: HTTP response """ - query_type = request.path.split('/')[-1] - - headers, status_code, content = api_.get_collection_edr_query( - request.headers, request.args, collection_id, instance_id, query_type) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_collection_edr_query(request, collection_id, + instance_id, query_type)) @BLUEPRINT.route('/stac') @@ -506,16 +372,7 @@ def stac_catalog_root(): :returns: HTTP response """ - - headers, status_code, content = api_.get_stac_root( - request.headers, request.args) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_stac_root(request)) @BLUEPRINT.route('/stac/') @@ -527,16 +384,7 @@ def stac_catalog_path(path): :returns: HTTP response """ - - headers, status_code, content = api_.get_stac_path( - request.headers, request.args, path) - - response = make_response(content, status_code) - - if headers: - response.headers = headers - - return response + return get_response(api_.get_stac_path(request, path)) APP.register_blueprint(BLUEPRINT) diff --git a/pygeoapi/l10n.py b/pygeoapi/l10n.py new file mode 100644 index 0000000..2275912 --- /dev/null +++ b/pygeoapi/l10n.py @@ -0,0 +1,479 @@ +# ================================================================= +# +# Authors: Sander Schaminee +# +# Copyright (c) 2021 GeoCat BV +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import logging +from typing import Union +from collections import OrderedDict +from copy import deepcopy + +from babel import Locale +from babel import UnknownLocaleError as _UnknownLocaleError +from urllib import parse + +LOGGER = logging.getLogger(__name__) + +# Specifies the name of a request query parameter used to set a locale +QUERY_PARAM = 'lang' + +# Cache Babel Locale lookups by string +_lc_cache = {} + +# Cache translated configurations +_cfg_cache = {} + + +class LocaleError(Exception): + """ General exception for any kind of locale parsing error. """ + pass + + +def str2locale(value, silent: bool = False) -> Union[Locale, None]: + """ Converts a web locale or language tag into a Babel Locale instance. + + .. note:: If `value` already is a Locale, it is returned as-is. + + :param value: A string containing a (web) locale (e.g. 'fr-CH') + or language tag (e.g. 'de'). + :param silent: If True (default = False), no errors will be raised + when parsing failed. Instead, `None` will be returned. + :returns: babel.core.Locale or None + :raises: LocaleError + """ + if isinstance(value, Locale): + return value + + loc = _lc_cache.get(value) + if loc: + # Value has been converted before: return cached Locale + return loc + + try: + loc = Locale.parse(value.strip().replace('-', '_')) + except (ValueError, AttributeError): + if not silent: + raise LocaleError(f"invalid locale '{value}'") + except _UnknownLocaleError as err: + if not silent: + raise LocaleError(err) + else: + # Add to Locale cache + _lc_cache[value] = loc + + return loc + + +def locale2str(value: Locale) -> str: + """ Converts a Babel Locale instance into a web locale string. + + :param value: babel.core.Locale + :returns: A string containing a web locale (e.g. 'fr-CH') + or language tag (e.g. 'de'). + :raises: LocaleError + """ + if not isinstance(value, Locale): + raise LocaleError(f"'{value}' is not of type {Locale.__name__}") + return str(value).replace('_', '-') + + +def best_match(accept_languages, available_locales) -> Locale: + """ Takes an Accept-Languages string (from header or request query params) + and finds the best matching locale from a list of available locales. + + This function provides a framework-independent alternative to the + `best_match()` function available in Flask/Werkzeug. + + If no match can be found for the Accept-Languages, + the first available locale is returned. + + This function always returns a Babel Locale instance. If you require the + web locale string, please use the :func:`locale2str` function. + If you only ever need the language part of the locale, use the `language` + property of the returned locale. + + .. note:: Any tag in the `accept_languages` string that is an invalid + or unknown locale is ignored. However, if no + `available_locales` are specified, a `LocaleError` is raised. + + :param accept_languages: A Locale or string with one or more languages. + This can be as simple as "de" for example, + but it's also possible to include a territory + (e.g. "en-US" or "fr_BE") or even a complex + string with quality values, e.g. + "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5". + :param available_locales: A list containing the available locales. + For example, a pygeoapi provider might only + support ["de", "en"]. + Locales in the list can be specified as strings + (e.g. "nl-NL") or `Locale` instances. + :returns: babel.core.Locale + :raises: LocaleError + """ + + def get_match(locale_, available_locales_): + """ Finds the first match of `locale_` in `available_locales_`. """ + if not locale_: + return None + territories_ = available_locales_.get(locale_.language, {}) + if locale_.territory in territories_: + # Full match on language and territory + return locale_ + if None in territories_: + # Match on language only (generic, no territory) + return Locale(locale_.language) + if territories_: + # Match on language but another territory (use first) + return Locale(locale_.language, territory=territories_[0]) + # No match at all + return None + + if not available_locales: + raise LocaleError('No available locales specified') + + if isinstance(accept_languages, Locale): + # If a Babel Locale was used as input, transform back into a string + accept_languages = locale2str(accept_languages) + if not isinstance(accept_languages, str): + # If `accept_languages` is not a string, ignore it + LOGGER.debug(f"ignoring invalid accept-languages '{accept_languages}'") + accept_languages = '' + + tags = accept_languages.split(',') + num_tags = len(tags) + req_locales = {} + for i, lang in enumerate(tags): + q_raw = None + q_out = None + if not lang: + continue + + # Check if complex (i.e. with quality weights) + try: + lang, q_raw = (v.strip() for v in lang.split(';')) + except ValueError: + # Tuple unpacking failed: tag is not complex (or too complex :)) + pass + + # Validate locale tag + loc = str2locale(lang, True) + if not loc: + LOGGER.debug(f"ignoring invalid accept-language '{lang}'") + continue + + # Validate quality weight (e.g. "q=0.7") + if q_raw: + try: + q_out = float([v.strip() for v in q_raw.split('=')][1]) + except (ValueError, IndexError): + # Tuple unpacking failed: not a valid q tag + pass + + # If there's no actual q, set one based on the language order + if not q_out: + q_out = num_tags - i + + # Store locale + req_locales[q_out] = loc + + # Process supported locales + prv_locales = OrderedDict() + for a in available_locales: + loc = str2locale(a) + prv_locales.setdefault(loc.language, []).append(loc.territory) + + # Return best match from accepted languages + for _, loc in sorted(req_locales.items(), reverse=True): + match = get_match(loc, prv_locales) + if match: + LOGGER.debug(f"'{match}' matches requested '{accept_languages}'") + return match + + # Nothing matched: return the first available locale + for lang, territories in prv_locales.items(): + match = Locale(lang, territory=territories[0]) + LOGGER.debug(f"No match found for language '{accept_languages}'; " + f"returning default locale '{match}'") + return match + + +def translate(value, language: Union[Locale, str]): + """ + If `value` is a language struct (where its keys are language codes + and its values are translations for each language), this function tries to + find and return the translation for the given `language`. + + If the given `value` is not a dict, the original value is returned. + If the requested language does not exist in the struct, + the first language value is returned. If there are no valid language keys + in the struct, the original value is returned as well. + + If `language` is not a string or Locale, a LocaleError is raised. + + :param value: A value to translate. Typically either a string or + a language struct dictionary. + :param language: A locale string (e.g. "en-US" or "en") or Babel Locale. + :returns: A translated string or the original value. + :raises: LocaleError + """ + nested_dicts = isinstance(value, dict) and any(isinstance(v, dict) + for v in value.values()) + if not isinstance(value, dict) or nested_dicts: + # Return non-dicts or dicts with nested dicts as-is + return value + + # Validate language key by type (do not check if parsable) + if not isinstance(language, (str, Locale)): + raise LocaleError('language is not a str or Locale') + + # First try fast approach: directly fetch expected language key + translation = value.get(locale2str(language) + if hasattr(language, 'language') else language) + if translation: + return translation + + # Find valid locale keys in language struct + # Also maps Locale instances to actual key names + loc_items = OrderedDict() + for k in value.keys(): + loc = str2locale(k, True) + if loc: + loc_items[loc] = k + + if not loc_items: + # No valid locale keys found: return as-is + return value + + # Find best language match and return value by its key + out_locale = best_match(language, loc_items) + return value[loc_items[out_locale]] + + +def translate_struct(struct, locale_: Locale, is_config: bool = False): + """ Returns a copy of a given dict or list, where all language structs + are filtered on the given locale, i.e. all language structs are replaced + by translated values for the best matching locale. + + :param struct: A dict or list (of dicts) to filter/translate. + :param locale_: The Babel Locale to filter on. + :param is_config: If True, the struct is treated as a pygeoapi config. + This means that the first 2 levels won't be translated + and the translated struct is cached for speed. + :returns: A translated dict or list + """ + + def _translate_dict(obj, level: int = 0): + """ Recursive function to walk and translate a struct. """ + items = obj.items() if isinstance(obj, dict) else enumerate(obj) + for k, v in items: + if 0 <= level <= max_level and isinstance(v, (dict, list)): + # Skip first 2 levels (don't translate) + _translate_dict(v, level + 1) + continue + if isinstance(v, list): + _translate_dict(v, level + 1) # noqa + continue + tr = translate(v, locale_) + if isinstance(tr, dict): + # Look for language structs in next level + _translate_dict(tr, level + 1) + else: + # Overwrite level with translated value + obj[k] = tr + + max_level = 1 if is_config else -1 + result = {} + if not struct: + return result + if not locale_: + return struct + + # Check if we already translated the dict before + result = _cfg_cache.get(locale_) if is_config else result + if not result: + # Create deep copy of config and translate/filter values + result = deepcopy(struct) + _translate_dict(result) + + # Cache translated pygeoapi configs for faster retrieval next time + if is_config: + _cfg_cache[locale_] = result + + return result + + +def locale_from_headers(headers) -> str: + """ + Gets a valid Locale from a request headers dictionary. + Supported are complex strings (e.g. "fr-CH, fr;q=0.9, en;q=0.8"), + web locales (e.g. "en-US") or basic language tags (e.g. "en"). + A value of `None` is returned if the locale was not found or invalid. + + :param headers: Mapping of request headers. + + :returns: locale string or None + """ + lang = {k.lower(): v for k, v in headers.items()}.get('accept-language') + if lang: + LOGGER.debug(f"Got locale '{lang}' from 'Accept-Language' header") + return lang + + +def locale_from_params(params) -> str: + """ + Gets a valid Locale from a request query parameters dictionary. + Supported are complex strings (e.g. "fr-CH, fr;q=0.9, en;q=0.8"), + web locales (e.g. "en-US") or basic language tags (e.g. "en"). + A value of `None` is returned if the locale was not found or invalid. + + :param params: Mapping of request query parameters. + + :returns: locale string or None + """ + lang = params.get(QUERY_PARAM) + if lang: + LOGGER.debug(f"Got locale '{lang}' from query parameter '{QUERY_PARAM}'") # noqa + return lang + + +def set_response_language(headers: dict, *locale_: Locale): + """ Sets the Content-Language on the given HTTP response headers dict. + + :param headers: A dict of HTTP response headers. + :param locale_: The Babel Locale(s) to which to set the + Content-Language header. + Multiple locales can be set for this header. + Note that duplicates will be removed. + :raises: LocaleError if no valid Babel Locale was found. + """ + if not hasattr(headers, '__setitem__'): + LOGGER.warning(f"Cannot set headers on object '{headers}'") + return + + locales = [] + for loc in locale_: + try: + loc_str = locale2str(loc) + except LocaleError: + if len(locale_) == 1: + raise + else: + if loc_str not in locales: + locales.append(loc_str) + + if not locales: + raise LocaleError('no valid locales set') + loc_str = ', '.join(locales) + LOGGER.debug(f'Setting Content-Language to {loc_str}') + headers['Content-Language'] = loc_str + + +def add_locale(url, locale_): + """ Adds a locale query parameter (e.g. 'lang=en-US') to a URL. + If `locale_` is None or an empty string, the URL will be returned as-is. + + :param url: The web page URL (may contain query string). + :param locale_: The web locale or language tag to append to the query. + :returns: A new URL with a 'lang=' query parameter. + :raises: requests.exceptions.MissingSchema + """ + loc = str2locale(locale_, True) + if not loc: + # Validation of locale failed + LOGGER.warning( + f"Invalid locale '{locale_}': returning URL as-is") + return url + + try: + url_comp = parse.urlparse(url) + params = dict(parse.parse_qsl(url_comp.query)) + params[QUERY_PARAM] = locale2str(loc) + qstr = parse.urlencode(params, quote_via=parse.quote, safe='/') + return parse.urlunparse(( + url_comp.scheme, + url_comp.netloc, + url_comp.path, + url_comp.params, + qstr, + url_comp.fragment + )) + except (TypeError, ValueError): + LOGGER.warning( + f"Failed to append '{QUERY_PARAM}={loc}': returning URL as-is") # noqa + return url + + +def get_locales(config: dict) -> list: + """ Reads the configured locales/languages from the given configuration. + The first Locale in the returned list should be the default locale. + + :param config: A pygeaapi configuration dict + :returns: A list of supported Locale instances + """ + srv_cfg = config.get('server', {}) + lang = srv_cfg.get('languages', srv_cfg.get('language', [])) + + if isinstance(lang, str): + LOGGER.info(f"pygeoapi only supports 1 language: {lang}") + lang = [lang] + if not isinstance(lang, list) or len(lang) == 0: + LOGGER.error("Missing 'language(s)' key in config or bad value(s)") + raise LocaleError('No languages have been configured') + + try: + return [str2locale(loc) for loc in lang] + except LocaleError as err: + LOGGER.debug(err) + raise LocaleError('Bad value in supported server language(s)') + + +def get_plugin_locale(config: dict, requested_locale: Union[str, None]) -> Union[Locale, None]: # noqa + """ Returns the supported locale (best match) for a plugin + based on the requested raw locale string. + Returns None if the plugin does not support any locales. + Returns the default (= first) locale that the plugin supports + if no match for the requested locale could be found. + + :param config: The plugin definition + :param requested_locale: The requested locale string (or None) + """ + plugin_name = f"{config.get('name', '')} plugin".strip() + if not requested_locale: + LOGGER.debug(f'No requested locale for {plugin_name}') + requested_locale = '' + + LOGGER.debug(f'Requested {plugin_name} locale: {requested_locale}') + locales = config.get('languages', config.get('language', [])) + if locales: + if not isinstance(locales, list): + locales = [locales] + locale = best_match(requested_locale, locales) + LOGGER.info(f'{plugin_name} locale set to {locale}') + return locale + + LOGGER.info(f'{plugin_name} has no locale support') + return None diff --git a/pygeoapi/linked_data.py b/pygeoapi/linked_data.py index 7b717b8..65a027c 100644 --- a/pygeoapi/linked_data.py +++ b/pygeoapi/linked_data.py @@ -33,6 +33,7 @@ Returns content as linked data representations import logging from pygeoapi.util import is_url +from pygeoapi import l10n LOGGER = logging.getLogger(__name__) @@ -48,9 +49,12 @@ def jsonldify(func): """ def inner(*args, **kwargs): - format_ = args[2] + apireq = args[1] + format_ = getattr(apireq, 'format', None) if not format_ == 'jsonld': return func(*args, **kwargs) + # Function args have been pre-processed, so get locale from APIRequest + locale_ = getattr(apireq, 'locale', None) LOGGER.debug('Creating JSON-LD representation') cls = args[0] cfg = cls.config @@ -63,14 +67,17 @@ def jsonldify(func): "@type": "DataCatalog", "@id": cfg.get('server', {}).get('url', None), "url": cfg.get('server', {}).get('url', None), - "name": ident.get('title', None), - "description": ident.get('description', None), - "keywords": ident.get('keywords', None), - "termsOfService": ident.get('terms_of_service', None), + "name": l10n.translate(ident.get('title', None), locale_), + "description": l10n.translate( + ident.get('description', None), locale_), + "keywords": l10n.translate( + ident.get('keywords', None), locale_), + "termsOfService": l10n.translate( + ident.get('terms_of_service', None), locale_), "license": meta.get('license', {}).get('url', None), "provider": { "@type": "Organization", - "name": provider.get('name', None), + "name": l10n.translate(provider.get('name', None), locale_), "url": provider.get('url', None), "address": { "@type": "PostalAddress", @@ -88,10 +95,13 @@ def jsonldify(func): "url": contact.get('url', None), "hoursAvailable": { "opens": contact.get('hours', None), - "description": contact.get('instructions', None) + "description": l10n.translate( + contact.get('instructions', None), locale_) }, - "contactType": contact.get('role', None), - "description": contact.get('position', None) + "contactType": l10n.translate( + contact.get('role', None), locale_), + "description": l10n.translate( + contact.get('position', None), locale_) } } } @@ -100,13 +110,14 @@ def jsonldify(func): return inner -def jsonldify_collection(cls, collection): +def jsonldify_collection(cls, collection, locale_): """ Transforms collection into a JSON-LD representation :param cls: API object :param collection: `collection` as prepared for non-LD JSON representation + :param locale_: The locale to use for translations (if supported) :returns: `collection` a dictionary, mapped into JSON-LD, of type schema:Dataset @@ -125,10 +136,10 @@ def jsonldify_collection(cls, collection): cls.config['server']['url'], collection['id'] ), - "name": collection['title'], - "description": collection['description'], + "name": l10n.translate(collection['title'], locale_), + "description": l10n.translate(collection['description'], locale_), "license": cls.fcmld['license'], - "keywords": collection.get('keywords', None), + "keywords": l10n.translate(collection.get('keywords', None), locale_), "spatial": None if (not hascrs84 or not bbox) else [{ "@type": "Place", "geo": { @@ -148,9 +159,9 @@ def jsonldify_collection(cls, collection): "@type": "DataDownload", "contentURL": link['href'], "encodingFormat": link['type'], - "description": link['title'], + "description": l10n.translate(link['title'], locale_), "inLanguage": link.get( - 'hreflang', cls.config.get('server', {}).get('language', None) + 'hreflang', l10n.locale2str(cls.default_locale) ), "author": link['rel'] if link.get( 'rel', None diff --git a/pygeoapi/openapi.py b/pygeoapi/openapi.py index 47b0b01..abab0ee 100644 --- a/pygeoapi/openapi.py +++ b/pygeoapi/openapi.py @@ -35,6 +35,7 @@ import click import yaml from pygeoapi import __version__ +from pygeoapi import l10n from pygeoapi.plugin import load_plugin from pygeoapi.provider.base import ProviderTypeError from pygeoapi.util import (filter_dict_by_key_value, get_provider_by_type, @@ -125,6 +126,10 @@ def get_oas_30(cfg): paths = {} + # TODO: make openapi multilingual (default language only for now) + server_locales = l10n.get_locales(cfg) + locale_ = server_locales[0] + osl = get_ogc_schemas_location(cfg['server']) OPENAPI_YAML['oapif'] = os.path.join(osl, 'ogcapi/features/part1/1.0/openapi/ogcapi-features-1.yaml') # noqa @@ -134,9 +139,9 @@ def get_oas_30(cfg): 'tags': [] } info = { - 'title': cfg['metadata']['identification']['title'], - 'description': cfg['metadata']['identification']['description'], - 'x-keywords': cfg['metadata']['identification']['keywords'], + 'title': l10n.translate(cfg['metadata']['identification']['title'], locale_), # noqa + 'description': l10n.translate(cfg['metadata']['identification']['description'], locale_), # noqa + 'x-keywords': l10n.translate(cfg['metadata']['identification']['keywords'], locale_), # noqa 'termsOfService': cfg['metadata']['identification']['terms_of_service'], 'contact': { @@ -154,7 +159,7 @@ def get_oas_30(cfg): oas['servers'] = [{ 'url': cfg['server']['url'], - 'description': cfg['metadata']['identification']['description'] + 'description': l10n.translate(cfg['metadata']['identification']['description'], locale_) # noqa }] paths['/'] = { @@ -164,7 +169,8 @@ def get_oas_30(cfg): 'tags': ['server'], 'operationId': 'getLandingPage', 'parameters': [ - {'$ref': '#/components/parameters/f'} + {'$ref': '#/components/parameters/f'}, + {'$ref': '#/components/parameters/lang'} ], 'responses': { '200': {'$ref': '{}#/components/responses/LandingPage'.format(OPENAPI_YAML['oapif'])}, # noqa @@ -181,7 +187,8 @@ def get_oas_30(cfg): 'tags': ['server'], 'operationId': 'getOpenapi', 'parameters': [ - {'$ref': '#/components/parameters/f'} + {'$ref': '#/components/parameters/f'}, + {'$ref': '#/components/parameters/lang'} ], 'responses': { '200': {'$ref': '#/components/responses/200'}, @@ -198,7 +205,8 @@ def get_oas_30(cfg): 'tags': ['server'], 'operationId': 'getConformanceDeclaration', 'parameters': [ - {'$ref': '#/components/parameters/f'} + {'$ref': '#/components/parameters/f'}, + {'$ref': '#/components/parameters/lang'} ], 'responses': { '200': {'$ref': '{}#/components/responses/ConformanceDeclaration'.format(OPENAPI_YAML['oapif'])}, # noqa @@ -215,7 +223,8 @@ def get_oas_30(cfg): 'tags': ['server'], 'operationId': 'getCollections', 'parameters': [ - {'$ref': '#/components/parameters/f'} + {'$ref': '#/components/parameters/f'}, + {'$ref': '#/components/parameters/lang'} ], 'responses': { '200': {'$ref': '{}#/components/responses/Collections'.format(OPENAPI_YAML['oapif'])}, # noqa @@ -227,7 +236,7 @@ def get_oas_30(cfg): oas['tags'].append({ 'name': 'server', - 'description': cfg['metadata']['identification']['description'], + 'description': l10n.translate(cfg['metadata']['identification']['description'], locale_), # noqa 'externalDocs': { 'description': 'information', 'url': cfg['metadata']['identification']['url']} @@ -271,6 +280,17 @@ def get_oas_30(cfg): 'style': 'form', 'explode': False }, + 'lang': { + 'name': 'lang', + 'in': 'query', + 'description': 'The optional lang parameter instructs the server return a response in a certain language, if supported. If the language is not among the available values, the Accept-Language header language will be used if it is supported. If the header is missing, the default server language is used. Note that providers may only support a single language (or often no language at all), that can be different from the server language. Language strings can be written in a complex (e.g. "fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7"), simple (e.g. "de") or locale-like (e.g. "de-CH" or "fr_BE") fashion.', # noqa + 'required': False, + 'schema': { + 'type': 'string', + 'enum': [l10n.locale2str(sl) for sl in server_locales], + 'default': l10n.locale2str(locale_) + } + }, 'properties': { 'name': 'properties', 'in': 'query', @@ -367,19 +387,23 @@ def get_oas_30(cfg): items_f = deepcopy(oas['components']['parameters']['f']) items_f['schema']['enum'].append('csv') + items_l = deepcopy(oas['components']['parameters']['lang']) LOGGER.debug('setting up datasets') collections = filter_dict_by_key_value(cfg['resources'], 'type', 'collection') for k, v in collections.items(): + name = l10n.translate(k, locale_) + title = l10n.translate(v['title'], locale_) + desc = l10n.translate(v['description'], locale_) collection_name_path = '/collections/{}'.format(k) tag = { - 'name': k, - 'description': v['description'], + 'name': name, + 'description': desc, 'externalDocs': {} } - for link in v['links']: + for link in l10n.translate(v['links'], locale_): if link['type'] == 'information': tag['externalDocs']['description'] = link['type'] tag['externalDocs']['url'] = link['url'] @@ -391,12 +415,13 @@ def get_oas_30(cfg): paths[collection_name_path] = { 'get': { - 'summary': 'Get collection metadata'.format(v['title']), # noqa - 'description': v['description'], - 'tags': [k], - 'operationId': 'describe{}Collection'.format(k.capitalize()), + 'summary': 'Get {} metadata'.format(title), + 'description': desc, + 'tags': name, + 'operationId': 'describe{}Collection'.format(name.capitalize()), # noqa 'parameters': [ - {'$ref': '#/components/parameters/f'} + {'$ref': '#/components/parameters/f'}, + {'$ref': '#/components/parameters/lang'} ], 'responses': { '200': {'$ref': '{}#/components/responses/Collection'.format(OPENAPI_YAML['oapif'])}, # noqa @@ -430,12 +455,13 @@ def get_oas_30(cfg): paths[items_path] = { 'get': { - 'summary': 'Get {} items'.format(v['title']), - 'description': v['description'], - 'tags': [k], - 'operationId': 'get{}Features'.format(k.capitalize()), + 'summary': 'Get {} items'.format(title), # noqa + 'description': desc, + 'tags': [name], + 'operationId': 'get{}Features'.format(name.capitalize()), 'parameters': [ items_f, + items_l, {'$ref': '{}#/components/parameters/bbox'.format(OPENAPI_YAML['oapif'])}, # noqa {'$ref': '{}#/components/parameters/limit'.format(OPENAPI_YAML['oapif'])}, # noqa coll_properties, @@ -460,13 +486,14 @@ def get_oas_30(cfg): paths[queryables_path] = { 'get': { - 'summary': 'Get {} queryables'.format(v['title']), - 'description': v['description'], - 'tags': [k], + 'summary': 'Get {} queryables'.format(title), + 'description': desc, + 'tags': [name], 'operationId': 'get{}Queryables'.format( - k.capitalize()), + name.capitalize()), 'parameters': [ items_f, + items_l ], 'responses': { '200': {'$ref': '#/components/responses/Queryables'}, # noqa @@ -523,13 +550,14 @@ def get_oas_30(cfg): paths['{}/items/{{featureId}}'.format(collection_name_path)] = { 'get': { - 'summary': 'Get {} item by id'.format(v['title']), - 'description': v['description'], - 'tags': [k], - 'operationId': 'get{}Feature'.format(k.capitalize()), + 'summary': 'Get {} item by id'.format(title), + 'description': desc, + 'tags': [name], + 'operationId': 'get{}Feature'.format(name.capitalize()), 'parameters': [ {'$ref': '{}#/components/parameters/featureId'.format(OPENAPI_YAML['oapif'])}, # noqa - {'$ref': '#/components/parameters/f'} + {'$ref': '#/components/parameters/f'}, + {'$ref': '#/components/parameters/lang'} ], 'responses': { '200': {'$ref': '{}#/components/responses/Feature'.format(OPENAPI_YAML['oapif'])}, # noqa @@ -551,12 +579,13 @@ def get_oas_30(cfg): paths[coverage_path] = { 'get': { - 'summary': 'Get {} coverage'.format(v['title']), - 'description': v['description'], - 'tags': [k], - 'operationId': 'get{}Coverage'.format(k.capitalize()), + 'summary': 'Get {} coverage'.format(title), + 'description': desc, + 'tags': [name], + 'operationId': 'get{}Coverage'.format(name.capitalize()), 'parameters': [ items_f, + items_l ], 'responses': { '200': {'$ref': '{}#/components/responses/Features'.format(OPENAPI_YAML['oapif'])}, # noqa @@ -572,13 +601,14 @@ def get_oas_30(cfg): paths[coverage_domainset_path] = { 'get': { - 'summary': 'Get {} coverage domain set'.format(v['title']), - 'description': v['description'], - 'tags': [k], + 'summary': 'Get {} coverage domain set'.format(title), + 'description': desc, + 'tags': [name], 'operationId': 'get{}CoverageDomainSet'.format( - k.capitalize()), + name.capitalize()), 'parameters': [ items_f, + items_l ], 'responses': { '200': {'$ref': '{}/schemas/cis_1.1/domainSet.yaml'.format(OPENAPI_YAML['oacov'])}, # noqa @@ -594,13 +624,14 @@ def get_oas_30(cfg): paths[coverage_rangetype_path] = { 'get': { - 'summary': 'Get {} coverage range type'.format(v['title']), - 'description': v['description'], - 'tags': [k], + 'summary': 'Get {} coverage range type'.format(title), + 'description': desc, + 'tags': [name], 'operationId': 'get{}CoverageRangeType'.format( - k.capitalize()), + name.capitalize()), 'parameters': [ items_f, + items_l ], 'responses': { '200': {'$ref': '{}/schemas/cis_1.1/rangeType.yaml'.format(OPENAPI_YAML['oacov'])}, # noqa @@ -672,12 +703,13 @@ def get_oas_30(cfg): paths[tiles_path] = { 'get': { - 'summary': 'Fetch a {} tiles description'.format(v['title']), # noqa - 'description': v['description'], - 'tags': [k], - 'operationId': 'describe{}Tiles'.format(k.capitalize()), + 'summary': 'Fetch a {} tiles description'.format(title), # noqa + 'description': desc, + 'tags': [name], + 'operationId': 'describe{}Tiles'.format(name.capitalize()), 'parameters': [ items_f, + # items_l TODO: is this useful? ], 'responses': { '200': {'$ref': '#/components/responses/Tiles'}, @@ -692,10 +724,10 @@ def get_oas_30(cfg): paths[tiles_data_path] = { 'get': { - 'summary': 'Get a {} tile'.format(v['title']), - 'description': v['description'], - 'tags': [k], - 'operationId': 'get{}Tiles'.format(k.capitalize()), + 'summary': 'Get a {} tile'.format(title), + 'description': desc, + 'tags': [name], + 'operationId': 'get{}Tiles'.format(name.capitalize()), 'parameters': [ {'$ref': '{}#/components/parameters/tileMatrixSetId'.format(OPENAPI_YAML['oat'])}, # noqa {'$ref': '{}#/components/parameters/tileMatrix'.format(OPENAPI_YAML['oat'])}, # noqa @@ -825,15 +857,17 @@ def get_oas_30(cfg): LOGGER.debug('setting up processes') for k, v in processes.items(): + name = l10n.translate(k, locale_) p = load_plugin('process', v['processor']) - process_name_path = '/processes/{}'.format(k) + md_desc = l10n.translate(p.metadata['description'], locale_) + process_name_path = '/processes/{}'.format(name) tag = { - 'name': k, - 'description': p.metadata['description'], + 'name': name, + 'description': md_desc, # noqa 'externalDocs': {} } - for link in p.metadata['links']: + for link in l10n.translate(p.metadata['links'], locale_): if link['type'] == 'information': tag['externalDocs']['description'] = link['type'] tag['externalDocs']['url'] = link['url'] @@ -846,9 +880,9 @@ def get_oas_30(cfg): paths[process_name_path] = { 'get': { 'summary': 'Get process metadata', - 'description': p.metadata['description'], - 'tags': [k], - 'operationId': 'describe{}Process'.format(k.capitalize()), + 'description': md_desc, + 'tags': [name], + 'operationId': 'describe{}Process'.format(name.capitalize()), # noqa 'parameters': [ {'$ref': '#/components/parameters/f'} ], @@ -861,9 +895,9 @@ def get_oas_30(cfg): paths['{}/jobs'.format(process_name_path)] = { 'get': { 'summary': 'Retrieve job list for process', - 'description': p.metadata['description'], - 'tags': [k], - 'operationId': 'get{}Jobs'.format(k.capitalize()), + 'description': md_desc, + 'tags': [name], + 'operationId': 'get{}Jobs'.format(name.capitalize()), 'responses': { '200': {'$ref': '#/components/responses/200'}, '404': {'$ref': '{}/responses/NotFound.yaml'.format(OPENAPI_YAML['oapip'])}, # noqa @@ -872,10 +906,10 @@ def get_oas_30(cfg): }, 'post': { 'summary': 'Process {} execution'.format( - p.metadata['title']), - 'description': p.metadata['description'], - 'tags': [k], - 'operationId': 'execute{}Job'.format(k.capitalize()), + l10n.translate(p.metadata['title'], locale_)), + 'description': md_desc, + 'tags': [name], + 'operationId': 'execute{}Job'.format(name.capitalize()), 'parameters': [{ 'name': 'response', 'in': 'query', @@ -926,12 +960,12 @@ def get_oas_30(cfg): 'get': { 'summary': 'Retrieve job details', 'description': '', - 'tags': [k], + 'tags': [name], 'parameters': [ name_in_path, {'$ref': '#/components/parameters/f'} ], - 'operationId': f'get{k.capitalize()}Job', + 'operationId': f'get{name.capitalize()}Job', 'responses': { '200': {'$ref': '#/components/responses/200'}, '404': {'$ref': '{}/responses/NotFound.yaml'.format(OPENAPI_YAML['oapip'])}, # noqa @@ -941,11 +975,11 @@ def get_oas_30(cfg): 'delete': { 'summary': 'Cancel / delete job', 'description': '', - 'tags': [k], + 'tags': [name], 'parameters': [ name_in_path ], - 'operationId': f'delete{k.capitalize()}Job', + 'operationId': f'delete{name.capitalize()}Job', 'responses': { '204': {'$ref': '#/components/responses/204'}, '404': {'$ref': '{}/responses/NotFound.yaml'.format(OPENAPI_YAML['oapip'])}, # noqa @@ -958,12 +992,12 @@ def get_oas_30(cfg): 'get': { 'summary': 'Retrieve job results', 'description': '', - 'tags': [k], + 'tags': [name], 'parameters': [ name_in_path, {'$ref': '#/components/parameters/f'} ], - 'operationId': f'get{k.capitalize()}JobResults', + 'operationId': f'get{name.capitalize()}JobResults', 'responses': { '200': {'$ref': '#/components/responses/200'}, '404': {'$ref': '{}/responses/NotFound.yaml'.format(OPENAPI_YAML['oapip'])}, # noqa diff --git a/pygeoapi/process/hello_world.py b/pygeoapi/process/hello_world.py index 873167c..6cb409b 100644 --- a/pygeoapi/process/hello_world.py +++ b/pygeoapi/process/hello_world.py @@ -38,10 +38,18 @@ LOGGER = logging.getLogger(__name__) PROCESS_METADATA = { 'version': '0.2.0', 'id': 'hello-world', - 'title': 'Hello World', - 'description': 'An example process that takes a name as input, and echoes' - 'it back as output. Intended to demonstrate a simple' - 'process with a single literal input.', + 'title': { + 'en': 'Hello World', + 'fr': 'Bonjour le Monde' + }, + 'description': { + 'en': 'An example process that takes a name as input, and echoes ' + 'it back as output. Intended to demonstrate a simple ' + 'process with a single literal input.', + 'fr': 'Un exemple de processus qui prend un nom en entrée et le ' + 'renvoie en sortie. Destiné à démontrer un processus ' + 'simple avec une seule entrée littérale.', + }, 'keywords': ['hello world', 'example', 'echo'], 'links': [{ 'type': 'text/html', diff --git a/pygeoapi/provider/csv_.py b/pygeoapi/provider/csv_.py index f29c4f5..932c3e6 100644 --- a/pygeoapi/provider/csv_.py +++ b/pygeoapi/provider/csv_.py @@ -147,7 +147,7 @@ class CSVProvider(BaseProvider): def query(self, startindex=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False, q=None): + select_properties=[], skip_geometry=False, q=None, **kwargs): """ CSV query @@ -169,7 +169,7 @@ class CSVProvider(BaseProvider): select_properties=select_properties, skip_geometry=skip_geometry) - def get(self, identifier): + def get(self, identifier, **kwargs): """ query CSV id diff --git a/pygeoapi/provider/elasticsearch_.py b/pygeoapi/provider/elasticsearch_.py index fc5fe0a..85913cc 100644 --- a/pygeoapi/provider/elasticsearch_.py +++ b/pygeoapi/provider/elasticsearch_.py @@ -144,7 +144,7 @@ class ElasticsearchProvider(BaseProvider): def query(self, startindex=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False, q=None): + select_properties=[], skip_geometry=False, q=None, **kwargs): """ query Elasticsearch index @@ -344,7 +344,7 @@ class ElasticsearchProvider(BaseProvider): return feature_collection - def get(self, identifier): + def get(self, identifier, **kwargs): """ Get ES document by id diff --git a/pygeoapi/provider/geojson.py b/pygeoapi/provider/geojson.py index 7c34022..b531e52 100644 --- a/pygeoapi/provider/geojson.py +++ b/pygeoapi/provider/geojson.py @@ -116,7 +116,7 @@ class GeoJSONProvider(BaseProvider): def query(self, startindex=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False, q=None): + select_properties=[], skip_geometry=False, q=None, **kwargs): """ query the provider @@ -148,7 +148,7 @@ class GeoJSONProvider(BaseProvider): return data - def get(self, identifier): + def get(self, identifier, **kwargs): """ query the provider by id diff --git a/pygeoapi/provider/mongo.py b/pygeoapi/provider/mongo.py index 55b042f..c15fe65 100644 --- a/pygeoapi/provider/mongo.py +++ b/pygeoapi/provider/mongo.py @@ -98,7 +98,7 @@ class MongoProvider(BaseProvider): def query(self, startindex=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False, q=None): + select_properties=[], skip_geometry=False, q=None, **kwargs): """ query the provider @@ -143,7 +143,7 @@ class MongoProvider(BaseProvider): return feature_collection - def get(self, identifier): + def get(self, identifier, **kwargs): """ query the provider by id diff --git a/pygeoapi/provider/mvt.py b/pygeoapi/provider/mvt.py index 4a87216..03b469e 100644 --- a/pygeoapi/provider/mvt.py +++ b/pygeoapi/provider/mvt.py @@ -214,7 +214,7 @@ class MVTProvider(BaseTileProvider): raise ProviderTileNotFoundError(err) def get_metadata(self, dataset, server_url, layer=None, - tileset=None, tilejson=True): + tileset=None, tilejson=True, **kwargs): """ Gets tile metadata diff --git a/pygeoapi/provider/ogr.py b/pygeoapi/provider/ogr.py index f0bdcf6..7773288 100644 --- a/pygeoapi/provider/ogr.py +++ b/pygeoapi/provider/ogr.py @@ -291,7 +291,7 @@ class OGRProvider(BaseProvider): def query(self, startindex=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False, q=None): + select_properties=[], skip_geometry=False, q=None, **kwargs): """ Query OGR source @@ -372,7 +372,7 @@ class OGRProvider(BaseProvider): return result - def get(self, identifier): + def get(self, identifier, **kwargs): """ Get Feature by id diff --git a/pygeoapi/provider/postgresql.py b/pygeoapi/provider/postgresql.py index 51c5e6c..da3a369 100644 --- a/pygeoapi/provider/postgresql.py +++ b/pygeoapi/provider/postgresql.py @@ -208,7 +208,7 @@ class PostgreSQLProvider(BaseProvider): def query(self, startindex=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False, q=None): + select_properties=[], skip_geometry=False, q=None, **kwargs): """ Query Postgis for all the content. e,g: http://localhost:5000/collections/hotosm_bdi_waterways/items? @@ -330,7 +330,7 @@ class PostgreSQLProvider(BaseProvider): id_ = item[0]['id'] if item else identifier return id_ - def get(self, identifier): + def get(self, identifier, **kwargs): """ Query the provider for a specific feature id e.g: /collections/hotosm_bdi_waterways/items/13990765 diff --git a/pygeoapi/provider/rasterio_.py b/pygeoapi/provider/rasterio_.py index 3343166..99905d0 100644 --- a/pygeoapi/provider/rasterio_.py +++ b/pygeoapi/provider/rasterio_.py @@ -65,7 +65,7 @@ class RasterioProvider(BaseProvider): LOGGER.warning(err) raise ProviderConnectionError(err) - def get_coverage_domainset(self): + def get_coverage_domainset(self, *args, **kwargs): """ Provide coverage domainset :returns: CIS JSON object of domainset metadata @@ -119,7 +119,7 @@ class RasterioProvider(BaseProvider): return domainset - def get_coverage_rangetype(self): + def get_coverage_rangetype(self, *args, **kwargs): """ Provide coverage rangetype :returns: CIS JSON object of rangetype metadata @@ -161,7 +161,7 @@ class RasterioProvider(BaseProvider): return rangetype def query(self, range_subset=[], subsets={}, bbox=[], datetime_=None, - format_='json'): + format_='json', **kwargs): """ Extract data from collection collection :param range_subset: list of bands diff --git a/pygeoapi/provider/sqlite.py b/pygeoapi/provider/sqlite.py index 0dacbaa..84ca19c 100644 --- a/pygeoapi/provider/sqlite.py +++ b/pygeoapi/provider/sqlite.py @@ -251,7 +251,7 @@ class SQLiteGPKGProvider(BaseProvider): def query(self, startindex=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False, q=None): + select_properties=[], skip_geometry=False, q=None, **kwargs): """ Query SQLite/GPKG for all the content. e,g: http://localhost:5000/collections/countries/items? @@ -310,7 +310,7 @@ class SQLiteGPKGProvider(BaseProvider): return feature_collection - def get(self, identifier): + def get(self, identifier, **kwargs): """ Query the provider for a specific feature id e.g: /collections/countries/items/1 diff --git a/pygeoapi/provider/tinydb_.py b/pygeoapi/provider/tinydb_.py index 69f661a..d1ba7e9 100644 --- a/pygeoapi/provider/tinydb_.py +++ b/pygeoapi/provider/tinydb_.py @@ -93,7 +93,7 @@ class TinyDBCatalogueProvider(BaseProvider): def query(self, startindex=0, limit=10, resulttype='results', bbox=[], datetime_=None, properties=[], sortby=[], - select_properties=[], skip_geometry=False, q=None): + select_properties=[], skip_geometry=False, q=None, **kwargs): """ query TinyDB document store @@ -203,7 +203,7 @@ class TinyDBCatalogueProvider(BaseProvider): return feature_collection - def get(self, identifier): + def get(self, identifier, **kwargs): """ Get TinyDB document by id diff --git a/pygeoapi/provider/xarray_.py b/pygeoapi/provider/xarray_.py index ec85904..d0327e1 100644 --- a/pygeoapi/provider/xarray_.py +++ b/pygeoapi/provider/xarray_.py @@ -74,7 +74,7 @@ class XarrayProvider(BaseProvider): LOGGER.warning(err) raise ProviderConnectionError(err) - def get_coverage_domainset(self): + def get_coverage_domainset(self, *args, **kwargs): """ Provide coverage domainset @@ -140,7 +140,7 @@ class XarrayProvider(BaseProvider): return domainset - def get_coverage_rangetype(self): + def get_coverage_rangetype(self, *args, **kwargs): """ Provide coverage rangetype @@ -182,7 +182,7 @@ class XarrayProvider(BaseProvider): return rangetype def query(self, range_subset=[], subsets={}, bbox=[], datetime_=None, - format_='json'): + format_='json', **kwargs): """ Extract data from collection collection diff --git a/pygeoapi/starlette_app.py b/pygeoapi/starlette_app.py index 041300f..1943f00 100644 --- a/pygeoapi/starlette_app.py +++ b/pygeoapi/starlette_app.py @@ -75,22 +75,30 @@ if (OGC_SCHEMAS_LOCATION is not None and api_ = API(CONFIG) +def get_response(result: tuple) -> Response: + """ Creates a Starlette Response object and updates matching headers. + + :param result: The result of the API call. + This should be a tuple of (headers, status, content). + :returns: A Response instance. + """ + headers, status, content = result + response = Response(content=content, status_code=status) + if headers is not None: + response.headers.update(headers) + return response + + @app.route('/') async def landing_page(request: Request): """ OGC API landing page endpoint + :param request: Starlette Request instance + :returns: Starlette HTTP Response """ - - headers, status_code, content = api_.landing_page( - request.headers, request.query_params) - - response = Response(content=content, status_code=status_code) - if headers: - response.headers.update(headers) - - return response + return get_response(api_.landing_page(request)) @app.route('/openapi') @@ -99,24 +107,17 @@ async def openapi(request: Request): """ OpenAPI endpoint + :param request: Starlette Request instance + :returns: Starlette HTTP Response """ - with open(os.environ.get('PYGEOAPI_OPENAPI'), encoding='utf8') as ff: if os.environ.get('PYGEOAPI_OPENAPI').endswith(('.yaml', '.yml')): - openapi = yaml_load(ff) + openapi_ = yaml_load(ff) else: # JSON file, do not transform - openapi = ff + openapi_ = ff - headers, status_code, content = api_.openapi( - request.headers, request.query_params, openapi) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + return get_response(api_.openapi(request, openapi_)) @app.route('/conformance') @@ -125,17 +126,11 @@ async def conformance(request: Request): """ OGC API conformance endpoint + :param request: Starlette Request instance + :returns: Starlette HTTP Response """ - - headers, status_code, content = api_.conformance( - request.headers, request.query_params) - - response = Response(content=content, status_code=status_code) - if headers: - response.headers.update(headers) - - return response + return get_response(api_.conformance(request)) @app.route('/collections') @@ -146,21 +141,14 @@ async def collections(request: Request, collection_id=None): """ OGC API collections endpoint + :param request: Starlette Request instance :param collection_id: collection identifier :returns: Starlette HTTP Response """ - if 'collection_id' in request.path_params: collection_id = request.path_params['collection_id'] - headers, status_code, content = api_.describe_collections( - request.headers, request.query_params, collection_id) - - response = Response(content=content, status_code=status_code) - if headers: - response.headers.update(headers) - - return response + return get_response(api_.describe_collections(request, collection_id)) @app.route('/collections/{collection_id}/queryables') @@ -169,21 +157,14 @@ async def collection_queryables(request: Request, collection_id=None): """ OGC API collections queryables endpoint + :param request: Starlette Request instance :param collection_id: collection identifier :returns: Starlette HTTP Response """ - if 'collection_id' in request.path_params: collection_id = request.path_params['collection_id'] - headers, status_code, content = api_.get_collection_queryables( - request.headers, request.query_params, collection_id) - - response = Response(content=content, status_code=status_code) - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_collection_queryables(request, collection_id)) @app.route('/collections/{name}/tiles') @@ -192,21 +173,14 @@ async def get_collection_tiles(request: Request, name=None): """ OGC open api collections tiles access point + :param request: Starlette Request instance :param name: identifier of collection name :returns: Starlette HTTP Response """ - if 'name' in request.path_params: name = request.path_params['name'] - headers, status_code, content = api_.get_collection_tiles( - request.headers, request.query_params, name) - - response = Response(content=content, status_code=status_code) - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_collection_tiles(request, name)) @app.route('/collections/{name}/tiles/\ @@ -219,6 +193,7 @@ async def get_collection_items_tiles(request: Request, name=None, """ OGC open api collection tiles service + :param request: Starlette Request instance :param name: identifier of collection name :param tileMatrixSetId: identifier of tile matrix set :param tile_matrix: identifier of {z} matrix index @@ -227,7 +202,6 @@ async def get_collection_items_tiles(request: Request, name=None, :returns: HTTP response """ - if 'name' in request.path_params: name = request.path_params['name'] if 'tileMatrixSetId' in request.path_params: @@ -238,15 +212,8 @@ async def get_collection_items_tiles(request: Request, name=None, tileRow = request.path_params['tileRow'] if 'tileCol' in request.path_params: tileCol = request.path_params['tileCol'] - headers, status_code, content = api_.get_collection_items_tiles( - request.headers, request.query_params, name, tileMatrixSetId, - tile_matrix, tileRow, tileCol) - - response = Response(content=content, status_code=status_code) - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_collection_tiles_data( + request, name, tileMatrixSetId, tile_matrix, tileRow, tileCol)) @app.route('/collections/{collection_id}/items') @@ -257,30 +224,22 @@ async def collection_items(request: Request, collection_id=None, item_id=None): """ OGC API collections items endpoint + :param request: Starlette Request instance :param collection_id: collection identifier :param item_id: item identifier :returns: Starlette HTTP Response """ - if 'collection_id' in request.path_params: collection_id = request.path_params['collection_id'] if 'item_id' in request.path_params: item_id = request.path_params['item_id'] if item_id is None: - headers, status_code, content = api_.get_collection_items( - request.headers, request.query_params, - collection_id, pathinfo=request.scope['path']) + return get_response(api_.get_collection_items( + request, collection_id, pathinfo=request.scope['path'])) else: - headers, status_code, content = api_.get_collection_item( - request.headers, request.query_params, collection_id, item_id) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_collection_item( + request, collection_id, item_id)) @app.route('/collections/{collection_id}/coverage') @@ -288,23 +247,15 @@ async def collection_coverage(request: Request, collection_id): """ OGC API - Coverages coverage endpoint + :param request: Starlette Request instance :param collection_id: collection identifier :returns: Starlette HTTP Response """ - if 'collection_id' in request.path_params: collection_id = request.path_params['collection_id'] - headers, status_code, content = api_.get_collection_coverage( - request.headers, request.query_params, collection_id) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_collection_coverage(request, collection_id)) @app.route('/collections/{collection_id}/coverage/domainset') @@ -312,23 +263,16 @@ async def collection_coverage_domainset(request: Request, collection_id): """ OGC API - Coverages coverage domainset endpoint + :param request: Starlette Request instance :param collection_id: collection identifier :returns: Starlette HTTP Response """ - if 'collection_id' in request.path_params: collection_id = request.path_params['collection_id'] - headers, status_code, content = api_.get_collection_coverage_domainset( - request.headers, request.query_params, collection_id) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_collection_coverage_domainset( + request, collection_id)) @app.route('/collections/{collection_id}/coverage/rangetype') @@ -336,23 +280,16 @@ async def collection_coverage_rangetype(request: Request, collection_id): """ OGC API - Coverages coverage rangetype endpoint + :param request: Starlette Request instance :param collection_id: collection identifier :returns: Starlette HTTP Response """ - if 'collection_id' in request.path_params: collection_id = request.path_params['collection_id'] - headers, status_code, content = api_.get_collection_coverage_rangetype( - request.headers, request.query_params, collection_id) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_collection_coverage_rangetype( + request, collection_id)) @app.route('/processes') @@ -363,23 +300,15 @@ async def get_processes(request: Request, process_id=None): """ OGC API - Processes description endpoint + :param request: Starlette Request instance :param process_id: identifier of process to describe :returns: Starlette HTTP Response """ - if 'process_id' in request.path_params: process_id = request.path_params['process_id'] - headers, status_code, content = api_.describe_processes( - request.headers, request.query_params, process_id) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + return get_response(api_.describe_processes(request, process_id)) @app.route('/processes/{process_id}/jobs', methods=['GET', 'POST']) @@ -390,6 +319,7 @@ async def get_process_jobs(request: Request, process_id=None, job_id=None): """ OGC API - Processes jobs endpoint + :param request: Starlette Request instance :param process_id: process identifier :param job_id: job identifier @@ -403,27 +333,15 @@ async def get_process_jobs(request: Request, process_id=None, job_id=None): if job_id is None: # list of submit job if request.method == 'GET': - headers, status_code, content = api_.get_process_jobs( - request.headers, request.query_params, process_id) + return get_response(api_.get_process_jobs(request, process_id)) elif request.method == 'POST': - request_body = await request.body() - headers, status_code, content = api_.execute_process( - request.headers, request.query_params, request_body, - process_id) + return get_response(api_.execute_process(request, process_id)) else: # get or delete job if request.method == 'DELETE': - headers, status_code, content = api_.delete_process_job( - process_id, job_id) + return get_response(api_.delete_process_job(process_id, job_id)) else: # Return status of a specific job - headers, status_code, content = api_.get_process_job_status( - request.headers, request.args, process_id, job_id) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_process_jobs( + request, process_id, job_id)) @app.route('/processes/{process_id}/jobs/{job_id}/results', methods=['GET']) @@ -433,6 +351,7 @@ async def get_process_job_result(request: Request, process_id=None, """ OGC API - Processes job result endpoint + :param request: Starlette Request instance :param process_id: process identifier :param job_id: job identifier @@ -444,15 +363,8 @@ async def get_process_job_result(request: Request, process_id=None, if 'job_id' in request.path_params: job_id = request.path_params['job_id'] - headers, status_code, content = api_.get_process_job_result( - request.headers, request.args, process_id, job_id) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_process_job_result( + request, process_id, job_id)) @app.route('/processes/{process_id}/jobs/{job_id}/results/{resource}', @@ -464,6 +376,7 @@ async def get_process_job_result_resource(request: Request, process_id=None, """ OGC API - Processes job result resource endpoint + :param request: Starlette Request instance :param process_id: process identifier :param job_id: job identifier :param resource: job resource @@ -478,15 +391,8 @@ async def get_process_job_result_resource(request: Request, process_id=None, if 'resource' in request.path_params: resource = request.path_params['resource'] - headers, status_code, content = api_.get_process_job_result_resource( - request.headers, request.args, process_id, job_id, resource) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_process_job_result_resource( + request, process_id, job_id, resource)) @app.route('/collections/{collection_id}/position') @@ -515,18 +421,9 @@ async def get_collection_edr_query(request: Request, collection_id=None, instanc if 'instance_id' in request.path_params: instance_id = request.path_params['instance_id'] - query_type = request.path.split('/')[-1] - - headers, status_code, content = api_.get_collection_edr_query( - request.headers, request.query_params, collection_id, instance_id, - query_type) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + query_type = request.path.split('/')[-1] # noqa + return get_response(api_.get_collection_edr_query(request, collection_id, + instance_id, query_type)) @app.route('/stac') @@ -534,18 +431,11 @@ async def stac_catalog_root(request: Request): """ STAC root endpoint + :param request: Starlette Request instance + :returns: Starlette HTTP response """ - - headers, status_code, content = api_.get_stac_root( - request.headers, request.query_params) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_stac_root(request)) @app.route('/stac/{path:path}') @@ -553,22 +443,12 @@ async def stac_catalog_path(request: Request): """ STAC endpoint - :param path: path + :param request: Starlette Request instance :returns: Starlette HTTP response """ - path = request.path_params["path"] - - headers, status_code, content = api_.get_stac_path( - request.headers, request.query_params, path) - - response = Response(content=content, status_code=status_code) - - if headers: - response.headers.update(headers) - - return response + return get_response(api_.get_stac_path(request, path)) @click.command() diff --git a/pygeoapi/util.py b/pygeoapi/util.py index 31ce7c1..e99277b 100644 --- a/pygeoapi/util.py +++ b/pygeoapi/util.py @@ -43,11 +43,13 @@ from urllib.request import urlopen from urllib.parse import urlparse import dateutil.parser +# from babel.support import Translations from jinja2 import Environment, FileSystemLoader from jinja2.exceptions import TemplateNotFound import yaml from pygeoapi import __version__ +from pygeoapi import l10n from pygeoapi.provider.base import ProviderTypeError LOGGER = logging.getLogger(__name__) @@ -277,6 +279,8 @@ def json_serial(obj): return base64.b64encode(obj) elif isinstance(obj, Decimal): return float(obj) + elif isinstance(obj, l10n.Locale): + return l10n.locale2str(obj) msg = '{} type {} not serializable'.format(obj, type(obj)) LOGGER.error(msg) @@ -298,13 +302,14 @@ def is_url(urlstring): return False -def render_j2_template(config, template, data): +def render_j2_template(config, template, data, locale_=None): """ render Jinja2 template :param config: dict of configuration :param template: template (relative path) :param data: dict of data + :param locale_: the requested output Locale :returns: string of rendered template """ @@ -312,11 +317,13 @@ def render_j2_template(config, template, data): custom_templates = False try: templates_path = config['server']['templates']['path'] - env = Environment(loader=FileSystemLoader(templates_path)) + env = Environment(loader=FileSystemLoader(templates_path), + extensions=['jinja2.ext.i18n']) custom_templates = True LOGGER.debug('using custom templates: {}'.format(templates_path)) except (KeyError, TypeError): - env = Environment(loader=FileSystemLoader(TEMPLATES)) + env = Environment(loader=FileSystemLoader(TEMPLATES), + extensions=['jinja2.ext.i18n']) LOGGER.debug('using default templates: {}'.format(TEMPLATES)) env.filters['to_json'] = to_json @@ -334,18 +341,21 @@ def render_j2_template(config, template, data): env.filters['filter_dict_by_key_value'] = filter_dict_by_key_value env.globals.update(filter_dict_by_key_value=filter_dict_by_key_value) + # TODO: insert Babel Translation stuff here try: template = env.get_template(template) except TemplateNotFound as err: if custom_templates: LOGGER.debug(err) LOGGER.debug('Custom template not found; using default') - env = Environment(loader=FileSystemLoader(TEMPLATES)) + env = Environment(loader=FileSystemLoader(TEMPLATES), + extensions=['jinja2.ext.i18n']) template = env.get_template(template) else: raise - return template.render(config=config, data=data, version=__version__) + return template.render(config=l10n.translate_struct(config, locale_, True), + data=data, version=__version__) def get_mimetype(filename): diff --git a/requirements.txt b/requirements.txt index 8cd9c63..e640b88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ rasterio shapely tinydb unicodecsv +Babel diff --git a/tests/pygeoapi-test-config.yml b/tests/pygeoapi-test-config.yml index 0159ea6..2b260c7 100644 --- a/tests/pygeoapi-test-config.yml +++ b/tests/pygeoapi-test-config.yml @@ -34,7 +34,10 @@ server: url: http://localhost:5000/ mimetype: application/json; charset=UTF-8 encoding: utf-8 - language: en-US + languages: + # First language is the default language + - en-US + - fr-CA cors: true pretty_print: true limit: 10 @@ -52,12 +55,21 @@ logging: metadata: identification: - title: pygeoapi default instance - description: pygeoapi provides an API to geospatial data + title: + en: pygeoapi default instance + fr: instance par défaut de pygeoapi + description: + en: pygeoapi provides an API to geospatial data + fr: pygeoapi fournit une API aux données géospatiales keywords: - - geospatial - - data - - api + en: + - geospatial + - data + - api + fr: + - géospatiale + - données + - api keywords_type: theme terms_of_service: https://creativecommons.org/licenses/by/4.0/ url: http://example.org @@ -86,8 +98,12 @@ metadata: resources: obs: type: collection - title: Observations - description: My cool observations + title: + en: Observations + fr: Observations + description: + en: My cool observations + fr: Mes belles observations keywords: - observations - monitoring @@ -160,8 +176,12 @@ resources: lakes: type: collection - title: Large Lakes - description: lakes of the world, public domain + title: + en: Large Lakes + fr: Grands Lacs + description: + en: lakes of the world, public domain + fr: lacs du monde, domaine public keywords: - lakes links: diff --git a/tests/test_api.py b/tests/test_api.py index 69719fd..f5e7bee 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -36,11 +36,18 @@ from pyld import jsonld import pytest from werkzeug.test import create_environ from werkzeug.wrappers import Request -from pygeoapi.api import API, check_format, validate_bbox, validate_datetime +from werkzeug.datastructures import ImmutableMultiDict +from pygeoapi.api import ( + API, APIRequest, FORMAT_TYPES, validate_bbox, validate_datetime, + F_HTML, F_JSON, F_JSONLD +) from pygeoapi.util import yaml_load LOGGER = logging.getLogger(__name__) +ROUTE_OBS = '/collections/obs/items' +ROUTE_LAKES = '/collections/lakes/items' + def get_test_file_path(filename): """helper function to open test file safely""" @@ -51,20 +58,15 @@ def get_test_file_path(filename): return 'tests/{}'.format(filename) -def make_req_headers(**kwargs): - environ = create_environ('/collections/obs/items', - 'http://localhost:5000/') - environ.update(kwargs) +def make_request(route: str, params: dict, data=None, **headers): + if isinstance(data, dict): + environ = create_environ(route, 'http://localhost:5000/', json=data) + else: + environ = create_environ(route, 'http://localhost:5000/', data=data) + environ.update(headers) request = Request(environ) - return request.headers - - -def make_lakes_req_headers(**kwargs): - environ = create_environ('/collections/lakes/items', - 'http://localhost:5000/') - environ.update(kwargs) - request = Request(environ) - return request.headers + request.args = ImmutableMultiDict(params.items()) + return request @pytest.fixture() @@ -84,47 +86,187 @@ def api_(config): return API(config) +def test_apirequest(api_): + # Test without (valid) locales + with pytest.raises(ValueError): + req = make_request(ROUTE_OBS, {}) + APIRequest(req, []) + APIRequest(req, None) + APIRequest(req, ['zz']) + + # Test all supported formats from query args + for f, mt in FORMAT_TYPES.items(): + req = make_request(ROUTE_OBS, {'f': f}) + apireq = APIRequest(req, api_.locales) + assert apireq.is_valid() + assert apireq.format == f + assert apireq.get_response_headers()['Content-Type'] == mt + + # Test all supported formats from Accept header + for f, mt in FORMAT_TYPES.items(): + req = make_request(ROUTE_OBS, {}, HTTP_ACCEPT=mt) + apireq = APIRequest(req, api_.locales) + assert apireq.is_valid() + assert apireq.format == f + assert apireq.get_response_headers()['Content-Type'] == mt + + # Test nonsense format + req = make_request(ROUTE_OBS, {'f': 'foo'}) + apireq = APIRequest(req, api_.locales) + assert not apireq.is_valid() + assert apireq.format == 'foo' + assert apireq.is_valid(('foo',)) + assert apireq.get_response_headers()['Content-Type'] == \ + FORMAT_TYPES[F_JSON] + + # Test without format + req = make_request(ROUTE_OBS, {}) + apireq = APIRequest(req, api_.locales) + assert apireq.is_valid() + assert apireq.format is None + assert apireq.get_response_headers()['Content-Type'] == \ + FORMAT_TYPES[F_JSON] + assert apireq.get_linkrel(F_JSON) == 'self' + assert apireq.get_linkrel(F_HTML) == 'alternate' + + # Test complex format string + hh = 'text/html,application/xhtml+xml,application/xml;q=0.9,' + req = make_request(ROUTE_OBS, {}, HTTP_ACCEPT=hh) + apireq = APIRequest(req, api_.locales) + assert apireq.is_valid() + assert apireq.format == F_HTML + assert apireq.get_response_headers()['Content-Type'] == \ + FORMAT_TYPES[F_HTML] + assert apireq.get_linkrel(F_HTML) == 'self' + assert apireq.get_linkrel(F_JSON) == 'alternate' + + # Overrule HTTP content negotiation + req = make_request(ROUTE_OBS, {'f': 'html'}, HTTP_ACCEPT='application/json') # noqa + apireq = APIRequest(req, api_.locales) + assert apireq.is_valid() + assert apireq.format == F_HTML + assert apireq.get_response_headers()['Content-Type'] == \ + FORMAT_TYPES[F_HTML] + + # Test data + for d in (None, '', 'test', {'key': 'value'}): + req = make_request(ROUTE_OBS, {}, d) + apireq = APIRequest.with_data(req, api_.locales) + if not d: + assert apireq.data == b'' + elif isinstance(d, dict): + assert d == json.loads(apireq.data) + else: + assert apireq.data == d.encode() + + # Test multilingual + test_lang = { + 'nl': ('en', 'en-US'), # unsupported lang should return default + 'en-US': ('en', 'en-US'), + 'de_CH': ('en', 'en-US'), + 'fr-CH, fr;q=0.9, en;q=0.8': ('fr', 'fr-CA'), + 'fr-CH, fr-BE;q=0.9': ('fr', 'fr-CA'), + } + sup_lang = ('en-US', 'fr_CA') + for lang_in, (lang_out, cl_out) in test_lang.items(): + # Using l query parameter + req = make_request(ROUTE_OBS, {'lang': lang_in}) + apireq = APIRequest(req, sup_lang) + assert apireq.raw_locale == lang_in + assert apireq.locale.language == lang_out + assert apireq.get_response_headers()['Content-Language'] == cl_out + + # Using Accept-Language header + req = make_request(ROUTE_OBS, {}, HTTP_ACCEPT_LANGUAGE=lang_in) + apireq = APIRequest(req, sup_lang) + assert apireq.raw_locale == lang_in + assert apireq.locale.language == lang_out + assert apireq.get_response_headers()['Content-Language'] == cl_out + + # Test language override + req = make_request(ROUTE_OBS, {'lang': 'fr'}, HTTP_ACCEPT_LANGUAGE='en_US') + apireq = APIRequest(req, sup_lang) + assert apireq.raw_locale == 'fr' + assert apireq.locale.language == 'fr' + assert apireq.get_response_headers()['Content-Language'] == 'fr-CA' + + # Test locale territory + req = make_request(ROUTE_OBS, {'lang': 'en-GB'}) + apireq = APIRequest(req, sup_lang) + assert apireq.raw_locale == 'en-GB' + assert apireq.locale.language == 'en' + assert apireq.locale.territory == 'US' + assert apireq.get_response_headers()['Content-Language'] == 'en-US' + + # Test without Accept-Language header or 'lang' query parameter + # (should return default language from YAML config) + req = make_request(ROUTE_OBS, {}) + apireq = APIRequest(req, api_.locales) + assert apireq.raw_locale is None + assert apireq.locale.language == api_.default_locale.language + assert apireq.get_response_headers()['Content-Language'] == 'en-US' + + # Test without Accept-Language header or 'lang' query param + # (should return first in custom list of languages) + sup_lang = ('de', 'fr', 'en') + req = make_request(ROUTE_LAKES, {}) + apireq = APIRequest(req, sup_lang) + assert apireq.raw_locale is None + assert apireq.locale.language == 'de' + assert apireq.get_response_headers()['Content-Language'] == 'de' + + def test_api(config, api_, openapi): assert api_.config == config assert isinstance(api_.config, dict) - req_headers = make_req_headers(HTTP_CONTENT_TYPE='application/json') - rsp_headers, code, response = api_.openapi(req_headers, {}, openapi) - assert rsp_headers['Content-Type'] ==\ - 'application/vnd.oai.openapi+json;version=3.0' + req = make_request(ROUTE_OBS, {}, HTTP_CONTENT_TYPE='application/json') + rsp_headers, code, response = api_.openapi(req, openapi) + assert rsp_headers['Content-Type'] == 'application/vnd.oai.openapi+json;version=3.0' # noqa + # No language requested: should be set to default from YAML + assert rsp_headers['Content-Language'] == 'en-US' root = json.loads(response) - assert isinstance(root, dict) a = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' - req_headers = make_req_headers(HTTP_ACCEPT=a) - rsp_headers, code, response = api_.openapi(req_headers, {}, openapi) - assert rsp_headers['Content-Type'] == 'text/html' + req = make_request(ROUTE_OBS, {}, HTTP_ACCEPT=a) + rsp_headers, code, response = api_.openapi(req, openapi) + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] == \ + FORMAT_TYPES[F_HTML] - req_headers = make_req_headers() - rsp_headers, code, response = api_.openapi(req_headers, {'f': 'foo'}, - openapi) + req = make_request(ROUTE_LAKES, {'f': 'foo'}) + rsp_headers, code, response = api_.openapi(req, openapi) + assert rsp_headers['Content-Language'] == 'en-US' assert code == 400 def test_api_exception(config, api_): - req_headers = make_req_headers() - rsp_headers, code, response = api_.landing_page(req_headers, {'f': 'foo'}) + req = make_request(ROUTE_OBS, {'f': 'foo'}) + rsp_headers, code, response = api_.landing_page(req) + assert rsp_headers['Content-Language'] == 'en-US' + assert code == 400 + + # When a language is set, the exception should still be English + req = make_request(ROUTE_OBS, {'f': 'foo', 'lang': 'fr'}) + rsp_headers, code, response = api_.landing_page(req) + assert rsp_headers['Content-Language'] == 'en-US' assert code == 400 def test_root(config, api_): - req_headers = make_req_headers() - rsp_headers, code, response = api_.landing_page(req_headers, {}) + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.landing_page(req) root = json.loads(response) - assert rsp_headers['Content-Type'] == 'application/json' + assert rsp_headers['Content-Type'] == 'application/json' == \ + FORMAT_TYPES[F_JSON] assert rsp_headers['X-Powered-By'].startswith('pygeoapi') + assert rsp_headers['Content-Language'] == 'en-US' assert isinstance(root, dict) assert 'links' in root assert root['links'][0]['rel'] == 'self' - assert root['links'][0]['type'] == 'application/json' + assert root['links'][0]['type'] == FORMAT_TYPES[F_JSON] assert root['links'][0]['href'].endswith('?f=json') assert any(link['href'].endswith('f=jsonld') and link['rel'] == 'alternate' for link in root['links']) @@ -136,17 +278,20 @@ def test_root(config, api_): assert 'description' in root assert root['description'] == 'pygeoapi provides an API to geospatial data' - rsp_headers, code, response = api_.landing_page(req_headers, {'f': 'html'}) - assert rsp_headers['Content-Type'] == 'text/html' + req = make_request(ROUTE_OBS, {'f': 'html'}) + rsp_headers, code, response = api_.landing_page(req) + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] + assert rsp_headers['Content-Language'] == 'en-US' def test_root_structured_data(config, api_): - req_headers = make_req_headers() - rsp_headers, code, response = api_.landing_page( - req_headers, {"f": "jsonld"}) + req = make_request(ROUTE_OBS, {"f": "jsonld"}) + rsp_headers, code, response = api_.landing_page(req) root = json.loads(response) - assert rsp_headers['Content-Type'] == 'application/ld+json' + assert rsp_headers['Content-Type'] == 'application/ld+json' == \ + FORMAT_TYPES[F_JSONLD] + assert rsp_headers['Content-Language'] == 'en-US' assert rsp_headers['X-Powered-By'].startswith('pygeoapi') assert isinstance(root, dict) @@ -171,49 +316,52 @@ def test_root_structured_data(config, api_): def test_conformance(config, api_): - req_headers = make_req_headers() - rsp_headers, code, response = api_.conformance(req_headers, {}) + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.conformance(req) root = json.loads(response) assert isinstance(root, dict) assert 'conformsTo' in root assert len(root['conformsTo']) == 16 - rsp_headers, code, response = api_.conformance(req_headers, {'f': 'foo'}) + req = make_request(ROUTE_OBS, {'f': 'foo'}) + rsp_headers, code, response = api_.conformance(req) assert code == 400 - rsp_headers, code, response = api_.conformance(req_headers, {'f': 'html'}) - assert rsp_headers['Content-Type'] == 'text/html' + req = make_request(ROUTE_OBS, {'f': 'html'}) + rsp_headers, code, response = api_.conformance(req) + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] + # No language requested: should be set to default from YAML + assert rsp_headers['Content-Language'] == 'en-US' def test_describe_collections(config, api_): - req_headers = make_req_headers() - rsp_headers, code, response = api_.describe_collections( - req_headers, {'f': 'foo'}) + req = make_request(ROUTE_OBS, {"f": "foo"}) + rsp_headers, code, response = api_.describe_collections(req) assert code == 400 - req_headers = make_req_headers() - rsp_headers, code, response = api_.describe_collections( - req_headers, {'f': 'html'}) - assert rsp_headers['Content-Type'] == 'text/html' + req = make_request(ROUTE_OBS, {"f": "html"}) + rsp_headers, code, response = api_.describe_collections(req) + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] - rsp_headers, code, response = api_.describe_collections(req_headers, {}) + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.describe_collections(req) collections = json.loads(response) assert len(collections) == 2 assert len(collections['collections']) == 5 assert len(collections['links']) == 3 - rsp_headers, code, response = api_.describe_collections( - req_headers, {}, 'foo') + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.describe_collections(req, 'foo') collection = json.loads(response) - assert code == 400 - rsp_headers, code, response = api_.describe_collections( - req_headers, {}, 'obs') + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.describe_collections(req, 'obs') collection = json.loads(response) + assert rsp_headers['Content-Language'] == 'en-US' assert collection['id'] == 'obs' assert collection['title'] == 'Observations' assert collection['description'] == 'My cool observations' @@ -231,12 +379,24 @@ def test_describe_collections(config, api_): } } - rsp_headers, code, response = api_.describe_collections( - req_headers, {'f': 'html'}, 'obs') - assert rsp_headers['Content-Type'] == 'text/html' + # French language request + req = make_request(ROUTE_OBS, {'lang': 'fr'}) + rsp_headers, code, response = api_.describe_collections(req, 'obs') + collection = json.loads(response) - rsp_headers, code, response = api_.describe_collections( - req_headers, {}, 'gdps-temperature') + assert rsp_headers['Content-Language'] == 'fr-CA' + assert collection['title'] == 'Observations' + assert collection['description'] == 'Mes belles observations' + + # Check HTML request in an unsupported language + req = make_request(ROUTE_OBS, {'f': 'html', 'lang': 'de'}) + rsp_headers, code, response = api_.describe_collections(req, 'obs') + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] + assert rsp_headers['Content-Language'] == 'en-US' + + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.describe_collections(req, + 'gdps-temperature') collection = json.loads(response) assert collection['id'] == 'gdps-temperature' @@ -244,18 +404,17 @@ def test_describe_collections(config, api_): def test_get_collection_queryables(config, api_): - req_headers = make_req_headers() - rsp_headers, code, response = api_.get_collection_queryables( - req_headers, {}, 'notfound') + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.get_collection_queryables(req, + 'notfound') assert code == 400 - req_headers = make_req_headers() - rsp_headers, code, response = api_.get_collection_queryables( - req_headers, {'f': 'html'}, 'obs') - assert rsp_headers['Content-Type'] == 'text/html' + req = make_request(ROUTE_OBS, {'f': 'html'}) + rsp_headers, code, response = api_.get_collection_queryables(req, 'obs') + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] - rsp_headers, code, response = api_.get_collection_queryables( - req_headers, {'f': 'json'}, 'obs') + req = make_request(ROUTE_OBS, {'f': 'json'}) + rsp_headers, code, response = api_.get_collection_queryables(req, 'obs') queryables = json.loads(response) assert 'properties' in queryables @@ -264,18 +423,19 @@ def test_get_collection_queryables(config, api_): # test with provider filtered properties api_.config['resources']['obs']['providers'][0]['properties'] = ['stn_id'] - rsp_headers, code, response = api_.get_collection_queryables( - req_headers, {'f': 'json'}, 'obs') + req = make_request(ROUTE_OBS, {'f': 'json'}) + rsp_headers, code, response = api_.get_collection_queryables(req, 'obs') queryables = json.loads(response) assert 'properties' in queryables assert len(queryables['properties']) == 1 + # No language requested: should be set to default from YAML + assert rsp_headers['Content-Language'] == 'en-US' def test_describe_collections_json_ld(config, api_): - req_headers = make_req_headers() - rsp_headers, code, response = api_.describe_collections( - req_headers, {'f': 'jsonld'}, 'obs') + req = make_request(ROUTE_OBS, {'f': 'jsonld'}) + rsp_headers, code, response = api_.describe_collections(req, 'obs') collection = json.loads(response) assert '@context' in collection @@ -304,57 +464,61 @@ def test_describe_collections_json_ld(config, api_): assert dataset['http://schema.org/temporalCoverage'][0][ '@value'] == '2000-10-30T18:24:39+00:00/2007-10-30T08:57:29+00:00' + # No language requested: should be set to default from YAML + assert rsp_headers['Content-Language'] == 'en-US' + def test_get_collection_items(config, api_): - req_headers = make_req_headers() - rsp_headers, code, response = api_.get_collection_items( - req_headers, {}, 'foo') + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.get_collection_items(req, 'foo') + features = json.loads(response) + assert code == 400 + + req = make_request(ROUTE_OBS, {'f': 'foo'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) assert code == 400 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'f': 'foo'}, 'obs') + req = make_request(ROUTE_OBS, {'bbox': '1,2,3'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) assert code == 400 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'bbox': '1,2,3'}, 'obs') - features = json.loads(response) + req = make_request(ROUTE_OBS, {'bbox': '1,2,3,4c'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 400 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'bbox': '1,2,3,4c'}, 'obs') + req = make_request(ROUTE_OBS, {'f': 'html', 'lang': 'fr'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] + assert rsp_headers['Content-Language'] == 'fr-CA' - assert code == 400 - - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'f': 'html'}, 'obs') - assert rsp_headers['Content-Type'] == 'text/html' - - rsp_headers, code, response = api_.get_collection_items( - req_headers, {}, 'obs') + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) + # No language requested: should be set to default from YAML + assert rsp_headers['Content-Language'] == 'en-US' assert len(features['features']) == 5 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'resulttype': 'hits'}, 'obs') + req = make_request(ROUTE_OBS, {'resulttype': 'hits'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) assert len(features['features']) == 0 # Invalid limit - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'limit': 0}, 'obs') + req = make_request(ROUTE_OBS, {'limit': 0}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) assert code == 400 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'limit': 2}, 'obs') + req = make_request(ROUTE_OBS, {'limit': 2}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) assert len(features['features']) == 2 @@ -374,14 +538,14 @@ def test_get_collection_items(config, api_): assert links[4]['rel'] == 'collection' # Invalid startindex - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'startindex': -1}, 'obs') + req = make_request(ROUTE_OBS, {'startindex': -1}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) assert code == 400 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'startindex': 2}, 'obs') + req = make_request(ROUTE_OBS, {'startindex': 2}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) assert len(features['features']) == 3 @@ -400,12 +564,12 @@ def test_get_collection_items(config, api_): assert '/collections/obs' in links[4]['href'] assert links[4]['rel'] == 'collection' - rsp_headers, code, response = api_.get_collection_items( - req_headers, { - 'startindex': 1, - 'limit': 1, - 'bbox': '-180,90,180,90' - }, 'obs') + req = make_request(ROUTE_OBS, { + 'startindex': 1, + 'limit': 1, + 'bbox': '-180,90,180,90' + }) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) assert len(features['features']) == 1 @@ -430,108 +594,106 @@ def test_get_collection_items(config, api_): assert '/collections/obs' in links[5]['href'] assert links[5]['rel'] == 'collection' - rsp_headers, code, response = api_.get_collection_items( - req_headers, { - 'sortby': 'bad-property', - 'stn_id': '35' - }, 'obs') + req = make_request(ROUTE_OBS, { + 'sortby': 'bad-property', + 'stn_id': '35' + }) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 400 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'sortby': 'stn_id'}, 'obs') + req = make_request(ROUTE_OBS, {'sortby': 'stn_id'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) assert code == 200 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'sortby': '+stn_id'}, 'obs') + req = make_request(ROUTE_OBS, {'sortby': '+stn_id'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) assert code == 200 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'sortby': '-stn_id'}, 'obs') + req = make_request(ROUTE_OBS, {'sortby': '-stn_id'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') features = json.loads(response) assert code == 200 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'f': 'csv'}, 'obs') + req = make_request(ROUTE_OBS, {'f': 'csv'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert rsp_headers['Content-Type'] == 'text/csv; charset=utf-8' - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'datetime': '2003'}, 'obs') + req = make_request(ROUTE_OBS, {'datetime': '2003'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 200 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'datetime': '1999'}, 'obs') + req = make_request(ROUTE_OBS, {'datetime': '1999'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 400 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'datetime': '2010-04-22'}, 'obs') + req = make_request(ROUTE_OBS, {'datetime': '2010-04-22'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 400 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'datetime': '2001-11-11/2003-12-18'}, 'obs') + req = make_request(ROUTE_OBS, {'datetime': '2001-11-11/2003-12-18'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 200 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'datetime': '../2003-12-18'}, 'obs') + req = make_request(ROUTE_OBS, {'datetime': '../2003-12-18'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 200 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'datetime': '2001-11-11/..'}, 'obs') + req = make_request(ROUTE_OBS, {'datetime': '2001-11-11/..'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 200 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'datetime': '1999/2005-04-22'}, 'obs') + req = make_request(ROUTE_OBS, {'datetime': '1999/2005-04-22'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 200 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'datetime': '1999/2000-04-22'}, 'obs') + req = make_request(ROUTE_OBS, {'datetime': '1999/2000-04-22'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 400 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'datetime': '2002/2014-04-22'}, 'obs') - api_.config['resources']['obs']['extents'].pop('temporal') - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'datetime': '2002/2014-04-22'}, 'obs') + req = make_request(ROUTE_OBS, {'datetime': '2002/2014-04-22'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 200 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'datetime': '2005-04-22'}, 'lakes') + req = make_request(ROUTE_OBS, {'datetime': '2005-04-22'}) + rsp_headers, code, response = api_.get_collection_items(req, 'lakes') assert code == 400 - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'skipGeometry': 'true'}, 'obs') + req = make_request(ROUTE_OBS, {'skipGeometry': 'true'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert json.loads(response)['features'][0]['geometry'] is None - rsp_headers, code, response = api_.get_collection_items( - req_headers, {'properties': 'foo,bar'}, 'obs') + req = make_request(ROUTE_OBS, {'properties': 'foo,bar'}) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') assert code == 400 def test_get_collection_items_json_ld(config, api_): - req_headers = make_req_headers() - rsp_headers, code, response = api_.get_collection_items( - req_headers, { - 'f': 'jsonld', - 'limit': 2 - }, 'obs') - assert rsp_headers['Content-Type'] == 'application/ld+json' + req = make_request(ROUTE_OBS, { + 'f': 'jsonld', + 'limit': 2 + }) + rsp_headers, code, response = api_.get_collection_items(req, 'obs') + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSONLD] + # No language requested: return default from YAML + assert rsp_headers['Content-Language'] == 'en-US' collection = json.loads(response) assert '@context' in collection @@ -562,44 +724,45 @@ def test_get_collection_items_json_ld(config, api_): def test_get_collection_item(config, api_): - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, {'f': 'foo'}) + rsp_headers, code, response = api_.get_collection_item(req, 'obs', '371') + + assert code == 400 + + req = make_request(ROUTE_OBS, {'f': 'json'}) rsp_headers, code, response = api_.get_collection_item( - req_headers, {'f': 'foo'}, 'obs', '371') + req, 'gdps-temperature', '371') + + assert code == 400 + + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.get_collection_item(req, 'foo', '371') assert code == 400 rsp_headers, code, response = api_.get_collection_item( - req_headers, {'f': 'json'}, 'gdps-temperature', '371') - - assert code == 400 - - rsp_headers, code, response = api_.get_collection_item( - req_headers, {}, 'foo', '371') - - assert code == 400 - - rsp_headers, code, response = api_.get_collection_item( - req_headers, {}, 'obs', 'notfound') + req, 'obs', 'notfound') assert code == 404 - rsp_headers, code, response = api_.get_collection_item( - req_headers, {'f': 'html'}, 'obs', '371') + req = make_request(ROUTE_OBS, {'f': 'html'}) + rsp_headers, code, response = api_.get_collection_item(req, 'obs', '371') - assert rsp_headers['Content-Type'] == 'text/html' + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] + assert rsp_headers['Content-Language'] == 'en-US' - rsp_headers, code, response = api_.get_collection_item( - req_headers, {}, 'obs', '371') + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.get_collection_item(req, 'obs', '371') feature = json.loads(response) assert feature['properties']['stn_id'] == '35' def test_get_collection_item_json_ld(config, api_): - req_headers = make_req_headers() - rsp_headers, code, response = api_.get_collection_item( - req_headers, {'f': 'jsonld'}, 'obs', '371') - assert rsp_headers['Content-Type'] == 'application/ld+json' + req = make_request(ROUTE_OBS, {'f': 'jsonld'}) + rsp_headers, code, response = api_.get_collection_item(req, 'obs', '371') + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSONLD] + assert rsp_headers['Content-Language'] == 'en-US' feature = json.loads(response) assert '@context' in feature assert feature['@context'][ @@ -619,16 +782,21 @@ def test_get_collection_item_json_ld(config, api_): assert expanded['https://purl.org/geojson/vocab#properties'][0][ 'https://schema.org/identifier'][0]['@value'] == '35' + req = make_request(ROUTE_OBS, {'f': 'jsonld', 'lang': 'fr'}) + rsp_headers, code, response = api_.get_collection_item(req, 'obs', '371') + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSONLD] + assert rsp_headers['Content-Language'] == 'fr-CA' + def test_get_coverage_domainset(config, api_): - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, {}) rsp_headers, code, response = api_.get_collection_coverage_domainset( - req_headers, {}, 'obs') + req, 'obs') assert code == 500 rsp_headers, code, response = api_.get_collection_coverage_domainset( - req_headers, {}, 'gdps-temperature') + req, 'gdps-temperature') domainset = json.loads(response) @@ -640,14 +808,14 @@ def test_get_coverage_domainset(config, api_): def test_get_collection_coverage_rangetype(config, api_): - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, {}) rsp_headers, code, response = api_.get_collection_coverage_rangetype( - req_headers, {}, 'obs') + req, 'obs') assert code == 500 rsp_headers, code, response = api_.get_collection_coverage_rangetype( - req_headers, {}, 'gdps-temperature') + req, 'gdps-temperature') rangetype = json.loads(response) @@ -659,29 +827,33 @@ def test_get_collection_coverage_rangetype(config, api_): def test_get_collection_coverage(config, api_): - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, {}) rsp_headers, code, response = api_.get_collection_coverage( - req_headers, {}, 'obs') + req, 'obs') assert code == 400 + req = make_request(ROUTE_OBS, {'rangeSubset': '12'}) rsp_headers, code, response = api_.get_collection_coverage( - req_headers, {'rangeSubset': '12'}, 'gdps-temperature') + req, 'gdps-temperature') assert code == 400 + req = make_request(ROUTE_OBS, {'subset': 'bad_axis(10:20)'}) rsp_headers, code, response = api_.get_collection_coverage( - req_headers, {'subset': 'bad_axis(10:20)'}, 'gdps-temperature') + req, 'gdps-temperature') assert code == 400 + req = make_request(ROUTE_OBS, {'f': 'blah'}) rsp_headers, code, response = api_.get_collection_coverage( - req_headers, {'f': 'blah'}, 'gdps-temperature') + req, 'gdps-temperature') assert code == 400 + req = make_request(ROUTE_OBS, {'subset': 'Lat(5:10),Long(5:10)'}) rsp_headers, code, response = api_.get_collection_coverage( - req_headers, {'subset': 'Lat(5:10),Long(5:10)'}, 'gdps-temperature') + req, 'gdps-temperature') assert code == 200 content = json.loads(response) @@ -692,8 +864,9 @@ def test_get_collection_coverage(config, api_): assert 'TMP' in content['ranges'] assert content['ranges']['TMP']['axisNames'] == ['y', 'x'] + req = make_request(ROUTE_OBS, {'bbox': '-79,45,-75,49'}) rsp_headers, code, response = api_.get_collection_coverage( - req_headers, {'bbox': '-79,45,-75,49'}, 'gdps-temperature') + req, 'gdps-temperature') assert code == 200 content = json.loads(response) @@ -703,67 +876,71 @@ def test_get_collection_coverage(config, api_): assert content['domain']['axes']['y']['start'] == 49.0 assert content['domain']['axes']['y']['stop'] == 45.0 + req = make_request(ROUTE_OBS, { + 'subset': 'Lat(5:10),Long(5:10)', + 'f': 'GRIB' + }) rsp_headers, code, response = api_.get_collection_coverage( - req_headers, {'subset': 'Lat(5:10),Long(5:10)', 'f': 'GRIB'}, - 'gdps-temperature') + req, 'gdps-temperature') assert code == 200 assert isinstance(response, bytes) - rsp_headers, code, response = api_.get_collection_coverage( - req_headers, {'subset': 'time("2006-07-01T06:00:00":"2007-07-01T06:00:00")'}, 'cmip5') # noqa + req = make_request(ROUTE_OBS, { + 'subset': 'time("2006-07-01T06:00:00":"2007-07-01T06:00:00")' + }) + rsp_headers, code, response = api_.get_collection_coverage(req, 'cmip5') assert code == 200 assert isinstance(json.loads(response), dict) - rsp_headers, code, response = api_.get_collection_coverage( - req_headers, {'subset': 'lat(1:2'}, 'cmip5') + req = make_request(ROUTE_OBS, {'subset': 'lat(1:2'}) + rsp_headers, code, response = api_.get_collection_coverage(req, 'cmip5') assert code == 400 - rsp_headers, code, response = api_.get_collection_coverage( - req_headers, {'subset': 'lat(1:2)'}, 'cmip5') + req = make_request(ROUTE_OBS, {'subset': 'lat(1:2)'}) + rsp_headers, code, response = api_.get_collection_coverage(req, 'cmip5') assert code == 204 def test_get_collection_tiles(config, api_): - req_headers = make_lakes_req_headers() - rsp_headers, code, response = api_.get_collection_tiles( - req_headers, {}, 'obs') - + req = make_request(ROUTE_LAKES, {}) + rsp_headers, code, response = api_.get_collection_tiles(req, 'obs') assert code == 400 - req_headers = make_lakes_req_headers() - rsp_headers, code, response = api_.get_collection_tiles( - req_headers, {}, 'lakes') - + rsp_headers, code, response = api_.get_collection_tiles(req, 'lakes') assert code == 200 + # Language settings should be ignored (return system default) + req = make_request(ROUTE_LAKES, {'lang': 'fr'}) + rsp_headers, code, response = api_.get_collection_tiles(req, 'lakes') + assert rsp_headers['Content-Language'] == 'en-US' + content = json.loads(response) + assert content['description'] == 'lakes of the world, public domain' + def test_describe_processes(config, api_): - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, {}) # Test for undefined process - rsp_headers, code, response = api_.describe_processes( - req_headers, {}, 'foo') + rsp_headers, code, response = api_.describe_processes(req, 'foo') data = json.loads(response) assert code == 404 assert data['code'] == 'NoSuchProcess' # Test for description of all processes - rsp_headers, code, response = api_.describe_processes( - req_headers, {}) + rsp_headers, code, response = api_.describe_processes(req) data = json.loads(response) assert code == 200 assert len(data['processes']) == 1 - # Test for particular, defined procss - rsp_headers, code, response = api_.describe_processes( - req_headers, {}, 'hello-world') + # Test for particular, defined process + rsp_headers, code, response = api_.describe_processes(req, 'hello-world') process = json.loads(response) assert code == 200 - assert rsp_headers['Content-Type'] == 'application/json' + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON] assert process['id'] == 'hello-world' assert process['version'] == '0.2.0' assert process['title'] == 'Hello World' @@ -777,45 +954,61 @@ def test_describe_processes(config, api_): assert 'async-execute' in process['jobControlOptions'] # Check HTML response when requested in headers - req_headers = make_req_headers(HTTP_ACCEPT='text/html') - rsp_headers, code, response = api_.describe_processes( - req_headers, {}, 'hello-world') + req = make_request(ROUTE_OBS, {}, HTTP_ACCEPT='text/html') + rsp_headers, code, response = api_.describe_processes(req, 'hello-world') assert code == 200 - assert rsp_headers['Content-Type'] == 'text/html' + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] + # No language requested: return default from YAML + assert rsp_headers['Content-Language'] == 'en-US' # Check JSON response when requested in headers - req_headers = make_req_headers(HTTP_ACCEPT='application/json') - rsp_headers, code, response = api_.describe_processes( - req_headers, {}, 'hello-world') + req = make_request(ROUTE_OBS, {}, HTTP_ACCEPT='application/json') + rsp_headers, code, response = api_.describe_processes(req, 'hello-world') assert code == 200 - assert rsp_headers['Content-Type'] == 'application/json' + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON] + assert rsp_headers['Content-Language'] == 'en-US' # Check HTML response when requested with query parameter - req_headers = make_req_headers() - rsp_headers, code, response = api_.describe_processes( - req_headers, {'f': 'html'}, 'hello-world') + req = make_request(ROUTE_OBS, {'f': 'html'}) + rsp_headers, code, response = api_.describe_processes(req, 'hello-world') assert code == 200 - assert rsp_headers['Content-Type'] == 'text/html' + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_HTML] + # No language requested: return default from YAML + assert rsp_headers['Content-Language'] == 'en-US' # Check JSON response when requested with query parameter - req_headers = make_req_headers() - rsp_headers, code, response = api_.describe_processes( - req_headers, {'f': 'json'}, 'hello-world') + req = make_request(ROUTE_OBS, {'f': 'json'}) + rsp_headers, code, response = api_.describe_processes(req, 'hello-world') assert code == 200 - assert rsp_headers['Content-Type'] == 'application/json' + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON] + assert rsp_headers['Content-Language'] == 'en-US' + + # Check JSON response when requested with French language parameter + req = make_request(ROUTE_OBS, {'lang': 'fr'}) + rsp_headers, code, response = api_.describe_processes(req, 'hello-world') + assert code == 200 + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON] + assert rsp_headers['Content-Language'] == 'fr-CA' + process = json.loads(response) + assert process['title'] == 'Bonjour le Monde' + + # Check JSON response when language requested in headers + req = make_request(ROUTE_OBS, {}, HTTP_ACCEPT_LANGUAGE='fr') + rsp_headers, code, response = api_.describe_processes(req, 'hello-world') + assert code == 200 + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON] + assert rsp_headers['Content-Language'] == 'fr-CA' # Test for undefined process - req_headers = make_req_headers() - rsp_headers, code, response = api_.describe_processes( - req_headers, {}, 'goodbye-world') + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.describe_processes(req, 'goodbye-world') data = json.loads(response) assert code == 404 assert data['code'] == 'NoSuchProcess' - assert rsp_headers['Content-Type'] == 'application/json' + assert rsp_headers['Content-Type'] == FORMAT_TYPES[F_JSON] def test_execute_process(config, api_): - req_headers = make_req_headers() req_body = { 'inputs': [{ 'id': 'name', @@ -866,24 +1059,24 @@ def test_execute_process(config, api_): cleanup_jobs = set() # Test posting empty payload to existing process - rsp_headers, code, response = api_.execute_process( - req_headers, {}, '', 'hello-world') + req = make_request(ROUTE_OBS, {}, '') + rsp_headers, code, response = api_.execute_process(req, 'hello-world') + assert rsp_headers['Content-Language'] == 'en-US' data = json.loads(response) assert code == 400 assert 'Location' not in rsp_headers assert data['code'] == 'MissingParameterValue' - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body), 'foo') + req = make_request(ROUTE_OBS, {}, req_body) + rsp_headers, code, response = api_.execute_process(req, 'foo') data = json.loads(response) assert code == 404 assert 'Location' not in rsp_headers assert data['code'] == 'NoSuchProcess' - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body), 'hello-world') + rsp_headers, code, response = api_.execute_process(req, 'hello-world') data = json.loads(response) assert code == 200 @@ -895,8 +1088,8 @@ def test_execute_process(config, api_): cleanup_jobs.add(tuple(['hello-world', rsp_headers['Location'].split('/')[-1]])) - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body_2), 'hello-world') + req = make_request(ROUTE_OBS, {}, req_body_2) + rsp_headers, code, response = api_.execute_process(req, 'hello-world') data = json.loads(response) assert code == 200 @@ -906,8 +1099,8 @@ def test_execute_process(config, api_): cleanup_jobs.add(tuple(['hello-world', rsp_headers['Location'].split('/')[-1]])) - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body_3), 'hello-world') + req = make_request(ROUTE_OBS, {}, req_body_3) + rsp_headers, code, response = api_.execute_process(req, 'hello-world') data = json.loads(response) assert code == 200 @@ -917,8 +1110,8 @@ def test_execute_process(config, api_): cleanup_jobs.add(tuple(['hello-world', rsp_headers['Location'].split('/')[-1]])) - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body_4), 'hello-world') + req = make_request(ROUTE_OBS, {}, req_body_4) + rsp_headers, code, response = api_.execute_process(req, 'hello-world') data = json.loads(response) assert code == 200 @@ -927,8 +1120,8 @@ def test_execute_process(config, api_): cleanup_jobs.add(tuple(['hello-world', rsp_headers['Location'].split('/')[-1]])) - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body_5), 'hello-world') + req = make_request(ROUTE_OBS, {}, req_body_5) + rsp_headers, code, response = api_.execute_process(req, 'hello-world') data = json.loads(response) assert code == 200 assert 'Location' in rsp_headers @@ -938,8 +1131,8 @@ def test_execute_process(config, api_): cleanup_jobs.add(tuple(['hello-world', rsp_headers['Location'].split('/')[-1]])) - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body_6), 'hello-world') + req = make_request(ROUTE_OBS, {}, req_body_6) + rsp_headers, code, response = api_.execute_process(req, 'hello-world') data = json.loads(response) assert code == 200 @@ -950,8 +1143,8 @@ def test_execute_process(config, api_): cleanup_jobs.add(tuple(['hello-world', rsp_headers['Location'].split('/')[-1]])) - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body_7), 'hello-world') + req = make_request(ROUTE_OBS, {}, req_body_7) + rsp_headers, code, response = api_.execute_process(req, 'hello-world') data = json.loads(response) assert code == 400 @@ -959,8 +1152,8 @@ def test_execute_process(config, api_): assert data['code'] == 'InvalidParameterValue' assert data['description'] == 'invalid request data' - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body_8), 'hello-world') + req = make_request(ROUTE_OBS, {}, req_body_8) + rsp_headers, code, response = api_.execute_process(req, 'hello-world') data = json.loads(response) assert code == 400 @@ -968,18 +1161,15 @@ def test_execute_process(config, api_): assert data['code'] == 'InvalidParameterValue' assert data['description'] == 'invalid request data' - # req_headers = make_req_headers() - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body), 'goodbye-world') + req = make_request(ROUTE_OBS, {}, req_body) + rsp_headers, code, response = api_.execute_process(req, 'goodbye-world') response = json.loads(response) assert code == 404 assert 'Location' not in rsp_headers assert response['code'] == 'NoSuchProcess' - req_headers = make_req_headers() - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body), 'hello-world') + rsp_headers, code, response = api_.execute_process(req, 'hello-world') response = json.loads(response) assert code == 200 @@ -987,16 +1177,13 @@ def test_execute_process(config, api_): cleanup_jobs.add(tuple(['hello-world', rsp_headers['Location'].split('/')[-1]])) - req_headers = make_req_headers() - req_body['mode'] = 'async' - rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body), 'hello-world') + req = make_request(ROUTE_OBS, {}, req_body) + rsp_headers, code, response = api_.execute_process(req, 'hello-world') assert 'Location' in rsp_headers response = json.loads(response) assert isinstance(response, dict) - assert code == 201 cleanup_jobs.add(tuple(['hello-world', @@ -1010,72 +1197,12 @@ def test_execute_process(config, api_): assert code == 200 -def test_check_format(): - args = {'f': 'html'} - - req_headers = {} - - assert check_format({}, req_headers) is None - - assert check_format(args, req_headers) == 'html' - - args['f'] = 'json' - assert check_format(args, req_headers) == 'json' - - args['f'] = 'jsonld' - assert check_format(args, req_headers) == 'jsonld' - - args['f'] = 'html' - assert check_format(args, req_headers) == 'html' - - req_headers['Accept'] = 'text/html' - assert check_format({}, req_headers) == 'html' - - req_headers['Accept'] = 'application/json' - assert check_format({}, req_headers) == 'json' - - req_headers['Accept'] = 'application/ld+json' - assert check_format({}, req_headers) == 'jsonld' - - req_headers['accept'] = 'text/html' - assert check_format({}, req_headers) == 'html' - - hh = 'text/html,application/xhtml+xml,application/xml;q=0.9,' - - req_headers['Accept'] = hh - assert check_format({}, req_headers) == 'html' - - req_headers['accept'] = hh - assert check_format({}, req_headers) == 'html' - - req_headers = make_req_headers(HTTP_ACCEPT=hh) - assert check_format({}, req_headers) == 'html' - - req_headers = make_req_headers(HTTP_ACCEPT='text/html') - assert check_format({}, req_headers) == 'html' - - req_headers = make_req_headers(HTTP_ACCEPT='application/json') - assert check_format({}, req_headers) == 'json' - - req_headers = make_req_headers(HTTP_ACCEPT='application/ld+json') - assert check_format({}, req_headers) == 'jsonld' - - # Overrule HTTP content negotiation - args['f'] = 'html' - assert check_format(args, req_headers) == 'html' - - req_headers = make_req_headers(HTTP_ACCEPT='text/html') - args['f'] = 'json' - assert check_format(args, req_headers) == 'json' - - def test_delete_process_job(api_): rsp_headers, code, response = api_.delete_process_job( 'does-not-exist', 'does-not-exist') assert code == 404 - req_headers = make_req_headers() req_body_sync = { 'inputs': [{ 'id': 'name', @@ -1091,8 +1218,9 @@ def test_delete_process_job(api_): }] } + req = make_request(ROUTE_OBS, {}, req_body_sync) rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body_sync), 'hello-world') + req, 'hello-world') data = json.loads(response) assert code == 200 @@ -1109,8 +1237,9 @@ def test_delete_process_job(api_): 'hello-world', job_id) assert code == 404 + req = make_request(ROUTE_OBS, {}, req_body_async) rsp_headers, code, response = api_.execute_process( - req_headers, {}, json.dumps(req_body_async), 'hello-world') + req, 'hello-world') assert code == 201 assert 'Location' in rsp_headers @@ -1128,9 +1257,8 @@ def test_delete_process_job(api_): def test_get_collection_edr_query(config, api_): # edr resource - req_headers = make_req_headers() - rsp_headers, code, response = api_.describe_collections( - req_headers, {}, 'icoads-sst') + req = make_request(ROUTE_OBS, {}) + rsp_headers, code, response = api_.describe_collections(req, 'icoads-sst') collection = json.loads(response) parameter_names = list(collection['parameter-names'].keys()) parameter_names.sort() @@ -1138,37 +1266,34 @@ def test_get_collection_edr_query(config, api_): assert parameter_names == ['AIRT', 'SST', 'UWND', 'VWND'] # no coords parameter - req_headers = make_req_headers() rsp_headers, code, response = api_.get_collection_edr_query( - req_headers, {}, 'icoads-sst', instance=None, query_type='position') + req, 'icoads-sst', None, 'position') assert code == 400 # bad query type - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, {'coords': 'POINT(11 11)'}) rsp_headers, code, response = api_.get_collection_edr_query( - req_headers, {'coords': 'POINT(11 11)'}, 'icoads-sst', instance=None, - query_type='corridor') + req, 'icoads-sst', None, 'corridor') assert code == 400 # bad coords parameter - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, {'coords': 'gah'}) rsp_headers, code, response = api_.get_collection_edr_query( - req_headers, {'coords': 'gah'}, 'icoads-sst', instance=None, - query_type='position') + req, 'icoads-sst', None, 'position') assert code == 400 # bad parameter-name parameter - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, { + 'coords': 'POINT(11 11)', 'parameter-name': 'bad' + }) rsp_headers, code, response = api_.get_collection_edr_query( - req_headers, {'coords': 'POINT(11 11)', 'parameter-name': 'bad'}, - 'icoads-sst', instance=None, query_type='position') + req, 'icoads-sst', None, 'position') assert code == 400 # all parameters - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, {'coords': 'POINT(11 11)'}) rsp_headers, code, response = api_.get_collection_edr_query( - req_headers, {'coords': 'POINT(11 11)'}, 'icoads-sst', instance=None, - query_type='position') + req, 'icoads-sst', None, 'position') assert code == 200 data = json.loads(response) @@ -1189,10 +1314,11 @@ def test_get_collection_edr_query(config, api_): assert parameters == ['AIRT', 'SST', 'UWND', 'VWND'] # single parameter - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, { + 'coords': 'POINT(11 11)', 'parameter-name': 'SST' + }) rsp_headers, code, response = api_.get_collection_edr_query( - req_headers, {'coords': 'POINT(11 11)', 'parameter-name': 'SST'}, - 'icoads-sst', instance=None, query_type='position') + req, 'icoads-sst', None, 'position') assert code == 200 data = json.loads(response) @@ -1201,26 +1327,21 @@ def test_get_collection_edr_query(config, api_): assert list(data['parameters'].keys())[0] == 'SST' # some data - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, { + 'coords': 'POINT(11 11)', 'datetime': '2000-01-16' + }) rsp_headers, code, response = api_.get_collection_edr_query( - req_headers, {'coords': 'POINT(11 11)', 'datetime': '2000-01-16'}, - 'icoads-sst', instance=None, query_type='position') + req, 'icoads-sst', None, 'position') assert code == 200 # no data - req_headers = make_req_headers() + req = make_request(ROUTE_OBS, { + 'coords': 'POINT(11 11)', 'datetime': '2000-01-17' + }) rsp_headers, code, response = api_.get_collection_edr_query( - req_headers, {'coords': 'POINT(11 11)', 'datetime': '2000-01-17'}, - 'icoads-sst', instance=None, query_type='position') + req, 'icoads-sst', None, 'position') assert code == 204 - # no data -# req_headers = make_req_headers() -# rsp_headers, code, response = api_.get_collection_edr_query( -# req_headers, {'coords': 'POINT(11 11)', 'datetime': '2000-01-15'}, -# 'icoads-sst', instance=None, query_type='position') -# assert code == 204 - def test_validate_bbox(): assert validate_bbox('1,2,3,4') == [1, 2, 3, 4] diff --git a/tests/test_l10n.py b/tests/test_l10n.py new file mode 100644 index 0000000..04025a2 --- /dev/null +++ b/tests/test_l10n.py @@ -0,0 +1,307 @@ +# ================================================================= +# +# Authors: Sander Schaminee +# +# Copyright (c) 2021 GeoCat BV +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= +import os + +from babel import Locale +from pygeoapi import l10n +from pygeoapi.util import yaml_load + +import pytest + + +def test_str2locale(): + us_locale = Locale.parse('en_US') + assert l10n.str2locale('en') == Locale.parse('en') + assert l10n.str2locale('en_US') == us_locale + assert l10n.str2locale('en-US') == us_locale + assert l10n.str2locale('eng_CA') == Locale.parse('en_CA') + assert l10n.str2locale(' fr-CH ') == Locale.parse('fr_CH') + assert l10n.str2locale(us_locale) is us_locale + + assert l10n.str2locale(None, True) is None + assert l10n.str2locale(42, True) is None + assert l10n.str2locale('is_BS', True) is None + + with pytest.raises(l10n.LocaleError): + for v in ('', None, 1, 42.0, 'is_BS', 'eng;CAN'): + l10n.str2locale(v) + + +def test_locale2str(): + assert l10n.locale2str(Locale.parse('en_US')) == 'en-US' + assert l10n.locale2str(Locale.parse('fr')) == 'fr' + + with pytest.raises(l10n.LocaleError): + for v in (None, 1, 42.0, 'is_BS', object()): + l10n.locale2str(v) # noqa + + +def test_bestmatch(): + assert l10n.best_match('de', ('en',)) == Locale('en') + assert l10n.best_match(None, ['en', 'de']) == Locale('en') # noqa + assert l10n.best_match('', ['en', 'de']) == Locale('en') + assert l10n.best_match('de-DE', ['en', 'de']) == Locale('de') + assert l10n.best_match('de-DE, en', ['en', 'de']) == Locale('de') + assert l10n.best_match('de, en', ['en_US', 'de-DE']) == Locale.parse('de_DE') # noqa + + assert l10n.best_match(Locale('de'), ['nl', 'de']) == Locale('de') + + accept = "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5" + assert l10n.best_match(accept, ['fr', 'en']) == Locale('fr') + assert l10n.best_match(accept, ['it', 'de']) == Locale('de') + assert l10n.best_match(accept, ['fr-BE', 'fr']) == Locale('fr') + assert l10n.best_match(accept, ['fr-BE', 'fr-FR']) == Locale.parse('fr_BE') + assert l10n.best_match(accept, ['fr-BE', 'fr-FR']) == Locale.parse('fr_BE') + assert l10n.best_match(accept, ['it', 'es']) == Locale('it') + assert l10n.best_match(accept, ['it', 'es']) == Locale('it') + assert l10n.best_match(accept, ('it', 'es')) == Locale('it') + + with pytest.raises(l10n.LocaleError): + l10n.best_match(accept, []) + l10n.best_match(accept, None) + l10n.best_match(accept, 42) + l10n.best_match(accept, ['is_BS']) + + +@pytest.fixture() +def language_struct(): + return {k: Locale.parse(k).display_name for k in ( + 'en', 'fr', 'en_US', 'fr_BE', 'alb', 'nl_BE' + )} + + +@pytest.fixture() +def nonlanguage_struct(): + return { + None: 'empty key', + 42: 'numeric key', + 'fla': 'non-language key' + } + + +def test_translate(language_struct, nonlanguage_struct): + assert l10n.translate({}, 'en-US') == {} + assert l10n.translate(42, 'fr') == 42 + assert l10n.translate(None, 'de') is None + assert l10n.translate(['list item'], Locale('en')) == ['list item'] + assert l10n.translate({'nested dict': {'en': 1, 'fr': 2}}, 'en') == {'nested dict': {'en': 1, 'fr': 2}} # noqa + + assert l10n.translate(nonlanguage_struct, 'fr') == nonlanguage_struct + assert l10n.translate(nonlanguage_struct, 'fla') == 'non-language key' + + assert l10n.translate(language_struct, 'en') == 'English' + assert l10n.translate(language_struct, 'en-US') == 'English (United States)' # noqa + assert l10n.translate(language_struct, 'sq_AL') == Locale.parse('alb').display_name # noqa + assert l10n.translate(language_struct, 'fr_CH') == Locale.parse('fr').display_name # noqa + assert l10n.translate(language_struct, 'nl') == Locale.parse('nl_BE').display_name # noqa + assert l10n.translate(language_struct, 'de') == 'English' + + assert l10n.translate(language_struct, Locale('en')) == 'English' + assert l10n.translate(language_struct, Locale.parse('en_US')) == 'English (United States)' # noqa + + with pytest.raises(l10n.LocaleError): + l10n.translate(language_struct, None) # noqa + l10n.translate(language_struct, 42) # noqa + + +def test_localefromheaders(): + assert l10n.locale_from_headers({}) is None + assert l10n.locale_from_headers({'Accept-Language': 'de'}) == 'de' + assert l10n.locale_from_headers({'accept-language': 'en_US'}) == 'en_US' + + +def test_localefromparams(): + assert l10n.locale_from_params({}) is None + assert l10n.locale_from_params({'lang': 'de'}) == 'de' + assert l10n.locale_from_params({'language': 'en_US'}) is None + assert l10n.locale_from_params({'lang': 'en_US'}) == 'en_US' + + +def test_addlocale(): + assert l10n.add_locale('http://a.pi/', None) == 'http://a.pi/' + assert l10n.add_locale('http://a.pi/', 'en') == 'http://a.pi/?lang=en' + assert l10n.add_locale('http://a.pi', 'de_CH') == 'http://a.pi?lang=de-CH' + assert l10n.add_locale('http://a.pi', 'zz') == 'http://a.pi' + assert l10n.add_locale('http://a.pi?q=1', 'nl') == 'http://a.pi?q=1&lang=nl' # noqa + assert l10n.add_locale('http://a.pi?lang=de', 'nl') == 'http://a.pi?lang=nl' # noqa + + +def test_getlocales(): + config = { + 'server': { + 'language': '' + } + } + with pytest.raises(l10n.LocaleError): + l10n.get_locales({}) + l10n.get_locales(config) + config['server']['language'] = 'zz' + l10n.get_locales(config) + + config['server']['language'] = 'en-US' + assert l10n.get_locales(config) == [Locale.parse('en_US')] + config['server']['language'] = 'de_CH' + assert l10n.get_locales(config) == [Locale.parse('de_CH')] + config['server']['language'] = ['de', 'en-US'] # noqa + assert l10n.get_locales(config) == [Locale.parse('de'), Locale.parse('en_US')] # noqa + + config = { + 'server': { + 'languages': [] + } + } + with pytest.raises(l10n.LocaleError): + l10n.get_locales(config) + + config['server']['languages'] = [None] + with pytest.raises(l10n.LocaleError): + l10n.get_locales(config) + + config['server']['languages'] = ['de', 'en-US'] + assert l10n.get_locales(config) == [Locale.parse('de'), Locale.parse('en_US')] # noqa + + +def test_getpluginlocale(): + assert l10n.get_plugin_locale({}, 'de') is None + assert l10n.get_plugin_locale({}, None) is None # noqa + assert l10n.get_plugin_locale({}, '') is None + assert l10n.get_plugin_locale({'language': 'de'}, 'en') == Locale('de') + assert l10n.get_plugin_locale({'language': None}, 'en') is None + assert l10n.get_plugin_locale({'languages': ['en']}, None) == Locale('en') # noqa + assert l10n.get_plugin_locale({'languages': []}, 'nl') is None + assert l10n.get_plugin_locale({'languages': ['en']}, 'fr') == Locale('en') + assert l10n.get_plugin_locale({'languages': ['en', 'de']}, 'de') == Locale('de') # noqa + assert l10n.get_plugin_locale({'languages': ['en', 'de']}, None) == Locale('en') # noqa + + +def test_setresponselanguage(): + # the following should not raise (only logs warning) + l10n.set_response_language(None, None) # noqa + + headers = {} + with pytest.raises(l10n.LocaleError): + l10n.set_response_language(headers, None) # noqa + l10n.set_response_language(headers, None, None) # noqa + l10n.set_response_language(headers, None, 'rubbish') # noqa + + l10n.set_response_language(headers, Locale('en')) + assert headers['Content-Language'] == 'en' + + l10n.set_response_language(headers, Locale('de')) + assert headers['Content-Language'] == 'de' + + l10n.set_response_language(headers, Locale('de'), Locale('en', 'US')) + assert headers['Content-Language'] == 'de, en-US' + + l10n.set_response_language(headers, Locale('en'), Locale('en')) + assert headers['Content-Language'] == 'en' + + +def get_test_file_path(filename): + """helper function to open test file safely""" + + if os.path.isfile(filename): + return filename + else: + return 'tests/{}'.format(filename) + + +@pytest.fixture() +def config(): + with open(get_test_file_path('pygeoapi-test-config.yml')) as fh: + return yaml_load(fh) + + +@pytest.fixture() +def locale_(): + return Locale.parse('en_US') + + +def test_translatedict(config, locale_): + cfg = l10n.translate_struct(config, locale_, True) + assert cfg['metadata']['identification']['title'] == 'pygeoapi default instance' # noqa + assert cfg['metadata']['identification']['keywords'] == ['geospatial', 'data', 'api'] # noqa + + # test full equality (must come from cache) + cfg2 = l10n.translate_struct(config, locale_, True) + assert cfg is cfg2 + + # missing locale_ should return the same dict + assert l10n.translate_struct(config, None) is config # noqa + + # missing or empty dict should return an empty dict + assert l10n.translate_struct(None, locale_) == {} # noqa + + # test custom dict (translate from level 0, do not cache) + test_dict = { + 'level0': { + 'en': 'test value', + 'fr': 'valeur de test' + } + } + tr_dict = l10n.translate_struct(test_dict, locale_) + assert tr_dict['level0'] == 'test value' + tr_dict2 = l10n.translate_struct(test_dict, locale_) + assert tr_dict == tr_dict2 + assert tr_dict is not tr_dict2 + + # test mixed structure + test_input = [ + {'test': { + 'en': 'test value', + 'fr': 'valeur de test' + }}, + 'some string', + {'item1': 1}, + {'item2a': [ + 'list_item1', + 'list_item2', + { + 'en': 'list value', + 'fr': 'valeur de liste' + } + ], + 'item2b': { + 'en': 'test value', + 'fr': 'valeur de test' + }} + ] + test_output = [ + {'test': 'test value'}, + 'some string', + {'item1': 1}, + {'item2a': [ + 'list_item1', + 'list_item2', + 'list value' + ], + 'item2b': 'test value' + } + ] + assert l10n.translate_struct(test_input, locale_) == test_output