Skip to content

Commit 09a5b33

Browse files
committed
Merge remote-tracking branch 'origin/develop' into s1-ard-julien
2 parents 03338b3 + 6f4f48b commit 09a5b33

File tree

6 files changed

+154
-7
lines changed

6 files changed

+154
-7
lines changed

services/cadip/rs_server_cadip/api/cadip_search.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,7 @@ def process_files_search( # pylint: disable=too-many-locals
622622
)
623623

624624
if kwargs.get("map_to_session", False):
625-
logger.debug(f"Retrieved products from CADIP station {station}: {products}")
625+
# logger.debug(f"Retrieved products from CADIP station {station}: {products}")
626626
return [product.properties for product in products]
627627
cadip_item_collection = create_stac_collection(products, cadip_odata_to_stac_template(), cadip_stac_mapper())
628628
logger.debug(f"Retrieved item collection from CADIP station {station}: {cadip_item_collection}")

services/common/rs_server_common/fastapi_app.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828
from rs_server_common.authentication import oauth2
2929
from rs_server_common.authentication.authentication import authenticate
3030
from rs_server_common.authentication.oauth2 import AUTH_PREFIX
31-
from rs_server_common.middlewares import HandleExceptionsMiddleware
31+
from rs_server_common.middlewares import (
32+
HandleExceptionsMiddleware,
33+
StacLinksTitleMiddleware,
34+
)
3235
from rs_server_common.schemas.health_schema import HealthSchema
3336
from rs_server_common.settings import docs_params
3437
from rs_server_common.utils import init_opentelemetry
@@ -221,7 +224,7 @@ async def patched_landing_page(self, request, **kwargs):
221224

222225
# This middleware allows to have consistant http/https protocol in stac links
223226
app.add_middleware(ProxyHeaderMiddleware)
224-
227+
app.add_middleware(StacLinksTitleMiddleware, title="My STAC Title")
225228
# Add CORS requests from the STAC browser
226229
if settings.CORS_ORIGINS:
227230
app.add_middleware(

services/common/rs_server_common/middlewares.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
# limitations under the License.
1414

1515
"""Common functions for fastapi middlewares"""
16+
import json
1617
import os
1718
import traceback
1819
from collections.abc import Callable
1920
from typing import ParamSpec, TypedDict
21+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
2022

21-
from fastapi import FastAPI, Request, status
23+
from fastapi import FastAPI, Request, Response, status
2224
from fastapi.responses import JSONResponse
2325
from rs_server_common import settings as common_settings
2426
from rs_server_common.authentication import authentication, oauth2
@@ -30,6 +32,24 @@
3032
from starlette.middleware.base import BaseHTTPMiddleware
3133
from starlette.middleware.sessions import SessionMiddleware
3234

35+
REL_TITLES = {
36+
"collection": "Collection",
37+
"item": "Item",
38+
"parent": "Parent Catalog",
39+
"root": "STAC Root Catalog",
40+
"conformance": "Conformance link",
41+
"service-desc": "Service description",
42+
"service-doc": "Service documentation",
43+
"search": "Search endpoint",
44+
"data": "Data link",
45+
"items": "This collection items",
46+
"self": "This collection",
47+
"license": "License description",
48+
"describedby": "Described by link",
49+
"next": "Next link",
50+
"previous": "Previous link",
51+
}
52+
# pylint: disable = too-few-public-methods, too-many-return-statements
3353
logger = Logging.default(__name__)
3454
P = ParamSpec("P")
3555

@@ -111,6 +131,114 @@ def is_bad_request(self, request: Request, e: Exception) -> bool:
111131
)
112132

113133

134+
def get_link_title(link: dict, entity: dict) -> str:
135+
"""
136+
Determine a human-readable STAC link title based on the link relation and context.
137+
"""
138+
rel = link.get("rel")
139+
href = link.get("href", "")
140+
if "title" in link:
141+
# don't overwrite
142+
return link["title"]
143+
match rel:
144+
# --- special cases needing entity context ---
145+
case "collection":
146+
return entity.get("title") or entity.get("id") or REL_TITLES["collection"]
147+
case "item":
148+
return entity.get("title") or entity.get("id") or REL_TITLES["item"]
149+
case "self" if entity.get("type") == "Catalog":
150+
return "STAC Landing Page"
151+
case "self" if href.endswith("/collections"):
152+
return "All Collections"
153+
case "child":
154+
path = urlparse(href).path
155+
collection_id = path.split("/")[-1] if path else "unknown"
156+
return f"All from collection {collection_id}"
157+
# --- all others: just lookup in REL_TITLES ---
158+
case _:
159+
return REL_TITLES.get(rel, href or "Unknown Entity") # type: ignore
160+
161+
162+
def normalize_href(href: str) -> str:
163+
"""Encode query parameters in href to match expected STAC format."""
164+
parsed = urlparse(href)
165+
query = urlencode(parse_qsl(parsed.query), safe="") # encode ":" -> "%3A"
166+
return urlunparse(parsed._replace(query=query))
167+
168+
169+
class StacLinksTitleMiddleware(BaseHTTPMiddleware):
170+
"""Middleware used to update links with title"""
171+
172+
def __init__(self, app: FastAPI, title: str = "Default Title"):
173+
"""
174+
Initialize the middleware.
175+
176+
Args:
177+
app: The FastAPI application instance to attach the middleware to.
178+
title: Default title to use for STAC links if no specific title is provided.
179+
"""
180+
super().__init__(app)
181+
self.title = title
182+
183+
async def dispatch(self, request: Request, call_next):
184+
"""
185+
Intercept and modify outgoing responses to ensure all STAC links have proper titles.
186+
187+
This middleware method:
188+
1. Awaits the response from the next handler.
189+
2. Reads and parses the response body as JSON.
190+
3. Updates the "title" property of each link using `get_link_title`.
191+
4. Rebuilds the response without the original Content-Length header to prevent mismatches.
192+
5. If the response body is not JSON, returns it unchanged.
193+
194+
Args:
195+
request: The incoming FastAPI Request object.
196+
call_next: The next ASGI handler in the middleware chain.
197+
198+
Returns:
199+
A FastAPI Response object with updated STAC link titles.
200+
"""
201+
response = await call_next(request)
202+
203+
body = b""
204+
async for chunk in response.body_iterator:
205+
body += chunk
206+
207+
try:
208+
data = json.loads(body)
209+
210+
if isinstance(data, dict) and "links" in data:
211+
for link in data["links"]:
212+
if isinstance(link, dict):
213+
# normalize href to decode any %xx
214+
if "href" in link:
215+
link["href"] = normalize_href(link["href"])
216+
# update title
217+
link["title"] = get_link_title(link, data)
218+
219+
headers = dict(response.headers)
220+
headers.pop("content-length", None)
221+
222+
response = Response(
223+
content=json.dumps(data, ensure_ascii=False).encode("utf-8"),
224+
status_code=response.status_code,
225+
headers=headers,
226+
media_type="application/json",
227+
)
228+
except Exception: # pylint: disable = broad-exception-caught
229+
headers = dict(response.headers)
230+
headers.pop("content-length", None)
231+
232+
response = Response(
233+
content=body,
234+
status_code=response.status_code,
235+
headers=headers,
236+
media_type=response.headers.get("content-type"),
237+
)
238+
239+
return response
240+
241+
114242
def insert_middleware_at(app: FastAPI, index: int, middleware: Middleware):
115243
"""Insert the given middleware at the specified index in a FastAPI application.
116244

tests/resources/endpoints/adgs_feature.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,25 @@
2929
{
3030
"rel": "collection",
3131
"type": "application/json",
32+
"title": "S1A_OPER_MPL_ORBPRE_20210214T021411_20210221T021411_0001",
3233
"href": "http://testserver/auxip/collections/s2_adgs2_AUX_OBMEMC"
3334
},
3435
{
3536
"rel": "parent",
3637
"type": "application/json",
38+
"title": "Parent Catalog",
3739
"href": "http://testserver/auxip/collections/s2_adgs2_AUX_OBMEMC"
3840
},
3941
{
4042
"rel": "root",
4143
"type": "application/json",
44+
"title": "STAC Root Catalog",
4245
"href": "http://testserver/auxip/"
4346
},
4447
{
4548
"rel": "self",
4649
"type": "application/geo+json",
50+
"title": "This collection",
4751
"href": "http://testserver/auxip/collections/s2_adgs2_AUX_OBMEMC/items/S1A_OPER_MPL_ORBPRE_20210214T021411_20210221T021411_0001"
4852
}
4953
],

tests/resources/endpoints/cadip_feature.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,21 +61,25 @@
6161
{
6262
"rel": "collection",
6363
"type": "application/json",
64+
"title": "S1A_20200105072204051312",
6465
"href": "http://testserver/cadip/collections/cadip_session_by_id"
6566
},
6667
{
6768
"rel": "parent",
6869
"type": "application/json",
70+
"title": "Parent Catalog",
6971
"href": "http://testserver/cadip/collections/cadip_session_by_id"
7072
},
7173
{
7274
"rel": "root",
7375
"type": "application/json",
76+
"title": "STAC Root Catalog",
7477
"href": "http://testserver/cadip/"
7578
},
7679
{
7780
"rel": "self",
7881
"type": "application/geo+json",
82+
"title": "This collection",
7983
"href": "http://testserver/cadip/collections/cadip_session_by_id/items/S1A_20200105072204051312"
8084
}
8185
],

tests/test_search_endpoint.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,10 @@ def test_cadip_feature_collection_mapping(
626626
items = response.json()
627627
# Assert that receive odata response is correctly mapped to stac feature.
628628
assert items["type"] == "FeatureCollection", "Type doesn't match"
629-
assert items["features"] == [cadip_feature], "Features don't match"
629+
assert items["features"][0]["properties"] == cadip_feature["properties"], "properties doesn't match"
630+
assert items["features"][0]["assets"] == cadip_feature["assets"], "assets doesn't match"
631+
assert items["features"][0]["id"] == cadip_feature["id"], "id doesn't match"
632+
630633
assert response.headers.get("Content-Type") == "application/geo+json"
631634

632635
@pytest.mark.unit
@@ -651,7 +654,9 @@ def test_adgs_feature_collection_mapping(
651654
items = response.json()
652655
# Assert that receive odata response is correctly mapped to stac feature.
653656
assert items["type"] == "FeatureCollection", "Type doesn't match"
654-
assert items["features"] == [adgs_feature], "Features don't match"
657+
assert items["features"][0]["properties"] == adgs_feature["properties"], "properties doesn't match"
658+
assert items["features"][0]["assets"] == adgs_feature["assets"], "assets doesn't match"
659+
assert items["features"][0]["id"] == adgs_feature["id"], "id doesn't match"
655660
assert response.headers.get("Content-Type") == "application/geo+json"
656661

657662
@pytest.mark.unit
@@ -1372,7 +1377,6 @@ def test_token_in_url(
13721377
json={"value": []} if is_last else adgs_response_10_items,
13731378
status=200,
13741379
)
1375-
13761380
response = client.get(endpoint + page)
13771381
assert response.status_code == status.HTTP_200_OK
13781382

@@ -1393,6 +1397,7 @@ def test_token_in_url(
13931397
"type": "application/geo+json",
13941398
"method": "GET",
13951399
"href": prev_url,
1400+
"title": "Previous link",
13961401
} in response.json()["links"]
13971402
else:
13981403
# If this is first page (1) check that "previous" link doesn't exist.
@@ -1403,6 +1408,7 @@ def test_token_in_url(
14031408
"type": "application/geo+json",
14041409
"method": "GET",
14051410
"href": next_url,
1411+
"title": "Next link",
14061412
} in response.json()["links"]
14071413

14081414
# Check that "previous" link exists
@@ -1431,6 +1437,7 @@ def setup(self, selector, cadip_response, adgs_response):
14311437
"rel": "self",
14321438
"type": "application/json",
14331439
"href": "http://testserver/cadip/collections/cadip_session_by_id",
1440+
"title": "This collection",
14341441
},
14351442
),
14361443
(
@@ -1443,6 +1450,7 @@ def setup(self, selector, cadip_response, adgs_response):
14431450
"rel": "self",
14441451
"type": "application/json",
14451452
"href": "http://testserver/auxip/collections/s2_adgs2_AUX_OBMEMC",
1453+
"title": "This collection",
14461454
},
14471455
),
14481456
],

0 commit comments

Comments
 (0)