From ceeda64b43965ff3c093b3664b778ddfe5ae9b29 Mon Sep 17 00:00:00 2001 From: Lars Falk-Petersen Date: Tue, 9 Jan 2024 14:36:03 +0100 Subject: [PATCH] Openapi schema now seems to validate: -> % st run --checks all --experimental=openapi-3.1 --hypothesis-suppress-health-check=filter_too_much http://0.0.0.0:5000/openapi.json ======================================================= Schemathesis test session starts ====================================================== Schema location: http://0.0.0.0:5000/openapi.json Base URL: http://0.0.0.0:5000/ Specification version: Open API 3.1.0 Random seed: 205554008151466352027775861975580443555 Workers: 1 Collected API operations: 8 Collected API links: 0 GET / . [ 12%] GET /conformance/ . [ 25%] GET /collections/ . [ 37%] GET /collections/{collection_id}/ . [ 50%] GET /collections/{collection_id}/instances/{instance_id}/ . [ 62%] GET /collections/isobaric/position/ . [ 75%] GET /collections/isobaric/instances/{instance_id}/position . [ 87%] GET /collections/isobaric/instances/ . [100%] =================================================================== SUMMARY =================================================================== Performed checks: not_a_server_error 244 / 244 passed PASSED status_code_conformance 244 / 244 passed PASSED content_type_conformance 244 / 244 passed PASSED response_headers_conformance 244 / 244 passed PASSED response_schema_conformance 244 / 244 passed PASSED Experimental Features: - OpenAPI 3.1: Support for response validation Feedback: https://github.com/schemathesis/schemathesis/discussions/1822 Your feedback is crucial for experimental features. Please visit the provided URL(s) to share your thoughts. Tip: Use the `--report` CLI option to visualize test results via Schemathesis.io. We run additional conformance checks on reports from public repos. --- app/initialize.py | 23 ++++----- app/routes/position_page.py | 96 ++++++++++++++++++++++++++----------- app/test_app.py | 8 ++++ tox.ini | 2 +- 4 files changed, 90 insertions(+), 39 deletions(-) diff --git a/app/initialize.py b/app/initialize.py index 3c5e5fa..883688f 100644 --- a/app/initialize.py +++ b/app/initialize.py @@ -173,19 +173,28 @@ def format_instance_id(timestamp: datetime) -> str: return timestamp.strftime("%Y%m%d%H0000") -def check_instance_exists(ds: xr.Dataset, instance_id: str) -> Tuple[bool, str]: +def check_instance_exists(ds: xr.Dataset, instance_id: str) -> Tuple[bool, dict]: """Check instance id exists in dataset.""" instance_dates = [get_temporal_extent(ds)] for d in instance_dates: if format_instance_id(d) == instance_id: logger.info("instance_id %s is valid", instance_id) - return True, "" + return True, {} logger.error("instance_id %s does not exist in dataset", instance_id) valid_dates = [format_instance_id(x) for x in instance_dates] return ( False, - f"instance_id {instance_id} does not exist in dataset. Valid dates are {valid_dates}.", + { + "detail": [ + { + "type": "string", + "loc": ["query", "instance"], + "msg": f"instance_id {instance_id} does not exist in dataset. Valid dates are {valid_dates}.", + "input": instance_id, + } + ] + }, ) @@ -194,11 +203,3 @@ def check_instance_exists(ds: xr.Dataset, instance_id: str) -> Tuple[bool, str]: BASE_URL = args.base_url BIND_HOST = args.bind_host API_URL = args.api_url - -# Open datafile at start -# _ = get_dataset() - -# class InstanceID(str, Enum): -# """List of instances, created when opening data file.""" -# blank = "" -# default = get_temporal_extent(get_dataset()).strftime("%Y%m%d%H0000") diff --git a/app/routes/position_page.py b/app/routes/position_page.py index 1dfc0ba..fc98977 100644 --- a/app/routes/position_page.py +++ b/app/routes/position_page.py @@ -2,6 +2,7 @@ from typing import List, Tuple, Annotated import logging from fastapi import APIRouter, status, Response, Request, Query, Path +from fastapi.responses import JSONResponse import xarray as xr from pydantic import AwareDatetime from shapely import wkt, GEOSException, Point @@ -22,6 +23,8 @@ ISOBARIC_LABEL, ) +POINT_REGEX = "^POINT\\(\\d+\\.?\\d* \\d+\\.?\\d*\\)$" + router = APIRouter() logger = logging.getLogger() @@ -33,6 +36,7 @@ title="Instance ID, consisting of date in format %Y%m%d%H0000", ) + def create_point(coords: str, instance_id: str = "") -> dict: """Return data for all isometric layers at a point.""" # Parse coordinates given as WKT @@ -42,10 +46,22 @@ def create_point(coords: str, instance_id: str = "") -> dict: except GEOSException: errmsg = ( "Error, coords should be a Well Known Text, for example " - + f'"POINT(11.0 59.0)". You gave "{coords}"' + + f"POINT(11.0 59.0). You gave <{coords}>" ) logger.error(errmsg) - return Response(status_code=status.HTTP_400_BAD_REQUEST, content=errmsg) + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "detail": [ + { + "type": "string", + "loc": ["query", "coords"], + "msg": errmsg, + "input": coords, + } + ] + }, + ) logger.info("create_data for coord %s, %s", point.y, point.x) dataset = get_dataset() @@ -53,7 +69,9 @@ def create_point(coords: str, instance_id: str = "") -> dict: # Sanity check on coordinates coords_ok, errmsg = check_coords_within_bounds(dataset, point) if not coords_ok: - return Response(status_code=status.HTTP_400_BAD_REQUEST, content=errmsg) + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content=errmsg + ) # Sanity check on instance id if len(instance_id) > 0: @@ -197,17 +215,22 @@ def create_point(coords: str, instance_id: str = "") -> dict: return cov.model_dump(exclude_none=True) -def check_coords_within_bounds(ds: xr.Dataset, point: Point) -> Tuple[bool, str]: +def check_coords_within_bounds(ds: xr.Dataset, point: Point) -> Tuple[bool, dict]: """Check coordinates are within bounds of dataset.""" if ( point.y > ds[TEMPERATURE_LABEL][LAT_LABEL].values.max() or point.y < ds[TEMPERATURE_LABEL][LAT_LABEL].values.min() ): - errmsg = ( - f"Error, coord {point.y} out of bounds. Min/max is " - + f"{ds[TEMPERATURE_LABEL][LAT_LABEL].values.min()}/" - + f"{ds[TEMPERATURE_LABEL][LAT_LABEL].values.max()}" - ) + errmsg = { + "detail": [ + { + "loc": ["string", 0], + "msg": f"Error, coord {point.y} out of bounds. Min/max is {ds[TEMPERATURE_LABEL][LAT_LABEL].values.min()}/{ds[TEMPERATURE_LABEL][LAT_LABEL].values.max()}", + "type": "string", + "input": point.y, + } + ] + } logger.error(errmsg) return False, errmsg @@ -215,14 +238,19 @@ def check_coords_within_bounds(ds: xr.Dataset, point: Point) -> Tuple[bool, str] point.x > ds[TEMPERATURE_LABEL][LON_LABEL].values.max() or point.x < ds[TEMPERATURE_LABEL][LON_LABEL].values.min() ): - errmsg = ( - f"Error, coord {point.x} out of bounds. Min/max is " - + f"{ds[TEMPERATURE_LABEL][LON_LABEL].values.min()}/ " - + f"{ds[TEMPERATURE_LABEL][LON_LABEL].values.max()}" - ) + errmsg = { + "detail": [ + { + "loc": ["string", 0], + "msg": f"Error, coord {point.x} out of bounds. Min/max is {ds[TEMPERATURE_LABEL][LON_LABEL].values.min()}/{ds[TEMPERATURE_LABEL][LON_LABEL].values.max()}", + "type": "string", + "input": point.x, + } + ] + } logger.error(errmsg) return False, errmsg - return True, "" + return True, {} @router.get("/collections/isobaric/position/", response_model=Coverage) @@ -233,8 +261,8 @@ async def get_isobaric_page( Query( min_length=9, max_length=50, - pattern="^POINT\\(\\d+.?\\d+ \\d+.?\\d+\\)$", - title="Coordinates, formated as WKT POINT(11.9384 60.1699)", + pattern=POINT_REGEX, + title="Coordinates, formated as a WKT point: POINT(11.9384 60.1699)", ), ] = "POINT(11.9384 60.1699)", ) -> dict: @@ -243,9 +271,21 @@ async def get_isobaric_page( This is the main function of this API. Needs a string with the coordinates, formated as a WKT. Example: POINT(11.9384 60.1699) or POINT(11 60). """ if len(coords) == 0: - return { - "body": f'Error: No coordinates provided. Example: {str(request.base_url)[0:-1]}{request.scope["path"]}?coords=POINT(11.9384 60.1699)' - } + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "body": { + "detail": [ + { + "loc": ["string", 0], + "msg": f'Error: No coordinates provided. Example: {str(request.base_url)[0:-1]}{request.scope["path"]}?coords=POINT(11.9384 60.1699)', + "type": "string", + } + ] + } + }, + ) + return create_point(coords=coords) @@ -261,13 +301,15 @@ async def get_instance_isobaric_page( coords: Annotated[ str, Query( - min_length=9, max_length=50, pattern="^POINT\\(\\d+.?\\d+ \\d+.?\\d+\\)$", - examples={ - "example1": { - "summary": "First example", - "coords": "POINT(11.9384 60.1699)", - }, - }, + min_length=9, + max_length=50, + pattern=POINT_REGEX, + # examples={ + # "example1": { + # "summary": "First example", + # "values": "POINT(11.9384 60.1699)", + # }, + # }, ), ] = "POINT(11.9384 60.1699)", ) -> dict: diff --git a/app/test_app.py b/app/test_app.py index d11a481..1fb3a1e 100644 --- a/app/test_app.py +++ b/app/test_app.py @@ -30,6 +30,14 @@ def test_collections(self) -> None: # '{"links":[{"href":"http://localhost:5000/collections/","hreflang":"en","rel":"self","type":"aplication/json"}],"collections":[{"id":"isobaric","title":"IsobaricGRIB - GRIB files"... def test_point(self) -> None: + # Test various coord formats, which should all work + response = client.get(f"/collections/isobaric/position?coords=POINT(11 60)") + self.assertEqual(response.status_code, 200) + response = client.get(f"/collections/isobaric/position?coords=POINT(11.0 60.0)") + self.assertEqual(response.status_code, 200) + response = client.get(f"/collections/isobaric/position?coords=POINT(11. 60.)") + self.assertEqual(response.status_code, 200) + response = client.get(f"/collections/isobaric/position?{sample_coords}") self.assertEqual(response.status_code, 200) # Test for values in range -> temperature diff --git a/tox.ini b/tox.ini index 7f56d5b..0bd554e 100644 --- a/tox.ini +++ b/tox.ini @@ -49,4 +49,4 @@ commands = bandit --recursive {toxinidir}/app/ ; deps = ; -r requirements.txt ; -r requirements-dev.txt -; commands = st run --checks all --experimental=openapi-3.1 http://0.0.0.0:5000/openapi.json +; commands = st run --checks all --experimental=openapi-3.1 --hypothesis-suppress-health-check=filter_too_much http://0.0.0.0:5000/openapi.json