stac-fastapi.api 3.0.0a3__tar.gz → 3.0.0b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/PKG-INFO +1 -1
  2. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi/api/app.py +27 -37
  3. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi/api/models.py +36 -63
  4. stac_fastapi_api-3.0.0b1/stac_fastapi/api/version.py +2 -0
  5. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi.api.egg-info/PKG-INFO +1 -1
  6. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/tests/test_api.py +9 -0
  7. stac_fastapi_api-3.0.0b1/tests/test_app.py +388 -0
  8. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/tests/test_middleware.py +30 -0
  9. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/tests/test_models.py +26 -4
  10. stac_fastapi_api-3.0.0a3/stac_fastapi/api/version.py +0 -2
  11. stac_fastapi_api-3.0.0a3/tests/test_app.py +0 -192
  12. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/README.md +0 -0
  13. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/setup.cfg +0 -0
  14. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/setup.py +0 -0
  15. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi/api/__init__.py +0 -0
  16. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi/api/config.py +0 -0
  17. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi/api/errors.py +0 -0
  18. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi/api/middleware.py +0 -0
  19. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi/api/openapi.py +0 -0
  20. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi/api/routes.py +0 -0
  21. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi.api.egg-info/SOURCES.txt +0 -0
  22. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi.api.egg-info/dependency_links.txt +0 -0
  23. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi.api.egg-info/not-zip-safe +0 -0
  24. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi.api.egg-info/requires.txt +0 -0
  25. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/stac_fastapi.api.egg-info/top_level.txt +0 -0
  26. {stac_fastapi_api-3.0.0a3 → stac_fastapi_api-3.0.0b1}/tests/test_app_prefix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: stac-fastapi.api
3
- Version: 3.0.0a3
3
+ Version: 3.0.0b1
4
4
  Summary: An implementation of STAC API based on the FastAPI framework.
5
5
  Home-page: https://github.com/stac-utils/stac-fastapi
6
6
  Author: Arturo Engineering
@@ -18,18 +18,15 @@ from starlette.responses import JSONResponse, Response
18
18
  from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers
19
19
  from stac_fastapi.api.middleware import CORSMiddleware, ProxyHeaderMiddleware
20
20
  from stac_fastapi.api.models import (
21
+ APIRequest,
21
22
  CollectionUri,
22
23
  EmptyRequest,
23
24
  GeoJSONResponse,
24
25
  ItemCollectionUri,
25
26
  ItemUri,
26
- create_request_model,
27
27
  )
28
28
  from stac_fastapi.api.openapi import update_openapi
29
29
  from stac_fastapi.api.routes import Scope, add_route_dependencies, create_async_endpoint
30
-
31
- # TODO: make this module not depend on `stac_fastapi.extensions`
32
- from stac_fastapi.extensions.core import FieldsExtension, TokenPaginationExtension
33
30
  from stac_fastapi.types.config import ApiSettings, Settings
34
31
  from stac_fastapi.types.core import AsyncBaseCoreClient, BaseCoreClient
35
32
  from stac_fastapi.types.extension import ApiExtension
@@ -108,7 +105,10 @@ class StacApi:
108
105
  search_post_request_model: Type[BaseSearchPostRequest] = attr.ib(
109
106
  default=BaseSearchPostRequest
110
107
  )
111
- pagination_extension = attr.ib(default=TokenPaginationExtension)
108
+ collections_get_request_model: Type[APIRequest] = attr.ib(default=EmptyRequest)
109
+ collection_get_request_model: Type[APIRequest] = attr.ib(default=CollectionUri)
110
+ items_get_request_model: Type[APIRequest] = attr.ib(default=ItemCollectionUri)
111
+ item_get_request_model: Type[APIRequest] = attr.ib(default=ItemUri)
112
112
  response_class: Type[Response] = attr.ib(default=JSONResponse)
113
113
  middlewares: List[Middleware] = attr.ib(
114
114
  default=attr.Factory(
@@ -211,7 +211,9 @@ class StacApi:
211
211
  response_model_exclude_unset=True,
212
212
  response_model_exclude_none=True,
213
213
  methods=["GET"],
214
- endpoint=create_async_endpoint(self.client.get_item, ItemUri),
214
+ endpoint=create_async_endpoint(
215
+ self.client.get_item, self.item_get_request_model
216
+ ),
215
217
  )
216
218
 
217
219
  def register_post_search(self):
@@ -220,15 +222,12 @@ class StacApi:
220
222
  Returns:
221
223
  None
222
224
  """
223
- fields_ext = self.get_extension(FieldsExtension)
224
225
  self.router.add_api_route(
225
226
  name="Search",
226
227
  path="/search",
227
- response_model=(
228
- (api.ItemCollection if not fields_ext else None)
229
- if self.settings.enable_response_models
230
- else None
231
- ),
228
+ response_model=api.ItemCollection
229
+ if self.settings.enable_response_models
230
+ else None,
232
231
  responses={
233
232
  200: {
234
233
  "content": {
@@ -252,15 +251,12 @@ class StacApi:
252
251
  Returns:
253
252
  None
254
253
  """
255
- fields_ext = self.get_extension(FieldsExtension)
256
254
  self.router.add_api_route(
257
255
  name="Search",
258
256
  path="/search",
259
- response_model=(
260
- (api.ItemCollection if not fields_ext else None)
261
- if self.settings.enable_response_models
262
- else None
263
- ),
257
+ response_model=api.ItemCollection
258
+ if self.settings.enable_response_models
259
+ else None,
264
260
  responses={
265
261
  200: {
266
262
  "content": {
@@ -302,7 +298,9 @@ class StacApi:
302
298
  response_model_exclude_unset=True,
303
299
  response_model_exclude_none=True,
304
300
  methods=["GET"],
305
- endpoint=create_async_endpoint(self.client.all_collections, EmptyRequest),
301
+ endpoint=create_async_endpoint(
302
+ self.client.all_collections, self.collections_get_request_model
303
+ ),
306
304
  )
307
305
 
308
306
  def register_get_collection(self):
@@ -329,7 +327,9 @@ class StacApi:
329
327
  response_model_exclude_unset=True,
330
328
  response_model_exclude_none=True,
331
329
  methods=["GET"],
332
- endpoint=create_async_endpoint(self.client.get_collection, CollectionUri),
330
+ endpoint=create_async_endpoint(
331
+ self.client.get_collection, self.collection_get_request_model
332
+ ),
333
333
  )
334
334
 
335
335
  def register_get_item_collection(self):
@@ -338,16 +338,6 @@ class StacApi:
338
338
  Returns:
339
339
  None
340
340
  """
341
- pagination_extension = self.get_extension(self.pagination_extension)
342
- if pagination_extension is not None:
343
- mixins = [pagination_extension.GET]
344
- else:
345
- mixins = None
346
- request_model = create_request_model(
347
- "ItemCollectionURI",
348
- base_model=ItemCollectionUri,
349
- mixins=mixins,
350
- )
351
341
  self.router.add_api_route(
352
342
  name="Get ItemCollection",
353
343
  path="/collections/{collection_id}/items",
@@ -366,7 +356,9 @@ class StacApi:
366
356
  response_model_exclude_unset=True,
367
357
  response_model_exclude_none=True,
368
358
  methods=["GET"],
369
- endpoint=create_async_endpoint(self.client.item_collection, request_model),
359
+ endpoint=create_async_endpoint(
360
+ self.client.item_collection, self.items_get_request_model
361
+ ),
370
362
  )
371
363
 
372
364
  def register_core(self):
@@ -439,11 +431,6 @@ class StacApi:
439
431
  """
440
432
  return add_route_dependencies(self.app.router.routes, scopes, dependencies)
441
433
 
442
- def add_middleware(self, middleware: Middleware):
443
- """Add a middleware class to the application."""
444
- self.app.user_middleware.insert(0, middleware)
445
- self.app.middleware_stack = self.app.build_middleware_stack()
446
-
447
434
  def __attrs_post_init__(self):
448
435
  """Post-init hook.
449
436
 
@@ -483,8 +470,11 @@ class StacApi:
483
470
  self.app.openapi = self.customize_openapi
484
471
 
485
472
  # add middlewares
473
+ if self.middlewares and self.app.middleware_stack is not None:
474
+ raise RuntimeError("Cannot add middleware after an application has started")
475
+
486
476
  for middleware in self.middlewares:
487
- self.add_middleware(middleware)
477
+ self.app.user_middleware.insert(0, middleware)
488
478
 
489
479
  # customize route dependencies
490
480
  for scopes, dependencies in self.route_dependencies:
@@ -1,12 +1,12 @@
1
1
  """Api request/response models."""
2
2
 
3
- import importlib.util
3
+ from dataclasses import dataclass, make_dataclass
4
4
  from typing import List, Optional, Type, Union
5
5
 
6
- import attr
7
- from fastapi import Path
6
+ from fastapi import Path, Query
8
7
  from pydantic import BaseModel, create_model
9
8
  from stac_pydantic.shared import BBox
9
+ from typing_extensions import Annotated
10
10
 
11
11
  from stac_fastapi.types.extension import ApiExtension
12
12
  from stac_fastapi.types.rfc3339 import DateTimeType
@@ -18,6 +18,12 @@ from stac_fastapi.types.search import (
18
18
  str_to_interval,
19
19
  )
20
20
 
21
+ try:
22
+ import orjson # noqa
23
+ from fastapi.responses import ORJSONResponse as JSONResponse
24
+ except ImportError: # pragma: nocover
25
+ from starlette.responses import JSONResponse
26
+
21
27
 
22
28
  def create_request_model(
23
29
  model_name="SearchGetRequest",
@@ -37,11 +43,11 @@ def create_request_model(
37
43
 
38
44
  mixins = mixins or []
39
45
 
40
- models = [base_model] + extension_models + mixins
46
+ models = extension_models + mixins + [base_model]
41
47
 
42
48
  # Handle GET requests
43
49
  if all([issubclass(m, APIRequest) for m in models]):
44
- return attr.make_class(model_name, attrs={}, bases=tuple(models))
50
+ return make_dataclass(model_name, [], bases=tuple(models))
45
51
 
46
52
  # Handle POST requests
47
53
  elif all([issubclass(m, BaseModel) for m in models]):
@@ -80,85 +86,52 @@ def create_post_request_model(
80
86
  )
81
87
 
82
88
 
83
- @attr.s # type:ignore
89
+ @dataclass
84
90
  class CollectionUri(APIRequest):
85
91
  """Get or delete collection."""
86
92
 
87
- collection_id: str = attr.ib(default=Path(..., description="Collection ID"))
93
+ collection_id: Annotated[str, Path(description="Collection ID")]
88
94
 
89
95
 
90
- @attr.s
91
- class ItemUri(CollectionUri):
96
+ @dataclass
97
+ class ItemUri(APIRequest):
92
98
  """Get or delete item."""
93
99
 
94
- item_id: str = attr.ib(default=Path(..., description="Item ID"))
100
+ collection_id: Annotated[str, Path(description="Collection ID")]
101
+ item_id: Annotated[str, Path(description="Item ID")]
95
102
 
96
103
 
97
- @attr.s
104
+ @dataclass
98
105
  class EmptyRequest(APIRequest):
99
106
  """Empty request."""
100
107
 
101
108
  ...
102
109
 
103
110
 
104
- @attr.s
105
- class ItemCollectionUri(CollectionUri):
111
+ @dataclass
112
+ class ItemCollectionUri(APIRequest):
106
113
  """Get item collection."""
107
114
 
108
- limit: int = attr.ib(default=10)
109
- bbox: Optional[BBox] = attr.ib(default=None, converter=str2bbox)
110
- datetime: Optional[DateTimeType] = attr.ib(default=None, converter=str_to_interval)
111
-
112
-
113
- class POSTTokenPagination(BaseModel):
114
- """Token pagination model for POST requests."""
115
-
116
- token: Optional[str] = None
117
-
118
-
119
- @attr.s
120
- class GETTokenPagination(APIRequest):
121
- """Token pagination for GET requests."""
122
-
123
- token: Optional[str] = attr.ib(default=None)
124
-
125
-
126
- class POSTPagination(BaseModel):
127
- """Page based pagination for POST requests."""
115
+ collection_id: Annotated[str, Path(description="Collection ID")]
116
+ limit: Annotated[int, Query()] = 10
117
+ bbox: Annotated[Optional[BBox], Query()] = None
118
+ datetime: Annotated[Optional[DateTimeType], Query()] = None
128
119
 
129
- page: Optional[str] = None
120
+ def __post_init__(self):
121
+ """convert attributes."""
122
+ if self.bbox:
123
+ self.bbox = str2bbox(self.bbox) # type: ignore
124
+ if self.datetime:
125
+ self.datetime = str_to_interval(self.datetime) # type: ignore
130
126
 
131
127
 
132
- @attr.s
133
- class GETPagination(APIRequest):
134
- """Page based pagination for GET requests."""
135
-
136
- page: Optional[str] = attr.ib(default=None)
137
-
138
-
139
- # Test for ORJSON and use it rather than stdlib JSON where supported
140
- if importlib.util.find_spec("orjson") is not None:
141
- from fastapi.responses import ORJSONResponse
142
-
143
- class GeoJSONResponse(ORJSONResponse):
144
- """JSON with custom, vendor content-type."""
145
-
146
- media_type = "application/geo+json"
147
-
148
- class JSONSchemaResponse(ORJSONResponse):
149
- """JSON with custom, vendor content-type."""
150
-
151
- media_type = "application/schema+json"
152
-
153
- else:
154
- from starlette.responses import JSONResponse
128
+ class GeoJSONResponse(JSONResponse):
129
+ """JSON with custom, vendor content-type."""
155
130
 
156
- class GeoJSONResponse(JSONResponse):
157
- """JSON with custom, vendor content-type."""
131
+ media_type = "application/geo+json"
158
132
 
159
- media_type = "application/geo+json"
160
133
 
161
- class JSONSchemaResponse(JSONResponse):
162
- """JSON with custom, vendor content-type."""
134
+ class JSONSchemaResponse(JSONResponse):
135
+ """JSON with custom, vendor content-type."""
163
136
 
164
- media_type = "application/schema+json"
137
+ media_type = "application/schema+json"
@@ -0,0 +1,2 @@
1
+ """Library version."""
2
+ __version__ = "3.0.0b1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: stac-fastapi.api
3
- Version: 3.0.0a3
3
+ Version: 3.0.0b1
4
4
  Summary: An implementation of STAC API based on the FastAPI framework.
5
5
  Home-page: https://github.com/stac-utils/stac-fastapi
6
6
  Author: Arturo Engineering
@@ -2,6 +2,7 @@ from fastapi import Depends, HTTPException, security, status
2
2
  from starlette.testclient import TestClient
3
3
 
4
4
  from stac_fastapi.api.app import StacApi
5
+ from stac_fastapi.api.models import ItemCollectionUri, create_request_model
5
6
  from stac_fastapi.extensions.core import (
6
7
  TokenPaginationExtension,
7
8
  TransactionExtension,
@@ -13,6 +14,13 @@ class TestRouteDependencies:
13
14
  @staticmethod
14
15
  def _build_api(**overrides):
15
16
  settings = config.ApiSettings()
17
+
18
+ items_get_request_model = create_request_model(
19
+ "ItemCollectionURI",
20
+ base_model=ItemCollectionUri,
21
+ mixins=[TokenPaginationExtension().GET],
22
+ )
23
+
16
24
  return StacApi(
17
25
  **{
18
26
  "settings": settings,
@@ -23,6 +31,7 @@ class TestRouteDependencies:
23
31
  ),
24
32
  TokenPaginationExtension(),
25
33
  ],
34
+ "items_get_request_model": items_get_request_model,
26
35
  **overrides,
27
36
  }
28
37
  )
@@ -0,0 +1,388 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from typing import List, Optional, Union
4
+
5
+ import pytest
6
+ from fastapi import Path, Query
7
+ from fastapi.testclient import TestClient
8
+ from pydantic import ValidationError
9
+ from stac_pydantic import api
10
+
11
+ from stac_fastapi.api import app
12
+ from stac_fastapi.api.models import (
13
+ APIRequest,
14
+ JSONResponse,
15
+ create_get_request_model,
16
+ create_post_request_model,
17
+ )
18
+ from stac_fastapi.extensions.core import FieldsExtension, FilterExtension
19
+ from stac_fastapi.types import stac
20
+ from stac_fastapi.types.config import ApiSettings
21
+ from stac_fastapi.types.core import BaseCoreClient, NumType
22
+ from stac_fastapi.types.search import BaseSearchPostRequest
23
+
24
+
25
+ def test_client_response_type(TestCoreClient):
26
+ """Test all GET endpoints. Verify that responses are valid STAC items."""
27
+
28
+ test_app = app.StacApi(
29
+ settings=ApiSettings(),
30
+ client=TestCoreClient(),
31
+ )
32
+
33
+ with TestClient(test_app.app) as client:
34
+ landing = client.get("/")
35
+ collection = client.get("/collections/test")
36
+ collections = client.get("/collections")
37
+ item = client.get("/collections/test/items/test")
38
+ item_collection = client.get(
39
+ "/collections/test/items",
40
+ params={"limit": 10},
41
+ )
42
+ get_search = client.get(
43
+ "/search",
44
+ params={
45
+ "collections": ["test"],
46
+ },
47
+ )
48
+ post_search = client.post(
49
+ "/search",
50
+ json={
51
+ "collections": ["test"],
52
+ },
53
+ )
54
+
55
+ assert landing.status_code == 200, landing.text
56
+ api.LandingPage(**landing.json())
57
+
58
+ assert collection.status_code == 200, collection.text
59
+ api.Collection(**collection.json())
60
+
61
+ assert collections.status_code == 200, collections.text
62
+ api.collections.Collections(**collections.json())
63
+
64
+ assert item.status_code == 200, item.text
65
+ api.Item(**item.json())
66
+
67
+ assert item_collection.status_code == 200, item_collection.text
68
+ api.ItemCollection(**item_collection.json())
69
+
70
+ assert get_search.status_code == 200, get_search.text
71
+ api.ItemCollection(**get_search.json())
72
+
73
+ assert post_search.status_code == 200, post_search.text
74
+ api.ItemCollection(**post_search.json())
75
+
76
+
77
+ @pytest.mark.parametrize("validate", [True, False])
78
+ def test_client_invalid_response_type(validate, TestCoreClient, item_dict):
79
+ """Check if the build in response validation switch works."""
80
+
81
+ class InValidResponseClient(TestCoreClient):
82
+ def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item:
83
+ item_dict.pop("bbox")
84
+ item_dict.pop("geometry")
85
+ return stac.Item(**item_dict)
86
+
87
+ test_app = app.StacApi(
88
+ settings=ApiSettings(enable_response_models=validate),
89
+ client=InValidResponseClient(),
90
+ )
91
+
92
+ with TestClient(test_app.app) as client:
93
+ item = client.get("/collections/test/items/test")
94
+
95
+ # Even if API validation passes, we should receive an invalid item
96
+ if item.status_code == 200:
97
+ with pytest.raises(ValidationError):
98
+ api.Item(**item.json())
99
+
100
+ # If internal validation is on, we should expect an internal error
101
+ if validate:
102
+ assert item.status_code == 500, item.text
103
+ else:
104
+ assert item.status_code == 200, item.text
105
+
106
+
107
+ def test_client_openapi(TestCoreClient):
108
+ """Test if response models are all documented with OpenAPI."""
109
+
110
+ test_app = app.StacApi(
111
+ settings=ApiSettings(),
112
+ client=TestCoreClient(),
113
+ )
114
+ test_app.app.openapi()
115
+ components = ["LandingPage", "Collection", "Collections", "Item", "ItemCollection"]
116
+ for component in components:
117
+ assert component in test_app.app.openapi_schema["components"]["schemas"]
118
+
119
+
120
+ @pytest.mark.parametrize("validate", [True, False])
121
+ def test_filter_extension(validate, TestCoreClient, item_dict):
122
+ """Test if Filter Parameters are passed correctly."""
123
+
124
+ class FilterClient(TestCoreClient):
125
+ def post_search(
126
+ self, search_request: BaseSearchPostRequest, **kwargs
127
+ ) -> stac.ItemCollection:
128
+ search_request.collections = ["test"]
129
+ search_request.filter = {}
130
+ search_request.filter_crs = "EPSG:4326"
131
+ search_request.filter_lang = "cql2-text"
132
+
133
+ return stac.ItemCollection(
134
+ type="FeatureCollection", features=[stac.Item(**item_dict)]
135
+ )
136
+
137
+ def get_search(
138
+ self,
139
+ collections: Optional[List[str]] = None,
140
+ ids: Optional[List[str]] = None,
141
+ bbox: Optional[List[NumType]] = None,
142
+ intersects: Optional[str] = None,
143
+ datetime: Optional[Union[str, datetime]] = None,
144
+ limit: Optional[int] = 10,
145
+ filter: Optional[str] = None,
146
+ filter_crs: Optional[str] = None,
147
+ filter_lang: Optional[str] = None,
148
+ **kwargs,
149
+ ) -> stac.ItemCollection:
150
+ # Check if all filter parameters are passed correctly
151
+
152
+ assert filter == "TEST"
153
+
154
+ # FIXME: https://github.com/stac-utils/stac-fastapi/issues/638
155
+ # hyphen alias for filter_crs and filter_lang are currently not working
156
+ # Query parameters `filter-crs` and `filter-lang`
157
+ # should be recognized by the API
158
+ # They are present in the `request.query_params` but not in the `kwargs`
159
+
160
+ # assert filter_crs == "EPSG:4326"
161
+ # assert filter_lang == "cql2-text"
162
+
163
+ return stac.ItemCollection(
164
+ type="FeatureCollection", features=[stac.Item(**item_dict)]
165
+ )
166
+
167
+ post_request_model = create_post_request_model([FilterExtension()])
168
+
169
+ test_app = app.StacApi(
170
+ settings=ApiSettings(enable_response_models=validate),
171
+ client=FilterClient(post_request_model=post_request_model),
172
+ search_get_request_model=create_get_request_model([FilterExtension()]),
173
+ search_post_request_model=post_request_model,
174
+ extensions=[FilterExtension()],
175
+ )
176
+
177
+ with TestClient(test_app.app) as client:
178
+ landing = client.get("/")
179
+ get_search = client.get(
180
+ "/search",
181
+ params={
182
+ "filter": "TEST",
183
+ "filter-crs": "EPSG:4326",
184
+ "filter-lang": "cql2-text",
185
+ },
186
+ )
187
+ post_search = client.post(
188
+ "/search",
189
+ json={
190
+ "collections": ["test"],
191
+ "filter": {},
192
+ "filter-crs": "EPSG:4326",
193
+ "filter-lang": "cql2-text",
194
+ },
195
+ )
196
+
197
+ assert landing.status_code == 200, landing.text
198
+ assert get_search.status_code == 200, get_search.text
199
+ assert post_search.status_code == 200, post_search.text
200
+
201
+
202
+ @pytest.mark.parametrize("validate", [True, False])
203
+ def test_fields_extension(validate, TestCoreClient, item_dict):
204
+ """Test if fields Parameters are passed correctly."""
205
+
206
+ class BadCoreClient(BaseCoreClient):
207
+ def post_search(
208
+ self, search_request: BaseSearchPostRequest, **kwargs
209
+ ) -> stac.ItemCollection:
210
+ resp = {"not": "a proper stac item"}
211
+
212
+ # if `fields` extension is enabled, then we return a JSONResponse
213
+ # to avoid Item validation
214
+ if getattr(search_request, "fields", None):
215
+ return JSONResponse(content=resp)
216
+
217
+ return resp
218
+
219
+ def get_search(
220
+ self,
221
+ collections: Optional[List[str]] = None,
222
+ ids: Optional[List[str]] = None,
223
+ bbox: Optional[List[NumType]] = None,
224
+ intersects: Optional[str] = None,
225
+ datetime: Optional[Union[str, datetime]] = None,
226
+ limit: Optional[int] = 10,
227
+ **kwargs,
228
+ ) -> stac.ItemCollection:
229
+ resp = {"not": "a proper stac item"}
230
+
231
+ # if `fields` extension is enabled, then we return a JSONResponse
232
+ # to avoid Item validation
233
+ if "fields" in kwargs:
234
+ return JSONResponse(content=resp)
235
+
236
+ return resp
237
+
238
+ def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item:
239
+ raise NotImplementedError
240
+
241
+ def all_collections(self, **kwargs) -> stac.Collections:
242
+ raise NotImplementedError
243
+
244
+ def get_collection(self, collection_id: str, **kwargs) -> stac.Collection:
245
+ raise NotImplementedError
246
+
247
+ def item_collection(
248
+ self,
249
+ collection_id: str,
250
+ bbox: Optional[List[Union[float, int]]] = None,
251
+ datetime: Optional[Union[str, datetime]] = None,
252
+ limit: int = 10,
253
+ token: str = None,
254
+ **kwargs,
255
+ ) -> stac.ItemCollection:
256
+ raise NotImplementedError
257
+
258
+ # With FieldsExtension
259
+ test_app = app.StacApi(
260
+ settings=ApiSettings(enable_response_models=validate),
261
+ client=BadCoreClient(),
262
+ search_get_request_model=create_get_request_model([FieldsExtension()]),
263
+ search_post_request_model=create_post_request_model([FieldsExtension()]),
264
+ extensions=[FieldsExtension()],
265
+ )
266
+
267
+ with TestClient(test_app.app) as client:
268
+ get_search = client.get(
269
+ "/search",
270
+ params={"fields": "properties.datetime"},
271
+ )
272
+ post_search = client.post(
273
+ "/search",
274
+ json={
275
+ "collections": ["test"],
276
+ "fields": {
277
+ "include": ["properties.datetime"],
278
+ "exclude": [],
279
+ },
280
+ },
281
+ )
282
+
283
+ # With or without validation, /search endpoints will always return 200
284
+ # because we have the `FieldsExtension` enabled, so the endpoint
285
+ # will avoid the model validation (by returning JSONResponse)
286
+ assert get_search.status_code == 200, get_search.text
287
+ assert post_search.status_code == 200, post_search.text
288
+
289
+ # Without FieldsExtension
290
+ test_app = app.StacApi(
291
+ settings=ApiSettings(enable_response_models=validate),
292
+ client=BadCoreClient(),
293
+ search_get_request_model=create_get_request_model([]),
294
+ search_post_request_model=create_post_request_model([]),
295
+ extensions=[],
296
+ )
297
+
298
+ with TestClient(test_app.app) as client:
299
+ get_search = client.get(
300
+ "/search",
301
+ params={"fields": "properties.datetime"},
302
+ )
303
+ post_search = client.post(
304
+ "/search",
305
+ json={
306
+ "collections": ["test"],
307
+ "fields": {
308
+ "include": ["properties.datetime"],
309
+ "exclude": [],
310
+ },
311
+ },
312
+ )
313
+
314
+ if validate:
315
+ # NOTE: the `fields` options will be ignored by fastAPI because it's
316
+ # not part of the request model, so the client should not by-pass the validation
317
+ assert get_search.status_code == 500, (
318
+ get_search.json()["code"] == "ResponseValidationError"
319
+ )
320
+ assert post_search.status_code == 500, (
321
+ post_search.json()["code"] == "ResponseValidationError"
322
+ )
323
+ else:
324
+ assert get_search.status_code == 200, get_search.text
325
+ assert post_search.status_code == 200, post_search.text
326
+
327
+
328
+ def test_request_model(AsyncTestCoreClient):
329
+ """Test if request models are passed correctly."""
330
+
331
+ @dataclass
332
+ class CollectionsRequest(APIRequest):
333
+ user: str = Query(...)
334
+
335
+ @dataclass
336
+ class CollectionRequest(APIRequest):
337
+ collection_id: str = Path(description="Collection ID")
338
+ user: str = Query(...)
339
+
340
+ @dataclass
341
+ class ItemsRequest(APIRequest):
342
+ collection_id: str = Path(description="Collection ID")
343
+ user: str = Query(...)
344
+
345
+ @dataclass
346
+ class ItemRequest(APIRequest):
347
+ collection_id: str = Path(description="Collection ID")
348
+ item_id: str = Path(description="Item ID")
349
+ user: str = Query(...)
350
+
351
+ test_app = app.StacApi(
352
+ settings=ApiSettings(),
353
+ client=AsyncTestCoreClient(),
354
+ collections_get_request_model=CollectionsRequest,
355
+ collection_get_request_model=CollectionRequest,
356
+ items_get_request_model=ItemsRequest,
357
+ item_get_request_model=ItemRequest,
358
+ extensions=[],
359
+ )
360
+
361
+ with TestClient(test_app.app) as client:
362
+ resp = client.get("/collections")
363
+ assert resp.status_code == 400
364
+
365
+ resp = client.get("/collections", params={"user": "Luke"})
366
+ assert resp.status_code == 200
367
+
368
+ resp = client.get("/collections/test_collection")
369
+ assert resp.status_code == 400
370
+
371
+ resp = client.get("/collections/test_collection", params={"user": "Leia"})
372
+ assert resp.status_code == 200
373
+
374
+ resp = client.get("/collections/test_collection/items")
375
+ assert resp.status_code == 400
376
+
377
+ resp = client.get(
378
+ "/collections/test_collection/items", params={"user": "Obi-Wan"}
379
+ )
380
+ assert resp.status_code == 200
381
+
382
+ resp = client.get("/collections/test_collection/items/test_item")
383
+ assert resp.status_code == 400
384
+
385
+ resp = client.get(
386
+ "/collections/test_collection/items/test_item", params={"user": "Chewbacca"}
387
+ )
388
+ assert resp.status_code == 200
@@ -1,6 +1,8 @@
1
1
  from unittest import mock
2
2
 
3
3
  import pytest
4
+ from fastapi import Request
5
+ from fastapi.responses import JSONResponse
4
6
  from starlette.applications import Starlette
5
7
  from starlette.testclient import TestClient
6
8
 
@@ -166,3 +168,31 @@ def test_cors_middleware(test_client):
166
168
  resp = test_client.get("/_mgmt/ping", headers={"Origin": "http://netloc"})
167
169
  assert resp.status_code == 200
168
170
  assert resp.headers["access-control-allow-origin"] == "*"
171
+
172
+
173
+ def test_middleware_stack():
174
+ stac_api = StacApi(
175
+ settings=ApiSettings(), client=mock.create_autospec(BaseCoreClient)
176
+ )
177
+
178
+ def exception_handler(request: Request, exc: Exception) -> JSONResponse:
179
+ return JSONResponse(
180
+ status_code=400,
181
+ content={"customerrordetail": "yoo", "body": "yo"},
182
+ )
183
+
184
+ class CustomException(Exception):
185
+ "Custom Exception"
186
+
187
+ pass
188
+
189
+ stac_api.app.add_exception_handler(CustomException, exception_handler)
190
+
191
+ @stac_api.app.get("/error")
192
+ def error_endpoint():
193
+ raise CustomException("got you!")
194
+
195
+ with TestClient(stac_api.app) as client:
196
+ resp = client.get("/error")
197
+ assert resp.status_code == 400
198
+ assert resp.json()["customerrordetail"] == "yoo"
@@ -1,6 +1,8 @@
1
1
  import json
2
2
 
3
3
  import pytest
4
+ from fastapi import Depends, FastAPI
5
+ from fastapi.testclient import TestClient
4
6
  from pydantic import ValidationError
5
7
 
6
8
  from stac_fastapi.api.models import create_get_request_model, create_post_request_model
@@ -26,13 +28,33 @@ def test_create_get_request_model():
26
28
  datetime="2020-01-01T00:00:00Z",
27
29
  limit=10,
28
30
  filter="test==test",
29
- # FIXME: https://github.com/stac-utils/stac-fastapi/issues/638
30
- # hyphen aliases are not properly working
31
- # **{"filter-crs": "epsg:4326", "filter-lang": "cql2-text"},
31
+ filter_crs="epsg:4326",
32
+ filter_lang="cql2-text",
32
33
  )
33
34
 
34
35
  assert model.collections == ["test1", "test2"]
35
- # assert model.filter_crs == "epsg:4326"
36
+ assert model.filter_crs == "epsg:4326"
37
+
38
+ app = FastAPI()
39
+
40
+ @app.get("/test")
41
+ def route(model=Depends(request_model)):
42
+ return model
43
+
44
+ with TestClient(app) as client:
45
+ resp = client.get(
46
+ "/test",
47
+ params={
48
+ "collections": "test1,test2",
49
+ "filter-crs": "epsg:4326",
50
+ "filter-lang": "cql2-text",
51
+ },
52
+ )
53
+ assert resp.status_code == 200
54
+ response_dict = resp.json()
55
+ assert response_dict["collections"] == ["test1", "test2"]
56
+ assert response_dict["filter_crs"] == "epsg:4326"
57
+ assert response_dict["filter_lang"] == "cql2-text"
36
58
 
37
59
 
38
60
  @pytest.mark.parametrize(
@@ -1,2 +0,0 @@
1
- """Library version."""
2
- __version__ = "3.0.0a3"
@@ -1,192 +0,0 @@
1
- from datetime import datetime
2
- from typing import List, Optional, Union
3
-
4
- import pytest
5
- from fastapi.testclient import TestClient
6
- from pydantic import ValidationError
7
- from stac_pydantic import api
8
-
9
- from stac_fastapi.api import app
10
- from stac_fastapi.api.models import create_get_request_model, create_post_request_model
11
- from stac_fastapi.extensions.core.filter.filter import FilterExtension
12
- from stac_fastapi.types import stac
13
- from stac_fastapi.types.config import ApiSettings
14
- from stac_fastapi.types.core import NumType
15
- from stac_fastapi.types.search import BaseSearchPostRequest
16
-
17
-
18
- def test_client_response_type(TestCoreClient):
19
- """Test all GET endpoints. Verify that responses are valid STAC items."""
20
-
21
- test_app = app.StacApi(
22
- settings=ApiSettings(),
23
- client=TestCoreClient(),
24
- )
25
-
26
- with TestClient(test_app.app) as client:
27
- landing = client.get("/")
28
- collection = client.get("/collections/test")
29
- collections = client.get("/collections")
30
- item = client.get("/collections/test/items/test")
31
- item_collection = client.get(
32
- "/collections/test/items",
33
- params={"limit": 10},
34
- )
35
- get_search = client.get(
36
- "/search",
37
- params={
38
- "collections": ["test"],
39
- },
40
- )
41
- post_search = client.post(
42
- "/search",
43
- json={
44
- "collections": ["test"],
45
- },
46
- )
47
-
48
- assert landing.status_code == 200, landing.text
49
- api.LandingPage(**landing.json())
50
-
51
- assert collection.status_code == 200, collection.text
52
- api.Collection(**collection.json())
53
-
54
- assert collections.status_code == 200, collections.text
55
- api.collections.Collections(**collections.json())
56
-
57
- assert item.status_code == 200, item.text
58
- api.Item(**item.json())
59
-
60
- assert item_collection.status_code == 200, item_collection.text
61
- api.ItemCollection(**item_collection.json())
62
-
63
- assert get_search.status_code == 200, get_search.text
64
- api.ItemCollection(**get_search.json())
65
-
66
- assert post_search.status_code == 200, post_search.text
67
- api.ItemCollection(**post_search.json())
68
-
69
-
70
- @pytest.mark.parametrize("validate", [True, False])
71
- def test_client_invalid_response_type(validate, TestCoreClient, item_dict):
72
- """Check if the build in response validation switch works."""
73
-
74
- class InValidResponseClient(TestCoreClient):
75
- def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item:
76
- item_dict.pop("bbox")
77
- item_dict.pop("geometry")
78
- return stac.Item(**item_dict)
79
-
80
- test_app = app.StacApi(
81
- settings=ApiSettings(enable_response_models=validate),
82
- client=InValidResponseClient(),
83
- )
84
-
85
- with TestClient(test_app.app) as client:
86
- item = client.get("/collections/test/items/test")
87
-
88
- # Even if API validation passes, we should receive an invalid item
89
- if item.status_code == 200:
90
- with pytest.raises(ValidationError):
91
- api.Item(**item.json())
92
-
93
- # If internal validation is on, we should expect an internal error
94
- if validate:
95
- assert item.status_code == 500, item.text
96
- else:
97
- assert item.status_code == 200, item.text
98
-
99
-
100
- def test_client_openapi(TestCoreClient):
101
- """Test if response models are all documented with OpenAPI."""
102
-
103
- test_app = app.StacApi(
104
- settings=ApiSettings(),
105
- client=TestCoreClient(),
106
- )
107
- test_app.app.openapi()
108
- components = ["LandingPage", "Collection", "Collections", "Item", "ItemCollection"]
109
- for component in components:
110
- assert component in test_app.app.openapi_schema["components"]["schemas"]
111
-
112
-
113
- @pytest.mark.parametrize("validate", [True, False])
114
- def test_filter_extension(validate, TestCoreClient, item_dict):
115
- """Test if Filter Parameters are passed correctly."""
116
-
117
- class FilterClient(TestCoreClient):
118
- def post_search(
119
- self, search_request: BaseSearchPostRequest, **kwargs
120
- ) -> stac.ItemCollection:
121
- search_request.collections = ["test"]
122
- search_request.filter = {}
123
- search_request.filter_crs = "EPSG:4326"
124
- search_request.filter_lang = "cql2-text"
125
-
126
- return stac.ItemCollection(
127
- type="FeatureCollection", features=[stac.Item(**item_dict)]
128
- )
129
-
130
- def get_search(
131
- self,
132
- collections: Optional[List[str]] = None,
133
- ids: Optional[List[str]] = None,
134
- bbox: Optional[List[NumType]] = None,
135
- intersects: Optional[str] = None,
136
- datetime: Optional[Union[str, datetime]] = None,
137
- limit: Optional[int] = 10,
138
- filter: Optional[str] = None,
139
- filter_crs: Optional[str] = None,
140
- filter_lang: Optional[str] = None,
141
- **kwargs,
142
- ) -> stac.ItemCollection:
143
- # Check if all filter parameters are passed correctly
144
-
145
- assert filter == "TEST"
146
-
147
- # FIXME: https://github.com/stac-utils/stac-fastapi/issues/638
148
- # hyphen alias for filter_crs and filter_lang are currently not working
149
- # Query parameters `filter-crs` and `filter-lang`
150
- # should be recognized by the API
151
- # They are present in the `request.query_params` but not in the `kwargs`
152
-
153
- # assert filter_crs == "EPSG:4326"
154
- # assert filter_lang == "cql2-text"
155
-
156
- return stac.ItemCollection(
157
- type="FeatureCollection", features=[stac.Item(**item_dict)]
158
- )
159
-
160
- post_request_model = create_post_request_model([FilterExtension()])
161
-
162
- test_app = app.StacApi(
163
- settings=ApiSettings(enable_response_models=validate),
164
- client=FilterClient(post_request_model=post_request_model),
165
- search_get_request_model=create_get_request_model([FilterExtension()]),
166
- search_post_request_model=post_request_model,
167
- extensions=[FilterExtension()],
168
- )
169
-
170
- with TestClient(test_app.app) as client:
171
- landing = client.get("/")
172
- get_search = client.get(
173
- "/search",
174
- params={
175
- "filter": "TEST",
176
- "filter-crs": "EPSG:4326",
177
- "filter-lang": "cql2-text",
178
- },
179
- )
180
- post_search = client.post(
181
- "/search",
182
- json={
183
- "collections": ["test"],
184
- "filter": {},
185
- "filter-crs": "EPSG:4326",
186
- "filter-lang": "cql2-text",
187
- },
188
- )
189
-
190
- assert landing.status_code == 200, landing.text
191
- assert get_search.status_code == 200, get_search.text
192
- assert post_search.status_code == 200, post_search.text