Skip to main content

server_less_openapi/
builder.rs

1//! OpenAPI spec builder for composing multiple specs.
2
3use crate::Result;
4use crate::error::OpenApiError;
5use crate::types::{OpenApiPath, OpenApiSchema};
6use serde_json::{Map, Value};
7
8/// Builder for composing OpenAPI specs from multiple sources.
9///
10/// # Example
11///
12/// ```ignore
13/// use server_less::OpenApiBuilder;
14///
15/// let spec = OpenApiBuilder::new()
16///     .title("My API")
17///     .version("1.0.0")
18///     .merge(UserService::openapi_spec())
19///     .merge(OrderService::openapi_spec())
20///     .build()?;
21/// ```
22///
23/// # Conflict Resolution
24///
25/// - **Paths**: Last write wins (later `merge()` calls override earlier ones for same path+method).
26/// - **Schemas**: Identical schemas are deduplicated; different schemas with same name cause an error.
27#[derive(Debug, Clone)]
28pub struct OpenApiBuilder {
29    title: Option<String>,
30    version: Option<String>,
31    description: Option<String>,
32    paths: Map<String, Value>,
33    schemas: Map<String, Value>,
34}
35
36impl Default for OpenApiBuilder {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl OpenApiBuilder {
43    /// Create a new builder.
44    pub fn new() -> Self {
45        Self {
46            title: None,
47            version: None,
48            description: None,
49            paths: Map::new(),
50            schemas: Map::new(),
51        }
52    }
53
54    /// Set the API title.
55    pub fn title(mut self, title: impl Into<String>) -> Self {
56        self.title = Some(title.into());
57        self
58    }
59
60    /// Set the API version.
61    pub fn version(mut self, version: impl Into<String>) -> Self {
62        self.version = Some(version.into());
63        self
64    }
65
66    /// Set the API description.
67    pub fn description(mut self, description: impl Into<String>) -> Self {
68        self.description = Some(description.into());
69        self
70    }
71
72    /// Merge an OpenAPI spec (as JSON value).
73    ///
74    /// This extracts paths and schemas from the spec and merges them.
75    ///
76    /// # Conflict Resolution
77    ///
78    /// - Paths: Last write wins
79    /// - Schemas: Identical schemas dedupe, different schemas error
80    pub fn merge(mut self, spec: Value) -> Result<Self> {
81        // Extract and merge paths
82        if let Some(paths) = spec.get("paths").and_then(|p| p.as_object()) {
83            for (path, methods) in paths {
84                if let Some(methods_obj) = methods.as_object() {
85                    let path_entry = self
86                        .paths
87                        .entry(path.clone())
88                        .or_insert_with(|| Value::Object(Map::new()));
89
90                    if let Some(path_obj) = path_entry.as_object_mut() {
91                        for (method, operation) in methods_obj {
92                            // Last write wins for paths
93                            path_obj.insert(method.clone(), operation.clone());
94                        }
95                    }
96                }
97            }
98        }
99
100        // Extract and merge schemas with conflict detection
101        if let Some(components) = spec.get("components").and_then(|c| c.as_object())
102            && let Some(schemas) = components.get("schemas").and_then(|s| s.as_object())
103        {
104            for (name, schema) in schemas {
105                self.merge_schema(name.clone(), schema.clone())?;
106            }
107        }
108
109        // Also check for schemas at top level (some generators put them there)
110        if let Some(schemas) = spec.get("schemas").and_then(|s| s.as_object()) {
111            for (name, schema) in schemas {
112                self.merge_schema(name.clone(), schema.clone())?;
113            }
114        }
115
116        Ok(self)
117    }
118
119    /// Merge typed paths.
120    pub fn merge_paths(mut self, paths: Vec<OpenApiPath>) -> Self {
121        for path_def in paths {
122            let path_entry = self
123                .paths
124                .entry(path_def.path.clone())
125                .or_insert_with(|| Value::Object(Map::new()));
126
127            if let Some(path_obj) = path_entry.as_object_mut() {
128                // Convert operation to JSON
129                let operation = serde_json::to_value(&path_def.operation)
130                    .unwrap_or_else(|_| Value::Object(Map::new()));
131                path_obj.insert(path_def.method.to_lowercase(), operation);
132            }
133        }
134        self
135    }
136
137    /// Merge typed schemas.
138    pub fn merge_schemas(mut self, schemas: Vec<OpenApiSchema>) -> Result<Self> {
139        for schema_def in schemas {
140            self.merge_schema(schema_def.name, schema_def.schema)?;
141        }
142        Ok(self)
143    }
144
145    /// Merge a single schema with conflict detection.
146    fn merge_schema(&mut self, name: String, schema: Value) -> Result<()> {
147        if let Some(existing) = self.schemas.get(&name) {
148            // Check if schemas are identical
149            if existing != &schema {
150                return Err(OpenApiError::SchemaConflict { name });
151            }
152            // Identical - already present, nothing to do
153        } else {
154            self.schemas.insert(name, schema);
155        }
156        Ok(())
157    }
158
159    /// Build the final OpenAPI spec.
160    pub fn build(self) -> Value {
161        let mut spec = Map::new();
162
163        // OpenAPI version
164        spec.insert("openapi".to_string(), Value::String("3.0.0".to_string()));
165
166        // Info object
167        let mut info = Map::new();
168        info.insert(
169            "title".to_string(),
170            Value::String(self.title.unwrap_or_else(|| "API".to_string())),
171        );
172        info.insert(
173            "version".to_string(),
174            Value::String(self.version.unwrap_or_else(|| "0.1.0".to_string())),
175        );
176        if let Some(desc) = self.description {
177            info.insert("description".to_string(), Value::String(desc));
178        }
179        spec.insert("info".to_string(), Value::Object(info));
180
181        // Paths
182        if !self.paths.is_empty() {
183            spec.insert("paths".to_string(), Value::Object(self.paths));
184        }
185
186        // Components/schemas
187        if !self.schemas.is_empty() {
188            let mut components = Map::new();
189            components.insert("schemas".to_string(), Value::Object(self.schemas));
190            spec.insert("components".to_string(), Value::Object(components));
191        }
192
193        Value::Object(spec)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use serde_json::json;
201
202    #[test]
203    fn test_basic_builder() {
204        let spec = OpenApiBuilder::new()
205            .title("Test API")
206            .version("1.0.0")
207            .description("A test API")
208            .build();
209
210        assert_eq!(spec["info"]["title"], "Test API");
211        assert_eq!(spec["info"]["version"], "1.0.0");
212        assert_eq!(spec["info"]["description"], "A test API");
213        assert_eq!(spec["openapi"], "3.0.0");
214    }
215
216    #[test]
217    fn test_merge_paths() {
218        let spec1 = json!({
219            "paths": {
220                "/users": {
221                    "get": {"summary": "List users"}
222                }
223            }
224        });
225
226        let spec2 = json!({
227            "paths": {
228                "/orders": {
229                    "get": {"summary": "List orders"}
230                }
231            }
232        });
233
234        let combined = OpenApiBuilder::new()
235            .merge(spec1)
236            .unwrap()
237            .merge(spec2)
238            .unwrap()
239            .build();
240
241        assert!(combined["paths"]["/users"]["get"].is_object());
242        assert!(combined["paths"]["/orders"]["get"].is_object());
243    }
244
245    #[test]
246    fn test_path_override() {
247        let spec1 = json!({
248            "paths": {
249                "/users": {
250                    "get": {"summary": "First"}
251                }
252            }
253        });
254
255        let spec2 = json!({
256            "paths": {
257                "/users": {
258                    "get": {"summary": "Second"}
259                }
260            }
261        });
262
263        let combined = OpenApiBuilder::new()
264            .merge(spec1)
265            .unwrap()
266            .merge(spec2)
267            .unwrap()
268            .build();
269
270        // Last write wins
271        assert_eq!(combined["paths"]["/users"]["get"]["summary"], "Second");
272    }
273
274    #[test]
275    fn test_schema_deduplication() {
276        let spec1 = json!({
277            "components": {
278                "schemas": {
279                    "User": {"type": "object", "properties": {"name": {"type": "string"}}}
280                }
281            }
282        });
283
284        let spec2 = json!({
285            "components": {
286                "schemas": {
287                    "User": {"type": "object", "properties": {"name": {"type": "string"}}}
288                }
289            }
290        });
291
292        // Identical schemas should dedupe without error
293        let result = OpenApiBuilder::new().merge(spec1).unwrap().merge(spec2);
294        assert!(result.is_ok());
295
296        let combined = result.unwrap().build();
297        assert!(combined["components"]["schemas"]["User"].is_object());
298    }
299
300    #[test]
301    fn test_schema_conflict() {
302        let spec1 = json!({
303            "components": {
304                "schemas": {
305                    "User": {"type": "object", "properties": {"name": {"type": "string"}}}
306                }
307            }
308        });
309
310        let spec2 = json!({
311            "components": {
312                "schemas": {
313                    "User": {"type": "object", "properties": {"id": {"type": "integer"}}}
314                }
315            }
316        });
317
318        // Different schemas should error
319        let result = OpenApiBuilder::new().merge(spec1).unwrap().merge(spec2);
320        assert!(result.is_err());
321
322        let err = result.unwrap_err();
323        assert!(matches!(err, OpenApiError::SchemaConflict { name } if name == "User"));
324    }
325
326    #[test]
327    fn test_merge_typed_paths() {
328        use crate::types::{OpenApiOperation, OpenApiPath};
329
330        let paths = vec![
331            OpenApiPath::new("/users", "get").with_operation(OpenApiOperation::new("List users")),
332            OpenApiPath::new("/users", "post").with_operation(OpenApiOperation::new("Create user")),
333        ];
334
335        let spec = OpenApiBuilder::new()
336            .title("Test")
337            .merge_paths(paths)
338            .build();
339
340        assert_eq!(spec["paths"]["/users"]["get"]["summary"], "List users");
341        assert_eq!(spec["paths"]["/users"]["post"]["summary"], "Create user");
342    }
343
344    #[test]
345    fn test_merge_typed_schemas() {
346        use crate::types::OpenApiSchema;
347
348        let schemas = vec![OpenApiSchema::new(
349            "User",
350            json!({"type": "object", "properties": {"name": {"type": "string"}}}),
351        )];
352
353        let spec = OpenApiBuilder::new()
354            .title("Test")
355            .merge_schemas(schemas)
356            .unwrap()
357            .build();
358
359        assert!(spec["components"]["schemas"]["User"].is_object());
360    }
361}