Skip to content

Commit da30d4a

Browse files
authored
Merge pull request #3 from flusflas/feature/dynamic-expressions
Adds `$eval()` for evaluating complex expressions.
2 parents 1b541c4 + cf04427 commit da30d4a

File tree

23 files changed

+1065
-210
lines changed

23 files changed

+1065
-210
lines changed

CHANGELOG.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,33 @@
22

33
All notable changes to this project will be documented in this file.
44

5-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5+
> ⚠️ **Warning: Pre-1.0.0 Release**
6+
>
7+
> This project is in early development. Breaking changes may occur in any minor
8+
> or patch release until version 1.0.0. Follow changelogs closely and pin
9+
> versions if needed.
10+
11+
## [0.4.0](https://github.com/flusflas/apier/tree/v0.4.0) (2025-08-04)
12+
13+
This release adds `$eval` support for dynamic expressions, allowing complex
14+
evaluations and manipulations of request and response data, and unlocking
15+
advanced pagination strategies.
16+
17+
### 🧨 Breaking Changes
18+
19+
- Renamed `reuse-previous-request` to `reuse_previous_request` in pagination
20+
configuration for naming consistency.
21+
- Enforced `#` prefix for dot-separated paths in dynamic expressions (e.g.,
22+
`#users.0.name` instead of `users.0.name`).
23+
24+
### Added
25+
26+
- Simple expression evaluation with `ast` module.
27+
- Support for nullish coalescing operator (`??`) in dynamic expressions.
28+
29+
### Fixed
30+
31+
- Evaluation of `results` parameter in pagination configuration.
732

833
## [0.3.0](https://github.com/flusflas/apier/tree/v0.3.0) (2025-07-31)
934

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
apier provides a command-line interface (CLI) for building client libraries and merging OpenAPI documents.
1515

16+
> 🐣 This project is in pre-1.0.0. Expect breaking changes in minor and patch versions.
17+
1618
## 🐍 Installation
1719

1820
apier is available on PyPI:

apier/extensions/pagination.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class PaginationDescription(BaseModel):
99
class Config:
1010
allow_population_by_field_name = True
1111

12-
reuse_previous_request: bool = Field(default=False, alias="reuse-previous-request")
12+
reuse_previous_request: bool = Field(default=False, alias="reuse_previous_request")
1313
method: str = ""
1414
url: str = ""
1515
modifiers: List[PaginationModifier] = Field(default_factory=list)
@@ -20,13 +20,13 @@ class Config:
2020
def validate_fields(cls, values: dict):
2121
if isinstance(values, PaginationDescription):
2222
values = values.dict()
23-
reuse = values.get("reuse-previous-request") or values.get(
23+
reuse = values.get("reuse_previous_request") or values.get(
2424
"reuse_previous_request"
2525
)
2626
for attr in ["method", "url"]:
2727
if not reuse and not values[attr]:
2828
raise ValueError(
29-
f"The field '{attr}' is required if 'reuse-previous-request' is False"
29+
f"The field '{attr}' is required if 'reuse_previous_request' is False"
3030
)
3131
return values
3232

apier/templates/python_tree/base/internal/expressions/__init__.py

Whitespace-only changes.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import ast
2+
import operator
3+
4+
# Supported binary operators
5+
allowed_operators = {
6+
ast.Add: operator.add,
7+
ast.Sub: operator.sub,
8+
ast.Mult: operator.mul,
9+
ast.Div: operator.truediv,
10+
ast.USub: operator.neg, # Unary minus
11+
}
12+
13+
# Supported comparison operators
14+
allowed_comparators = {
15+
ast.Eq: operator.eq,
16+
ast.NotEq: operator.ne,
17+
ast.Lt: operator.lt,
18+
ast.LtE: operator.le,
19+
ast.Gt: operator.gt,
20+
ast.GtE: operator.ge,
21+
}
22+
23+
# Whitelisted functions
24+
allowed_functions = {
25+
"int": int,
26+
"len": len,
27+
"round": lambda x: int(round(x)),
28+
"floor": lambda x: int(x // 1),
29+
"ceil": lambda x: int(-(-x // 1)),
30+
}
31+
32+
# Constants
33+
allowed_constants = {
34+
"true": True,
35+
"false": False,
36+
"null": None,
37+
}
38+
39+
40+
def eval_expr(expr, vars=None):
41+
"""
42+
Evaluates a compound expression using AST with a restricted subset of
43+
Python syntax:
44+
- Arithmetic (+, -, *, /, unary -)
45+
- Function calls (`int`, `len`, `round`, `floor`, `ceil`)
46+
- Comparisons (==, !=, <, <=, >, >=)
47+
48+
Note: This implementation is a basic and temporary solution based on Python syntax.
49+
It has limited extensibility and is not designed to be portable across other languages.
50+
For more complex use cases, a dedicated expression language or library would be required.
51+
52+
Parameters:
53+
expr (str): The expression string to evaluate.
54+
vars (dict, optional): A dictionary of variables to use in the expression.
55+
56+
Returns:
57+
The result of evaluating the expression.
58+
"""
59+
# Parse the expression into an AST
60+
try:
61+
tree = ast.parse(expr, mode="eval")
62+
except SyntaxError as e:
63+
raise SyntaxError(f"Invalid expression syntax: {e.msg}") from e
64+
65+
vars = vars.copy() if vars is not None else {}
66+
vars.update(allowed_constants)
67+
68+
def _eval(node: ast.AST):
69+
"""Recursively evaluate supported AST nodes."""
70+
71+
if isinstance(node, ast.Constant):
72+
if isinstance(node.value, complex):
73+
raise ValueError("Complex numbers are not supported.")
74+
if node.value is True or node.value is False or node.value is None:
75+
raise ValueError(f"Unsupported constant: {node.value}")
76+
return node.value
77+
78+
elif isinstance(node, ast.Name):
79+
# Variable reference
80+
if vars is not None and node.id in vars:
81+
return vars[node.id]
82+
else:
83+
raise ValueError(f"Variable not defined: {node.id}")
84+
85+
elif isinstance(node, ast.BinOp) and type(node.op) in allowed_operators:
86+
left = _eval(node.left)
87+
right = _eval(node.right)
88+
return allowed_operators[type(node.op)](left, right)
89+
90+
elif isinstance(node, ast.UnaryOp) and type(node.op) in allowed_operators:
91+
operand = _eval(node.operand)
92+
return allowed_operators[type(node.op)](operand)
93+
94+
elif isinstance(node, ast.Call):
95+
# Only allow calls to whitelisted functions
96+
if isinstance(node.func, ast.Name) and node.func.id in allowed_functions:
97+
func = allowed_functions[node.func.id]
98+
args = [_eval(arg) for arg in node.args]
99+
return func(*args)
100+
else:
101+
raise ValueError(f"Function not allowed: '{node.func.id}'")
102+
103+
elif isinstance(node, ast.List):
104+
return [_eval(elt) for elt in node.elts]
105+
106+
elif isinstance(node, ast.Tuple):
107+
return tuple(_eval(elt) for elt in node.elts)
108+
109+
elif isinstance(node, ast.Compare):
110+
# Only support simple comparisons (not chained comparisons)
111+
if len(node.ops) != 1 or len(node.comparators) != 1:
112+
raise ValueError("Only simple comparisons are supported.")
113+
op = node.ops[0]
114+
if type(op) not in allowed_comparators:
115+
raise ValueError(
116+
f"Comparison operator not allowed: {type(op).__name__}"
117+
)
118+
left = _eval(node.left)
119+
right = _eval(node.comparators[0])
120+
return allowed_comparators[type(op)](left, right)
121+
122+
else:
123+
raise SyntaxError(f"Unsupported syntax: {type(node).__name__}")
124+
125+
return _eval(tree.body)

apier/templates/python_tree/base/internal/runtime_expr.py renamed to apier/templates/python_tree/base/internal/expressions/runtime.py

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from requests import PreparedRequest, Request, Response
88

9+
from .evaluation import eval_expr
10+
911

1012
class RuntimeExpressionError(Exception):
1113
"""Invalid runtime expression."""
@@ -21,6 +23,7 @@ def evaluate(
2123
path_values: dict = None,
2224
query_param_types: dict = None,
2325
header_param_types: dict = None,
26+
**kwargs,
2427
):
2528
"""
2629
Evaluates an OpenAPI runtime expression (https://swagger.io/docs/specification/links/).
@@ -45,23 +48,85 @@ def evaluate(
4548
will be returned.
4649
:return: The result of the evaluated expression.
4750
"""
51+
52+
# The 'eval_variables' is a dictionary used to store variables when
53+
# evaluating expressions within an $eval() expression. The result of the
54+
# evaluation will be stored in this dictionary. This parameter is intended
55+
# for internal use only and is not meant to be accessed directly outside the
56+
# function.
57+
eval_variables = kwargs.get("eval_variables")
58+
is_eval = eval_variables is not None
59+
4860
try:
4961
expression = expression.strip()
5062

63+
if expression.startswith("$eval("):
64+
if is_eval:
65+
raise RuntimeExpressionError(
66+
caused_by=ValueError(
67+
"Nested evaluation expressions are not supported"
68+
)
69+
)
70+
71+
inner_expression = expression[len("$eval(") : -1].strip()
72+
eval_variables = {}
73+
expr = evaluate(
74+
resp,
75+
inner_expression,
76+
path_values=path_values,
77+
query_param_types=query_param_types,
78+
header_param_types=header_param_types,
79+
eval_variables=eval_variables,
80+
)
81+
82+
return eval_expr(expr, eval_variables)
83+
5184
if "{" in expression:
5285

53-
def replace_group(match):
54-
return str(
55-
_evaluate_runtime_expression(
86+
def replace_match(match):
87+
default_value_defined = " ?? " in match
88+
89+
# Handle coalescing operator (??) to provide a default value
90+
if default_value_defined:
91+
match, default_value = map(str.strip, match.split(" ?? ", 1))
92+
default_value = eval_expr(default_value)
93+
94+
try:
95+
group_result = evaluate(
5696
resp,
57-
match.group(1),
97+
match,
5898
path_values=path_values,
5999
query_param_types=query_param_types,
60100
header_param_types=header_param_types,
61101
)
62-
)
63-
64-
return re.sub(r"{(\$[^}]+)}", replace_group, expression)
102+
except RuntimeExpressionError as e:
103+
if default_value_defined and isinstance(
104+
e.caused_by, (KeyError, IndexError)
105+
):
106+
group_result = default_value
107+
else:
108+
raise e
109+
110+
# If inside an $eval(), the expression must be replaced with
111+
# variables instead of the group result
112+
if is_eval:
113+
var_name = f"var{len(eval_variables)}"
114+
eval_variables[var_name] = group_result
115+
return var_name
116+
117+
return group_result
118+
119+
parts = re.split(r"({\$?[^}]+})", expression)
120+
parts = [part for part in parts if part]
121+
for i, part in enumerate(parts):
122+
if part.startswith("{") and part.endswith("}"):
123+
parts[i] = replace_match(part[1:-1])
124+
return parts[0] if len(parts) == 1 else "".join(map(str, parts))
125+
126+
# If inside an $eval() expression, any subexpressions (enclosed in {})
127+
# should already have been resolved, so the expression can be returned as is
128+
if is_eval:
129+
return expression
65130

66131
if expression.startswith("$"):
67132
return _evaluate_runtime_expression(
@@ -72,15 +137,22 @@ def replace_group(match):
72137
header_param_types=header_param_types,
73138
)
74139

75-
if isinstance(resp, Response):
76-
resp = resp.json()
140+
if expression.startswith("#"):
141+
# Dot-separated path
142+
if isinstance(resp, Response):
143+
resp = resp.json()
144+
145+
if not isinstance(resp, dict):
146+
raise ValueError("Invalid response format")
77147

78-
if not isinstance(resp, dict):
79-
raise ValueError("Invalid dict response")
148+
return _get_from_dict(resp, expression[1:].strip())
149+
150+
# Return the expression as a literal value
151+
return expression
80152

81-
return _get_from_dict(resp, expression)
82153
except RuntimeExpressionError as e:
83154
raise e
155+
84156
except Exception as e:
85157
raise RuntimeExpressionError(caused_by=e)
86158

@@ -112,7 +184,9 @@ def get_query_string(name):
112184
query_params = parse_qs(parsed_url.query)
113185
value = query_params.get(name, [])
114186
if len(value) == 0:
115-
raise RuntimeExpressionError(f"Query parameter '{name}' not found")
187+
raise RuntimeExpressionError(
188+
caused_by=KeyError(f"Query parameter '{name}' not found")
189+
)
116190
elif len(value) == 1:
117191
return value[0]
118192
else:
@@ -121,7 +195,9 @@ def get_query_string(name):
121195
def get_path_value(name):
122196
if path_values is not None and name in path_values:
123197
return path_values[name]
124-
raise RuntimeExpressionError(f"Path parameter '{name}' not found")
198+
raise RuntimeExpressionError(
199+
caused_by=KeyError(f"Path parameter '{name}' not found")
200+
)
125201

126202
expression_funcs = {
127203
"$url": lambda: resp.request.url,
@@ -151,7 +227,7 @@ def get_path_value(name):
151227
if expr == expression:
152228
return fn()
153229

154-
raise RuntimeExpressionError("invalid runtime expression")
230+
raise RuntimeExpressionError(caused_by=ValueError("Invalid runtime expression"))
155231

156232

157233
def prepare_request(req: Union[PreparedRequest, Request], expression: str, value):
@@ -237,6 +313,9 @@ def set_query_param(name):
237313

238314

239315
def _get_from_dict(d: dict, key: str, separator="."):
316+
if key == "":
317+
return d
318+
240319
try:
241320

242321
def get_item(a, b):

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
content_types_compatible,
2222
ContentTypeValidationResult,
2323
)
24-
from .runtime_expr import evaluate, prepare_request
24+
from .expressions.runtime import evaluate, prepare_request
2525
from ..models.basemodel import APIBaseModel
2626
from ..models.exceptions import ExceptionList, ResponseError
2727
from ..models.extensions.pagination import PaginationDescription
@@ -269,10 +269,8 @@ def _handle_pagination(
269269
)
270270
req.prepare_url(url, None)
271271
if pagination_info.method:
272-
# TODO: Evaluate expression ???
273272
req.prepare_method(pagination_info.method)
274273
for modifier in pagination_info.modifiers:
275-
# TODO: Evaluate complex expressions
276274
value = evaluate(resp, modifier.value, path_values, query_params, headers)
277275
prepare_request(req, modifier.param, value)
278276

0 commit comments

Comments
 (0)