Skip to content

Commit 348be08

Browse files
committed
fix: enforce strict type safety with Pydantic validation and comprehensive negative tests
- Regenerate types from corrected OpenAPI spec (nullable/default fixes) - Add preprocessing script (scripts/preprocess_spec.py) respecting default values - Add runtime validation layer (src/devhelm/_validation.py) - Validate all API inputs via Pydantic before sending requests - Parse all API responses through Pydantic models - Add 410 negative validation tests (tests/test_negative_validation.py) - Add schema correctness tests (tests/test_schemas.py) - Update all 12 resource modules to use generated types and validation Made-with: Cursor
1 parent e31608d commit 348be08

30 files changed

Lines changed: 7663 additions & 3371 deletions

docs/openapi/monitoring-api.json

Lines changed: 2044 additions & 2090 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ dev = [
5050
[tool.ruff.lint]
5151
extend-select = ["C90", "I"]
5252

53+
[tool.ruff.lint.per-file-ignores]
54+
"src/devhelm/_generated.py" = ["I001"]
55+
5356
[tool.ruff.format]
5457
skip-magic-trailing-comma = true
5558

scripts/preprocess_spec.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env python3
2+
"""Preprocess the vendored OpenAPI spec before running datamodel-codegen.
3+
4+
Applies structural fixes that code generators need:
5+
1. setRequiredFields — mark non-nullable fields as required
6+
2. pushRequiredIntoAllOf — propagate required into allOf members
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import sys
13+
from pathlib import Path
14+
15+
16+
_REQUEST_PREFIXES = (
17+
"Create", "Update", "Add", "Acquire", "Resolve", "Reorder",
18+
"Test", "Change", "Admin", "Bulk", "Monitor",
19+
)
20+
21+
22+
def set_required_fields(spec: dict) -> None:
23+
schemas = spec.get("components", {}).get("schemas", {})
24+
for schema_name, schema in schemas.items():
25+
if schema.get("type") != "object" or "properties" not in schema:
26+
continue
27+
28+
if isinstance(schema.get("required"), list):
29+
is_req = schema_name.endswith("Request") and schema_name.startswith(
30+
_REQUEST_PREFIXES
31+
)
32+
if not is_req:
33+
for prop, prop_schema in schema.get("properties", {}).items():
34+
if prop_schema.get("nullable"):
35+
continue
36+
if prop in schema["required"]:
37+
continue
38+
if prop_schema.get("allOf"):
39+
continue
40+
if "default" in prop_schema:
41+
continue
42+
schema["required"].append(prop)
43+
continue
44+
45+
is_request = schema_name.endswith("Request") and schema_name.startswith(
46+
_REQUEST_PREFIXES
47+
)
48+
if is_request:
49+
continue
50+
51+
required = []
52+
for prop, prop_schema in schema.get("properties", {}).items():
53+
if prop_schema.get("nullable"):
54+
continue
55+
if prop_schema.get("allOf"):
56+
continue
57+
if "default" in prop_schema:
58+
continue
59+
required.append(prop)
60+
if required:
61+
schema["required"] = required
62+
63+
64+
def push_required_into_all_of(spec: dict) -> None:
65+
schemas = spec.get("components", {}).get("schemas", {})
66+
for schema in schemas.values():
67+
if not isinstance(schema.get("required"), list):
68+
continue
69+
if not isinstance(schema.get("allOf"), list):
70+
continue
71+
for member in schema["allOf"]:
72+
if "properties" not in member:
73+
continue
74+
member_required = [f for f in schema["required"] if f in member["properties"]]
75+
if member_required:
76+
existing = member.get("required", [])
77+
member["required"] = list(set(existing + member_required))
78+
79+
80+
81+
# fix_missing_nullable — REMOVED.
82+
# The root cause (Lombok not copying @Nullable to getters) was fixed in the API
83+
# by adding `jakarta.annotation.Nullable` to lombok.copyableAnnotations. The
84+
# generated OpenAPI spec now correctly marks nullable fields via the existing
85+
# PropertyCustomizer in OpenApiConfig.java. All DTO fields also have explicit
86+
# @Nullable or @NotNull/@NotBlank annotations, enforced by DtoAnnotationTest.
87+
88+
89+
def main() -> None:
90+
if len(sys.argv) != 3:
91+
print(f"Usage: {sys.argv[0]} <input.json> <output.json>", file=sys.stderr)
92+
sys.exit(1)
93+
94+
input_path = Path(sys.argv[1])
95+
output_path = Path(sys.argv[2])
96+
97+
spec = json.loads(input_path.read_text())
98+
set_required_fields(spec)
99+
push_required_into_all_of(spec)
100+
101+
output_path.write_text(json.dumps(spec, indent=2))
102+
print(f"Preprocessed: {input_path} -> {output_path}")
103+
104+
105+
if __name__ == "__main__":
106+
main()

scripts/typegen.sh

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,21 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
88
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
99

1010
INPUT="$ROOT_DIR/docs/openapi/monitoring-api.json"
11+
PREPROCESSED="$ROOT_DIR/.openapi-preprocessed.json"
1112
OUTPUT="$ROOT_DIR/src/devhelm/_generated.py"
1213

1314
if [[ ! -f "$INPUT" ]]; then
1415
echo "error: OpenAPI spec not found at $INPUT" >&2
1516
exit 1
1617
fi
1718

18-
echo "=> Generating Pydantic models from OpenAPI spec..."
19+
echo "=> Preprocessing OpenAPI spec..."
20+
python3 "$SCRIPT_DIR/preprocess_spec.py" "$INPUT" "$PREPROCESSED"
21+
22+
echo "=> Generating Pydantic models from preprocessed spec..."
1923

2024
uv run datamodel-codegen \
21-
--input "$INPUT" \
25+
--input "$PREPROCESSED" \
2226
--output "$OUTPUT" \
2327
--output-model-type pydantic_v2.BaseModel \
2428
--target-python-version 3.11 \
@@ -28,4 +32,5 @@ uv run datamodel-codegen \
2832
--input-file-type openapi \
2933
--formatters ruff-format
3034

35+
rm -f "$PREPROCESSED"
3136
echo "=> Generated: $OUTPUT"

src/devhelm/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from devhelm.types import (
2121
AcquireDeployLockRequest,
2222
AddCustomDomainRequest,
23+
AddResourceGroupMemberRequest,
2324
AdminAddSubscriberRequest,
2425
AlertChannelDto,
2526
ApiKeyCreateResponse,
@@ -49,6 +50,8 @@
4950
MonitorDto,
5051
MonitorVersionDto,
5152
NotificationPolicyDto,
53+
ReorderComponentsRequest,
54+
ResolveIncidentRequest,
5255
ResourceGroupDto,
5356
ResourceGroupMemberDto,
5457
SecretDto,
@@ -63,6 +66,7 @@
6366
StatusPageIncidentUpdateDto,
6467
StatusPageSubscriberDto,
6568
TagDto,
69+
TestChannelResult,
6670
UpdateAlertChannelRequest,
6771
UpdateEnvironmentRequest,
6872
UpdateMonitorRequest,
@@ -76,6 +80,7 @@
7680
UpdateTagRequest,
7781
UpdateWebhookEndpointRequest,
7882
WebhookEndpointDto,
83+
WebhookTestResult,
7984
)
8085

8186
__all__ = [
@@ -131,6 +136,8 @@
131136
"DashboardOverviewDto",
132137
"DeployLockDto",
133138
"AssertionTestResultDto",
139+
"TestChannelResult",
140+
"WebhookTestResult",
134141
# Request types
135142
"CreateStatusPageRequest",
136143
"UpdateStatusPageRequest",
@@ -143,9 +150,11 @@
143150
"CreateStatusPageIncidentUpdateRequest",
144151
"AddCustomDomainRequest",
145152
"AdminAddSubscriberRequest",
153+
"ReorderComponentsRequest",
146154
"CreateMonitorRequest",
147155
"UpdateMonitorRequest",
148156
"CreateManualIncidentRequest",
157+
"ResolveIncidentRequest",
149158
"CreateAlertChannelRequest",
150159
"UpdateAlertChannelRequest",
151160
"CreateNotificationPolicyRequest",
@@ -158,6 +167,7 @@
158167
"UpdateTagRequest",
159168
"CreateResourceGroupRequest",
160169
"UpdateResourceGroupRequest",
170+
"AddResourceGroupMemberRequest",
161171
"CreateWebhookEndpointRequest",
162172
"UpdateWebhookEndpointRequest",
163173
"CreateApiKeyRequest",

0 commit comments

Comments
 (0)