Skip to content

Commit 616f4fa

Browse files
YuriZmytrakovYuri Zmytrakovjonhealy1
authored
Default and max allowed returned values for collections and items (#482)
**Description:** This PR implements returned object limit constraints for collections and items search endpoints which are set in environment variables. The changes ensure consistent behavior between `GET` and `POST` search methods. **PR Checklist:** - [x] Code is formatted and linted (run `pre-commit run --all-files`) - [x] Tests pass (run `make test`) - [x] Documentation has been updated to reflect changes, if applicable - [x] Changes are added to the changelog --------- Co-authored-by: Yuri Zmytrakov <[email protected]> Co-authored-by: Jonathan Healy <[email protected]>
1 parent 0517b4b commit 616f4fa

File tree

8 files changed

+181
-86
lines changed

8 files changed

+181
-86
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1616

1717
### Changed
1818

19+
- Removed ENV_MAX_LIMIT environment variable; maximum limits are now handled by the default global limit environment variable. [#482](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/482)
20+
- Changed the default and maximum pagination limits for collections/items endpoints. [#482](https://github.com/stac-utils/stac-fastapi-elasticsearch-opensearch/pull/482)
21+
1922
### Fixed
2023

2124
## [v6.5.1] - 2025-09-30

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,9 +323,11 @@ You can customize additional settings in your `.env` file:
323323
| `ENABLE_COLLECTIONS_SEARCH` | Enable collection search extensions (sort, fields, free text search, structured filtering, and datetime filtering) on the core `/collections` endpoint. | `true` | Optional |
324324
| `ENABLE_COLLECTIONS_SEARCH_ROUTE` | Enable the custom `/collections-search` endpoint (both GET and POST methods). When disabled, the custom endpoint will not be available, but collection search extensions will still be available on the core `/collections` endpoint if `ENABLE_COLLECTIONS_SEARCH` is true. | `false` | Optional |
325325
| `ENABLE_TRANSACTIONS_EXTENSIONS` | Enables or disables the Transactions and Bulk Transactions API extensions. This is useful for deployments where mutating the catalog via the API should be prevented. If set to `true`, the POST `/collections` route for search will be unavailable in the API. | `true` | Optional |
326-
| `STAC_ITEM_LIMIT` | Sets the environment variable for result limiting to SFEOS for the number of returned items and STAC collections. | `10` | Optional |
326+
| `STAC_GLOBAL_COLLECTION_MAX_LIMIT` | Configures the maximum number of STAC collections that can be returned in a single search request. | N/A | Optional |
327+
| `STAC_DEFAULT_COLLECTION_LIMIT` | Configures the default number of STAC collections returned when no limit parameter is specified in the request. | `300` | Optional |
328+
| `STAC_GLOBAL_ITEM_MAX_LIMIT` | Configures the maximum number of STAC items that can be returned in a single search request. | N/A | Optional |
329+
| `STAC_DEFAULT_ITEM_LIMIT` | Configures the default number of STAC items returned when no limit parameter is specified in the request. | `10` | Optional |
327330
| `STAC_INDEX_ASSETS` | Controls if Assets are indexed when added to Elasticsearch/Opensearch. This allows asset fields to be included in search queries. | `false` | Optional |
328-
| `ENV_MAX_LIMIT` | Configures the environment variable in SFEOS to override the default `MAX_LIMIT`, which controls the limit parameter for returned items and STAC collections. | `10,000` | Optional |
329331
| `USE_DATETIME` | Configures the datetime search behavior in SFEOS. When enabled, searches both datetime field and falls back to start_datetime/end_datetime range for items with null datetime. When disabled, searches only by start_datetime/end_datetime range. | `true` | Optional |
330332

331333
> [!NOTE]

stac_fastapi/core/stac_fastapi/core/core.py

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -274,34 +274,31 @@ async def all_collections(
274274
"""
275275
base_url = str(request.base_url)
276276

277-
# Get the global limit from environment variable
278-
global_limit = None
279-
env_limit = os.getenv("STAC_ITEM_LIMIT")
280-
if env_limit:
281-
try:
282-
global_limit = int(env_limit)
283-
except ValueError:
284-
# Handle invalid integer in environment variable
285-
pass
286-
287-
# Apply global limit if it exists
288-
if global_limit is not None:
289-
# If a limit was provided, use the smaller of the two
290-
if limit is not None:
291-
limit = min(limit, global_limit)
292-
else:
293-
limit = global_limit
277+
global_max_limit = (
278+
int(os.getenv("STAC_GLOBAL_COLLECTION_MAX_LIMIT"))
279+
if os.getenv("STAC_GLOBAL_COLLECTION_MAX_LIMIT")
280+
else None
281+
)
282+
query_limit = request.query_params.get("limit")
283+
default_limit = int(os.getenv("STAC_DEFAULT_COLLECTION_LIMIT", 300))
284+
285+
body_limit = None
286+
try:
287+
if request.method == "POST" and request.body():
288+
body_data = await request.json()
289+
body_limit = body_data.get("limit")
290+
except Exception:
291+
pass
292+
293+
if body_limit is not None:
294+
limit = int(body_limit)
295+
elif query_limit:
296+
limit = int(query_limit)
294297
else:
295-
# No global limit, use provided limit or default
296-
if limit is None:
297-
query_limit = request.query_params.get("limit")
298-
if query_limit:
299-
try:
300-
limit = int(query_limit)
301-
except ValueError:
302-
limit = 10
303-
else:
304-
limit = 10
298+
limit = default_limit
299+
300+
if global_max_limit is not None:
301+
limit = min(limit, global_max_limit)
305302

306303
# Get token from query params only if not already provided (for GET requests)
307304
if token is None:
@@ -575,7 +572,7 @@ async def item_collection(
575572
request (Request): FastAPI Request object.
576573
bbox (Optional[BBox]): Optional bounding box filter.
577574
datetime (Optional[str]): Optional datetime or interval filter.
578-
limit (Optional[int]): Optional page size. Defaults to env ``STAC_ITEM_LIMIT`` when unset.
575+
limit (Optional[int]): Optional page size. Defaults to env `STAC_DEFAULT_ITEM_LIMIT` when unset.
579576
sortby (Optional[str]): Optional sort specification. Accepts repeated values
580577
like ``sortby=-properties.datetime`` or ``sortby=+id``. Bare fields (e.g. ``sortby=id``)
581578
imply ascending order.
@@ -666,15 +663,12 @@ async def get_search(
666663
q (Optional[List[str]]): Free text query to filter the results.
667664
intersects (Optional[str]): GeoJSON geometry to search in.
668665
kwargs: Additional parameters to be passed to the API.
669-
670666
Returns:
671667
ItemCollection: Collection of `Item` objects representing the search results.
672668
673669
Raises:
674670
HTTPException: If any error occurs while searching the catalog.
675671
"""
676-
limit = int(request.query_params.get("limit", os.getenv("STAC_ITEM_LIMIT", 10)))
677-
678672
base_args = {
679673
"collections": collections,
680674
"ids": ids,
@@ -749,6 +743,34 @@ async def post_search(
749743
Raises:
750744
HTTPException: If there is an error with the cql2_json filter.
751745
"""
746+
global_max_limit = (
747+
int(os.getenv("STAC_GLOBAL_ITEM_MAX_LIMIT"))
748+
if os.getenv("STAC_GLOBAL_ITEM_MAX_LIMIT")
749+
else None
750+
)
751+
query_limit = request.query_params.get("limit")
752+
default_limit = int(os.getenv("STAC_DEFAULT_ITEM_LIMIT", 10))
753+
754+
body_limit = None
755+
try:
756+
if request.method == "POST" and request.body():
757+
body_data = await request.json()
758+
body_limit = body_data.get("limit")
759+
except Exception:
760+
pass
761+
762+
if body_limit is not None:
763+
limit = int(body_limit)
764+
elif query_limit:
765+
limit = int(query_limit)
766+
else:
767+
limit = default_limit
768+
769+
if global_max_limit:
770+
limit = min(limit, global_max_limit)
771+
772+
search_request.limit = limit
773+
752774
base_url = str(request.base_url)
753775

754776
search = self.database.make_search()
@@ -825,7 +847,6 @@ async def post_search(
825847
if hasattr(search_request, "sortby") and getattr(search_request, "sortby"):
826848
sort = self.database.populate_sort(getattr(search_request, "sortby"))
827849

828-
limit = 10
829850
if search_request.limit:
830851
limit = search_request.limit
831852

stac_fastapi/core/stac_fastapi/core/utilities.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,7 @@
1010

1111
from stac_fastapi.types.stac import Item
1212

13-
14-
def get_max_limit():
15-
"""
16-
Retrieve a MAX_LIMIT value from an environment variable.
17-
18-
Returns:
19-
int: The int value parsed from the environment variable.
20-
"""
21-
return int(os.getenv("ENV_MAX_LIMIT", 10000))
13+
MAX_LIMIT = 10000
2214

2315

2416
def get_bool_env(name: str, default: Union[bool, str] = False) -> bool:

stac_fastapi/elasticsearch/stac_fastapi/elasticsearch/database_logic.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from stac_fastapi.core.base_database_logic import BaseDatabaseLogic
1919
from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
20-
from stac_fastapi.core.utilities import bbox2polygon, get_bool_env, get_max_limit
20+
from stac_fastapi.core.utilities import MAX_LIMIT, bbox2polygon, get_bool_env
2121
from stac_fastapi.elasticsearch.config import AsyncElasticsearchSettings
2222
from stac_fastapi.elasticsearch.config import (
2323
ElasticsearchSettings as SyncElasticsearchSettings,
@@ -762,7 +762,7 @@ async def execute_search(
762762
index_param = ITEM_INDICES
763763
query = add_collections_to_body(collection_ids, query)
764764

765-
max_result_window = get_max_limit()
765+
max_result_window = MAX_LIMIT
766766

767767
size_limit = min(limit + 1, max_result_window)
768768

stac_fastapi/opensearch/stac_fastapi/opensearch/database_logic.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from stac_fastapi.core.base_database_logic import BaseDatabaseLogic
1919
from stac_fastapi.core.serializers import CollectionSerializer, ItemSerializer
20-
from stac_fastapi.core.utilities import bbox2polygon, get_bool_env, get_max_limit
20+
from stac_fastapi.core.utilities import MAX_LIMIT, bbox2polygon, get_bool_env
2121
from stac_fastapi.extensions.core.transaction.request import (
2222
PartialCollection,
2323
PartialItem,
@@ -775,7 +775,7 @@ async def execute_search(
775775

776776
search_body["sort"] = sort if sort else DEFAULT_SORT
777777

778-
max_result_window = get_max_limit()
778+
max_result_window = MAX_LIMIT
779779

780780
size_limit = min(limit + 1, max_result_window)
781781

stac_fastapi/tests/api/test_api.py

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,70 +1475,102 @@ def create_items(date_prefix: str, start_day: int, count: int) -> dict:
14751475

14761476

14771477
@pytest.mark.asyncio
1478-
async def test_collections_limit_env_variable(app_client, txn_client, load_test_data):
1479-
limit = "5"
1480-
os.environ["STAC_ITEM_LIMIT"] = limit
1481-
item = load_test_data("test_collection.json")
1478+
async def test_global_collection_max_limit_set(app_client, txn_client, load_test_data):
1479+
"""Test with global collection max limit set, expect cap the limit"""
1480+
os.environ["STAC_GLOBAL_COLLECTION_MAX_LIMIT"] = "5"
14821481

14831482
for i in range(10):
1484-
test_collection = item.copy()
1485-
test_collection["id"] = f"test-collection-env-{i}"
1486-
test_collection["title"] = f"Test Collection Env {i}"
1483+
test_collection = load_test_data("test_collection.json")
1484+
test_collection_id = f"test-collection-global-{i}"
1485+
test_collection["id"] = test_collection_id
1486+
await create_collection(txn_client, test_collection)
1487+
1488+
resp = await app_client.get("/collections?limit=10")
1489+
assert resp.status_code == 200
1490+
resp_json = resp.json()
1491+
assert len(resp_json["collections"]) == 5
1492+
1493+
del os.environ["STAC_GLOBAL_COLLECTION_MAX_LIMIT"]
1494+
1495+
1496+
@pytest.mark.asyncio
1497+
async def test_default_collection_limit(app_client, txn_client, load_test_data):
1498+
"""Test default collection limit set, should use default when no limit provided"""
1499+
os.environ["STAC_DEFAULT_COLLECTION_LIMIT"] = "5"
1500+
1501+
for i in range(10):
1502+
test_collection = load_test_data("test_collection.json")
1503+
test_collection_id = f"test-collection-default-{i}"
1504+
test_collection["id"] = test_collection_id
14871505
await create_collection(txn_client, test_collection)
14881506

14891507
resp = await app_client.get("/collections")
14901508
assert resp.status_code == 200
14911509
resp_json = resp.json()
1492-
assert int(limit) == len(resp_json["collections"])
1510+
assert len(resp_json["collections"]) == 5
1511+
1512+
del os.environ["STAC_DEFAULT_COLLECTION_LIMIT"]
14931513

14941514

14951515
@pytest.mark.asyncio
1496-
async def test_search_collection_limit_env_variable(
1497-
app_client, txn_client, load_test_data
1498-
):
1499-
limit = "5"
1500-
os.environ["STAC_ITEM_LIMIT"] = limit
1516+
async def test_no_global_item_max_limit_set(app_client, txn_client, load_test_data):
1517+
"""Test with no global max limit set for items"""
1518+
1519+
if "STAC_GLOBAL_ITEM_MAX_LIMIT" in os.environ:
1520+
del os.environ["STAC_GLOBAL_ITEM_MAX_LIMIT"]
15011521

15021522
test_collection = load_test_data("test_collection.json")
1503-
test_collection_id = "test-collection-search-limit"
1523+
test_collection_id = "test-collection-no-global-limit"
15041524
test_collection["id"] = test_collection_id
15051525
await create_collection(txn_client, test_collection)
15061526

15071527
item = load_test_data("test_item.json")
15081528
item["collection"] = test_collection_id
15091529

1510-
for i in range(10):
1530+
for i in range(20):
15111531
test_item = item.copy()
1512-
test_item["id"] = f"test-item-search-{i}"
1532+
test_item["id"] = f"test-item-{i}"
15131533
await create_item(txn_client, test_item)
15141534

1515-
resp = await app_client.get("/search", params={"collections": [test_collection_id]})
1535+
resp = await app_client.get(f"/collections/{test_collection_id}/items?limit=20")
1536+
assert resp.status_code == 200
1537+
resp_json = resp.json()
1538+
assert len(resp_json["features"]) == 20
1539+
1540+
resp = await app_client.get(f"/search?collections={test_collection_id}&limit=20")
15161541
assert resp.status_code == 200
15171542
resp_json = resp.json()
1518-
assert int(limit) == len(resp_json["features"])
1543+
assert len(resp_json["features"]) == 20
15191544

1545+
resp = await app_client.post(
1546+
"/search", json={"collections": [test_collection_id], "limit": 20}
1547+
)
1548+
assert resp.status_code == 200
1549+
resp_json = resp.json()
1550+
assert len(resp_json["features"]) == 20
15201551

1521-
async def test_search_max_item_limit(
1522-
app_client, load_test_data, txn_client, monkeypatch
1523-
):
1524-
limit = "10"
1525-
monkeypatch.setenv("ENV_MAX_LIMIT", limit)
15261552

1527-
test_collection = load_test_data("test_collection.json")
1528-
await create_collection(txn_client, test_collection)
1553+
@pytest.mark.asyncio
1554+
async def test_no_global_collection_max_limit_set(
1555+
app_client, txn_client, load_test_data
1556+
):
1557+
"""Test with no global max limit set for collections"""
15291558

1530-
item = load_test_data("test_item.json")
1559+
if "STAC_GLOBAL_COLLECTION_MAX_LIMIT" in os.environ:
1560+
del os.environ["STAC_GLOBAL_COLLECTION_MAX_LIMIT"]
15311561

1562+
test_collections = []
15321563
for i in range(20):
1533-
test_item = item.copy()
1534-
test_item["id"] = f"test-item-collection-{i}"
1535-
await create_item(txn_client, test_item)
1536-
1537-
resp = await app_client.get("/search", params={"limit": 20})
1564+
test_collection = load_test_data("test_collection.json")
1565+
test_collection_id = f"test-collection-no-global-limit-{i}"
1566+
test_collection["id"] = test_collection_id
1567+
await create_collection(txn_client, test_collection)
1568+
test_collections.append(test_collection_id)
15381569

1570+
resp = await app_client.get("/collections?limit=20")
15391571
assert resp.status_code == 200
15401572
resp_json = resp.json()
1541-
assert int(limit) == len(resp_json["features"])
1573+
assert len(resp_json["collections"]) == 20
15421574

15431575

15441576
@pytest.mark.asyncio

0 commit comments

Comments
 (0)