From 217c963ba48b2c1654a7f7655a29075a1d06b460 Mon Sep 17 00:00:00 2001 From: Edan Bainglass Date: Fri, 31 Jul 2026 14:17:16 +0200 Subject: [PATCH] Conform query parameters to JSON:API style --- README.md | 25 +++ aiida_restapi/common/pagination.py | 4 +- aiida_restapi/common/query.py | 245 ++++++++++++++++++++------- aiida_restapi/jsonapi/adapters.py | 89 +++++----- aiida_restapi/jsonapi/models/base.py | 10 +- aiida_restapi/models/node.py | 2 +- aiida_restapi/routers/nodes.py | 2 +- aiida_restapi/services/entity.py | 26 +-- aiida_restapi/services/node.py | 14 +- tests/test_nodes.py | 33 ++-- tests/test_query.py | 129 ++++++++++++++ 11 files changed, 436 insertions(+), 143 deletions(-) create mode 100644 tests/test_query.py diff --git a/README.md b/README.md index 4bb89d77..62aa54a3 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,31 @@ By default, the API allows requests from localhost. You can configure the CORS p See the [examples](https://github.com/aiidateam/aiida-restapi/tree/master/examples) directory. +### Collection queries + +Resource collections use JSON:API-style query parameters: + +```text +GET /nodes?filter[node_type]=data.core.int.Int.&sort=-ctime&page[offset]=0&page[limit]=10 +``` + +- `filter[field]=value` and `filter[field][==]=value` test equality +- `filter[field][in]=["value1","value2"]` tests membership +- `filter[field][<|<=|>|>=]=value` compares numeric values +- `filter[field][like|ilike]=pattern` matches strings using SQL `%` and `_` wildcards +- `filter[field][contains]=["value1","value2"]` tests array containment +- `filter[field][of_length|shorter|longer|has_key]=value` filters arrays and dictionaries +- `filter[field][!operator]=value` negates any supported leaf operator +- `sort=field,-other` sorts ascending and descending, respectively +- `page[offset]` and `page[limit]` control offset pagination +- `include=relationship,other` includes related resources +- Nested dictionary values use dot-separated fields, for example `filter[attributes.config.timeout][>=]=30` +- `in` and `contains` operators, and their negated forms, require a non-empty JSON array +- Values are decoded as JSON scalars when possible +- Repeated constraints are combined with AND +- Repeated `in` constraints are flattened +- General boolean grouping (e.g., `OR`) is intentionally reserved for `POST /querybuilder` + ## Development ```shell diff --git a/aiida_restapi/common/pagination.py b/aiida_restapi/common/pagination.py index c1261a2b..4932c31a 100644 --- a/aiida_restapi/common/pagination.py +++ b/aiida_restapi/common/pagination.py @@ -11,6 +11,6 @@ class PaginatedResults(pdt.BaseModel, t.Generic[ResultType]): total: int - page: int - page_size: int + offset: int + limit: int data: list[ResultType] diff --git a/aiida_restapi/common/query.py b/aiida_restapi/common/query.py index 09c25788..b0a161d4 100644 --- a/aiida_restapi/common/query.py +++ b/aiida_restapi/common/query.py @@ -3,16 +3,19 @@ from __future__ import annotations import json +import re import typing as t import pydantic as pdt -from fastapi import Depends, Query +from aiida.common.exceptions import InputValidationError +from fastapi import Depends, Query, Request __all__ = [ 'CollectionQueryParams', - 'QueryBuilderParams', + 'QueryParams', 'ResourceQueryParams', 'collection_query_params', + 'query_parameter_items', 'querybuilder_params', 'resource_query_params', ] @@ -23,8 +26,12 @@ class Filtering(pdt.BaseModel): default_factory=dict, description='AiiDA QueryBuilder filters', examples=[ - {'node_type': {'==': 'data.core.int.Int.'}}, - {'attributes.value': {'>': 42}}, + 'filter[node_type]=data.core.int.Int.', + 'filter[pk][in]=[1,10,100]', + 'filter[attributes.value][<=]=42', + 'filter[label][like]=%test%', + 'filter[attributes.some_array][contains]=[1,True,"hello"]', + 'filter[attributes.some_dict][has_key]=some_key', ], ) @@ -34,22 +41,18 @@ class Sorting(pdt.BaseModel): default=None, description='Fields to sort by', examples=[ - {'attributes.value': 'desc'}, + 'ctime', + ['label', '-ctime'], ], ) class Pagination(pdt.BaseModel): - page_size: pdt.PositiveInt = pdt.Field( + limit: pdt.PositiveInt = pdt.Field( default=10, description='Number of results per page', examples=[10], ) - page: pdt.PositiveInt = pdt.Field( - default=1, - description='Page number', - examples=[1], - ) offset: pdt.NonNegativeInt = pdt.Field( default=0, description='Offset for results', @@ -68,11 +71,13 @@ class Include(pdt.BaseModel): ) -class QueryBuilderParams(Filtering, Sorting, Pagination): +class QueryParams(Filtering, Sorting, Pagination): """QueryBuilder parameters: filters, sorting, pagination.""" + query_items: list[tuple[str, str]] = pdt.Field(default_factory=list, exclude=True) + -class CollectionQueryParams(QueryBuilderParams, Include): +class CollectionQueryParams(QueryParams, Include): """Query parameters for a collection resource: filters, sorting, pagination, include.""" @@ -84,61 +89,171 @@ def _parse_csv(raw: str) -> list[str]: return [item.strip() for item in raw.split(',') if item.strip()] +def _parse_filter_value(raw: str) -> t.Any: + try: + return json.loads(raw) + except json.JSONDecodeError: + return raw + + +_FILTER_KEY = re.compile(r'filter\[([^][\s]+)\](?:\[([^][\s]+)\])?') +_TRUNCATED_OPERATOR_KEY = re.compile(r'filter\[[^][\s]+\]\[!?[<>=]*') + +_GENERAL_OPS = { + '==', + '!==', + 'in', +} + +_NUMERIC_OPS = { + '<', + '<=', + '>', + '>=', +} + +_STRING_OPS = { + 'like', + 'ilike', +} + +_ARRAY_OPS = { + 'contains', + 'of_length', + 'shorter', + 'longer', +} + +_DICT_OPS = { + 'has_key', +} + +_FILTER_OPERATORS = { + *_GENERAL_OPS, + *_NUMERIC_OPS, + *_STRING_OPS, + *_ARRAY_OPS, + *_DICT_OPS, +} + + +def query_parameter_items(request: Request) -> list[tuple[str, str]]: + """Return decoded query items, repairing comparison operators split at their equals sign.""" + items: list[tuple[str, str]] = [] + for key, value in request.query_params.multi_items(): + normalized_key = key + normalized_value = value + if _TRUNCATED_OPERATOR_KEY.fullmatch(key): + operator_remainder, separator, operand = value.partition(']=') + if separator: + normalized_key = f'{key}={operator_remainder}]' + normalized_value = operand + items.append((normalized_key, normalized_value)) + return items + + +def _parse_filter_list(operator: str, raw: str) -> list[t.Any]: + try: + value = json.loads(raw) + except json.JSONDecodeError as exception: + raise InputValidationError(f'The `{operator}` filter requires a JSON list.') from exception + if not isinstance(value, list): + raise InputValidationError(f'The `{operator}` filter requires a JSON list.') + if not value: + raise InputValidationError(f'The `{operator}` filter requires at least one value.') + return value + + +def _parse_filter_operator(operator: str | None) -> str: + if operator is None: + return '==' + positive_operator = operator[1:] if operator.startswith('!') else operator + if positive_operator in _FILTER_OPERATORS: + return operator + raise InputValidationError(f'Invalid filter operator: {operator!r}.') + + +def _parse_filter_operand(operator: str, raw: str) -> t.Any: + positive_operator = operator.lstrip('!~') + if positive_operator in {'in', 'contains'}: + return _parse_filter_list(positive_operator, raw) + value = _parse_filter_value(raw) + if positive_operator in _NUMERIC_OPS and not isinstance(value, (int, float)): + raise InputValidationError(f'The `{positive_operator}` filter requires a numeric value.') + if positive_operator in _STRING_OPS and not isinstance(value, str): + raise InputValidationError(f'The `{positive_operator}` filter requires a string value.') + if positive_operator in _ARRAY_OPS and not isinstance(value, int): + raise InputValidationError(f'The `{positive_operator}` filter requires an integer value.') + return value + + +def _parse_filters(query_items: list[tuple[str, str]]) -> dict[str, t.Any]: + expressions: dict[str, list[dict[str, t.Any]]] = {} + for key, raw in query_items: + if not key.startswith('filter'): + continue + match = _FILTER_KEY.fullmatch(key) + if match is None: + raise InputValidationError(f'Invalid filter parameter: {key!r}.') + field, raw_operator = match.groups() + operator = _parse_filter_operator(raw_operator) + operand = _parse_filter_operand(operator, raw) + expressions.setdefault(field, []).append({operator: operand}) + + filters: dict[str, t.Any] = {} + for field, field_expressions in expressions.items(): + normalized_expressions = field_expressions + for membership_operator in ('in', '!in'): + membership = [ + expression[membership_operator] + for expression in normalized_expressions + if membership_operator in expression + ] + if len(membership) > 1: + normalized_expressions = [ + expression for expression in normalized_expressions if membership_operator not in expression + ] + [{membership_operator: [item for values in membership for item in values]}] + filters[field] = ( + normalized_expressions[0] if len(normalized_expressions) == 1 else {'and': normalized_expressions} + ) + + return filters + + +def _parse_sort(raw: str | None) -> dict[str, t.Literal['asc', 'desc']]: + order_by: dict[str, t.Literal['asc', 'desc']] = {} + for field in _parse_csv(raw) if raw else []: + direction: t.Literal['asc', 'desc'] = 'desc' if field.startswith('-') else 'asc' + name = field[1:] if field.startswith(('-', '+')) else field + if not name or name.startswith(('-', '+')) or any(character.isspace() for character in name): + raise InputValidationError(f'Invalid sort field: {field!r}.') + order_by[name] = direction + return order_by + + def querybuilder_params( - filters: t.Annotated[ + request: Request, + sort: t.Annotated[ str | None, - Query(description='AiiDA QueryBuilder filters as JSON string or object'), + Query(description='Comma-separated fields to sort by; prefix descending fields with `-`'), ] = None, - order_by: t.Annotated[ - str | None, - Query(description='Fields to sort by, as a comma-separated string, JSON array, or JSON object'), - ] = None, - page_size: t.Annotated[ + limit: t.Annotated[ int, - Query(ge=1, description='Number of results per page'), + Query(alias='page[limit]', ge=1, description='Number of results per page'), ] = 10, - page: t.Annotated[ - int, - Query(ge=1, description='Page number'), - ] = 1, offset: t.Annotated[ int, - Query(ge=0, description='Offset for results'), + Query(alias='page[offset]', ge=0, description='Number of results to skip'), ] = 0, -) -> QueryBuilderParams: - """Dependency to parse QueryBuilder parameters. - - :param filters: AiiDA QueryBuilder filters as JSON string. - :type filters: str | None - :param order_by: Comma-separated string of fields to sort by. - :type order_by: str | None - :param page_size: Number of results per page. - :type page_size: int - :param page: Page number. - :type page: int - :return: Structured query parameters. - :rtype: QueryBuilderParams - :raises HTTPException: If arguments cannot be parsed as JSON. - """ - query_filters: dict[str, t.Any] = {} - if filters: - query_filters = json.loads(filters) - if not isinstance(query_filters, dict): - raise pdt.ValidationError('Filters must be a JSON object') - - query_order_by: str | list[str] | dict[str, t.Any] | None = None - if order_by: - if order_by.lstrip().startswith(('{', '[', '"')): - query_order_by = json.loads(order_by) - else: - query_order_by = _parse_csv(order_by) - - return QueryBuilderParams( - filters=query_filters, - order_by=query_order_by, - page_size=page_size, - page=page, +) -> QueryParams: + """Parse JSON:API filtering, sorting, and pagination parameters.""" + query_items = query_parameter_items(request) + return QueryParams( + filters=_parse_filters(query_items), + order_by=_parse_sort(sort), + limit=limit, offset=offset, + query_items=query_items, ) @@ -159,19 +274,23 @@ def include_params( def collection_query_params( - qb_params: t.Annotated[QueryBuilderParams, Depends(querybuilder_params)], + query_params: t.Annotated[QueryParams, Depends(querybuilder_params)], include: t.Annotated[Include, Depends(include_params)], ) -> CollectionQueryParams: """Dependency to parse collection query parameters. - :param qb_params: The query builder parameters. - :type qb_params: QueryBuilderParams + :param query_params: The query builder parameters. + :type query_params: QueryParams :param include: The include parameters. :type include: Include :return: The combined collection query parameters. :rtype: CollectionQueryParams """ - return CollectionQueryParams(**qb_params.model_dump(), **include.model_dump()) + return CollectionQueryParams( + **query_params.model_dump(), + **include.model_dump(), + query_items=query_params.query_items, + ) def resource_query_params( diff --git a/aiida_restapi/jsonapi/adapters.py b/aiida_restapi/jsonapi/adapters.py index ec10dd97..81883426 100644 --- a/aiida_restapi/jsonapi/adapters.py +++ b/aiida_restapi/jsonapi/adapters.py @@ -3,7 +3,7 @@ from __future__ import annotations import typing as t -from copy import deepcopy +from urllib.parse import urlencode from aiida import orm from starlette.requests import Request @@ -209,15 +209,16 @@ def collection( meta = { 'total': results.total, - 'page': results.page, - 'page_size': results.page_size, + 'offset': results.offset, + 'limit': results.limit, } | (meta or {}) toplevel_links = cls._build_toplevel_links( request, results.total, - query_params.page, - query_params.page_size, + query_params.offset, + query_params.limit, + query_params.query_items, ) return { @@ -404,8 +405,9 @@ def _build_toplevel_links( cls, request: Request, total: int, - page: int, - page_size: int, + offset: int, + limit: int, + query_items: list[tuple[str, str]], ) -> dict[str, str]: """Return dict suitable for JSON:API top-level links (self/next/prev/first/last). @@ -413,54 +415,59 @@ def _build_toplevel_links( :type request: Request :param total: The total number of items. :type total: int - :param page: The current page. - :type page: int - :param page_size: The page size. - :type page_size: int + :param offset: The number of results skipped. + :type offset: int + :param limit: The maximum number of results returned. + :type limit: int + :param query_items: The query parameters to include in the links. + :type query_items: list[tuple[str, str]] :return: The top-level links. :rtype: dict[str, str] """ - current = cls._build_link(request, page=page, page_size=page_size) - - links: dict[str, str] = {'self': str(current)} - - if page > 1: - links['prev'] = cls._build_link(request, page=page - 1, page_size=page_size) - links['first'] = cls._build_link(request, page=1, page_size=page_size) - - last_page = (total + page_size - 1) // page_size if page_size > 0 else 1 - if last_page >= 1: - links['last'] = cls._build_link(request, page=last_page, page_size=page_size) + links = { + 'self': cls._build_link(request, query_items, offset=offset, limit=limit), + 'first': cls._build_link(request, query_items, offset=0, limit=limit), + 'last': cls._build_link(request, query_items, offset=max(0, ((total - 1) // limit) * limit), limit=limit), + } - if page < last_page: - links['next'] = cls._build_link(request, page=page + 1, page_size=page_size) + if offset > 0: + links['prev'] = cls._build_link(request, query_items, offset=max(0, offset - limit), limit=limit) + if offset + limit < total: + links['next'] = cls._build_link(request, query_items, offset=offset + limit, limit=limit) return links @staticmethod - def _build_link(request: Request, page: int | None = None, page_size: int | None = None) -> str: + def _build_link( + request: Request, + query_items: list[tuple[str, str]] | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> str: """Return a relative URL with updated query parameters. :param request: The incoming request. :type request: Request - :param page: The current page. - :type page: int | None - :param page_size: The page size. - :type page_size: int | None + :param query_items: The query parameters to include in the link. + :type query_items: list[tuple[str, str]] | None + :param offset: The number of results skipped. + :type offset: int | None + :param limit: The maximum number of results returned. + :type limit: int | None :return: The updated URL relative to the base URL. :rtype: str """ - q = deepcopy(dict(request.query_params)) - - if page is not None: - q['page'] = str(page) - if page_size is not None: - q['page_size'] = str(page_size) - - url = request.url.replace_query_params(**q) - - link = url.path - if url.query: - link += f'?{url.query}' + source_query_items = request.query_params.multi_items() if query_items is None else query_items + query = [(key, value) for key, value in source_query_items if key not in {'page[offset]', 'page[limit]'}] + if offset is not None: + query.append(('page[offset]', str(offset))) + if limit is not None: + query.append(('page[limit]', str(limit))) + + link = request.url.path + safe_key_chars = r'[]<>=!' + safe_value_chars = r'%[]{}:!,?@&=+$-_.~*\'()"' + if query: + link += f'?{urlencode(query, safe=safe_key_chars + safe_value_chars)}' return link diff --git a/aiida_restapi/jsonapi/models/base.py b/aiida_restapi/jsonapi/models/base.py index e5884cf4..d8e7980c 100644 --- a/aiida_restapi/jsonapi/models/base.py +++ b/aiida_restapi/jsonapi/models/base.py @@ -120,7 +120,7 @@ class BaseToplevelLinks(pdt.BaseModel): description='The link that generated the current response document. If a document has extensions ' 'or profiles applied to it, this link **SHOULD** be represented by a link object with the "type" ' 'target attribute specifying the JSON:API media type with all applicable parameters.', - examples=['../{type}?page=2'], + examples=['../{type}?page[offset]=10&page[limit]=10'], ), ] = None @@ -144,28 +144,28 @@ class PaginationLinks(pdt.BaseModel): JsonLinkType | None, pdt.Field( description='The first page of data.', - examples=['../{type}?page=1'], + examples=['../{type}?page[offset]=0&page[limit]=10'], ), ] = None prev: t.Annotated[ JsonLinkType | None, pdt.Field( description='The previous page of data.', - examples=['../{type}?page=1'], + examples=['../{type}?page[offset]=0&page[limit]=10'], ), ] = None next: t.Annotated[ JsonLinkType | None, pdt.Field( description='The next page of data.', - examples=['../{type}?page=3'], + examples=['../{type}?page[offset]=20&page[limit]=10'], ), ] = None last: t.Annotated[ JsonLinkType | None, pdt.Field( description='The last page of data.', - examples=['../{type}?page=10'], + examples=['../{type}?page[offset]=90&page[limit]=10'], ), ] = None diff --git a/aiida_restapi/models/node.py b/aiida_restapi/models/node.py index ae7a4c2e..025ec22b 100644 --- a/aiida_restapi/models/node.py +++ b/aiida_restapi/models/node.py @@ -51,7 +51,7 @@ class NodeType(pdt.BaseModel): ) nodes: str = pdt.Field( description='The URL to access nodes of this type.', - examples=['../nodes?filters={"node_type":{"data.core.int.Int."}}'], + examples=['../nodes?filter[node_type]=data.core.int.Int.'], ) projections: str = pdt.Field( description='The URL to access projectable properties of this node type.', diff --git a/aiida_restapi/routers/nodes.py b/aiida_restapi/routers/nodes.py index 5086aa02..a8978d5c 100644 --- a/aiida_restapi/routers/nodes.py +++ b/aiida_restapi/routers/nodes.py @@ -193,7 +193,7 @@ async def get_node_types() -> list: { 'label': model_registry.get_node_class_name(node_type), 'node_type': node_type, - 'nodes': f'{api_prefix}/nodes?filters={{"node_type":"{node_type}"}}', + 'nodes': f'{api_prefix}/nodes?filter[node_type]={node_type}', 'projections': f'{api_prefix}/nodes/projections?type={node_type}', 'node_schema': f'{api_prefix}/nodes/schema?type={node_type}', } diff --git a/aiida_restapi/services/entity.py b/aiida_restapi/services/entity.py index e0bfea8e..ea734a1a 100644 --- a/aiida_restapi/services/entity.py +++ b/aiida_restapi/services/entity.py @@ -11,7 +11,7 @@ from aiida_restapi.common.exceptions import QueryBuilderException from aiida_restapi.common.pagination import PaginatedResults -from aiida_restapi.common.query import QueryBuilderParams +from aiida_restapi.common.query import QueryParams from aiida_restapi.common.types import EntityIdentifier, EntityModelType, EntityType @@ -89,11 +89,11 @@ def load_one(self, identifier: EntityIdentifier) -> EntityType: """ return self.entity_class.collection.get(**self._lookup_kwargs(identifier)) - def get_many(self, query_params: QueryBuilderParams) -> PaginatedResults[dict[str, t.Any]]: + def get_many(self, query_params: QueryParams) -> PaginatedResults[dict[str, t.Any]]: """Get AiiDA entities with optional filtering, sorting, and/or pagination. :param query_params: The query parameters for filtering, sorting, and pagination. - :type query_params: QueryBuilderParams + :type query_params: QueryParams :return: The paginated results, including total count, current page, page size, and list of serialized entities. :rtype: PaginatedResults """ @@ -102,16 +102,16 @@ def get_many(self, query_params: QueryBuilderParams) -> PaginatedResults[dict[st results = self.entity_class.collection.query( filters=query_params.filters, order_by=query_params.order_by, - limit=query_params.page_size, - offset=query_params.page_size * (query_params.page - 1), + limit=query_params.limit, + offset=query_params.offset, project=self.project, ).dict() except Exception as exception: raise QueryBuilderException(str(exception)) from exception return PaginatedResults( total=total, - page=query_params.page, - page_size=len(results), + offset=query_params.offset, + limit=query_params.limit, data=[next(iter(result.values())) for result in results], ) @@ -160,7 +160,7 @@ def get_related_many( self, identifier: EntityIdentifier, related_type: type[orm.Entity], - query_params: QueryBuilderParams, + query_params: QueryParams, ) -> PaginatedResults[dict[str, t.Any]]: """Get related foreign entities of an entity. @@ -168,15 +168,15 @@ def get_related_many( :type identifier: EntityIdentifier :param related_type: The related AiiDA ORM entity class to retrieve. :type related_type: type[orm.Entity] - :param query_params: The query parameters, including filters, order_by, page_size, and page. + :param query_params: The query parameters, including filters, sorting, offset, and limit. :type query_params: QueryParams :return: The paginated results of related foreign entities. :rtype: PaginatedResults """ qb = ( orm.QueryBuilder( - limit=query_params.page_size, - offset=query_params.page_size * (query_params.page - 1), + limit=query_params.limit, + offset=query_params.offset, ) .append( self.entity_class, @@ -204,8 +204,8 @@ def get_related_many( return PaginatedResults( total=total, - page=query_params.page, - page_size=len(results), + offset=query_params.offset, + limit=query_params.limit, data=[next(iter(result.values())) for result in results], ) diff --git a/aiida_restapi/services/node.py b/aiida_restapi/services/node.py index 9028c7e9..af174917 100644 --- a/aiida_restapi/services/node.py +++ b/aiida_restapi/services/node.py @@ -20,7 +20,7 @@ from aiida_restapi.common.exceptions import QueryBuilderException from aiida_restapi.common.pagination import PaginatedResults -from aiida_restapi.common.query import QueryBuilderParams +from aiida_restapi.common.query import QueryParams from aiida_restapi.common.types import NodeModelType, NodeType from aiida_restapi.config import API_CONFIG @@ -150,14 +150,14 @@ def get_links( self, identifier: int | UUID, direction: t.Literal['incoming', 'outgoing'], - query_params: QueryBuilderParams = QueryBuilderParams(), + query_params: QueryParams = QueryParams(), ) -> PaginatedResults[dict[str, t.Any]]: """Get the incoming links of a node. :param identifier: The identifier of the node to retrieve links for. :type identifier: int | UUID :param query_params: The query parameters for filtering, sorting, and pagination. - :type query_params: QueryBuilderParams + :type query_params: QueryParams :param direction: Specify whether to retrieve incoming or outgoing links. :type direction: str :return: The paginated requested linked nodes. @@ -168,8 +168,8 @@ def get_links( qb = ( orm.QueryBuilder( - limit=query_params.page_size, - offset=query_params.page_size * (query_params.page - 1), + limit=query_params.limit, + offset=query_params.offset, ) .append( orm.Node, @@ -216,8 +216,8 @@ def get_links( return PaginatedResults( total=total, - page=query_params.page, - page_size=len(data), + offset=query_params.offset, + limit=query_params.limit, data=data, ) diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 4f236d2e..2fb7dab0 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -141,8 +141,10 @@ def test_get_nodes(client: TestClient): @pytest.mark.usefixtures('default_nodes') def test_get_nodes_by_type(client: TestClient): """Test listing existing nodes by type.""" - filters = {'node_type': {'in': ['data.core.int.Int.', 'data.core.float.Float.']}} - response = client.get(f'/nodes?filters={json.dumps(filters)}') + response = client.get( + '/nodes', + params={'filter[node_type][in]': '["data.core.int.Int.", "data.core.float.Float."]'}, + ) assert response.status_code == 200 data = response.json()['data'] assert len(data) == 2 @@ -153,8 +155,7 @@ def test_get_nodes_by_type(client: TestClient): @pytest.mark.usefixtures('default_nodes') def test_get_nodes_with_filters(client: TestClient): """Test listing existing nodes with filters.""" - filters = {'attributes.value': 1.1} - response = client.get(f'/nodes?filters={json.dumps(filters)}') + response = client.get('/nodes', params={'filter[attributes.value]': '1.1'}) assert response.status_code == 200 data = response.json()['data'] assert len(data) == 1 @@ -169,8 +170,7 @@ def test_get_nodes_with_filters(client: TestClient): @pytest.mark.usefixtures('default_nodes') def test_get_nodes_in_order(client: TestClient): """Test listing existing nodes in order.""" - order_by = {'ctime': 'desc'} - response = client.get(f'/nodes?order_by={json.dumps(order_by)}') + response = client.get('/nodes?sort=-ctime') assert response.status_code == 200 data = response.json()['data'] assert len(data) == 4 @@ -181,17 +181,30 @@ def test_get_nodes_in_order(client: TestClient): @pytest.mark.usefixtures('default_nodes') def test_get_nodes_pagination(client: TestClient): """Test listing existing nodes with pagination.""" - response = client.get('/nodes?page_size=2&page=1') + response = client.get('/nodes?page[limit]=2&page[offset]=0') assert response.status_code == 200 - data = response.json()['data'] + document = response.json() + data = document['data'] assert len(data) == 2 assert all(result['attributes']['pk'] in (1, 2) for result in data) + assert document['meta'] == {'total': 4, 'offset': 0, 'limit': 2} + assert 'page[offset]=2&page[limit]=2' in document['links']['next'] - check = client.get('/nodes?page_size=2&page=2') + check = client.get('/nodes?page[limit]=2&page[offset]=2') assert check.status_code == 200 - data = check.json()['data'] + document = check.json() + data = document['data'] assert len(data) == 2 assert all(result['attributes']['pk'] in (3, 4) for result in data) + assert document['meta'] == {'total': 4, 'offset': 2, 'limit': 2} + assert 'next' not in document['links'] + + +def test_get_nodes_rejects_invalid_filter(client: TestClient): + """Test malformed filter parameters return a JSON:API error document.""" + response = client.get('/nodes', params={'filter[label][unknown]': 'value'}) + assert response.status_code == 422 + assert response.json()['errors'][0]['status'] == '422' def test_get_node_types(client: TestClient): diff --git a/tests/test_query.py b/tests/test_query.py new file mode 100644 index 00000000..6078a620 --- /dev/null +++ b/tests/test_query.py @@ -0,0 +1,129 @@ +"""Tests for REST API query parameter parsing.""" + +from urllib.parse import urlencode + +import pytest +from aiida.common.exceptions import InputValidationError +from starlette.requests import Request + +from aiida_restapi.common.query import querybuilder_params + + +def make_request(params: list[tuple[str, str]]) -> Request: + """Return a request with the given query parameters.""" + return Request({'type': 'http', 'method': 'GET', 'path': '/nodes', 'query_string': urlencode(params).encode()}) + + +def parse(params: list[tuple[str, str]]): + """Parse query parameters using defaults for sorting and pagination.""" + return querybuilder_params(make_request(params)) + + +@pytest.mark.parametrize( + 'parameter,operator', + [ + ('filter[value]', '=='), + ('filter[value][==]', '=='), + ('filter[value][!==]', '!=='), + ('filter[value][<]', '<'), + ('filter[value][<=]', '<='), + ('filter[value][>]', '>'), + ('filter[value][>=]', '>='), + ('filter[value][like]', 'like'), + ('filter[value][ilike]', 'ilike'), + ('filter[value][of_length]', 'of_length'), + ('filter[value][shorter]', 'shorter'), + ('filter[value][longer]', 'longer'), + ('filter[value][has_key]', 'has_key'), + ], +) +def test_filter_operators(parameter: str, operator: str): + """Test mapping HTTP filter operators to QueryBuilder operators.""" + raw = '2' if operator in {'<', '<=', '>', '>=', 'of_length', 'shorter', 'longer'} else 'value' + assert parse([(parameter, raw)]).filters == {'value': {operator: 2 if raw == '2' else raw}} + + +def test_filter_typed_values_and_dot_path(): + """Test JSON scalar decoding and nested dictionary dot paths.""" + result = parse( + [ + ('filter[attributes.config.enabled]', 'true'), + ('filter[attributes.config.timeout][>=]', '30'), + ] + ) + assert result.filters == { + 'attributes.config.enabled': {'==': True}, + 'attributes.config.timeout': {'>=': 30}, + } + + +def test_filter_in_json_list(): + """Test membership filters accept JSON lists.""" + assert parse([('filter[value][in]', '[1, 2, 3]')]).filters == {'value': {'in': [1, 2, 3]}} + assert parse([('filter[value][in]', '["blah", "bl,ah"]')]).filters == {'value': {'in': ['blah', 'bl,ah']}} + + +def test_filter_contains_json_list(): + """Test array containment filters accept JSON lists.""" + assert parse([('filter[value][contains]', '["one", 2, true]')]).filters == {'value': {'contains': ['one', 2, True]}} + + +@pytest.mark.parametrize('operator', ['in', '!in', 'contains', '!contains']) +@pytest.mark.parametrize('value', ['', 'one,two', '"one"', '1', '{}']) +def test_filter_list_requires_json_list(operator: str, value: str): + """Test list operators reject malformed JSON and non-list values.""" + with pytest.raises(InputValidationError, match='requires a JSON list'): + parse([(f'filter[value][{operator}]', value)]) + + +@pytest.mark.parametrize('operator', ['in', '!in', 'contains', '!contains']) +def test_filter_list_requires_values(operator: str): + """Test list operators reject an empty JSON list.""" + with pytest.raises(InputValidationError, match='requires at least one value'): + parse([(f'filter[value][{operator}]', '[]')]) + + +def test_filter_repeated_in(): + """Test repeated membership filters are flattened.""" + result = parse([('filter[value][in]', '["one", "two"]'), ('filter[value][in]', '["three"]')]) + assert result.filters == {'value': {'in': ['one', 'two', 'three']}} + + +def test_filter_contains_and_repeated_constraints(): + """Test structural containment and AND aggregation of repeated constraints.""" + result = parse( + [ + ('filter[attributes.tags][contains]', '["one"]'), + ('filter[attributes.tags][contains]', '["two"]'), + ] + ) + assert result.filters == { + 'attributes.tags': {'and': [{'contains': ['one']}, {'contains': ['two']}]}, + } + + +def test_filter_negation(): + """Test leaf negation.""" + result = parse( + [ + ('filter[label][!like]', 'test%'), + ('filter[pk][!==]', '2'), + ] + ) + assert result.filters == {'label': {'!like': 'test%'}, 'pk': {'!==': 2}} + + +@pytest.mark.parametrize( + 'parameter', + ['filter', 'filter[]', 'filter[value][unknown]', 'filter[value][!]'], +) +def test_invalid_filter_parameter(parameter: str): + """Test malformed filters and unknown operators are rejected.""" + with pytest.raises(InputValidationError): + parse([(parameter, 'value')]) + + +def test_sort(): + """Test ascending and descending sort fields.""" + result = querybuilder_params(make_request([('sort', 'label,-ctime')]), sort='label,-ctime') + assert result.order_by == {'label': 'asc', 'ctime': 'desc'}