Skip to content

Commit 4b9155e

Browse files
authored
Merge pull request #69 from devhelmhq/fix/postel-nested-response-shapes
fix: ignore unknown fields on nested response models
2 parents 555c38d + 7ce1388 commit 4b9155e

5 files changed

Lines changed: 246 additions & 153 deletions

File tree

scripts/inject_strict_config.py

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
#!/usr/bin/env python3
2-
"""Inject ``model_config = ConfigDict(extra='forbid', populate_by_name=True)``
3-
into every generated Pydantic BaseModel class, and add Pydantic v2
4-
``Field(discriminator=...)`` annotations on tagged-union fields.
2+
"""Inject ``model_config`` into every generated Pydantic BaseModel class,
3+
and add Pydantic v2 ``Field(discriminator=...)`` annotations on tagged-union
4+
fields.
5+
6+
Request / Params models get ``extra='forbid'`` so typos fail before the HTTP
7+
call. Every other generated model — response DTOs and nested value objects
8+
used when decoding API responses — gets ``extra='ignore'`` (Postel's Law).
9+
Additive API fields, including nullable ones the published surface has never
10+
seen, must not crash the client.
511
612
datamodel-code-generator does not emit a config block when the source
713
OpenAPI spec lacks ``additionalProperties: false``. Springdoc never emits
@@ -30,9 +36,10 @@
3036
value and reports only that subtype's errors (typically 1).
3137
Implements P0.Bug4 from the round-3 DevEx audit.
3238
33-
This implements policies P1 (response extras forbidden) and P2 (request
34-
extras forbidden) from `mini/cowork/design/040-codegen-policies.md` plus
35-
the two DevEx fixes above.
39+
This implements Postel's Law on the wire (`runbooks/api-contract.md` § 2.2:
40+
tolerant response decoders, strict request authoring) plus the two DevEx
41+
fixes above. P2 (request extras forbidden) stays; P1 is now "response extras
42+
ignored", not rejected.
3643
3744
The transform is purely syntactic so we can run it on the codegen output
3845
without parsing Python AST. Idempotent: re-runs upgrade an existing
@@ -50,20 +57,28 @@
5057
# `root-model-extra`), so skip them. Their behavior is governed by the
5158
# inner type, which on its own enforces strict validation.
5259
CLASS_RE = re.compile(r"^class\s+([A-Za-z_][\w]*)\s*\(\s*(BaseModel)\s*\)\s*:\s*$")
53-
CONFIG_LINE_STRICT = " model_config = ConfigDict(extra='forbid', populate_by_name=True)"
54-
CONFIG_LINE_TOLERANT = " model_config = ConfigDict(extra='ignore', populate_by_name=True)"
60+
CONFIG_LINE_STRICT = (
61+
" model_config = ConfigDict(extra='forbid', populate_by_name=True)"
62+
)
63+
CONFIG_LINE_TOLERANT = (
64+
" model_config = ConfigDict(extra='ignore', populate_by_name=True)"
65+
)
5566

5667

5768
def _is_response_shape(class_name: str) -> bool:
58-
"""Response-shape classes tolerate unknown fields (Postel's Law)."""
59-
if class_name[0].islower():
60-
return False
61-
if class_name.endswith(("Request", "Params")):
69+
"""Tolerate unknown fields on every non-authoring model (Postel's Law).
70+
71+
``*Request`` / ``*Params`` stay ``extra='forbid'``. Everything else —
72+
``*Dto``, nested value objects on those DTOs (``StatusPageBranding``,
73+
check-detail variants, channel configs), envelopes — ignores unknown
74+
keys so an additive API field is a non-event. Shared nested types used
75+
on both request and response follow the response rule: crashing a
76+
``get`` / ``list`` is worse than dropping an unknown nested request key
77+
the API would ignore anyway.
78+
"""
79+
if not class_name or class_name[0].islower():
6280
return False
63-
return bool(
64-
class_name.endswith(("Dto", "Response"))
65-
or class_name.startswith(("SingleValueResponse", "TableValueResult", "CursorPage"))
66-
)
81+
return not class_name.endswith(("Request", "Params"))
6782

6883

6984
# Keep the old name for backward compat in case anything imports it
@@ -78,7 +93,7 @@ def _is_response_shape(class_name: str) -> bool:
7893
"Note: ``currentStatus`` was removed from this DTO. "
7994
"Inspect ``enabled`` and the incident-policy API to derive a "
8095
"live status for a monitor instead."
81-
),
96+
)
8297
}
8398

8499

@@ -104,9 +119,7 @@ def inject(source: str) -> tuple[str, int]:
104119
"""Return (new_source, count_of_classes_modified)."""
105120
if "from pydantic import" in source and "ConfigDict" not in source:
106121
source = source.replace(
107-
"from pydantic import",
108-
"from pydantic import ConfigDict, ",
109-
1,
122+
"from pydantic import", "from pydantic import ConfigDict, ", 1
110123
)
111124
source = source.replace("ConfigDict, ConfigDict, ", "ConfigDict, ", 1)
112125

@@ -147,7 +160,11 @@ def inject(source: str) -> tuple[str, int]:
147160
i += 1
148161
continue
149162
class_name = m.group(1)
150-
config_line = CONFIG_LINE_TOLERANT if _is_response_shape(class_name) else CONFIG_LINE_STRICT
163+
config_line = (
164+
CONFIG_LINE_TOLERANT
165+
if _is_response_shape(class_name)
166+
else CONFIG_LINE_STRICT
167+
)
151168
# Look at the very next line. If it's already model_config or pass,
152169
# leave the class alone (idempotency / empty class).
153170
next_idx = i + 1

scripts/typegen.sh

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,10 @@ uv run datamodel-codegen \
6969
# satisfying the discriminator requirement and making the field optional at
7070
# construction (callers don't need to repeat the discriminator value).
7171

72-
# Post-process: inject `model_config = ConfigDict(extra='forbid')` into every
73-
# generated class so that requests with unknown fields and responses with
74-
# unknown fields BOTH fail loudly. Implements P1 + P2 from
75-
# `mini/cowork/design/040-codegen-policies.md`.
76-
echo "=> Injecting strict-fail config (extra='forbid') into generated models..."
72+
# Post-process: extra='forbid' on *Request/*Params, extra='ignore' on every
73+
# other generated model (Postel's Law — additive API response fields must
74+
# not crash). See scripts/inject_strict_config.py.
75+
echo "=> Injecting model_config (forbid on requests, ignore on responses)..."
7776
uv run python "$SCRIPT_DIR/inject_strict_config.py" "$OUTPUT"
7877

7978
# Re-format after injection so the file stays ruff-clean. Non-fatal so the

0 commit comments

Comments
 (0)