Skip to main content

vercel_rpc_cli/codegen/
client.rs

1use std::fmt::Write;
2
3use super::typescript::{emit_jsdoc, rust_type_to_ts};
4use crate::model::{Manifest, Procedure, ProcedureKind};
5
6// Header comment included at the top of every generated client file.
7const GENERATED_HEADER: &str = "\
8// This file is auto-generated by vercel-rpc-cli. Do not edit manually.
9// Re-run `rpc generate` or use `rpc watch` to regenerate.
10";
11
12/// Standard RPC error class with status code and structured error data.
13const ERROR_CLASS: &str = r#"export class RpcError extends Error {
14  readonly status: number;
15  readonly data: unknown;
16
17  constructor(status: number, message: string, data?: unknown) {
18    super(message);
19    this.name = "RpcError";
20    this.status = status;
21    this.data = data;
22  }
23}"#;
24
25/// Internal fetch helper shared by query and mutate methods.
26const FETCH_HELPER: &str = r#"async function rpcFetch(
27  baseUrl: string,
28  method: "GET" | "POST",
29  procedure: string,
30  input?: unknown,
31): Promise<unknown> {
32  let url = `${baseUrl}/${procedure}`;
33  const init: RequestInit = { method, headers: {} };
34
35  if (method === "GET" && input !== undefined) {
36    url += `?input=${encodeURIComponent(JSON.stringify(input))}`;
37  } else if (method === "POST" && input !== undefined) {
38    init.body = JSON.stringify(input);
39    (init.headers as Record<string, string>)["Content-Type"] = "application/json";
40  }
41
42  const res = await fetch(url, init);
43
44  if (!res.ok) {
45    let data: unknown;
46    try {
47      data = await res.json();
48    } catch {
49      data = await res.text().catch(() => null);
50    }
51    throw new RpcError(
52      res.status,
53      `RPC error on "${procedure}": ${res.status} ${res.statusText}`,
54      data,
55    );
56  }
57
58  const json = await res.json();
59  return json?.result?.data ?? json;
60}"#;
61
62/// Generates the complete `rpc-client.ts` file content from a manifest.
63///
64/// The output includes:
65/// 1. Auto-generation header
66/// 2. Re-export of `Procedures` type from the types file
67/// 3. `RpcError` class for structured error handling
68/// 4. Internal `rpcFetch` helper
69/// 5. `createRpcClient` factory function with fully typed `query` / `mutate` methods
70pub fn generate_client_file(
71    manifest: &Manifest,
72    types_import_path: &str,
73    preserve_docs: bool,
74) -> String {
75    let mut out = String::with_capacity(2048);
76
77    // Header
78    out.push_str(GENERATED_HEADER);
79    out.push('\n');
80
81    // Collect all user-defined type names (structs + enums) for import
82    let type_names: Vec<&str> = manifest
83        .structs
84        .iter()
85        .map(|s| s.name.as_str())
86        .chain(manifest.enums.iter().map(|e| e.name.as_str()))
87        .collect();
88
89    // Import Procedures type (and any referenced types) from the types file
90    if type_names.is_empty() {
91        let _ = writeln!(
92            out,
93            "import type {{ Procedures }} from \"{types_import_path}\";\n"
94        );
95        let _ = writeln!(out, "export type {{ Procedures }};\n");
96    } else {
97        let types_csv = type_names.join(", ");
98        let _ = writeln!(
99            out,
100            "import type {{ Procedures, {types_csv} }} from \"{types_import_path}\";\n"
101        );
102        let _ = writeln!(out, "export type {{ Procedures, {types_csv} }};\n");
103    }
104
105    // Error class
106    let _ = writeln!(out, "{ERROR_CLASS}\n");
107
108    // Internal fetch helper
109    let _ = writeln!(out, "{FETCH_HELPER}\n");
110
111    // Type helpers for ergonomic API
112    generate_type_helpers(&mut out);
113    out.push('\n');
114
115    // Client factory
116    generate_client_factory(manifest, preserve_docs, &mut out);
117
118    out
119}
120
121/// Emits utility types that power the typed client API.
122fn generate_type_helpers(out: &mut String) {
123    let _ = writeln!(out, "type QueryKey = keyof Procedures[\"queries\"];");
124    let _ = writeln!(out, "type MutationKey = keyof Procedures[\"mutations\"];");
125    let _ = writeln!(
126        out,
127        "type QueryInput<K extends QueryKey> = Procedures[\"queries\"][K][\"input\"];"
128    );
129    let _ = writeln!(
130        out,
131        "type QueryOutput<K extends QueryKey> = Procedures[\"queries\"][K][\"output\"];"
132    );
133    let _ = writeln!(
134        out,
135        "type MutationInput<K extends MutationKey> = Procedures[\"mutations\"][K][\"input\"];"
136    );
137    let _ = writeln!(
138        out,
139        "type MutationOutput<K extends MutationKey> = Procedures[\"mutations\"][K][\"output\"];"
140    );
141}
142
143/// Generates the `createRpcClient` factory using an interface for typed overloads.
144fn generate_client_factory(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
145    let has_queries = manifest
146        .procedures
147        .iter()
148        .any(|p| p.kind == ProcedureKind::Query);
149    let has_mutations = manifest
150        .procedures
151        .iter()
152        .any(|p| p.kind == ProcedureKind::Mutation);
153
154    // Emit the RpcClient interface with overloaded method signatures
155    let _ = writeln!(out, "export interface RpcClient {{");
156
157    if has_queries {
158        generate_query_overloads(manifest, preserve_docs, out);
159    }
160
161    if has_mutations {
162        if has_queries {
163            out.push('\n');
164        }
165        generate_mutation_overloads(manifest, preserve_docs, out);
166    }
167
168    let _ = writeln!(out, "}}");
169    out.push('\n');
170
171    // Emit the factory function
172    let _ = writeln!(
173        out,
174        "export function createRpcClient(baseUrl: string): RpcClient {{"
175    );
176    let _ = writeln!(out, "  return {{");
177
178    if has_queries {
179        let _ = writeln!(
180            out,
181            "    query(key: QueryKey, ...args: unknown[]): Promise<unknown> {{"
182        );
183        let _ = writeln!(
184            out,
185            "      return rpcFetch(baseUrl, \"GET\", key, args[0]);"
186        );
187        let _ = writeln!(out, "    }},");
188    }
189
190    if has_mutations {
191        let _ = writeln!(
192            out,
193            "    mutate(key: MutationKey, ...args: unknown[]): Promise<unknown> {{"
194        );
195        let _ = writeln!(
196            out,
197            "      return rpcFetch(baseUrl, \"POST\", key, args[0]);"
198        );
199        let _ = writeln!(out, "    }},");
200    }
201
202    let _ = writeln!(out, "  }} as RpcClient;");
203    let _ = writeln!(out, "}}");
204}
205
206/// Generates query overload signatures for the RpcClient interface.
207fn generate_query_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
208    let (void_queries, non_void_queries): (Vec<_>, Vec<_>) = manifest
209        .procedures
210        .iter()
211        .filter(|p| p.kind == ProcedureKind::Query)
212        .partition(|p| is_void_input(p));
213
214    // Overload signatures for void-input queries (no input argument required)
215    for proc in &void_queries {
216        if preserve_docs && let Some(doc) = &proc.docs {
217            emit_jsdoc(doc, "  ", out);
218        }
219        let output_ts = proc
220            .output
221            .as_ref()
222            .map(rust_type_to_ts)
223            .unwrap_or_else(|| "void".to_string());
224        let _ = writeln!(
225            out,
226            "  query(key: \"{}\"): Promise<{}>;",
227            proc.name, output_ts,
228        );
229    }
230
231    // Overload signatures for non-void-input queries
232    for proc in &non_void_queries {
233        if preserve_docs && let Some(doc) = &proc.docs {
234            emit_jsdoc(doc, "  ", out);
235        }
236        let input_ts = proc
237            .input
238            .as_ref()
239            .map(rust_type_to_ts)
240            .unwrap_or_else(|| "void".to_string());
241        let output_ts = proc
242            .output
243            .as_ref()
244            .map(rust_type_to_ts)
245            .unwrap_or_else(|| "void".to_string());
246        let _ = writeln!(
247            out,
248            "  query(key: \"{}\", input: {}): Promise<{}>;",
249            proc.name, input_ts, output_ts,
250        );
251    }
252}
253
254/// Generates mutation overload signatures for the RpcClient interface.
255fn generate_mutation_overloads(manifest: &Manifest, preserve_docs: bool, out: &mut String) {
256    let (void_mutations, non_void_mutations): (Vec<_>, Vec<_>) = manifest
257        .procedures
258        .iter()
259        .filter(|p| p.kind == ProcedureKind::Mutation)
260        .partition(|p| is_void_input(p));
261
262    // Overload signatures for void-input mutations
263    for proc in &void_mutations {
264        if preserve_docs && let Some(doc) = &proc.docs {
265            emit_jsdoc(doc, "  ", out);
266        }
267        let output_ts = proc
268            .output
269            .as_ref()
270            .map(rust_type_to_ts)
271            .unwrap_or_else(|| "void".to_string());
272        let _ = writeln!(
273            out,
274            "  mutate(key: \"{}\"): Promise<{}>;",
275            proc.name, output_ts,
276        );
277    }
278
279    // Overload signatures for non-void-input mutations
280    for proc in &non_void_mutations {
281        if preserve_docs && let Some(doc) = &proc.docs {
282            emit_jsdoc(doc, "  ", out);
283        }
284        let input_ts = proc
285            .input
286            .as_ref()
287            .map(rust_type_to_ts)
288            .unwrap_or_else(|| "void".to_string());
289        let output_ts = proc
290            .output
291            .as_ref()
292            .map(rust_type_to_ts)
293            .unwrap_or_else(|| "void".to_string());
294        let _ = writeln!(
295            out,
296            "  mutate(key: \"{}\", input: {}): Promise<{}>;",
297            proc.name, input_ts, output_ts,
298        );
299    }
300}
301
302/// Returns `true` if the procedure takes no input (void).
303fn is_void_input(proc: &Procedure) -> bool {
304    proc.input.as_ref().is_none_or(|ty| ty.name == "()")
305}