Skip to content

Commit 888b615

Browse files
authored
fix: refactor apis resolution, raise HTTPExceptions (#8)
1 parent 2e9158a commit 888b615

File tree

3 files changed

+164
-24
lines changed

3 files changed

+164
-24
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ export UPSTREAM_API_URLS=https://stac.eoapi.dev,https://stac.maap-project.org
2424
Run the server:
2525

2626
```bash
27-
uv run python -m uvicorn stac_fastapi.collection_discovery.app:app --host 0.0.0.0 --port 8000
27+
uv run python -m uvicorn stac_fastapi.collection_discovery.app:app \
28+
--host 0.0.0.0 \
29+
--port 8000 \
30+
--reload
2831
```
2932

3033
### Run the server with Docker

src/stac_fastapi/collection_discovery/core.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from urllib.parse import unquote, urlencode, urljoin
77

88
import attr
9-
from fastapi import Query, Request
9+
from fastapi import HTTPException, Query, Request
1010
from httpx import AsyncClient
1111
from pydantic import BaseModel
1212
from stac_pydantic.links import Relations
@@ -44,6 +44,33 @@ def _robust_urljoin(base: str, path: str) -> str:
4444
HTTPX_TIMEOUT = 15.0
4545

4646

47+
def _resolve_apis(
48+
apis: list[str] | None, request: Request, allow_empty: bool = False
49+
) -> list[str]:
50+
"""Resolve the list of APIs from parameter or app settings.
51+
52+
Args:
53+
apis: User-provided list of API URLs, or None to use settings
54+
request: FastAPI request object containing app state
55+
allow_empty: If True, return empty list when no APIs found; if False, raise error
56+
57+
Returns:
58+
List of API URLs
59+
60+
Raises:
61+
HTTPException: When no APIs are found and allow_empty is False
62+
"""
63+
if not apis:
64+
apis = request.app.state.settings.upstream_api_urls
65+
if not apis and not allow_empty:
66+
raise HTTPException(
67+
status_code=400,
68+
detail="No APIs specified. Provide 'apis' parameter or configure "
69+
"upstream_api_urls in this application.",
70+
)
71+
return apis or []
72+
73+
4774
class UpstreamApiStatus(BaseModel):
4875
"""Status information for an upstream API."""
4976

@@ -113,13 +140,15 @@ def _build_search_params(
113140
}
114141

115142
def _get_search_state(
116-
self, token: str | None, apis: list[str], param_str: str
143+
self, token: str | None, apis: list[str] | None, param_str: str
117144
) -> dict[str, Any]:
118145
"""Get or create search state based on token."""
119146
if token:
120147
search_state = self._decode_token(token)
121148
logger.info("Continuing collection search with token pagination")
122149
else:
150+
if not apis:
151+
raise ValueError("No apis specified")
123152
search_state = {
124153
"current": {
125154
api: _robust_urljoin(api, f"collections?{param_str}") for api in apis
@@ -209,10 +238,10 @@ async def all_collections(
209238
**kwargs,
210239
) -> Collections:
211240
"""Collection search for multiple upstream APIs"""
212-
if not apis:
213-
apis = request.app.state.settings.upstream_api_urls
214-
if not apis:
215-
raise ValueError("no apis specified!")
241+
# When using token pagination, apis are encoded in the token
242+
# Only validate apis parameter when not using token
243+
if not token:
244+
apis = _resolve_apis(apis, request)
216245

217246
params = self._build_search_params(
218247
bbox, datetime, limit, fields, sortby, filter_expr, filter_lang, q
@@ -231,10 +260,11 @@ async def all_collections(
231260
}
232261

233262
async def fetch_api_data(
234-
client, api: str, url: str
263+
client: AsyncClient, api: str, url: str
235264
) -> tuple[str, dict[str, Any]]:
236265
"""Fetch data from a single API endpoint."""
237266
api_request = await client.get(url)
267+
api_request.raise_for_status()
238268
json_response = api_request.json()
239269
return api, json_response
240270

@@ -313,10 +343,7 @@ async def landing_page(
313343
)
314344

315345
# Add upstream APIs as child links
316-
if not apis:
317-
apis = request.app.state.settings.upstream_api_urls
318-
if not apis:
319-
raise ValueError("no apis specified!")
346+
apis = _resolve_apis(apis, request)
320347

321348
# include the configured APIs in the description
322349
landing_page["description"] = (
@@ -414,10 +441,7 @@ async def conformance_classes(
414441

415442
local_conformance_set = set(local_conformance_classes)
416443

417-
if not apis:
418-
apis = request.app.state.settings.upstream_api_urls
419-
if not apis:
420-
raise ValueError("no apis specified!")
444+
apis = _resolve_apis(apis, request)
421445

422446
semaphore = asyncio.Semaphore(10)
423447

@@ -511,10 +535,7 @@ async def health_check(
511535
] = None,
512536
) -> HealthCheckResponse:
513537
"""PgSTAC HealthCheck."""
514-
if not apis:
515-
apis = request.app.state.settings.upstream_api_urls
516-
if not apis:
517-
raise ValueError("no apis specified!")
538+
apis = _resolve_apis(apis, request)
518539

519540
upstream_apis: dict[str, UpstreamApiStatus] = {}
520541
semaphore = asyncio.Semaphore(10)

tests/unit/test_core.py

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ async def test_all_collections_api_error(
170170
self, collection_search_client, mock_request, sample_collections_response
171171
):
172172
"""Test handling of API errors."""
173+
from httpx import HTTPStatusError
174+
173175
# One API returns error, one succeeds
174176
respx.get("https://api1.example.com/collections").mock(
175177
return_value=Response(500, json={"error": "Internal server error"})
@@ -179,7 +181,7 @@ async def test_all_collections_api_error(
179181
)
180182

181183
# Should raise exception due to failed API call
182-
with pytest.raises(KeyError):
184+
with pytest.raises(HTTPStatusError):
183185
await collection_search_client.all_collections(request=mock_request)
184186

185187
@pytest.mark.asyncio
@@ -243,30 +245,40 @@ async def test_all_collections_with_single_api(
243245

244246
@pytest.mark.asyncio
245247
async def test_all_collections_empty_apis_parameter(self, collection_search_client):
246-
"""Test collection search with empty apis parameter raises ValueError."""
248+
"""Test collection search with empty apis parameter raises HTTPException."""
247249
from unittest.mock import Mock
248250

251+
from fastapi import HTTPException
252+
249253
# Create a mock request with empty upstream_api_urls in settings
250254
mock_request = Mock()
251255
mock_request.app.state.settings.upstream_api_urls = []
252256

253-
with pytest.raises(ValueError, match="no apis specified!"):
257+
with pytest.raises(HTTPException) as exc_info:
254258
await collection_search_client.all_collections(request=mock_request, apis=[])
255259

260+
assert exc_info.value.status_code == 400
261+
assert "No APIs specified" in exc_info.value.detail
262+
256263
@pytest.mark.asyncio
257264
async def test_all_collections_no_apis_fallback_to_settings(
258265
self, collection_search_client
259266
):
260267
"""Test that when no apis parameter provided, it falls back to settings."""
261268
from unittest.mock import Mock
262269

270+
from fastapi import HTTPException
271+
263272
# Create a mock request with empty upstream_api_urls in settings
264273
mock_request = Mock()
265274
mock_request.app.state.settings.upstream_api_urls = []
266275

267-
with pytest.raises(ValueError, match="no apis specified!"):
276+
with pytest.raises(HTTPException) as exc_info:
268277
await collection_search_client.all_collections(request=mock_request)
269278

279+
assert exc_info.value.status_code == 400
280+
assert "No APIs specified" in exc_info.value.detail
281+
270282
@pytest.mark.asyncio
271283
@respx.mock
272284
async def test_all_collections_apis_parameter_with_pagination(
@@ -319,6 +331,110 @@ async def test_all_collections_apis_parameter_with_search_params(
319331
assert len(result["collections"]) == 2
320332
assert result["numberReturned"] == 2
321333

334+
@pytest.mark.asyncio
335+
@respx.mock
336+
async def test_apis_parameter_preserved_in_pagination(
337+
self, collection_search_client, mock_request
338+
):
339+
"""Test that apis parameter is preserved when following next link."""
340+
# Mock response with next link for first page
341+
first_page_response = {
342+
"collections": [
343+
{
344+
"type": "Collection",
345+
"id": "api3-page1-collection-1",
346+
"title": "Collection Page 1",
347+
"description": "First page collection",
348+
"extent": {
349+
"spatial": {"bbox": [[-180, -90, 180, 90]]},
350+
"temporal": {
351+
"interval": [["2020-01-01T00:00:00Z", "2021-01-01T00:00:00Z"]]
352+
},
353+
},
354+
"license": "MIT",
355+
"links": [],
356+
},
357+
],
358+
"links": [
359+
{"rel": "self", "href": "https://api3.example.com/collections"},
360+
{
361+
"rel": "next",
362+
"href": "https://api3.example.com/collections?token=page2",
363+
},
364+
],
365+
}
366+
367+
# Mock response for second page
368+
second_page_response = {
369+
"collections": [
370+
{
371+
"type": "Collection",
372+
"id": "api3-page2-collection-1",
373+
"title": "Collection Page 2",
374+
"description": "Second page collection",
375+
"extent": {
376+
"spatial": {"bbox": [[-180, -90, 180, 90]]},
377+
"temporal": {
378+
"interval": [["2020-01-01T00:00:00Z", "2021-01-01T00:00:00Z"]]
379+
},
380+
},
381+
"license": "MIT",
382+
"links": [],
383+
},
384+
],
385+
"links": [
386+
{
387+
"rel": "self",
388+
"href": "https://api3.example.com/collections?token=page2",
389+
},
390+
{
391+
"rel": "previous",
392+
"href": "https://api3.example.com/collections",
393+
},
394+
],
395+
}
396+
397+
respx.route(url="https://api3.example.com/collections?token=page2").mock(
398+
return_value=Response(200, json=second_page_response)
399+
)
400+
respx.route(url="https://api3.example.com/collections").mock(
401+
return_value=Response(200, json=first_page_response)
402+
)
403+
404+
# First request with custom apis parameter
405+
custom_apis = ["https://api3.example.com"]
406+
first_result = await collection_search_client.all_collections(
407+
request=mock_request, apis=custom_apis
408+
)
409+
410+
# Extract the next link and token from first page
411+
next_link = next(
412+
(link for link in first_result["links"] if link["rel"] == "next"), None
413+
)
414+
assert next_link is not None
415+
assert "token=" in next_link["href"]
416+
417+
# Extract token from the next link
418+
token = next_link["href"].split("token=")[1]
419+
420+
# Decode the token to verify apis are preserved
421+
decoded_token = collection_search_client._decode_token(token)
422+
423+
# The token should contain the current state with the API URL
424+
assert "current" in decoded_token
425+
assert "https://api3.example.com" in decoded_token["current"]
426+
427+
# Now follow the next link (simulate second request)
428+
# This should use the token which should have the apis preserved
429+
second_result = await collection_search_client.all_collections(
430+
request=mock_request, token=token
431+
)
432+
433+
# Should have collections from page 2
434+
assert len(second_result["collections"]) == 1
435+
collection_ids = [c["id"] for c in second_result["collections"]]
436+
assert "api3-page2-collection-1" in collection_ids
437+
322438
@pytest.mark.asyncio
323439
async def test_not_implemented_methods(self, collection_search_client):
324440
"""Test that certain methods raise NotImplementedError."""

0 commit comments

Comments
 (0)