1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct OpenApiSpec {
7 pub openapi: String,
8 pub info: Info,
9 pub paths: HashMap<String, PathItem>,
10 #[serde(skip_serializing_if = "Option::is_none")]
11 pub components: Option<Components>,
12 #[serde(skip_serializing_if = "Option::is_none")]
13 pub servers: Option<Vec<Server>>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Info {
18 pub title: String,
19 pub version: String,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub description: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Server {
26 pub url: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub description: Option<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct PathItem {
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub get: Option<Operation>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub post: Option<Operation>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub put: Option<Operation>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub delete: Option<Operation>,
41}
42
43impl PathItem {
44 pub fn with_get(mut self, operation: Operation) -> Self {
45 self.get = Some(operation);
46 self
47 }
48
49 pub fn with_post(mut self, operation: Operation) -> Self {
50 self.post = Some(operation);
51 self
52 }
53
54 pub fn with_put(mut self, operation: Operation) -> Self {
55 self.put = Some(operation);
56 self
57 }
58
59 pub fn with_delete(mut self, operation: Operation) -> Self {
60 self.delete = Some(operation);
61 self
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, Default)]
66pub struct Operation {
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub summary: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub description: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub tags: Option<Vec<String>>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub parameters: Option<Vec<Parameter>>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub request_body: Option<RequestBody>,
77 pub responses: HashMap<String, Response>,
78}
79
80impl Operation {
81 pub fn with_description(mut self, description: impl Into<String>) -> Self {
82 self.description = Some(description.into());
83 self
84 }
85
86 pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
87 let tags = self.tags.get_or_insert_with(Vec::new);
88 tags.push(tag.into());
89 self
90 }
91
92 pub fn add_parameter(mut self, parameter: Parameter) -> Self {
93 let parameters = self.parameters.get_or_insert_with(Vec::new);
94 parameters.push(parameter);
95 self
96 }
97
98 pub fn with_request_body(mut self, request_body: RequestBody) -> Self {
99 self.request_body = Some(request_body);
100 self
101 }
102
103 pub fn add_response(mut self, status_code: impl Into<String>, response: Response) -> Self {
104 self.responses.insert(status_code.into(), response);
105 self
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "lowercase")]
111pub enum ParameterLocation {
112 Query,
113 Path,
114 Header,
115 Cookie,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct Parameter {
120 pub name: String,
121 #[serde(rename = "in")]
122 pub location: String, #[serde(skip_serializing_if = "Option::is_none")]
124 pub description: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub required: Option<bool>,
127 pub schema: Schema,
128}
129
130impl Parameter {
131 pub fn new(
132 name: impl Into<String>,
133 location: ParameterLocation,
134 schema: Schema,
135 ) -> Self {
136 let location = match location {
137 ParameterLocation::Query => "query",
138 ParameterLocation::Path => "path",
139 ParameterLocation::Header => "header",
140 ParameterLocation::Cookie => "cookie",
141 }
142 .to_string();
143
144 Self {
145 name: name.into(),
146 location,
147 description: None,
148 required: None,
149 schema,
150 }
151 }
152
153 pub fn query(name: impl Into<String>, schema: Schema) -> Self {
154 Self::new(name, ParameterLocation::Query, schema)
155 }
156
157 pub fn path(name: impl Into<String>, schema: Schema) -> Self {
158 let mut p = Self::new(name, ParameterLocation::Path, schema);
159 p.required = Some(true);
160 p
161 }
162
163 pub fn header(name: impl Into<String>, schema: Schema) -> Self {
164 Self::new(name, ParameterLocation::Header, schema)
165 }
166
167 pub fn cookie(name: impl Into<String>, schema: Schema) -> Self {
168 Self::new(name, ParameterLocation::Cookie, schema)
169 }
170
171 pub fn with_description(mut self, description: impl Into<String>) -> Self {
172 self.description = Some(description.into());
173 self
174 }
175
176 pub fn required(mut self, required: bool) -> Self {
177 self.required = Some(required);
178 self
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct RequestBody {
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub description: Option<String>,
186 pub required: bool,
187 pub content: HashMap<String, MediaType>,
188}
189
190impl RequestBody {
191 pub fn json(schema: Schema) -> Self {
192 let mut content = HashMap::new();
193 content.insert("application/json".to_string(), MediaType { schema });
194 Self {
195 description: None,
196 required: true,
197 content,
198 }
199 }
200
201 pub fn with_description(mut self, description: impl Into<String>) -> Self {
202 self.description = Some(description.into());
203 self
204 }
205
206 pub fn required(mut self, required: bool) -> Self {
207 self.required = required;
208 self
209 }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct Response {
214 pub description: String,
215 #[serde(skip_serializing_if = "Option::is_none")]
216 pub content: Option<HashMap<String, MediaType>>,
217}
218
219impl Response {
220 pub fn new(description: impl Into<String>) -> Self {
221 Self {
222 description: description.into(),
223 content: None,
224 }
225 }
226
227 pub fn json(description: impl Into<String>, schema: Schema) -> Self {
228 let mut content = HashMap::new();
229 content.insert("application/json".to_string(), MediaType { schema });
230 Self {
231 description: description.into(),
232 content: Some(content),
233 }
234 }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct MediaType {
239 pub schema: Schema,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243#[serde(untagged)]
244pub enum Schema {
245 Simple {
246 #[serde(rename = "type")]
247 type_name: String,
248 },
249 Object {
250 #[serde(rename = "type")]
251 type_name: String,
252 properties: HashMap<String, Box<Schema>>,
253 },
254 Array {
255 #[serde(rename = "type")]
256 type_name: String,
257 items: Box<Schema>,
258 },
259}
260
261impl Schema {
262 pub fn string() -> Self {
263 Self::Simple {
264 type_name: "string".to_string(),
265 }
266 }
267
268 pub fn integer() -> Self {
269 Self::Simple {
270 type_name: "integer".to_string(),
271 }
272 }
273
274 pub fn number() -> Self {
275 Self::Simple {
276 type_name: "number".to_string(),
277 }
278 }
279
280 pub fn boolean() -> Self {
281 Self::Simple {
282 type_name: "boolean".to_string(),
283 }
284 }
285
286 pub fn object(properties: HashMap<String, Schema>) -> Self {
287 Self::Object {
288 type_name: "object".to_string(),
289 properties: properties
290 .into_iter()
291 .map(|(k, v)| (k, Box::new(v)))
292 .collect(),
293 }
294 }
295
296 pub fn array(items: Schema) -> Self {
297 Self::Array {
298 type_name: "array".to_string(),
299 items: Box::new(items),
300 }
301 }
302}
303
304pub trait ToSchema {
306 fn schema() -> Schema;
307}
308
309impl ToSchema for String {
310 fn schema() -> Schema {
311 Schema::string()
312 }
313}
314impl ToSchema for bool {
315 fn schema() -> Schema {
316 Schema::boolean()
317 }
318}
319impl ToSchema for i32 {
320 fn schema() -> Schema {
321 Schema::integer()
322 }
323}
324impl ToSchema for i64 {
325 fn schema() -> Schema {
326 Schema::integer()
327 }
328}
329impl ToSchema for u32 {
330 fn schema() -> Schema {
331 Schema::integer()
332 }
333}
334impl ToSchema for u64 {
335 fn schema() -> Schema {
336 Schema::integer()
337 }
338}
339impl ToSchema for f32 {
340 fn schema() -> Schema {
341 Schema::number()
342 }
343}
344impl ToSchema for f64 {
345 fn schema() -> Schema {
346 Schema::number()
347 }
348}
349impl<T: ToSchema> ToSchema for Vec<T> {
350 fn schema() -> Schema {
351 Schema::array(T::schema())
352 }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, Default)]
356pub struct Components {
357 #[serde(skip_serializing_if = "Option::is_none")]
358 pub schemas: Option<HashMap<String, Schema>>,
359}
360
361pub struct OpenApiBuilder {
363 spec: OpenApiSpec,
364}
365
366impl OpenApiBuilder {
367 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
368 Self {
369 spec: OpenApiSpec {
370 openapi: "3.0.0".to_string(),
371 info: Info {
372 title: title.into(),
373 version: version.into(),
374 description: None,
375 },
376 paths: HashMap::new(),
377 components: None,
378 servers: None,
379 },
380 }
381 }
382
383 pub fn description(mut self, desc: impl Into<String>) -> Self {
384 self.spec.info.description = Some(desc.into());
385 self
386 }
387
388 pub fn server(mut self, url: impl Into<String>, description: Option<String>) -> Self {
389 let servers = self.spec.servers.get_or_insert_with(Vec::new);
390 servers.push(Server {
391 url: url.into(),
392 description,
393 });
394 self
395 }
396
397 pub fn path(mut self, path: impl Into<String>, item: PathItem) -> Self {
398 self.spec.paths.insert(path.into(), item);
399 self
400 }
401
402 pub fn build(self) -> OpenApiSpec {
403 self.spec
404 }
405
406 pub fn to_json(&self) -> Result<String, serde_json::Error> {
407 serde_json::to_string_pretty(&self.spec)
408 }
409}
410
411pub fn get_operation(summary: impl Into<String>) -> Operation {
413 Operation {
414 summary: Some(summary.into()),
415 description: None,
416 tags: None,
417 parameters: None,
418 request_body: None,
419 responses: HashMap::new(),
420 }
421}
422
423pub fn post_operation(summary: impl Into<String>) -> Operation {
425 Operation {
426 summary: Some(summary.into()),
427 description: None,
428 tags: None,
429 parameters: None,
430 request_body: None,
431 responses: HashMap::new(),
432 }
433}
434
435pub trait AutoDocs {
437 fn with_auto_docs(self, spec: OpenApiSpec) -> Self;
439}
440
441impl AutoDocs for oxidite_core::Router {
442 fn with_auto_docs(mut self, spec: OpenApiSpec) -> Self {
443 let spec_arc = std::sync::Arc::new(spec);
444
445 let spec_json = spec_arc.clone();
446 self.get("/openapi.json", move || {
447 let spec_json = spec_json.clone();
448 async move { Ok(oxidite_core::OxiditeResponse::json((*spec_json).clone())) }
449 });
450
451 let spec_docs = spec_arc.clone();
452 self.get("/api/docs", move || {
453 let spec_docs = spec_docs.clone();
454 async move {
455 Ok(oxidite_core::OxiditeResponse::html(generate_docs_html(
456 &spec_docs,
457 )))
458 }
459 });
460
461 self
462 }
463}
464
465pub fn generate_docs_html(spec: &OpenApiSpec) -> String {
467 let spec_json = serde_json::to_string_pretty(spec).unwrap_or_else(|_| "{}".to_string());
468 let safe_title = html_escape(&spec.info.title);
469 let safe_spec_json = spec_json.replace("</script>", "<\\/script>");
470
471 format!(r#"<!DOCTYPE html>
472<html lang="en">
473<head>
474 <meta charset="UTF-8">
475 <meta name="viewport" content="width=device-width, initial-scale=1.0">
476 <title>{} - API Documentation</title>
477 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
478</head>
479<body>
480 <div id="swagger-ui"></div>
481 <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
482 <script>
483 const spec = {};
484 SwaggerUIBundle({{
485 spec: spec,
486 dom_id: '#swagger-ui',
487 deepLinking: true,
488 presets: [
489 SwaggerUIBundle.presets.apis,
490 SwaggerUIBundle.SwaggerUIStandalonePreset
491 ],
492 }});
493 </script>
494</body>
495</html>"#, safe_title, safe_spec_json)
496}
497
498fn html_escape(input: &str) -> String {
499 input
500 .replace('&', "&")
501 .replace('<', "<")
502 .replace('>', ">")
503 .replace('"', """)
504 .replace('\'', "'")
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_openapi_builder() {
513 let spec = OpenApiBuilder::new("Test API", "1.0.0")
514 .description("A test API")
515 .server("http://localhost:8080", Some("Local server".to_string()))
516 .build();
517
518 assert_eq!(spec.info.title, "Test API");
519 assert_eq!(spec.info.version, "1.0.0");
520 }
521
522 #[test]
523 fn test_operation_builder_helpers() {
524 let operation = get_operation("Get users")
525 .with_description("Return users")
526 .add_tag("users")
527 .add_parameter(Parameter::query("page", Schema::integer()).required(false))
528 .add_response("200", Response::json("ok", Schema::array(Schema::string())));
529
530 assert_eq!(operation.summary.as_deref(), Some("Get users"));
531 assert_eq!(operation.tags.as_ref().map(Vec::len), Some(1));
532 assert!(operation.responses.contains_key("200"));
533 }
534
535 #[test]
536 fn test_generate_docs_html_escapes_title() {
537 let spec = OpenApiBuilder::new("<script>x</script>", "1.0.0").build();
538 let html = generate_docs_html(&spec);
539 assert!(html.contains("<script>x</script>"));
540 assert!(!html.contains("<title><script>x</script>"));
541 }
542
543 #[test]
544 fn to_schema_infers_basic_types() {
545 let string_schema = <String as ToSchema>::schema();
546 let vec_schema = <Vec<i32> as ToSchema>::schema();
547 assert!(matches!(string_schema, Schema::Simple { .. }));
548 assert!(matches!(vec_schema, Schema::Array { .. }));
549 }
550}