Skip to content

Commit

Permalink
Enable return of 'compact' GeoJSON response
Browse files Browse the repository at this point in the history
  • Loading branch information
nikki-t committed May 8, 2024
1 parent 67ecda5 commit 6f82215
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 45 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
- Issue 101 - Add support for HTTP Accept header
- Issue 100 - Add option to 'compact' GeoJSON result into single feature
### Deprecated
### Removed
### Fixed
Expand Down
114 changes: 70 additions & 44 deletions hydrocron/api/controllers/timeseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def get_request_headers(event):
return headers


def get_request_parameters(event):
def get_request_parameters(event, accept_header):
"""Return request parameters from event object.
:param event: Request data dictionary
Expand All @@ -60,18 +60,22 @@ def get_request_parameters(event):
:rtype: dict
"""

parameters = {}
try:
feature = event['body']['feature']
feature_id = event['body']['feature_id']
start_time = event['body']['start_time']
end_time = event['body']['end_time']
output = 'default' if 'output' not in event['body'].keys() else event['body']['output']
fields = event['body']['fields']
parameters['feature'] = event['body']['feature']
parameters['feature_id'] = event['body']['feature_id']
parameters['start_time'] = event['body']['start_time']
parameters['end_time'] = event['body']['end_time']
parameters['output'] = 'default' if 'output' not in event['body'].keys() else event['body']['output']
parameters['fields'] = event['body']['fields']
parameters['compact'] = 'false' if 'compact' not in event['body'].keys() else event['body']['compact']
if accept_header == 'application/geo+json': # Default is different for geo+json
parameters['compact'] = 'true' if 'compact' not in event['body'].keys() else event['body']['compact']
except KeyError as e:
logging.error('Error encountered with request parameters: %s', e)
raise RequestError(f'400: This required parameter is missing: {e}') from e

parameters, error_message = validate_parameters(feature, feature_id, start_time, end_time, output, fields)
error_message = validate_parameters(parameters)
if error_message:
raise RequestError(error_message)

Expand Down Expand Up @@ -112,56 +116,42 @@ def get_return_type(accept_header, output):
return return_type, output


def validate_parameters(feature, feature_id, start_time, end_time, output, fields):
def validate_parameters(parameters):
"""
Determine if all parameters are present and in the correct format. Return 400
Bad Request if any errors are found alongside 0 hits.
:param feature: Data requested for Reach or Node or Lake
:type feature: str
:param feature_id: ID of the feature to retrieve
:type feature_id: str
:param start_time: Start time of the timeseries
:type start_time: str
:param end_time: End time of the timeseries
:type end_time: str
:param output: Format of the data returned
:type output: str
:param fields: List of requested columns
:type fields: str
:param parameters: Dictionary of query parameters
:type parameters: dict
:rtype: dict
:rtype: str
"""

parameters = {}
error_message = ''

if feature not in ('Node', 'Reach'):
error_message = f'400: feature parameter should be Reach or Node, not: {feature}'
if parameters['feature'] not in ('Node', 'Reach'):
error_message = f'400: feature parameter should be Reach or Node, not: {parameters["feature"]}'

elif not feature_id.isdigit():
error_message = f'400: feature_id cannot contain letters: {feature_id}'
elif not parameters['feature_id'].isdigit():
error_message = f'400: feature_id cannot contain letters: {parameters["feature_id"]}'

elif not is_date_valid(start_time) or not is_date_valid(end_time):
elif not is_date_valid(parameters['start_time']) or not is_date_valid(parameters['end_time']):
error_message = ('400: start_time and end_time parameters must conform '
'to format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DDTHH:MM:SS-00:00')

elif output not in ('csv', 'geojson', 'default'):
error_message = f'400: output parameter should be csv or geojson, not: {output}'
elif parameters['output'] not in ('csv', 'geojson', 'default'):
error_message = f'400: output parameter should be csv or geojson, not: {parameters["output"]}'

elif not is_fields_valid(feature, fields):
elif not is_fields_valid(parameters['feature'], parameters['fields']):
error_message = '400: fields parameter should contain valid SWOT fields'

elif parameters['compact'] not in ('true', 'false'):
error_message = f'400: compact parameter should be true or false, not {parameters["compact"]}'

else:
parameters['feature'] = feature
parameters['feature_id'] = feature_id
start_time, end_time = sanitize_time(start_time, end_time)
parameters['start_time'] = start_time
parameters['end_time'] = end_time
parameters['output'] = output
parameters['fields'] = fields
parameters['start_time'], parameters['end_time'] = sanitize_time(parameters['start_time'], parameters['end_time'])

return parameters, error_message
return error_message


def is_date_valid(query_date):
Expand Down Expand Up @@ -350,7 +340,7 @@ def add_units(gdf, columns):
return columns + unit_columns


def get_response(results, hits, elapsed, return_type, output):
def get_response(results, hits, elapsed, return_type, output, compact):
"""Create and return HTTP response based on results.
:param results: Dictionary of SWOT timeseries results
Expand All @@ -363,25 +353,32 @@ def get_response(results, hits, elapsed, return_type, output):
:type return_type: str
:param output: Output to return in request
:type output: str
:param compact: Whether to return compact GeoJSON response
:type compact: str
rtype: dict
"""

if results['http_code'] == '200 OK':

if return_type in ('text/csv', 'application/geo+json'):
data = results['response']
if compact == 'true' and return_type == 'application/geo+json':
data = compact_results(results['response'])
else:
data = results['response']

else: # 'application/json'
data = {
'status': results['http_code'],
'time': elapsed,
'hits': hits,
'results': {
'csv': "",
'csv': '',
'geojson': {}
}
}
if output == 'geojson' and compact == 'true':
results['response'] = compact_results(results['response'])
data['results'][output] = results['response']

else:
Expand All @@ -391,6 +388,35 @@ def get_response(results, hits, elapsed, return_type, output):
return data


def compact_results(results):
"""Compact GeoJSON results to return a properties object with aggregated
time series data.
:param results: Dictionary of SWOT timeseries results
:type results: dict
rtype: dict
"""

response = {
'type': 'FeatureCollection',
'features': [
{
'id': '0',
'type': 'Feature',
'properties': {},
'geometry': results['features'][0]['geometry'] # Grab first geometry
}
]
}

fields = list(results['features'][0]['properties'].keys())
for field in fields:
response['features'][0]['properties'][field] = [ feature['properties'][field] for feature in results['features'] ]

return response


def lambda_handler(event, context): # noqa: E501 # pylint: disable=W0613
"""
This function queries the database for relevant results
Expand All @@ -410,7 +436,7 @@ def lambda_handler(event, context): # noqa: E501 # pylint: disable=W0613
if event['body'] == {} and 'Elastic-Heartbeat' in headers['user_agent']:
return {}
logging.info('user_ip: %s', headers['user_ip'])
parameters = get_request_parameters(event)
parameters = get_request_parameters(event, headers['accept'])
return_type, output = get_return_type(headers['accept'], parameters['output'])
except RequestError as e:
raise e
Expand All @@ -428,7 +454,7 @@ def lambda_handler(event, context): # noqa: E501 # pylint: disable=W0613
elapsed = round((end - start) * 1000, 3)

try:
data = get_response(results, hits, elapsed, return_type, output)
data = get_response(results, hits, elapsed, return_type, output, parameters['compact'])
except RequestError as e:
raise e
logging.info('response: %s', json.dumps(data))
Expand Down
8 changes: 8 additions & 0 deletions terraform/api-specification-templates/hydrocron_aws_api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ paths:
type: string
default: feature_id, time_str, wse, geometry
example: feature_id, time_str, wse, geometry
- name: compact
in: query
description: Whether to return a compact GeoJSON response
required: false
schema:
type: boolean
default: false
example: true
responses:
"200":
description: OK
Expand Down
71 changes: 70 additions & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,8 @@ def test_timeseries_lambda_handler_geojson_accept(hydrocron_api):
"feature_id": "71224100223",
"start_time": "2023-06-04T00:00:00Z",
"end_time": "2023-06-23T00:00:00Z",
"fields": "reach_id,time_str,wse,sword_version,collection_shortname,crid"
"fields": "reach_id,time_str,wse,sword_version,collection_shortname,crid",
"compact": "false"
},
"headers": {
"User-Agent": "curl/8.4.0",
Expand Down Expand Up @@ -670,3 +671,71 @@ def test_timeseries_lambda_handler_reachid_not_found(hydrocron_api):
with pytest.raises(hydrocron.api.controllers.timeseries.RequestError) as e:
hydrocron.api.controllers.timeseries.lambda_handler(event, context)
assert "400: Results with the specified Feature ID 71224100228 were not found" in str(e.value)


def test_timeseries_lambda_handler_json_compact(hydrocron_api):
"""
Test the lambda handler for the timeseries endpoint
Parameters
----------
hydrocron_api: Fixture ensuring the database is configured for the api
"""
import hydrocron.api.controllers.timeseries

event = {
"body": {
"feature": "Reach",
"feature_id": "71224100223",
"start_time": "2023-06-04T00:00:00Z",
"end_time": "2023-06-23T00:00:00Z",
"output": "geojson",
"fields": "reach_id,time_str,wse,sword_version,collection_shortname,crid",
"compact": "true"
},
"headers": {
"User-Agent": "curl/8.4.0",
"X-Forwarded-For": "123.456.789.000"
}
}

context = "_"
result = hydrocron.api.controllers.timeseries.lambda_handler(event, context)
test_data = (pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
.joinpath('test_data').joinpath('api_query_results_geojson_compact.json'))
with open(test_data) as jf:
expected = json.load(jf)
assert result['status'] == '200 OK' and \
result['results']['geojson'] == expected


def test_timeseries_lambda_handler_geojson_accept_compact(hydrocron_api):
"""
Test the lambda handler for the timeseries endpoint
Parameters
----------
hydrocron_api: Fixture ensuring the database is configured for the api
"""
import hydrocron.api.controllers.timeseries

event = {
"body": {
"feature": "Reach",
"feature_id": "71224100223",
"start_time": "2023-06-04T00:00:00Z",
"end_time": "2023-06-23T00:00:00Z",
"fields": "reach_id,time_str,wse,sword_version,collection_shortname,crid",
},
"headers": {
"User-Agent": "curl/8.4.0",
"X-Forwarded-For": "123.456.789.000",
"Accept": "application/geo+json"
}
}

context = "_"
result = hydrocron.api.controllers.timeseries.lambda_handler(event, context)
test_data = (pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
.joinpath('test_data').joinpath('api_query_results_geojson_compact.json'))
with open(test_data) as jf:
expected = json.load(jf)
assert result == expected

0 comments on commit 6f82215

Please sign in to comment.