Skip to main content

sova_openapi/
build.rs

1use crate::doc::Doc;
2use crate::validate_meta::OpenApiValidate;
3use http::Method;
4use sova_core::extend::{to_brace_path, RouteEntry, RouteTable};
5use serde_json::{json, Map, Value};
6
7/// Fixed validation error body schema (auto-422).
8pub fn validation_error_schema() -> Value {
9    json!({
10        "type": "object",
11        "required": ["error", "issues"],
12        "properties": {
13            "error": { "type": "string", "const": "validation_failed" },
14            "issues": {
15                "type": "array",
16                "items": {
17                    "type": "object",
18                    "required": ["path", "code", "message"],
19                    "properties": {
20                        "path": { "type": "string" },
21                        "code": { "type": "string" },
22                        "message": { "type": "string" }
23                    }
24                }
25            }
26        }
27    })
28}
29
30pub struct BuildOptions<'a> {
31    pub title: &'a str,
32    pub version: &'a str,
33    pub servers: &'a [String],
34    /// Path prefix for docs routes to exclude (e.g. `/docs`).
35    pub docs_prefix: &'a str,
36}
37
38/// Build an OpenAPI 3.1 document from a [`RouteTable`].
39pub fn build_document(table: &RouteTable, opts: &BuildOptions<'_>) -> Value {
40    let mut paths = Map::new();
41
42    for entry in &table.0 {
43        let RouteEntry::Http {
44            method,
45            path,
46            meta,
47        } = entry
48        else {
49            continue;
50        };
51
52        if path == opts.docs_prefix
53            || path.starts_with(&format!("{}/", opts.docs_prefix.trim_end_matches('/')))
54        {
55            continue;
56        }
57
58        let doc = meta.get::<Doc>();
59        if doc.as_ref().is_some_and(|d| d.is_skip()) {
60            continue;
61        }
62
63        let Some(brace) = to_brace_path(path) else {
64            continue;
65        };
66
67        let oav = meta.get::<OpenApiValidate>();
68        let method_key = method_name(method);
69        let operation = operation_object(path, doc.as_deref(), oav.as_deref());
70
71        paths
72            .entry(brace)
73            .or_insert_with(|| json!({}))
74            .as_object_mut()
75            .unwrap()
76            .insert(method_key.into(), operation);
77    }
78
79    let mut root = json!({
80        "openapi": "3.1.0",
81        "info": {
82            "title": opts.title,
83            "version": opts.version,
84        },
85        "paths": paths,
86    });
87
88    if !opts.servers.is_empty() {
89        let servers: Vec<Value> = opts
90            .servers
91            .iter()
92            .map(|url| json!({ "url": url }))
93            .collect();
94        root.as_object_mut()
95            .unwrap()
96            .insert("servers".into(), Value::Array(servers));
97    }
98
99    root
100}
101
102fn method_name(method: &Method) -> &'static str {
103    match *method {
104        Method::GET => "get",
105        Method::POST => "post",
106        Method::PUT => "put",
107        Method::PATCH => "patch",
108        Method::DELETE => "delete",
109        Method::HEAD => "head",
110        Method::OPTIONS => "options",
111        _ => "get",
112    }
113}
114
115fn operation_object(
116    express_path: &str,
117    doc: Option<&Doc>,
118    oav: Option<&OpenApiValidate>,
119) -> Value {
120    let mut op = Map::new();
121    let mut parameters = Vec::new();
122
123    let params_schema = oav
124        .and_then(|o| o.params.as_ref())
125        .or_else(|| doc.and_then(|d| d.params_schema.as_ref()));
126    let query_schema = oav
127        .and_then(|o| o.query.as_ref())
128        .or_else(|| doc.and_then(|d| d.query_schema.as_ref()));
129    let body_schema = oav
130        .and_then(|o| o.body.as_ref())
131        .or_else(|| doc.and_then(|d| d.body_schema.as_ref()));
132
133    for name in path_param_names(express_path) {
134        let schema = params_schema
135            .and_then(|s| property_schema(s, &name))
136            .unwrap_or_else(|| json!({ "type": "string" }));
137        parameters.push(json!({
138            "name": name,
139            "in": "path",
140            "required": true,
141            "schema": schema,
142        }));
143    }
144
145    if let Some(query) = query_schema {
146        if let Some(props) = query.get("properties").and_then(|p| p.as_object()) {
147            let required = query
148                .get("required")
149                .and_then(|r| r.as_array())
150                .cloned()
151                .unwrap_or_default();
152            for (name, schema) in props {
153                let req = required.iter().any(|v| v.as_str() == Some(name.as_str()));
154                parameters.push(json!({
155                    "name": name,
156                    "in": "query",
157                    "required": req,
158                    "schema": schema,
159                }));
160            }
161        } else {
162            parameters.push(json!({
163                "name": "query",
164                "in": "query",
165                "schema": query,
166            }));
167        }
168    }
169
170    if let Some(body) = body_schema {
171        op.insert(
172            "requestBody".into(),
173            json!({
174                "required": true,
175                "content": {
176                    "application/json": {
177                        "schema": body
178                    }
179                }
180            }),
181        );
182    }
183
184    if !parameters.is_empty() {
185        op.insert("parameters".into(), Value::Array(parameters));
186    }
187
188    let mut responses = Map::new();
189    if let Some(doc) = doc {
190        for (code, schema) in &doc.responses {
191            responses.insert(
192                code.to_string(),
193                json!({
194                    "description": status_description(*code),
195                    "content": {
196                        "application/json": {
197                            "schema": schema
198                        }
199                    }
200                }),
201            );
202        }
203    }
204    if body_schema.is_some() || query_schema.is_some() {
205        responses.entry("422".to_string()).or_insert_with(|| {
206            json!({
207                "description": "Validation failed",
208                "content": {
209                    "application/json": {
210                        "schema": validation_error_schema()
211                    }
212                }
213            })
214        });
215    }
216    if responses.is_empty() {
217        responses.insert("200".into(), json!({ "description": "OK" }));
218    }
219    op.insert("responses".into(), Value::Object(responses));
220
221    Value::Object(op)
222}
223
224fn path_param_names(path: &str) -> Vec<String> {
225    path.trim_matches('/')
226        .split('/')
227        .filter_map(|seg| seg.strip_prefix(':').map(str::to_string))
228        .collect()
229}
230
231fn property_schema(params_schema: &Value, name: &str) -> Option<Value> {
232    params_schema
233        .get("properties")
234        .and_then(|p| p.get(name))
235        .cloned()
236}
237
238fn status_description(code: u16) -> &'static str {
239    match code {
240        200 => "OK",
241        201 => "Created",
242        204 => "No Content",
243        400 => "Bad Request",
244        401 => "Unauthorized",
245        404 => "Not Found",
246        422 => "Unprocessable Entity",
247        _ => "Response",
248    }
249}