Repository: stac-utils/stac-fastapi
Pull request #718 (2024-06-26 10:06:31)

remove pagination extension dependency and add request model attributes
closes https://github.com/stac-utils/stac-fastapi/issues/602

This will also help https://github.com/stac-utils/stac-fastapi/pull/714 in case users needs to create custom request model which won't work with `create_request_model` method 

jonhealy1: I think I fixed the merge in the changelog correctly

Patch:
diff --git a/CHANGES.md b/CHANGES.md
index 2b74edb..fc4a11e 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -2,9 +2,23 @@
 
 ## [Unreleased] - TBD
 
+### Added
+
+* Add attributes to `stac_fastapi.api.app.StacApi` to enable customization of request model for:
+  - `/collections`: **collections_get_request_model**, default to `EmptyRequest`
+  - `/collections/{collection_id}`: **collection_get_request_model**, default to `CollectionUri`
+  - `/collections/{collection_id}/items`: **items_get_request_model**, default to `ItemCollectionUri`
+  - `/collections/{collection_id}/items/{item_id}`: **item_get_request_model**, default to `ItemUri`
+
+### Removed
+
+* Removed the Filter Extension dependency from `AggregationExtensionPostRequest` and `AggregationExtensionGetRequest` [#716](https://github.com/stac-utils/stac-fastapi/pull/716)
+* Removed `pagination_extension` attribute in `stac_fastapi.api.app.StacApi`
+* Removed use of `pagination_extension` in `register_get_item_collection` function (User now need to construct the request model and pass it using `items_get_request_model` attribute)
+
 ### Changed
 
-* moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717)
+* Moved `GETPagination`, `POSTPagination`, `GETTokenPagination` and `POSTTokenPagination` to `stac_fastapi.extensions.core.pagination.request` submodule [#717](https://github.com/stac-utils/stac-fastapi/pull/717)
 
 ## [3.0.0a4] - 2024-06-27
 
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index dff2035..e5326e2 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -74,6 +74,8 @@ nav:
           - search: api/stac_fastapi/types/search.md
           - stac: api/stac_fastapi/types/stac.md
           - version: api/stac_fastapi/types/version.md
+  - Migration Guides:
+    - v2.5 -> v3.0: migrations/v3.0.0.md
   - Performance Benchmarks: benchmarks.html
   - Development - Contributing: "contributing.md"
   - Release Notes: "release-notes.md"
diff --git a/docs/src/migrations/v3.0.0.md b/docs/src/migrations/v3.0.0.md
new file mode 100644
index 0000000..6cbb360
--- /dev/null
+++ b/docs/src/migrations/v3.0.0.md
@@ -0,0 +1,163 @@
+
+# stac-fastapi v3.0 Migration Guide
+
+This document aims to help you update your application from **stac-fastapi** 2.5 to 3.0.0.
+
+## Dependencies
+
+- **pydantic~=2.0**
+- **fastapi>=0.111**
+- **stac-pydantic~=3.1**
+
+Most of the **stac-fastapi's** dependencies have been upgraded. Moving from pydantic v1 to v2 is mostly the one update bringing most breaking changes (see https://docs.pydantic.dev/latest/migration/).
+
+In addition to pydantic v2 update, `stac-pydantic` has been updated to better match the STAC and STAC-API specifications (see https://github.com/stac-utils/stac-pydantic/blob/main/CHANGELOG.md#310-2024-05-21)
+
+
+## Deprecation
+
+* the `ContextExtension` have been removed (see https://github.com/stac-utils/stac-pydantic/pull/138) and was replaced by optional `NumberMatched` and `NumberReturned` attributes, defined by the OGC features specification.
+
+* `stac_fastapi.api.config_openapi` method was removed (see https://github.com/stac-utils/stac-fastapi/pull/523)
+
+* passing `response_class` in `stac_fastapi.api.routes.create_async_endpoint` is now deprecated. The response class now has to be set when registering the endpoint to the application (see https://github.com/stac-utils/stac-fastapi/issues/461)
+
+* `PostFieldsExtension.filter_fields` property has been removed.
+
+## Middlewares configuration
+
+The `StacApi.middlewares` attribute has been updated to accept a list of `starlette.middleware.Middleware`. This enables dynamic configuration of middlewares (see https://github.com/stac-utils/stac-fastapi/pull/442).
+
+```python
+# before
+class myMiddleware(mainMiddleware):
+    option1 = option1
+    option2 = option2
+
+stac = StacApi(
+    middlewares=[
+        myMiddleware,
+    ]
+)
+
+# now
+stac = StacApi(
+    middlewares=[
+        Middleware(myMiddleware, option1, option2),
+    ]
+)
+```
+
+## Request Models
+
+In stac-fastapi v2.0, users could already customize both GET/POST search request models. For v3.0, we've added more attributes to enable other endpoints customization:
+
+- `collections_get_request_model`: GET request model for the `/collections` endpoint (default to `EmptyRequest`)
+- `collection_get_request_model`: GET request model for the `/collections/{collection_id}` endpoint (default to `stac_fastapi.api.models.CollectionUri`)
+- `items_get_request_model`: GET request model for the `/collections/{collection_id}/items` endpoint (default to `stac_fastapi.api.models.ItemCollectionUri`)
+- `item_get_request_model`: GET request model for the `/collections/{collection_id}/items/{item_id}` endpoint (default to `stac_fastapi.api.models.ItemUri`)
+
+```python
+# before
+getSearchModel = create_request_model(
+    model_name="SearchGetRequest",
+    base_model=BaseSearchGetRequest
+    extensions=[...],
+    request_type="GET"
+)
+stac = StacApi(
+    search_get_request_model=getSearchModel,
+    search_post_request_model=...,
+)
+
+# now
+@dataclass
+class CollectionsRequest(APIRequest):
+    user: str = Query(...)
+
+stac = StacApi(
+    search_get_request_model=getSearchModel,
+    search_post_request_model=postSearchModel,
+        collections_get_request_model=CollectionsRequest,
+        collection_get_request_model=...,
+        items_get_request_model=...,
+        item_get_request_model=...,
+)
+```
+
+## Filter extension
+
+`default_includes` attribute has been removed from the `ApiSettings` object. If you need `defaults` includes you can overwrite the `FieldExtension` models (see https://github.com/stac-utils/stac-fastapi/pull/706).
+
+```python
+# before
+stac = StacApi(
+    extensions=[
+        FieldsExtension()
+    ]
+)
+
+# now
+class PostFieldsExtension(requests.PostFieldsExtension):
+    include: Optional[Set[str]] = Field(
+        default_factory=lambda: {
+            "id",
+            "type",
+            "stac_version",
+            "geometry",
+            "bbox",
+            "links",
+            "assets",
+            "properties.datetime",
+            "collection",
+        }
+    )
+    exclude: Optional[Set[str]] = set()
+
+
+class FieldsExtensionPostRequest(BaseModel):
+    """Additional fields and schema for the POST request."""
+
+    fields: Optional[PostFieldsExtension] = Field(PostFieldsExtension())
+
+
+class FieldsExtension(FieldsExtensionBase):
+    """Override the POST model"""
+
+    POST = FieldsExtensionPostRequest
+
+
+from stac_fastapi.api.app import StacApi
+
+stac = StacApi(
+    extensions=[
+        FieldsExtension()
+    ]
+)
+```
+
+## Pagination extension
+
+In stac-fastapi v3.0, we removed the `pagination_extension` attribute in `stac_fastapi.api.app.StacApi`. This attribute was used within the `register_get_item_collection` to update the request model for the `/collections/{collection_id}/items` endpoint.
+
+It's now up to the user to create the request model and use the `items_get_request_model=` attribute in the StacApi object.
+
+```python
+# before
+stac=StacApi(
+    pagination_extension=TokenPaginationExtension,
+    extension=[TokenPaginationExtension]
+)
+
+# now
+items_get_request_model = create_request_model(
+    "ItemCollectionURI",
+    base_model=ItemCollectionUri,
+    mixins=[TokenPaginationExtension().GET],
+)
+
+stac=StacApi(
+    extension=[TokenPaginationExtension],
+    items_get_request_model=items_get_request_model,
+)
+```
diff --git a/stac_fastapi/api/stac_fastapi/api/app.py b/stac_fastapi/api/stac_fastapi/api/app.py
index 44a5576..a03c5d1 100644
--- a/stac_fastapi/api/stac_fastapi/api/app.py
+++ b/stac_fastapi/api/stac_fastapi/api/app.py
@@ -18,18 +18,18 @@ from starlette.responses import JSONResponse, Response
 from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers
 from stac_fastapi.api.middleware import CORSMiddleware, ProxyHeaderMiddleware
 from stac_fastapi.api.models import (
+    APIRequest,
     CollectionUri,
     EmptyRequest,
     GeoJSONResponse,
     ItemCollectionUri,
     ItemUri,
-    create_request_model,
 )
 from stac_fastapi.api.openapi import update_openapi
 from stac_fastapi.api.routes import Scope, add_route_dependencies, create_async_endpoint
 
 # TODO: make this module not depend on `stac_fastapi.extensions`
-from stac_fastapi.extensions.core import FieldsExtension, TokenPaginationExtension
+from stac_fastapi.extensions.core import FieldsExtension
 from stac_fastapi.types.config import ApiSettings, Settings
 from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient
 from stac_fastapi.types.extension import ApiExtension
@@ -108,7 +108,10 @@ class StacApi:
     search_post_request_model: Type[BaseSearchPostRequest] = attr.ib(
         default=BaseSearchPostRequest
     )
-    pagination_extension = attr.ib(default=TokenPaginationExtension)
+    collections_get_request_model: Type[APIRequest] = attr.ib(default=EmptyRequest)
+    collection_get_request_model: Type[APIRequest] = attr.ib(default=CollectionUri)
+    items_get_request_model: Type[APIRequest] = attr.ib(default=ItemCollectionUri)
+    item_get_request_model: Type[APIRequest] = attr.ib(default=ItemUri)
     response_class: Type[Response] = attr.ib(default=JSONResponse)
     middlewares: List[Middleware] = attr.ib(
         default=attr.Factory(
@@ -211,7 +214,9 @@ class StacApi:
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["GET"],
-            endpoint=create_async_endpoint(self.client.get_item, ItemUri),
+            endpoint=create_async_endpoint(
+                self.client.get_item, self.item_get_request_model
+            ),
         )
 
     def register_post_search(self):
@@ -302,7 +307,9 @@ class StacApi:
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["GET"],
-            endpoint=create_async_endpoint(self.client.all_collections, EmptyRequest),
+            endpoint=create_async_endpoint(
+                self.client.all_collections, self.collections_get_request_model
+            ),
         )
 
     def register_get_collection(self):
@@ -329,7 +336,9 @@ class StacApi:
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["GET"],
-            endpoint=create_async_endpoint(self.client.get_collection, CollectionUri),
+            endpoint=create_async_endpoint(
+                self.client.get_collection, self.collection_get_request_model
+            ),
         )
 
     def register_get_item_collection(self):
@@ -338,16 +347,6 @@ class StacApi:
         Returns:
             None
         """
-        pagination_extension = self.get_extension(self.pagination_extension)
-        if pagination_extension is not None:
-            mixins = [pagination_extension.GET]
-        else:
-            mixins = None
-        request_model = create_request_model(
-            "ItemCollectionURI",
-            base_model=ItemCollectionUri,
-            mixins=mixins,
-        )
         self.router.add_api_route(
             name="Get ItemCollection",
             path="/collections/{collection_id}/items",
@@ -366,7 +365,9 @@ class StacApi:
             response_model_exclude_unset=True,
             response_model_exclude_none=True,
             methods=["GET"],
-            endpoint=create_async_endpoint(self.client.item_collection, request_model),
+            endpoint=create_async_endpoint(
+                self.client.item_collection, self.items_get_request_model
+            ),
         )
 
     def register_core(self):


Test patch:
diff --git a/stac_fastapi/api/tests/test_api.py b/stac_fastapi/api/tests/test_api.py
index d559a37..7db4d9a 100644
--- a/stac_fastapi/api/tests/test_api.py
+++ b/stac_fastapi/api/tests/test_api.py
@@ -2,6 +2,7 @@ from fastapi import Depends, HTTPException, security, status
 from starlette.testclient import TestClient
 
 from stac_fastapi.api.app import StacApi
+from stac_fastapi.api.models import ItemCollectionUri, create_request_model
 from stac_fastapi.extensions.core import (
     TokenPaginationExtension,
     TransactionExtension,
@@ -13,6 +14,13 @@ class TestRouteDependencies:
     @staticmethod
     def _build_api(**overrides):
         settings = config.ApiSettings()
+
+        items_get_request_model = create_request_model(
+            "ItemCollectionURI",
+            base_model=ItemCollectionUri,
+            mixins=[TokenPaginationExtension().GET],
+        )
+
         return StacApi(
             **{
                 "settings": settings,
@@ -23,6 +31,7 @@ class TestRouteDependencies:
                     ),
                     TokenPaginationExtension(),
                 ],
+                "items_get_request_model": items_get_request_model,
                 **overrides,
             }
         )
diff --git a/stac_fastapi/api/tests/test_app.py b/stac_fastapi/api/tests/test_app.py
index 829982b..1076c24 100644
--- a/stac_fastapi/api/tests/test_app.py
+++ b/stac_fastapi/api/tests/test_app.py
@@ -1,13 +1,19 @@
+from dataclasses import dataclass
 from datetime import datetime
 from typing import List, Optional, Union
 
 import pytest
+from fastapi import Path, Query
 from fastapi.testclient import TestClient
 from pydantic import ValidationError
 from stac_pydantic import api
 
 from stac_fastapi.api import app
-from stac_fastapi.api.models import create_get_request_model, create_post_request_model
+from stac_fastapi.api.models import (
+    APIRequest,
+    create_get_request_model,
+    create_post_request_model,
+)
 from stac_fastapi.extensions.core import FieldsExtension, FilterExtension
 from stac_fastapi.types import stac
 from stac_fastapi.types.config import ApiSettings
@@ -294,3 +300,66 @@ def test_fields_extension(validate, TestCoreClient, item_dict):
     else:
         assert get_search.status_code == 200, get_search.text
         assert post_search.status_code == 200, post_search.text
+
+
+def test_request_model(AsyncTestCoreClient):
+    """Test if request models are passed correctly."""
+
+    @dataclass
+    class CollectionsRequest(APIRequest):
+        user: str = Query(...)
+
+    @dataclass
+    class CollectionRequest(APIRequest):
+        collection_id: str = Path(description="Collection ID")
+        user: str = Query(...)
+
+    @dataclass
+    class ItemsRequest(APIRequest):
+        collection_id: str = Path(description="Collection ID")
+        user: str = Query(...)
+
+    @dataclass
+    class ItemRequest(APIRequest):
+        collection_id: str = Path(description="Collection ID")
+        item_id: str = Path(description="Item ID")
+        user: str = Query(...)
+
+    test_app = app.StacApi(
+        settings=ApiSettings(),
+        client=AsyncTestCoreClient(),
+        collections_get_request_model=CollectionsRequest,
+        collection_get_request_model=CollectionRequest,
+        items_get_request_model=ItemsRequest,
+        item_get_request_model=ItemRequest,
+        extensions=[],
+    )
+
+    with TestClient(test_app.app) as client:
+        resp = client.get("/collections")
+        assert resp.status_code == 400
+
+        resp = client.get("/collections", params={"user": "Luke"})
+        assert resp.status_code == 200
+
+        resp = client.get("/collections/test_collection")
+        assert resp.status_code == 400
+
+        resp = client.get("/collections/test_collection", params={"user": "Leia"})
+        assert resp.status_code == 200
+
+        resp = client.get("/collections/test_collection/items")
+        assert resp.status_code == 400
+
+        resp = client.get(
+            "/collections/test_collection/items", params={"user": "Obi-Wan"}
+        )
+        assert resp.status_code == 200
+
+        resp = client.get("/collections/test_collection/items/test_item")
+        assert resp.status_code == 400
+
+        resp = client.get(
+            "/collections/test_collection/items/test_item", params={"user": "Chewbacca"}
+        )
+        assert resp.status_code == 200