webapp-scaffold/bin/postprocess-openapi.py
Uwe Schuster 8677faf54b postprocess-openapi: also fix type: Any and duplicate params
Two more oatpp 1.3 spec quirks surfaced when fewo-webapp's #469
increment 2 wired up Orval's strict Zod codegen on top of the v0.4.1
`required: []` strip:

- `"type": "Any"` is emitted for `oatpp::Any` / `oatpp::Fields` fields.
  OpenAPI 3.0 has no "Any" type — the empty schema `{}` (or absence of
  `type`) is the canonical "any value" form. Strip the offending key
  recursively across the whole spec.
- Some endpoints emit the same path parameter twice (same `name` + same
  `in: path`). Dedupe by `(name, in)` preserving first occurrence.

These were caught only by `@scalar/openapi-parser`'s strict mode (used by
Orval's Zod project); the fetch-client project tolerated them. Without
both fixes, fewoZod fails with `must NOT have duplicate items` and
`must match exactly one schema in oneOf` per affected component.

Bumps to v0.4.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:25:49 +02:00

102 lines
3.8 KiB
Python
Executable file

#!/usr/bin/env python3
"""Post-process openapi.json before handing it to Orval.
oatpp 1.3's Swagger generator produces a valid spec but with rough edges
that make the generated client less ergonomic:
- Missing operationIds on some endpoints → orval falls back to path-based
names that collide. We synthesise an operationId from method + path.
- summary/description strings occasionally contain stray control characters.
- Some endpoints have no tags, which throws off orval's file-splitting.
Run between `fetch-openapi.sh` and `orval` (see package.json `codegen`).
"""
import json
import re
import sys
from pathlib import Path
SRC = Path("openapi.json")
if not SRC.exists():
sys.exit(f"{SRC} not found — run fetch-openapi.sh first")
spec = json.loads(SRC.read_text())
def op_id(method: str, path: str) -> str:
"""`getApiAnnouncementsEntityIdHistory` from `GET /api/announcements/{entity_id}/history`."""
parts = [method.lower()]
for seg in path.strip("/").split("/"):
if seg.startswith("{") and seg.endswith("}"):
parts.append(seg[1:-1])
else:
parts.append(seg)
return re.sub(r"[^A-Za-z0-9]+(\w)", lambda m: m.group(1).upper(),
"_".join(parts))
paths = spec.get("paths", {})
for path, methods in paths.items():
for method, op in methods.items():
if not isinstance(op, dict):
continue
op.setdefault("operationId", op_id(method, path))
op.setdefault("tags", [path.strip("/").split("/")[1] if "/" in path.strip("/") else "default"])
# oatpp 1.3 emits `"required": []` on schemas that have no required fields.
# OpenAPI 3.0.x rejects this (`must NOT have fewer than 1 items`), which
# breaks strict consumers like Orval's Zod generator. Strip empty arrays —
# absence of the keyword has the same semantics.
schemas = spec.get("components", {}).get("schemas", {})
stripped_required = 0
for sch in schemas.values():
if isinstance(sch, dict) and sch.get("required") == []:
del sch["required"]
stripped_required += 1
# oatpp 1.3 emits `"type": "Any"` for `oatpp::Any` / `oatpp::Fields` fields.
# `Any` is not a valid OpenAPI 3.0 type — the spec uses an empty schema `{}`
# (or `nullable: true` with no type) to mean "any value". Replace `Any` with
# an empty schema; recurse so we catch nested cases (object property,
# array item, etc.).
fixed_any = 0
def fix_any(node):
global fixed_any
if isinstance(node, dict):
if node.get("type") == "Any":
del node["type"]
fixed_any += 1
for v in node.values():
fix_any(v)
elif isinstance(node, list):
for item in node:
fix_any(item)
fix_any(spec)
# oatpp 1.3 sometimes emits the same path parameter twice (same name + same
# `in: path`). OpenAPI 3.0 rejects duplicate parameters. Dedupe by (name, in)
# preserving first occurrence.
deduped_params = 0
for path_item in paths.values():
if not isinstance(path_item, dict):
continue
for op in path_item.values():
if not isinstance(op, dict) or "parameters" not in op:
continue
seen = set()
unique = []
for p in op["parameters"]:
key = (p.get("name"), p.get("in"))
if key in seen:
deduped_params += 1
continue
seen.add(key)
unique.append(p)
op["parameters"] = unique
SRC.write_text(json.dumps(spec, indent=2))
report = [f"{len(paths)} paths"]
if stripped_required: report.append(f"stripped empty `required` from {stripped_required} schemas")
if fixed_any: report.append(f"replaced {fixed_any} invalid `type: Any` with any-schema")
if deduped_params: report.append(f"deduped {deduped_params} duplicate parameter(s)")
print(f" postprocessed {SRC}" + ", ".join(report))