diff --git a/app/stac_api/pagination.py b/app/stac_api/pagination.py index 84be48be..8a7fe0d1 100644 --- a/app/stac_api/pagination.py +++ b/app/stac_api/pagination.py @@ -171,7 +171,25 @@ def get_page_size(self, request): ) -class GetPostCursorPagination(CursorPagination): +class SortedCursorPagination(CursorPagination): + '''Pagination class that supports sorting via the sortby parameter''' + ordering = 'name' + + def get_ordering(self, request, queryset, view): + '''Get the ordering from the sortby query parameter if present.''' + sort_fields = getattr(view, 'sort_fields', None) + if not sort_fields: + return super().get_ordering(request, queryset, view) + + ordering = [] + for model_field, is_ascending in sort_fields: + if not is_ascending: + model_field = "-" + model_field + ordering.append(model_field) + return ordering + + +class GetPostCursorPagination(SortedCursorPagination): '''Pagination to be used for the GET/POST /search endpoint where the pagination is either in query or in payload depending on the method. ''' diff --git a/app/stac_api/utils.py b/app/stac_api/utils.py index 718a2451..d1a3588e 100644 --- a/app/stac_api/utils.py +++ b/app/stac_api/utils.py @@ -20,6 +20,7 @@ from django.conf import settings from django.contrib.gis.geos import Point from django.contrib.gis.geos import Polygon +from django.core.exceptions import ValidationError from django.core.management import call_command from django.core.management.base import BaseCommand from django.core.management.base import CommandParser @@ -629,3 +630,125 @@ def parse_cache_control_header(cache_control_header): parts = [i.strip() for i in cache_control_header.split(',')] args = {i.split('=')[0].strip(): i.split('=')[-1].strip() for i in parts if i} return {k: True if v == k else v for k, v in args.items()} + + +# Maps sortby parameter values to Django model field +SORTABLE_FIELDS = { + 'id': 'name', + 'collection': 'collection__name', + 'datetime': 'properties_datetime', + 'title': 'properties_title', + 'created': 'created', + 'updated': 'updated', +} + + +def parse_sortby_get(sortby_param, sortable_fields): + '''Parse and validate the GET (string) format of the sortby parameter. + + The sortby parameter is a comma-separated string of fields prefixed with '+' + (ascending, default) or '-' (descending). + + Example: "-created,title". + + Args: + sortby_param: string + Comma-separated list of fields prefixed with '+' or '-' + sortable_fields: dict + Mapping of allowed sortby field names to Django model fields + + Returns: + list: List of tuples (field, direction) where `direction` is True for ascending, + False for descending and `field` is the Django model field corresponding to + the given input field. Returns empty list if sortby_param is None or empty. + + Raises: + ValidationError: If an invalid field is specified + ''' + if not sortby_param: + return [] + + sort_fields = [] + for sort_field in sortby_param.split(','): + sort_field = sort_field.strip() + if not sort_field: + continue + + if sort_field.startswith('-'): + is_ascending = False + field_name = sort_field[1:] + elif sort_field.startswith('+'): + is_ascending = True + field_name = sort_field[1:] + else: + is_ascending = True + field_name = sort_field + + internal_field = _resolve_sort_field(field_name, sortable_fields) + sort_fields.append((internal_field, is_ascending)) + return sort_fields + + +def parse_sortby_post(sortby_param, sortable_fields): + '''Parse the POST (list of objects) format of the sortby parameter. + + The sortby parameter in the request body is a list of objects with a 'field' + and a 'direction' ('asc' or 'desc') property. + + Example: [{"field": "created", "direction": "desc"}]. + + Args: + sortby_param: list + List of {"field": ..., "direction": ...} objects + sortable_fields: dict + Mapping of allowed sortby field names to Django model fields + + Returns: + list: List of tuples (field, direction) where `direction` is True for ascending, + False for descending and `field` is the Django model field corresponding to + the given input field. Returns empty list if sortby_param is None or empty. + + Raises: + ValidationError: If an invalid field or direction is specified + ''' + sort_fields = [] + for sort_item in sortby_param: + if not isinstance(sort_item, dict) or 'field' not in sort_item: + raise ValidationError("Each sortby entry must be an object with a 'field' property") + field_name = sort_item['field'] + direction = str(sort_item.get('direction', 'asc')).lower() + if direction == 'asc': + is_ascending = True + elif direction == 'desc': + is_ascending = False + else: + raise ValidationError( + f"Invalid sort direction '{direction}'. " + f"Allowed values are: 'asc', 'desc'" + ) + internal_field = _resolve_sort_field(field_name, sortable_fields) + sort_fields.append((internal_field, is_ascending)) + return sort_fields + + +def _resolve_sort_field(field_name, sortable_fields): + '''Resolve a sortby field name to its Django model field, validating it. + + Args: + field_name: string + The field name provided in the sortby parameter + sortable_fields: dict + Mapping of allowed sortby field names to Django model fields + + Returns: + string: The Django model field corresponding to the given field name + + Raises: + ValidationError: If the field name is not allowed for sorting + ''' + if field_name not in sortable_fields: + raise ValidationError( + f"Invalid sort field '{field_name}'. " + f"Allowed fields are: {', '.join(sortable_fields.keys())}" + ) + return sortable_fields[field_name] diff --git a/app/stac_api/validators_serializer.py b/app/stac_api/validators_serializer.py index 67688f61..0d045829 100644 --- a/app/stac_api/validators_serializer.py +++ b/app/stac_api/validators_serializer.py @@ -3,15 +3,19 @@ from django.contrib.gis.gdal.error import GDALException from django.contrib.gis.geos import GEOSGeometry +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import IntegrityError from django.db import transaction from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from stac_api.utils import SORTABLE_FIELDS from stac_api.utils import fromisoformat from stac_api.utils import geometry_from_bbox from stac_api.utils import harmonize_post_get_for_search +from stac_api.utils import parse_sortby_get +from stac_api.utils import parse_sortby_post from stac_api.validators import validate_geometry logger = logging.getLogger(__name__) @@ -72,7 +76,7 @@ def __init__(self): self.queriable_date_fields = ['created', 'updated'] self.queriable_str_fields = ['title', 'cf:standard_name', 'unit'] - def validate(self, request): + def validate(self, request): # pylint: disable=too-many-branches '''Validates the request of the search endpoint This function validates the request of the search endpoint. As a simplification the @@ -93,6 +97,15 @@ def validate(self, request): if request.method == "POST": self.validate_query_parameters_post_search(query_param) + try: + if 'sortby' in query_param: + if request.method == "POST": + request.sort_fields = parse_sortby_post(query_param['sortby'], SORTABLE_FIELDS) + else: + request.sort_fields = parse_sortby_get(query_param['sortby'], SORTABLE_FIELDS) + except DjangoValidationError as error: + self.errors['sortby'] = _(error.message) + if 'bbox' in query_param: self.validate_bbox(query_param['bbox']) if 'datetime' in query_param: @@ -366,6 +379,7 @@ def validate_query_parameters_post_search(self, query_param): "limit", "cursor", "query", + "sortby", "forecast:reference_datetime", "forecast:horizon", "forecast:duration", diff --git a/app/stac_api/views/general.py b/app/stac_api/views/general.py index 84a30700..e0944163 100644 --- a/app/stac_api/views/general.py +++ b/app/stac_api/views/general.py @@ -69,6 +69,8 @@ class SearchList(generics.GenericAPIView, mixins.ListModelMixin): # search overall collections and that the item name is only unique within a collection # we must use the pk as ordering attribute, otherwise the cursor pagination will not work ordering = ['pk'] + # Resolved sortby fields. None means no sortby was provided and the default ordering is used. + sort_fields = None # pylint: disable=too-many-branches def get_queryset(self): @@ -121,6 +123,7 @@ def list(self, request, *args, **kwargs): validate_search_request = ValidateSearchRequest() validate_search_request.validate(request) # validate the search request + self.sort_fields = getattr(request, 'sort_fields', None) queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) diff --git a/app/stac_api/views/item.py b/app/stac_api/views/item.py index 6d0f8cc9..7d2f6a5f 100644 --- a/app/stac_api/views/item.py +++ b/app/stac_api/views/item.py @@ -18,12 +18,15 @@ from stac_api.models.collection import Collection from stac_api.models.item import Asset from stac_api.models.item import Item +from stac_api.pagination import SortedCursorPagination from stac_api.serializers.item import AssetSerializer from stac_api.serializers.item import ItemDetailSerializer from stac_api.serializers.item import ItemListSerializer from stac_api.serializers.item import ItemSerializer from stac_api.serializers.utils import get_relation_links +from stac_api.utils import SORTABLE_FIELDS from stac_api.utils import get_asset_path +from stac_api.utils import parse_sortby_get from stac_api.validators_view import validate_collection from stac_api.validators_view import validate_item from stac_api.validators_view import validate_renaming @@ -89,8 +92,10 @@ def get_asset_etag(request, *args, **kwargs): class ItemsList(generics.GenericAPIView): serializer_class = ItemSerializer - ordering = ['name'] + pagination_class = SortedCursorPagination name = 'items-list' # this name must match the name in urls.py + # Resolved sortby fields. None means no sortby was provided and the default ordering is used. + sort_fields = None def get_queryset(self): # filter based on the url @@ -122,6 +127,11 @@ def get_queryset(self): def list(self, request, *args, **kwargs): validate_collection(self.kwargs) + + sort_fields = request.query_params.get('sortby') + if sort_fields: + self.sort_fields = parse_sortby_get(sort_fields, SORTABLE_FIELDS) + queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: diff --git a/app/tests/tests_10/test_items_endpoint.py b/app/tests/tests_10/test_items_endpoint.py index c24b4f46..594f54e5 100644 --- a/app/tests/tests_10/test_items_endpoint.py +++ b/app/tests/tests_10/test_items_endpoint.py @@ -188,6 +188,102 @@ def test_items_endpoint_non_existing_collection(self): response = self.client.get(f"/{STAC_BASE_V}/collections/non-existing-collection/items") self.assertStatusCode(404, response) + def test_sortby_id_ascending(self): + response = self.client.get( + f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=id" + ) + + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ["item-1", "item-2"]) + + def test_sortby_id_descending(self): + response = self.client.get( + f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=-id" + ) + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ["item-2", "item-1"]) + + def test_sortby_properties_datetime(self): + + self.factory.create_item_sample( + self.collection, + name='item-dt-1', + properties_datetime=timezone.now() + timedelta(days=2), + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-dt-2', + properties_datetime=timezone.now() + timedelta(days=1), + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-dt-3', + properties_datetime=timezone.now() + timedelta(days=3), + db_create=True + ) + + # Test ascending sort + response = self.client.get( + f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=datetime" + ) + + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ['item-1', 'item-2', 'item-dt-2', 'item-dt-1', 'item-dt-3']) + + # Test descending sort + response = self.client.get( + f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=-datetime" + ) + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ['item-dt-3', 'item-dt-1', 'item-dt-2', 'item-1', 'item-2']) + + def test_sortby_multiple_fields(self): + tomorrow = timezone.now() + timedelta(days=1) + self.factory.create_item_sample( + self.collection, + name='item-multi-1', + properties_datetime=tomorrow, + properties_title='AAA', + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-multi-2', + properties_datetime=tomorrow, + properties_title='BBB', + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-multi-3', + properties_datetime=tomorrow + timedelta(days=1), + properties_title='CCC', + db_create=True + ) + + # Sort by datetime ascending, then by title descending + response = self.client.get( + (f"/{STAC_BASE_V}/collections/{self.collection.name}/items?" + f"sortby=datetime,-title") + ) + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual( + item_ids, ['item-1', 'item-2', 'item-multi-2', 'item-multi-1', 'item-multi-3'] + ) + + def test_sortby_invalid_field(self): + response = self.client.get( + f"/{STAC_BASE_V}/collections/{self.collection.name}/items?sortby=expires" + ) + self.assertStatusCode(400, response) + class ItemsDatetimeQueryEndpointTestCase(StacBaseTestCase): diff --git a/app/tests/tests_10/test_search_endpoint.py b/app/tests/tests_10/test_search_endpoint.py index f50e80b4..4c8f21dc 100644 --- a/app/tests/tests_10/test_search_endpoint.py +++ b/app/tests/tests_10/test_search_endpoint.py @@ -3,9 +3,7 @@ from datetime import UTC from datetime import datetime from datetime import timedelta -from unittest import skip from unittest.mock import patch -from urllib.parse import quote_plus from django.test import Client from django.test import override_settings @@ -616,211 +614,3 @@ def test_post_search_no_cache_setting(self): response.has_header('Cache-Control'), msg="Unexpected Cache-Control header in POST response" ) - - -class SearchEndpointTestForecast(StacBaseTestCase): - - @classmethod - def setUpTestData(cls): - cls.factory = Factory() - cls.collection = cls.factory.create_collection_sample().model - cls.factory.create_item_sample( - cls.collection, 'item-forecast-1', 'item-forecast-1', db_create=True - ) - cls.factory.create_item_sample( - cls.collection, 'item-forecast-2', 'item-forecast-2', db_create=True - ) - cls.factory.create_item_sample( - cls.collection, 'item-forecast-3', 'item-forecast-3', db_create=True - ) - cls.factory.create_item_sample( - cls.collection, 'item-forecast-4', 'item-forecast-4', db_create=True - ) - cls.factory.create_item_sample( - cls.collection, 'item-forecast-5', 'item-forecast-5', db_create=True - ) - cls.now = datetime.now(UTC) - cls.yesterday = cls.now - timedelta(days=1) - - def setUp(self): # pylint: disable=invalid-name - self.client = Client() - self.path = f'/{STAC_BASE_V}/search' - self.maxDiff = None # pylint: disable=invalid-name - - def test_reference_datetime_exact(self): - payload = {"forecast:reference_datetime": "2025-01-01T13:05:10Z"} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 1) - for feature in json_data['features']: - self.assertIn(feature['id'], ['item-forecast-1']) - - payload = {"forecast:reference_datetime": "2025-02-01T13:05:10Z"} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 3) - for feature in json_data['features']: - self.assertIn(feature['id'], ['item-forecast-2', 'item-forecast-3', 'item-forecast-4']) - - def test_reference_datetime_range(self): - payload = {"forecast:reference_datetime": "2025-02-01T00:00:00Z/2025-02-28T00:00:00Z"} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 3) - for feature in json_data['features']: - self.assertIn(feature['id'], ['item-forecast-2', 'item-forecast-3', 'item-forecast-4']) - - def test_reference_datetime_open_end(self): - payload = {"forecast:reference_datetime": "2025-02-01T13:05:10Z/.."} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 4) - for feature in json_data['features']: - self.assertIn( - feature['id'], - ['item-forecast-2', 'item-forecast-3', 'item-forecast-4', 'item-forecast-5'] - ) - - def test_reference_datetime_open_start(self): - payload = {"forecast:reference_datetime": "../2025-02-01T13:05:10Z"} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 4) - for feature in json_data['features']: - self.assertIn( - feature['id'], - ['item-forecast-1', 'item-forecast-2', 'item-forecast-3', 'item-forecast-4'] - ) - - def test_horizon(self): - payload = {"forecast:horizon": "PT3H"} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 1) - for feature in json_data['features']: - self.assertIn(feature['id'], ['item-forecast-3']) - - def test_duration(self): - payload = {"forecast:duration": "PT12H"} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 4) - for feature in json_data['features']: - self.assertIn( - feature['id'], - ['item-forecast-1', 'item-forecast-2', 'item-forecast-4', 'item-forecast-5'] - ) - - def test_variable(self): - payload = {"forecast:variable": "air_temperature"} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 2) - for feature in json_data['features']: - self.assertIn(feature['id'], ['item-forecast-4', 'item-forecast-5']) - - def test_perturbed(self): - payload = {"forecast:perturbed": "True"} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 1) - for feature in json_data['features']: - self.assertIn(feature['id'], ['item-forecast-4']) - - def test_multiple(self): - payload = { - "forecast:perturbed": "False", "forecast:horizon": "PT6H", "forecast:variable": "T" - } - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 2) - for feature in json_data['features']: - self.assertIn(feature['id'], ['item-forecast-1', 'item-forecast-2']) - - def test_get_request_does_not_filter_forecast(self): - response = self.client.get( - f"{self.path}?" + quote_plus( - "forecast:reference_datetime=2025-01-01T13:05:10Z&" + "forecast:duration=PT12H&" + - "forecast:perturbed=False&" + "forecast:horizon=PT6H&" + "forecast:variable=T" - ) - ) - self.assertStatusCode(200, response) - json_data = response.json() - # As GET request should not filter for forecast expect all 5 features to be returned. - self.assertEqual(len(json_data['features']), 5) - - -class SearchEndpointTestCF(StacBaseTestCase): - - @classmethod - def setUpTestData(cls): - cls.factory = Factory() - cls.collection = cls.factory.create_collection_sample().model - cls.factory.create_item_sample(cls.collection, 'item-cf-1', 'item-cf-1', db_create=True) - cls.factory.create_item_sample(cls.collection, 'item-cf-2', 'item-cf-2', db_create=True) - cls.factory.create_item_sample(cls.collection, 'item-cf-3', 'item-cf-3', db_create=True) - - def setUp(self): # pylint: disable=invalid-name - self.client = Client() - self.path = f'/{STAC_BASE_V}/search' - self.maxDiff = None # pylint: disable=invalid-name - - def test_cf_standard_name(self): - payload = {"query": {"cf:standard_name": {"eq": "air_temperature"}}} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 2) - for feature in json_data['features']: - self.assertIn(feature['id'], ['item-cf-1', 'item-cf-2']) - - def test_unit(self): - payload = {"query": {"unit": {"eq": "K"}}} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 1) - for feature in json_data['features']: - self.assertIn(feature['id'], ['item-cf-1']) - - @skip( - "PB-2354: Known bug - Cannot filter by multiple fields. " - "Will be fixed by implementing Filter Extension instead." - ) - def test_multiple_cf(self): - payload = {"query": {"cf:standard_name": {"eq": "air_temperature"}, "unit": {"eq": "K"}}} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(200, response) - json_data = response.json() - self.assertEqual(len(json_data['features']), 1) - for feature in json_data['features']: - self.assertIn(feature['id'], ['item-cf-1']) - - def test_cf_standard_name_invalid_as_direct_param(self): - payload = {"cf:standard_name": "air_temperature"} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(400, response) - - def test_unit_invalid_as_direct_param(self): - payload = {"unit": "K"} - response = self.client.post(self.path, data=payload, content_type="application/json") - self.assertStatusCode(400, response) - - def test_get_request_does_not_filter_cf(self): - response = self.client.get( - f"{self.path}?" + quote_plus("cf:standard_name=air_temperature&" + "unit=K") - ) - self.assertStatusCode(200, response) - json_data = response.json() - # As GET request should not filter for CF expect all 3 features to be returned. - self.assertEqual(len(json_data['features']), 3) diff --git a/app/tests/tests_10/test_search_endpoint_extensions.py b/app/tests/tests_10/test_search_endpoint_extensions.py new file mode 100644 index 00000000..19f5323a --- /dev/null +++ b/app/tests/tests_10/test_search_endpoint_extensions.py @@ -0,0 +1,441 @@ +import logging +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from unittest import skip +from urllib.parse import quote_plus + +from django.test import Client +from django.utils import timezone + +from stac_api.utils import fromisoformat + +from tests.tests_10.base_test import STAC_BASE_V +from tests.tests_10.base_test import StacBaseTestCase +from tests.tests_10.data_factory import Factory + +logger = logging.getLogger(__name__) + + +class SearchEndpointTestForecast(StacBaseTestCase): + + @classmethod + def setUpTestData(cls): + cls.factory = Factory() + cls.collection = cls.factory.create_collection_sample().model + cls.factory.create_item_sample( + cls.collection, 'item-forecast-1', 'item-forecast-1', db_create=True + ) + cls.factory.create_item_sample( + cls.collection, 'item-forecast-2', 'item-forecast-2', db_create=True + ) + cls.factory.create_item_sample( + cls.collection, 'item-forecast-3', 'item-forecast-3', db_create=True + ) + cls.factory.create_item_sample( + cls.collection, 'item-forecast-4', 'item-forecast-4', db_create=True + ) + cls.factory.create_item_sample( + cls.collection, 'item-forecast-5', 'item-forecast-5', db_create=True + ) + cls.now = datetime.now(UTC) + cls.yesterday = cls.now - timedelta(days=1) + + def setUp(self): # pylint: disable=invalid-name + self.client = Client() + self.path = f'/{STAC_BASE_V}/search' + self.maxDiff = None # pylint: disable=invalid-name + + def test_reference_datetime_exact(self): + payload = {"forecast:reference_datetime": "2025-01-01T13:05:10Z"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 1) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-1']) + + payload = {"forecast:reference_datetime": "2025-02-01T13:05:10Z"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 3) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-2', 'item-forecast-3', 'item-forecast-4']) + + def test_reference_datetime_range(self): + payload = {"forecast:reference_datetime": "2025-02-01T00:00:00Z/2025-02-28T00:00:00Z"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 3) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-2', 'item-forecast-3', 'item-forecast-4']) + + def test_reference_datetime_open_end(self): + payload = {"forecast:reference_datetime": "2025-02-01T13:05:10Z/.."} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 4) + for feature in json_data['features']: + self.assertIn( + feature['id'], + ['item-forecast-2', 'item-forecast-3', 'item-forecast-4', 'item-forecast-5'] + ) + + def test_reference_datetime_open_start(self): + payload = {"forecast:reference_datetime": "../2025-02-01T13:05:10Z"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 4) + for feature in json_data['features']: + self.assertIn( + feature['id'], + ['item-forecast-1', 'item-forecast-2', 'item-forecast-3', 'item-forecast-4'] + ) + + def test_horizon(self): + payload = {"forecast:horizon": "PT3H"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 1) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-3']) + + def test_duration(self): + payload = {"forecast:duration": "PT12H"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 4) + for feature in json_data['features']: + self.assertIn( + feature['id'], + ['item-forecast-1', 'item-forecast-2', 'item-forecast-4', 'item-forecast-5'] + ) + + def test_variable(self): + payload = {"forecast:variable": "air_temperature"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 2) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-4', 'item-forecast-5']) + + def test_perturbed(self): + payload = {"forecast:perturbed": "True"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 1) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-4']) + + def test_multiple(self): + payload = { + "forecast:perturbed": "False", "forecast:horizon": "PT6H", "forecast:variable": "T" + } + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 2) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-forecast-1', 'item-forecast-2']) + + def test_get_request_does_not_filter_forecast(self): + response = self.client.get( + f"{self.path}?" + quote_plus( + "forecast:reference_datetime=2025-01-01T13:05:10Z&" + "forecast:duration=PT12H&" + + "forecast:perturbed=False&" + "forecast:horizon=PT6H&" + "forecast:variable=T" + ) + ) + self.assertStatusCode(200, response) + json_data = response.json() + # As GET request should not filter for forecast expect all 5 features to be returned. + self.assertEqual(len(json_data['features']), 5) + + +class SearchEndpointTestCF(StacBaseTestCase): + + @classmethod + def setUpTestData(cls): + cls.factory = Factory() + cls.collection = cls.factory.create_collection_sample().model + cls.factory.create_item_sample(cls.collection, 'item-cf-1', 'item-cf-1', db_create=True) + cls.factory.create_item_sample(cls.collection, 'item-cf-2', 'item-cf-2', db_create=True) + cls.factory.create_item_sample(cls.collection, 'item-cf-3', 'item-cf-3', db_create=True) + + def setUp(self): # pylint: disable=invalid-name + self.client = Client() + self.path = f'/{STAC_BASE_V}/search' + self.maxDiff = None # pylint: disable=invalid-name + + def test_cf_standard_name(self): + payload = {"query": {"cf:standard_name": {"eq": "air_temperature"}}} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 2) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-cf-1', 'item-cf-2']) + + def test_unit(self): + payload = {"query": {"unit": {"eq": "K"}}} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 1) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-cf-1']) + + @skip( + "PB-2354: Known bug - Cannot filter by multiple fields. " + "Will be fixed by implementing Filter Extension instead." + ) + def test_multiple_cf(self): + payload = {"query": {"cf:standard_name": {"eq": "air_temperature"}, "unit": {"eq": "K"}}} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + json_data = response.json() + self.assertEqual(len(json_data['features']), 1) + for feature in json_data['features']: + self.assertIn(feature['id'], ['item-cf-1']) + + def test_cf_standard_name_invalid_as_direct_param(self): + payload = {"cf:standard_name": "air_temperature"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(400, response) + + def test_unit_invalid_as_direct_param(self): + payload = {"unit": "K"} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(400, response) + + def test_get_request_does_not_filter_cf(self): + response = self.client.get( + f"{self.path}?" + quote_plus("cf:standard_name=air_temperature&" + "unit=K") + ) + self.assertStatusCode(200, response) + json_data = response.json() + # As GET request should not filter for CF expect all 3 features to be returned. + self.assertEqual(len(json_data['features']), 3) + + +class SearchEndpointSortTestCase(StacBaseTestCase): + '''Tests for the sortby parameter on the GET and POST /search endpoint''' + + @classmethod + def setUpTestData(cls): + cls.factory = Factory() + cls.collection = cls.factory.create_collection_sample().model + # Give item-1 and item-2 distinct datetimes so that sorting by + # properties.datetime is fully deterministic (no tie-breaking). + cls.items = [ + cls.factory.create_item_sample( + cls.collection, + name='item-1', + sample='item-1', + properties_datetime=fromisoformat('2020-10-28T13:05:10Z'), + db_create=True + ), + cls.factory.create_item_sample( + cls.collection, + name='item-2', + sample='item-1', + properties_datetime=fromisoformat('2020-10-29T13:05:10Z'), + db_create=True + ) + ] + + def setUp(self): # pylint: disable=invalid-name + self.client = Client() + self.path = f'/{STAC_BASE_V}/search' + self.maxDiff = None # pylint: disable=invalid-name + + def test_get_sortby_id_ascending(self): + response = self.client.get(f"{self.path}?sortby=id") + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ["item-1", "item-2"]) + + def test_get_sortby_id_descending(self): + response = self.client.get(f"{self.path}?sortby=-id") + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ["item-2", "item-1"]) + + def test_get_sortby_properties_datetime(self): + self.factory.create_item_sample( + self.collection, + name='item-dt-1', + properties_datetime=timezone.now() + timedelta(days=2), + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-dt-2', + properties_datetime=timezone.now() + timedelta(days=1), + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-dt-3', + properties_datetime=timezone.now() + timedelta(days=3), + db_create=True + ) + + # ascending sort + response = self.client.get(f"{self.path}?sortby=datetime") + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ['item-1', 'item-2', 'item-dt-2', 'item-dt-1', 'item-dt-3']) + + # descending sort + response = self.client.get(f"{self.path}?sortby=-datetime") + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ['item-dt-3', 'item-dt-1', 'item-dt-2', 'item-2', 'item-1']) + + def test_get_sortby_multiple_fields(self): + tomorrow = timezone.now() + timedelta(days=1) + self.factory.create_item_sample( + self.collection, + name='item-multi-1', + properties_datetime=tomorrow, + properties_title='AAA', + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-multi-2', + properties_datetime=tomorrow, + properties_title='BBB', + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-multi-3', + properties_datetime=tomorrow + timedelta(days=1), + properties_title='CCC', + db_create=True + ) + + # Sort by datetime ascending, then by title descending + response = self.client.get(f"{self.path}?sortby=datetime,-title") + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual( + item_ids, ['item-1', 'item-2', 'item-multi-2', 'item-multi-1', 'item-multi-3'] + ) + + def test_get_sortby_invalid_field(self): + response = self.client.get(f"{self.path}?sortby=expires") + self.assertStatusCode(400, response) + + def test_post_sortby_empty_list(self): + payload = {"sortby": []} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ["item-1", "item-2"]) + + def test_post_sortby_id_ascending(self): + payload = {"sortby": [{"field": "id", "direction": "asc"}]} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ["item-1", "item-2"]) + + def test_post_sortby_id_descending(self): + payload = {"sortby": [{"field": "id", "direction": "desc"}]} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ["item-2", "item-1"]) + + def test_post_sortby_properties_datetime(self): + self.factory.create_item_sample( + self.collection, + name='item-dt-1', + properties_datetime=timezone.now() + timedelta(days=2), + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-dt-2', + properties_datetime=timezone.now() + timedelta(days=1), + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-dt-3', + properties_datetime=timezone.now() + timedelta(days=3), + db_create=True + ) + + # ascending sort + payload = {"sortby": [{"field": "datetime", "direction": "asc"}]} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ['item-1', 'item-2', 'item-dt-2', 'item-dt-1', 'item-dt-3']) + + # descending sort + payload = {"sortby": [{"field": "datetime", "direction": "desc"}]} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual(item_ids, ['item-dt-3', 'item-dt-1', 'item-dt-2', 'item-2', 'item-1']) + + def test_post_sortby_multiple_fields(self): + tomorrow = timezone.now() + timedelta(days=1) + self.factory.create_item_sample( + self.collection, + name='item-multi-1', + properties_datetime=tomorrow, + properties_title='AAA', + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-multi-2', + properties_datetime=tomorrow, + properties_title='BBB', + db_create=True + ) + self.factory.create_item_sample( + self.collection, + name='item-multi-3', + properties_datetime=tomorrow + timedelta(days=1), + properties_title='CCC', + db_create=True + ) + + # Sort by datetime ascending, then by title descending + payload = { + "sortby": [{ + "field": "datetime", "direction": "asc" + }, { + "field": "title", "direction": "desc" + }] + } + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(200, response) + item_ids = [item['id'] for item in response.json()['features']] + self.assertEqual( + item_ids, ['item-1', 'item-2', 'item-multi-2', 'item-multi-1', 'item-multi-3'] + ) + + def test_post_sortby_invalid_field(self): + payload = {"sortby": [{"field": "expires", "direction": "asc"}]} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(400, response) + + def test_post_sortby_invalid_direction(self): + payload = {"sortby": [{"field": "id", "direction": "sideways"}]} + response = self.client.post(self.path, data=payload, content_type="application/json") + self.assertStatusCode(400, response) diff --git a/app/tests/tests_10/test_utils.py b/app/tests/tests_10/test_utils.py index a8fc961b..df151f7b 100644 --- a/app/tests/tests_10/test_utils.py +++ b/app/tests/tests_10/test_utils.py @@ -1,6 +1,10 @@ from unittest import TestCase +from django.core.exceptions import ValidationError + from stac_api.utils import parse_cache_control_header +from stac_api.utils import parse_sortby_get +from stac_api.utils import parse_sortby_post class TestUtils(TestCase): @@ -23,3 +27,113 @@ def test_parse_cache_control_header(self): self.assertEqual(parse_cache_control_header(','), {}) self.assertEqual(parse_cache_control_header(' '), {}) self.assertEqual(parse_cache_control_header(' , '), {}) + + def test_parse_sortby_get_empty(self): + result = parse_sortby_get(None, {}) + self.assertEqual(result, []) + + result = parse_sortby_get('', {}) + self.assertEqual(result, []) + + result = parse_sortby_get(' ', {}) + self.assertEqual(result, []) + + def test_parse_sortby_get_handles_single_field_correctly(self): + sortable_fields = {'external': 'internal'} + + result = parse_sortby_get('external', sortable_fields) + self.assertEqual(result, [('internal', True)]) + + result = parse_sortby_get('+external', sortable_fields) + self.assertEqual(result, [('internal', True)]) + + result = parse_sortby_get('-external', sortable_fields) + self.assertEqual(result, [('internal', False)]) + + def test_parse_sortby_get_handles_multiple_fields_correctly(self): + sortable_fields = { + 'external_1': 'internal_1', + 'external_2': 'internal_2', + 'external_3': 'internal_3', + } + + result = parse_sortby_get('external_1,-external_2', sortable_fields) + self.assertEqual(result, [('internal_1', True), ('internal_2', False)]) + + result = parse_sortby_get('-external_1,external_2', sortable_fields) + self.assertEqual(result, [('internal_1', False), ('internal_2', True)]) + + result = parse_sortby_get('+external_1,-external_2,external_3', sortable_fields) + self.assertEqual( + result, [('internal_1', True), ('internal_2', False), ('internal_3', True)] + ) + + def test_parse_sortby_get_ignores_whitespace(self): + sortable_fields = { + 'external_1': 'internal_1', + 'external_2': 'internal_2', + } + result = parse_sortby_get('external_1 , -external_2', sortable_fields) + self.assertEqual(result, [('internal_1', True), ('internal_2', False)]) + + def test_parse_sortby_get_raises_for_invalid_field(self): + with self.assertRaises(ValidationError): + parse_sortby_get('invalid_field', {'external': 'internal'}) + + def test_parse_sortby_post_format_empty(self): + result = parse_sortby_post([], {}) + self.assertEqual(result, []) + + def test_parse_sortby_post_format_handles_single_field_correctly(self): + sortable_fields = {'external': 'internal'} + + result = parse_sortby_post([{'field': 'external', 'direction': 'asc'}], sortable_fields) + self.assertEqual(result, [('internal', True)]) + + result = parse_sortby_post([{'field': 'external', 'direction': 'desc'}], sortable_fields) + self.assertEqual(result, [('internal', False)]) + + # direction defaults to ascending when omitted + result = parse_sortby_post([{'field': 'external'}], sortable_fields) + self.assertEqual(result, [('internal', True)]) + + def test_parse_sortby_post_format_handles_multiple_fields_correctly(self): + sortable_fields = { + 'external_1': 'internal_1', + 'external_2': 'internal_2', + } + sortby_param = [{ + 'field': 'external_1', + 'direction': 'asc', + }, { + 'field': 'external_2', + 'direction': 'desc', + }] + result = parse_sortby_post(sortby_param, sortable_fields) + self.assertEqual(result, [('internal_1', True), ('internal_2', False)]) + + sortby_param = [{ + 'field': 'external_1', + 'direction': 'desc', + }, { + 'field': 'external_2', + 'direction': 'asc', + }] + result = parse_sortby_post(sortby_param, sortable_fields) + self.assertEqual(result, [('internal_1', False), ('internal_2', True)]) + + def test_parse_sortby_post_format_raises_for_invalid_field(self): + with self.assertRaises(ValidationError): + parse_sortby_post([{ + 'field': 'invalid_field', 'direction': 'asc' + }], {'external': 'internal'}) + + def test_parse_sortby_post_format_raises_for_invalid_direction(self): + with self.assertRaises(ValidationError): + parse_sortby_post([{ + 'field': 'external', 'direction': 'sideways' + }], {'external': 'internal'}) + + def test_parse_sortby_post_format_raises_for_missing_field(self): + with self.assertRaises(ValidationError): + parse_sortby_post([{'direction': 'asc'}], {'external': 'internal'}) diff --git a/spec/components/parameters.yaml b/spec/components/parameters.yaml index 20495079..4399b62f 100644 --- a/spec/components/parameters.yaml +++ b/spec/components/parameters.yaml @@ -132,3 +132,13 @@ components: required: false schema: type: string + sortby: + name: sortby + in: query + description: >- + An array of property names, prefixed by either "+" for ascending or + "-" for descending. If no prefix is provided, "+" is assumed. + required: false + schema: + type: string + example: '-created,id' diff --git a/spec/components/schemas.yaml b/spec/components/schemas.yaml index a9908953..8c82c09a 100644 --- a/spec/components/schemas.yaml +++ b/spec/components/schemas.yaml @@ -1732,6 +1732,34 @@ components: description: Purposes of the asset example: - thumbnail + sortby: + type: array + description: >- + An array of objects containing a property name and sort direction. + items: + type: object + required: + - field + properties: + field: + type: string + direction: + type: string + default: asc + enum: + - asc + - desc + example: + - field: created + direction: asc + - field: collection + direction: desc + sortbyFilter: + description: Sort the results by the specified fields. + properties: + sortby: + $ref: "#/components/schemas/sortby" + type: object searchBody: allOf: # - $ref: "#/components/schemas/assetQueryFilter" @@ -1742,6 +1770,7 @@ components: - $ref: "#/components/schemas/collectionsFilter" - $ref: "#/components/schemas/idsFilter" - $ref: "#/components/schemas/limitFilter" + - $ref: "#/components/schemas/sortbyFilter" - $ref: "#/components/schemas/forecast_reference_datetimeFilter" - $ref: "#/components/schemas/forecast_horizonFilter" - $ref: "#/components/schemas/forecast_durationFilter" diff --git a/spec/openapi.yaml b/spec/openapi.yaml index 09e41a35..c5e7e229 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -92,6 +92,7 @@ paths: - $ref: "./components/parameters.yaml#/components/parameters/limit" - $ref: "./components/parameters.yaml#/components/parameters/bbox" - $ref: "./components/parameters.yaml#/components/parameters/datetime" + - $ref: "./components/parameters.yaml#/components/parameters/sortby" responses: "200": $ref: "./components/responses.yaml#/components/responses/Features" @@ -331,6 +332,7 @@ paths: - $ref: "./components/parameters.yaml#/components/parameters/limit" - $ref: "./components/parameters.yaml#/components/parameters/ids" - $ref: "./components/parameters.yaml#/components/parameters/collectionsArray" + - $ref: "./components/parameters.yaml#/components/parameters/sortby" responses: "200": content: diff --git a/spec/static/spec/v1/openapi.yaml b/spec/static/spec/v1/openapi.yaml index fb376e67..203a3e04 100644 --- a/spec/static/spec/v1/openapi.yaml +++ b/spec/static/spec/v1/openapi.yaml @@ -130,6 +130,15 @@ components: required: false schema: type: string + sortby: + name: sortby + in: query + description: >- + An array of property names, prefixed by either "+" for ascending or "-" for descending. If no prefix is provided, "+" is assumed. + required: false + schema: + type: string + example: '-created,id' responses: Collection: headers: @@ -1954,6 +1963,34 @@ components: description: Purposes of the asset example: - thumbnail + sortby: + type: array + description: >- + An array of objects containing a property name and sort direction. + items: + type: object + required: + - field + properties: + field: + type: string + direction: + type: string + default: asc + enum: + - asc + - desc + example: + - field: created + direction: asc + - field: collection + direction: desc + sortbyFilter: + description: Sort the results by the specified fields. + properties: + sortby: + $ref: "#/components/schemas/sortby" + type: object searchBody: allOf: # - $ref: "#/components/schemas/assetQueryFilter" @@ -1964,6 +2001,7 @@ components: - $ref: "#/components/schemas/collectionsFilter" - $ref: "#/components/schemas/idsFilter" - $ref: "#/components/schemas/limitFilter" + - $ref: "#/components/schemas/sortbyFilter" - $ref: "#/components/schemas/forecast_reference_datetimeFilter" - $ref: "#/components/schemas/forecast_horizonFilter" - $ref: "#/components/schemas/forecast_durationFilter" @@ -2108,6 +2146,7 @@ paths: - $ref: "#/components/parameters/limit" - $ref: "#/components/parameters/bbox" - $ref: "#/components/parameters/datetime" + - $ref: "#/components/parameters/sortby" responses: "200": $ref: "#/components/responses/Features" @@ -2340,6 +2379,7 @@ paths: - $ref: "#/components/parameters/limit" - $ref: "#/components/parameters/ids" - $ref: "#/components/parameters/collectionsArray" + - $ref: "#/components/parameters/sortby" responses: "200": content: diff --git a/spec/static/spec/v1/openapitransactional.yaml b/spec/static/spec/v1/openapitransactional.yaml index 5092d347..0355269e 100644 --- a/spec/static/spec/v1/openapitransactional.yaml +++ b/spec/static/spec/v1/openapitransactional.yaml @@ -130,6 +130,15 @@ components: required: false schema: type: string + sortby: + name: sortby + in: query + description: >- + An array of property names, prefixed by either "+" for ascending or "-" for descending. If no prefix is provided, "+" is assumed. + required: false + schema: + type: string + example: '-created,id' uploadId: name: uploadId in: path @@ -2038,6 +2047,34 @@ components: description: Purposes of the asset example: - thumbnail + sortby: + type: array + description: >- + An array of objects containing a property name and sort direction. + items: + type: object + required: + - field + properties: + field: + type: string + direction: + type: string + default: asc + enum: + - asc + - desc + example: + - field: created + direction: asc + - field: collection + direction: desc + sortbyFilter: + description: Sort the results by the specified fields. + properties: + sortby: + $ref: "#/components/schemas/sortby" + type: object searchBody: allOf: # - $ref: "#/components/schemas/assetQueryFilter" @@ -2048,6 +2085,7 @@ components: - $ref: "#/components/schemas/collectionsFilter" - $ref: "#/components/schemas/idsFilter" - $ref: "#/components/schemas/limitFilter" + - $ref: "#/components/schemas/sortbyFilter" - $ref: "#/components/schemas/forecast_reference_datetimeFilter" - $ref: "#/components/schemas/forecast_horizonFilter" - $ref: "#/components/schemas/forecast_durationFilter" @@ -3070,6 +3108,7 @@ paths: - $ref: "#/components/parameters/limit" - $ref: "#/components/parameters/bbox" - $ref: "#/components/parameters/datetime" + - $ref: "#/components/parameters/sortby" responses: "200": $ref: "#/components/responses/Features" @@ -3603,6 +3642,7 @@ paths: - $ref: "#/components/parameters/limit" - $ref: "#/components/parameters/ids" - $ref: "#/components/parameters/collectionsArray" + - $ref: "#/components/parameters/sortby" responses: "200": content: