Skip to main content

umbral_openapi/
client_gen.rs

1//! `umbral gen-client` — a typed client generated from the REST surface
2//! (gaps3 #38 / Kikosi #1).
3//!
4//! `umbral typegen` gives a frontend the *shapes*. This gives it the *client*:
5//! `new Umbral(url).from("post").filter({ status: "published" }).list()`, where
6//! the filter object autocompletes to exactly this model's filterable fields and
7//! their value types — because the same model registry that renders the OpenAPI
8//! document knows every column's type, choices, FK target, and which lookups
9//! (`__gte`, `__in`, `__contains`, `__isnull`) the REST list endpoint accepts.
10//!
11//! # Two files, one runtime
12//!
13//! - `client.js` — a single-file, dependency-free ES module: `Umbral`, `Query`,
14//!   `UmbralError`. Usable straight from a `<script type="module">` with no
15//!   build step, and by any bundler.
16//! - `client.d.ts` — every type: row interfaces, choice unions, per-model
17//!   `Filters` / `Ordering` / `Create` / `Update`, the paginator's envelope, and
18//!   the class declarations.
19//!
20//! There is no `.ts` runtime: TypeScript's types *erase*, so the row/filter
21//! types produce no JavaScript at all. Emitting `.js` + `.d.ts` means the
22//! runtime exists exactly once (no bundler, no transpile step, no second copy to
23//! keep in step), while `import { Umbral } from "./api/client"` still type-checks
24//! fully — TS resolves the `.d.ts` for types and the bundler resolves the `.js`
25//! for code. It's the shape every published SDK ships.
26//!
27//! # Realtime is delegated, not reimplemented
28//!
29//! `Umbral.on(...)` does NOT open its own `EventSource`. It loads the realtime
30//! plugin's already-served `{realtimePath}/client.js` and calls
31//! `umbral.realtime.model(...)`, inheriting the hard parts: ONE SSE connection
32//! shared across every tab via `SharedWorker` (union-routed), presence, and
33//! graceful degradation. A per-subscription `EventSource` would open one
34//! connection per model — six subscriptions exhausts the browser's per-origin
35//! connection cap.
36//!
37//! It reads the live registry + umbral-rest's per-resource config
38//! (`filters_enabled_for`, `is_hidden`, `registered_base_path`,
39//! `registered_pagination_style`, `registered_pagination_schema`,
40//! `registered_security_schemes`), which are populated when the plugins' routes
41//! are built — so `gen-client` runs as an offline CLI step with no server and no
42//! database, and reflects the *exact* surface the app serves.
43
44use std::fmt::Write as _;
45
46use serde_json::Value;
47use umbral::migrate::{Column, ModelMeta};
48use umbral_casing::pascal_case_from_ident;
49use umbral_rest::{PaginationScalar, PaginationSchema, PaginationStyle};
50
51/// The generated client: one runtime module + one declaration file.
52#[derive(Debug, Clone)]
53pub struct GeneratedClient {
54    /// `client.js` — the single-file ES-module runtime.
55    pub js: String,
56    /// `client.d.ts` — every type, including declarations for the runtime's classes.
57    pub dts: String,
58}
59
60/// Generate the client from the live registry + REST config.
61///
62/// Reads every registered model, keeps the REST-exposed ones, and resolves FK
63/// value types against the *full* set (so a filter on an FK column gets the
64/// target's real PK type even when the target model isn't itself exposed).
65pub fn generate() -> GeneratedClient {
66    let all: Vec<ModelMeta> = umbral::migrate::registered_plugins()
67        .iter()
68        .flat_map(|p| umbral::migrate::models_for_plugin(p))
69        .collect();
70    generate_for(&all)
71}
72
73/// [`generate`] over an explicit model list. `all` is every model the app knows
74/// (used to resolve FK value types); the REST-exposed subset is what gets
75/// emitted, decided by `umbral_rest::is_exposed`.
76pub fn generate_for(all: &[ModelMeta]) -> GeneratedClient {
77    generate_with(
78        all,
79        umbral_rest::registered_base_path(),
80        umbral_rest::registered_pagination_style(),
81        umbral_rest::registered_pagination_schema(),
82        &umbral_rest::registered_security_schemes(),
83        umbral::routes::registered_openapi_paths().unwrap_or_default(),
84    )
85}
86
87/// [`generate_for`] with the REST config passed explicitly rather than read from
88/// umbral-rest's `OnceLock`s — the base path, the paginator's [`PaginationStyle`]
89/// and (for a custom paginator) its declared [`PaginationSchema`], and the
90/// OpenAPI security schemes that drive the client's auth. Exposure / hidden /
91/// filters-enabled are still read from the (gracefully-defaulting) readers.
92///
93/// Tests and the pagination/auth demos drive this so they can exercise every
94/// paginator shape and auth scheme without a full `App::build` (which can only
95/// run once per process because the config is process-global).
96pub fn generate_with(
97    all: &[ModelMeta],
98    base_path: &str,
99    style: PaginationStyle,
100    schema: Option<PaginationSchema>,
101    security_schemes: &[(String, Value)],
102    openapi_paths: &[(String, Value)],
103) -> GeneratedClient {
104    let mut exposed: Vec<ModelMeta> = all
105        .iter()
106        .filter(|m| umbral_rest::is_exposed(&m.table))
107        .cloned()
108        .collect();
109    exposed.sort_by(|a, b| a.name.cmp(&b.name));
110
111    let auth = AuthModel::from_schemes(security_schemes);
112    let schema = schema.as_ref();
113    let methods = page_methods(style, schema);
114    let session = AuthEndpoints::discover(openapi_paths);
115
116    let emit = Emit {
117        all,
118        exposed: &exposed,
119        base_path,
120        style,
121        schema,
122        auth: &auth,
123        methods: &methods,
124        session: session.as_ref(),
125    };
126    GeneratedClient {
127        js: emit_js(&emit),
128        dts: emit_dts(&emit),
129    }
130}
131
132/// Return `models` with every `hide(...)`-ed column removed from each model's
133/// field list — the response shape, since hide is response-only.
134fn strip_hidden(models: &[ModelMeta]) -> Vec<ModelMeta> {
135    models
136        .iter()
137        .map(|m| {
138            let table = m.table.clone();
139            let mut stripped = m.clone();
140            stripped
141                .fields
142                .retain(|c| !umbral_rest::is_hidden(&table, &c.name));
143            stripped
144        })
145        .collect()
146}
147
148// =========================================================================
149// Pagination methods — described once, rendered into BOTH the .js impl and
150// the .d.ts signature so the two can't drift apart.
151// =========================================================================
152
153/// One query-builder paging method, e.g. `page(n: number)` → `?page=`.
154struct PageMethod {
155    /// The JS/TS method name (camelCase): `pageSize`.
156    name: String,
157    /// The TS type of its single argument: `number`.
158    ty: &'static str,
159    /// The wire query param it sets: `page_size`.
160    wire: String,
161    /// Doc line.
162    doc: String,
163}
164
165/// The paging methods this paginator exposes. Built-ins are known; a `Custom`
166/// paginator contributes one method per param it declared in its
167/// [`PaginationSchema`], and one that declared nothing contributes none (the
168/// generic `.param(...)` escape hatch covers it).
169fn page_methods(style: PaginationStyle, schema: Option<&PaginationSchema>) -> Vec<PageMethod> {
170    let m = |name: &str, ty: &'static str, wire: &str, doc: &str| PageMethod {
171        name: name.to_string(),
172        ty,
173        wire: wire.to_string(),
174        doc: doc.to_string(),
175    };
176    match (style, schema) {
177        (PaginationStyle::PageNumber, _) => vec![
178            m("page", "number", "page", "1-based page number (`?page=`)."),
179            m(
180                "pageSize",
181                "number",
182                "page_size",
183                "Rows per page (`?page_size=`).",
184            ),
185        ],
186        (PaginationStyle::LimitOffset, _) => vec![
187            m("limit", "number", "limit", "Max rows (`?limit=`)."),
188            m("offset", "number", "offset", "Rows to skip (`?offset=`)."),
189        ],
190        (PaginationStyle::Custom, Some(s)) => s
191            .params
192            .iter()
193            .map(|p| PageMethod {
194                name: camel_case(&p.name),
195                ty: scalar_ts(p.ty),
196                wire: p.name.clone(),
197                doc: format!("Custom pagination param (`?{}=`).", p.name),
198            })
199            .collect(),
200        _ => Vec::new(),
201    }
202}
203
204// =========================================================================
205// client.js — the runtime. One copy, no types, no imports.
206// =========================================================================
207
208/// The `AuthClient` class + the `this.auth = …` wiring, or empty when the app
209/// serves no auth endpoints (no dead code in a REST-only app).
210fn auth_runtime_js(session: Option<&AuthEndpoints>) -> (String, String) {
211    let Some(s) = session else {
212        return (String::new(), String::new());
213    };
214    let login = s.login.clone().unwrap_or_default();
215
216    let register = match &s.register {
217        Some(p) => format!(
218            r#"
219  /** Register, then adopt the returned token (same shape as login). */
220  async register(body) {{
221    const out = await this.client._request("POST", "{p}", body);
222    if (out && out.token) this.client._setToken(out.token);
223    return out;
224  }}
225"#
226        ),
227        None => String::new(),
228    };
229
230    let logout = match &s.logout {
231        Some(p) => format!(
232            r#"
233  /** Clear the server session, then drop the token locally — even if the
234      request fails, so a user pressing "log out" is never left holding one. */
235  async logout() {{
236    try {{ await this.client._request("POST", "{p}"); }}
237    finally {{ this.client._setToken(null); }}
238  }}
239"#
240        ),
241        None => r#"
242  /** No server logout endpoint; drop the token locally. */
243  async logout() { this.client._setToken(null); }
244"#
245        .to_string(),
246    };
247
248    let me = match &s.me {
249        Some(p) => format!(
250            r#"
251  /** The current user, or `null` when not signed in. A 401 is the ANSWER to
252      "am I logged in?", not an error — so it resolves null instead of throwing.
253      Any other failure still throws. */
254  async me() {{
255    try {{
256      return await this.client._request("GET", "{p}");
257    }} catch (err) {{
258      if (err instanceof UmbralError && err.status === 401) return null;
259      throw err;
260    }}
261  }}
262"#
263        ),
264        None => String::new(),
265    };
266
267    let class = format!(
268        r#"
269/** The session client — `api.auth`. Wraps this app's auth endpoints and owns
270    the bearer token, which every subsequent request picks up automatically. */
271export class AuthClient {{
272  constructor(client) {{ this.client = client; }}
273
274  /** The bearer token currently in use, or null. */
275  get token() {{ return this.client._token; }}
276
277  /** Sign in. Stores the returned token; later calls send it automatically.
278      (Browsers also get the server's session cookie, so either works.) */
279  async login(credentials) {{
280    const out = await this.client._request("POST", "{login}", credentials);
281    this.client._setToken(out && out.token ? out.token : null);
282    return out;
283  }}
284{register}{logout}{me}}}
285"#
286    );
287    (class, "\n    this.auth = new AuthClient(this);".to_string())
288}
289
290fn emit_js(e: &Emit<'_>) -> String {
291    let (base_path, auth, methods, session) = (e.base_path, e.auth, e.methods, e.session);
292    // Auth defaults, derived from the app's declared security schemes — not
293    // hardcoded. `token` uses this Authorization prefix; `apiKey` uses this
294    // header; a session (cookie) scheme sends credentials by default.
295    let bearer_prefix = auth.bearer_prefix();
296    let api_key_header = auth.api_key_header();
297    let credentials_default = if auth.cookie {
298        "\"include\""
299    } else {
300        "undefined"
301    };
302    let (auth_class, auth_wiring) = auth_runtime_js(session);
303
304    let mut page_impls = String::new();
305    for m in methods {
306        let _ = write!(
307            page_impls,
308            "\n  /** {doc} */\n  {name}(v) {{ this.params.set(\"{wire}\", String(v)); return this; }}\n",
309            doc = m.doc,
310            name = m.name,
311            wire = m.wire,
312        );
313    }
314
315    format!(
316        r#"{header}
317/** Thrown when the API returns a non-2xx response. `body` is the parsed error. */
318export class UmbralError extends Error {{
319  constructor(status, body) {{
320    super(`umbral: request failed with status ${{status}}`);
321    this.name = "UmbralError";
322    this.status = status;
323    this.body = body;
324  }}
325}}
326
327/** A list query. Built by `Umbral.from(table)`; see client.d.ts for the types. */
328export class Query {{
329  constructor(client, table) {{
330    this.client = client;
331    this.table = table;
332    this.params = new URLSearchParams();
333  }}
334
335  /** Field filters — keys and value types are specific to this model. */
336  filter(f) {{
337    for (const [k, v] of Object.entries(f || {{}})) {{
338      if (v === undefined) continue;
339      this.params.set(k, Array.isArray(v) ? v.join(",") : String(v));
340    }}
341    return this;
342  }}
343
344  /** Full-text `?search=` across the model's searchable columns. */
345  search(term) {{ this.params.set("search", term); return this; }}
346
347  /** `?ordering=` — pass fields; prefix `-` for descending. */
348  orderBy(...fields) {{ this.params.set("ordering", fields.join(",")); return this; }}
349{page_impls}
350  /** Set any raw query param — the escape hatch for params the typed builder
351      methods don't cover (a custom paginator's cursor, a one-off flag). */
352  param(key, value) {{ this.params.set(key, String(value)); return this; }}
353
354  /** Sparse fieldset (`?fields=`) — fetch only these columns. */
355  fields(...cols) {{ this.params.set("fields", cols.join(",")); return this; }}
356
357  /** Fetch the list. Resolves to the envelope your paginator emits. */
358  async list() {{
359    const qs = this.params.toString();
360    const path = `{base_path}/${{this.table}}/` + (qs ? `?${{qs}}` : "");
361    return this.client._request("GET", path);
362  }}
363}}
364
365{auth_class}
366/** A typed client for this app's REST API. `new Umbral("https://api.example.com")`. */
367export class Umbral {{
368  constructor(baseUrl, opts = {{}}) {{
369    this.baseUrl = String(baseUrl).replace(/\/+$/, "");
370    this.opts = opts;
371    this.realtimePath = (opts.realtimePath ?? "/realtime").replace(/\/+$/, "");
372    this._rt = null;
373    // The live bearer token: seeded from `opts.token` and replaced whenever it
374    // changes. Every request reads it from here.
375    this._token = opts.token ?? null;{auth_wiring}
376  }}
377
378  /** @internal Set the live token and notify the app so it can persist it.
379   *  Deliberately NOT written to localStorage by the client: that is readable by
380   *  any XSS on the page. Browsers already get an httpOnly session cookie; if you
381   *  need the token across reloads, persist it yourself via `onToken` and decide
382   *  the trade-off knowingly. */
383  _setToken(token) {{
384    this._token = token;
385    if (this.opts.onToken) this.opts.onToken(token);
386  }}
387
388  /** Start a query against a REST-exposed table. */
389  from(table) {{ return new Query(this, table); }}
390
391  /** Retrieve one row by primary key. */
392  get(table, id) {{ return this._request("GET", `{base_path}/${{table}}/${{id}}`); }}
393
394  /** Create a row. */
395  create(table, data) {{ return this._request("POST", `{base_path}/${{table}}/`, data); }}
396
397  /** Partially update a row (PATCH). */
398  update(table, id, data) {{ return this._request("PATCH", `{base_path}/${{table}}/${{id}}`, data); }}
399
400  /** Delete a row by primary key. */
401  async delete(table, id) {{ await this._request("DELETE", `{base_path}/${{table}}/${{id}}`); }}
402
403  /** Subscribe to `created` / `updated` / `deleted` for a model.
404   *
405   *  Delegates to the realtime plugin's runtime (loaded once from
406   *  `{{realtimePath}}/client.js`) rather than opening its own EventSource — so
407   *  every subscription in every tab shares ONE server connection via
408   *  SharedWorker, with presence and graceful degradation. Opening an
409   *  EventSource per subscription would blow the browser's per-origin
410   *  connection cap at ~6 models.
411   *
412   *  Returns synchronously; the underlying subscription attaches once the
413   *  runtime loads, and `close()` before then cancels it.
414   */
415  on(table, handlers, opts) {{
416    const group = opts && opts.group;
417    if (!group) throw new Error("umbral: .on(...) requires opts.group — the group you expose(...)-d to");
418    // SSR / non-browser: degrade to a no-op subscription, matching the realtime
419    // runtime's own posture (no transport → a no-op unsubscribe, no noise). A
420    // component that subscribes on mount then renders on the server must not
421    // throw or log.
422    if (typeof document === "undefined") return {{ close() {{}} }};
423    let sub = null;
424    let closed = false;
425    this._realtime()
426      .then((rt) => {{
427        if (closed) return;
428        sub = rt.model(String(table), handlers || {{}}, {{ group }});
429      }})
430      .catch((err) => {{
431        if (typeof console !== "undefined" && console.error) console.error(err);
432      }});
433    return {{
434      close() {{
435        closed = true;
436        if (sub) {{ try {{ sub.unsubscribe(); }} catch (_) {{}} sub = null; }}
437      }},
438    }};
439  }}
440
441  /** @internal Load the realtime runtime once, memoised. It is a classic
442   *  script (sets `window.umbral.realtime`), so it is injected via a script tag
443   *  — that works cross-origin without CORS, unlike a dynamic `import()`. */
444  _realtime() {{
445    const g = globalThis;
446    if (g.umbral && g.umbral.realtime) return Promise.resolve(g.umbral.realtime);
447    if (this._rt) return this._rt;
448    if (typeof document === "undefined") {{
449      return Promise.reject(new Error("umbral: realtime requires a browser environment"));
450    }}
451    const src = `${{this.baseUrl}}${{this.realtimePath}}/client.js`;
452    this._rt = new Promise((resolve, reject) => {{
453      const done = () => {{
454        if (g.umbral && g.umbral.realtime) resolve(g.umbral.realtime);
455        else reject(new Error(`umbral: loaded ${{src}} but umbral.realtime is missing`));
456      }};
457      let el = document.querySelector('script[data-umbral-realtime]');
458      if (!el) {{
459        el = document.createElement("script");
460        el.src = src;
461        el.async = true;
462        el.setAttribute("data-umbral-realtime", "");
463        el.addEventListener("load", done);
464        el.addEventListener("error", () =>
465          reject(new Error(`umbral: failed to load ${{src}} — is RealtimePlugin mounted at ${{this.realtimePath}}?`)));
466        document.head.appendChild(el);
467      }} else {{
468        el.addEventListener("load", done);
469        el.addEventListener("error", () => reject(new Error(`umbral: failed to load ${{src}}`)));
470        done();
471      }}
472    }});
473    return this._rt;
474  }}
475
476  /** @internal */
477  async _request(method, path, body) {{
478    const doFetch = this.opts.fetch ?? fetch;
479    const headers = {{ "Accept": "application/json", ...this.opts.headers }};
480    // Auth, in ascending precedence: static token, static apiKey, then the
481    // dynamic hook (so a fresh JWT overrides a stale static one). Defaults for
482    // the prefix / header come from your API's declared security scheme.
483    if (this._token) {{
484      headers["Authorization"] = `${{this.opts.tokenPrefix ?? "{bearer_prefix}"}} ${{this._token}}`;
485    }}
486    if (this.opts.apiKey) {{
487      headers[this.opts.apiKeyHeader ?? "{api_key_header}"] = this.opts.apiKey;
488    }}
489    if (this.opts.getAuthHeaders) {{
490      Object.assign(headers, await this.opts.getAuthHeaders());
491    }}
492    const init = {{ method, headers }};
493    const credentials = this.opts.credentials ?? {credentials_default};
494    if (credentials) init.credentials = credentials;
495    if (body !== undefined) {{
496      headers["Content-Type"] = "application/json";
497      init.body = JSON.stringify(body);
498    }}
499    const res = await doFetch(this.baseUrl + path, init);
500    const parsed = res.status === 204 ? null : await res.json().catch(() => null);
501    if (!res.ok) throw new UmbralError(res.status, parsed);
502    return parsed;
503  }}
504}}
505"#,
506        header = header("client.js — the runtime (ES module)", base_path, auth),
507    )
508}
509
510// =========================================================================
511// client.d.ts — every type, plus declarations for the runtime's classes.
512// =========================================================================
513
514/// Auth types + the `AuthClient` declaration, or empty when the app serves no
515/// auth endpoints. Types come from the *published* request/response schemas, so
516/// they track the real contract.
517fn auth_types_dts(session: Option<&AuthEndpoints>) -> (String, String) {
518    let Some(s) = session else {
519        return (String::new(), String::new());
520    };
521    let mut out = format!(
522        "/** The signed-in user, from this app's `/me` + login response schema. */\n\
523         export type AuthUser = {user};\n\n\
524         /** Credentials the login endpoint accepts. */\n\
525         export type LoginCredentials = {login};\n\n\
526         /** What a successful login returns. The token is stored on the client \
527         automatically; browsers additionally get an httpOnly session cookie. */\n\
528         export interface LoginResult {{\n  user: AuthUser;\n  token: string;\n}}\n",
529        user = s.user,
530        login = s.login_body,
531    );
532    let mut methods = String::from(
533        "  /** The bearer token currently in use, or null. */\n  \
534         readonly token: string | null;\n  \
535         /** Sign in. Stores the token; later requests send it automatically. */\n  \
536         login(credentials: LoginCredentials): Promise<LoginResult>;\n",
537    );
538    if s.register.is_some() {
539        let _ = write!(
540            out,
541            "\n/** Fields the register endpoint accepts. */\nexport type RegisterCredentials = {};\n",
542            s.register_body,
543        );
544        methods.push_str(
545            "  /** Register, then adopt the returned token. */\n  \
546             register(body: RegisterCredentials): Promise<LoginResult>;\n",
547        );
548    }
549    methods.push_str(
550        "  /** Clear the server session and drop the token locally. */\n  \
551         logout(): Promise<void>;\n",
552    );
553    if s.me.is_some() {
554        methods.push_str(
555            "  /** The current user, or `null` when not signed in (a 401 is the\n      \
556             answer to \"am I logged in?\", not an error). */\n  \
557             me(): Promise<AuthUser | null>;\n",
558        );
559    }
560    let _ = write!(
561        out,
562        "\n/** The session client — reachable as `client.auth`. */\n\
563         export declare class AuthClient {{\n{methods}}}\n"
564    );
565    (
566        out,
567        "\n  /** The session client: login / logout / me. */\n  readonly auth: AuthClient;\n"
568            .to_string(),
569    )
570}
571
572/// Everything the two emitters need — passed as one context so the signature
573/// doesn't grow a new positional argument every time the surface does.
574struct Emit<'a> {
575    /// Every model the app knows (FK value types resolve against this).
576    all: &'a [ModelMeta],
577    /// The REST-exposed subset, sorted — what actually gets emitted.
578    exposed: &'a [ModelMeta],
579    base_path: &'a str,
580    style: PaginationStyle,
581    schema: Option<&'a PaginationSchema>,
582    auth: &'a AuthModel,
583    methods: &'a [PageMethod],
584    session: Option<&'a AuthEndpoints>,
585}
586
587fn emit_dts(e: &Emit<'_>) -> String {
588    let (all, exposed, base_path, style, schema, auth, methods, session) = (
589        e.all,
590        e.exposed,
591        e.base_path,
592        e.style,
593        e.schema,
594        e.auth,
595        e.methods,
596        e.session,
597    );
598    let mut out = String::new();
599    out.push_str(&header("client.d.ts — the types", base_path, auth));
600
601    // Row types describe RESPONSES, and `hide(...)` is response-only — a hidden
602    // column (a `password_hash`, an internal `cost`) is never in the JSON the API
603    // returns. So strip hidden columns from the row interfaces. They stay
604    // settable in the create/update DTOs (a hidden field can be write-only), and
605    // FK value types still resolve against the full, unstripped model set.
606    out.push_str(&umbral::typegen::typescript_for(&strip_hidden(exposed)));
607
608    // gaps3 #29 item 5: `#[derive(Dto)]` structs go in the CLIENT too, not just in
609    // `umbral typegen`'s output. `gen-client` is the artefact people actually import —
610    // a client that types every model and none of the hand-shaped response bodies is a
611    // client you abandon after the first custom handler, which is precisely what the
612    // audit found.
613    let dtos = umbral::typegen::registered_dtos();
614    if !dtos.is_empty() {
615        out.push('\n');
616        out.push_str(&umbral::typegen::typescript_for_dtos(&dtos));
617    }
618
619    for model in exposed {
620        out.push('\n');
621        push_filters_type(&mut out, all, model);
622        push_ordering_type(&mut out, model);
623        push_create_type(&mut out, all, model);
624        push_update_type(&mut out, all, model);
625    }
626
627    out.push('\n');
628    out.push_str(&envelope_type(style, schema));
629    out.push('\n');
630    push_resource_map(&mut out, exposed);
631    out.push('\n');
632    out.push_str(&options_types(auth));
633    let (auth_types, auth_member) = auth_types_dts(session);
634    if !auth_types.is_empty() {
635        out.push('\n');
636        out.push_str(&auth_types);
637    }
638    out.push('\n');
639    out.push_str(&class_declarations(methods, &auth_member));
640    out
641}
642
643fn header(what: &str, base_path: &str, auth: &AuthModel) -> String {
644    format!(
645        "// Code generated by `umbral gen-client`. DO NOT EDIT.\n\
646         //\n\
647         // {what}\n\
648         //\n\
649         // Regenerate after any model or REST-resource change:\n\
650         //     cargo run -- gen-client --out <this directory>\n\
651         //\n\
652         // Base path: {base_path}. Auth: {auth}.\n\n",
653        auth = auth.summary(),
654    )
655}
656
657/// `UmbralOptions` + the realtime interfaces.
658fn options_types(auth: &AuthModel) -> String {
659    let bearer_prefix = auth.bearer_prefix();
660    let api_key_header = auth.api_key_header();
661    let creds = if auth.cookie {
662        "\"include\""
663    } else {
664        "undefined"
665    };
666    format!(
667        r#"export interface UmbralOptions {{
668  /** Token sent as `{bearer_prefix} <token>` in the `Authorization` header
669      (the prefix comes from your API's declared security scheme). Override the
670      prefix with `tokenPrefix` — e.g. an API that expects `Token <key>`. */
671  token?: string;
672  /** Overrides the `Authorization` prefix for `token`. Defaults to
673      "{bearer_prefix}", read from the security scheme. */
674  tokenPrefix?: string;
675  /** API key sent in the `{api_key_header}` header (the header name comes from
676      your API's declared apiKey scheme). Override with `apiKeyHeader`. */
677  apiKey?: string;
678  /** Overrides the header `apiKey` is sent in. Defaults to "{api_key_header}",
679      read from the security scheme. */
680  apiKeyHeader?: string;
681  /** Called whenever the live token changes — `auth.login()` sets it,
682      `auth.logout()` clears it (null). Persist it here if you need it across
683      reloads. The client deliberately does NOT write it to localStorage: that is
684      readable by any XSS on the page, and browsers already hold an httpOnly
685      session cookie. Make that trade-off knowingly. */
686  onToken?: (token: string | null) => void;
687  /** Dynamic auth: called per request, its headers merged in last (they win).
688      Use for a rotating JWT, a refresh flow, request signing — anything the
689      static options above can't express. */
690  getAuthHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
691  /** Extra static headers merged into every request. */
692  headers?: Record<string, string>;
693  /** `fetch` credentials mode (cookies). Defaults to {creds}. */
694  credentials?: RequestCredentials;
695  /** Custom fetch (SSR / tests). Defaults to the global `fetch`. */
696  fetch?: typeof fetch;
697  /** Base path of the realtime plugin, for `.on(...)`. Defaults to "/realtime". */
698  realtimePath?: string;
699}}
700
701/** A live subscription started by `Umbral.on`. Call `close()` to stop it. */
702export interface Subscription {{
703  close(): void;
704}}
705
706/** Handlers for model-change events. Each receives the row your `expose(...)`
707    projection carries (the id by default), so you know which row to refetch. */
708export interface ModelEvents<Row> {{
709  created?(row: Row): void;
710  updated?(row: Row): void;
711  deleted?(row: Row): void;
712}}
713"#
714    )
715}
716
717/// Declarations for the classes `client.js` exports.
718fn class_declarations(methods: &[PageMethod], auth_member: &str) -> String {
719    let mut page_sigs = String::new();
720    for m in methods {
721        let _ = write!(
722            page_sigs,
723            "\n  /** {doc} */\n  {name}(v: {ty}): this;\n",
724            doc = m.doc,
725            name = m.name,
726            ty = m.ty,
727        );
728    }
729    format!(
730        r#"/** Thrown when the API returns a non-2xx response. `body` is the parsed error. */
731export declare class UmbralError extends Error {{
732  readonly status: number;
733  readonly body: unknown;
734  constructor(status: number, body: unknown);
735}}
736
737/** A list query. Keys and value types are specific to the model it came from. */
738export declare class Query<Row, Filters, Ordering> {{
739  /** Field filters — the keys are exactly the (field, lookup) pairs this model accepts. */
740  filter(f: Filters): this;
741  /** Full-text `?search=` across the model's searchable columns. */
742  search(term: string): this;
743  /** `?ordering=` — pass fields; prefix `-` for descending. */
744  orderBy(...fields: Ordering[]): this;
745{page_sigs}
746  /** Set any raw query param — the escape hatch for params the typed methods don't cover. */
747  param(key: string, value: string | number | boolean): this;
748  /** Sparse fieldset (`?fields=`) — fetch only these columns. */
749  fields(...cols: string[]): this;
750  /** Fetch the list. */
751  list(): Promise<Paginated<Row>>;
752}}
753
754/** A typed client for this app's REST API. */
755export declare class Umbral {{
756  constructor(baseUrl: string, opts?: UmbralOptions);
757{auth_member}
758  /** Start a query against a REST-exposed table. */
759  from<K extends keyof UmbralResources>(
760    table: K,
761  ): Query<UmbralResources[K]["row"], UmbralResources[K]["filters"], UmbralResources[K]["ordering"]>;
762
763  /** Retrieve one row by primary key. */
764  get<K extends keyof UmbralResources>(
765    table: K,
766    id: UmbralResources[K]["id"],
767  ): Promise<UmbralResources[K]["row"]>;
768
769  /** Create a row. The body type omits server-managed fields; `noedit` fields
770      are allowed here (settable on create). */
771  create<K extends keyof UmbralResources>(
772    table: K,
773    data: UmbralResources[K]["create"],
774  ): Promise<UmbralResources[K]["row"]>;
775
776  /** Partially update a row (PATCH). The body type excludes `noedit` fields. */
777  update<K extends keyof UmbralResources>(
778    table: K,
779    id: UmbralResources[K]["id"],
780    data: UmbralResources[K]["update"],
781  ): Promise<UmbralResources[K]["row"]>;
782
783  /** Delete a row by primary key. */
784  delete<K extends keyof UmbralResources>(table: K, id: UmbralResources[K]["id"]): Promise<void>;
785
786  /** Subscribe to `created` / `updated` / `deleted` for a model, over the
787      realtime plugin's shared connection (one SSE stream across all tabs). The
788      model must be `expose(...)`-d to `group` on the server. */
789  on<K extends keyof UmbralResources>(
790    table: K,
791    handlers: ModelEvents<Partial<UmbralResources[K]["row"]>>,
792    opts: {{ group: string }},
793  ): Subscription;
794}}
795"#
796    )
797}
798
799// =========================================================================
800// Type emission (shared by the .d.ts): filters, ordering, write DTOs,
801// envelope, resource map.
802// =========================================================================
803
804/// The TypeScript type of a filter key's value for one (column, lookup) pair.
805///
806/// Mirrors the REST filter contract (see `umbral-rest/src/filtering.rs`):
807/// comparisons carry the field's own type, `__in` is an array of it (the client
808/// joins to the CSV the backend expects), substring lookups are `string`, and
809/// `__isnull` is `boolean`.
810fn filter_value_type(all: &[ModelMeta], model: &ModelMeta, col: &Column, lookup: &str) -> String {
811    match lookup {
812        "in" => format!("{}[]", umbral::typegen::ts_base_type(all, model, col)),
813        "isnull" => "boolean".to_string(),
814        "contains" | "icontains" | "startswith" => "string".to_string(),
815        _ => umbral::typegen::ts_base_type(all, model, col),
816    }
817}
818
819/// The filter key for a (column, lookup): bare name for `eq`, `col__lookup`
820/// otherwise — exactly the query-param names the REST list endpoint parses.
821fn filter_key(col_name: &str, lookup: &str) -> String {
822    if lookup == "eq" {
823        col_name.to_string()
824    } else {
825        format!("{col_name}__{lookup}")
826    }
827}
828
829/// A column the client should see: not hidden by the REST layer.
830fn visible(table: &str, col: &Column) -> bool {
831    !umbral_rest::is_hidden(table, &col.name)
832}
833
834/// `export interface PostFilters {{ status?: PostStatus; views__gte?: number; ... }}`
835fn push_filters_type(out: &mut String, all: &[ModelMeta], model: &ModelMeta) {
836    let name = format!("{}Filters", model.name);
837    if !umbral_rest::filters_enabled_for(&model.table) {
838        let _ = writeln!(
839            out,
840            "/** Filtering is disabled for `{}`. */\nexport type {name} = Record<string, never>;",
841            model.table,
842        );
843        return;
844    }
845    let _ = writeln!(
846        out,
847        "/** Filterable query parameters for `{}`. Every key is optional and \
848         AND-combined server-side. */",
849        model.table,
850    );
851    let _ = writeln!(out, "export interface {name} {{");
852    for col in &model.fields {
853        // The REST filter surface excludes the primary key and hidden columns.
854        if col.primary_key || !visible(&model.table, col) {
855            continue;
856        }
857        for lookup in umbral_rest::filtering::applicable_lookups(col) {
858            let key = filter_key(&col.name, lookup);
859            let ty = filter_value_type(all, model, col, lookup);
860            // Every lookup key is optional.
861            let _ = writeln!(out, "  \"{key}\"?: {ty};");
862        }
863    }
864    out.push_str("}\n");
865}
866
867/// `export type PostOrdering = "id" | "-id" | "title" | "-title" | ...` — every
868/// visible column, ascending or `-`-prefixed descending, matching the REST
869/// `?ordering=` param.
870fn push_ordering_type(out: &mut String, model: &ModelMeta) {
871    let name = format!("{}Ordering", model.name);
872    let mut variants: Vec<String> = Vec::new();
873    for col in &model.fields {
874        if !visible(&model.table, col) {
875            continue;
876        }
877        variants.push(format!("\"{}\"", col.name));
878        variants.push(format!("\"-{}\"", col.name));
879    }
880    if variants.is_empty() {
881        let _ = writeln!(out, "export type {name} = never;");
882    } else {
883        let _ = writeln!(out, "export type {name} = {};", variants.join(" | "));
884    }
885}
886
887/// Whether a column can appear in a *create* request body.
888///
889/// Excludes the server-managed columns the REST write path fills or refuses:
890/// the primary key (assigned on insert), `#[umbral(noform)]` (stripped from
891/// every write body), `#[umbral(privileged)]` (the mass-assignment guard strips
892/// it by default), and `auto_now`/`auto_now_add` (stamped by the server).
893/// A `#[umbral(noedit)]` column IS creatable — you can set a username on create,
894/// you just can't change it later (see [`in_update`]).
895fn in_create(col: &Column) -> bool {
896    !(col.primary_key || col.noform || col.privileged || col.auto_now || col.auto_now_add)
897}
898
899/// Whether a column can appear in an *update* body: everything creatable, minus
900/// `#[umbral(noedit)]` — the "set once, then read-only" fields.
901fn in_update(col: &Column) -> bool {
902    in_create(col) && !col.noedit
903}
904
905/// The TS type of a writable field's value: the same base type as the row (FK →
906/// target PK, choices → union, scalar otherwise), plus `| null` for a nullable
907/// column (you may write null to it).
908fn write_field_type(all: &[ModelMeta], model: &ModelMeta, col: &Column) -> String {
909    let base = umbral::typegen::ts_base_type(all, model, col);
910    if col.nullable {
911        format!("{base} | null")
912    } else {
913        base
914    }
915}
916
917/// `export interface PostCreate {{ title: string; author: string; body?: string | null; ... }}`
918///
919/// A field is required (no `?`) only when it is non-nullable and has no server
920/// default — otherwise the server can fill it, so the client may omit it.
921fn push_create_type(out: &mut String, all: &[ModelMeta], model: &ModelMeta) {
922    let name = format!("{}Create", model.name);
923    let _ = writeln!(
924        out,
925        "/** Body for creating a `{}`. Server-managed columns (id, auto-timestamps, \
926         privileged, no-form) are omitted. */",
927        model.table,
928    );
929    let _ = writeln!(out, "export interface {name} {{");
930    for col in &model.fields {
931        if !in_create(col) {
932            continue;
933        }
934        let optional = col.nullable || !col.default.is_empty();
935        let q = if optional { "?" } else { "" };
936        let _ = writeln!(
937            out,
938            "  {}{q}: {};",
939            col.name,
940            write_field_type(all, model, col)
941        );
942    }
943    out.push_str("}\n");
944}
945
946/// `export interface PostUpdate {{ title?: string; ... }}` — a PATCH body. Every
947/// field is optional (partial update), and `#[umbral(noedit)]` columns are gone:
948/// the type won't let you change a set-once field.
949fn push_update_type(out: &mut String, all: &[ModelMeta], model: &ModelMeta) {
950    let name = format!("{}Update", model.name);
951    let _ = writeln!(
952        out,
953        "/** Body for updating a `{}` (PATCH; all fields optional). `noedit` \
954         columns are excluded — they can be set on create but not changed. */",
955        model.table,
956    );
957    let _ = writeln!(out, "export interface {name} {{");
958    for col in &model.fields {
959        if !in_update(col) {
960            continue;
961        }
962        let _ = writeln!(
963            out,
964            "  {}?: {};",
965            col.name,
966            write_field_type(all, model, col)
967        );
968    }
969    out.push_str("}\n");
970}
971
972/// The TS scalar for a declared pagination field.
973fn scalar_ts(s: PaginationScalar) -> &'static str {
974    match s {
975        PaginationScalar::String => "string",
976        PaginationScalar::Number => "number",
977        PaginationScalar::Boolean => "boolean",
978    }
979}
980
981/// `page_size` / `next_cursor` → `pageSize` / `nextCursor` for a builder method
982/// name. Envelope keys keep their wire name (they're response JSON keys); only
983/// the *method* names are camelCased.
984fn camel_case(s: &str) -> String {
985    let pascal = pascal_case_from_ident(s);
986    let mut chars = pascal.chars();
987    match chars.next() {
988        Some(first) => first.to_ascii_lowercase().to_string() + chars.as_str(),
989        None => String::new(),
990    }
991}
992
993/// The list-response wrapper, shaped to the configured paginator.
994///
995/// Built-in styles have a known shape. A `Custom` paginator that declared a
996/// [`PaginationSchema`] gets a *typed* envelope (its declared keys); a `Custom`
997/// paginator that didn't gets an honest permissive envelope — `results`/`count`
998/// optional plus an index signature — so reading a custom key still type-checks
999/// without the generator pretending to know a shape it doesn't.
1000fn envelope_type(style: PaginationStyle, schema: Option<&PaginationSchema>) -> String {
1001    let doc = "/** A list response, shaped to this API's paginator. */";
1002    match (style, schema) {
1003        (PaginationStyle::Custom, Some(s)) => {
1004            let mut fields = String::from("  results: T[];\n");
1005            for f in &s.envelope {
1006                let null = if f.nullable { " | null" } else { "" };
1007                let _ = writeln!(fields, "  {}: {}{null};", f.name, scalar_ts(f.ty));
1008            }
1009            format!("{doc}\nexport interface Paginated<T> {{\n{fields}}}\n")
1010        }
1011        (PaginationStyle::Custom, None) => format!(
1012            "{doc}\n/** This app uses a custom paginator that did not declare its shape, \
1013             so the envelope is left open: read known keys, set params via `.param(...)`. */\n\
1014             export interface Paginated<T> {{\n  results?: T[];\n  count?: number;\n  \
1015             [key: string]: unknown;\n}}\n"
1016        ),
1017        (style, _) => {
1018            let extra = match style {
1019                PaginationStyle::PageNumber => {
1020                    "  total_pages: number;\n  current_page: number;\n  page_size: number;\n  \
1021                     next: number | null;\n  previous: number | null;\n"
1022                }
1023                PaginationStyle::LimitOffset => {
1024                    "  limit: number;\n  offset: number;\n  next: number | null;\n  \
1025                     previous: number | null;\n"
1026                }
1027                _ => "",
1028            };
1029            format!(
1030                "/** A list response. `results` + `count` are always present; the rest \
1031                 depend on the paginator. */\n\
1032                 export interface Paginated<T> {{\n  results: T[];\n  count: number;\n{extra}}}\n"
1033            )
1034        }
1035    }
1036}
1037
1038/// `interface UmbralResources {{ "post": {{ row: Post; filters: PostFilters; ... }}; ... }}`
1039fn push_resource_map(out: &mut String, exposed: &[ModelMeta]) {
1040    out.push_str(
1041        "/** Maps each REST-exposed table to its row, filter, and ordering types. \
1042         `Umbral.from` keys off this. */\n",
1043    );
1044    out.push_str("export interface UmbralResources {\n");
1045    for model in exposed {
1046        // Each resource carries its OWN primary-key type — `number` for an i64
1047        // PK, `string` for a Uuid or String PK — so `.get`/`.update`/`.delete`
1048        // take exactly that model's id, not a union across every model.
1049        let id = model
1050            .pk_column()
1051            .map(ts_scalar_for_pk)
1052            .unwrap_or_else(|| "string | number".to_string());
1053        let _ = writeln!(
1054            out,
1055            "  \"{table}\": {{ row: {name}; filters: {name}Filters; ordering: {name}Ordering; \
1056             create: {name}Create; update: {name}Update; id: {id} }};",
1057            table = model.table,
1058            name = model.name,
1059        );
1060    }
1061    out.push_str("}\n");
1062}
1063
1064/// The PK's TS scalar. A PK is never an FK or a choices column, so the plain
1065/// SqlType→TS scalar is right; `number` for int PKs, `string` for uuid/slug.
1066fn ts_scalar_for_pk(col: &Column) -> String {
1067    umbral::typegen::ts_base_type(
1068        &[],
1069        &ModelMeta {
1070            view: None,
1071            materialized: false,
1072            name: String::new(),
1073            table: String::new(),
1074            fields: vec![col.clone()],
1075            ..ModelMeta::default()
1076        },
1077        col,
1078    )
1079}
1080
1081// =========================================================================
1082// Auth, derived from the declared OpenAPI security schemes.
1083// =========================================================================
1084
1085/// How the client authenticates, derived from the app's OpenAPI security schemes
1086/// (`registered_security_schemes()`) — nothing is hardcoded. A `http`/bearer-style
1087/// scheme yields the `Authorization` prefix from its `scheme` field (`bearer` →
1088/// `Bearer`, `token` → `Token`); an `apiKey`/header scheme yields its header
1089/// `name` (`x-umbral-api-key`, whatever the API declared); an `apiKey`/cookie
1090/// scheme (session auth) flips fetch to send credentials. These become the
1091/// *defaults* baked into the generated client; every one stays overridable.
1092#[derive(Debug, Default)]
1093struct AuthModel {
1094    /// `Authorization` prefix for a bearer-style scheme (title-cased `scheme`).
1095    bearer_prefix: Option<String>,
1096    /// Header name for an `apiKey`-in-header scheme.
1097    api_key_header: Option<String>,
1098    /// An `apiKey`-in-cookie (session) scheme is present → send credentials.
1099    cookie: bool,
1100}
1101
1102impl AuthModel {
1103    fn from_schemes(schemes: &[(String, Value)]) -> Self {
1104        let mut m = AuthModel::default();
1105        for (_name, v) in schemes {
1106            match v.get("type").and_then(Value::as_str) {
1107                Some("http") => {
1108                    let scheme = v.get("scheme").and_then(Value::as_str).unwrap_or("bearer");
1109                    // Basic auth has no token/key option — it goes through
1110                    // `headers` / `getAuthHeaders`. Any other http scheme is
1111                    // token-bearing; its `scheme` IS the Authorization prefix.
1112                    if !scheme.eq_ignore_ascii_case("basic") && m.bearer_prefix.is_none() {
1113                        m.bearer_prefix = Some(title_case(scheme));
1114                    }
1115                }
1116                Some("apiKey") => match v.get("in").and_then(Value::as_str) {
1117                    Some("header") => {
1118                        if m.api_key_header.is_none() {
1119                            if let Some(name) = v.get("name").and_then(Value::as_str) {
1120                                m.api_key_header = Some(name.to_string());
1121                            }
1122                        }
1123                    }
1124                    Some("cookie") => m.cookie = true,
1125                    _ => {}
1126                },
1127                _ => {}
1128            }
1129        }
1130        m
1131    }
1132
1133    /// The `Authorization` prefix the generated client defaults to for `token`.
1134    fn bearer_prefix(&self) -> &str {
1135        self.bearer_prefix.as_deref().unwrap_or("Bearer")
1136    }
1137
1138    /// The header the generated client defaults to for `apiKey`.
1139    fn api_key_header(&self) -> &str {
1140        self.api_key_header.as_deref().unwrap_or("X-API-Key")
1141    }
1142
1143    /// One-line human summary for the file header.
1144    fn summary(&self) -> String {
1145        let mut parts = vec![format!("{} token", self.bearer_prefix())];
1146        parts.push(format!("apiKey ({})", self.api_key_header()));
1147        if self.cookie {
1148            parts.push("session cookie".to_string());
1149        }
1150        parts.join(" / ")
1151    }
1152}
1153
1154/// Title-case a security-scheme token: `bearer` → `Bearer`, `token` → `Token`.
1155/// Only the first byte is upper-cased — HTTP auth schemes are single words.
1156fn title_case(s: &str) -> String {
1157    let mut chars = s.chars();
1158    match chars.next() {
1159        Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
1160        None => String::new(),
1161    }
1162}
1163
1164// =========================================================================
1165// The session client — discovered from the app's published OpenAPI paths.
1166// =========================================================================
1167
1168/// The auth endpoints this app actually serves, discovered from the paths every
1169/// plugin publishes via `Plugin::openapi_paths()`.
1170///
1171/// Keyed off `operationId` (`auth_login`, `auth_logout`, `auth_me`,
1172/// `auth_register`), NOT off path spelling — so a plugin mounted at a custom
1173/// prefix (`.at("/accounts")`) still generates a working session client, and an
1174/// app with no auth plugin generates none at all (no dead code).
1175#[derive(Debug, Default)]
1176struct AuthEndpoints {
1177    login: Option<String>,
1178    logout: Option<String>,
1179    me: Option<String>,
1180    register: Option<String>,
1181    /// TS type of the login request body, from the published schema.
1182    login_body: String,
1183    /// TS type of the register request body.
1184    register_body: String,
1185    /// TS type of the user object (`/me`, and `login.user`).
1186    user: String,
1187}
1188
1189impl AuthEndpoints {
1190    /// `Some` only when the app serves a login endpoint — the session client is
1191    /// meaningless without one.
1192    fn discover(paths: &[(String, Value)]) -> Option<Self> {
1193        let mut e = AuthEndpoints::default();
1194        for (path, item) in paths {
1195            // A path item maps method → operation.
1196            let Some(ops) = item.as_object() else {
1197                continue;
1198            };
1199            for (_method, op) in ops {
1200                let Some(id) = op.get("operationId").and_then(Value::as_str) else {
1201                    continue;
1202                };
1203                match id {
1204                    "auth_login" => {
1205                        e.login = Some(path.clone());
1206                        e.login_body = request_body_ts(op).unwrap_or_else(|| "unknown".into());
1207                        // login returns `{ user, token }` — lift the user shape.
1208                        if let Some(schema) = response_schema(op) {
1209                            if let Some(user) = schema.get("properties").and_then(|p| p.get("user"))
1210                            {
1211                                e.user = schema_to_ts(user);
1212                            }
1213                        }
1214                    }
1215                    "auth_logout" => e.logout = Some(path.clone()),
1216                    "auth_register" => {
1217                        e.register = Some(path.clone());
1218                        e.register_body = request_body_ts(op).unwrap_or_else(|| "unknown".into());
1219                    }
1220                    "auth_me" => {
1221                        e.me = Some(path.clone());
1222                        if e.user.is_empty() {
1223                            if let Some(schema) = response_schema(op) {
1224                                e.user = schema_to_ts(&schema);
1225                            }
1226                        }
1227                    }
1228                    _ => {}
1229                }
1230            }
1231        }
1232        if e.user.is_empty() {
1233            e.user = "Record<string, unknown>".to_string();
1234        }
1235        e.login.as_ref()?;
1236        Some(e)
1237    }
1238}
1239
1240/// The `application/json` request-body schema of an operation, as a TS type.
1241fn request_body_ts(op: &Value) -> Option<String> {
1242    let schema = op
1243        .get("requestBody")?
1244        .get("content")?
1245        .get("application/json")?
1246        .get("schema")?;
1247    Some(schema_to_ts(schema))
1248}
1249
1250/// The `200` `application/json` response schema of an operation.
1251fn response_schema(op: &Value) -> Option<Value> {
1252    op.get("responses")?
1253        .get("200")?
1254        .get("content")?
1255        .get("application/json")?
1256        .get("schema")
1257        .cloned()
1258}
1259
1260/// Render a (simple, inline) JSON Schema as a TypeScript type.
1261///
1262/// Covers what the auth surface publishes: objects with `properties` +
1263/// `required`, and the scalar leaves. Anything it can't model degrades to
1264/// `unknown` rather than guessing — a wrong type is worse than an honest one.
1265fn schema_to_ts(schema: &Value) -> String {
1266    match schema.get("type").and_then(Value::as_str) {
1267        Some("object") => {
1268            let Some(props) = schema.get("properties").and_then(Value::as_object) else {
1269                return "Record<string, unknown>".to_string();
1270            };
1271            let required: Vec<&str> = schema
1272                .get("required")
1273                .and_then(Value::as_array)
1274                .map(|a| a.iter().filter_map(Value::as_str).collect())
1275                .unwrap_or_default();
1276            let mut out = String::from("{ ");
1277            for (name, sub) in props {
1278                let opt = if required.contains(&name.as_str()) {
1279                    ""
1280                } else {
1281                    "?"
1282                };
1283                let _ = write!(out, "{name}{opt}: {}; ", schema_to_ts(sub));
1284            }
1285            out.push('}');
1286            out
1287        }
1288        Some("array") => match schema.get("items") {
1289            Some(items) => format!("{}[]", schema_to_ts(items)),
1290            None => "unknown[]".to_string(),
1291        },
1292        Some("string") => "string".to_string(),
1293        Some("integer") | Some("number") => "number".to_string(),
1294        Some("boolean") => "boolean".to_string(),
1295        _ => "unknown".to_string(),
1296    }
1297}