Skip to content

Commit 288c3c6

Browse files
authored
feat: paginate collection (#26)
To address performance and scalability issues with the `/collections` endpoint under high collection volume, I integrated pagination support using the `CollectionPaginationExtension` from `STAC FastAPI`. The `all_collections()` method now supports `limit` (default: 10) and `offset` (default: 0) parameters, ensuring the API no longer returns the full collection list by default. The response includes pagination metadata with `first`, `next`, and `previous` links to facilitate navigation.
1 parent e013217 commit 288c3c6

7 files changed

Lines changed: 409 additions & 147 deletions

File tree

stac_fastapi/eodag/app.py

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,16 @@
3737
create_post_request_model,
3838
create_request_model,
3939
)
40-
from stac_fastapi.extensions.core import FilterExtension, QueryExtension, SortExtension
40+
from stac_fastapi.extensions.core import (
41+
CollectionSearchExtension,
42+
FilterExtension,
43+
FreeTextExtension,
44+
QueryExtension,
45+
SortExtension,
46+
)
47+
from stac_fastapi.extensions.core.free_text import FreeTextConformanceClasses
48+
from stac_fastapi.extensions.core.query import QueryConformanceClasses
49+
from stac_fastapi.extensions.core.sort import SortConformanceClasses
4150

4251
from stac_fastapi.eodag.config import get_settings
4352
from stac_fastapi.eodag.core import EodagCoreClient
@@ -47,10 +56,10 @@
4756
BaseCollectionOrderClient,
4857
CollectionOrderExtension,
4958
)
50-
from stac_fastapi.eodag.extensions.collection_search import CollectionSearchExtension
5159
from stac_fastapi.eodag.extensions.data_download import DataDownload
5260
from stac_fastapi.eodag.extensions.ecmwf import EcmwfExtension
5361
from stac_fastapi.eodag.extensions.filter import FiltersClient
62+
from stac_fastapi.eodag.extensions.offset_pagination import OffsetPaginationExtension
5463
from stac_fastapi.eodag.extensions.pagination import PaginationExtension
5564
from stac_fastapi.eodag.extensions.stac import (
5665
ElectroOpticalExtension,
@@ -92,22 +101,56 @@
92101
EcmwfExtension(),
93102
]
94103
)
95-
extensions_map = {
96-
"collection-search": CollectionSearchExtension(),
97-
"pagination": PaginationExtension(),
98-
"filter": FilterExtension(client=FiltersClient(stac_metadata_model=stac_metadata_model)),
104+
105+
# search extensions
106+
search_extensions_map = {
99107
"query": QueryExtension(),
100108
"sort": SortExtension(),
101-
"collection-order": CollectionOrderExtension(
102-
client=BaseCollectionOrderClient(stac_metadata_model=stac_metadata_model)
103-
),
104-
"data-download": DataDownload(),
109+
"filter": FilterExtension(client=FiltersClient(stac_metadata_model=stac_metadata_model)),
110+
"pagination": PaginationExtension(),
111+
}
112+
113+
# collection_search extensions
114+
cs_extensions_map = {
115+
"query": QueryExtension(conformance_classes=[QueryConformanceClasses.COLLECTIONS]),
116+
"offset-pagination": OffsetPaginationExtension(),
117+
"collection-search": CollectionSearchExtension(),
118+
"free-text": FreeTextExtension(conformance_classes=[FreeTextConformanceClasses.COLLECTIONS]),
105119
}
106120

107-
if enabled_extensions := os.getenv("ENABLED_EXTENSIONS"):
108-
extensions = [extensions_map[extension_name] for extension_name in enabled_extensions.split(",")]
109-
else:
110-
extensions = list(extensions_map.values())
121+
# item_collection extensions
122+
itm_col_extensions_map = {
123+
"pagination": PaginationExtension(),
124+
"sort": SortExtension(conformance_classes=[SortConformanceClasses.ITEMS]),
125+
}
126+
127+
all_extensions = {
128+
**search_extensions_map,
129+
**cs_extensions_map,
130+
**itm_col_extensions_map,
131+
**{
132+
"data-download": DataDownload(),
133+
"collection-order": CollectionOrderExtension(
134+
client=BaseCollectionOrderClient(stac_metadata_model=stac_metadata_model)
135+
),
136+
},
137+
}
138+
139+
140+
def get_enabled_extensions(specif_extensions: dict):
141+
"""
142+
Retrieve the list of enabled extensions based on the environment variable `ENABLED_EXTENSIONS`.
143+
144+
:param specif_extensions: A dictionary mapping extension names to their corresponding objects.
145+
:returns: A list of enabled extension objects. If `ENABLED_EXTENSIONS` is not set, all extensions
146+
from `specif_extensions` are returned.
147+
"""
148+
if enabled := os.getenv("ENABLED_EXTENSIONS"):
149+
return [specif_extensions[name] for name in enabled.split(",") if name in specif_extensions]
150+
return list(specif_extensions.values())
151+
152+
153+
extensions = get_enabled_extensions(all_extensions)
111154

112155
for e in extensions:
113156
if isinstance(e, CollectionOrderExtension):
@@ -142,22 +185,23 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
142185
add_exception_handlers(app)
143186
app.add_middleware(RequestIDMiddleware)
144187

188+
search_extensions = get_enabled_extensions(search_extensions_map)
145189

146-
search_post_model = create_post_request_model(extensions)
147-
search_get_model = create_get_request_model(extensions)
190+
search_post_model = create_post_request_model(search_extensions)
191+
search_get_model = create_get_request_model(search_extensions)
148192

149193

150194
collections_model = create_request_model(
151195
"CollectionsRequest",
152196
base_model=EmptyRequest,
153-
extensions=[e for e in extensions if isinstance(e, CollectionSearchExtension)],
197+
extensions=get_enabled_extensions(cs_extensions_map),
154198
request_type="GET",
155199
)
156200

157201
item_collection_model = create_request_model(
158202
"ItemsRequest",
159203
base_model=ItemCollectionUri,
160-
extensions=[e for e in extensions if isinstance(e, PaginationExtension) or isinstance(e, SortExtension)],
204+
extensions=get_enabled_extensions(itm_col_extensions_map),
161205
request_type="GET",
162206
)
163207

stac_fastapi/eodag/core.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import logging
2424
from datetime import datetime
2525
from typing import TYPE_CHECKING, Any, cast
26-
from urllib.parse import unquote_plus, urljoin
26+
from urllib.parse import unquote_plus
2727

2828
import attr
2929
import orjson
@@ -53,6 +53,7 @@
5353
from stac_fastapi.eodag.errors import NoMatchingProductType, ResponseSearchError
5454
from stac_fastapi.eodag.models.links import (
5555
CollectionLinks,
56+
CollectionSearchPagingLinks,
5657
ItemCollectionLinks,
5758
PagingLinks,
5859
)
@@ -221,7 +222,8 @@ async def all_collections(
221222
request: Request,
222223
bbox: Optional[list[NumType]] = None,
223224
datetime: Optional[DateTimeType] = None,
224-
limit: Optional[int] = None,
225+
limit: Optional[int] = 10,
226+
offset: Optional[int] = 0,
225227
q: Optional[str] = None,
226228
query: Optional[str] = None,
227229
) -> Collections:
@@ -232,13 +234,18 @@ async def all_collections(
232234
:param bbox: Bounding box to filter the collections.
233235
:param datetime: Date and time range to filter the collections.
234236
:param limit: Maximum number of collections to return.
237+
:param offset: Starting position from which to return collections.
235238
:param q: Query string to filter the collections.
236239
:param query: Query string to filter collections.
237240
:returns: All collections.
238241
:raises HTTPException: If the unsupported bbox parameter is provided.
239242
"""
240243
base_url = get_base_url(request)
241244

245+
next_link: Optional[dict[str, Any]] = None
246+
prev_link: Optional[dict[str, Any]] = None
247+
first_link: dict[str, Any] = {"body": {"limit": limit, "offset": 0}}
248+
242249
# get provider filter
243250
provider = None
244251
if query:
@@ -278,21 +285,41 @@ async def all_collections(
278285
).intersection(bbox_geom)
279286
]
280287

288+
limit = limit if limit is not None else 10
289+
offset = offset if offset is not None else 0
290+
291+
paged_collections = collections[offset : offset + limit]
292+
293+
total = len(collections)
294+
295+
if offset + limit < total:
296+
next_link = {"body": {"limit": limit, "offset": offset + limit}}
297+
298+
if offset > 0:
299+
prev_link = {"body": {"limit": limit, "offset": max(0, offset - limit)}}
300+
281301
links = [
282-
{
283-
"rel": Relations.self.value,
284-
"type": MimeTypes.json,
285-
"href": urljoin(base_url, "collections"),
286-
"title": "Collections",
287-
},
288302
{
289303
"rel": Relations.root,
290304
"type": MimeTypes.json,
291305
"href": base_url,
292306
"title": get_settings().stac_fastapi_title,
293307
},
294308
]
295-
return Collections(collections=collections[:limit] or [], links=links)
309+
extension_names = [type(ext).__name__ for ext in self.extensions]
310+
311+
paging_links = CollectionSearchPagingLinks(
312+
request=request, next=next_link, prev=prev_link, first=first_link
313+
).get_links(extensions=extension_names)
314+
315+
links.extend(paging_links)
316+
317+
return Collections(
318+
collections=paged_collections or [],
319+
links=links,
320+
numberMatched=total,
321+
numberReturned=len(paged_collections),
322+
)
296323

297324
async def get_collection(self, collection_id: str, request: Request, **kwargs: Any) -> Collection:
298325
"""

stac_fastapi/eodag/extensions/collection_search.py

Lines changed: 0 additions & 92 deletions
This file was deleted.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2025, CS GROUP - France, https://www.cs-soprasteria.com
3+
#
4+
# This file is part of stac-fastapi-eodag project
5+
# https://www.github.com/CS-SI/stac-fastapi-eodag
6+
#
7+
# Licensed under the Apache License, Version 2.0 (the "License");
8+
# you may not use this file except in compliance with the License.
9+
# You may obtain a copy of the License at
10+
#
11+
# http://www.apache.org/licenses/LICENSE-2.0
12+
#
13+
# Unless required by applicable law or agreed to in writing, software
14+
# distributed under the License is distributed on an "AS IS" BASIS,
15+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
# See the License for the specific language governing permissions and
17+
# limitations under the License.
18+
"""Offset pagination extension."""
19+
20+
from typing import Annotated
21+
22+
import attr
23+
from fastapi import Query
24+
from pydantic import NonNegativeInt
25+
from stac_fastapi.extensions.core.pagination import OffsetPaginationExtension as BaseOffsetPaginationExtension
26+
from stac_fastapi.types.search import APIRequest
27+
28+
29+
@attr.s
30+
class GETOffsetPagination(APIRequest):
31+
"""Offset pagination for GET requests."""
32+
33+
offset: Annotated[NonNegativeInt, Query()] = attr.ib(default=0)
34+
35+
36+
@attr.s
37+
class OffsetPaginationExtension(BaseOffsetPaginationExtension):
38+
"""Customized Offset Pagination.
39+
40+
This extension overrides the default GET pagination class to apply a modified
41+
offset parameter definition. Specifically, we enforce a non-negative integer
42+
constraint and potentially adjust default values.
43+
44+
A new GET class (`GETOffsetPagination`) is defined to include these changes,
45+
so they are correctly reflected in both the request validation and the
46+
generated OpenAPI schema.
47+
"""
48+
49+
GET = GETOffsetPagination

0 commit comments

Comments
 (0)