Multilingual support (alternative) (#664)
* Created localization (l10n) module + tests. Added l10n support to API and plugins (wip).
* Big refactor:
* All routed API methods are now decorated by @pre_process (consistency) and no longer have a headers+format argument but a request argument (**kwargs also removed)
* The pre_process decorator turns an incoming Flask/Starlette request into a generic APIRequest instance
* The new APIRequest class extracts all relevant info (params, data, locale, etc.) from the request and exposes them as properties
* Removed a lot of boilerplate (i.e. format checking) and wrapped that into methods
* Updated server-specific API calls in each route method (pass entire request object, not headers and query params)
* Several improvements and fixes:
* Updated OpenAPI page with "l" query param
* Added example translations (metadata)
* Changed plugin signature: added explicit locale attribute (instead of **kwargs)
* Moved locale processing to get_plugin_locale() function in l10n module
* API should pass raw requested locale to plugins, locale should always be set
* Fixed API tests and added APIRequest tests
* Prepared utils.py for Jinja2 i18n extension
* Rebased on commit b40297a8 and fixed compatibility with #661 and #662
* Updated documentation for language support
* Rebased and fixed compatibility with PR #658:
* Fixed EDR provider signature (added locale)
* Fixed EDR API routes and query function (and improved parameter-name handling)
* Fixed EDR tests
* Translate entire config in render_j2_template for requested locale:
* Added new translate_dict function to l10n module (+ tests)
* Updated all render_j2_template calls with locale parameter
* Updated pygeoapi-test-config.yml with some language structs
* Minor improvements
* support both 'language' and 'languages' property in server config and provider definitions
* renamed and modified translate_dict() to more generic translate_struct() function (l10n module)
* remove Content-Language header from provider responses if provider has no language support and format is json(ld)
* updated tests
* Leave provider locale handling to API
* Moved code to determine locale from providers to API class (and remove for formatters and processes)
* Removed locale parameter from plugin __init__ signatures
* Removed locale parameter from load_plugin()
* Added **kwargs to provider implementations for get, query, get_metadata, get_coverage_domainset and get_coverage_rangetype method signatures
* Added language=<locale> to all API calls to provider get, query, get_metadata, get_coverage_domainset and get_coverage_rangetype methods
* Use 'lang' instead of 'l' as language query parameter
* Updated Open API
* Updated documentation
* Fixed tests
* Implemented requested PR changes:
* Added usage examples to the APIRequest docstring
* Removed language support from coverage functions
* Updated plugins.rst and language.rst to match new behavior
* Removed language struct from resource links in pygeoapi-config.yml
* Rebased on latest master (fixed test_api.py)
* Rebased and applied fixes:
* Data property in APIRequest now is an awaitable attribute (fixed for Starlette compatibility)
* Named references to 'l' parameter to 'lang'
* Final changes/improvements:
* Make sure that Content-Language is always set;
* Added more tests to ensure that the default language returned is the first configured language (if no language was requested by the user);
* Updated docs;
* Replaced re-occuring strings with constants in api.py;
* Fixed Flake8 checks.
* add missing async to starlette routes (#704)
Co-authored-by: Tom Kralidis <tomkralidis@gmail.com>
This commit is contained in:
@@ -29,6 +29,7 @@ pygeoapi |release| documentation
|
||||
data-publishing/index
|
||||
plugins
|
||||
html-templating
|
||||
language
|
||||
development
|
||||
ogc-compliance
|
||||
contributing
|
||||
|
||||
@@ -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=<code>`` query parameter, where ``<code>`` 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 <https://www.w3.org/International/articles/language-tags/>`_ for more information or
|
||||
this `list of language codes <http://www.lingoes.net/en/translator/langcode.htm>`_ 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 <http://babel.pocoo.org/en/latest/api/core.html#babel.core.Locale>`_.
|
||||
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.
|
||||
@@ -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<language>`.
|
||||
|
||||
|
||||
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':
|
||||
|
||||
+47
-13
@@ -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]
|
||||
|
||||
+1041
-749
File diff suppressed because it is too large
Load Diff
+49
-201
@@ -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/<collection_id>/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/<collection_id>/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/<collection_id>/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/<collection_id>/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/<collection_id>/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/<collection_id>/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/<collection_id>/tiles/<tileMatrixSetId>/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/<collection_id>/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/<process_id>/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/<process_id>/jobs/<job_id>/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/<process_id>/jobs/<job_id>/results/<resource>',
|
||||
@@ -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/<collection_id>/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/<path:path>')
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
# =================================================================
|
||||
#
|
||||
# Authors: Sander Schaminee <sander.schaminee@geocat.net>
|
||||
#
|
||||
# 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=<locale>' 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
|
||||
+26
-15
@@ -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
|
||||
|
||||
+104
-70
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+69
-189
@@ -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()
|
||||
|
||||
+15
-5
@@ -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):
|
||||
|
||||
@@ -8,3 +8,4 @@ rasterio
|
||||
shapely
|
||||
tinydb
|
||||
unicodecsv
|
||||
Babel
|
||||
|
||||
@@ -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:
|
||||
|
||||
+483
-362
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,307 @@
|
||||
# =================================================================
|
||||
#
|
||||
# Authors: Sander Schaminee <sander.schaminee@geocat.net>
|
||||
#
|
||||
# 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
|
||||
Reference in New Issue
Block a user