From e493ae0abb6ace041de8c9e4551588d5f12cf5f0 Mon Sep 17 00:00:00 2001 From: Justin Trautmann Date: Tue, 1 Apr 2025 17:05:22 +0100 Subject: [PATCH 1/7] remove some models from fastapi --- .../stapi_fastapi/backends/product_backend.py | 6 +- .../stapi_fastapi/backends/root_backend.py | 9 +- .../src/stapi_fastapi/models/__init__.py | 5 +- .../src/stapi_fastapi/models/conformance.py | 9 -- .../src/stapi_fastapi/models/constraints.py | 5 - .../src/stapi_fastapi/models/opportunity.py | 83 ----------- .../src/stapi_fastapi/models/order.py | 131 ------------------ .../src/stapi_fastapi/models/product.py | 7 +- .../src/stapi_fastapi/models/root.py | 3 +- .../src/stapi_fastapi/models/shared.py | 32 ----- .../stapi_fastapi/routers/product_router.py | 14 +- .../src/stapi_fastapi/routers/root_router.py | 37 ++--- .../stapi_fastapi/types/datetime_interval.py | 41 ------ .../src/stapi_fastapi/types/filter.py | 19 --- stapi-fastapi/tests/backends.py | 6 +- stapi-fastapi/tests/conftest.py | 6 +- stapi-fastapi/tests/shared.py | 14 +- stapi-fastapi/tests/test_opportunity.py | 2 +- stapi-fastapi/tests/test_opportunity_async.py | 4 +- stapi-fastapi/tests/test_order.py | 2 +- stapi-pydantic/src/stapi_pydantic/shared.py | 2 +- 21 files changed, 59 insertions(+), 378 deletions(-) delete mode 100644 stapi-fastapi/src/stapi_fastapi/models/conformance.py delete mode 100644 stapi-fastapi/src/stapi_fastapi/models/constraints.py delete mode 100644 stapi-fastapi/src/stapi_fastapi/models/opportunity.py delete mode 100644 stapi-fastapi/src/stapi_fastapi/models/order.py delete mode 100644 stapi-fastapi/src/stapi_fastapi/models/shared.py delete mode 100644 stapi-fastapi/src/stapi_fastapi/types/datetime_interval.py delete mode 100644 stapi-fastapi/src/stapi_fastapi/types/filter.py diff --git a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py index d324ae0..e429222 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/product_backend.py @@ -6,14 +6,14 @@ from fastapi import Request from returns.maybe import Maybe from returns.result import ResultE - -from stapi_fastapi.models.opportunity import ( +from stapi_pydantic.opportunity import ( Opportunity, OpportunityCollection, OpportunityPayload, OpportunitySearchRecord, ) -from stapi_fastapi.models.order import Order, OrderPayload +from stapi_pydantic.order import Order, OrderPayload + from stapi_fastapi.routers.product_router import ProductRouter SearchOpportunities = Callable[ diff --git a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py index 9d2e459..c773b45 100644 --- a/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py +++ b/stapi-fastapi/src/stapi_fastapi/backends/root_backend.py @@ -4,16 +4,15 @@ from fastapi import Request from returns.maybe import Maybe from returns.result import ResultE - -from stapi_fastapi.models.opportunity import OpportunitySearchRecord -from stapi_fastapi.models.order import ( +from stapi_pydantic.opportunity import OpportunitySearchRecord +from stapi_pydantic.order import ( Order, OrderStatus, ) GetOrders = Callable[ [str | None, int, Request], - Coroutine[Any, Any, ResultE[tuple[list[Order], Maybe[str]]]], + Coroutine[Any, Any, ResultE[tuple[list[Order[OrderStatus]], Maybe[str]]]], ] """ Type alias for an async function that returns a list of existing Orders. @@ -33,7 +32,7 @@ - Returning returns.result.Failure[Exception] will result in a 500. """ -GetOrder = Callable[[str, Request], Coroutine[Any, Any, ResultE[Maybe[Order]]]] +GetOrder = Callable[[str, Request], Coroutine[Any, Any, ResultE[Maybe[Order[OrderStatus]]]]] """ Type alias for an async function that gets details for the order with `order_id`. diff --git a/stapi-fastapi/src/stapi_fastapi/models/__init__.py b/stapi-fastapi/src/stapi_fastapi/models/__init__.py index 084f481..cd6e1c9 100644 --- a/stapi-fastapi/src/stapi_fastapi/models/__init__.py +++ b/stapi-fastapi/src/stapi_fastapi/models/__init__.py @@ -1,6 +1,7 @@ -from .opportunity import OpportunityProperties +from stapi_pydantic.opportunity import OpportunityProperties +from stapi_pydantic.shared import Link + from .product import Product, Provider, ProviderRole -from .shared import Link __all__ = [ "Link", diff --git a/stapi-fastapi/src/stapi_fastapi/models/conformance.py b/stapi-fastapi/src/stapi_fastapi/models/conformance.py deleted file mode 100644 index c3d9d4a..0000000 --- a/stapi-fastapi/src/stapi_fastapi/models/conformance.py +++ /dev/null @@ -1,9 +0,0 @@ -from pydantic import BaseModel, Field - -CORE = "https://stapi.example.com/v0.1.0/core" -OPPORTUNITIES = "https://stapi.example.com/v0.1.0/opportunities" -ASYNC_OPPORTUNITIES = "https://stapi.example.com/v0.1.0/async-opportunities" - - -class Conformance(BaseModel): - conforms_to: list[str] = Field(default_factory=list, serialization_alias="conformsTo") diff --git a/stapi-fastapi/src/stapi_fastapi/models/constraints.py b/stapi-fastapi/src/stapi_fastapi/models/constraints.py deleted file mode 100644 index ad3e6de..0000000 --- a/stapi-fastapi/src/stapi_fastapi/models/constraints.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic import BaseModel, ConfigDict - - -class Constraints(BaseModel): - model_config = ConfigDict(extra="allow") diff --git a/stapi-fastapi/src/stapi_fastapi/models/opportunity.py b/stapi-fastapi/src/stapi_fastapi/models/opportunity.py deleted file mode 100644 index 0257694..0000000 --- a/stapi-fastapi/src/stapi_fastapi/models/opportunity.py +++ /dev/null @@ -1,83 +0,0 @@ -from enum import StrEnum -from typing import Any, Literal, TypeVar - -from geojson_pydantic import Feature, FeatureCollection -from geojson_pydantic.geometries import Geometry -from pydantic import AwareDatetime, BaseModel, ConfigDict, Field - -from stapi_fastapi.models.shared import Link -from stapi_fastapi.types.datetime_interval import DatetimeInterval -from stapi_fastapi.types.filter import CQL2Filter - - -# Copied and modified from https://github.com/stac-utils/stac-pydantic/blob/main/stac_pydantic/item.py#L11 -class OpportunityProperties(BaseModel): - datetime: DatetimeInterval - product_id: str - model_config = ConfigDict(extra="allow") - - -class OpportunityPayload(BaseModel): - datetime: DatetimeInterval - geometry: Geometry - filter: CQL2Filter | None = None - - next: str | None = None - limit: int = 10 - - model_config = ConfigDict(strict=True) - - def search_body(self) -> dict[str, Any]: - return self.model_dump(mode="json", include={"datetime", "geometry", "filter"}) - - def body(self) -> dict[str, Any]: - return self.model_dump(mode="json") - - -G = TypeVar("G", bound=Geometry) -P = TypeVar("P", bound=OpportunityProperties) - - -class Opportunity(Feature[G, P]): - type: Literal["Feature"] = "Feature" - links: list[Link] = Field(default_factory=list) - - -class OpportunityCollection(FeatureCollection[Opportunity[G, P]]): - type: Literal["FeatureCollection"] = "FeatureCollection" - links: list[Link] = Field(default_factory=list) - id: str | None = None - - -class OpportunitySearchStatusCode(StrEnum): - received = "received" - in_progress = "in_progress" - failed = "failed" - canceled = "canceled" - completed = "completed" - - -class OpportunitySearchStatus(BaseModel): - timestamp: AwareDatetime - status_code: OpportunitySearchStatusCode - reason_code: str | None = None - reason_text: str | None = None - links: list[Link] = Field(default_factory=list) - - -class OpportunitySearchRecord(BaseModel): - id: str - product_id: str - opportunity_request: OpportunityPayload - status: OpportunitySearchStatus - links: list[Link] = Field(default_factory=list) - - -class OpportunitySearchRecords(BaseModel): - search_records: list[OpportunitySearchRecord] - links: list[Link] = Field(default_factory=list) - - -class Prefer(StrEnum): - respond_async = "respond-async" - wait = "wait" diff --git a/stapi-fastapi/src/stapi_fastapi/models/order.py b/stapi-fastapi/src/stapi_fastapi/models/order.py deleted file mode 100644 index 121df01..0000000 --- a/stapi-fastapi/src/stapi_fastapi/models/order.py +++ /dev/null @@ -1,131 +0,0 @@ -from collections.abc import Iterator -from enum import StrEnum -from typing import Any, Generic, Literal, TypeVar - -from geojson_pydantic.base import _GeoJsonBase -from geojson_pydantic.geometries import Geometry -from pydantic import ( - AwareDatetime, - BaseModel, - ConfigDict, - Field, - StrictStr, - field_validator, -) - -from stapi_fastapi.models.opportunity import OpportunityProperties -from stapi_fastapi.models.shared import Link -from stapi_fastapi.types.datetime_interval import DatetimeInterval -from stapi_fastapi.types.filter import CQL2Filter - -Props = TypeVar("Props", bound=dict[str, Any] | BaseModel) -Geom = TypeVar("Geom", bound=Geometry) - - -class OrderParameters(BaseModel): - model_config = ConfigDict(extra="forbid") - - -OPP = TypeVar("OPP", bound=OpportunityProperties) -ORP = TypeVar("ORP", bound=OrderParameters) - - -class OrderStatusCode(StrEnum): - received = "received" - accepted = "accepted" - rejected = "rejected" - completed = "completed" - canceled = "canceled" - scheduled = "scheduled" - held = "held" - processing = "processing" - reserved = "reserved" - tasked = "tasked" - user_canceled = "user_canceled" - - -class OrderStatus(BaseModel): - timestamp: AwareDatetime - status_code: OrderStatusCode - reason_code: str | None = None - reason_text: str | None = None - links: list[Link] = Field(default_factory=list) - - model_config = ConfigDict(extra="allow") - - -class OrderStatuses[T: OrderStatus](BaseModel): - statuses: list[T] - links: list[Link] = Field(default_factory=list) - - -class OrderSearchParameters(BaseModel): - datetime: DatetimeInterval - geometry: Geometry - # TODO: validate the CQL2 filter? - filter: CQL2Filter | None = None - - -class OrderProperties[T: OrderStatus](BaseModel): - product_id: str - created: AwareDatetime - status: T - - search_parameters: OrderSearchParameters - opportunity_properties: dict[str, Any] - order_parameters: dict[str, Any] - - model_config = ConfigDict(extra="allow") - - -# derived from geojson_pydantic.Feature -class Order(_GeoJsonBase): - # We need to enforce that orders have an id defined, as that is required to - # retrieve them via the API - id: StrictStr - type: Literal["Feature"] = "Feature" - - geometry: Geometry = Field(...) - properties: OrderProperties[OrderStatus] = Field(...) - - links: list[Link] = Field(default_factory=list) - - __geojson_exclude_if_none__ = {"bbox", "id"} - - @field_validator("geometry", mode="before") - def set_geometry(cls, geometry: Any) -> Any: - """set geometry from geo interface or input""" - if hasattr(geometry, "__geo_interface__"): - return geometry.__geo_interface__ - - return geometry - - -# derived from geojson_pydantic.FeatureCollection -class OrderCollection(_GeoJsonBase): - type: Literal["FeatureCollection"] = "FeatureCollection" - features: list[Order] - links: list[Link] = Field(default_factory=list) - - def __iter__(self) -> Iterator[Order]: # type: ignore [override] - """iterate over features""" - return iter(self.features) - - def __len__(self) -> int: - """return features length""" - return len(self.features) - - def __getitem__(self, index: int) -> Order: - """get feature at a given index""" - return self.features[index] - - -class OrderPayload(BaseModel, Generic[ORP]): - datetime: DatetimeInterval - geometry: Geometry - # TODO: validate the CQL2 filter? - filter: CQL2Filter | None = None - - order_parameters: ORP - - model_config = ConfigDict(strict=True) diff --git a/stapi-fastapi/src/stapi_fastapi/models/product.py b/stapi-fastapi/src/stapi_fastapi/models/product.py index 180455b..72c128e 100644 --- a/stapi-fastapi/src/stapi_fastapi/models/product.py +++ b/stapi-fastapi/src/stapi_fastapi/models/product.py @@ -4,10 +4,9 @@ from typing import TYPE_CHECKING, Any, Literal, Self from pydantic import AnyHttpUrl, BaseModel, Field - -from stapi_fastapi.models.opportunity import OpportunityProperties -from stapi_fastapi.models.order import OrderParameters -from stapi_fastapi.models.shared import Link +from stapi_pydantic.opportunity import OpportunityProperties +from stapi_pydantic.order import OrderParameters +from stapi_pydantic.shared import Link if TYPE_CHECKING: from stapi_fastapi.backends.product_backend import ( diff --git a/stapi-fastapi/src/stapi_fastapi/models/root.py b/stapi-fastapi/src/stapi_fastapi/models/root.py index 0a4480c..0b9c0fd 100644 --- a/stapi-fastapi/src/stapi_fastapi/models/root.py +++ b/stapi-fastapi/src/stapi_fastapi/models/root.py @@ -1,6 +1,5 @@ from pydantic import BaseModel, Field - -from stapi_fastapi.models.shared import Link +from stapi_pydantic.shared import Link class RootResponse(BaseModel): diff --git a/stapi-fastapi/src/stapi_fastapi/models/shared.py b/stapi-fastapi/src/stapi_fastapi/models/shared.py deleted file mode 100644 index 5564a79..0000000 --- a/stapi-fastapi/src/stapi_fastapi/models/shared.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Any - -from pydantic import ( - AnyUrl, - BaseModel, - ConfigDict, - SerializerFunctionWrapHandler, - model_serializer, -) - - -class Link(BaseModel): - href: AnyUrl - rel: str - type: str | None = None - title: str | None = None - method: str | None = None - headers: dict[str, str | list[str]] | None = None - body: Any = None - - model_config = ConfigDict(extra="allow") - - # redefining init is a hack to get str type to validate for `href`, - # as str is ultimately coerced into an AnyUrl automatically anyway - def __init__(self, href: AnyUrl | str, **kwargs: Any) -> None: - super().__init__(href=href, **kwargs) - - # overriding the default serialization to filter None field values from - # dumped json - @model_serializer(mode="wrap", when_used="json") - def serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: - return {k: v for k, v in handler(self).items() if v is not None} diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index ade0b69..ecb64d5 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -17,18 +17,18 @@ from geojson_pydantic.geometries import Geometry from returns.maybe import Maybe, Some from returns.result import Failure, Success - -from stapi_fastapi.constants import TYPE_JSON -from stapi_fastapi.exceptions import ConstraintsException, NotFoundException -from stapi_fastapi.models.opportunity import ( +from stapi_pydantic.opportunity import ( OpportunityCollection, OpportunityPayload, OpportunitySearchRecord, Prefer, ) -from stapi_fastapi.models.order import Order, OrderPayload +from stapi_pydantic.order import Order, OrderPayload, OrderStatus +from stapi_pydantic.shared import Link + +from stapi_fastapi.constants import TYPE_JSON +from stapi_fastapi.exceptions import ConstraintsException, NotFoundException from stapi_fastapi.models.product import Product -from stapi_fastapi.models.shared import Link from stapi_fastapi.responses import GeoJSONResponse from stapi_fastapi.routers.route_names import ( CREATE_ORDER, @@ -115,7 +115,7 @@ async def _create_order( payload: OrderPayload, # type: ignore request: Request, response: Response, - ) -> Order: + ) -> Order[OrderStatus]: return await self.create_order(payload, request, response) _create_order.__annotations__["payload"] = OrderPayload[ diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index aa57ff1..c819e59 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -6,33 +6,34 @@ from fastapi.datastructures import URL from returns.maybe import Maybe, Some from returns.result import Failure, Success - -from stapi_fastapi.backends.root_backend import ( - GetOpportunitySearchRecord, - GetOpportunitySearchRecords, - GetOrder, - GetOrders, - GetOrderStatuses, -) -from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON -from stapi_fastapi.exceptions import NotFoundException -from stapi_fastapi.models.conformance import ( +from stapi_pydantic.conformance import ( ASYNC_OPPORTUNITIES, CORE, Conformance, ) -from stapi_fastapi.models.opportunity import ( +from stapi_pydantic.opportunity import ( OpportunitySearchRecord, OpportunitySearchRecords, ) -from stapi_fastapi.models.order import ( +from stapi_pydantic.order import ( Order, OrderCollection, + OrderStatus, OrderStatuses, ) +from stapi_pydantic.shared import Link + +from stapi_fastapi.backends.root_backend import ( + GetOpportunitySearchRecord, + GetOpportunitySearchRecords, + GetOrder, + GetOrders, + GetOrderStatuses, +) +from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON +from stapi_fastapi.exceptions import NotFoundException from stapi_fastapi.models.product import Product, ProductsCollection from stapi_fastapi.models.root import RootResponse -from stapi_fastapi.models.shared import Link from stapi_fastapi.responses import GeoJSONResponse from stapi_fastapi.routers.product_router import ProductRouter from stapi_fastapi.routers.route_names import ( @@ -237,7 +238,9 @@ def get_products(self, request: Request, next: str | None = None, limit: int = 1 links=links, ) - async def get_orders(self, request: Request, next: str | None = None, limit: int = 10) -> OrderCollection: + async def get_orders( + self, request: Request, next: str | None = None, limit: int = 10 + ) -> OrderCollection[OrderStatus]: links: list[Link] = [] match await self._get_orders(next, limit, request): case Success((orders, maybe_pagination_token)): @@ -263,7 +266,7 @@ async def get_orders(self, request: Request, next: str | None = None, limit: int raise AssertionError("Expected code to be unreachable") return OrderCollection(features=orders, links=links) - async def get_order(self, order_id: str, request: Request) -> Order: + async def get_order(self, order_id: str, request: Request) -> Order[OrderStatus]: """ Get details for order with `order_id`. """ @@ -332,7 +335,7 @@ def generate_order_href(self, request: Request, order_id: str) -> URL: def generate_order_statuses_href(self, request: Request, order_id: str) -> URL: return request.url_for(f"{self.name}:{LIST_ORDER_STATUSES}", order_id=order_id) - def order_links(self, order: Order, request: Request) -> list[Link]: + def order_links(self, order: Order[OrderStatus], request: Request) -> list[Link]: return [ Link( href=str(self.generate_order_href(request, order.id)), diff --git a/stapi-fastapi/src/stapi_fastapi/types/datetime_interval.py b/stapi-fastapi/src/stapi_fastapi/types/datetime_interval.py deleted file mode 100644 index ffc6d32..0000000 --- a/stapi-fastapi/src/stapi_fastapi/types/datetime_interval.py +++ /dev/null @@ -1,41 +0,0 @@ -from collections.abc import Callable -from datetime import datetime -from typing import Annotated, Any - -from pydantic import ( - AfterValidator, - AwareDatetime, - BeforeValidator, - WithJsonSchema, - WrapSerializer, -) - - -def validate_before(value: Any) -> Any: - if isinstance(value, str): - start, end = value.split("/", 1) - return (datetime.fromisoformat(start), datetime.fromisoformat(end)) - return value - - -def validate_after(value: tuple[datetime, datetime]) -> tuple[datetime, datetime]: - if value[1] < value[0]: - raise ValueError("end before start") - return value - - -def serialize( - value: tuple[datetime, datetime], - serializer: Callable[[tuple[datetime, datetime]], tuple[str, str]], -) -> str: - del serializer # unused - return f"{value[0].isoformat()}/{value[1].isoformat()}" - - -type DatetimeInterval = Annotated[ - tuple[AwareDatetime, AwareDatetime], - BeforeValidator(validate_before), - AfterValidator(validate_after), - WrapSerializer(serialize, return_type=str), - WithJsonSchema({"type": "string"}, mode="serialization"), -] diff --git a/stapi-fastapi/src/stapi_fastapi/types/filter.py b/stapi-fastapi/src/stapi_fastapi/types/filter.py deleted file mode 100644 index 084f14f..0000000 --- a/stapi-fastapi/src/stapi_fastapi/types/filter.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Annotated, Any - -from pydantic import BeforeValidator -from pygeofilter.parsers import cql2_json - - -def validate(v: dict[str, Any]) -> dict[str, Any]: - if v: - try: - cql2_json.parse({"filter": v}) - except Exception as e: - raise ValueError("Filter is not valid cql2-json") from e - return v - - -type CQL2Filter = Annotated[ - dict, - BeforeValidator(validate), -] diff --git a/stapi-fastapi/tests/backends.py b/stapi-fastapi/tests/backends.py index 0b0d62d..5b5071e 100644 --- a/stapi-fastapi/tests/backends.py +++ b/stapi-fastapi/tests/backends.py @@ -4,7 +4,8 @@ from fastapi import Request from returns.maybe import Maybe, Nothing, Some from returns.result import Failure, ResultE, Success -from stapi_fastapi.models.opportunity import ( +from stapi_fastapi.routers.product_router import ProductRouter +from stapi_pydantic.opportunity import ( Opportunity, OpportunityCollection, OpportunityPayload, @@ -12,7 +13,7 @@ OpportunitySearchStatus, OpportunitySearchStatusCode, ) -from stapi_fastapi.models.order import ( +from stapi_pydantic.order import ( Order, OrderPayload, OrderProperties, @@ -20,7 +21,6 @@ OrderStatus, OrderStatusCode, ) -from stapi_fastapi.routers.product_router import ProductRouter async def mock_get_orders(next: str | None, limit: int, request: Request) -> ResultE[tuple[list[Order], Maybe[str]]]: diff --git a/stapi-fastapi/tests/conftest.py b/stapi-fastapi/tests/conftest.py index e527dfd..bc32597 100644 --- a/stapi-fastapi/tests/conftest.py +++ b/stapi-fastapi/tests/conftest.py @@ -8,13 +8,13 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from stapi_fastapi.models.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES -from stapi_fastapi.models.opportunity import ( - Opportunity, -) from stapi_fastapi.models.product import ( Product, ) from stapi_fastapi.routers.root_router import RootRouter +from stapi_pydantic.opportunity import ( + Opportunity, +) from .backends import ( mock_get_opportunity_search_record, diff --git a/stapi-fastapi/tests/shared.py b/stapi-fastapi/tests/shared.py index 58b708b..6accd97 100644 --- a/stapi-fastapi/tests/shared.py +++ b/stapi-fastapi/tests/shared.py @@ -12,22 +12,22 @@ from httpx import Response from pydantic import BaseModel, Field, model_validator from pytest import fail -from stapi_fastapi.models.opportunity import ( +from stapi_fastapi.models.product import ( + Product, + Provider, + ProviderRole, +) +from stapi_pydantic.opportunity import ( Opportunity, OpportunityCollection, OpportunityProperties, OpportunitySearchRecord, ) -from stapi_fastapi.models.order import ( +from stapi_pydantic.order import ( Order, OrderParameters, OrderStatus, ) -from stapi_fastapi.models.product import ( - Product, - Provider, - ProviderRole, -) from .backends import ( mock_create_order, diff --git a/stapi-fastapi/tests/test_opportunity.py b/stapi-fastapi/tests/test_opportunity.py index 7cf187f..05aad04 100644 --- a/stapi-fastapi/tests/test_opportunity.py +++ b/stapi-fastapi/tests/test_opportunity.py @@ -1,6 +1,6 @@ import pytest from fastapi.testclient import TestClient -from stapi_fastapi.models.opportunity import ( +from stapi_pydantic.opportunity import ( OpportunityCollection, ) diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index 2f45755..8d86591 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -6,13 +6,13 @@ import pytest from fastapi import status from fastapi.testclient import TestClient -from stapi_fastapi.models.opportunity import ( +from stapi_fastapi.models.shared import Link +from stapi_pydantic.opportunity import ( OpportunityCollection, OpportunitySearchRecord, OpportunitySearchStatus, OpportunitySearchStatusCode, ) -from stapi_fastapi.models.shared import Link from .shared import ( create_mock_opportunity, diff --git a/stapi-fastapi/tests/test_order.py b/stapi-fastapi/tests/test_order.py index aee6586..b11de07 100644 --- a/stapi-fastapi/tests/test_order.py +++ b/stapi-fastapi/tests/test_order.py @@ -6,7 +6,7 @@ from geojson_pydantic import Point from geojson_pydantic.types import Position2D from httpx import Response -from stapi_fastapi.models.order import Order, OrderPayload, OrderStatus, OrderStatusCode +from stapi_pydantic.order import Order, OrderPayload, OrderStatus, OrderStatusCode from .shared import MyOrderParameters, find_link, pagination_tester diff --git a/stapi-pydantic/src/stapi_pydantic/shared.py b/stapi-pydantic/src/stapi_pydantic/shared.py index 92d61a6..5564a79 100644 --- a/stapi-pydantic/src/stapi_pydantic/shared.py +++ b/stapi-pydantic/src/stapi_pydantic/shared.py @@ -22,7 +22,7 @@ class Link(BaseModel): # redefining init is a hack to get str type to validate for `href`, # as str is ultimately coerced into an AnyUrl automatically anyway - def __init__(self, href: AnyUrl | str, **kwargs: Any): + def __init__(self, href: AnyUrl | str, **kwargs: Any) -> None: super().__init__(href=href, **kwargs) # overriding the default serialization to filter None field values from From 57bd6a4648285004b5299b15d62138afd3306e29 Mon Sep 17 00:00:00 2001 From: Justin Trautmann Date: Tue, 1 Apr 2025 17:19:21 +0100 Subject: [PATCH 2/7] Product subclass --- .../src/stapi_fastapi/models/__init__.py | 3 +- .../src/stapi_fastapi/models/product.py | 78 +------------------ .../src/stapi_fastapi/routers/root_router.py | 3 +- 3 files changed, 7 insertions(+), 77 deletions(-) diff --git a/stapi-fastapi/src/stapi_fastapi/models/__init__.py b/stapi-fastapi/src/stapi_fastapi/models/__init__.py index cd6e1c9..6692ca9 100644 --- a/stapi-fastapi/src/stapi_fastapi/models/__init__.py +++ b/stapi-fastapi/src/stapi_fastapi/models/__init__.py @@ -1,7 +1,8 @@ from stapi_pydantic.opportunity import OpportunityProperties +from stapi_pydantic.product import Provider, ProviderRole from stapi_pydantic.shared import Link -from .product import Product, Provider, ProviderRole +from .product import Product __all__ = [ "Link", diff --git a/stapi-fastapi/src/stapi_fastapi/models/product.py b/stapi-fastapi/src/stapi_fastapi/models/product.py index 72c128e..7214b6a 100644 --- a/stapi-fastapi/src/stapi_fastapi/models/product.py +++ b/stapi-fastapi/src/stapi_fastapi/models/product.py @@ -1,12 +1,8 @@ from __future__ import annotations -from enum import StrEnum -from typing import TYPE_CHECKING, Any, Literal, Self +from typing import TYPE_CHECKING, Any -from pydantic import AnyHttpUrl, BaseModel, Field -from stapi_pydantic.opportunity import OpportunityProperties -from stapi_pydantic.order import OrderParameters -from stapi_pydantic.shared import Link +from stapi_pydantic.product import Product as BaseProduct if TYPE_CHECKING: from stapi_fastapi.backends.product_backend import ( @@ -17,43 +13,7 @@ ) -type Constraints = BaseModel - - -class ProviderRole(StrEnum): - licensor = "licensor" - producer = "producer" - processor = "processor" - host = "host" - - -class Provider(BaseModel): - name: str - description: str | None = None - roles: list[ProviderRole] - url: AnyHttpUrl - - # redefining init is a hack to get str type to validate for `url`, - # as str is ultimately coerced into an AnyHttpUrl automatically anyway - def __init__(self, url: AnyHttpUrl | str, **kwargs: Any) -> None: - super().__init__(url=url, **kwargs) - - -class Product(BaseModel): - type_: Literal["Product"] = Field(default="Product", alias="type") - conformsTo: list[str] = Field(default_factory=list) - id: str - title: str = "" - description: str = "" - keywords: list[str] = Field(default_factory=list) - license: str - providers: list[Provider] = Field(default_factory=list) - links: list[Link] = Field(default_factory=list) - - # we don't want to include these in the model fields - _constraints: type[Constraints] - _opportunity_properties: type[OpportunityProperties] - _order_parameters: type[OrderParameters] +class Product(BaseProduct): _create_order: CreateOrder _search_opportunities: SearchOpportunities | None _search_opportunities_async: SearchOpportunitiesAsync | None @@ -62,9 +22,6 @@ class Product(BaseModel): def __init__( self, *args: Any, - constraints: type[Constraints], - opportunity_properties: type[OpportunityProperties], - order_parameters: type[OrderParameters], create_order: CreateOrder, search_opportunities: SearchOpportunities | None = None, search_opportunities_async: SearchOpportunitiesAsync | None = None, @@ -79,9 +36,6 @@ def __init__( "arguments must be provided if either is provided" ) - self._constraints = constraints - self._opportunity_properties = opportunity_properties - self._order_parameters = order_parameters self._create_order = create_order self._search_opportunities = search_opportunities self._search_opportunities_async = search_opportunities_async @@ -109,18 +63,6 @@ def get_opportunity_collection(self) -> GetOpportunityCollection: raise AttributeError("This product does not support async opportunity search") return self._get_opportunity_collection - @property - def constraints(self) -> type[Constraints]: - return self._constraints - - @property - def opportunity_properties(self) -> type[OpportunityProperties]: - return self._opportunity_properties - - @property - def order_parameters(self) -> type[OrderParameters]: - return self._order_parameters - @property def supports_opportunity_search(self) -> bool: return self._search_opportunities is not None @@ -128,17 +70,3 @@ def supports_opportunity_search(self) -> bool: @property def supports_async_opportunity_search(self) -> bool: return self._search_opportunities_async is not None and self._get_opportunity_collection is not None - - def with_links(self, links: list[Link] | None = None) -> Self: - if not links: - return self - - new = self.model_copy(deep=True) - new.links.extend(links) - return new - - -class ProductsCollection(BaseModel): - type_: Literal["ProductCollection"] = Field(default="ProductCollection", alias="type") - links: list[Link] = Field(default_factory=list) - products: list[Product] diff --git a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py index c819e59..1e1b78e 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/root_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/root_router.py @@ -21,6 +21,7 @@ OrderStatus, OrderStatuses, ) +from stapi_pydantic.product import ProductsCollection from stapi_pydantic.shared import Link from stapi_fastapi.backends.root_backend import ( @@ -32,7 +33,7 @@ ) from stapi_fastapi.constants import TYPE_GEOJSON, TYPE_JSON from stapi_fastapi.exceptions import NotFoundException -from stapi_fastapi.models.product import Product, ProductsCollection +from stapi_fastapi.models.product import Product from stapi_fastapi.models.root import RootResponse from stapi_fastapi.responses import GeoJSONResponse from stapi_fastapi.routers.product_router import ProductRouter From 59cf75754e4ab51285f80a21a624db368174acd1 Mon Sep 17 00:00:00 2001 From: Justin Trautmann Date: Tue, 1 Apr 2025 17:22:40 +0100 Subject: [PATCH 3/7] fix some test --- stapi-fastapi/tests/conftest.py | 2 +- stapi-fastapi/tests/shared.py | 7 ++----- stapi-fastapi/tests/test_conformance.py | 2 +- stapi-fastapi/tests/test_datetime_interval.py | 2 +- stapi-fastapi/tests/test_opportunity_async.py | 2 +- stapi-fastapi/tests/test_root.py | 2 +- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/stapi-fastapi/tests/conftest.py b/stapi-fastapi/tests/conftest.py index bc32597..ffbe9cb 100644 --- a/stapi-fastapi/tests/conftest.py +++ b/stapi-fastapi/tests/conftest.py @@ -7,7 +7,7 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from stapi_fastapi.models.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES +from stapi_pydantic.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES from stapi_fastapi.models.product import ( Product, ) diff --git a/stapi-fastapi/tests/shared.py b/stapi-fastapi/tests/shared.py index 6accd97..93454a0 100644 --- a/stapi-fastapi/tests/shared.py +++ b/stapi-fastapi/tests/shared.py @@ -12,11 +12,8 @@ from httpx import Response from pydantic import BaseModel, Field, model_validator from pytest import fail -from stapi_fastapi.models.product import ( - Product, - Provider, - ProviderRole, -) +from stapi_fastapi.models.product import Product +from stapi_pydantic.product import Provider, ProviderRole from stapi_pydantic.opportunity import ( Opportunity, OpportunityCollection, diff --git a/stapi-fastapi/tests/test_conformance.py b/stapi-fastapi/tests/test_conformance.py index 9b1f840..18d843a 100644 --- a/stapi-fastapi/tests/test_conformance.py +++ b/stapi-fastapi/tests/test_conformance.py @@ -1,6 +1,6 @@ from fastapi import status from fastapi.testclient import TestClient -from stapi_fastapi.models.conformance import CORE +from stapi_pydantic.conformance import CORE def test_conformance(stapi_client: TestClient) -> None: diff --git a/stapi-fastapi/tests/test_datetime_interval.py b/stapi-fastapi/tests/test_datetime_interval.py index 309cbeb..d701c3c 100644 --- a/stapi-fastapi/tests/test_datetime_interval.py +++ b/stapi-fastapi/tests/test_datetime_interval.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, ValidationError from pyrfc3339.utils import format_timezone from pytest import mark, raises -from stapi_fastapi.types.datetime_interval import DatetimeInterval +from stapi_pydantic.datetime_interval import DatetimeInterval EUROPE_BERLIN = ZoneInfo("Europe/Berlin") diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index 8d86591..40f6f79 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -6,7 +6,7 @@ import pytest from fastapi import status from fastapi.testclient import TestClient -from stapi_fastapi.models.shared import Link +from stapi_pydantic.shared import Link from stapi_pydantic.opportunity import ( OpportunityCollection, OpportunitySearchRecord, diff --git a/stapi-fastapi/tests/test_root.py b/stapi-fastapi/tests/test_root.py index 4583f7c..efa0c1a 100644 --- a/stapi-fastapi/tests/test_root.py +++ b/stapi-fastapi/tests/test_root.py @@ -1,6 +1,6 @@ from fastapi import status from fastapi.testclient import TestClient -from stapi_fastapi.models.conformance import CORE +from stapi_pydantic.conformance import CORE def test_root(stapi_client: TestClient, assert_link) -> None: From 748c5e7e3328fcf5e6def5ba2b97e86384809a75 Mon Sep 17 00:00:00 2001 From: Justin Trautmann Date: Tue, 1 Apr 2025 17:29:15 +0100 Subject: [PATCH 4/7] cleanup --- stapi-fastapi/CHANGELOG.md | 5 ++++ .../stapi_fastapi/routers/product_router.py | 2 +- .../src/stapi_fastapi/types/__init__.py | 0 .../stapi_fastapi/types/json_schema_model.py | 26 ------------------- stapi-fastapi/tests/conftest.py | 2 +- stapi-fastapi/tests/shared.py | 2 +- stapi-fastapi/tests/test_opportunity_async.py | 2 +- 7 files changed, 9 insertions(+), 30 deletions(-) delete mode 100644 stapi-fastapi/src/stapi_fastapi/types/__init__.py delete mode 100644 stapi-fastapi/src/stapi_fastapi/types/json_schema_model.py diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index 18858f0..b3a5744 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Add constants for route names to be used in link href generation +## Changed + +- stapi-fastapi is now using stapi-pydantic models, deduplicating code +- Product in stapu-fastapi is now subclass of Product from stapi-pydantic + ## [v0.6.0] - 2025-02-11 ### Added diff --git a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py index ecb64d5..a40f4db 100644 --- a/stapi-fastapi/src/stapi_fastapi/routers/product_router.py +++ b/stapi-fastapi/src/stapi_fastapi/routers/product_router.py @@ -17,6 +17,7 @@ from geojson_pydantic.geometries import Geometry from returns.maybe import Maybe, Some from returns.result import Failure, Success +from stapi_pydantic.json_schema_model import JsonSchemaModel from stapi_pydantic.opportunity import ( OpportunityCollection, OpportunityPayload, @@ -38,7 +39,6 @@ GET_PRODUCT, SEARCH_OPPORTUNITIES, ) -from stapi_fastapi.types.json_schema_model import JsonSchemaModel if TYPE_CHECKING: from stapi_fastapi.routers import RootRouter diff --git a/stapi-fastapi/src/stapi_fastapi/types/__init__.py b/stapi-fastapi/src/stapi_fastapi/types/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/stapi-fastapi/src/stapi_fastapi/types/json_schema_model.py b/stapi-fastapi/src/stapi_fastapi/types/json_schema_model.py deleted file mode 100644 index 95f0203..0000000 --- a/stapi-fastapi/src/stapi_fastapi/types/json_schema_model.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Annotated, Any - -from pydantic import ( - BaseModel, - PlainSerializer, - PlainValidator, - WithJsonSchema, -) - - -def validate(v: Any) -> Any: - if not issubclass(v, BaseModel): - raise RuntimeError("BaseModel class required") - return v - - -def serialize(v: type[BaseModel]) -> dict[str, Any]: - return v.model_json_schema() - - -type JsonSchemaModel = Annotated[ - type[BaseModel], - PlainValidator(validate), - PlainSerializer(serialize), - WithJsonSchema({"type": "object"}), -] diff --git a/stapi-fastapi/tests/conftest.py b/stapi-fastapi/tests/conftest.py index ffbe9cb..f1a540f 100644 --- a/stapi-fastapi/tests/conftest.py +++ b/stapi-fastapi/tests/conftest.py @@ -7,11 +7,11 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from stapi_pydantic.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES from stapi_fastapi.models.product import ( Product, ) from stapi_fastapi.routers.root_router import RootRouter +from stapi_pydantic.conformance import ASYNC_OPPORTUNITIES, CORE, OPPORTUNITIES from stapi_pydantic.opportunity import ( Opportunity, ) diff --git a/stapi-fastapi/tests/shared.py b/stapi-fastapi/tests/shared.py index 93454a0..4aef6e1 100644 --- a/stapi-fastapi/tests/shared.py +++ b/stapi-fastapi/tests/shared.py @@ -13,7 +13,6 @@ from pydantic import BaseModel, Field, model_validator from pytest import fail from stapi_fastapi.models.product import Product -from stapi_pydantic.product import Provider, ProviderRole from stapi_pydantic.opportunity import ( Opportunity, OpportunityCollection, @@ -25,6 +24,7 @@ OrderParameters, OrderStatus, ) +from stapi_pydantic.product import Provider, ProviderRole from .backends import ( mock_create_order, diff --git a/stapi-fastapi/tests/test_opportunity_async.py b/stapi-fastapi/tests/test_opportunity_async.py index 40f6f79..b78459e 100644 --- a/stapi-fastapi/tests/test_opportunity_async.py +++ b/stapi-fastapi/tests/test_opportunity_async.py @@ -6,13 +6,13 @@ import pytest from fastapi import status from fastapi.testclient import TestClient -from stapi_pydantic.shared import Link from stapi_pydantic.opportunity import ( OpportunityCollection, OpportunitySearchRecord, OpportunitySearchStatus, OpportunitySearchStatusCode, ) +from stapi_pydantic.shared import Link from .shared import ( create_mock_opportunity, From d863b371d7f3ed0b4f63bd0f7f0e1ffea3b6b8aa Mon Sep 17 00:00:00 2001 From: Justin Trautmann Date: Wed, 2 Apr 2025 09:27:17 +0100 Subject: [PATCH 5/7] fix api validation --- stapi-fastapi/tests/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stapi-fastapi/tests/application.py b/stapi-fastapi/tests/application.py index c201911..2ee9bc8 100644 --- a/stapi-fastapi/tests/application.py +++ b/stapi-fastapi/tests/application.py @@ -8,7 +8,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from stapi_fastapi.models.conformance import CORE, OPPORTUNITIES +from stapi_pydantic.conformance import CORE, OPPORTUNITIES from stapi_fastapi.routers.root_router import RootRouter from tests.backends import ( From 919653ee33f11b9f3fcf0db71f60935e54e78202 Mon Sep 17 00:00:00 2001 From: Justin Trautmann Date: Wed, 2 Apr 2025 09:28:23 +0100 Subject: [PATCH 6/7] lint --- stapi-fastapi/tests/application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stapi-fastapi/tests/application.py b/stapi-fastapi/tests/application.py index 2ee9bc8..da85b38 100644 --- a/stapi-fastapi/tests/application.py +++ b/stapi-fastapi/tests/application.py @@ -8,8 +8,8 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from stapi_pydantic.conformance import CORE, OPPORTUNITIES from stapi_fastapi.routers.root_router import RootRouter +from stapi_pydantic.conformance import CORE, OPPORTUNITIES from tests.backends import ( mock_get_opportunity_search_record, From 78cfb4c83901a682563835e2ea2abc4365d9fab7 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Wed, 2 Apr 2025 02:41:36 -0600 Subject: [PATCH 7/7] Update stapi-fastapi/CHANGELOG.md --- stapi-fastapi/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stapi-fastapi/CHANGELOG.md b/stapi-fastapi/CHANGELOG.md index b3a5744..d09e4ce 100644 --- a/stapi-fastapi/CHANGELOG.md +++ b/stapi-fastapi/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Changed - stapi-fastapi is now using stapi-pydantic models, deduplicating code -- Product in stapu-fastapi is now subclass of Product from stapi-pydantic +- Product in stapi-fastapi is now subclass of Product from stapi-pydantic ## [v0.6.0] - 2025-02-11