fraiseql_cli/commands/
generate_client.rs1use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result};
13use fraiseql_core::schema::CompiledSchema;
14
15#[derive(Debug, Clone, Copy)]
17pub enum ClientLanguage {
18 TypeScript,
20}
21
22pub 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 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
93fn 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 #[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 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 _ => return None,
146 };
147 Some(FunctionTypeSpec {
148 name: def.name,
149 shape,
150 })
151 })
152 .collect()
153}
154
155fn 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
163fn 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}