domainstack_schema/
openapi.rs

1//! OpenAPI specification builder.
2
3use crate::{Schema, ToSchema};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Complete OpenAPI 3.0 specification.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct OpenApiSpec {
10    pub openapi: String,
11    pub info: Info,
12    pub components: Components,
13}
14
15/// API information.
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/// Components containing reusable schemas.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Components {
27    pub schemas: HashMap<String, Schema>,
28}
29
30/// Builder for OpenAPI specifications.
31pub struct OpenApiBuilder {
32    title: String,
33    version: String,
34    description: Option<String>,
35    schemas: HashMap<String, Schema>,
36}
37
38impl OpenApiBuilder {
39    /// Create a new OpenAPI builder.
40    ///
41    /// # Example
42    ///
43    /// ```rust
44    /// use domainstack_schema::OpenApiBuilder;
45    ///
46    /// let spec = OpenApiBuilder::new("My API", "1.0.0")
47    ///     .description("A sample API")
48    ///     .build();
49    /// ```
50    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
51        Self {
52            title: title.into(),
53            version: version.into(),
54            description: None,
55            schemas: HashMap::new(),
56        }
57    }
58
59    /// Set the API description.
60    pub fn description(mut self, desc: impl Into<String>) -> Self {
61        self.description = Some(desc.into());
62        self
63    }
64
65    /// Register a type that implements ToSchema.
66    ///
67    /// # Example
68    ///
69    /// ```rust
70    /// use domainstack_schema::{OpenApiBuilder, ToSchema, Schema};
71    ///
72    /// struct User;
73    /// impl ToSchema for User {
74    ///     fn schema_name() -> &'static str { "User" }
75    ///     fn schema() -> Schema { Schema::object() }
76    /// }
77    ///
78    /// let spec = OpenApiBuilder::new("API", "1.0")
79    ///     .register::<User>()
80    ///     .build();
81    /// ```
82    pub fn register<T: ToSchema>(mut self) -> Self {
83        self.schemas
84            .insert(T::schema_name().to_string(), T::schema());
85        self
86    }
87
88    /// Manually add a schema with a custom name.
89    pub fn schema(mut self, name: impl Into<String>, schema: Schema) -> Self {
90        self.schemas.insert(name.into(), schema);
91        self
92    }
93
94    /// Build the final OpenAPI specification.
95    pub fn build(self) -> OpenApiSpec {
96        OpenApiSpec {
97            openapi: "3.0.0".to_string(),
98            info: Info {
99                title: self.title,
100                version: self.version,
101                description: self.description,
102            },
103            components: Components {
104                schemas: self.schemas,
105            },
106        }
107    }
108}
109
110impl OpenApiSpec {
111    /// Create a new builder.
112    pub fn builder(title: impl Into<String>, version: impl Into<String>) -> OpenApiBuilder {
113        OpenApiBuilder::new(title, version)
114    }
115
116    /// Convert to JSON string.
117    pub fn to_json(&self) -> Result<String, serde_json::Error> {
118        serde_json::to_string_pretty(self)
119    }
120
121    /// Convert to YAML string (requires serde_yaml - not included by default).
122    #[cfg(feature = "yaml")]
123    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
124        serde_yaml::to_string(self)
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    struct User;
133    impl ToSchema for User {
134        fn schema_name() -> &'static str {
135            "User"
136        }
137
138        fn schema() -> Schema {
139            Schema::object()
140                .property("email", Schema::string().format("email"))
141                .property("age", Schema::integer().minimum(0).maximum(150))
142                .required(&["email", "age"])
143        }
144    }
145
146    struct Product;
147    impl ToSchema for Product {
148        fn schema_name() -> &'static str {
149            "Product"
150        }
151
152        fn schema() -> Schema {
153            Schema::object()
154                .property("name", Schema::string())
155                .property("price", Schema::number().minimum(0.0))
156                .required(&["name", "price"])
157        }
158    }
159
160    #[test]
161    fn test_openapi_builder() {
162        let spec = OpenApiBuilder::new("Test API", "1.0.0")
163            .description("A test API")
164            .register::<User>()
165            .register::<Product>()
166            .build();
167
168        assert_eq!(spec.openapi, "3.0.0");
169        assert_eq!(spec.info.title, "Test API");
170        assert_eq!(spec.info.version, "1.0.0");
171        assert_eq!(spec.components.schemas.len(), 2);
172        assert!(spec.components.schemas.contains_key("User"));
173        assert!(spec.components.schemas.contains_key("Product"));
174    }
175
176    #[test]
177    fn test_to_json() {
178        let spec = OpenApiBuilder::new("API", "1.0").register::<User>().build();
179
180        let json = spec.to_json().unwrap();
181        assert!(json.contains("\"openapi\": \"3.0.0\""));
182        assert!(json.contains("\"User\""));
183    }
184
185    #[test]
186    fn test_manual_schema() {
187        let custom_schema = Schema::string().format("custom");
188
189        let spec = OpenApiBuilder::new("API", "1.0")
190            .schema("CustomType", custom_schema)
191            .build();
192
193        assert_eq!(spec.components.schemas.len(), 1);
194        assert!(spec.components.schemas.contains_key("CustomType"));
195    }
196}