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 Python,
22 Go,
24 Rust,
26}
27
28pub 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 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 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
132fn 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 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 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 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
210fn 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
219fn 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}