fraiseql_cli/commands/
openapi.rs1use anyhow::{Context, Result};
4use fraiseql_core::schema::CompiledSchema;
5
6pub fn run(schema_path: &str, output: &str) -> Result<()> {
16 let schema_json = std::fs::read_to_string(schema_path)
17 .with_context(|| format!("Failed to read schema file: {schema_path}"))?;
18
19 let schema: CompiledSchema = serde_json::from_str(&schema_json)
20 .with_context(|| format!("Failed to parse compiled schema: {schema_path}"))?;
21
22 let config = schema.rest_config.as_ref().ok_or_else(|| {
23 anyhow::anyhow!(
24 "No REST configuration found in schema. Add [rest] section to fraiseql.toml."
25 )
26 })?;
27
28 if !config.enabled {
29 anyhow::bail!("REST transport is disabled (rest.enabled = false)");
30 }
31
32 let spec = generate_spec(&schema)?;
33
34 let pretty = serde_json::to_string_pretty(&spec).context("Failed to serialize OpenAPI spec")?;
35
36 if output == "-" {
37 println!("{pretty}");
38 } else {
39 std::fs::write(output, &pretty)
40 .with_context(|| format!("Failed to write OpenAPI spec to: {output}"))?;
41 eprintln!("OpenAPI spec written to {output}");
42 }
43
44 Ok(())
45}
46
47fn generate_spec(schema: &CompiledSchema) -> Result<serde_json::Value> {
52 let config = schema.rest_config.as_ref().expect("rest_config already validated");
56 let base_path = &config.path;
57
58 let mut paths = serde_json::Map::new();
59
60 for query in &schema.queries {
62 if query.name.ends_with("_aggregate") || query.name.ends_with("_window") {
63 continue;
64 }
65 if schema.find_type(&query.return_type).is_none() {
66 continue;
67 }
68
69 let resource_name = if query.returns_list {
70 query.name.clone()
71 } else {
72 continue; };
74
75 let path = format!("/{resource_name}");
76 paths.insert(
77 path,
78 serde_json::json!({
79 "get": {
80 "summary": format!("List {resource_name}"),
81 "tags": [capitalize(&resource_name)],
82 "responses": {
83 "200": {
84 "description": format!("List of {resource_name}"),
85 "content": {
86 "application/json": {
87 "schema": {
88 "type": "object",
89 "properties": {
90 "data": {
91 "type": "array",
92 "items": {
93 "$ref": format!("#/components/schemas/{}", query.return_type)
94 }
95 }
96 }
97 }
98 }
99 }
100 }
101 }
102 }
103 }),
104 );
105 }
106
107 let mut schemas = serde_json::Map::new();
109 for type_def in &schema.types {
110 let mut properties = serde_json::Map::new();
111 let mut required = Vec::new();
112
113 for field in &type_def.fields {
114 properties.insert(field.name.to_string(), field_type_to_json_schema(&field.field_type));
115 if !field.nullable {
116 required.push(serde_json::json!(field.name.to_string()));
117 }
118 }
119
120 let mut type_schema = serde_json::json!({
121 "type": "object",
122 "properties": properties,
123 });
124 if !required.is_empty() {
125 type_schema["required"] = serde_json::Value::Array(required);
126 }
127 schemas.insert(type_def.name.to_string(), type_schema);
128 }
129
130 Ok(serde_json::json!({
131 "openapi": "3.0.3",
132 "info": {
133 "title": "FraiseQL REST API",
134 "version": "1.0.0",
135 "description": "Auto-generated REST API from compiled schema",
136 },
137 "servers": [{
138 "url": base_path,
139 "description": "REST API base path"
140 }],
141 "paths": paths,
142 "components": {
143 "schemas": schemas,
144 }
145 }))
146}
147
148use fraiseql_core::schema::FieldType;
149
150fn field_type_to_json_schema(ft: &FieldType) -> serde_json::Value {
151 match ft {
152 FieldType::String => serde_json::json!({ "type": "string" }),
153 FieldType::Int => serde_json::json!({ "type": "integer" }),
154 FieldType::Float => serde_json::json!({ "type": "number" }),
155 FieldType::Boolean => serde_json::json!({ "type": "boolean" }),
156 FieldType::Id | FieldType::Uuid => {
157 serde_json::json!({ "type": "string", "format": "uuid" })
158 },
159 FieldType::DateTime => serde_json::json!({ "type": "string", "format": "date-time" }),
160 FieldType::Date => serde_json::json!({ "type": "string", "format": "date" }),
161 FieldType::Time => serde_json::json!({ "type": "string", "format": "time" }),
162 FieldType::Json => serde_json::json!({ "type": "object" }),
163 FieldType::Decimal => serde_json::json!({ "type": "string", "format": "decimal" }),
164 FieldType::Vector => serde_json::json!({ "type": "array", "items": { "type": "number" } }),
165 FieldType::Scalar(_) => serde_json::json!({ "type": "string" }),
166 FieldType::List(inner) => {
167 serde_json::json!({ "type": "array", "items": field_type_to_json_schema(inner) })
168 },
169 FieldType::Object(name) => {
170 serde_json::json!({ "$ref": format!("#/components/schemas/{name}") })
171 },
172 FieldType::Enum(name) => {
173 serde_json::json!({ "$ref": format!("#/components/schemas/{name}") })
174 },
175 FieldType::Input(name) => {
176 serde_json::json!({ "$ref": format!("#/components/schemas/{name}") })
177 },
178 FieldType::Interface(name) | FieldType::Union(name) => {
179 serde_json::json!({ "type": "object", "description": format!("See {name}") })
180 },
181 _ => serde_json::json!({ "type": "string" }),
182 }
183}
184
185fn capitalize(s: &str) -> String {
186 let mut chars = s.chars();
187 match chars.next() {
188 None => String::new(),
189 Some(c) => c.to_uppercase().to_string() + chars.as_str(),
190 }
191}
192
193#[cfg(test)]
194#[allow(clippy::unwrap_used)] mod tests {
196 use std::io::Write;
197
198 use tempfile::NamedTempFile;
199
200 use super::*;
201
202 fn minimal_schema_json() -> String {
203 serde_json::json!({
204 "types": [{
205 "name": "User",
206 "sql_source": "v_user",
207 "fields": [
208 { "name": "id", "field_type": "UUID" },
209 { "name": "name", "field_type": "String" },
210 ]
211 }],
212 "queries": [{
213 "name": "users",
214 "return_type": "User",
215 "returns_list": true,
216 }],
217 "mutations": [],
218 "rest_config": {
219 "enabled": true,
220 "path": "/rest/v1"
221 }
222 })
223 .to_string()
224 }
225
226 #[test]
227 fn run_writes_openapi_spec() {
228 let mut schema_file = NamedTempFile::new().unwrap();
229 write!(schema_file, "{}", minimal_schema_json()).unwrap();
230
231 let output_file = NamedTempFile::new().unwrap();
232 let output_path = output_file.path().to_str().unwrap().to_string();
233
234 run(schema_file.path().to_str().unwrap(), &output_path).unwrap();
235
236 let content = std::fs::read_to_string(&output_path).unwrap();
237 let spec: serde_json::Value = serde_json::from_str(&content).unwrap();
238 assert_eq!(spec["openapi"], "3.0.3");
239 assert!(spec["paths"]["/users"]["get"].is_object());
240 }
241
242 #[test]
243 fn run_fails_without_rest_config() {
244 let schema = serde_json::json!({
245 "types": [],
246 "queries": [],
247 "mutations": [],
248 });
249 let mut schema_file = NamedTempFile::new().unwrap();
250 write!(schema_file, "{schema}").unwrap();
251
252 let result = run(schema_file.path().to_str().unwrap(), "/dev/null");
253 assert!(result.is_err());
254 }
255
256 #[test]
257 fn run_fails_when_disabled() {
258 let schema = serde_json::json!({
259 "types": [],
260 "queries": [],
261 "mutations": [],
262 "rest_config": { "enabled": false }
263 });
264 let mut schema_file = NamedTempFile::new().unwrap();
265 write!(schema_file, "{schema}").unwrap();
266
267 let result = run(schema_file.path().to_str().unwrap(), "/dev/null");
268 assert!(result.is_err());
269 }
270}