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
7pub 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 pub docs_prefix: &'a str,
36}
37
38pub 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}