Skip to main content

fraiseql_cli/commands/
generate_client.rs

1//! `fraiseql generate-client` — generate consumer-side typed clients from a
2//! compiled schema (`schema.compiled.json`).
3//!
4//! This is distinct from `fraiseql generate`, which emits server-side **authoring**
5//! code (FraiseQL type definitions in another language, fed back into the
6//! compiler). `generate-client` consumes the compiler's *output* to build a client
7//! that *calls* the API. The generation itself lives in the `fraiseql-codegen`
8//! crate; this command is the thin filesystem wrapper.
9
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13use fraiseql_core::schema::CompiledSchema;
14
15/// Supported client target languages.
16#[derive(Debug, Clone, Copy)]
17pub enum ClientLanguage {
18    /// TypeScript client (interfaces + typed query/mutation functions).
19    TypeScript,
20    /// Python client (TypedDicts + typed query/mutation functions; ≥ 3.12).
21    Python,
22    /// Go client (structs + typed query/mutation functions; ≥ 1.21).
23    Go,
24    /// Rust client (serde types + typed query/mutation functions).
25    Rust,
26}
27
28/// Run `generate-client <language>`.
29///
30/// `schema_path` is auto-detected from conventional locations when `None`.
31/// Refuses to overwrite an existing generated tree unless `force` is set.
32///
33/// # Errors
34///
35/// Returns an error if the schema cannot be found, read, or parsed, if the output
36/// directory already contains a generated client and `force` is not set, or if any
37/// file cannot be written.
38pub fn run(
39    language: ClientLanguage,
40    schema_path: Option<&Path>,
41    out_dir: &Path,
42    force: bool,
43) -> Result<()> {
44    let schema_path = match schema_path {
45        Some(p) => p.to_path_buf(),
46        None => auto_detect_compiled_schema()?,
47    };
48
49    let raw = std::fs::read_to_string(&schema_path)
50        .with_context(|| format!("Failed to read compiled schema {}", schema_path.display()))?;
51    // `from_json`, not a bare `serde_json::from_str`: deserialization alone
52    // leaves the schema's derived state unbuilt, and a generated client needs
53    // the filter/sort input surface `where`/`orderBy` are typed against (#1154).
54    // Without it the client compiles against `JSON` and its user gets no
55    // typechecking on a filter — the exact gap this release closes.
56    let schema: CompiledSchema = CompiledSchema::from_json(&raw, false).with_context(|| {
57        format!("Failed to parse {} as a compiled schema", schema_path.display())
58    })?;
59
60    let mut files = match language {
61        ClientLanguage::TypeScript => fraiseql_codegen::client::typescript::generate(&schema)
62            .map_err(|e| anyhow::anyhow!("client generation failed: {e}"))?,
63        ClientLanguage::Python => fraiseql_codegen::client::python::generate(&schema)
64            .map_err(|e| anyhow::anyhow!("client generation failed: {e}"))?,
65        ClientLanguage::Go => fraiseql_codegen::client::go::generate(&schema)
66            .map_err(|e| anyhow::anyhow!("client generation failed: {e}"))?,
67        ClientLanguage::Rust => fraiseql_codegen::client::rust::generate(&schema)
68            .map_err(|e| anyhow::anyhow!("client generation failed: {e}"))?,
69    };
70
71    // `functions.d.ts` (phase 08): typed guest payloads + host-op declarations, when
72    // the compiled schema declares functions. Parsed from the same raw JSON — the
73    // `functions` section is not part of `CompiledSchema`.
74    if matches!(language, ClientLanguage::TypeScript) {
75        let specs = functions_type_specs(&raw, &schema);
76        if !specs.is_empty() {
77            let dts = fraiseql_codegen::client::typescript::generate_functions_dts(&schema, &specs)
78                .map_err(|e| anyhow::anyhow!("functions.d.ts generation failed: {e}"))?;
79            files.insert(std::path::PathBuf::from("functions.d.ts"), dts);
80        }
81    }
82
83    if out_dir.exists() && !force && contains_generated_client(out_dir) {
84        anyhow::bail!(
85            "Output directory {} already contains a generated client. Pass --force to overwrite.",
86            out_dir.display()
87        );
88    }
89
90    for (rel_path, content) in &files {
91        let full_path = out_dir.join(rel_path);
92        if let Some(parent) = full_path.parent() {
93            std::fs::create_dir_all(parent)
94                .with_context(|| format!("Failed to create {}", parent.display()))?;
95        }
96        std::fs::write(&full_path, content)
97            .with_context(|| format!("Failed to write {}", full_path.display()))?;
98        println!("  wrote {}", full_path.display());
99    }
100
101    match language {
102        ClientLanguage::TypeScript => println!(
103            "\nGenerated {} files into {}. Type-check with `tsc --strict --noEmit {}/index.ts`.",
104            files.len(),
105            out_dir.display(),
106            out_dir.display(),
107        ),
108        ClientLanguage::Python => println!(
109            "\nGenerated {} files into {}. Type-check with `ty check {}` (Python >= 3.12).",
110            files.len(),
111            out_dir.display(),
112            out_dir.display(),
113        ),
114        ClientLanguage::Go => println!(
115            "\nGenerated {} files into {} (package `fraiseqlclient`, Go >= 1.21). Compile with `go build ./...`.",
116            files.len(),
117            out_dir.display(),
118        ),
119        ClientLanguage::Rust => println!(
120            "\nGenerated {} files into {}. Declare it (`pub mod {};`) and compile with `cargo check`; \
121             the client needs `serde` (with `derive`) and `serde_json`.",
122            files.len(),
123            out_dir.display(),
124            out_dir
125                .file_name()
126                .map_or_else(|| "generated".into(), std::ffi::OsStr::to_string_lossy),
127        ),
128    }
129    Ok(())
130}
131
132/// Resolve the `functions.d.ts` type specs from the compiled schema's `functions`
133/// section (parsed from the raw JSON — it is not part of [`CompiledSchema`]).
134///
135/// Each function's trigger determines its payload shape: `after:mutation` /
136/// `after:capture` on entity `E` → `{ event_kind, old: E|null, new: E|null }` (typed
137/// to `E` when it names a schema type, else `unknown`); `cron` → schedule context;
138/// `after:ingest` → the inbound-message shape. Triggers with no author-facing payload
139/// yet (`http`, `before:mutation`, `after:storage`) are skipped.
140fn functions_type_specs(
141    raw: &str,
142    schema: &CompiledSchema,
143) -> Vec<fraiseql_codegen::client::typescript::FunctionTypeSpec> {
144    use fraiseql_codegen::client::typescript::{
145        FunctionPayloadShape, FunctionTypeSpec, RequestQueryField,
146    };
147    use fraiseql_functions::{FunctionsConfig, triggers::registry::ParsedTrigger};
148
149    let Some(functions) = serde_json::from_str::<serde_json::Value>(raw)
150        .ok()
151        .and_then(|value| value.get("functions").cloned())
152        .filter(|value| !value.is_null())
153        .and_then(|value| serde_json::from_value::<FunctionsConfig>(value).ok())
154    else {
155        return Vec::new();
156    };
157
158    let is_type = |name: &str| schema.types.iter().any(|ty| ty.name == name);
159    let entity_shape = |entity_type: String| FunctionPayloadShape::Entity {
160        entity: Some(entity_type).filter(|name| is_type(name)),
161    };
162
163    functions
164        .definitions
165        .into_iter()
166        .filter_map(|def| {
167            // The trigger grammar has one implementation (`ParsedTrigger::parse`), and
168            // this used to be a fourth hand-rolled `split(':')` beside it — the shape
169            // that had already cost the server loader two whole trigger kinds (#1325).
170            let shape = match ParsedTrigger::parse(&def.trigger).ok()? {
171                ParsedTrigger::AfterMutation { entity_type, .. }
172                | ParsedTrigger::AfterCapture { entity_type, .. } => entity_shape(entity_type),
173                ParsedTrigger::Cron { .. } => FunctionPayloadShape::Cron,
174                ParsedTrigger::AfterIngest { .. } => FunctionPayloadShape::Ingest,
175                // #1329: the payload is the arguments of whichever root query field
176                // named this function. The binding is one-way — the query names the
177                // function — so the queries are found by scanning them, and finding
178                // none is impossible in a compiled artifact: `validate_query_bindings`
179                // refuses a `request:query` function no query names. An empty union
180                // would still be valid TypeScript and would silently type every guest
181                // payload as `never`, so it is skipped rather than emitted.
182                ParsedTrigger::RequestQuery => {
183                    let fields: Vec<RequestQueryField> = schema
184                        .queries
185                        .iter()
186                        .filter(|q| q.function.as_deref() == Some(def.name.as_str()))
187                        .map(|q| RequestQueryField {
188                            name:      q.name.clone(),
189                            arguments: q.arguments.clone(),
190                        })
191                        .collect();
192                    if fields.is_empty() {
193                        return None;
194                    }
195                    FunctionPayloadShape::Request { fields }
196                },
197                // http / before:mutation / after:storage: no author-facing payload yet.
198                ParsedTrigger::Http { .. }
199                | ParsedTrigger::BeforeMutation { .. }
200                | ParsedTrigger::AfterStorage { .. } => return None,
201            };
202            Some(FunctionTypeSpec {
203                name: def.name,
204                shape,
205            })
206        })
207        .collect()
208}
209
210/// Whether a directory already holds a generated client (detected via the
211/// auto-generated sentinel in each language's `types` file).
212fn contains_generated_client(out_dir: &Path) -> bool {
213    ["types.ts", "types.py", "types.go", "types.rs"].iter().any(|marker| {
214        std::fs::read_to_string(out_dir.join(marker))
215            .is_ok_and(|content| content.contains("AUTO-GENERATED by fraiseql-codegen"))
216    })
217}
218
219/// Search conventional locations for a `schema.compiled.json`.
220fn auto_detect_compiled_schema() -> Result<PathBuf> {
221    let candidates = [
222        "schema.compiled.json",
223        "target/fraiseql/schema.compiled.json",
224        "build/schema.compiled.json",
225    ];
226    for candidate in candidates {
227        let path = PathBuf::from(candidate);
228        if path.exists() {
229            return Ok(path);
230        }
231    }
232    anyhow::bail!(
233        "No compiled schema found. Compile first (`fraiseql compile`) or pass an explicit path: \
234         fraiseql generate-client typescript --schema <path> --out <dir>"
235    )
236}