From 7a307f563d5cebb4357520603df8f1486dcdc1de Mon Sep 17 00:00:00 2001 From: sean Date: Mon, 4 Dec 2023 14:42:21 +0000 Subject: [PATCH 01/12] accept reformatting --- openeo_fastapi/api/app.py | 26 ++++++++++++++++++++++++++ openeo_fastapi/client/__init__.py | 0 openeo_fastapi/client/core.py | 15 +++++++++++++++ pyproject.toml | 2 ++ tests/conftest.py | 14 ++++++++++++++ tests/test_api.py | 24 ++++++++++++++++++++++++ tests/test_base.py | 5 ----- 7 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 openeo_fastapi/api/app.py create mode 100644 openeo_fastapi/client/__init__.py create mode 100644 openeo_fastapi/client/core.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py delete mode 100644 tests/test_base.py diff --git a/openeo_fastapi/api/app.py b/openeo_fastapi/api/app.py new file mode 100644 index 0000000..ab68c34 --- /dev/null +++ b/openeo_fastapi/api/app.py @@ -0,0 +1,26 @@ +from attrs import Factory, define, field +from fastapi import FastAPI, Response + + +@define +class OpenEOApi: + """Factory for creating FastApi applications conformant to the OpenEO Api specification.""" + + client: field() + app: field(default=Factory(lambda self: FastAPI)) + + def register_get_capabilities(self): + """Register landing page (GET /). + + Returns: + None + """ + self.app.add_api_route( + name="capabilities", + path="/", + response_class=Response, + response_model_exclude_unset=False, + response_model_exclude_none=True, + methods=["GET"], + endpoint=self.client.get_capabilities, + ) diff --git a/openeo_fastapi/client/__init__.py b/openeo_fastapi/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openeo_fastapi/client/core.py b/openeo_fastapi/client/core.py new file mode 100644 index 0000000..df65b02 --- /dev/null +++ b/openeo_fastapi/client/core.py @@ -0,0 +1,15 @@ +import abc +import json + +from attrs import define +from fastapi import Response + + +@define +class OpenEOCore: + """Base client for the OpenEO Api.""" + + @abc.abstractmethod + def get_capabilities(self): + """ """ + return Response(status_code=200, content=json.dumps({"version": "1"})) diff --git a/pyproject.toml b/pyproject.toml index f90b9a7..1a23b73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ readme = "README.md" python = ">=3.9,<3.12" fastapi = "^0.95.1" pydantic = "<2" +attrs = "^23.1.0" +httpx = "^0.24.1" [tool.poetry.group.dev.dependencies] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9b907ae --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +import pytest +from fastapi import FastAPI + +from openeo_fastapi.api.app import OpenEOApi +from openeo_fastapi.client.core import OpenEOCore + + +@pytest.fixture() +def core_api(): + client = OpenEOCore() + + api = OpenEOApi(client=client, app=FastAPI()) + + return api diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..c5a5d78 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,24 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from openeo_fastapi.api.app import OpenEOApi + + +def test_api_core(core_api): + """Test the OpenEOApi and OpenEOCore classes interact as intended.""" + + assert isinstance(core_api, OpenEOApi) + assert isinstance(core_api.app, FastAPI) + + +def test_get_capabilities(core_api): + """Test the OpenEOApi and OpenEOCore classes interact as intended.""" + + core_api.register_get_capabilities() + + test_app = TestClient(core_api.app) + + response = test_app.get("/") + + assert response.status_code == 200 + assert response.text == '{"version": "1"}' diff --git a/tests/test_base.py b/tests/test_base.py deleted file mode 100644 index b12cdd6..0000000 --- a/tests/test_base.py +++ /dev/null @@ -1,5 +0,0 @@ -import pytest - - -def test_setup(): - assert True From 08555681f0372e920b30873d6240e9f4fe9d0ab9 Mon Sep 17 00:00:00 2001 From: sean Date: Mon, 4 Dec 2023 16:21:48 +0000 Subject: [PATCH 02/12] accept reformatting --- openeo_fastapi/api/app.py | 8 +- openeo_fastapi/client/core.py | 30 ++++- openeo_fastapi/client/models.py | 208 ++++++++++++++++++++++++++++++++ tests/conftest.py | 28 ++++- tests/test_api.py | 6 +- 5 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 openeo_fastapi/client/models.py diff --git a/openeo_fastapi/api/app.py b/openeo_fastapi/api/app.py index ab68c34..04e5de4 100644 --- a/openeo_fastapi/api/app.py +++ b/openeo_fastapi/api/app.py @@ -1,6 +1,8 @@ from attrs import Factory, define, field from fastapi import FastAPI, Response +from openeo_fastapi.client import models + @define class OpenEOApi: @@ -9,6 +11,10 @@ class OpenEOApi: client: field() app: field(default=Factory(lambda self: FastAPI)) + def _route_filter(self): + """ """ + pass + def register_get_capabilities(self): """Register landing page (GET /). @@ -18,7 +24,7 @@ def register_get_capabilities(self): self.app.add_api_route( name="capabilities", path="/", - response_class=Response, + response_model=models.Capabilities, response_model_exclude_unset=False, response_model_exclude_none=True, methods=["GET"], diff --git a/openeo_fastapi/client/core.py b/openeo_fastapi/client/core.py index df65b02..aa6a3bf 100644 --- a/openeo_fastapi/client/core.py +++ b/openeo_fastapi/client/core.py @@ -1,15 +1,39 @@ import abc import json +from typing import Optional, Union -from attrs import define +from attrs import define, field from fastapi import Response +from openeo_fastapi.client import models + @define class OpenEOCore: """Base client for the OpenEO Api.""" + # TODO. Improve. Not quite sure about setting these here. + backend_version: str = field() + billing: str = field() + endpoints: list = field() + links: list = field() + _id: str = field(default="OpenEOApi") + title: str = field(default="OpenEO FastApi") + description: str = field(default="Implemented from the OpenEO FastAPi package.") + stac_version: str = field(default="1.0.0") + api_version: str = field(default="1.1.0") + @abc.abstractmethod - def get_capabilities(self): + def get_capabilities(self) -> models.Capabilities: """ """ - return Response(status_code=200, content=json.dumps({"version": "1"})) + return models.Capabilities( + id=self._id, + title=self.title, + stac_version=self.stac_version, + api_version=self.api_version, + description=self.description, + backend_version=self.backend_version, + billing=self.billing, + links=self.links, + endpoints=self.endpoints, + ) diff --git a/openeo_fastapi/client/models.py b/openeo_fastapi/client/models.py new file mode 100644 index 0000000..0bc7c4f --- /dev/null +++ b/openeo_fastapi/client/models.py @@ -0,0 +1,208 @@ +from enum import Enum +from pathlib import Path +from typing import List, Optional, Union + +from pydantic import AnyUrl, BaseModel, Field, constr + + +class Type5(Enum): + Catalog = "Catalog" + + +class Link(BaseModel): + rel: str = Field( + ..., + description="Relationship between the current document and the linked document. SHOULD be a [registered link relation type](https://www.iana.org/assignments/link-relations/link-relations.xml) whenever feasible.", + example="related", + ) + href: Union[AnyUrl, Path] = Field( + ..., + description="The value MUST be a valid URL.", + example="https://example.openeo.org", + ) + type: Optional[str] = Field( + None, + description="The value MUST be a string that hints at the format used to represent data at the provided URI, preferably a media (MIME) type.", + example="text/html", + ) + title: Optional[str] = Field( + None, description="Used as a human-readable label for a link.", example="openEO" + ) + + +class StacVersion(BaseModel): + __root__: constr(regex=r"^(0\.9.\d+|1\.\d+.\d+)") = Field( + ..., + description="The [version of the STAC specification](https://github.com/radiantearth/stac-spec/releases), which MAY not be equal to the [STAC API version](#section/STAC). Supports versions 0.9.x and 1.x.x.", + ) + + +class Production(BaseModel): + __root__: bool = Field( + ..., + description="Specifies whether the implementation is ready to be used in production use (`true`) or not (`false`).\nClients SHOULD only connect to non-production implementations if the user explicitly confirmed to use a non-production implementation.\nThis flag is part of `GET /.well-known/openeo` and `GET /`. It MUST be used consistently in both endpoints.", + ) + + +class Method(Enum): + GET = "GET" + HEAD = "HEAD" + POST = "POST" + PATCH = "PATCH" + PUT = "PUT" + DELETE = "DELETE" + OPTIONS = "OPTIONS" + + +class Endpoint(BaseModel): + path: str = Field( + ..., + description="Path to the endpoint, relative to the URL of this endpoint. In general the paths MUST follow the paths specified in the openAPI specification as closely as possible. Therefore, paths MUST be prepended with a leading slash, but MUST NOT contain a trailing slash. Variables in the paths MUST be placed in curly braces and follow the parameter names in the openAPI specification, e.g. `{job_id}`.", + ) + methods: list[Method] = Field( + ..., + description="Supported HTTP verbs in uppercase. It is OPTIONAL to list `OPTIONS` as method (see the [CORS section](#section/Cross-Origin-Resource-Sharing-(CORS))).", + ) + + +class Plan(BaseModel): + name: str = Field( + ..., + description="Name of the plan. It MUST be accepted in a *case insensitive* manner throughout the API.", + example="free", + ) + description: str = Field( + ..., + description="A description that gives a rough overview over the plan.\n\n[CommonMark 0.29](http://commonmark.org/) syntax MAY be used for rich text representation.", + example="Free plan for testing.", + ) + paid: bool = Field( + ..., + description="Indicates whether the plan is a paid plan (`true`) or a free plan (`false`).", + ) + url: Optional[AnyUrl] = Field( + None, + description="URL to a web page with more details about the plan.", + example="http://cool-cloud-corp.com/plans/free-plan", + ) + + +class Billing(BaseModel): + currency: str = Field( + ..., + description="The currency the back-end is billing in. The currency MUST be either a valid currency code as defined in ISO-4217 or a proprietary currency, e.g. tiles or back-end specific credits. If set to the default value `null`, budget and costs are not supported by the back-end and users can't be charged.", + example="USD", + ) + default_plan: Optional[str] = Field( + None, + description="Name of the default plan to use when the user doesn't specify a plan or has no default plan has been assigned for the user.", + example="free", + ) + plans: Optional[list[Plan]] = Field( + None, + description="Array of plans", + example=[ + { + "name": "free", + "description": "Free plan. Calculates one tile per second and a maximum amount of 100 tiles per hour.", + "url": "http://cool-cloud-corp.com/plans/free-plan", + "paid": False, + }, + { + "name": "premium", + "description": "Premium plan. Calculates unlimited tiles and each calculated tile costs 0.003 USD.", + "url": "http://cool-cloud-corp.com/plans/premium-plan", + "paid": True, + }, + ], + ) + + +class Capabilities(BaseModel): + api_version: str = Field( + ..., + description="Version number of the openEO specification this back-end implements.", + ) + backend_version: str = Field( + ..., + description="Version number of the back-end implementation.\nEvery change on back-end side MUST cause a change of the version number.", + example="1.1.2", + ) + stac_version: StacVersion + type: Optional[Type5] = Field( + None, + description="For STAC versions >= 1.0.0-rc.1 this field is required.", + example="Catalog", + ) + id: str = Field( + ..., + description="Identifier for the service.\nThis field originates from STAC and is used as unique identifier for the STAC catalog available at `/collections`.", + example="cool-eo-cloud", + ) + title: str = Field( + ..., description="The name of the service.", example="Cool EO Cloud" + ) + description: str = Field( + ..., + description="A description of the service, which allows the service provider to introduce the user to its service.\n[CommonMark 0.29](http://commonmark.org/) syntax MAY be used for rich text representation.", + example="This service is provided to you by [Cool EO Cloud Corp.](http://cool-eo-cloud-corp.com). It implements the full openEO API and allows to process a range of 999 EO data sets, including \n\n* Sentinel 1/2/3 and 5\n* Landsat 7/8\n\nA free plan is available to test the service. For further information please contact our customer service at [support@cool-eo-cloud-corp.com](mailto:support@cool-eo-cloud-corp.com).", + ) + production: Optional[Production] = None + endpoints: list[Endpoint] = Field( + ..., + description="Lists all supported endpoints. Supported are all endpoints, which are implemented, return a 2XX or 3XX HTTP status code and are fully compatible to the API specification. An entry for this endpoint (path `/` with method `GET`) SHOULD NOT be listed.", + example=[ + {"path": "/collections", "methods": ["GET"]}, + {"path": "/collections/{collection_id}", "methods": ["GET"]}, + {"path": "/processes", "methods": ["GET"]}, + {"path": "/jobs", "methods": ["GET", "POST"]}, + {"path": "/jobs/{job_id}", "methods": ["GET", "DELETE", "PATCH"]}, + ], + ) + billing: Optional[Billing] = Field( + None, + description="Billing related data, e.g. the currency used or available plans to process jobs.\nThis property MUST be specified if the back-end uses any billing related API functionalities, e.g. budgeting or estimates.\nThe absence of this property doesn't mean the back-end is necessarily free to use for all. Providers may choose to bill users outside of the API, e.g. with a monthly fee that is not depending on individual API interactions.", + title="Billing", + ) + links: list[Link] = Field( + ..., + description="Links related to this service, e.g. the homepage of\nthe service provider or the terms of service.\n\nIt is highly RECOMMENDED to provide links with the\nfollowing `rel` (relation) types:\n\n1. `version-history`: A link back to the Well-Known URL\n(see `/.well-known/openeo`) to allow clients to work on\nthe most recent version.\n\n2. `terms-of-service`: A link to the terms of service. If\na back-end provides a link to the terms of service, the\nclients MUST provide a way to read the terms of service\nand only connect to the back-end after the user agreed to\nthem. The user interface MUST be designed in a way that\nthe terms of service are not agreed to by default, i.e.\nthe user MUST explicitly agree to them.\n\n3. `privacy-policy`: A link to the privacy policy (GDPR).\nIf a back-end provides a link to a privacy policy, the\nclients MUST provide a way to read the privacy policy and\nonly connect to the back-end after the user agreed to\nthem. The user interface MUST be designed in a way that\nthe privacy policy is not agreed to by default, i.e. the\nuser MUST explicitly agree to them.\n\n4. `service-desc` or `service-doc`: A link to the API definition.\nUse `service-desc` for machine-readable API definition and \n`service-doc` for human-readable API definition.\nRequired if full OGC API compatibility is desired.\n\n5. `conformance`: A link to the Conformance declaration\n(see `/conformance`). \nRequired if full OGC API compatibility is desired.\n\n6. `data`: A link to the collections (see `/collections`).\nRequired if full OGC API compatibility is desired.\n\nFor additional relation types see also the lists of\n[common relation types in openEO](#section/API-Principles/Web-Linking).", + example=[ + { + "href": "http://www.cool-cloud-corp.com", + "rel": "about", + "type": "text/html", + "title": "Homepage of the service provider", + }, + { + "href": "https://www.cool-cloud-corp.com/tos", + "rel": "terms-of-service", + "type": "text/html", + "title": "Terms of Service", + }, + { + "href": "https://www.cool-cloud-corp.com/privacy", + "rel": "privacy-policy", + "type": "text/html", + "title": "Privacy Policy", + }, + { + "href": "http://www.cool-cloud-corp.com/.well-known/openeo", + "rel": "version-history", + "type": "application/json", + "title": "List of supported openEO versions", + }, + { + "href": "http://www.cool-cloud-corp.com/api/v1.0/conformance", + "rel": "conformance", + "type": "application/json", + "title": "OGC Conformance Classes", + }, + { + "href": "http://www.cool-cloud-corp.com/api/v1.0/collections", + "rel": "data", + "type": "application/json", + "title": "List of Datasets", + }, + ], + ) diff --git a/tests/conftest.py b/tests/conftest.py index 9b907ae..0935842 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,12 +2,38 @@ from fastapi import FastAPI from openeo_fastapi.api.app import OpenEOApi +from openeo_fastapi.client import models from openeo_fastapi.client.core import OpenEOCore @pytest.fixture() def core_api(): - client = OpenEOCore() + client = OpenEOCore( + title="Test Api", + description="My Test Api", + backend_version="1", + links=[ + models.Link( + href="https://eodc.eu/", + rel="about", + type="text/html", + title="Homepage of the service provider", + ) + ], + billing=models.Billing( + currency="credits", + default_plan="a-cloud", + plans=[ + models.Plan(name="user", description="Subscription plan.", paid=True) + ], + ), + endpoints=[ + models.Endpoint( + path="/", + methods=["GET"], + ) + ], + ) api = OpenEOApi(client=client, app=FastAPI()) diff --git a/tests/test_api.py b/tests/test_api.py index c5a5d78..50dd92a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,11 +14,11 @@ def test_api_core(core_api): def test_get_capabilities(core_api): """Test the OpenEOApi and OpenEOCore classes interact as intended.""" - core_api.register_get_capabilities() - test_app = TestClient(core_api.app) + core_api.register_get_capabilities() + response = test_app.get("/") assert response.status_code == 200 - assert response.text == '{"version": "1"}' + assert response.json()["title"] == "Test Api" From 08703ba6ddf6158104979081f6bac3bd11d91a62 Mon Sep 17 00:00:00 2001 From: SerRichard Date: Mon, 4 Dec 2023 16:29:03 +0000 Subject: [PATCH 03/12] edit readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1c9bfa4..73529c5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Included is a vscode dev container which is intended to be used as the developme 2. Once the development environment is ready, run the following commands. ``` - # Working from /openeo-fastapi + # In /openeo-fastapi poetry lock From e3252c35facda8426a5ee71fceb700be95a7ce41 Mon Sep 17 00:00:00 2001 From: SerRichard Date: Mon, 4 Dec 2023 16:32:27 +0000 Subject: [PATCH 04/12] accept reformatting --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 73529c5..28dd986 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Included is a vscode dev container which is intended to be used as the developme 2. Once the development environment is ready, run the following commands. ``` - # In /openeo-fastapi + # From /openeo-fastapi poetry lock From 01e09f55fce06f53db0100bb5851d32a3d0bbbe7 Mon Sep 17 00:00:00 2001 From: Roman Schmidt Date: Mon, 18 Dec 2023 13:50:29 +0000 Subject: [PATCH 05/12] add /collections and /collections/id --- openeo_fastapi/api/app.py | 75 +++++- openeo_fastapi/client/collections.py | 39 ++++ openeo_fastapi/client/core.py | 14 +- openeo_fastapi/client/models.py | 336 ++++++++++++++++++++++++++- pyproject.toml | 2 + tests/conftest.py | 10 +- tests/test_api.py | 32 ++- 7 files changed, 497 insertions(+), 11 deletions(-) create mode 100644 openeo_fastapi/client/collections.py diff --git a/openeo_fastapi/api/app.py b/openeo_fastapi/api/app.py index 04e5de4..f348418 100644 --- a/openeo_fastapi/api/app.py +++ b/openeo_fastapi/api/app.py @@ -1,5 +1,9 @@ +from typing import Type + +import attr from attrs import Factory, define, field -from fastapi import FastAPI, Response +from fastapi import APIRouter, FastAPI, Response +from starlette.responses import JSONResponse from openeo_fastapi.client import models @@ -10,6 +14,8 @@ class OpenEOApi: client: field() app: field(default=Factory(lambda self: FastAPI)) + router: APIRouter = attr.ib(default=attr.Factory(APIRouter)) + response_class: type[Response] = attr.ib(default=JSONResponse) def _route_filter(self): """ """ @@ -21,7 +27,7 @@ def register_get_capabilities(self): Returns: None """ - self.app.add_api_route( + self.router.add_api_route( name="capabilities", path="/", response_model=models.Capabilities, @@ -30,3 +36,68 @@ def register_get_capabilities(self): methods=["GET"], endpoint=self.client.get_capabilities, ) + + def register_get_collections(self): + """Register collection Endpoint (GET /collections). + + Returns: + None + """ + self.router.add_api_route( + name="collections", + path="/collections", + response_model=Response, + response_model_exclude_unset=False, + response_model_exclude_none=True, + methods=["GET"], + endpoint=self.client.get_collections, + ) + + def register_get_collection(self): + """Register Endpoint for Individual Collection (GET /collections/{collection_id}). + + Returns: + None + """ + self.router.add_api_route( + name="collection", + path="/collections/{collection_id}", + response_model=Response, + response_model_exclude_unset=False, + response_model_exclude_none=True, + methods=["GET"], + endpoint=self.client.get_collection, + ) + + def register_core(self): + """Register core OpenEO endpoints. + + GET / + GET /capabilities + GET /collections + GET /collections/{collection_id} + GET /processes + + + Injects application logic (OpenEOApi.client) into the API layer. + + Returns: + None + """ + + self.register_get_capabilities() + self.register_get_collections() + self.register_get_collection() + + def __attrs_post_init__(self): + """Post-init hook. + + Responsible for setting up the application upon instantiation of the class. + + Returns: + None + """ + + # Register core STAC endpoints + self.register_core() + self.app.include_router(router=self.router) diff --git a/openeo_fastapi/client/collections.py b/openeo_fastapi/client/collections.py new file mode 100644 index 0000000..0025bf0 --- /dev/null +++ b/openeo_fastapi/client/collections.py @@ -0,0 +1,39 @@ +import os + +import aiohttp +from fastapi import APIRouter + +from openeo_fastapi.client.models import Collection, Collections + +router_collections = APIRouter() + + +async def get_collections(): + """ + Basic metadata for all datasets + """ + try: + async with aiohttp.ClientSession() as client: + async with client.get( + os.getenv("STAC_API_URL") + "collections" + ) as response: + resp = await response.json() + + except Exception as e: + raise Exception("Ran into: ", e) + + return Collections(collections=resp["collections"], links=resp["links"]) + + +async def get_collection(collection_id): + try: + async with aiohttp.ClientSession() as client: + async with client.get( + os.getenv("STAC_API_URL") + f"collections/{collection_id}" + ) as response: + resp = await response.json() + + except Exception as e: + raise Exception("Ran into: ", e) + + return Collection(**resp) diff --git a/openeo_fastapi/client/core.py b/openeo_fastapi/client/core.py index aa6a3bf..06c6489 100644 --- a/openeo_fastapi/client/core.py +++ b/openeo_fastapi/client/core.py @@ -1,11 +1,9 @@ import abc -import json -from typing import Optional, Union from attrs import define, field -from fastapi import Response from openeo_fastapi.client import models +from openeo_fastapi.client.collections import get_collection, get_collections @define @@ -37,3 +35,13 @@ def get_capabilities(self) -> models.Capabilities: links=self.links, endpoints=self.endpoints, ) + + @abc.abstractclassmethod + def get_collection(self, collection_id) -> models.Collection: + collection = get_collection(collection_id) + return collection + + @abc.abstractclassmethod + async def get_collections(self) -> models.Collections: + collections = await get_collections() + return collections diff --git a/openeo_fastapi/client/models.py b/openeo_fastapi/client/models.py index 0bc7c4f..036ace5 100644 --- a/openeo_fastapi/client/models.py +++ b/openeo_fastapi/client/models.py @@ -1,8 +1,29 @@ +import sys from enum import Enum from pathlib import Path -from typing import List, Optional, Union +from typing import Any, Dict, List, Optional, Union -from pydantic import AnyUrl, BaseModel, Field, constr +from pydantic import AnyUrl, BaseModel, Extra, Field, constr + +# Avoids a Pydantic error: +# TypeError: You should use `typing_extensions.TypedDict` instead of +# `typing.TypedDict` with Python < 3.9.2. Without it, there is no way to +# differentiate required and optional fields when subclassed. +if sys.version_info < (3, 9, 2): + from typing_extensions import TypedDict +else: + from typing import TypedDict + + +class Type1(Enum): + Collection = "Collection" + + +class Type2(Enum): + spatial = "spatial" + temporal = "temporal" + bands = "bands" + other = "other" class Type5(Enum): @@ -206,3 +227,314 @@ class Capabilities(BaseModel): }, ], ) + + +class CollectionId(str): + collection_id: constr(regex=rb"^[\w\-\.~\/]+$") = Field( + ..., + description="A unique identifier for the collection, which MUST match the specified pattern.", + example="Sentinel-2A", + ) + + +class StacExtensions(BaseModel): + __root__: list[Union[AnyUrl, str]] = Field( + ..., + description=( + "A list of implemented STAC extensions. The list contains URLs to the JSON Schema " + "files it can be validated against. For STAC < 1.0.0-rc.1 shortcuts such as `sar` " + "can be used instead of the schema URL." + ), + unique_items=True, + ) + + +class StacAssets(BaseModel): + pass + + class Config: + extra = Extra.allow + + +class Role(Enum): + producer = "producer" + licensor = "licensor" + processor = "processor" + host = "host" + + +class StacProvider(BaseModel): + name: str = Field( + ..., + description="The name of the organization or the individual.", + example="Cool EO Cloud Corp", + ) + description: Optional[str] = Field( + None, + description=( + "Multi-line description to add further provider information such as processing details for " + "processors and producers, hosting details for hosts or basic contact information." + "CommonMark 0.29 syntax MAY be used for rich text representation." + ), + example="No further processing applied.", + ) + roles: Optional[list[Role]] = Field( + None, + description=( + "Roles of the provider.\n\nThe provider's role(s) can be one or more of the following " + "elements:\n* licensor: The organization that is licensing the dataset under the license" + "specified in the collection's license field.\n* producer: The producer of the data is the" + "provider that initially captured and processed the source data, e.g. ESA for Sentinel-2 data." + "* processor: A processor is any provider who processed data to a derived product.\n* host: The" + "host is the actual provider offering the data on their storage. There SHOULD be no more than" + "one host, specified as last element of the list." + ), + example=["producer", "licensor", "host"], + ) + url: Optional[AnyUrl] = Field( + None, + description=( + "Homepage on which the provider describes the dataset and publishes contact information." + ), + example="http://cool-eo-cloud-corp.com", + ) + + +class StacProviders(BaseModel): + __root__: list[StacProvider] = Field( + ..., + description=( + "A list of providers, which MAY include all organizations capturing or processing " + "the data or the hosting provider. Providers SHOULD be listed in chronological order" + "with the most recent provider being the last element of the list." + ), + ) + + +class Description(BaseModel): + __root__: str = Field( + ..., + description="""Detailed description to explain the entity. + [CommonMark 0.29](http://commonmark.org/) syntax MAY be used for rich text representation.""", + ) + + +class Dimension(BaseModel): + type: Type2 = Field(..., description="Type of the dimension.") + description: Optional[Description] = None + + +class Spatial(BaseModel): + bbox: Optional[list[list[float]]] = Field( + None, + description=( + "One or more bounding boxes that describe the spatial extent\nof the dataset." + "The first bounding box describes the overall spatial extent\nof the data. All " + "subsequent bounding boxes describe more\nprecise bounding boxes, e.g. to identify " + "clusters of data.\nClients only interested in the overall spatial extent will " + "only need to access the first item in each array." + ), + min_items=1, + ) + + +class IntervalItem(BaseModel): + __root__: list[Any] = Field( + ..., + description=( + "Begin and end times of the time interval. The coordinate reference system is the " + "Gregorian calendar.\n\nThe value `null` is supported and indicates an open time interval." + ), + example=["2011-11-11T12:22:11Z", None], + ) + + +class Temporal(BaseModel): + interval: Optional[list[IntervalItem]] = Field( + None, + description=( + "One or more time intervals that describe the temporal extent of the dataset." + "The first time interval describes the overall temporal extent of the data. " + "All subsequent time intervals describe more precise time intervals, e.g. to " + "identify clusters of data. Clients only interested in the overall extent will" + "only need to access the first item in each array." + ), + min_items=1, + ) + + +class Extent(BaseModel): + spatial: Spatial = Field( + ..., + description="The *potential* spatial extents of the features in the collection.", + title="Collection Spatial Extent", + ) + temporal: Temporal = Field( + ..., + description="The *potential* temporal extents of the features in the collection.", + title="Collection Temporal Extent", + ) + + +class CollectionSummaryStats(BaseModel): + min: Union[str, float] = Field(alias="minimum") + max: Union[str, float] = Field(alias="maximum") + + +class StacLicense(BaseModel): + __root__: str = Field( + ..., + description=( + "License(s) of the data as a SPDX [License identifier](https://spdx.org/licenses/)." + "Alternatively, use `proprietary` if the license is not on the SPDX\nlicense list or" + "`various` if multiple licenses apply. In these two cases\nlinks to the license texts " + "SHOULD be added, see the `license` link\nrelation type.\n\nNon-SPDX licenses SHOULD " + "add a link to the license text with the\n`license` relation in the links section. " + "The license text MUST NOT be\nprovided as a value of this field. If there is no public" + "license URL\navailable, it is RECOMMENDED to host the license text and link to it." + ), + example="Apache-2.0", + ) + + +class Collection(BaseModel): + stac_version: StacVersion + stac_extensions: Optional[StacExtensions] = None + type: Optional[Type1] = Field( + None, description="For STAC versions >= 1.0.0-rc.1 this field is required." + ) + id: CollectionId + title: Optional[str] = Field( + None, description="A short descriptive one-line title for the collection." + ) + description: str = Field( + ..., + description=( + "Detailed multi-line description to explain the collection.\n\n" + "[CommonMark 0.29](http://commonmark.org/) syntax MAY be used for rich text representation." + ), + ) + keywords: Optional[list[str]] = Field( + None, description="List of keywords describing the collection." + ) + version: Optional[str] = Field( + None, + description=( + "Version of the collection.\n\nThis property REQUIRES to add `version` (STAC < 1.0.0-rc.1)" + "or\n`https://stac-extensions.github.io/version/v1.0.0/schema.json` (STAC >= 1.0.0-rc.1)\n" + "to the list of `stac_extensions`." + ), + ) + deprecated: Optional[bool] = Field( + False, + description=( + "Specifies that the collection is deprecated with the potential to\nbe removed. " + "It should be transitioned out of usage as soon as\npossible and users should refrain from " + "using it in new projects.\n\nA link with relation type `latest-version` SHOULD be added to " + "the\nlinks and MUST refer to the collection that can be used instead.\n\nThis property " + "REQUIRES to add `version` (STAC < 1.0.0-rc.1) or\n" + "`https://stac-extensions.github.io/version/v1.0.0/schema.json` (STAC >= 1.0.0-rc.1)\n" + "to the list of `stac_extensions`." + ), + ) + license: StacLicense + providers: Optional[StacProviders] = None + extent: Extent = Field( + ..., + description=( + "The extent of the data in the collection. Additional members MAY\nbe added to " + "represent other extents, for example, thermal or\npressure ranges.\n\nThe first " + "item in the array always describes the overall extent of\nthe data. All subsequent " + "items describe more preciseextents,\ne.g. to identify clusters of data.\nClients only " + "interested in the overall extent will only need to\naccess the first item in each array." + ), + title="Collection Extent", + ) + links: list[Link] = Field( + ..., + description=( + "Links related to this collection. Could reference to licensing information, other meta data " + "formats with additional information or a preview image.\nIt is RECOMMENDED to provide links " + "with the following `rel` (relation) types:\n1. `root` and `parent`: URL to the data discovery " + "endpoint at `/collections`.\n2. `license`: A link to the license(s) SHOULD be specified if the " + "`license` field is set to `proprietary` or `various`.\n3. `example`: Links to examples of " + "processes that use this collection.\n4. `latest-version`: If a collection has been marked as " + "deprecated, a link SHOULD point to the latest version of the collection. The relation types " + "`predecessor-version` (link to older version) and `successor-version` (link to newer version) " + "can also be used to show the relation between versions.\n5. `alternate`: An alternative " + "representation of the collection. For example, this could be the collection available through" + "another catalog service such as OGC CSW, a human-readable HTML version or a metadata document" + "following another standard such as ISO 19115 or DCAT. For additional relation types see also" + "the lists of [common relation types in openEO](#section/API-Principles/Web-Linking) and the " + "STAC specification for Collections." + ), + ) + cube_dimensions: Optional[dict[str, Dimension]] = Field( + None, + alias="cube:dimensions", + description=( + "Uniquely named dimensions of the data cube.\n\nThe keys of the object are the dimension names." + "For interoperability, it is RECOMMENDED to use the\nfollowing dimension names if there is only " + "a single dimension with the specified criteria:\n\n* `x` for the dimension of type `spatial` " + "with the axis set to `x`\n* `y` for the dimension of type `spatial` with the axis set to `y`* " + "`z` for the dimension of type `spatial` with the axis set to `z`\n* `t` for the dimension of " + " type `temporal` * `bands` for dimensions of type `bands`\n\nThis property REQUIRES to add " + "`datacube` to the list of `stac_extensions`." + ), + title="STAC Collection Cube Dimensions", + ) + summaries: Optional[dict[str, Union[list[Any], CollectionSummaryStats]]] = Field( + None, + description=( + "Collection properties from STAC extensions (e.g. EO,SAR, Satellite or Scientific) or even " + "custom extensions.Summaries are either a unique set of all available\nvalues *or* " + "statistics. Statistics by default only specify the range (minimum and maximum values), " + "but can optionally be accompanied by additional statistical values. The range can specify" + "the potential range of values, but it is recommended to be as precise as possible. The set " + "of values MUST contain at least one element and it is strongly RECOMMENDED to list all values." + "It is recommended to list as many properties as reasonable so that consumers get a full" + "overview of the Collection. Properties that are covered by the Collection specification " + "(e.g.\n`providers` and `license`) SHOULD NOT be repeated in the summaries.\n\nPotential " + "fields for the summaries can be found here: **[STAC Common Metadata](https://github.com/radiantearth/stac-spec/blob/v1.0.0-rc.2/item-spec/common-metadata.md)**" # noqa:E501 + "A list of commonly used fields throughout all domains **[Content Extensions](https://github.com/radiantearth/stac-spec/blob/v1.0.0-rc.2/extensions/README.md#list-of-content-extensions)**:" # noqa:E501 + "Domain-specific fields for domains such as EO, SAR and point clouds.\n* **Custom Properties**:" + "It is generally allowed to add custom fields." + ), + title="STAC Summaries (Collection Properties)", + ) + assets: Optional[StacAssets] = Field( + None, + description=( + "Dictionary of asset objects for data that can be downloaded,\neach with a unique key." + "The keys MAY be used by clients as file names.\n\nImplementing this property REQUIRES " + "to add `collection-assets`\nto the list of `stac_extensions` in STAC < 1.0.0-rc.1." + ), + ) + + class Config: + extra = Extra.allow + allow_population_by_field_name = True + + +class LinksPagination(BaseModel): + __root__: list[Link] = Field( + ..., + description="""Links related to this list of resources, for example links for pagination\nor + alternative formats such as a human-readable HTML version.\nThe links array MUST NOT be paginated. + If pagination is implemented, the following `rel` (relation) types apply:\n\n1. `next` (REQUIRED): + A link to the next page, except on the last page.\n\n2. `prev` (OPTIONAL): A link to the previous + page, except on the first page.\n\n3. `first` (OPTIONAL): A link to the first page, except on the + first page.\n\n4. `last` (OPTIONAL): A link to the last page, except on the last page.\n\nFor + additional relation types see also the lists of + [common relation types in openEO](#section/API-Principles/Web-Linking).""", + ) + + +class Collections(TypedDict, total=False): + """All collections endpoint. + + https://github.com/radiantearth/stac-api-spec/tree/master/collections + """ + + collections: list[Collection] + links: list[dict[str, Any]] diff --git a/pyproject.toml b/pyproject.toml index 1a23b73..30e74e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ fastapi = "^0.95.1" pydantic = "<2" attrs = "^23.1.0" httpx = "^0.24.1" +aiohttp = ">3.9" + [tool.poetry.group.dev.dependencies] diff --git a/tests/conftest.py b/tests/conftest.py index 0935842..7239ccf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,7 +31,15 @@ def core_api(): models.Endpoint( path="/", methods=["GET"], - ) + ), + models.Endpoint( + path="/collections", + methods=["GET"], + ), + models.Endpoint( + path="/collections/{collection_id}", + methods=["GET"], + ), ], ) diff --git a/tests/test_api.py b/tests/test_api.py index 50dd92a..a094027 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,11 +14,37 @@ def test_api_core(core_api): def test_get_capabilities(core_api): """Test the OpenEOApi and OpenEOCore classes interact as intended.""" - test_app = TestClient(core_api.app) - - core_api.register_get_capabilities() + test_api = core_api(client=TestClient, app=FastAPI()) + test_client = TestClient(test_api.app) + test_app = test_client.app response = test_app.get("/") assert response.status_code == 200 assert response.json()["title"] == "Test Api" + + +def test_get_collections(core_api): + """Test the OpenEOApi and OpenEOCore classes interact as intended.""" + + test_api = core_api(client=TestClient, app=FastAPI()) + test_client = TestClient(test_api.app) + test_app = test_client.app + + response = test_app.get("/collections") + + assert response.status_code == 200 + assert response.json()["title"] == "Test Api" + + +def test_get_collection(core_api): + """Test the OpenEOApi and OpenEOCore classes interact as intended.""" + + test_api = core_api(client=TestClient, app=FastAPI()) + test_client = TestClient(test_api.app) + test_app = test_client.app + + response = test_app.get("/collections/viirs-15a2h-001") + + assert response.status_code == 200 + assert response.json()["title"] == "Test Api" From 7490de531c1af3d80d5fb3b40a99a9cee031578b Mon Sep 17 00:00:00 2001 From: Roman Schmidt Date: Mon, 18 Dec 2023 14:36:02 +0000 Subject: [PATCH 06/12] test pass with real STAC API --- openeo_fastapi/api/app.py | 4 ++-- openeo_fastapi/client/core.py | 4 ++-- tests/conftest.py | 11 +++++++++++ tests/test_api.py | 21 ++++++++++----------- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/openeo_fastapi/api/app.py b/openeo_fastapi/api/app.py index f348418..d41cf79 100644 --- a/openeo_fastapi/api/app.py +++ b/openeo_fastapi/api/app.py @@ -46,7 +46,7 @@ def register_get_collections(self): self.router.add_api_route( name="collections", path="/collections", - response_model=Response, + response_model=models.Collections, response_model_exclude_unset=False, response_model_exclude_none=True, methods=["GET"], @@ -62,7 +62,7 @@ def register_get_collection(self): self.router.add_api_route( name="collection", path="/collections/{collection_id}", - response_model=Response, + response_model=models.Collection, response_model_exclude_unset=False, response_model_exclude_none=True, methods=["GET"], diff --git a/openeo_fastapi/client/core.py b/openeo_fastapi/client/core.py index 06c6489..563dd44 100644 --- a/openeo_fastapi/client/core.py +++ b/openeo_fastapi/client/core.py @@ -37,8 +37,8 @@ def get_capabilities(self) -> models.Capabilities: ) @abc.abstractclassmethod - def get_collection(self, collection_id) -> models.Collection: - collection = get_collection(collection_id) + async def get_collection(self, collection_id) -> models.Collection: + collection = await get_collection(collection_id) return collection @abc.abstractclassmethod diff --git a/tests/conftest.py b/tests/conftest.py index 7239ccf..b239194 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,6 @@ +import os +from unittest import mock + import pytest from fastapi import FastAPI @@ -6,6 +9,14 @@ from openeo_fastapi.client.core import OpenEOCore +@pytest.fixture(autouse=True) +def mock_settings_env_vars(): + with mock.patch.dict( + os.environ, {"STAC_API_URL": "https://stac.terrabyte.lrz.de/public/api"} + ): + yield + + @pytest.fixture() def core_api(): client = OpenEOCore( diff --git a/tests/test_api.py b/tests/test_api.py index a094027..583e675 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,3 +1,6 @@ +import logging +from typing import List + from fastapi import FastAPI from fastapi.testclient import TestClient @@ -14,9 +17,7 @@ def test_api_core(core_api): def test_get_capabilities(core_api): """Test the OpenEOApi and OpenEOCore classes interact as intended.""" - test_api = core_api(client=TestClient, app=FastAPI()) - test_client = TestClient(test_api.app) - test_app = test_client.app + test_app = TestClient(core_api.app) response = test_app.get("/") @@ -27,24 +28,22 @@ def test_get_capabilities(core_api): def test_get_collections(core_api): """Test the OpenEOApi and OpenEOCore classes interact as intended.""" - test_api = core_api(client=TestClient, app=FastAPI()) - test_client = TestClient(test_api.app) - test_app = test_client.app + test_app = TestClient(core_api.app) response = test_app.get("/collections") assert response.status_code == 200 - assert response.json()["title"] == "Test Api" + assert isinstance(response.json()["collections"], list) def test_get_collection(core_api): """Test the OpenEOApi and OpenEOCore classes interact as intended.""" - test_api = core_api(client=TestClient, app=FastAPI()) - test_client = TestClient(test_api.app) - test_app = test_client.app + test_app = TestClient(core_api.app) response = test_app.get("/collections/viirs-15a2h-001") + logging.info(str(response)) + assert response.status_code == 200 - assert response.json()["title"] == "Test Api" + assert response.json()["id"] == "viirs-15a2h-001" From 196acd9463fd6be9a63e84b700a31d0ce433e7af Mon Sep 17 00:00:00 2001 From: Roman Schmidt Date: Mon, 18 Dec 2023 14:36:32 +0000 Subject: [PATCH 07/12] test pass with real STAC API --- tests/test_api.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 583e675..8278062 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,3 @@ -import logging -from typing import List - from fastapi import FastAPI from fastapi.testclient import TestClient @@ -43,7 +40,5 @@ def test_get_collection(core_api): response = test_app.get("/collections/viirs-15a2h-001") - logging.info(str(response)) - assert response.status_code == 200 assert response.json()["id"] == "viirs-15a2h-001" From 236e05078b37572bd315176313e7b4e73cfbe34a Mon Sep 17 00:00:00 2001 From: Roman Schmidt Date: Tue, 19 Dec 2023 12:45:03 +0000 Subject: [PATCH 08/12] mocked tests --- pyproject.toml | 2 + tests/collections.json | 102 +++++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 5 +- tests/test_api.py | 81 ++++++++++++++++++++++++-------- 4 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 tests/collections.json diff --git a/pyproject.toml b/pyproject.toml index 30e74e3..36fee70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ pytest = "^7.2.0" ipykernel = "^6.15.1" pre-commit = "^2.20.0" pytest-cov = "^4.0.0" +pytest-asyncio = "^0.23.0" +aioresponses = "^0.7.5" [build-system] requires = ["poetry-core"] diff --git a/tests/collections.json b/tests/collections.json new file mode 100644 index 0000000..7d640b9 --- /dev/null +++ b/tests/collections.json @@ -0,0 +1,102 @@ +{ + "collections": [ + { + "stac_version": "1.0.0", + "type": "Collection", + "id": "Sentinel-2A", + "title": "Sentinel-2A MSI L1C", + "description": "Sentinel-2A is a wide-swath, high-resolution, multi-spectral imaging mission supporting Copernicus Land Monitoring studies, including the monitoring of vegetation, soil and water cover, as well as observation of inland waterways and coastal areas.", + "license": "proprietary", + "extent": { + "spatial": { + "bbox": [ + [ + -180, + -56, + 180, + 83 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2015-06-23T00:00:00Z", + "2019-01-01T00:00:00Z" + ] + ] + } + }, + "keywords": [ + "copernicus", + "esa", + "msi", + "sentinel" + ], + "providers": [ + { + "name": "European Space Agency (ESA)", + "roles": [ + "producer", + "licensor" + ], + "url": "https://sentinel.esa.int/web/sentinel/user-guides/sentinel-2-msi" + }, + { + "name": "openEO", + "roles": [ + "host" + ], + "url": "https://developers.google.com/earth-engine/datasets/catalog/COPERNICUS_S2" + } + ], + "links": [ + { + "rel": "license", + "href": "https://scihub.copernicus.eu/twiki/pub/SciHubWebPortal/TermsConditions/Sentinel_Data_Terms_and_Conditions.pdf" + } + ] + }, + { + "stac_version": "1.0.0", + "type": "Collection", + "id": "MOD09Q1", + "title": "MODIS/Terra Surface Reflectance 8-Day L3 Global 250m SIN Grid V006", + "description": "The MOD09Q1 Version 6 product provides an estimate of the surface spectral reflectance of Terra MODIS Bands 1-2 corrected for atmospheric conditions such as gasses, aerosols, and Rayleigh scattering. Provided along with the two 250 m MODIS bands is one additional layer, the Surface Reflectance QC 250 m band. For each pixel, a value is selected from all the acquisitions within the 8-day composite period. The criteria for the pixel choice include cloud and solar zenith. When several acquisitions meet the criteria the pixel with the minimum channel 3 (blue) value is used. Validation at stage 3 has been achieved for all MODIS Surface Reflectance products.", + "license": "proprietary", + "extent": { + "spatial": { + "bbox": [ + [ + -180, + -90, + 180, + 90 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2000-02-01T00:00:00Z", + null + ] + ] + } + }, + "links": [ + { + "rel": "license", + "href": "https://openeo.example/api/v1/collections/MOD09Q1/license" + } + ] + } + ], + "links": [ + { + "rel": "alternate", + "href": "https://openeo.example/csw", + "title": "openEO catalog (OGC Catalogue Services 3.0)" + } + ] + } diff --git a/tests/conftest.py b/tests/conftest.py index b239194..4eccf5f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,16 +3,19 @@ import pytest from fastapi import FastAPI +from requests_mock.contrib import fixture from openeo_fastapi.api.app import OpenEOApi from openeo_fastapi.client import models from openeo_fastapi.client.core import OpenEOCore +pytestmark = pytest.mark.unit + @pytest.fixture(autouse=True) def mock_settings_env_vars(): with mock.patch.dict( - os.environ, {"STAC_API_URL": "https://stac.terrabyte.lrz.de/public/api"} + os.environ, {"STAC_API_URL": "http://test-stac-api.mock.com/api/"} ): yield diff --git a/tests/test_api.py b/tests/test_api.py index 8278062..1a48bd1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,44 +1,87 @@ +import json +import os + +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from openeo_fastapi.api.app import OpenEOApi +from openeo_fastapi.client.collections import get_collection, get_collections +from openeo_fastapi.client.models import Collection +path_to_current_file = os.path.realpath(__file__) +current_directory = os.path.split(path_to_current_file)[0] -def test_api_core(core_api): - """Test the OpenEOApi and OpenEOCore classes interact as intended.""" - assert isinstance(core_api, OpenEOApi) - assert isinstance(core_api.app, FastAPI) +from aioresponses import aioresponses -def test_get_capabilities(core_api): - """Test the OpenEOApi and OpenEOCore classes interact as intended.""" +@pytest.mark.asyncio +async def test_get_collections(): + with open(os.path.join(current_directory, "collections.json")) as f_in: + collections = json.load(f_in) + with aioresponses() as m: + m.get("http://test-stac-api.mock.com/api/collections", payload=collections) - test_app = TestClient(core_api.app) + data = await get_collections() - response = test_app.get("/") + assert data == collections + m.assert_called_once_with("http://test-stac-api.mock.com/api/collections") - assert response.status_code == 200 - assert response.json()["title"] == "Test Api" +@pytest.mark.asyncio +async def test_get_collection(): + with open(os.path.join(current_directory, "collections.json")) as f_in: + collection = json.load(f_in)["collections"][0] + with aioresponses() as m: + m.get( + "http://test-stac-api.mock.com/api/collections/Sentinel-2A", + payload=collection, + ) -def test_get_collections(core_api): - """Test the OpenEOApi and OpenEOCore classes interact as intended.""" + data = await get_collection("Sentinel-2A") - test_app = TestClient(core_api.app) + assert data == Collection(**collection) + m.assert_called_once_with( + "http://test-stac-api.mock.com/api/collections/Sentinel-2A" + ) - response = test_app.get("/collections") - assert response.status_code == 200 - assert isinstance(response.json()["collections"], list) +def test_api_core(core_api): + """Test the OpenEOApi and OpenEOCore classes interact as intended.""" + + assert isinstance(core_api, OpenEOApi) + assert isinstance(core_api.app, FastAPI) -def test_get_collection(core_api): +def test_get_capabilities(core_api): """Test the OpenEOApi and OpenEOCore classes interact as intended.""" test_app = TestClient(core_api.app) - response = test_app.get("/collections/viirs-15a2h-001") + response = test_app.get("/") assert response.status_code == 200 - assert response.json()["id"] == "viirs-15a2h-001" + assert response.json()["title"] == "Test Api" + + +# def test_get_collections(core_api): +# """Test the OpenEOApi and OpenEOCore classes interact as intended.""" +# +# test_app = TestClient(core_api.app) +# +# response = test_app.get("/collections") +# +# assert response.status_code == 200 +# assert isinstance(response.json()["collections"], list) +# +# +# def test_get_collection(core_api): +# """Test the OpenEOApi and OpenEOCore classes interact as intended.""" +# +# test_app = TestClient(core_api.app) +# +# response = test_app.get("/collections/viirs-15a2h-001") +# +# assert response.status_code == 200 +# assert response.json()["id"] == "viirs-15a2h-001" From 01e340a3425b7a36efab39836c99d35a925d0f23 Mon Sep 17 00:00:00 2001 From: Roman Schmidt Date: Tue, 19 Dec 2023 12:59:36 +0000 Subject: [PATCH 09/12] test in dev container --- tests/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4eccf5f..b4bbda3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,6 @@ import pytest from fastapi import FastAPI -from requests_mock.contrib import fixture from openeo_fastapi.api.app import OpenEOApi from openeo_fastapi.client import models From 64b22b49e9da82d592f210268fe40123e3009704 Mon Sep 17 00:00:00 2001 From: Roman Schmidt Date: Tue, 19 Dec 2023 16:07:01 +0000 Subject: [PATCH 10/12] remove reponse model for error --- openeo_fastapi/api/app.py | 4 +-- openeo_fastapi/client/collections.py | 40 ++++++++++++++-------------- tests/test_api.py | 26 +----------------- 3 files changed, 23 insertions(+), 47 deletions(-) diff --git a/openeo_fastapi/api/app.py b/openeo_fastapi/api/app.py index d41cf79..7b7e9e3 100644 --- a/openeo_fastapi/api/app.py +++ b/openeo_fastapi/api/app.py @@ -46,7 +46,7 @@ def register_get_collections(self): self.router.add_api_route( name="collections", path="/collections", - response_model=models.Collections, + response_model=None, response_model_exclude_unset=False, response_model_exclude_none=True, methods=["GET"], @@ -62,7 +62,7 @@ def register_get_collection(self): self.router.add_api_route( name="collection", path="/collections/{collection_id}", - response_model=models.Collection, + response_model=None, response_model_exclude_unset=False, response_model_exclude_none=True, methods=["GET"], diff --git a/openeo_fastapi/client/collections.py b/openeo_fastapi/client/collections.py index 0025bf0..d345264 100644 --- a/openeo_fastapi/client/collections.py +++ b/openeo_fastapi/client/collections.py @@ -2,6 +2,7 @@ import aiohttp from fastapi import APIRouter +from starlette.responses import JSONResponse from openeo_fastapi.client.models import Collection, Collections @@ -12,28 +13,27 @@ async def get_collections(): """ Basic metadata for all datasets """ - try: - async with aiohttp.ClientSession() as client: - async with client.get( - os.getenv("STAC_API_URL") + "collections" - ) as response: - resp = await response.json() - except Exception as e: - raise Exception("Ran into: ", e) - - return Collections(collections=resp["collections"], links=resp["links"]) + async with aiohttp.ClientSession() as client: + async with client.get(os.getenv("STAC_API_URL") + "collections") as response: + resp = await response.json() + if response.status == 200 and resp.get("collections"): + return Collections(collections=resp["collections"], links=resp["links"]) + else: + return resp async def get_collection(collection_id): - try: - async with aiohttp.ClientSession() as client: - async with client.get( - os.getenv("STAC_API_URL") + f"collections/{collection_id}" - ) as response: - resp = await response.json() - - except Exception as e: - raise Exception("Ran into: ", e) + """ + Metadata for specific datasets + """ - return Collection(**resp) + async with aiohttp.ClientSession() as client: + async with client.get( + os.getenv("STAC_API_URL") + f"collections/{collection_id}" + ) as response: + resp = await response.json() + if response.status == 200 and resp.get("id"): + return Collection(**resp) + else: + return resp diff --git a/tests/test_api.py b/tests/test_api.py index 1a48bd1..1c5aa40 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,6 +2,7 @@ import os import pytest +from aioresponses import aioresponses from fastapi import FastAPI from fastapi.testclient import TestClient @@ -13,9 +14,6 @@ current_directory = os.path.split(path_to_current_file)[0] -from aioresponses import aioresponses - - @pytest.mark.asyncio async def test_get_collections(): with open(os.path.join(current_directory, "collections.json")) as f_in: @@ -63,25 +61,3 @@ def test_get_capabilities(core_api): assert response.status_code == 200 assert response.json()["title"] == "Test Api" - - -# def test_get_collections(core_api): -# """Test the OpenEOApi and OpenEOCore classes interact as intended.""" -# -# test_app = TestClient(core_api.app) -# -# response = test_app.get("/collections") -# -# assert response.status_code == 200 -# assert isinstance(response.json()["collections"], list) -# -# -# def test_get_collection(core_api): -# """Test the OpenEOApi and OpenEOCore classes interact as intended.""" -# -# test_app = TestClient(core_api.app) -# -# response = test_app.get("/collections/viirs-15a2h-001") -# -# assert response.status_code == 200 -# assert response.json()["id"] == "viirs-15a2h-001" From 110acc19ea4320442fb8d6e18d4f27fd86dc5d0c Mon Sep 17 00:00:00 2001 From: Roman Schmidt Date: Tue, 19 Dec 2023 16:10:51 +0000 Subject: [PATCH 11/12] clean up --- openeo_fastapi/api/app.py | 2 +- tests/test_api.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/openeo_fastapi/api/app.py b/openeo_fastapi/api/app.py index 7b7e9e3..5407c2e 100644 --- a/openeo_fastapi/api/app.py +++ b/openeo_fastapi/api/app.py @@ -98,6 +98,6 @@ def __attrs_post_init__(self): None """ - # Register core STAC endpoints + # Register core endpoints self.register_core() self.app.include_router(router=self.router) diff --git a/tests/test_api.py b/tests/test_api.py index 1c5aa40..205c89f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -16,6 +16,7 @@ @pytest.mark.asyncio async def test_get_collections(): + # TODO: Make collections a fixture with open(os.path.join(current_directory, "collections.json")) as f_in: collections = json.load(f_in) with aioresponses() as m: From 187f6d7711c87fdb5aba51f260dc9374b198ea79 Mon Sep 17 00:00:00 2001 From: Roman Schmidt Date: Wed, 3 Jan 2024 09:10:41 +0000 Subject: [PATCH 12/12] links to stac-fastapi --- openeo_fastapi/client/models.py | 68 ++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/openeo_fastapi/client/models.py b/openeo_fastapi/client/models.py index 036ace5..ef704bc 100644 --- a/openeo_fastapi/client/models.py +++ b/openeo_fastapi/client/models.py @@ -230,6 +230,10 @@ class Capabilities(BaseModel): class CollectionId(str): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + collection_id: constr(regex=rb"^[\w\-\.~\/]+$") = Field( ..., description="A unique identifier for the collection, which MUST match the specified pattern.", @@ -238,6 +242,10 @@ class CollectionId(str): class StacExtensions(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + __root__: list[Union[AnyUrl, str]] = Field( ..., description=( @@ -250,6 +258,10 @@ class StacExtensions(BaseModel): class StacAssets(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + pass class Config: @@ -257,6 +269,10 @@ class Config: class Role(Enum): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + producer = "producer" licensor = "licensor" processor = "processor" @@ -264,6 +280,10 @@ class Role(Enum): class StacProvider(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + name: str = Field( ..., description="The name of the organization or the individual.", @@ -301,6 +321,10 @@ class StacProvider(BaseModel): class StacProviders(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + __root__: list[StacProvider] = Field( ..., description=( @@ -312,6 +336,10 @@ class StacProviders(BaseModel): class Description(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + __root__: str = Field( ..., description="""Detailed description to explain the entity. @@ -320,11 +348,19 @@ class Description(BaseModel): class Dimension(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + type: Type2 = Field(..., description="Type of the dimension.") description: Optional[Description] = None class Spatial(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + bbox: Optional[list[list[float]]] = Field( None, description=( @@ -339,6 +375,10 @@ class Spatial(BaseModel): class IntervalItem(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + __root__: list[Any] = Field( ..., description=( @@ -350,6 +390,10 @@ class IntervalItem(BaseModel): class Temporal(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + interval: Optional[list[IntervalItem]] = Field( None, description=( @@ -364,6 +408,10 @@ class Temporal(BaseModel): class Extent(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + spatial: Spatial = Field( ..., description="The *potential* spatial extents of the features in the collection.", @@ -377,11 +425,19 @@ class Extent(BaseModel): class CollectionSummaryStats(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + min: Union[str, float] = Field(alias="minimum") max: Union[str, float] = Field(alias="maximum") class StacLicense(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + __root__: str = Field( ..., description=( @@ -398,6 +454,10 @@ class StacLicense(BaseModel): class Collection(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + stac_version: StacVersion stac_extensions: Optional[StacExtensions] = None type: Optional[Type1] = Field( @@ -517,6 +577,10 @@ class Config: class LinksPagination(BaseModel): + """ + Based on https://github.com/stac-utils/stac-fastapi/tree/main/stac_fastapi/types/stac_fastapi/types + """ + __root__: list[Link] = Field( ..., description="""Links related to this list of resources, for example links for pagination\nor @@ -531,8 +595,8 @@ class LinksPagination(BaseModel): class Collections(TypedDict, total=False): - """All collections endpoint. - + """ + All collections endpoint. https://github.com/radiantearth/stac-api-spec/tree/master/collections """