Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] OGC API Features - Part 4 / Support for PostgreSQLProvider #1266

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
32154d0
feat: optimize @crs_transform decorator
MTachon Apr 13, 2023
412d53f
refactor: make _load_and_prepare_item more compact in providers' base…
MTachon Apr 17, 2023
fc7c853
Merge remote-tracking branch 'pygeoapi/master' into ogcapif-postgresq…
MTachon Jun 8, 2023
35d046d
Merge remote-tracking branch 'pygeoapi/master' into crs-transform-geo…
MTachon Jun 9, 2023
033ff70
test: database table and config for transactions with PostgreSQLProvider
MTachon Jun 15, 2023
eb17840
Remove id/identifier from json_data
MTachon Jun 15, 2023
466dd08
test: function to test item creation
MTachon Jun 16, 2023
bfb49f2
Fix typo in table creation statement
MTachon Jun 16, 2023
328ec59
Check that item to create has a 'type' member with 'Feature' as value
MTachon Jun 16, 2023
66960a4
Add 'create' method to PostgreSQLProvider
MTachon Jun 16, 2023
395c714
Merge branch 'crs-transform-geojson' into ogcapif-postgresql-transact…
MTachon Jun 19, 2023
032eefb
Add 'replace' action, crs support, update 'create' with SRID info for…
MTachon Jun 19, 2023
86713b0
Remove typing annotation not supported by python version
MTachon Jun 19, 2023
95d08e1
crs_transform in providers implementation instead, fixes circular imp…
MTachon Jun 19, 2023
0e767bb
Fix Find_SRID call
MTachon Jun 19, 2023
44f384e
Remove crs_transform_func parameter from _load_and_prepare_item calls
MTachon Jun 19, 2023
429c3f9
Add 'create' action to _load_and_prepare_item call
MTachon Jun 19, 2023
d534b9f
Refresh database object to get updated identifier
MTachon Jun 20, 2023
c4e8165
Use requests instead of urllib in test function
MTachon Jun 20, 2023
1286fd7
Test with 'verify=False' in requests.get
MTachon Jun 20, 2023
8b04ba2
Testing with get_collection_item call
MTachon Jun 20, 2023
87f5512
Fix KeyError
MTachon Jun 20, 2023
8296f6c
Change wrong hasattr assert statement
MTachon Jun 20, 2023
8fcf509
Add exception handling when attempting to create a new feature from i…
MTachon Jun 20, 2023
4827f46
Use only API methods to get back newly created features
MTachon Jun 20, 2023
e9947a8
Content-Crs as header and not parameter in mock_request
MTachon Jun 21, 2023
61a6dce
Change header name Content-Crs -> HTTP_CONTENT_CRS to make it work wi…
MTachon Jun 21, 2023
b42969e
Fix tinydb test and don't remove id in properties.identifier by defau…
MTachon Jun 21, 2023
66be0c7
Merge remote-tracking branch 'pygeoapi/master' into ogcapif-postgresq…
MTachon Jun 22, 2023
6ebe573
Add schemas.py and move SchemaType definition to the file
MTachon Jun 30, 2023
b1245b4
docs: add docstrings schemas.py and fix type annotation for python ve…
MTachon Jun 30, 2023
441ad04
Add test_schemas.py
MTachon Jun 30, 2023
71dafd3
test: refactor test_get_geometry_schema
MTachon Jul 3, 2023
874915d
Extend test for 'create' action, add test_manage_collection_items_pos…
MTachon Jul 6, 2023
f849a4b
Merge remote-tracking branch 'pygeoapi/master' into ogcapif-postgresq…
MTachon Jul 6, 2023
d591812
Merge remote-tracking branch 'pygeoapi/master' into ogcapif-postgresq…
MTachon Aug 21, 2023
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
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ jobs:
pytest tests/test_util.py
pytest tests/test_xarray_netcdf_provider.py
pytest tests/test_xarray_zarr_provider.py
pytest tests/test_schemas.py
- name: build docs 🏗️
run: cd docs && make html
- name: failed tests 🚩
Expand Down
69 changes: 59 additions & 10 deletions pygeoapi/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@
json_serial, render_j2_template, str2bool,
TEMPLATES, to_json, get_api_rules, get_base_url,
get_crs_from_uri, get_supported_crs_list,
CrsTransformSpec, transform_bbox)
get_transform_from_crs, CrsTransformSpec,
transform_bbox)

from pygeoapi.models.provider.base import TilesMetadataFormat

Expand Down Expand Up @@ -2104,7 +2105,8 @@ def manage_collection_item(
Adds an item to a collection

:param request: A request object
:param action: an action among 'create', 'update', 'delete', 'options'
:param action: an action among 'create', 'replace', 'update', 'delete',
'options'
:param dataset: dataset name

:returns: tuple of headers, status code, content
Expand Down Expand Up @@ -2160,17 +2162,44 @@ def manage_collection_item(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)

if action in ['create', 'update'] and not request.data:
msg = 'No data found'
LOGGER.error(msg)
return self.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)
crs_transform_func = None
if action in ('create', 'replace', 'update'):
if not request.data:
msg = 'No data found'
LOGGER.error(msg)
return self.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)

LOGGER.debug('Processing Content-Crs header')
content_crs_uri = request.headers.get('Content-Crs', DEFAULT_CRS)
supported_crs_list = get_supported_crs_list(
provider_def, DEFAULT_CRS_LIST,
)
if content_crs_uri not in supported_crs_list:
msg = (
f'Content-Crs {content_crs_uri} not supported for this '
'collection'
)
return self.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'NoApplicableCode', msg)
storage_crs_uri = provider_def.get(
'storage_crs', DEFAULT_STORAGE_CRS,
)
if content_crs_uri != storage_crs_uri:
content_crs = get_crs_from_uri(content_crs_uri)
storage_crs = get_crs_from_uri(storage_crs_uri)
crs_transform_func = get_transform_from_crs(
content_crs, storage_crs, geom_objects=False,
)

if action == 'create':
LOGGER.debug('Creating item')
try:
identifier = p.create(request.data)
identifier = p.create(
request.data, crs_transform_func=crs_transform_func,
)
except (ProviderInvalidDataError, TypeError) as err:
msg = str(err)
return self.get_exception(
Expand All @@ -2181,10 +2210,30 @@ def manage_collection_item(

return headers, HTTPStatus.CREATED, ''

if action == 'replace':
LOGGER.debug('Replacing item')
try:
p.replace(
identifier,
request.data,
crs_transform_func=crs_transform_func,
)
except (ProviderInvalidDataError, TypeError) as err:
msg = str(err)
return self.get_exception(
HTTPStatus.BAD_REQUEST, headers, request.format,
'InvalidParameterValue', msg)

return headers, HTTPStatus.NO_CONTENT, ''

if action == 'update':
LOGGER.debug('Updating item')
try:
_ = p.update(identifier, request.data)
p.update(
identifier,
request.data,
crs_transform_func=crs_transform_func,
)
except (ProviderInvalidDataError, TypeError) as err:
msg = str(err)
return self.get_exception(
Expand Down
6 changes: 5 additions & 1 deletion pygeoapi/flask_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,14 @@ def collection_items(collection_id, item_id=None):
return get_response(
api_.manage_collection_item(request, 'delete',
collection_id, item_id))
elif request.method == 'PUT':
elif request.method == 'PATCH':
return get_response(
api_.manage_collection_item(request, 'update',
collection_id, item_id))
elif request.method == 'PUT':
return get_response(
api_.manage_collection_item(request, 'replace',
collection_id, item_id))
elif request.method == 'OPTIONS':
return get_response(
api_.manage_collection_item(request, 'options',
Expand Down
3 changes: 2 additions & 1 deletion pygeoapi/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
from pygeoapi.models.openapi import OAPIFormat
from pygeoapi.plugin import load_plugin
from pygeoapi.process.manager.base import get_manager
from pygeoapi.provider.base import ProviderTypeError, SchemaType
from pygeoapi.provider.base import ProviderTypeError
from pygeoapi.schemas import SchemaType
from pygeoapi.util import (filter_dict_by_key_value, get_provider_by_type,
filter_providers_by_type, to_json, yaml_load,
get_api_rules, get_base_url)
Expand Down
78 changes: 40 additions & 38 deletions pygeoapi/provider/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,11 @@

import json
import logging
from enum import Enum

LOGGER = logging.getLogger(__name__)
from pygeoapi.schemas import SchemaType


class SchemaType(Enum):
item = 'item'
create = 'create'
update = 'update'
replace = 'replace'
LOGGER = logging.getLogger(__name__)


class BaseProvider:
Expand Down Expand Up @@ -196,25 +191,22 @@ def get_coverage_rangetype(self):

raise NotImplementedError()

def _load_and_prepare_item(self, item, identifier=None,
accept_missing_identifier=False,
raise_if_exists=True):
def _load_and_prepare_item(
self, action, item, identifier=None, accept_missing_identifier=False,
):
"""
Helper function to load a record, detect its idenfier and prepare
a record item

:param action: an action among 'create', 'replace', 'update'
:param item: `str` of incoming item data
:param identifier: `str` of item identifier (optional)
:param accept_missing_identifier: `bool` of whether a missing
identifier in item is valid
(typically for a create() method)
:param raise_if_exists: `bool` of whether to check if record
already exists
identifier in item is valid (typically for a create() method)

:returns: `tuple` of item identifier and item data/payload
"""

identifier2 = None
msg = None

LOGGER.debug('Loading data')
Expand All @@ -232,40 +224,50 @@ def _load_and_prepare_item(self, item, identifier=None,
raise ProviderInvalidDataError(msg)

LOGGER.debug('Detecting identifier')
if identifier is not None:
identifier2 = identifier
else:
try:
identifier2 = json_data['id']
except KeyError:
LOGGER.debug('Cannot find id; trying properties.identifier')
try:
identifier2 = json_data['properties']['identifier']
except KeyError:
LOGGER.debug('Cannot find properties.identifier')

if identifier2 is None and not accept_missing_identifier:
msg = 'Missing identifier (id or properties.identifier)'
LOGGER.error(msg)
raise ProviderInvalidDataError(msg)
if identifier is None:
identifier = json_data.pop('id', None)
if identifier is None:
LOGGER.debug('Cannot find id; trying properties.identifier')
identifier = json_data.get(
'properties', dict(),
).get('identifier', None)
if identifier is None:
LOGGER.debug('Cannot find properties.identifier')
if not accept_missing_identifier:
msg = 'Missing identifier (id or properties.identifier)'
LOGGER.error(msg)
raise ProviderInvalidDataError(msg)

if 'geometry' not in json_data or 'properties' not in json_data:
msg = 'Missing core GeoJSON geometry or properties'
LOGGER.error(msg)
raise ProviderInvalidDataError(msg)
if action != 'update':
LOGGER.debug('Check that the item is of "Feature" type')
if json_data.pop('type', None) != 'Feature':
msg = (
'Incorrect feature GeoJSON representation, missing "type" '
'member'
)
LOGGER.error(msg)
raise ProviderInvalidDataError(msg)
LOGGER.debug(
'Check the presence of "geometry" and "properties" GeoJSON '
'members'
)
if 'geometry' not in json_data or 'properties' not in json_data:
msg = 'Missing core GeoJSON geometry or properties'
LOGGER.error(msg)
raise ProviderInvalidDataError(msg)

if identifier2 is not None and raise_if_exists:
if identifier is not None and action == 'create':
LOGGER.debug('Querying database whether item exists')
try:
_ = self.get(identifier2)
_ = self.get(identifier)

msg = 'record already exists'
LOGGER.error(msg)
raise ProviderInvalidDataError(msg)
except ProviderItemNotFoundError:
LOGGER.debug('record does not exist')

return identifier2, json_data
return identifier, json_data

def __repr__(self):
return f'<BaseProvider> {self.type}'
Expand Down
44 changes: 34 additions & 10 deletions pygeoapi/provider/elasticsearch_.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
ProviderQueryError,
ProviderItemNotFoundError)
from pygeoapi.models.cql import CQLModel, get_next_node
from pygeoapi.util import get_envelope, crs_transform
from pygeoapi.util import get_envelope, crs_transform, crs_transform_feature


LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -394,46 +394,70 @@ def get(self, identifier, **kwargs):

return feature_

def create(self, item):
def create(self, item, crs_transform_func=None):
"""
Create a new item

:param item: `dict` of new item
:param crs_transform_func: `callable` to transform the coordinates of
the item's geometry, optional

:returns: identifier of created item
"""

identifier, json_data = self._load_and_prepare_item(
item, accept_missing_identifier=True)
'create',
item, accept_missing_identifier=True,
)
if identifier is None:
# If there is no incoming identifier, allocate a random one
identifier = str(uuid.uuid4())
json_data["id"] = identifier
if crs_transform_func is not None:
crs_transform_feature(json_data, crs_transform_func)

LOGGER.debug(f'Inserting data with identifier {identifier}')
_ = self.es.index(index=self.index_name, id=identifier, body=json_data)
LOGGER.debug('Item added')

return identifier

def update(self, identifier, item):
def replace(self, identifier, item, crs_transform_func=None):
"""
Replaces an existing item

:param identifier: feature id
:param item: `dict` of new item replacing existing item
:param crs_transform_func: `callable` to transform the coordinates of
the item's geometry, optional
"""

LOGGER.debug(f'Replacing item {identifier}')
identifier, json_data = self._load_and_prepare_item(
'replace', item, identifier,
)
if crs_transform_func is not None:
crs_transform_feature(json_data, crs_transform_func)
_ = self.es.index(index=self.index_name, id=identifier, body=json_data)

def update(self, identifier, item, crs_transform_func=None):
"""
Updates an existing item

:param identifier: feature id
:param item: `dict` of partial or full item

:returns: `bool` of update result
:param crs_transform_func: `callable` to transform the coordinates of
the item's geometry, optional
"""

LOGGER.debug(f'Updating item {identifier}')
identifier, json_data = self._load_and_prepare_item(
item, identifier, raise_if_exists=False)

'update', item, identifier,
)
if crs_transform_func is not None:
crs_transform_feature(json_data, crs_transform_func)
_ = self.es.index(index=self.index_name, id=identifier, body=json_data)

return True

def delete(self, identifier):
"""
Deletes an existing item
Expand Down
Loading
Loading