diff --git a/openeo_fastapi/api/app.py b/openeo_fastapi/api/app.py index dfd62e6..ac81460 100644 --- a/openeo_fastapi/api/app.py +++ b/openeo_fastapi/api/app.py @@ -1,10 +1,7 @@ -import re - import attr from attrs import define, field -from fastapi import APIRouter, Response +from fastapi import APIRouter, HTTPException, Response from starlette.responses import JSONResponse -from starlette.routing import Route from openeo_fastapi.client import models @@ -137,6 +134,21 @@ def register_core(self): self.register_get_processes() self.register_well_known() + def http_exception_handler(self, request, exception): + """Register exception handler to turn python exceptions into expected OpenEO error output.""" + exception_headers = { + "allow_origin": "*", + "allow_credentials": "true", + "allow_methods": "*", + } + from fastapi.encoders import jsonable_encoder + + return JSONResponse( + headers=exception_headers, + status_code=exception.status_code, + content=jsonable_encoder(exception.detail), + ) + def __attrs_post_init__(self): """Post-init hook. @@ -151,3 +163,4 @@ def __attrs_post_init__(self): self.register_get_capabilities() self.app.include_router(router=self.router) + self.app.add_exception_handler(HTTPException, self.http_exception_handler) diff --git a/openeo_fastapi/client/auth.py b/openeo_fastapi/client/auth.py index 97a98d5..4bd23ce 100644 --- a/openeo_fastapi/client/auth.py +++ b/openeo_fastapi/client/auth.py @@ -1,4 +1,4 @@ -from abc import ABC, abstractmethod, abstractproperty +from abc import ABC, abstractmethod from enum import Enum import requests diff --git a/openeo_fastapi/client/collections.py b/openeo_fastapi/client/collections.py index f2f6fe2..61b6e2b 100644 --- a/openeo_fastapi/client/collections.py +++ b/openeo_fastapi/client/collections.py @@ -1,17 +1,15 @@ -from typing import List - import aiohttp +from fastapi import HTTPException from openeo_fastapi.client.models import Collection, Collections, Endpoint from openeo_fastapi.client.register import EndpointRegister -from openeo_fastapi.client.settings import AppSettings class CollectionRegister(EndpointRegister): def __init__(self, settings) -> None: super().__init__() self.endpoints = self._initialize_endpoints() - self.settings: AppSettings = settings + self.settings = settings pass def _initialize_endpoints(self) -> list[Endpoint]: @@ -26,62 +24,60 @@ def _initialize_endpoints(self) -> list[Endpoint]: ), ] - async def get_collections(self): + async def _proxy_request(self, path): """ - Returns Basic metadata for all datasets + Proxy the request with aiohttp. """ - stac_url = ( - self.settings.STAC_API_URL - if self.settings.STAC_API_URL.endswith("/") - else self.settings.STAC_API_URL + "/" - ) - - try: - async with aiohttp.ClientSession() as client: - async with client.get(stac_url + "collections") as response: - resp = await response.json() - if response.status == 200 and resp.get("collections"): - collections_list = [] - for collection_json in resp["collections"]: - if ( - not self.settings.STAC_COLLECTIONS_WHITELIST - or collection_json["id"] - in self.settings.STAC_COLLECTIONS_WHITELIST - ): - collections_list.append(collection_json) - - return Collections( - collections=collections_list, links=resp["links"] - ) - else: - return {"Error": "No Collections found."} - except Exception as e: - raise Exception("Ran into: ", e) + async with aiohttp.ClientSession() as client: + async with client.get(self.settings.STAC_API_URL + path) as response: + resp = await response.json() + if response.status == 200: + return resp async def get_collection(self, collection_id): """ Returns Metadata for specific datasetsbased on collection_id (str). """ - stac_url = ( - self.settings.STAC_API_URL - if self.settings.STAC_API_URL.endswith("/") - else self.settings.STAC_API_URL + "/" + not_found = HTTPException( + status_code=404, + detail={ + "code": "NotFound", + "message": f"Collection {collection_id} not found.", + }, ) - try: - async with aiohttp.ClientSession() as client: - async with client.get( - stac_url + f"collections/{collection_id}" - ) as response: - resp = await response.json() - if response.status == 200 and resp.get("id"): - if ( - not self.settings.STAC_COLLECTIONS_WHITELIST - or resp["id"] in self.settings.STAC_COLLECTIONS_WHITELIST - ): - return Collection(**resp) - else: - return {"Error": "Collection not found."} + if ( + not self.settings.STAC_COLLECTIONS_WHITELIST + or collection_id in self.settings.STAC_COLLECTIONS_WHITELIST + ): + path = f"collections/{collection_id}" + resp = await self._proxy_request(path) + + if resp: + return Collection(**resp) + raise not_found + raise not_found + + async def get_collections(self): + """ + Returns Basic metadata for all datasets + """ + path = "collections" + resp = await self._proxy_request(path) + + if resp: + collections_list = [ + collection + for collection in resp["collections"] + if ( + not self.settings.STAC_COLLECTIONS_WHITELIST + or collection["id"] in self.settings.STAC_COLLECTIONS_WHITELIST + ) + ] - except Exception as e: - raise Exception("Ran into: ", e) + return Collections(collections=collections_list, links=resp["links"]) + else: + raise HTTPException( + status_code=404, + detail={"code": "NotFound", "message": "No Collections found."}, + ) diff --git a/openeo_fastapi/client/core.py b/openeo_fastapi/client/core.py index 8c28def..48c8740 100644 --- a/openeo_fastapi/client/core.py +++ b/openeo_fastapi/client/core.py @@ -17,7 +17,7 @@ class OpenEOCore: billing: str = field() links: list = field() - settings: AppSettings = field() + settings = AppSettings() _id: str = field(default="OpenEOApi") diff --git a/openeo_fastapi/client/processes.py b/openeo_fastapi/client/processes.py index e66bd00..daaaa32 100644 --- a/openeo_fastapi/client/processes.py +++ b/openeo_fastapi/client/processes.py @@ -1,8 +1,8 @@ import functools -from typing import List, Union +from typing import Union -import openeo_pg_parser_networkx import openeo_processes_dask.specs +from openeo_pg_parser_networkx import Process as pgProcess from openeo_pg_parser_networkx import ProcessRegistry from openeo_fastapi.client.models import Endpoint, Error, Process, ProcessesGetResponse @@ -36,9 +36,7 @@ def _create_process_registry(self): } for process_id, spec in predefined_processes_specs.items(): - process_registry[ - ("predefined", process_id) - ] = openeo_pg_parser_networkx.Process(spec) + process_registry[("predefined", process_id)] = pgProcess(spec) return process_registry diff --git a/openeo_fastapi/client/register.py b/openeo_fastapi/client/register.py index c1eca7b..3b78f8c 100644 --- a/openeo_fastapi/client/register.py +++ b/openeo_fastapi/client/register.py @@ -1,5 +1,4 @@ import abc -from typing import List from openeo_fastapi.client.models import Endpoint diff --git a/openeo_fastapi/client/settings.py b/openeo_fastapi/client/settings.py index bd0f353..0a82dd8 100644 --- a/openeo_fastapi/client/settings.py +++ b/openeo_fastapi/client/settings.py @@ -1,13 +1,16 @@ +from pathlib import Path from typing import Any, Optional -from pydantic import BaseSettings, HttpUrl +from pydantic import BaseSettings, HttpUrl, validator class AppSettings(BaseSettings): """Place to store application settings.""" - API_DNS = HttpUrl - API_TLS: str = "True" + API_DNS: HttpUrl + API_TLS: bool = True + + ALEMBIC_DIR: Path API_TITLE: str API_DESCRIPTION: str @@ -17,9 +20,16 @@ class AppSettings(BaseSettings): # External APIs STAC_VERSION: str = "1.0.0" - STAC_API_URL: Optional[HttpUrl] + STAC_API_URL: HttpUrl STAC_COLLECTIONS_WHITELIST: Optional[list[str]] = [] + @validator("STAC_API_URL") + @classmethod + def name_must_contain_space(cls, v: str) -> str: + if v.endswith("/"): + return v + return v.__add__("/") + class Config: @classmethod def parse_env_var(cls, field_name: str, raw_val: str) -> Any: diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 5b8da80..39df653 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -1,9 +1,9 @@ import os -from unittest import mock +from unittest.mock import patch import pytest from aioresponses import aioresponses -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from openeo_fastapi.api.app import OpenEOApi @@ -55,7 +55,7 @@ async def test_get_collections(collections_core, collections): @pytest.mark.asyncio async def test_get_collections_whitelist(collections_core, collections, s2a_collection): - with mock.patch.dict(os.environ, {"STAC_COLLECTIONS_WHITELIST": "Sentinel-2A"}): + with patch.dict(os.environ, {"STAC_COLLECTIONS_WHITELIST": "Sentinel-2A"}): with aioresponses() as m: get_collections_url = f"http://test-stac-api.mock.com/api/collections" m.get( @@ -100,3 +100,23 @@ def test_get_processes(core_api, app_settings): assert response.status_code == 200 assert "processes" in response.json().keys() + + +def test_exception_handler(core_api): + test_client = TestClient(core_api.app) + + # Define a route that raises an exception + @core_api.app.get("/test-exception") + def test_exception(): + raise HTTPException( + status_code=404, + detail={"code": "NotFound", "message": "This is a test exception"}, + ) + + response = test_client.get("/test-exception") + + assert response.status_code == 404 + + # Assert that the response body matches the expected response generated by the exception handler + expected_response = {"code": "NotFound", "message": "This is a test exception"} + assert response.json() == expected_response diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 8daf2bb..f1b5d93 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -1,5 +1,3 @@ -from unittest.mock import patch - import pytest from pydantic import ValidationError diff --git a/tests/conftest.py b/tests/conftest.py index 5e382ac..e5b8713 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,10 +10,6 @@ from fastapi import FastAPI from requests import Response -from openeo_fastapi.api.app import OpenEOApi -from openeo_fastapi.client import auth, models, settings -from openeo_fastapi.client.core import CollectionRegister, OpenEOCore - pytestmark = pytest.mark.unit path_to_current_file = os.path.realpath(__file__) current_directory = os.path.split(path_to_current_file)[0] @@ -26,32 +22,44 @@ fs = fsspec.filesystem(protocol="file") +SETTINGS_DICT = { + "API_DNS": "http://test.api.org", + "API_TLS": "False", + "API_TITLE": "Test Api", + "API_DESCRIPTION": "My Test Api", + "STAC_API_URL": "http://test-stac-api.mock.com/api/", + "ALEMBIC_DIR": str(ALEMBIC_DIR), +} + +os.environ["API_DNS"] = "http://test.api.org" +os.environ["API_TLS"] = "False" +os.environ["API_TITLE"] = "Test Api" +os.environ["API_DESCRIPTION"] = "My Test Api" +os.environ["STAC_API_URL"] = "http://test-stac-api.mock.com/api/" +os.environ["ALEMBIC_DIR"] = str(ALEMBIC_DIR) + +from openeo_fastapi.api.app import OpenEOApi +from openeo_fastapi.client import auth, models, settings +from openeo_fastapi.client.core import CollectionRegister, OpenEOCore + @pytest.fixture(autouse=True) def mock_settings_env_vars(): with mock.patch.dict( os.environ, - { - "API_DNS": "test.api.org", - "API_TLS": "False", - "API_TITLE": "Test Api", - "API_DESCRIPTION": "My Test Api", - "STAC_API_URL": "http://test-stac-api.mock.com/api/", - "ALEMBIC_DIR": str(ALEMBIC_DIR), - }, + SETTINGS_DICT, ): yield @pytest.fixture() def app_settings(): - return settings.AppSettings() + return settings.AppSettings(**SETTINGS_DICT) @pytest.fixture() def core_api(): client = OpenEOCore( - settings=settings.AppSettings(), links=[ models.Link( href="https://eodc.eu/",