From 28577bcded1da0feb30d4a1a96c072fcbdd504f8 Mon Sep 17 00:00:00 2001 From: Christian Wygoda Date: Fri, 25 Nov 2022 08:53:11 +0100 Subject: [PATCH 1/5] Add test for OpenAPI response --- stac_fastapi/api/tests/test_api.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index ab5a304d4..832b15faf 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -129,3 +129,28 @@ def must_be_bob( detail="You're not Bob", headers={"WWW-Authenticate": "Basic"}, ) + + +def test_openapi(monkeypatch: MonkeyPatch): + settings = config.ApiSettings() + api = StacApi( + **{ + "settings": settings, + "client": DummyCoreClient(), + "extensions": [ + TransactionExtension( + client=DummyTransactionsClient(), settings=settings + ), + TokenPaginationExtension(), + ], + } + ) + + with TestClient(api.app) as client: + response = client.get(api.app.openapi_url) + + assert response.status_code == 200 + assert ( + response.headers["Content-Type"] + == "application/vnd.oai.openapi+json;version=3.0" + ) From 6b064683c845b50e6763d0a51767a372047c500d Mon Sep 17 00:00:00 2001 From: Christian Wygoda Date: Fri, 25 Nov 2022 08:54:31 +0100 Subject: [PATCH 2/5] Take OpenAPI info metadata from ApiSettings --- stac_fastapi/api/stac_fastapi/api/app.py | 39 +++++++------------ stac_fastapi/api/tests/test_api.py | 17 +++++++- .../types/stac_fastapi/types/config.py | 4 ++ 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 1f2768ee2..7af27e3be 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -1,10 +1,9 @@ """fastapi app creation.""" -from typing import Any, Dict, List, Optional, Tuple, Type, Union +from typing import Dict, List, Optional, Tuple, Type, Union import attr from brotli_asgi import BrotliMiddleware from fastapi import APIRouter, FastAPI -from fastapi.openapi.utils import get_openapi from fastapi.params import Depends from stac_pydantic import Collection, Item, ItemCollection from stac_pydantic.api import ConformanceClasses, LandingPage @@ -61,22 +60,33 @@ class StacApi: exceptions: Dict[Type[Exception], int] = attr.ib( default=attr.Factory(lambda: DEFAULT_STATUS_CODES) ) + title: str = attr.ib( + default=attr.Factory(lambda self: self.settings.api_title, takes_self=True) + ) + api_version: str = attr.ib( + default=attr.Factory(lambda self: self.settings.api_version, takes_self=True) + ) + description: str = attr.ib( + default=attr.Factory( + lambda self: self.settings.api_description, takes_self=True + ) + ) app: FastAPI = attr.ib( default=attr.Factory( lambda self: FastAPI( openapi_url=self.settings.openapi_url, docs_url=self.settings.docs_url, redoc_url=None, + description=self.description, + title=self.title, + version=self.api_version, ), takes_self=True, ), converter=update_openapi, ) router: APIRouter = attr.ib(default=attr.Factory(APIRouter)) - title: str = attr.ib(default="stac-fastapi") - api_version: str = attr.ib(default="0.1") stac_version: str = attr.ib(default=STAC_VERSION) - description: str = attr.ib(default="stac-fastapi") search_get_request_model: Type[BaseSearchGetRequest] = attr.ib( default=BaseSearchGetRequest ) @@ -308,22 +318,6 @@ def register_core(self): self.register_get_collection() self.register_get_item_collection() - def customize_openapi(self) -> Optional[Dict[str, Any]]: - """Customize openapi schema.""" - if self.app.openapi_schema: - return self.app.openapi_schema - - openapi_schema = get_openapi( - title=self.title, - version=self.api_version, - description=self.description, - routes=self.app.routes, - servers=self.app.servers, - ) - - self.app.openapi_schema = openapi_schema - return self.app.openapi_schema - def add_health_check(self): """Add a health check.""" mgmt_router = APIRouter(prefix=self.app.state.router_prefix) @@ -388,9 +382,6 @@ def __attrs_post_init__(self): # register exception handlers add_exception_handlers(self.app, status_codes=self.exceptions) - # customize openapi - self.app.openapi = self.customize_openapi - # add middlewares for middleware in self.middlewares: self.app.add_middleware(middleware) diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index 832b15faf..1db63adf6 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -1,4 +1,5 @@ from fastapi import Depends, HTTPException, security, status +from pytest import MonkeyPatch from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi @@ -132,7 +133,16 @@ def must_be_bob( def test_openapi(monkeypatch: MonkeyPatch): - settings = config.ApiSettings() + api_description = "API Description for Testing" + api_title = "API Title For Testing" + api_version = "0.1-testing" + + with monkeypatch.context() as m: + m.setenv("API_DESCRIPTION", api_description) + m.setenv("API_TITLE", api_title) + m.setenv("API_VERSION", api_version) + settings = config.ApiSettings() + api = StacApi( **{ "settings": settings, @@ -154,3 +164,8 @@ def test_openapi(monkeypatch: MonkeyPatch): response.headers["Content-Type"] == "application/vnd.oai.openapi+json;version=3.0" ) + + info = response.json()["info"] + assert info["description"] == api_description + assert info["title"] == api_title + assert info["version"] == api_version diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index a5ffbb95f..41d8b0072 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -29,6 +29,10 @@ class ApiSettings(BaseSettings): openapi_url: str = "/api" docs_url: str = "/api.html" + api_title: str = "stac-fastapi" + api_description: str = "stac-fastapi" + api_version: str = "0.1" + class Config: """model config (https://pydantic-docs.helpmanual.io/usage/model_config/).""" From 8a8da645203c46e1bbede946ecee8455ee4f6e68 Mon Sep 17 00:00:00 2001 From: Christian Wygoda Date: Fri, 25 Nov 2022 11:13:53 +0100 Subject: [PATCH 3/5] Allow setting servers in OpenAPI from ApiSettings --- stac_fastapi/api/stac_fastapi/api/app.py | 1 + stac_fastapi/api/tests/test_api.py | 11 ++++++++++- stac_fastapi/types/stac_fastapi/types/config.py | 15 +++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index 7af27e3be..f8760e653 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -80,6 +80,7 @@ class StacApi: description=self.description, title=self.title, version=self.api_version, + servers=[server.dict() for server in self.settings.api_servers], ), takes_self=True, ), diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index 1db63adf6..1eaafe4b4 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -1,3 +1,5 @@ +from json import dumps + from fastapi import Depends, HTTPException, security, status from pytest import MonkeyPatch from starlette.testclient import TestClient @@ -136,11 +138,16 @@ def test_openapi(monkeypatch: MonkeyPatch): api_description = "API Description for Testing" api_title = "API Title For Testing" api_version = "0.1-testing" + api_servers = [ + {"url": "http://api1", "description": "API 1"}, + {"url": "http://api2"}, + ] with monkeypatch.context() as m: m.setenv("API_DESCRIPTION", api_description) m.setenv("API_TITLE", api_title) m.setenv("API_VERSION", api_version) + m.setenv("API_SERVERS", dumps(api_servers)) settings = config.ApiSettings() api = StacApi( @@ -165,7 +172,9 @@ def test_openapi(monkeypatch: MonkeyPatch): == "application/vnd.oai.openapi+json;version=3.0" ) - info = response.json()["info"] + data = response.json() + info = data["info"] assert info["description"] == api_description assert info["title"] == api_title assert info["version"] == api_version + assert data["servers"] == api_servers diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index 41d8b0072..84dc15238 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -1,7 +1,17 @@ """stac_fastapi.types.config module.""" -from typing import Optional, Set +from typing import List, Optional, Set -from pydantic import BaseSettings +from pydantic import AnyHttpUrl, BaseModel, BaseSettings + + +class ApiServer(BaseModel): + """ApiServer. + + Defines a server entry in the OpenAPI document. + """ + + url: AnyHttpUrl + description: Optional[str] = None class ApiSettings(BaseSettings): @@ -32,6 +42,7 @@ class ApiSettings(BaseSettings): api_title: str = "stac-fastapi" api_description: str = "stac-fastapi" api_version: str = "0.1" + api_servers: List[ApiServer] = [] class Config: """model config (https://pydantic-docs.helpmanual.io/usage/model_config/).""" From 361a0918c46faf1363f6500e9974317ff45a3ae9 Mon Sep 17 00:00:00 2001 From: Christian Wygoda Date: Fri, 25 Nov 2022 12:25:07 +0100 Subject: [PATCH 4/5] Set additional OpenAPI metadata from ApiSettings --- stac_fastapi/api/stac_fastapi/api/app.py | 6 +- stac_fastapi/api/tests/test_api.py | 81 ++++++++++++++----- .../types/stac_fastapi/types/config.py | 20 ++--- 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py index f8760e653..00f0ffe7c 100644 --- a/stac_fastapi/api/stac_fastapi/api/app.py +++ b/stac_fastapi/api/stac_fastapi/api/app.py @@ -80,7 +80,11 @@ class StacApi: description=self.description, title=self.title, version=self.api_version, - servers=[server.dict() for server in self.settings.api_servers], + servers=self.settings.api_servers, + terms_of_service=self.settings.api_terms_of_service, + contact=self.settings.api_contact, + license_info=self.settings.api_license_info, + openapi_tags=self.settings.api_tags, ), takes_self=True, ), diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py index 1eaafe4b4..b66f08a35 100644 --- a/stac_fastapi/api/tests/test_api.py +++ b/stac_fastapi/api/tests/test_api.py @@ -1,7 +1,9 @@ -from json import dumps +from json import dumps, loads +from os import environ +from typing import Any, Dict from fastapi import Depends, HTTPException, security, status -from pytest import MonkeyPatch +from pytest import MonkeyPatch, mark from starlette.testclient import TestClient from stac_fastapi.api.app import StacApi @@ -134,21 +136,48 @@ def must_be_bob( ) -def test_openapi(monkeypatch: MonkeyPatch): - api_description = "API Description for Testing" - api_title = "API Title For Testing" - api_version = "0.1-testing" - api_servers = [ - {"url": "http://api1", "description": "API 1"}, - {"url": "http://api2"}, - ] - - with monkeypatch.context() as m: - m.setenv("API_DESCRIPTION", api_description) - m.setenv("API_TITLE", api_title) - m.setenv("API_VERSION", api_version) - m.setenv("API_SERVERS", dumps(api_servers)) - settings = config.ApiSettings() +@mark.parametrize( + "env,", + ( + {}, + { + "api_description": "API Description for Testing", + "api_title": "API Title For Testing", + "api_version": "0.1-testing", + "api_servers": [ + {"url": "http://api1", "description": "API 1"}, + {"url": "http://api2"}, + ], + "api_terms_of_service": "http://terms-of-service", + "api_contact": { + "name": "Contact", + "url": "http://contact", + "email": "info@contact", + }, + "api_license_info": { + "name": "License", + "url": "http://license", + }, + "api_tags": [ + { + "name": "Tag", + "description": "Test tag", + "externalDocs": { + "url": "http://tags/tag", + "description": "rtfm", + }, + } + ], + }, + ), +) +def test_openapi(monkeypatch: MonkeyPatch, env: Dict[str, Any]): + for key, value in env.items(): + monkeypatch.setenv( + key.upper(), + value if isinstance(value, str) else dumps(value), + ) + settings = config.ApiSettings() api = StacApi( **{ @@ -172,9 +201,19 @@ def test_openapi(monkeypatch: MonkeyPatch): == "application/vnd.oai.openapi+json;version=3.0" ) + def expected_value(key: str, json=False) -> Any: + if key.upper() in environ: + value = environ[key.upper()] + return loads(value) if json else value + return getattr(settings, key) + data = response.json() info = data["info"] - assert info["description"] == api_description - assert info["title"] == api_title - assert info["version"] == api_version - assert data["servers"] == api_servers + assert info["description"] == expected_value("api_description") + assert info["title"] == expected_value("api_title") + assert info["version"] == expected_value("api_version") + assert info.get("termsOfService", None) == expected_value("api_terms_of_service") + assert info.get("contact") == expected_value("api_contact", True) + assert info.get("license") == expected_value("api_license_info", True) + assert data.get("servers", []) == expected_value("api_servers", True) + assert data.get("tags", []) == expected_value("api_tags", True) diff --git a/stac_fastapi/types/stac_fastapi/types/config.py b/stac_fastapi/types/stac_fastapi/types/config.py index 84dc15238..6f7b5a83a 100644 --- a/stac_fastapi/types/stac_fastapi/types/config.py +++ b/stac_fastapi/types/stac_fastapi/types/config.py @@ -1,17 +1,7 @@ """stac_fastapi.types.config module.""" -from typing import List, Optional, Set +from typing import Any, Dict, List, Optional, Set -from pydantic import AnyHttpUrl, BaseModel, BaseSettings - - -class ApiServer(BaseModel): - """ApiServer. - - Defines a server entry in the OpenAPI document. - """ - - url: AnyHttpUrl - description: Optional[str] = None +from pydantic import AnyHttpUrl, BaseSettings class ApiSettings(BaseSettings): @@ -42,7 +32,11 @@ class ApiSettings(BaseSettings): api_title: str = "stac-fastapi" api_description: str = "stac-fastapi" api_version: str = "0.1" - api_servers: List[ApiServer] = [] + api_servers: List[Dict[str, Any]] = [] + api_terms_of_service: Optional[AnyHttpUrl] = None + api_contact: Optional[Dict[str, Any]] = None + api_license_info: Optional[Dict[str, Any]] = None + api_tags: List[Dict[str, Any]] = [] class Config: """model config (https://pydantic-docs.helpmanual.io/usage/model_config/).""" From 12688a41f8bf055991a01715eb0f2500d4e4e471 Mon Sep 17 00:00:00 2001 From: Christian Wygoda Date: Fri, 25 Nov 2022 15:05:45 +0100 Subject: [PATCH 5/5] Update CHANGES.md --- CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index e6c919904..5d932dd78 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ ## Unreleased +* Add support for setting OpenAPI metadata through environment variables. + ### Added * Add support in pgstac backend for /queryables and /collections/{collection_id}/queryables endpoints with functions exposed in pgstac 0.6.8