Skip to main content

oxidite_openapi/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// OpenAPI 3.0 Specification
5#[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, // "query", "path", "header", "cookie"
123    #[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
304/// Lightweight schema inference trait for common Rust types.
305pub 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
361/// OpenAPI Documentation Builder
362pub 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
411/// Helper to create a GET operation
412pub 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
423/// Helper to create a POST operation
424pub 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
435/// Auto-documentation trait for routers
436pub trait AutoDocs {
437    /// Register the /api/docs endpoint with OpenAPI documentation
438    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
465/// Generate HTML documentation page
466pub 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('&', "&amp;")
501        .replace('<', "&lt;")
502        .replace('>', "&gt;")
503        .replace('"', "&quot;")
504        .replace('\'', "&#x27;")
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("&lt;script&gt;x&lt;/script&gt;"));
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}