Skip to content

Commit 0d6c8e6

Browse files
committed
refactor: improve pagination handling and input validation
Ensure pagination modifiers and parameters are nullable, simplify case handling with utility method, and improve mutual exclusivity validation. Updated tests and templates to reflect the changes, including consistent snake_case for operation IDs.
1 parent e9591d9 commit 0d6c8e6

File tree

7 files changed

+139
-33
lines changed

7 files changed

+139
-33
lines changed

apier/extensions/pagination.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import List, Optional
3+
from typing import List, Optional, Callable
44

55
from pydantic import BaseModel, Field, root_validator
66

@@ -42,19 +42,19 @@ class Config:
4242
description="The operation ID of the next API operation, as defined in the OpenAPI spec.",
4343
)
4444
parameters: Optional[List[NextOperationParameters]] = Field(
45-
[],
45+
None,
4646
description="Parameters to be passed to the next operation. If required "
4747
"parameters are not provided, such as path parameters, the operation "
4848
"will fail.",
4949
)
5050

5151
# Modifiers mode
52-
reuse_previous_request: bool = Field(
53-
default=False,
52+
reuse_previous_request: Optional[bool] = Field(
53+
None,
5454
description="Whether the next request should reuse the previous request's parameters.",
5555
)
56-
modifiers: List[PaginationModifier] = Field(
57-
default_factory=list,
56+
modifiers: Optional[List[PaginationModifier]] = Field(
57+
None,
5858
description="List of request modifiers to update parameters for the next request.",
5959
)
6060

@@ -68,28 +68,45 @@ class Config:
6868
description="A dynamic expression that evaluates to a boolean indicating if there are more results.",
6969
)
7070

71-
@root_validator
71+
@root_validator(pre=True)
7272
def check_mutually_exclusive_fields(cls, values):
7373
"""
74-
Validates that the modes are mutually exclusive.
75-
- If either `operation_id` or `parameters` is set, use 'operation' mode.
76-
Otherwise, use 'modifiers' mode.
77-
- In 'operation' mode, `operation_id` must be present.
74+
Enforces mutual exclusivity between:
75+
- operation mode: operation_id and/or parameters
76+
- modifiers mode: reuse_previous_request and/or modifiers
77+
78+
In 'operation' mode, `operation_id` must be present.
7879
"""
7980

81+
if ("operation_id" in values or "parameters" in values) and (
82+
"reuse_previous_request" in values or "modifiers" in values
83+
):
84+
raise ValueError(
85+
"Cannot use 'operation_id' or 'parameters' with 'reuse_previous_request' or 'modifiers'."
86+
)
87+
8088
operation_id = values.get("operation_id")
8189
operation_params = values.get("parameters")
8290

8391
mode = "operation" if operation_id or operation_params else "modifiers"
8492
if mode == "operation" and not operation_id:
8593
raise ValueError("'operation_id' is required in operation mode.")
8694

87-
if mode == "operation" and not operation_id:
88-
raise ValueError(
89-
"'operation_id' is required if 'operation_params' are set."
90-
)
91-
9295
return values
9396

97+
def update_operation_case(
98+
self, func: Callable[[str], str]
99+
) -> PaginationDescription:
100+
"""
101+
Updates the case of the operation ID and parameters using the provided function.
102+
"""
103+
if self.operation_id:
104+
self.operation_id = func(self.operation_id)
105+
106+
for param in self.parameters or []:
107+
param.name = func(param.name)
108+
109+
return self
110+
94111

95112
PaginationDescription.update_forward_refs()

apier/templates/python_tree/base/internal/resource.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ def _handle_pagination(
267267
if pagination_info.reuse_previous_request:
268268
req = resp.request
269269

270-
for modifier in pagination_info.modifiers:
270+
for modifier in pagination_info.modifiers or []:
271271
value = evaluate(resp, modifier.value, path_values, query_params, headers)
272272
prepare_request(req, modifier.param, value)
273273

@@ -278,7 +278,7 @@ def _handle_pagination(
278278
op_id = pagination_info.operation_id
279279
evaluated_params = {}
280280

281-
for param in pagination_info.parameters:
281+
for param in pagination_info.parameters or []:
282282
evaluated_params[param.name] = evaluate(
283283
resp, param.value, path_values, query_params, headers
284284
)
@@ -292,10 +292,6 @@ def _handle_pagination(
292292

293293
next_op = getattr(api_operations, op_id)
294294

295-
# Check if next_op accepts a "req" parameter
296-
if "req" in next_op.__code__.co_varnames:
297-
evaluated_params["req"] = req.body
298-
299295
ret._pagination.iter_func = lambda: next_op(**evaluated_params)
300296
return
301297

apier/templates/python_tree/renderer.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,9 @@ def prepare_endpoints(self):
9191
for endpoint in self.endpoints:
9292
for op in endpoint.operations:
9393
# Convert operation name in pagination extensions to snake_case
94-
next_op_id = get_nested(
95-
op, "extensions.pagination.next.operation_id", default=None
96-
)
97-
if next_op_id:
98-
op.extensions.pagination.next.operation_id = to_snake_case(
99-
next_op_id
100-
)
94+
next_op = get_nested(op, "extensions.pagination.next", default=None)
95+
if next_op:
96+
next_op.update_operation_case(to_snake_case)
10197

10298
self.api_tree = build_endpoints_tree(self.endpoints)
10399

apier/templates/python_tree/templates/node.jinja

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class {{ api_node.api | pascal_case }}{{ loop.index }}(APIResource{% for next_no
5656
{# Pagination description #}
5757
{% set has_pagination = op.extensions and op.extensions.pagination and op.extensions.pagination.next %}
5858
{% if has_pagination %}
59-
pagination_info = PaginationDescription.parse_obj({{ op.extensions.pagination.next.dict() }})
59+
pagination_info = PaginationDescription.parse_obj({{ op.extensions.pagination.next.dict(exclude_none=True) }})
6060

6161
{% endif -%}
6262

tests/definitions/pagination_api.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,20 +205,20 @@ paths:
205205
next:
206206
operation_id: GetPaginationNextOperationCursor
207207
parameters:
208-
- name: cursor_id
208+
- name: cursor-id
209209
value: "#cursor"
210210
result: "#results"
211211
has_more: "#hasMore"
212212

213-
/pagination/next_operation/{cursor_id}:
213+
/pagination/next_operation/{cursor-id}:
214214
get:
215215
tags:
216216
- Pagination
217217
summary: Next operation with cursor ID
218218
description: Fetches the next page of results using a cursor ID.
219219
operationId: GetPaginationNextOperationCursor
220220
parameters:
221-
- name: cursor_id
221+
- name: cursor-id
222222
in: path
223223
required: true
224224
schema:

tests/test_extensions.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import pytest
2+
from pydantic import ValidationError
3+
4+
from apier.extensions.pagination import PaginationDescription
5+
6+
7+
@pytest.mark.parametrize(
8+
"data,expected_mode",
9+
[
10+
(
11+
{
12+
"operation_id": "getItems",
13+
"parameters": [{"name": "page", "value": "$response.body#/nextPage"}],
14+
"result": "$response.body#/items",
15+
"has_more": "$response.body#/hasMore",
16+
},
17+
"operation",
18+
),
19+
(
20+
{
21+
"reuse_previous_request": True,
22+
"modifiers": [{"op": "set", "param": "page", "value": "2"}],
23+
"result": "$response.body#/items",
24+
"has_more": "$response.body#/hasMore",
25+
},
26+
"modifiers",
27+
),
28+
( # Operation mode with no parameters
29+
{
30+
"operation_id": "getItems",
31+
"result": "$response.body#/items",
32+
"has_more": "$response.body#/hasMore",
33+
},
34+
"operation",
35+
),
36+
( # Modifiers mode with no modifiers
37+
{
38+
"reuse_previous_request": True,
39+
"result": "$response.body#/items",
40+
"has_more": "$response.body#/hasMore",
41+
},
42+
"modifiers",
43+
),
44+
],
45+
)
46+
def test_pagination_description_valid_modes(data, expected_mode):
47+
"""Tests that PaginationDescription can be created with valid modes."""
48+
desc = PaginationDescription(**data)
49+
if expected_mode == "operation":
50+
assert desc.operation_id is not None
51+
assert bool(desc.modifiers) == bool(data.get("modifiers"))
52+
assert desc.reuse_previous_request is None
53+
assert desc.modifiers is None
54+
else:
55+
assert desc.reuse_previous_request == data.get("reuse_previous_request")
56+
assert bool(desc.modifiers) == bool(data.get("modifiers"))
57+
assert desc.operation_id is None
58+
assert desc.parameters is None
59+
60+
61+
@pytest.mark.parametrize(
62+
"invalid_data, error_message",
63+
[
64+
( # Both operation and modifiers mode fields set
65+
{
66+
"operation_id": "getItems",
67+
"modifiers": [{"op": "set", "param": "page", "value": "2"}],
68+
"result": "$response.body#/items",
69+
"has_more": "$response.body#/hasMore",
70+
},
71+
"Cannot use 'operation_id' or 'parameters' with 'reuse_previous_request' or 'modifiers'",
72+
),
73+
( # Both parameters and reuse_previous_request set
74+
{
75+
"parameters": [{"name": "page", "value": "2"}],
76+
"reuse_previous_request": True,
77+
"result": "$response.body#/items",
78+
"has_more": "$response.body#/hasMore",
79+
},
80+
"Cannot use 'operation_id' or 'parameters' with 'reuse_previous_request' or 'modifiers'",
81+
),
82+
( # Parameters without operation_id in operation mode
83+
{
84+
"parameters": [{"name": "page", "value": "2"}],
85+
"result": "$response.body#/items",
86+
"has_more": "$response.body#/hasMore",
87+
},
88+
"'operation_id' is required in operation mode.",
89+
),
90+
],
91+
)
92+
def test_pagination_description_mutually_exclusive_modes(invalid_data, error_message):
93+
"""Tests that mutually exclusive fields raise ValidationError."""
94+
with pytest.raises(ValidationError) as e:
95+
PaginationDescription(**invalid_data)
96+
97+
assert error_message in str(e.value)
File renamed without changes.

0 commit comments

Comments
 (0)