Skip to main content

fraiseql_cli/commands/
openapi.rs

1//! `fraiseql openapi` — generate an OpenAPI 3.0.3 specification from a compiled schema.
2
3use anyhow::{Context, Result};
4use fraiseql_core::schema::CompiledSchema;
5
6/// Run the `openapi` command.
7///
8/// Reads a compiled schema, derives the REST route table, generates an OpenAPI
9/// 3.0.3 spec, and writes it to the output path.
10///
11/// # Errors
12///
13/// Returns an error if the schema cannot be read, is missing REST configuration,
14/// or route derivation fails.
15pub 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
47// ---------------------------------------------------------------------------
48// Inline route derivation (avoids fraiseql-server dependency)
49// ---------------------------------------------------------------------------
50
51fn generate_spec(schema: &CompiledSchema) -> Result<serde_json::Value> {
52    // For the CLI, we generate a simplified spec without full route derivation.
53    // The full spec is generated at runtime by the server.
54    // This CLI command provides a preview based on schema metadata.
55    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    // Generate paths from queries.
61    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; // Single queries need ID paths — skip for the simple listing.
73        };
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    // Build component schemas from types.
108    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)] // Reason: test code
195mod 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}