dograh/sdk/codegen/client_codegen.py

356 lines
12 KiB
Python
Raw Permalink Normal View History

"""Generate SDK client mixins (Python + TypeScript) from a filtered OpenAPI dump.
Input: a spec produced by calling FastAPI's `get_openapi(routes=...)` with
only the routes tagged via `sdk_expose(...)`. Because it's already filtered,
this script does *no* filtering it just walks the operations and emits
typed method stubs.
Request/response types come from sibling model files already produced by
`datamodel-codegen` (Python) and `openapi-typescript --root-types
--root-types-no-schema-prefix` (TypeScript). We only import the names
here; this script doesn't generate types itself.
Output:
--py-out sdk/python/src/dograh_sdk/_generated_client.py
--ts-out sdk/typescript/src/_generated_client.ts
"""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
_API_PREFIX = "/api/v1"
# openapi scalar → (python, typescript)
_TYPE_MAP = {
"integer": ("int", "number"),
"number": ("float", "number"),
"string": ("str", "string"),
"boolean": ("bool", "boolean"),
}
def _map_scalar(schema: dict[str, Any]) -> tuple[str, str]:
t = schema.get("type")
if t in _TYPE_MAP:
return _TYPE_MAP[t]
# optional string often shown as anyOf:[{type:string}, {type:null}]
for branch in schema.get("anyOf") or []:
if branch.get("type") in _TYPE_MAP and branch.get("type") != "null":
return _TYPE_MAP[branch["type"]]
return ("Any", "unknown")
def _ref_name(schema: dict[str, Any]) -> str | None:
ref = schema.get("$ref")
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
return ref.rsplit("/", 1)[-1]
return None
@dataclass
class ResponseType:
"""What comes back from an operation. `class_name` is a model class from
`_generated_models`; `is_list` wraps it as a list."""
class_name: str | None = None
is_list: bool = False
@property
def py(self) -> str:
if self.class_name is None:
return "Any"
return f"list[{self.class_name}]" if self.is_list else self.class_name
@property
def ts(self) -> str:
if self.class_name is None:
return "unknown"
return f"{self.class_name}[]" if self.is_list else self.class_name
@dataclass
class Param:
name: str
py_type: str
ts_type: str
required: bool
@dataclass
class Operation:
method: str
verb: str
path: str
description: str
path_params: list[Param] = field(default_factory=list)
query_params: list[Param] = field(default_factory=list)
request_class: str | None = None # None → no body
response: ResponseType = field(default_factory=ResponseType)
def _collect(spec: dict[str, Any]) -> list[Operation]:
ops: list[Operation] = []
used_models: set[str] = set()
for path, methods in spec.get("paths", {}).items():
for verb, op in methods.items():
if not isinstance(op, dict) or "x-sdk-method" not in op:
continue
description = (op.get("x-sdk-description") or op.get("summary") or "").strip()
sdk_path = path[len(_API_PREFIX):] if path.startswith(_API_PREFIX) else path
path_params: list[Param] = []
query_params: list[Param] = []
for p in op.get("parameters") or []:
py_t, ts_t = _map_scalar(p.get("schema") or {})
param = Param(
name=p["name"],
py_type=py_t,
ts_type=ts_t,
required=bool(p.get("required")),
)
if p.get("in") == "path":
path_params.append(param)
elif p.get("in") == "query":
query_params.append(param)
request_class: str | None = None
rb = op.get("requestBody") or {}
rb_schema = (
(rb.get("content") or {}).get("application/json", {}).get("schema") or {}
)
if rb_schema:
request_class = _ref_name(rb_schema)
response = ResponseType()
r200 = (
op.get("responses", {})
.get("200", {})
.get("content", {})
.get("application/json", {})
.get("schema")
or {}
)
if r200:
name = _ref_name(r200)
if name:
response = ResponseType(class_name=name)
elif r200.get("type") == "array":
item = r200.get("items") or {}
name = _ref_name(item)
if name:
response = ResponseType(class_name=name, is_list=True)
for cls in (request_class, response.class_name):
if cls:
used_models.add(cls)
ops.append(Operation(
method=op["x-sdk-method"],
verb=verb.lower(),
path=sdk_path,
description=description,
path_params=path_params,
query_params=query_params,
request_class=request_class,
response=response,
))
ops.sort(key=lambda o: o.method)
return ops, sorted(used_models)
# ── Python emitter ─────────────────────────────────────────────────────
def _py_method(op: Operation) -> str:
positional = [f"{p.name}: {p.py_type}" for p in op.path_params]
kw_only: list[str] = []
if op.request_class:
kw_only.append(f"body: {op.request_class}")
for p in op.query_params:
kw_only.append(f"{p.name}: {p.py_type} | None = None")
sig = ", ".join(["self", *positional] + (["*"] + kw_only if kw_only else []))
lines: list[str] = []
lines.append(f" def {op.method}({sig}) -> {op.response.py}:")
lines.append(f' """{op.description or op.verb.upper() + " " + op.path}"""')
path_expr = f'f"{op.path}"' if op.path_params else f'"{op.path}"'
call_kwargs: list[str] = []
if op.query_params:
lines.append(" params: dict[str, Any] = {}")
for p in op.query_params:
lines.append(f" if {p.name} is not None:")
lines.append(f' params["{p.name}"] = {p.name}')
call_kwargs.append("params=params")
if op.request_class:
call_kwargs.append('json=body.model_dump(mode="json", exclude_none=True)')
extra = (", " + ", ".join(call_kwargs)) if call_kwargs else ""
raw_call = f'self._request("{op.verb.upper()}", {path_expr}{extra})'
if op.response.class_name is None:
lines.append(f" return {raw_call}")
elif op.response.is_list:
lines.append(f" data = {raw_call}")
lines.append(f" return [{op.response.class_name}.model_validate(x) for x in data]")
else:
lines.append(f" data = {raw_call}")
lines.append(f" return {op.response.class_name}.model_validate(data)")
lines.append("")
return "\n".join(lines)
_PY_HEADER = '''\
"""GENERATED — do not edit. Source: filtered OpenAPI from `api.app`.
Regenerate with `./scripts/generate_sdk.sh`.
`DograhClient` mixes in this class to get HTTP methods for every route
decorated with `sdk_expose(...)` on the backend. Request/response types
come from `_generated_models` (datamodel-codegen output).
"""
from __future__ import annotations
from typing import Any
from dograh_sdk._generated_models import (
{imports}
)
class _GeneratedClient:
# `DograhClient.__init__` installs `self._request` (see client.py).
'''
def emit_python(ops: list[Operation], models: list[str]) -> str:
imports = "\n".join(f" {m}," for m in models)
body = "\n".join(_py_method(op) for op in ops)
return _PY_HEADER.format(imports=imports) + body
# ── TypeScript emitter ─────────────────────────────────────────────────
def _snake_to_camel(s: str) -> str:
parts = s.split("_")
return parts[0] + "".join(p.title() for p in parts[1:])
def _ts_method(op: Operation) -> str:
name = _snake_to_camel(op.method)
positional = [f"{_snake_to_camel(p.name)}: {p.ts_type}" for p in op.path_params]
opts_props: list[str] = []
if op.request_class:
opts_props.append(f"body: {op.request_class}")
for p in op.query_params:
opts_props.append(f"{_snake_to_camel(p.name)}?: {p.ts_type}")
args = list(positional)
if opts_props:
required_in_opts = op.request_class is not None
opts_sig = "{ " + "; ".join(opts_props) + " }"
# If body is required, opts is required too (no `= {}` default)
args.append(f"opts: {opts_sig}" if required_in_opts else f"opts: {opts_sig} = {{}}")
sig = ", ".join(args)
ret = op.response.ts
lines: list[str] = []
lines.append(f" /** {op.description or op.verb.upper() + ' ' + op.path} */")
lines.append(f" async {name}({sig}): Promise<{ret}> {{")
path_expr = op.path
for p in op.path_params:
path_expr = path_expr.replace("{" + p.name + "}", "${" + _snake_to_camel(p.name) + "}")
tmpl = f"`{path_expr}`" if op.path_params else f'"{op.path}"'
call_opts: list[str] = []
if op.query_params:
entries: list[str] = []
for p in op.query_params:
camel = _snake_to_camel(p.name)
entries.append(f' ...(opts.{camel} !== undefined ? {{ "{p.name}": opts.{camel} }} : {{}}),')
lines.append(" const params: Record<string, unknown> = {")
lines.extend(entries)
lines.append(" };")
call_opts.append("params")
if op.request_class:
call_opts.append("json: opts.body")
extra = (", { " + ", ".join(call_opts) + " }") if call_opts else ""
generic = f"<{ret}>" if ret != "unknown" else ""
lines.append(f' return this.request{generic}("{op.verb.upper()}", {tmpl}{extra});')
lines.append(" }")
lines.append("")
return "\n".join(lines)
_TS_HEADER = """\
// GENERATED do not edit. Source: filtered OpenAPI from `api.app`.
//
// Regenerate with `./scripts/generate_sdk.sh`.
//
// `DograhClient` extends this base to get HTTP methods for every route
// decorated with `sdk_expose(...)`. Request/response types come from
// `_generated_models` (openapi-typescript output, --root-types).
import type {{
{imports}
}} from "./_generated_models.js";
export abstract class _GeneratedClient {{
protected abstract request<T = unknown>(
method: string,
path: string,
opts?: {{ json?: unknown; params?: Record<string, unknown> }},
): Promise<T>;
"""
def emit_typescript(ops: list[Operation], models: list[str]) -> str:
imports = "\n".join(f" {m}," for m in models)
body = "\n".join(_ts_method(op) for op in ops)
return _TS_HEADER.format(imports=imports) + body + "}\n"
# ── CLI ────────────────────────────────────────────────────────────────
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--input", required=True, help="Path to filtered openapi.json")
ap.add_argument("--py-out", required=True)
ap.add_argument("--ts-out", required=True)
args = ap.parse_args()
spec = json.loads(Path(args.input).read_text())
ops, models = _collect(spec)
if not ops:
raise SystemExit("No x-sdk-method operations — nothing to emit.")
Path(args.py_out).write_text(emit_python(ops, models))
Path(args.ts_out).write_text(emit_typescript(ops, models))
print(f"{len(ops)} operations, {len(models)} models referenced")
print(f"{args.py_out}")
print(f"{args.ts_out}")
if __name__ == "__main__":
main()