stac-fastapi.api 3.0.5__tar.gz → 4.0.1__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 (24) hide show
  1. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/PKG-INFO +3 -4
  2. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/setup.py +2 -3
  3. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi/api/middleware.py +24 -24
  4. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi/api/models.py +5 -6
  5. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi/api/version.py +1 -1
  6. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi.api.egg-info/PKG-INFO +3 -4
  7. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi.api.egg-info/requires.txt +1 -1
  8. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/tests/test_app.py +31 -21
  9. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/tests/test_middleware.py +42 -0
  10. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/tests/test_models.py +32 -11
  11. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/README.md +0 -0
  12. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/setup.cfg +0 -0
  13. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi/api/__init__.py +0 -0
  14. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi/api/app.py +0 -0
  15. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi/api/config.py +0 -0
  16. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi/api/errors.py +0 -0
  17. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi/api/openapi.py +0 -0
  18. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi/api/routes.py +0 -0
  19. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi.api.egg-info/SOURCES.txt +0 -0
  20. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi.api.egg-info/dependency_links.txt +0 -0
  21. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi.api.egg-info/not-zip-safe +0 -0
  22. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/stac_fastapi.api.egg-info/top_level.txt +0 -0
  23. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/tests/test_api.py +0 -0
  24. {stac_fastapi_api-3.0.5 → stac_fastapi_api-4.0.1}/tests/test_app_prefix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: stac-fastapi.api
3
- Version: 3.0.5
3
+ Version: 4.0.1
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
@@ -10,17 +10,16 @@ Keywords: STAC FastAPI COG
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: Intended Audience :: Information Technology
12
12
  Classifier: Intended Audience :: Science/Research
13
- Classifier: Programming Language :: Python :: 3.8
14
13
  Classifier: Programming Language :: Python :: 3.9
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: License :: OSI Approved :: MIT License
20
- Requires-Python: >=3.8
19
+ Requires-Python: >=3.9
21
20
  Description-Content-Type: text/markdown
22
21
  Requires-Dist: brotli_asgi
23
- Requires-Dist: stac-fastapi.types~=3.0
22
+ Requires-Dist: stac-fastapi.types~=4.0
24
23
  Provides-Extra: dev
25
24
  Requires-Dist: httpx; extra == "dev"
26
25
  Requires-Dist: pytest; extra == "dev"
@@ -7,7 +7,7 @@ with open("README.md") as f:
7
7
 
8
8
  install_requires = [
9
9
  "brotli_asgi",
10
- "stac-fastapi.types~=3.0",
10
+ "stac-fastapi.types~=4.0",
11
11
  ]
12
12
 
13
13
  extra_reqs = {
@@ -31,12 +31,11 @@ setup(
31
31
  description="An implementation of STAC API based on the FastAPI framework.",
32
32
  long_description=desc,
33
33
  long_description_content_type="text/markdown",
34
- python_requires=">=3.8",
34
+ python_requires=">=3.9",
35
35
  classifiers=[
36
36
  "Intended Audience :: Developers",
37
37
  "Intended Audience :: Information Technology",
38
38
  "Intended Audience :: Science/Research",
39
- "Programming Language :: Python :: 3.8",
40
39
  "Programming Language :: Python :: 3.9",
41
40
  "Programming Language :: Python :: 3.10",
42
41
  "Programming Language :: Python :: 3.11",
@@ -1,9 +1,10 @@
1
1
  """Api middleware."""
2
2
 
3
+ import contextlib
3
4
  import re
4
5
  import typing
5
6
  from http.client import HTTP_PORT, HTTPS_PORT
6
- from typing import List, Tuple
7
+ from typing import List, Optional, Tuple
7
8
 
8
9
  from starlette.middleware.cors import CORSMiddleware as _CORSMiddleware
9
10
  from starlette.types import ASGIApp, Receive, Scope, Send
@@ -44,6 +45,10 @@ class CORSMiddleware(_CORSMiddleware):
44
45
  )
45
46
 
46
47
 
48
+ _PROTO_HEADER_REGEX = re.compile(r"proto=(?P<proto>http(s)?)")
49
+ _HOST_HEADER_REGEX = re.compile(r"host=(?P<host>[\w.-]+)(:(?P<port>\d{1,5}))?")
50
+
51
+
47
52
  class ProxyHeaderMiddleware:
48
53
  """Account for forwarding headers when deriving base URL.
49
54
 
@@ -68,11 +73,13 @@ class ProxyHeaderMiddleware:
68
73
  proto == "https" and port != HTTPS_PORT
69
74
  ):
70
75
  port_suffix = f":{port}"
76
+
71
77
  scope["headers"] = self._replace_header_value_by_name(
72
78
  scope,
73
79
  "host",
74
80
  f"{domain}{port_suffix}",
75
81
  )
82
+
76
83
  await self.app(scope, receive, send)
77
84
 
78
85
  def _get_forwarded_url_parts(self, scope: Scope) -> Tuple[str]:
@@ -87,37 +94,30 @@ class ProxyHeaderMiddleware:
87
94
  else:
88
95
  domain = header_host_parts[0]
89
96
  port = None
90
- forwarded = self._get_header_value_by_name(scope, "forwarded")
91
- if forwarded is not None:
92
- parts = forwarded.split(";")
93
- for part in parts:
94
- if len(part) > 0 and re.search("=", part):
95
- key, value = part.split("=")
96
- if key == "proto":
97
- proto = value
98
- elif key == "host":
99
- host_parts = value.split(":")
100
- domain = host_parts[0]
101
- try:
102
- port = int(host_parts[1]) if len(host_parts) == 2 else None
103
- except ValueError:
104
- # ignore ports that are not valid integers
105
- pass
97
+
98
+ port_str = None # make sure it is defined in all paths since we access it later
99
+
100
+ if forwarded := self._get_header_value_by_name(scope, "forwarded"):
101
+ for proxy in forwarded.split(","):
102
+ if proto_expr := _PROTO_HEADER_REGEX.search(proxy):
103
+ proto = proto_expr.group("proto")
104
+ if host_expr := _HOST_HEADER_REGEX.search(proxy):
105
+ domain = host_expr.group("host")
106
+ port_str = host_expr.group("port") # None if not present in the match
107
+
106
108
  else:
107
109
  domain = self._get_header_value_by_name(scope, "x-forwarded-host", domain)
108
110
  proto = self._get_header_value_by_name(scope, "x-forwarded-proto", proto)
109
111
  port_str = self._get_header_value_by_name(scope, "x-forwarded-port", port)
110
- try:
111
- port = int(port_str) if port_str is not None else None
112
- except ValueError:
113
- # ignore ports that are not valid integers
114
- pass
112
+
113
+ with contextlib.suppress(ValueError): # ignore ports that are not valid integers
114
+ port = int(port_str) if port_str is not None else port
115
115
 
116
116
  return (proto, domain, port)
117
117
 
118
118
  def _get_header_value_by_name(
119
- self, scope: Scope, header_name: str, default_value: str = None
120
- ) -> str:
119
+ self, scope: Scope, header_name: str, default_value: Optional[str] = None
120
+ ) -> Optional[str]:
121
121
  headers = scope["headers"]
122
122
  candidates = [
123
123
  value.decode() for key, value in headers if key.decode() == header_name
@@ -9,14 +9,15 @@ from stac_pydantic.shared import BBox
9
9
  from typing_extensions import Annotated
10
10
 
11
11
  from stac_fastapi.types.extension import ApiExtension
12
- from stac_fastapi.types.rfc3339 import DateTimeType
13
12
  from stac_fastapi.types.search import (
14
13
  APIRequest,
15
14
  BaseSearchGetRequest,
16
15
  BaseSearchPostRequest,
16
+ DatetimeMixin,
17
+ DateTimeQueryType,
17
18
  Limit,
18
19
  _bbox_converter,
19
- _datetime_converter,
20
+ _validate_datetime,
20
21
  )
21
22
 
22
23
  try:
@@ -110,7 +111,7 @@ class EmptyRequest(APIRequest):
110
111
 
111
112
 
112
113
  @attr.s
113
- class ItemCollectionUri(APIRequest):
114
+ class ItemCollectionUri(APIRequest, DatetimeMixin):
114
115
  """Get item collection."""
115
116
 
116
117
  collection_id: Annotated[str, Path(description="Collection ID")] = attr.ib()
@@ -121,9 +122,7 @@ class ItemCollectionUri(APIRequest):
121
122
  ),
122
123
  ] = attr.ib(default=10)
123
124
  bbox: Optional[BBox] = attr.ib(default=None, converter=_bbox_converter)
124
- datetime: Optional[DateTimeType] = attr.ib(
125
- default=None, converter=_datetime_converter
126
- )
125
+ datetime: DateTimeQueryType = attr.ib(default=None, validator=_validate_datetime)
127
126
 
128
127
 
129
128
  class GeoJSONResponse(JSONResponse):
@@ -1,3 +1,3 @@
1
1
  """Library version."""
2
2
 
3
- __version__ = "3.0.5"
3
+ __version__ = "4.0.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: stac-fastapi.api
3
- Version: 3.0.5
3
+ Version: 4.0.1
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
@@ -10,17 +10,16 @@ Keywords: STAC FastAPI COG
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: Intended Audience :: Information Technology
12
12
  Classifier: Intended Audience :: Science/Research
13
- Classifier: Programming Language :: Python :: 3.8
14
13
  Classifier: Programming Language :: Python :: 3.9
15
14
  Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: License :: OSI Approved :: MIT License
20
- Requires-Python: >=3.8
19
+ Requires-Python: >=3.9
21
20
  Description-Content-Type: text/markdown
22
21
  Requires-Dist: brotli_asgi
23
- Requires-Dist: stac-fastapi.types~=3.0
22
+ Requires-Dist: stac-fastapi.types~=4.0
24
23
  Provides-Extra: dev
25
24
  Requires-Dist: httpx; extra == "dev"
26
25
  Requires-Dist: pytest; extra == "dev"
@@ -1,5 +1,5 @@
1
1
  brotli_asgi
2
- stac-fastapi.types~=3.0
2
+ stac-fastapi.types~=4.0
3
3
 
4
4
  [benchmark]
5
5
  pytest-benchmark
@@ -1,4 +1,3 @@
1
- from datetime import datetime
2
1
  from typing import List, Optional, Union
3
2
 
4
3
  import attr
@@ -127,7 +126,7 @@ def test_filter_extension(validate, TestCoreClient, item_dict):
127
126
  self, search_request: BaseSearchPostRequest, **kwargs
128
127
  ) -> stac.ItemCollection:
129
128
  search_request.collections = ["test"]
130
- search_request.filter = {}
129
+ search_request.filter_expr = {}
131
130
  search_request.filter_crs = "EPSG:4326"
132
131
  search_request.filter_lang = "cql2-text"
133
132
 
@@ -141,25 +140,16 @@ def test_filter_extension(validate, TestCoreClient, item_dict):
141
140
  ids: Optional[List[str]] = None,
142
141
  bbox: Optional[List[NumType]] = None,
143
142
  intersects: Optional[str] = None,
144
- datetime: Optional[Union[str, datetime]] = None,
143
+ datetime: Optional[str] = None,
145
144
  limit: Optional[int] = 10,
146
- filter: Optional[str] = None,
145
+ filter_expr: Optional[str] = None,
147
146
  filter_crs: Optional[str] = None,
148
147
  filter_lang: Optional[str] = None,
149
148
  **kwargs,
150
149
  ) -> stac.ItemCollection:
151
- # Check if all filter parameters are passed correctly
152
-
153
- assert filter == "TEST"
154
-
155
- # FIXME: https://github.com/stac-utils/stac-fastapi/issues/638
156
- # hyphen alias for filter_crs and filter_lang are currently not working
157
- # Query parameters `filter-crs` and `filter-lang`
158
- # should be recognized by the API
159
- # They are present in the `request.query_params` but not in the `kwargs`
160
-
161
- # assert filter_crs == "EPSG:4326"
162
- # assert filter_lang == "cql2-text"
150
+ assert filter_expr == "TEST"
151
+ assert filter_crs == "EPSG:4326"
152
+ assert filter_lang == "cql2-text"
163
153
 
164
154
  return stac.ItemCollection(
165
155
  type="FeatureCollection", features=[stac.Item(**item_dict)]
@@ -221,7 +211,7 @@ def test_fields_extension(validate, TestCoreClient, item_dict):
221
211
  ids: Optional[List[str]] = None,
222
212
  bbox: Optional[List[NumType]] = None,
223
213
  intersects: Optional[str] = None,
224
- datetime: Optional[Union[str, datetime]] = None,
214
+ datetime: Optional[str] = None,
225
215
  limit: Optional[int] = 10,
226
216
  **kwargs,
227
217
  ) -> stac.ItemCollection:
@@ -247,7 +237,7 @@ def test_fields_extension(validate, TestCoreClient, item_dict):
247
237
  self,
248
238
  collection_id: str,
249
239
  bbox: Optional[List[Union[float, int]]] = None,
250
- datetime: Optional[Union[str, datetime]] = None,
240
+ datetime: Optional[str] = None,
251
241
  limit: int = 10,
252
242
  token: str = None,
253
243
  **kwargs,
@@ -392,6 +382,7 @@ def test_client_datetime_input_params():
392
382
 
393
383
  class FakeClient(BaseCoreClient):
394
384
  def post_search(self, search_request: BaseSearchPostRequest, **kwargs):
385
+ assert isinstance(search_request.datetime, str)
395
386
  return search_request.datetime
396
387
 
397
388
  def get_search(
@@ -400,10 +391,11 @@ def test_client_datetime_input_params():
400
391
  ids: Optional[List[str]] = None,
401
392
  bbox: Optional[List[NumType]] = None,
402
393
  intersects: Optional[str] = None,
403
- datetime: Optional[Union[str, datetime]] = None,
394
+ datetime: Optional[str] = None,
404
395
  limit: Optional[int] = 10,
405
396
  **kwargs,
406
397
  ):
398
+ assert isinstance(datetime, str)
407
399
  return datetime
408
400
 
409
401
  def get_item(self, item_id: str, collection_id: str, **kwargs) -> stac.Item:
@@ -419,7 +411,7 @@ def test_client_datetime_input_params():
419
411
  self,
420
412
  collection_id: str,
421
413
  bbox: Optional[List[Union[float, int]]] = None,
422
- datetime: Optional[Union[str, datetime]] = None,
414
+ datetime: Optional[str] = None,
423
415
  limit: int = 10,
424
416
  token: str = None,
425
417
  **kwargs,
@@ -439,6 +431,13 @@ def test_client_datetime_input_params():
439
431
  "datetime": "2020-01-01T00:00:00.00001Z",
440
432
  },
441
433
  )
434
+ get_search_zero = client.get(
435
+ "/search",
436
+ params={
437
+ "collections": ["test"],
438
+ "datetime": "2020-01-01T00:00:00.0000Z",
439
+ },
440
+ )
442
441
  post_search = client.post(
443
442
  "/search",
444
443
  json={
@@ -446,9 +445,20 @@ def test_client_datetime_input_params():
446
445
  "datetime": "2020-01-01T00:00:00.00001Z",
447
446
  },
448
447
  )
448
+ post_search_zero = client.post(
449
+ "/search",
450
+ json={
451
+ "collections": ["test"],
452
+ "datetime": "2020-01-01T00:00:00.0000Z",
453
+ },
454
+ )
449
455
 
450
456
  assert get_search.status_code == 200, get_search.text
451
- assert get_search.json() == "2020-01-01T00:00:00.000010+00:00"
457
+ assert get_search.json() == "2020-01-01T00:00:00.00001Z"
458
+ assert get_search_zero.status_code == 200, get_search_zero.text
459
+ assert get_search_zero.json() == "2020-01-01T00:00:00.0000Z"
452
460
 
453
461
  assert post_search.status_code == 200, post_search.text
454
462
  assert post_search.json() == "2020-01-01T00:00:00.00001Z"
463
+ assert post_search_zero.status_code == 200, post_search_zero.text
464
+ assert post_search_zero.json() == "2020-01-01T00:00:00.0000Z"
@@ -155,6 +155,48 @@ def test_replace_header_value_by_name(
155
155
  },
156
156
  ("https", "test", 1234),
157
157
  ),
158
+ (
159
+ {
160
+ "scheme": "http",
161
+ "server": ["testserver", 80],
162
+ "headers": [
163
+ (
164
+ b"forwarded",
165
+ # two proxy servers added an entry, we want to use the last one
166
+ b"proto=https;host=test:1234,proto=https;host=second-server:1111",
167
+ )
168
+ ],
169
+ },
170
+ ("https", "second-server", 1111),
171
+ ),
172
+ (
173
+ {
174
+ "scheme": "http",
175
+ "server": ["testserver", 80],
176
+ "headers": [
177
+ (
178
+ b"forwarded",
179
+ # check when host and port are inverted
180
+ b"host=test:1234;proto=https",
181
+ )
182
+ ],
183
+ },
184
+ ("https", "test", 1234),
185
+ ),
186
+ (
187
+ {
188
+ "scheme": "http",
189
+ "server": ["testserver", 80],
190
+ "headers": [
191
+ (
192
+ b"forwarded",
193
+ # proto is set, but no host
194
+ b'for="85.193.181.55";proto=https,for="85.193.181.55";proto=https',
195
+ )
196
+ ],
197
+ },
198
+ ("https", "testserver", 80),
199
+ ),
158
200
  ],
159
201
  )
160
202
  def test_get_forwarded_url_parts(
@@ -28,19 +28,32 @@ def test_create_get_request_model():
28
28
  ),
29
29
  datetime="2020-01-01T00:00:00.00001Z",
30
30
  limit=10,
31
- filter="test==test",
31
+ filter_expr="test==test",
32
32
  filter_crs="epsg:4326",
33
33
  filter_lang="cql2-text",
34
34
  )
35
35
 
36
36
  assert model.collections == ["test1", "test2"]
37
+ assert model.filter_expr == "test==test"
37
38
  assert model.filter_crs == "epsg:4326"
38
- d = model.datetime
39
+ d = model.start_date
39
40
  assert d.microsecond == 10
41
+ assert not model.end_date
40
42
 
43
+ model = request_model(
44
+ datetime="2020-01-01T00:00:00.00001Z/2020-01-02T00:00:00.00001Z",
45
+ )
46
+ assert model.start_date
47
+ assert model.end_date
48
+
49
+ # invalid datetime format
41
50
  with pytest.raises(HTTPException):
42
51
  request_model(datetime="yo")
43
52
 
53
+ # Wrong order
54
+ with pytest.raises(HTTPException):
55
+ request_model(datetime="2020-01-02T00:00:00.00001Z/2020-01-01T00:00:00.00001Z")
56
+
44
57
  app = FastAPI()
45
58
 
46
59
  @app.get("/test")
@@ -52,6 +65,7 @@ def test_create_get_request_model():
52
65
  "/test",
53
66
  params={
54
67
  "collections": "test1,test2",
68
+ "filter": "test=test",
55
69
  "filter-crs": "epsg:4326",
56
70
  "filter-lang": "cql2-text",
57
71
  },
@@ -59,6 +73,7 @@ def test_create_get_request_model():
59
73
  assert resp.status_code == 200
60
74
  response_dict = resp.json()
61
75
  assert response_dict["collections"] == ["test1", "test2"]
76
+ assert response_dict["filter_expr"] == "test=test"
62
77
  assert response_dict["filter_crs"] == "epsg:4326"
63
78
  assert response_dict["filter_lang"] == "cql2-text"
64
79
 
@@ -77,21 +92,27 @@ def test_create_post_request_model(filter_val, passes):
77
92
  with pytest.raises(ValidationError):
78
93
  model = request_model(filter=filter_val)
79
94
  else:
80
- model = request_model(
81
- collections=["test1", "test2"],
82
- ids=["test1", "test2"],
83
- bbox=[0, 0, 1, 1],
84
- datetime="2020-01-01T00:00:00.00001Z",
85
- limit=10,
86
- filter=filter_val,
87
- **{"filter-crs": "epsg:4326", "filter-lang": "cql2-json"},
95
+ model = request_model.model_validate(
96
+ {
97
+ "collections": ["test1", "test2"],
98
+ "ids": ["test1", "test2"],
99
+ "bbox": [0, 0, 1, 1],
100
+ "datetime": "2020-01-01T00:00:00.00001Z",
101
+ "limit": 10,
102
+ "filter": filter_val,
103
+ "filter-crs": "epsg:4326",
104
+ "filter-lang": "cql2-json",
105
+ }
88
106
  )
89
107
 
90
108
  assert model.collections == ["test1", "test2"]
109
+ assert model.filter_expr == filter_val
91
110
  assert model.filter_crs == "epsg:4326"
92
- assert model.filter == filter_val
93
111
  assert model.datetime == "2020-01-01T00:00:00.00001Z"
94
112
 
113
+ with pytest.raises(ValidationError):
114
+ request_model(datetime="yo")
115
+
95
116
 
96
117
  @pytest.mark.parametrize(
97
118
  "sortby,passes",