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}
21
22/// Run `generate-client <language>`.
23///
24/// `schema_path` is auto-detected from conventional locations when `None`.
25/// Refuses to overwrite an existing generated tree unless `force` is set.
26///
27/// # Errors
28///
29/// Returns an error if the schema cannot be found, read, or parsed, if the output
30/// directory already contains a generated client and `force` is not set, or if any
31/// file cannot be written.
32pub fn run(
33    language: ClientLanguage,
34    schema_path: Option<&Path>,
35    out_dir: &Path,
36    force: bool,
37) -> Result<()> {
38    let schema_path = match schema_path {
39        Some(p) => p.to_path_buf(),
40        None => auto_detect_compiled_schema()?,
41    };
42
43    let raw = std::fs::read_to_string(&schema_path)
44        .with_context(|| format!("Failed to read compiled schema {}", schema_path.display()))?;
45    let schema: CompiledSchema = serde_json::from_str(&raw).with_context(|| {
46        format!("Failed to parse {} as a compiled schema", schema_path.display())
47    })?;
48
49    let mut files = match language {
50        ClientLanguage::TypeScript => fraiseql_codegen::client::typescript::generate(&schema)
51            .map_err(|e| anyhow::anyhow!("client generation failed: {e}"))?,
52    };
53
54    // `functions.d.ts` (phase 08): typed guest payloads + host-op declarations, when
55    // the compiled schema declares functions. Parsed from the same raw JSON — the
56    // `functions` section is not part of `CompiledSchema`.
57    if matches!(language, ClientLanguage::TypeScript) {
58        let specs = functions_type_specs(&raw, &schema);
59        if !specs.is_empty() {
60            let dts = fraiseql_codegen::client::typescript::generate_functions_dts(&schema, &specs)
61                .map_err(|e| anyhow::anyhow!("functions.d.ts generation failed: {e}"))?;
62            files.insert(std::path::PathBuf::from("functions.d.ts"), dts);
63        }
64    }
65
66    if out_dir.exists() && !force && contains_generated_client(out_dir) {
67        anyhow::bail!(
68            "Output directory {} already contains a generated client. Pass --force to overwrite.",
69            out_dir.display()
70        );
71    }
72
73    for (rel_path, content) in &files {
74        let full_path = out_dir.join(rel_path);
75        if let Some(parent) = full_path.parent() {
76            std::fs::create_dir_all(parent)
77                .with_context(|| format!("Failed to create {}", parent.display()))?;
78        }
79        std::fs::write(&full_path, content)
80            .with_context(|| format!("Failed to write {}", full_path.display()))?;
81        println!("  wrote {}", full_path.display());
82    }
83
84    println!(
85        "\nGenerated {} files into {}. Type-check with `tsc --strict --noEmit {}/index.ts`.",
86        files.len(),
87        out_dir.display(),
88        out_dir.display(),
89    );
90    Ok(())
91}
92
93/// Resolve the `functions.d.ts` type specs from the compiled schema's `functions`
94/// section (parsed from the raw JSON — it is not part of [`CompiledSchema`]).
95///
96/// Each function's trigger determines its payload shape: `after:mutation` /
97/// `after:capture` on entity `E` → `{ event_kind, old: E|null, new: E|null }` (typed
98/// to `E` when it names a schema type, else `unknown`); `cron` → schedule context;
99/// `after:ingest` → the inbound-message shape. Triggers with no author-facing payload
100/// yet (`http`, `before:mutation`, `after:storage`) are skipped.
101fn functions_type_specs(
102    raw: &str,
103    schema: &CompiledSchema,
104) -> Vec<fraiseql_codegen::client::typescript::FunctionTypeSpec> {
105    use fraiseql_codegen::client::typescript::{FunctionPayloadShape, FunctionTypeSpec};
106
107    /// The subset of the `functions` section we need.
108    #[derive(serde::Deserialize)]
109    struct FunctionsLite {
110        #[serde(default)]
111        definitions: Vec<FunctionDefLite>,
112    }
113    #[derive(serde::Deserialize)]
114    struct FunctionDefLite {
115        name:    String,
116        trigger: String,
117    }
118
119    let Some(functions) = serde_json::from_str::<serde_json::Value>(raw)
120        .ok()
121        .and_then(|value| value.get("functions").cloned())
122        .filter(|value| !value.is_null())
123        .and_then(|value| serde_json::from_value::<FunctionsLite>(value).ok())
124    else {
125        return Vec::new();
126    };
127
128    let is_type = |name: &str| schema.types.iter().any(|ty| ty.name == name);
129    let entity_shape = |parts: &[&str]| {
130        // `after:{mutation,capture}:<Entity>[:op]` — the 3rd segment is the entity type.
131        let entity = parts.get(2).filter(|name| is_type(name)).map(|name| (*name).to_string());
132        FunctionPayloadShape::Entity { entity }
133    };
134
135    functions
136        .definitions
137        .into_iter()
138        .filter_map(|def| {
139            let parts: Vec<&str> = def.trigger.split(':').collect();
140            let shape = match (parts.first().copied(), parts.get(1).copied()) {
141                (Some("after"), Some("mutation" | "capture")) => entity_shape(&parts),
142                (Some("cron"), _) => FunctionPayloadShape::Cron,
143                (Some("after"), Some("ingest")) => FunctionPayloadShape::Ingest,
144                // http / before:mutation / after:storage: no author-facing payload yet.
145                _ => return None,
146            };
147            Some(FunctionTypeSpec {
148                name: def.name,
149                shape,
150            })
151        })
152        .collect()
153}
154
155/// Whether a directory already holds a generated client (detected via the
156/// auto-generated sentinel in `types.ts`).
157fn contains_generated_client(out_dir: &Path) -> bool {
158    let marker = out_dir.join("types.ts");
159    std::fs::read_to_string(marker)
160        .is_ok_and(|content| content.contains("AUTO-GENERATED by fraiseql-codegen"))
161}
162
163/// Search conventional locations for a `schema.compiled.json`.
164fn auto_detect_compiled_schema() -> Result<PathBuf> {
165    let candidates = [
166        "schema.compiled.json",
167        "target/fraiseql/schema.compiled.json",
168        "build/schema.compiled.json",
169    ];
170    for candidate in candidates {
171        let path = PathBuf::from(candidate);
172        if path.exists() {
173            return Ok(path);
174        }
175    }
176    anyhow::bail!(
177        "No compiled schema found. Compile first (`fraiseql compile`) or pass an explicit path: \
178         fraiseql generate-client typescript --schema <path> --out <dir>"
179    )
180}