Skip to content

Commit 9c24209

Browse files
author
Anders Brams
committed
fix: correctly serialize enum path/query params
1 parent 7f765af commit 9c24209

15 files changed

Lines changed: 327 additions & 30 deletions

File tree

‎openapi_python/generator/normalize.py‎

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -338,8 +338,40 @@ def _schema_map_to_type(
338338
), state
339339

340340

341+
def _schema_freeform_object_to_type(schema: dict) -> TypeAnnotation:
342+
return _nullable(
343+
DictAnnotation(NamedAnnotation("str"), AnyAnnotation()),
344+
bool(schema.get("nullable")),
345+
)
346+
347+
348+
def _enum_values_for_schema(state: _TypeState, schema: dict) -> tuple[object, ...]:
349+
if "enum" in schema and isinstance(schema["enum"], list):
350+
return tuple(schema["enum"])
351+
352+
ref = schema.get("$ref")
353+
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
354+
component = ref.rsplit("/", 1)[-1]
355+
component_schema = safe_get(state.components, "schemas", component, type=dict)
356+
if component_schema is not None and isinstance(
357+
component_schema.get("enum"), list
358+
):
359+
return tuple(component_schema["enum"])
360+
361+
return ()
362+
363+
364+
def _with_enum_values(
365+
state: _TypeState, schema: dict, annotation: TypeAnnotation
366+
) -> TypeAnnotation:
367+
values = _enum_values_for_schema(state, schema)
368+
if not values:
369+
return annotation
370+
return _union((annotation, LiteralAnnotation(values)))
371+
372+
341373
def _schema_object_to_type(
342-
state: _TypeState, schema: dict, hint: str
374+
state: _TypeState, schema: dict, hint: str, *, allow_enum_values: bool = False
343375
) -> tuple[TypeAnnotation, _TypeState]:
344376
nullable = bool(schema.get("nullable"))
345377
name = _type_name_from_hint(hint)
@@ -356,8 +388,13 @@ def _schema_object_to_type(
356388
prop_title = str(safe_get(prop_schema, "title") or prop_name)
357389
prop_schema = safe_get(props, prop_name, type=dict) or {}
358390
field_type, state = _schema_to_type(
359-
state, prop_schema, f"{name}{_pascal(prop_title)}"
391+
state,
392+
prop_schema,
393+
f"{name}{_pascal(prop_title)}",
394+
allow_enum_values=allow_enum_values,
360395
)
396+
if allow_enum_values:
397+
field_type = _with_enum_values(state, prop_schema, field_type)
361398
fields.append(
362399
FieldDef(
363400
name=prop_name,
@@ -385,6 +422,7 @@ def _schema_to_type(
385422
hint: str,
386423
*,
387424
component_name: str | None = None,
425+
allow_enum_values: bool = False,
388426
) -> tuple[TypeAnnotation, _TypeState]:
389427
"""
390428
Takes a JSON schema object and returns the corresponding Python type
@@ -425,16 +463,17 @@ def _schema_to_type(
425463
if schema_type == "array":
426464
return _schema_array_to_type(state, schema, hint)
427465

428-
additional_properties = safe_get(schema, "additionalProperties", type=dict)
429-
if (
430-
schema_type == "object"
431-
and "properties" not in schema
432-
and additional_properties is not None
433-
):
434-
return _schema_map_to_type(state, schema, hint)
466+
additional_properties = schema.get("additionalProperties")
467+
if schema_type == "object" and "properties" not in schema:
468+
if additional_properties is True:
469+
return _schema_freeform_object_to_type(schema), state
470+
if isinstance(additional_properties, dict):
471+
return _schema_map_to_type(state, schema, hint)
435472

436473
if schema_type == "object" or "properties" in schema:
437-
return _schema_object_to_type(state, schema, hint)
474+
return _schema_object_to_type(
475+
state, schema, hint, allow_enum_values=allow_enum_values
476+
)
438477

439478
base = _PRIMITIVES.get(str(schema_type), "Any")
440479
annotation: TypeAnnotation = (
@@ -444,9 +483,11 @@ def _schema_to_type(
444483

445484

446485
def _schema_type(
447-
state: _TypeState, schema: dict, hint: str
486+
state: _TypeState, schema: dict, hint: str, *, allow_enum_values: bool = False
448487
) -> tuple[TypeAnnotation, _TypeState]:
449-
return _schema_to_type(state, schema or {}, hint)
488+
return _schema_to_type(
489+
state, schema or {}, hint, allow_enum_values=allow_enum_values
490+
)
450491

451492

452493
def _path_symbol(path: str) -> str:
@@ -547,6 +588,7 @@ def _bucket_type(
547588
"required": list(bucket.required),
548589
},
549590
hint,
591+
allow_enum_values=True,
550592
)
551593

552594

‎openapi_python/generator/render.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,7 @@ def _route_aliases(
471471
def _render_transport(spec: NormalizedSpec, *, protocol_only: bool) -> str:
472472
return _render_template(
473473
"transport.py.j2",
474-
typing_imports="Protocol" if protocol_only else "TYPE_CHECKING, Protocol",
474+
typing_imports=("Protocol" if protocol_only else "TYPE_CHECKING, Protocol"),
475475
include_default_transport=not protocol_only,
476476
)
477477

‎openapi_python/generator/runtime_default.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
from __future__ import annotations
22

33
from collections.abc import Mapping
4+
from enum import Enum
45
from typing import TYPE_CHECKING
56

67
if TYPE_CHECKING:
78
import httpx
89

910

11+
def _serialize_value(value: object) -> object:
12+
if isinstance(value, Enum):
13+
return value.value
14+
return value
15+
16+
17+
def _serialize_mapping(values: Mapping[str, object] | None) -> dict[str, object]:
18+
return {key: _serialize_value(value) for key, value in (values or {}).items()}
19+
20+
1021
class RuntimeDefaultTransport:
1122
def __init__(self, *, client: httpx.Client | None = None) -> None:
1223
if client is None:
@@ -26,11 +37,16 @@ def request(
2637
headers: Mapping[str, object] | None,
2738
body: object | None,
2839
) -> object:
29-
query_dict = {key: str(value) for key, value in (query or {}).items()}
30-
header_dict = {key: str(value) for key, value in (headers or {}).items()}
40+
path_params = _serialize_mapping(params)
41+
query_dict = {
42+
key: str(value) for key, value in _serialize_mapping(query).items()
43+
}
44+
header_dict = {
45+
key: str(value) for key, value in _serialize_mapping(headers).items()
46+
}
3147
response = self._client.request(
3248
method=method.upper(),
33-
url=f"{base_url.rstrip('/')}{route.format(**(params or {}))}",
49+
url=f"{base_url.rstrip('/')}{route.format(**path_params)}",
3450
params=query_dict or None,
3551
headers=header_dict or None,
3652
json=body,

‎openapi_python/generator/templates/default_transport.py.j2‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
def _serialize_value(value: object) -> object:
2+
if isinstance(value, Enum):
3+
return value.value
4+
return value
5+
6+
7+
def _serialize_mapping(values: Mapping[str, object] | None) -> dict[str, object]:
8+
return {key: _serialize_value(value) for key, value in (values or {}).items()}
9+
10+
111
class DefaultTransport:
212
def __init__(self, *, client: httpx.Client | None = None) -> None:
313
if client is None:
@@ -17,9 +27,10 @@ class DefaultTransport:
1727
headers: Mapping[str, object] | None,
1828
body: object | None,
1929
) -> object:
20-
formatted_route = route.format(**(params or {}))
21-
query_dict = {key: str(value) for key, value in (query or {}).items()}
22-
header_dict = {key: str(value) for key, value in (headers or {}).items()}
30+
path_params = _serialize_mapping(params)
31+
formatted_route = route.format(**path_params)
32+
query_dict = {key: str(value) for key, value in _serialize_mapping(query).items()}
33+
header_dict = {key: str(value) for key, value in _serialize_mapping(headers).items()}
2334
request_kwargs: dict[str, object] = {"json": body}
2435
if isinstance(body, Mapping) and any(
2536
isinstance(value, (bytes, bytearray)) for value in body.values()
@@ -70,9 +81,10 @@ class DefaultAsyncTransport:
7081
headers: Mapping[str, object] | None,
7182
body: object | None,
7283
) -> object:
73-
formatted_route = route.format(**(params or {}))
74-
query_dict = {key: str(value) for key, value in (query or {}).items()}
75-
header_dict = {key: str(value) for key, value in (headers or {}).items()}
84+
path_params = _serialize_mapping(params)
85+
formatted_route = route.format(**path_params)
86+
query_dict = {key: str(value) for key, value in _serialize_mapping(query).items()}
87+
header_dict = {key: str(value) for key, value in _serialize_mapping(headers).items()}
7688
request_kwargs: dict[str, object] = {"json": body}
7789
if isinstance(body, Mapping) and any(
7890
isinstance(value, (bytes, bytearray)) for value in body.values()

‎openapi_python/generator/templates/transport.py.j2‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from collections.abc import Mapping
4+
from enum import Enum
45
from typing import {{ typing_imports }}
56

67
from .types import RouteLiteral as RouteLiteral
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
5+
from fastapi import FastAPI
6+
from pydantic import BaseModel
7+
8+
app = FastAPI()
9+
10+
11+
class PipelineName(str, Enum):
12+
create_sdwan = "create-sdwan"
13+
test_pipeline = "test-pipeline"
14+
deploy_sdwan_spoke = "deploy-sdwan-spoke"
15+
deploy_sdwan_customer_hub = "deploy-sdwan-customer-hub"
16+
deploy_cisco_access_switch = "deploy-cisco-access-switch"
17+
18+
19+
class PipelineExecution(BaseModel):
20+
name: PipelineName
21+
id: int
22+
23+
24+
@app.get("/api/v1/pipelines/{name}/executions/{id}", response_model=PipelineExecution)
25+
def get_pipeline_execution(name: PipelineName, id: int) -> PipelineExecution:
26+
return PipelineExecution(name=name, id=id)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
import importlib
4+
import json
5+
from pathlib import Path
6+
from typing import Any, cast
7+
8+
from app import app
9+
10+
from openapi_python.generator import GenerationRequest, generate_client
11+
12+
13+
class _Response:
14+
content = b"{}"
15+
16+
def raise_for_status(self) -> None:
17+
pass
18+
19+
def json(self) -> dict[str, object]:
20+
return {}
21+
22+
23+
class _HttpClient:
24+
def __init__(self) -> None:
25+
self.url = ""
26+
27+
def request(self, **kwargs: Any) -> _Response:
28+
self.url = str(kwargs["url"])
29+
return _Response()
30+
31+
32+
def main() -> None:
33+
generate_client(
34+
GenerationRequest(
35+
output_dir=Path(__file__).parent / "generated",
36+
spec_json=json.dumps(app.openapi()),
37+
overwrite=True,
38+
)
39+
)
40+
types = importlib.import_module("generated.my_client.types")
41+
transport_module = importlib.import_module("generated.my_client.transport")
42+
http_client = _HttpClient()
43+
transport = transport_module.DefaultTransport(client=cast(Any, http_client))
44+
45+
transport.request(
46+
method="get",
47+
route="/api/v1/pipelines/{name}/executions/{id}",
48+
base_url="http://testserver",
49+
params={
50+
"name": types.PipelineName.DEPLOY_CISCO_ACCESS_SWITCH,
51+
"id": 1,
52+
},
53+
query=None,
54+
headers=None,
55+
body=None,
56+
)
57+
assert (
58+
http_client.url
59+
== "http://testserver/api/v1/pipelines/deploy-cisco-access-switch/executions/1"
60+
)
61+
62+
63+
if __name__ == "__main__":
64+
main()
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from __future__ import annotations
2+
3+
from typing import assert_type
4+
5+
from generated.my_client import AsyncClient
6+
from generated.my_client.types import PipelineExecution, PipelineName
7+
8+
client = AsyncClient(base_url="http://testserver")
9+
10+
11+
async def main() -> None:
12+
result = await client.get("/api/v1/pipelines/{name}/executions/{id}")(
13+
params={
14+
"name": PipelineName.DEPLOY_CISCO_ACCESS_SWITCH.value,
15+
"id": 1,
16+
}
17+
)
18+
assert_type(result, PipelineExecution)
19+
assert_type(result["name"], PipelineName)
20+
assert_type(result["id"], int)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import annotations
2+
3+
from typing import assert_type
4+
5+
from generated.my_client import Client
6+
from generated.my_client.types import PipelineExecution, PipelineName
7+
8+
client = Client(base_url="http://testserver")
9+
10+
result = client.get("/api/v1/pipelines/{name}/executions/{id}")(
11+
params={
12+
"name": PipelineName.DEPLOY_CISCO_ACCESS_SWITCH.value,
13+
"id": 1,
14+
}
15+
)
16+
assert_type(result, PipelineExecution)
17+
assert_type(result["name"], PipelineName)
18+
assert_type(result["id"], int)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from fastapi import FastAPI
6+
from pydantic import BaseModel
7+
8+
app = FastAPI()
9+
10+
11+
class TaskExecutionDto(BaseModel):
12+
id: int
13+
log: list[dict[str, Any]]
14+
metadata: dict[str, object]
15+
16+
17+
@app.get("/task-executions/{task_id}", response_model=TaskExecutionDto)
18+
def get_task_execution(task_id: int) -> TaskExecutionDto:
19+
return TaskExecutionDto(
20+
id=task_id,
21+
log=[{"message": "started", "attempt": 1}],
22+
metadata={"trigger": "manual", "dry_run": False},
23+
)

0 commit comments

Comments
 (0)