Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/source/crs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Metadata
The conformance class `http://www.opengis.net/spec/ogcapi-features-2/1.0/conf/crs` is present as a `conformsTo` field
in the root landing page response.

The configured CRSs, or their defaults, `crs` and `storageCRS` and optionally `storageCrsCoordinateEpoch` will be present in the "Describe Collection" response.
The configured CRSs, or their defaults, `crs` and `storageCrs` and optionally `storageCrsCoordinateEpoch` will be present in the "Describe Collection" response.

Parameters
----------
Expand Down Expand Up @@ -95,7 +95,7 @@ Suppose an addresses collection with the following CRS support in its collection
"http://www.opengis.net/def/crs/EPSG/0/28992",
"http://www.opengis.net/def/crs/OGC/1.3/CRS84"
],
"storageCRS": "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
"storageCrs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84"


This allows a `bbox-crs` query using Dutch "RD" coordinates with CRS `http://www.opengis.net/def/crs/EPSG/0/28992` to retrieve
Expand Down
3 changes: 0 additions & 3 deletions docs/source/data-publishing/ogcapi-features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -538,9 +538,6 @@ PostgreSQL

Must have PostGIS installed.

.. note::
Geometry must be using EPSG:4326

.. code-block:: yaml

providers:
Expand Down
42 changes: 42 additions & 0 deletions docs/source/data-publishing/ogcapi-tiles.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pygeoapi core tile providers are listed below, along with supported features.
`MVT-elastic`_,✅,✅,✅,❌,❌,✅
`MVT-proxy`_,❓,❓,❓,❓,❌,✅
`WMTSFacade`_,✅,❌,✅,✅,✅,❌
`MVT-postgresql`_,✅,✅,✅,✅,❌,✅

Below are specific connection examples based on supported providers.

Expand Down Expand Up @@ -130,6 +131,47 @@ Following code block shows how to configure pygeoapi to read Mapbox vector tiles
name: pbf
mimetype: application/vnd.mapbox-vector-tile

MVT-postgresql
^^^^^^^^^^^^^^

.. note::
Requires Python packages sqlalchemy, geoalchemy2 and psycopg2-binary

.. note::
Must have PostGIS installed with protobuf-c support

.. note::
Geometry must be using EPSG:4326

This provider gives support to serving tiles generated using `PostgreSQL <https://www.postgresql.org/>`_ with `PostGIS <https://postgis.net/>`_.
The tiles are rendered on-the-fly using `ST_AsMVT <https://postgis.net/docs/ST_AsMVT.html>`_ and related methods.

This code block shows how to configure pygeoapi to render Mapbox vector tiles from a PostGIS table.

.. code-block:: yaml

providers:
- type: tile
name: MVT-postgresql
data:
host: 127.0.0.1
port: 3010 # Default 5432 if not provided
dbname: test
user: postgres
password: postgres
search_path: [osm, public]
id_field: osm_id
table: hotosm_bdi_waterways
geom_field: foo_geom
options:
zoom:
min: 0
max: 15
format:
name: pbf
mimetype: application/vnd.mapbox-vector-tile

PostgreSQL-related connection options can also be added to `options`. Please refer to the :ref:`PostgreSQL OGC Features Provider<PostgreSQL>` documentation for more information.

WMTSFacade
^^^^^^^^^^
Expand Down
102 changes: 98 additions & 4 deletions pygeoapi/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@

from collections import ChainMap, OrderedDict
from copy import deepcopy
from datetime import datetime
from datetime import (datetime, timezone)
from functools import partial
from gzip import compress
from http import HTTPStatus
Expand Down Expand Up @@ -165,6 +165,32 @@ def apply_gzip(headers: dict, content: Union[str, bytes]) -> Union[str, bytes]:
return content


def pre_load_colls(func):
"""
Decorator function that makes sure the loaded collections are updated.
This is used when the resources are loaded dynamically, not strictly
from the yaml file.

:param func: decorated function

:returns: `func`
"""

def inner(*args, **kwargs):
cls = args[0]

# Validation on the method name for the provided class instance on this
# decoration function
if hasattr(cls, 'reload_resources_if_necessary'):
# Validate the resources are up to date
cls.reload_resources_if_necessary()

# Continue
return func(*args, **kwargs)

return inner


class APIRequest:
"""
Transforms an incoming server-specific Request into an object
Expand Down Expand Up @@ -565,9 +591,74 @@ def __init__(self, config, openapi):
self.tpl_config = deepcopy(self.config)
self.tpl_config['server']['url'] = self.base_url

# Now that the basic configuration is read, call the load_resources function. # noqa
# This call enables the api engine to load resources dynamically.
# This pattern allows for loading resources coming from another
# source (e.g. a database) rather than from the yaml file.
# This, along with the @pre_load_colls decorative function, enables
# resources management on multiple distributed pygeoapi instances.
self.load_resources()

self.manager = get_manager(self.config)
LOGGER.info('Process manager plugin loaded')

def on_load_resources(self, resources: dict) -> dict:
"""
Overridable function to load the available resources dynamically.
By default, this function simply returns the provided resources
as-is. This is the native behavior of the API; expecting
resources to be configured in the yaml config file.

:param resources: the resources as currently configured
(self.config['resources'])
:returns: the resources dictionary that's available in the API.
"""

# By default, return the same resources object, unchanged.
return resources

def on_load_resources_check(self, last_loaded_resources: datetime) -> bool: # noqa
"""
Overridable function to check if the resources should be reloaded.
Return True in your API implementation when resources should be
reloaded. This implementation depends on your environment and
messaging broker.
Natively, the resources used by the pygeoapi instance are strictly
the ones from the yaml configuration file. It doesn't support
resources changing on-the-fly. Therefore, False is returned here
and they are never reloaded.
"""

# By default, return False to not reload the resources.
return False

def load_resources(self) -> None:
"""
Calls on_load_resources and reassigns the resources configuration.
"""

# Call on_load_resources sending the current resources configuration.
self.config['resources'] = self.on_load_resources(self.config['resources']) # noqa

# Copy over for the template config also
# TODO: Check relevancy of this line
self.tpl_config['resources'] = deepcopy(self.config['resources'])

# Keep track of UTC date of last time resources were loaded
self.last_loaded_resources = datetime.now(timezone.utc)

def reload_resources_if_necessary(self) -> None:
"""
Checks if the resources should be reloaded by calling overridable
function 'on_load_resources_check' and then, when necessary, calls
'load_resources'.
"""

# If the resources should be reloaded
if self.on_load_resources_check(self.last_loaded_resources):
# Reload the resources
self.load_resources()

def get_exception(self, status, headers, format_, code,
description) -> Tuple[dict, int, str]:
"""
Expand Down Expand Up @@ -657,7 +748,7 @@ def _create_crs_transform_spec(

if not query_crs_uri:
if storage_crs_uri in DEFAULT_CRS_LIST:
# Could be that storageCRS is
# Could be that storageCrs is
# http://www.opengis.net/def/crs/OGC/1.3/CRS84h
query_crs_uri = storage_crs_uri
else:
Expand Down Expand Up @@ -714,7 +805,7 @@ def _set_content_crs_header(
# If empty use default CRS
storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS)
if storage_crs_uri in DEFAULT_CRS_LIST:
# Could be that storageCRS is one of the defaults like
# Could be that storageCrs is one of the defaults like
# http://www.opengis.net/def/crs/OGC/1.3/CRS84h
content_crs_uri = storage_crs_uri
else:
Expand Down Expand Up @@ -922,6 +1013,7 @@ def conformance(api, request: APIRequest) -> Tuple[dict, int, str]:


@jsonldify
@pre_load_colls
def describe_collections(api: API, request: APIRequest,
dataset=None) -> Tuple[dict, int, str]:
"""
Expand Down Expand Up @@ -1136,7 +1228,7 @@ def describe_collections(api: API, request: APIRequest,
# OAPIF Part 2 - list supported CRSs and StorageCRS
if collection_data_type in ['edr', 'feature']:
collection['crs'] = get_supported_crs_list(collection_data, DEFAULT_CRS_LIST) # noqa
collection['storageCRS'] = collection_data.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa
collection['storageCrs'] = collection_data.get('storage_crs', DEFAULT_STORAGE_CRS) # noqa
if 'storage_crs_coordinate_epoch' in collection_data:
collection['storageCrsCoordinateEpoch'] = collection_data.get('storage_crs_coordinate_epoch') # noqa

Expand Down Expand Up @@ -1425,6 +1517,8 @@ def get_collection_schema(api: API, request: Union[APIRequest, Any],

for k, v in p.fields.items():
schema['properties'][k] = v
if v['type'] == 'float':
schema['properties'][k]['type'] = 'number'
if v.get('format') is None:
schema['properties'][k].pop('format', None)

Expand Down
3 changes: 2 additions & 1 deletion pygeoapi/api/coverages.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@

from . import (
APIRequest, API, F_JSON, SYSTEM_LOCALE, validate_bbox, validate_datetime,
validate_subset
validate_subset, pre_load_colls
)

LOGGER = logging.getLogger(__name__)
Expand All @@ -68,6 +68,7 @@
]


@pre_load_colls
def get_collection_coverage(
api: API, request: APIRequest, dataset) -> Tuple[dict, int, str]:
"""
Expand Down
39 changes: 26 additions & 13 deletions pygeoapi/api/itemtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@

from . import (
APIRequest, API, SYSTEM_LOCALE, F_JSON, FORMAT_TYPES, F_HTML, F_JSONLD,
validate_bbox, validate_datetime
validate_bbox, validate_datetime, pre_load_colls
)

LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -100,6 +100,7 @@
]


@pre_load_colls
def get_collection_queryables(api: API, request: Union[APIRequest, Any],
dataset=None) -> Tuple[dict, int, str]:
"""
Expand Down Expand Up @@ -199,6 +200,8 @@ def get_collection_queryables(api: API, request: Union[APIRequest, Any],
'title': k,
'type': v['type']
}
if v['type'] == 'float':
queryables['properties'][k]['type'] = 'number'
if v.get('format') is not None:
queryables['properties'][k]['format'] = v['format']
if 'values' in v:
Expand Down Expand Up @@ -231,6 +234,7 @@ def get_collection_queryables(api: API, request: Union[APIRequest, Any],
return headers, HTTPStatus.OK, to_json(queryables, api.pretty_print)


@pre_load_colls
def get_collection_items(
api: API, request: Union[APIRequest, Any],
dataset) -> Tuple[dict, int, str]:
Expand Down Expand Up @@ -397,8 +401,8 @@ def get_collection_items(
# bbox but no bbox-crs param: assume bbox is in default CRS
bbox_crs = DEFAULT_CRS

# Transform bbox to storageCRS
# when bbox-crs different from storageCRS.
# Transform bbox to storageCrs
# when bbox-crs different from storageCrs.
if len(bbox) > 0:
try:
# Get a pyproj CRS instance for the Collection's Storage CRS
Expand Down Expand Up @@ -580,7 +584,21 @@ def get_collection_items(
'href': f'{uri}?f={F_HTML}{serialized_query_params}'
}])

if offset > 0:
next_link = False
prev_link = False

if 'next' in [link['rel'] for link in content['links']]:
LOGGER.debug('Using next link from provider')
else:
if content.get('numberMatched', -1) > (limit + offset):
next_link = True
elif len(content['features']) == limit:
next_link = True

if offset > 0:
prev_link = True

if prev_link:
prev = max(0, offset - limit)
content['links'].append(
{
Expand All @@ -590,13 +608,6 @@ def get_collection_items(
'href': f'{uri}?offset={prev}{serialized_query_params}'
})

next_link = False

if content.get('numberMatched', -1) > (limit + offset):
next_link = True
elif len(content['features']) == limit:
next_link = True

if next_link:
next_ = offset + limit
next_href = f'{uri}?offset={next_}{serialized_query_params}'
Expand Down Expand Up @@ -688,6 +699,7 @@ def get_collection_items(
return headers, HTTPStatus.OK, to_json(content, api.pretty_print)


@pre_load_colls
def manage_collection_item(
api: API, request: APIRequest,
action, dataset, identifier=None) -> Tuple[dict, int, str]:
Expand Down Expand Up @@ -799,6 +811,7 @@ def manage_collection_item(
return headers, HTTPStatus.OK, ''


@pre_load_colls
def get_collection_item(api: API, request: APIRequest,
dataset, identifier) -> Tuple[dict, int, str]:
"""
Expand Down Expand Up @@ -999,7 +1012,7 @@ def create_crs_transform_spec(

if not query_crs_uri:
if storage_crs_uri in DEFAULT_CRS_LIST:
# Could be that storageCRS is
# Could be that storageCrs is
# http://www.opengis.net/def/crs/OGC/1.3/CRS84h
query_crs_uri = storage_crs_uri
else:
Expand Down Expand Up @@ -1056,7 +1069,7 @@ def set_content_crs_header(
# If empty use default CRS
storage_crs_uri = config.get('storage_crs', DEFAULT_STORAGE_CRS)
if storage_crs_uri in DEFAULT_CRS_LIST:
# Could be that storageCRS is one of the defaults like
# Could be that storageCrs is one of the defaults like
# http://www.opengis.net/def/crs/OGC/1.3/CRS84h
content_crs_uri = storage_crs_uri
else:
Expand Down
3 changes: 2 additions & 1 deletion pygeoapi/api/maps.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
filter_dict_by_key_value
)

from . import APIRequest, API, validate_datetime
from . import APIRequest, API, validate_datetime, pre_load_colls

LOGGER = logging.getLogger(__name__)

Expand All @@ -60,6 +60,7 @@
]


@pre_load_colls
def get_collection_map(api: API, request: APIRequest,
dataset, style=None) -> Tuple[dict, int, str]:
"""
Expand Down
Loading