1use 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#[derive(Debug, Clone)]
53pub struct GeneratedClient {
54 pub js: String,
56 pub dts: String,
58}
59
60pub 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
73pub 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
87pub 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
132fn 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
148struct PageMethod {
155 name: String,
157 ty: &'static str,
159 wire: String,
161 doc: String,
163}
164
165fn 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
204fn 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 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
510fn 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
572struct Emit<'a> {
575 all: &'a [ModelMeta],
577 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 out.push_str(&umbral::typegen::typescript_for(&strip_hidden(exposed)));
607
608 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
657fn 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
717fn 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
799fn 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
819fn 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
829fn visible(table: &str, col: &Column) -> bool {
831 !umbral_rest::is_hidden(table, &col.name)
832}
833
834fn 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 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 let _ = writeln!(out, " \"{key}\"?: {ty};");
862 }
863 }
864 out.push_str("}\n");
865}
866
867fn 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
887fn in_create(col: &Column) -> bool {
896 !(col.primary_key || col.noform || col.privileged || col.auto_now || col.auto_now_add)
897}
898
899fn in_update(col: &Column) -> bool {
902 in_create(col) && !col.noedit
903}
904
905fn 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
917fn 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
946fn 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
972fn scalar_ts(s: PaginationScalar) -> &'static str {
974 match s {
975 PaginationScalar::String => "string",
976 PaginationScalar::Number => "number",
977 PaginationScalar::Boolean => "boolean",
978 }
979}
980
981fn 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
993fn 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
1038fn 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 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
1064fn 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#[derive(Debug, Default)]
1093struct AuthModel {
1094 bearer_prefix: Option<String>,
1096 api_key_header: Option<String>,
1098 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 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 fn bearer_prefix(&self) -> &str {
1135 self.bearer_prefix.as_deref().unwrap_or("Bearer")
1136 }
1137
1138 fn api_key_header(&self) -> &str {
1140 self.api_key_header.as_deref().unwrap_or("X-API-Key")
1141 }
1142
1143 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
1154fn 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#[derive(Debug, Default)]
1176struct AuthEndpoints {
1177 login: Option<String>,
1178 logout: Option<String>,
1179 me: Option<String>,
1180 register: Option<String>,
1181 login_body: String,
1183 register_body: String,
1185 user: String,
1187}
1188
1189impl AuthEndpoints {
1190 fn discover(paths: &[(String, Value)]) -> Option<Self> {
1193 let mut e = AuthEndpoints::default();
1194 for (path, item) in paths {
1195 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 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
1240fn 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
1250fn 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
1260fn 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}