Skip to content

Commit bbca19b

Browse files
authored
Merge pull request #1482 from RS-PYTHON/feat-rspy519/implement-first-last-stacbrowser
feat-rspy519/implement-first-last-stacbrowser
2 parents b07678c + 8473f6a commit bbca19b

File tree

5 files changed

+208
-5
lines changed

5 files changed

+208
-5
lines changed

.github/workflows/publish-binaries.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ jobs:
371371
- name: Copy layer-cleanup.sh script
372372
run: cp -t ./build_context_path ./.github/scripts/layer-cleanup.sh
373373
shell: bash
374-
374+
375375
- name: Copy debug mode dependencies
376376
if: ${{ needs.set-env.outputs.debug_mode }} == true
377377
run: cp -t ./build_context_path ./.github/scripts/git_debug_image.sh
@@ -417,7 +417,7 @@ jobs:
417417
- name: Copy layer-cleanup.sh script
418418
run: cp -t ./build_context_path ./.github/scripts/layer-cleanup.sh
419419
shell: bash
420-
420+
421421
- name: Copy debug mode dependencies
422422
if: ${{ needs.set-env.outputs.debug_mode }} == true
423423
run: cp -t ./build_context_path ./.github/scripts/git_debug_image.sh
@@ -464,7 +464,7 @@ jobs:
464464
- name: Copy layer-cleanup.sh script
465465
run: cp -t ./build_context_path ./.github/scripts/layer-cleanup.sh
466466
shell: bash
467-
467+
468468
- name: Copy debug mode dependencies
469469
if: ${{ needs.set-env.outputs.debug_mode }} == true
470470
run: cp -t ./build_context_path ./.github/scripts/git_debug_image.sh
@@ -511,7 +511,7 @@ jobs:
511511
- name: Copy layer-cleanup.sh script
512512
run: cp -t ./build_context_path ./.github/scripts/layer-cleanup.sh
513513
shell: bash
514-
514+
515515
- id: publish-docker
516516
uses: ./.github/actions/publish-docker
517517
with:

services/catalog/rs_server_catalog/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from rs_server_common.middlewares import (
3636
AuthenticationMiddleware,
3737
HandleExceptionsMiddleware,
38+
PaginationLinksMiddleware,
3839
apply_middlewares,
3940
insert_middleware_after,
4041
)
@@ -126,6 +127,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
126127
if common_settings.CLUSTER_MODE:
127128
app = apply_middlewares(app)
128129

130+
app.add_middleware(PaginationLinksMiddleware)
129131

130132
logger.debug(f"Middlewares: {app.user_middleware}")
131133

services/common/rs_server_common/fastapi_app.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from rs_server_common.authentication.oauth2 import AUTH_PREFIX
3131
from rs_server_common.middlewares import (
3232
HandleExceptionsMiddleware,
33+
PaginationLinksMiddleware,
3334
StacLinksTitleMiddleware,
3435
)
3536
from rs_server_common.schemas.health_schema import HealthSchema
@@ -224,6 +225,10 @@ async def patched_landing_page(self, request, **kwargs):
224225

225226
# This middleware allows to have consistant http/https protocol in stac links
226227
app.add_middleware(ProxyHeaderMiddleware)
228+
229+
# Middleware for implementing first and last buttons in STAC Browser
230+
app.add_middleware(PaginationLinksMiddleware)
231+
227232
app.add_middleware(StacLinksTitleMiddleware, title="My STAC Title")
228233
# Add CORS requests from the STAC browser
229234
if settings.CORS_ORIGINS:

services/common/rs_server_common/middlewares.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
import os
1818
import traceback
1919
from collections.abc import Callable
20-
from typing import ParamSpec, TypedDict
20+
from typing import Any, ParamSpec, TypedDict
2121
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
2222

23+
import brotli
2324
from fastapi import FastAPI, Request, Response, status
2425
from fastapi.responses import JSONResponse
2526
from rs_server_common import settings as common_settings
@@ -131,6 +132,111 @@ def is_bad_request(self, request: Request, e: Exception) -> bool:
131132
)
132133

133134

135+
class PaginationLinksMiddleware(BaseHTTPMiddleware):
136+
"""
137+
Middleware to implement 'first' button's functionality in STAC Browser
138+
"""
139+
140+
async def dispatch(
141+
self,
142+
request: Request,
143+
call_next: Callable,
144+
): # pylint: disable=too-many-branches,too-many-statements
145+
146+
# Only for /search in auxip, prip, cadip
147+
if request.url.path in ["/auxip/search", "/cadip/search", "/prip/search", "/catalog/search"]:
148+
149+
first_link: dict[str, Any] = {
150+
"rel": "first",
151+
"type": "application/geo+json",
152+
"method": request.method,
153+
"href": f"{str(request.base_url).rstrip('/')}{request.url.path}",
154+
"title": "First link",
155+
}
156+
157+
if common_settings.CLUSTER_MODE:
158+
first_link["href"] = f"https://{str(request.base_url.hostname).rstrip('/')}{request.url.path}"
159+
160+
if request.method == "GET":
161+
# parse query params to remove any 'prev' or 'next'
162+
query_dict = dict(request.query_params)
163+
164+
query_dict.pop("token", None)
165+
if "page" in query_dict:
166+
query_dict["page"] = "1"
167+
new_query_string = urlencode(query_dict, doseq=True)
168+
first_link["href"] += f"?{new_query_string}"
169+
170+
elif request.method == "POST":
171+
try:
172+
query = await request.json()
173+
body = {}
174+
175+
for key in ["datetime", "limit"]:
176+
if key in query and query[key] is not None:
177+
body[key] = query[key]
178+
179+
if "token" in query and request.url.path != "/catalog/search":
180+
body["token"] = "page=1" # nosec
181+
182+
first_link["body"] = body
183+
except Exception: # pylint: disable = broad-exception-caught
184+
logger.error(traceback.format_exc())
185+
186+
response = await call_next(request)
187+
188+
encoding = response.headers.get("content-encoding", "")
189+
if encoding == "br":
190+
body_bytes = b"".join([section async for section in response.body_iterator])
191+
response_body = brotli.decompress(body_bytes)
192+
193+
if request.url.path == "/catalog/search":
194+
first_link["auth:refs"] = ["apikey", "openid", "oauth2"]
195+
else:
196+
response_body = b""
197+
async for chunk in response.body_iterator:
198+
response_body += chunk
199+
200+
try:
201+
data = json.loads(response_body)
202+
203+
links = data.get("links", [])
204+
has_prev = any(link.get("rel") == "previous" for link in links)
205+
206+
if has_prev is True:
207+
links.append(first_link)
208+
data["links"] = links
209+
210+
headers = dict(response.headers)
211+
headers.pop("content-length", None)
212+
213+
if encoding == "br":
214+
new_body = brotli.compress(json.dumps(data).encode("utf-8"))
215+
else:
216+
new_body = json.dumps(data).encode("utf-8")
217+
218+
response = Response(
219+
content=new_body,
220+
status_code=response.status_code,
221+
headers=headers,
222+
media_type="application/json",
223+
)
224+
except Exception: # pylint: disable = broad-exception-caught
225+
headers = dict(response.headers)
226+
headers.pop("content-length", None)
227+
228+
response = Response(
229+
content=response_body,
230+
status_code=response.status_code,
231+
headers=headers,
232+
media_type=response.headers.get("content-type"),
233+
)
234+
else:
235+
return await call_next(request)
236+
237+
return response
238+
239+
134240
def get_link_title(link: dict, entity: dict) -> str:
135241
"""
136242
Determine a human-readable STAC link title based on the link relation and context.

tests/test_stac_pagination.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Copyright 2025 CS Group
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Unit tests for the stac pagination."""
16+
17+
import json
18+
19+
import brotli
20+
import httpx
21+
import pytest
22+
from fastapi import FastAPI, Response
23+
from fastapi.testclient import TestClient
24+
from rs_server_common.middlewares import (
25+
HandleExceptionsMiddleware,
26+
PaginationLinksMiddleware,
27+
)
28+
29+
30+
@pytest.mark.anyio
31+
@pytest.mark.parametrize("anyio_backend", ["asyncio"], ids=["asyncio"])
32+
@pytest.mark.parametrize("use_br", [True, False], ids=["br", "no_br"])
33+
async def test_pagination_links_middleware_catalog_authrefs(use_br):
34+
"""
35+
Tests for cluster where there must be set the 'br' encoding and authentication references for /catalog
36+
"""
37+
app = FastAPI()
38+
app.add_middleware(HandleExceptionsMiddleware)
39+
app.add_middleware(PaginationLinksMiddleware)
40+
41+
@app.post("/catalog/search")
42+
def catalog_search():
43+
payload = {"links": [{"rel": "previous"}], "features": []}
44+
raw = json.dumps(payload).encode("utf-8")
45+
if use_br:
46+
body = brotli.compress(raw)
47+
headers = {"content-type": "application/json", "content-encoding": "br"}
48+
return Response(content=body, status_code=200, headers=headers)
49+
return Response(content=raw, status_code=200, media_type="application/json")
50+
51+
transport = httpx.ASGITransport(app=app)
52+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
53+
if use_br:
54+
# checks if the headers were kept
55+
async with client.stream("POST", "/catalog/search", json={"limit": 1}) as resp:
56+
assert resp.status_code == 200
57+
assert resp.headers.get("content-type") == "application/json"
58+
assert resp.headers.get("content-encoding") == "br"
59+
else:
60+
resp = await client.post("/catalog/search", json={"limit": 1})
61+
assert resp.status_code == 200
62+
assert resp.headers.get("content-type") == "application/json"
63+
assert resp.headers.get("content-encoding") is None
64+
65+
# checks if 'first' was added when there is 'previous'
66+
data = resp.json()
67+
assert any(rel.get("rel") == "first" for rel in data.get("links", []))
68+
69+
70+
@pytest.mark.anyio
71+
def test_pagination_links_middleware_handles_malformed_json():
72+
"""
73+
Test case with a malformed JSON
74+
"""
75+
app = FastAPI()
76+
app.add_middleware(HandleExceptionsMiddleware)
77+
app.add_middleware(PaginationLinksMiddleware)
78+
79+
@app.post("/auxip/search")
80+
def auxip_search():
81+
headers = {"content-type": "application/json"}
82+
return Response(content=b"not-json", status_code=200, headers=headers)
83+
84+
client = TestClient(app)
85+
resp = client.post("/auxip/search", json={"limit": 1})
86+
87+
assert resp.status_code == 200
88+
assert resp.headers.get("content-type") == "application/json"
89+
assert resp.headers.get("content-encoding") is None
90+
assert resp.text == "not-json"

0 commit comments

Comments
 (0)