stac-fastapi.api 2.5.5.post1__tar.gz → 3.0.0a0__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 (25) hide show
  1. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/PKG-INFO +1 -4
  2. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/setup.py +0 -3
  3. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi/api/app.py +108 -27
  4. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi/api/config.py +1 -0
  5. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi/api/errors.py +2 -1
  6. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi/api/middleware.py +1 -0
  7. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi/api/models.py +13 -34
  8. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi/api/openapi.py +2 -3
  9. stac_fastapi_api-3.0.0a0/stac_fastapi/api/version.py +2 -0
  10. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi.api.egg-info/PKG-INFO +1 -4
  11. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi.api.egg-info/SOURCES.txt +3 -1
  12. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi.api.egg-info/requires.txt +0 -3
  13. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/tests/test_api.py +48 -16
  14. stac_fastapi_api-3.0.0a0/tests/test_app.py +188 -0
  15. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/tests/test_app_prefix.py +1 -1
  16. stac_fastapi_api-3.0.0a0/tests/test_models.py +101 -0
  17. stac_fastapi_api-2.5.5.post1/stac_fastapi/api/version.py +0 -2
  18. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/README.md +0 -0
  19. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/setup.cfg +0 -0
  20. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi/api/__init__.py +0 -0
  21. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi/api/routes.py +0 -0
  22. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi.api.egg-info/dependency_links.txt +0 -0
  23. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi.api.egg-info/not-zip-safe +0 -0
  24. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/stac_fastapi.api.egg-info/top_level.txt +0 -0
  25. {stac_fastapi_api-2.5.5.post1 → stac_fastapi_api-3.0.0a0}/tests/test_middleware.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: stac-fastapi.api
3
- Version: 2.5.5.post1
3
+ Version: 3.0.0a0
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,9 +18,6 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: License :: OSI Approved :: MIT License
19
19
  Requires-Python: >=3.8
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: attrs
22
- Requires-Dist: pydantic[dotenv]<2
23
- Requires-Dist: stac_pydantic==2.0.*
24
21
  Requires-Dist: brotli_asgi
25
22
  Requires-Dist: stac-fastapi.types
26
23
  Provides-Extra: dev
@@ -6,9 +6,6 @@ with open("README.md") as f:
6
6
  desc = f.read()
7
7
 
8
8
  install_requires = [
9
- "attrs",
10
- "pydantic[dotenv]<2",
11
- "stac_pydantic==2.0.*",
12
9
  "brotli_asgi",
13
10
  "stac-fastapi.types",
14
11
  ]
@@ -1,5 +1,6 @@
1
1
  """Fastapi app creation."""
2
2
 
3
+
3
4
  from typing import Any, Dict, List, Optional, Tuple, Type, Union
4
5
 
5
6
  import attr
@@ -7,10 +8,11 @@ from brotli_asgi import BrotliMiddleware
7
8
  from fastapi import APIRouter, FastAPI
8
9
  from fastapi.openapi.utils import get_openapi
9
10
  from fastapi.params import Depends
10
- from stac_pydantic import Collection, Item, ItemCollection
11
- from stac_pydantic.api import ConformanceClasses, LandingPage
11
+ from stac_pydantic import api
12
12
  from stac_pydantic.api.collections import Collections
13
- from stac_pydantic.version import STAC_VERSION
13
+ from stac_pydantic.api.version import STAC_API_VERSION
14
+ from stac_pydantic.shared import MimeTypes
15
+ from starlette.middleware import Middleware
14
16
  from starlette.responses import JSONResponse, Response
15
17
 
16
18
  from stac_fastapi.api.errors import DEFAULT_STATUS_CODES, add_exception_handlers
@@ -94,7 +96,7 @@ class StacApi:
94
96
  lambda self: self.settings.stac_fastapi_version, takes_self=True
95
97
  )
96
98
  )
97
- stac_version: str = attr.ib(default=STAC_VERSION)
99
+ stac_version: str = attr.ib(default=STAC_API_VERSION)
98
100
  description: str = attr.ib(
99
101
  default=attr.Factory(
100
102
  lambda self: self.settings.stac_fastapi_description, takes_self=True
@@ -108,9 +110,13 @@ class StacApi:
108
110
  )
109
111
  pagination_extension = attr.ib(default=TokenPaginationExtension)
110
112
  response_class: Type[Response] = attr.ib(default=JSONResponse)
111
- middlewares: List = attr.ib(
113
+ middlewares: List[Middleware] = attr.ib(
112
114
  default=attr.Factory(
113
- lambda: [BrotliMiddleware, CORSMiddleware, ProxyHeaderMiddleware]
115
+ lambda: [
116
+ Middleware(BrotliMiddleware),
117
+ Middleware(CORSMiddleware),
118
+ Middleware(ProxyHeaderMiddleware),
119
+ ]
114
120
  )
115
121
  )
116
122
  route_dependencies: List[Tuple[List[Scope], List[Depends]]] = attr.ib(default=[])
@@ -138,9 +144,17 @@ class StacApi:
138
144
  self.router.add_api_route(
139
145
  name="Landing Page",
140
146
  path="/",
141
- response_model=LandingPage
142
- if self.settings.enable_response_models
143
- else None,
147
+ response_model=(
148
+ api.LandingPage if self.settings.enable_response_models else None
149
+ ),
150
+ responses={
151
+ 200: {
152
+ "content": {
153
+ MimeTypes.json.value: {},
154
+ },
155
+ "model": api.LandingPage,
156
+ },
157
+ },
144
158
  response_class=self.response_class,
145
159
  response_model_exclude_unset=False,
146
160
  response_model_exclude_none=True,
@@ -157,9 +171,17 @@ class StacApi:
157
171
  self.router.add_api_route(
158
172
  name="Conformance Classes",
159
173
  path="/conformance",
160
- response_model=ConformanceClasses
161
- if self.settings.enable_response_models
162
- else None,
174
+ response_model=(
175
+ api.ConformanceClasses if self.settings.enable_response_models else None
176
+ ),
177
+ responses={
178
+ 200: {
179
+ "content": {
180
+ MimeTypes.json.value: {},
181
+ },
182
+ "model": api.ConformanceClasses,
183
+ },
184
+ },
163
185
  response_class=self.response_class,
164
186
  response_model_exclude_unset=True,
165
187
  response_model_exclude_none=True,
@@ -176,7 +198,15 @@ class StacApi:
176
198
  self.router.add_api_route(
177
199
  name="Get Item",
178
200
  path="/collections/{collection_id}/items/{item_id}",
179
- response_model=Item if self.settings.enable_response_models else None,
201
+ response_model=api.Item if self.settings.enable_response_models else None,
202
+ responses={
203
+ 200: {
204
+ "content": {
205
+ MimeTypes.geojson.value: {},
206
+ },
207
+ "model": api.Item,
208
+ },
209
+ },
180
210
  response_class=GeoJSONResponse,
181
211
  response_model_exclude_unset=True,
182
212
  response_model_exclude_none=True,
@@ -194,9 +224,19 @@ class StacApi:
194
224
  self.router.add_api_route(
195
225
  name="Search",
196
226
  path="/search",
197
- response_model=(ItemCollection if not fields_ext else None)
198
- if self.settings.enable_response_models
199
- else None,
227
+ response_model=(
228
+ (api.ItemCollection if not fields_ext else None)
229
+ if self.settings.enable_response_models
230
+ else None
231
+ ),
232
+ responses={
233
+ 200: {
234
+ "content": {
235
+ MimeTypes.geojson.value: {},
236
+ },
237
+ "model": api.ItemCollection,
238
+ },
239
+ },
200
240
  response_class=GeoJSONResponse,
201
241
  response_model_exclude_unset=True,
202
242
  response_model_exclude_none=True,
@@ -216,9 +256,19 @@ class StacApi:
216
256
  self.router.add_api_route(
217
257
  name="Search",
218
258
  path="/search",
219
- response_model=(ItemCollection if not fields_ext else None)
220
- if self.settings.enable_response_models
221
- else None,
259
+ response_model=(
260
+ (api.ItemCollection if not fields_ext else None)
261
+ if self.settings.enable_response_models
262
+ else None
263
+ ),
264
+ responses={
265
+ 200: {
266
+ "content": {
267
+ MimeTypes.geojson.value: {},
268
+ },
269
+ "model": api.ItemCollection,
270
+ },
271
+ },
222
272
  response_class=GeoJSONResponse,
223
273
  response_model_exclude_unset=True,
224
274
  response_model_exclude_none=True,
@@ -237,9 +287,17 @@ class StacApi:
237
287
  self.router.add_api_route(
238
288
  name="Get Collections",
239
289
  path="/collections",
240
- response_model=Collections
241
- if self.settings.enable_response_models
242
- else None,
290
+ response_model=(
291
+ Collections if self.settings.enable_response_models else None
292
+ ),
293
+ responses={
294
+ 200: {
295
+ "content": {
296
+ MimeTypes.json.value: {},
297
+ },
298
+ "model": Collections,
299
+ },
300
+ },
243
301
  response_class=self.response_class,
244
302
  response_model_exclude_unset=True,
245
303
  response_model_exclude_none=True,
@@ -256,7 +314,17 @@ class StacApi:
256
314
  self.router.add_api_route(
257
315
  name="Get Collection",
258
316
  path="/collections/{collection_id}",
259
- response_model=Collection if self.settings.enable_response_models else None,
317
+ response_model=api.Collection
318
+ if self.settings.enable_response_models
319
+ else None,
320
+ responses={
321
+ 200: {
322
+ "content": {
323
+ MimeTypes.json.value: {},
324
+ },
325
+ "model": api.Collection,
326
+ },
327
+ },
260
328
  response_class=self.response_class,
261
329
  response_model_exclude_unset=True,
262
330
  response_model_exclude_none=True,
@@ -283,9 +351,17 @@ class StacApi:
283
351
  self.router.add_api_route(
284
352
  name="Get ItemCollection",
285
353
  path="/collections/{collection_id}/items",
286
- response_model=ItemCollection
287
- if self.settings.enable_response_models
288
- else None,
354
+ response_model=(
355
+ api.ItemCollection if self.settings.enable_response_models else None
356
+ ),
357
+ responses={
358
+ 200: {
359
+ "content": {
360
+ MimeTypes.geojson.value: {},
361
+ },
362
+ "model": api.ItemCollection,
363
+ },
364
+ },
289
365
  response_class=GeoJSONResponse,
290
366
  response_model_exclude_unset=True,
291
367
  response_model_exclude_none=True,
@@ -363,6 +439,11 @@ class StacApi:
363
439
  """
364
440
  return add_route_dependencies(self.app.router.routes, scopes, dependencies)
365
441
 
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
+
366
447
  def __attrs_post_init__(self):
367
448
  """Post-init hook.
368
449
 
@@ -407,7 +488,7 @@ class StacApi:
407
488
 
408
489
  # add middlewares
409
490
  for middleware in self.middlewares:
410
- self.app.add_middleware(middleware)
491
+ self.add_middleware(middleware)
411
492
 
412
493
  # customize route dependencies
413
494
  for scopes, dependencies in self.route_dependencies:
@@ -1,4 +1,5 @@
1
1
  """Application settings."""
2
+
2
3
  import enum
3
4
 
4
5
 
@@ -4,7 +4,7 @@ import logging
4
4
  from typing import Callable, Dict, Type, TypedDict
5
5
 
6
6
  from fastapi import FastAPI
7
- from fastapi.exceptions import RequestValidationError
7
+ from fastapi.exceptions import RequestValidationError, ResponseValidationError
8
8
  from starlette import status
9
9
  from starlette.requests import Request
10
10
  from starlette.responses import JSONResponse
@@ -27,6 +27,7 @@ DEFAULT_STATUS_CODES = {
27
27
  DatabaseError: status.HTTP_424_FAILED_DEPENDENCY,
28
28
  Exception: status.HTTP_500_INTERNAL_SERVER_ERROR,
29
29
  InvalidQueryParameter: status.HTTP_400_BAD_REQUEST,
30
+ ResponseValidationError: status.HTTP_500_INTERNAL_SERVER_ERROR,
30
31
  }
31
32
 
32
33
 
@@ -1,4 +1,5 @@
1
1
  """Api middleware."""
2
+
2
3
  import re
3
4
  import typing
4
5
  from http.client import HTTP_PORT, HTTPS_PORT
@@ -1,12 +1,11 @@
1
1
  """Api request/response models."""
2
2
 
3
3
  import importlib.util
4
- from typing import Optional, Type, Union
4
+ from typing import List, Optional, Type, Union
5
5
 
6
6
  import attr
7
- from fastapi import Body, Path
7
+ from fastapi import Path
8
8
  from pydantic import BaseModel, create_model
9
- from pydantic.fields import UndefinedType
10
9
  from stac_pydantic.shared import BBox
11
10
 
12
11
  from stac_fastapi.types.extension import ApiExtension
@@ -23,8 +22,8 @@ from stac_fastapi.types.search import (
23
22
  def create_request_model(
24
23
  model_name="SearchGetRequest",
25
24
  base_model: Union[Type[BaseModel], APIRequest] = BaseSearchGetRequest,
26
- extensions: Optional[ApiExtension] = None,
27
- mixins: Optional[Union[BaseModel, APIRequest]] = None,
25
+ extensions: Optional[List[ApiExtension]] = None,
26
+ mixins: Optional[Union[List[BaseModel], List[APIRequest]]] = None,
28
27
  request_type: Optional[str] = "GET",
29
28
  ) -> Union[Type[BaseModel], APIRequest]:
30
29
  """Create a pydantic model for validating request bodies."""
@@ -47,40 +46,19 @@ def create_request_model(
47
46
  # Handle POST requests
48
47
  elif all([issubclass(m, BaseModel) for m in models]):
49
48
  for model in models:
50
- for k, v in model.__fields__.items():
51
- field_info = v.field_info
52
- body = Body(
53
- None
54
- if isinstance(field_info.default, UndefinedType)
55
- else field_info.default,
56
- default_factory=field_info.default_factory,
57
- alias=field_info.alias,
58
- alias_priority=field_info.alias_priority,
59
- title=field_info.title,
60
- description=field_info.description,
61
- const=field_info.const,
62
- gt=field_info.gt,
63
- ge=field_info.ge,
64
- lt=field_info.lt,
65
- le=field_info.le,
66
- multiple_of=field_info.multiple_of,
67
- min_items=field_info.min_items,
68
- max_items=field_info.max_items,
69
- min_length=field_info.min_length,
70
- max_length=field_info.max_length,
71
- regex=field_info.regex,
72
- extra=field_info.extra,
73
- )
74
- fields[k] = (v.outer_type_, body)
49
+ for k, field_info in model.model_fields.items():
50
+ fields[k] = (field_info.annotation, field_info)
75
51
  return create_model(model_name, **fields, __base__=base_model)
76
52
 
77
53
  raise TypeError("Mixed Request Model types. Check extension request types.")
78
54
 
79
55
 
80
56
  def create_get_request_model(
81
- extensions, base_model: BaseSearchGetRequest = BaseSearchGetRequest
82
- ):
57
+ extensions: Optional[List[ApiExtension]],
58
+ base_model: BaseSearchGetRequest = BaseSearchGetRequest,
59
+ ) -> APIRequest:
83
60
  """Wrap create_request_model to create the GET request model."""
61
+
84
62
  return create_request_model(
85
63
  "SearchGetRequest",
86
64
  base_model=base_model,
@@ -90,8 +68,9 @@ def create_get_request_model(
90
68
 
91
69
 
92
70
  def create_post_request_model(
93
- extensions, base_model: BaseSearchPostRequest = BaseSearchPostRequest
94
- ):
71
+ extensions: Optional[List[ApiExtension]],
72
+ base_model: BaseSearchPostRequest = BaseSearchPostRequest,
73
+ ) -> Type[BaseModel]:
95
74
  """Wrap create_request_model to create the POST request model."""
96
75
  return create_request_model(
97
76
  "SearchPostRequest",
@@ -1,4 +1,5 @@
1
1
  """openapi."""
2
+
2
3
  import warnings
3
4
 
4
5
  from fastapi import FastAPI
@@ -43,9 +44,7 @@ def update_openapi(app: FastAPI) -> FastAPI:
43
44
  # Get the response from the old endpoint function
44
45
  response: JSONResponse = await old_endpoint(req)
45
46
  # Update the content type header in place
46
- response.headers[
47
- "content-type"
48
- ] = "application/vnd.oai.openapi+json;version=3.0"
47
+ response.headers["content-type"] = "application/vnd.oai.openapi+json;version=3.0"
49
48
  # Return the updated response
50
49
  return response
51
50
 
@@ -0,0 +1,2 @@
1
+ """Library version."""
2
+ __version__ = "3.0.0a0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: stac-fastapi.api
3
- Version: 2.5.5.post1
3
+ Version: 3.0.0a0
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,9 +18,6 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: License :: OSI Approved :: MIT License
19
19
  Requires-Python: >=3.8
20
20
  Description-Content-Type: text/markdown
21
- Requires-Dist: attrs
22
- Requires-Dist: pydantic[dotenv]<2
23
- Requires-Dist: stac_pydantic==2.0.*
24
21
  Requires-Dist: brotli_asgi
25
22
  Requires-Dist: stac-fastapi.types
26
23
  Provides-Extra: dev
@@ -17,5 +17,7 @@ stac_fastapi/api/openapi.py
17
17
  stac_fastapi/api/routes.py
18
18
  stac_fastapi/api/version.py
19
19
  tests/test_api.py
20
+ tests/test_app.py
20
21
  tests/test_app_prefix.py
21
- tests/test_middleware.py
22
+ tests/test_middleware.py
23
+ tests/test_models.py
@@ -1,6 +1,3 @@
1
- attrs
2
- pydantic[dotenv]<2
3
- stac_pydantic==2.0.*
4
1
  brotli_asgi
5
2
  stac-fastapi.types
6
3
 
@@ -41,11 +41,11 @@ class TestRouteDependencies:
41
41
  method=route["method"].lower(),
42
42
  url=path,
43
43
  auth=("bob", "dobbs"),
44
- content='{"dummy": "payload"}',
44
+ content=route["payload"],
45
45
  headers={"content-type": "application/json"},
46
46
  )
47
47
  assert (
48
- response.status_code == 200
48
+ 200 <= response.status_code < 300
49
49
  ), "Authenticated requests should be accepted"
50
50
  assert response.json() == "dummy response"
51
51
 
@@ -58,27 +58,59 @@ class TestRouteDependencies:
58
58
  == "application/vnd.oai.openapi+json;version=3.0"
59
59
  )
60
60
 
61
- def test_build_api_with_route_dependencies(self):
61
+ def test_build_api_with_route_dependencies(self, collection, item):
62
62
  routes = [
63
- {"path": "/collections", "method": "POST"},
64
- {"path": "/collections/{collectionId}", "method": "PUT"},
65
- {"path": "/collections/{collectionId}", "method": "DELETE"},
66
- {"path": "/collections/{collectionId}/items", "method": "POST"},
67
- {"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"},
68
- {"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"},
63
+ {"path": "/collections", "method": "POST", "payload": collection},
64
+ {
65
+ "path": "/collections/{collectionId}",
66
+ "method": "PUT",
67
+ "payload": collection,
68
+ },
69
+ {"path": "/collections/{collectionId}", "method": "DELETE", "payload": ""},
70
+ {
71
+ "path": "/collections/{collectionId}/items",
72
+ "method": "POST",
73
+ "payload": item,
74
+ },
75
+ {
76
+ "path": "/collections/{collectionId}/items/{itemId}",
77
+ "method": "PUT",
78
+ "payload": item,
79
+ },
80
+ {
81
+ "path": "/collections/{collectionId}/items/{itemId}",
82
+ "method": "DELETE",
83
+ "payload": "",
84
+ },
69
85
  ]
70
86
  dependencies = [Depends(must_be_bob)]
71
87
  api = self._build_api(route_dependencies=[(routes, dependencies)])
72
88
  self._assert_dependency_applied(api, routes)
73
89
 
74
- def test_add_route_dependencies_after_building_api(self):
90
+ def test_add_route_dependencies_after_building_api(self, collection, item):
75
91
  routes = [
76
- {"path": "/collections", "method": "POST"},
77
- {"path": "/collections/{collectionId}", "method": "PUT"},
78
- {"path": "/collections/{collectionId}", "method": "DELETE"},
79
- {"path": "/collections/{collectionId}/items", "method": "POST"},
80
- {"path": "/collections/{collectionId}/items/{itemId}", "method": "PUT"},
81
- {"path": "/collections/{collectionId}/items/{itemId}", "method": "DELETE"},
92
+ {"path": "/collections", "method": "POST", "payload": collection},
93
+ {
94
+ "path": "/collections/{collectionId}",
95
+ "method": "PUT",
96
+ "payload": collection,
97
+ },
98
+ {"path": "/collections/{collectionId}", "method": "DELETE", "payload": ""},
99
+ {
100
+ "path": "/collections/{collectionId}/items",
101
+ "method": "POST",
102
+ "payload": item,
103
+ },
104
+ {
105
+ "path": "/collections/{collectionId}/items/{itemId}",
106
+ "method": "PUT",
107
+ "payload": item,
108
+ },
109
+ {
110
+ "path": "/collections/{collectionId}/items/{itemId}",
111
+ "method": "DELETE",
112
+ "payload": "",
113
+ },
82
114
  ]
83
115
  api = self._build_api()
84
116
  api.add_route_dependencies(scopes=routes, dependencies=[Depends(must_be_bob)])
@@ -0,0 +1,188 @@
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
+ def test_filter_extension(TestCoreClient, item_dict):
114
+ """Test if Filter Parameters are passed correctly."""
115
+
116
+ class FilterClient(TestCoreClient):
117
+ def post_search(
118
+ self, search_request: BaseSearchPostRequest, **kwargs
119
+ ) -> stac.ItemCollection:
120
+ search_request.collections = ["test"]
121
+ search_request.filter = {}
122
+ search_request.filter_crs = "EPSG:4326"
123
+ search_request.filter_lang = "cql2-text"
124
+
125
+ return stac.ItemCollection(
126
+ type="FeatureCollection", features=[stac.Item(**item_dict)]
127
+ )
128
+
129
+ def get_search(
130
+ self,
131
+ collections: Optional[List[str]] = None,
132
+ ids: Optional[List[str]] = None,
133
+ bbox: Optional[List[NumType]] = None,
134
+ intersects: Optional[str] = None,
135
+ datetime: Optional[Union[str, datetime]] = None,
136
+ limit: Optional[int] = 10,
137
+ filter: Optional[str] = None,
138
+ filter_crs: Optional[str] = None,
139
+ filter_lang: Optional[str] = None,
140
+ **kwargs,
141
+ ) -> stac.ItemCollection:
142
+ # Check if all filter parameters are passed correctly
143
+
144
+ assert filter == "TEST"
145
+
146
+ # FIXME: https://github.com/stac-utils/stac-fastapi/issues/638
147
+ # hyphen alias for filter_crs and filter_lang are currently not working
148
+ # Query parameters `filter-crs` and `filter-lang`
149
+ # should be recognized by the API
150
+ # They are present in the `request.query_params` but not in the `kwargs`
151
+
152
+ # assert filter_crs == "EPSG:4326"
153
+ # assert filter_lang == "cql2-text"
154
+
155
+ return stac.ItemCollection(
156
+ type="FeatureCollection", features=[stac.Item(**item_dict)]
157
+ )
158
+
159
+ post_request_model = create_post_request_model([FilterExtension()])
160
+
161
+ test_app = app.StacApi(
162
+ settings=ApiSettings(),
163
+ client=FilterClient(post_request_model=post_request_model),
164
+ search_get_request_model=create_get_request_model([FilterExtension()]),
165
+ search_post_request_model=post_request_model,
166
+ )
167
+
168
+ with TestClient(test_app.app) as client:
169
+ get_search = client.get(
170
+ "/search",
171
+ params={
172
+ "filter": "TEST",
173
+ "filter-crs": "EPSG:4326",
174
+ "filter-lang": "cql2-text",
175
+ },
176
+ )
177
+ post_search = client.post(
178
+ "/search",
179
+ json={
180
+ "collections": ["test"],
181
+ "filter": {},
182
+ "filter-crs": "EPSG:4326",
183
+ "filter-lang": "cql2-text",
184
+ },
185
+ )
186
+
187
+ assert get_search.status_code == 200, get_search.text
188
+ assert post_search.status_code == 200, post_search.text
@@ -51,7 +51,7 @@ def test_api_prefix(TestCoreClient, prefix):
51
51
  collections = client.get(f"{prefix}/collections")
52
52
  assert collections.status_code == 200, collections.json()
53
53
  collection_id = collections.json()["collections"][0]["id"]
54
- print(collections.json()["links"])
54
+
55
55
  collection = client.get(f"{prefix}/collections/{collection_id}")
56
56
  assert collection.status_code == 200, collection.json()
57
57
 
@@ -0,0 +1,101 @@
1
+ import json
2
+
3
+ import pytest
4
+ from pydantic import ValidationError
5
+
6
+ from stac_fastapi.api.models import create_get_request_model, create_post_request_model
7
+ from stac_fastapi.extensions.core.filter.filter import FilterExtension
8
+ from stac_fastapi.extensions.core.sort.sort import SortExtension
9
+ from stac_fastapi.types.search import BaseSearchGetRequest, BaseSearchPostRequest
10
+
11
+
12
+ def test_create_get_request_model():
13
+ extensions = [FilterExtension()]
14
+ request_model = create_get_request_model(extensions, BaseSearchGetRequest)
15
+
16
+ model = request_model(
17
+ collections="test1,test2",
18
+ ids="test1,test2",
19
+ bbox="0,0,1,1",
20
+ intersects=json.dumps(
21
+ {
22
+ "type": "Polygon",
23
+ "coordinates": [[[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]],
24
+ }
25
+ ),
26
+ datetime="2020-01-01T00:00:00Z",
27
+ limit=10,
28
+ 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"},
32
+ )
33
+
34
+ assert model.collections == ["test1", "test2"]
35
+ # assert model.filter_crs == "epsg:4326"
36
+
37
+
38
+ @pytest.mark.parametrize(
39
+ "filter,passes",
40
+ [(None, True), ({"test": "test"}, True), ("test==test", False), ([], False)],
41
+ )
42
+ def test_create_post_request_model(filter, passes):
43
+ extensions = [FilterExtension()]
44
+ request_model = create_post_request_model(extensions, BaseSearchPostRequest)
45
+
46
+ if not passes:
47
+ with pytest.raises(ValidationError):
48
+ model = request_model(filter=filter)
49
+ else:
50
+ model = request_model(
51
+ collections=["test1", "test2"],
52
+ ids=["test1", "test2"],
53
+ bbox=[0, 0, 1, 1],
54
+ datetime="2020-01-01T00:00:00Z",
55
+ limit=10,
56
+ filter=filter,
57
+ **{"filter-crs": "epsg:4326", "filter-lang": "cql2-text"},
58
+ )
59
+
60
+ assert model.collections == ["test1", "test2"]
61
+ assert model.filter_crs == "epsg:4326"
62
+ assert model.filter == filter
63
+
64
+
65
+ @pytest.mark.parametrize(
66
+ "sortby,passes",
67
+ [
68
+ (None, True),
69
+ (
70
+ [
71
+ {"field": "test", "direction": "asc"},
72
+ {"field": "test2", "direction": "desc"},
73
+ ],
74
+ True,
75
+ ),
76
+ ({"field": "test", "direction": "desc"}, False),
77
+ ("test", False),
78
+ ],
79
+ )
80
+ def test_create_post_request_model_nested_fields(sortby, passes):
81
+ extensions = [SortExtension()]
82
+ request_model = create_post_request_model(extensions, BaseSearchPostRequest)
83
+
84
+ if not passes:
85
+ with pytest.raises(ValidationError):
86
+ model = request_model(sortby=sortby)
87
+ else:
88
+ model = request_model(
89
+ collections=["test1", "test2"],
90
+ ids=["test1", "test2"],
91
+ bbox=[0, 0, 1, 1],
92
+ datetime="2020-01-01T00:00:00Z",
93
+ limit=10,
94
+ sortby=sortby,
95
+ )
96
+
97
+ assert model.collections == ["test1", "test2"]
98
+ if model.sortby is None:
99
+ assert sortby is None
100
+ else:
101
+ assert model.model_dump(mode="json")["sortby"] == sortby
@@ -1,2 +0,0 @@
1
- """Library version."""
2
- __version__ = "2.5.5.post1"