Skip to main content

siderite_openapi/
builder.rs

1//! Assembles an [`OpenApi`] document from described operations.
2
3use crate::model::{
4    Components, HttpMethod, Info, OPENAPI_VERSION, OpenApi, Operation, Parameter,
5    ParameterLocation, PathItem,
6};
7use siderite_validation::{SchemaConflict, SchemaObject, SchemaRegistry};
8use std::collections::BTreeMap;
9use thiserror::Error;
10
11/// Errors produced while building a document.
12#[derive(Debug, Clone, PartialEq, Eq, Error)]
13#[non_exhaustive]
14pub enum OpenApiError {
15    /// The same method was registered twice on one path.
16    #[error("duplicate operation {method} {path}")]
17    DuplicateOperation {
18        /// Path template.
19        path: String,
20        /// Lower-case method.
21        method: &'static str,
22    },
23    /// Two operations share an `operationId`.
24    #[error("duplicate operationId `{0}`")]
25    DuplicateOperationId(String),
26    /// Two Rust types claimed one schema name.
27    #[error("schema name conflicts: {0:?}")]
28    SchemaConflicts(Vec<SchemaConflict>),
29}
30
31/// Collects operations and produces a document.
32///
33/// Operations are described against the builder's shared [`SchemaRegistry`]
34/// so every named schema appears exactly once in `components.schemas`.
35#[derive(Debug)]
36pub struct DocumentBuilder {
37    info: Info,
38    registry: SchemaRegistry,
39    paths: BTreeMap<String, PathItem>,
40    errors: Vec<OpenApiError>,
41}
42
43impl DocumentBuilder {
44    /// Start a document.
45    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
46        Self {
47            info: Info {
48                title: title.into(),
49                version: version.into(),
50                description: None,
51            },
52            registry: SchemaRegistry::new(),
53            paths: BTreeMap::new(),
54            errors: Vec::new(),
55        }
56    }
57
58    /// Set the API description.
59    #[must_use]
60    pub fn description(mut self, description: impl Into<String>) -> Self {
61        self.info.description = Some(description.into());
62        self
63    }
64
65    /// Registry to describe operations against.
66    pub fn registry(&mut self) -> &mut SchemaRegistry {
67        &mut self.registry
68    }
69
70    /// Add an operation at `path` (siderite `{param}` template syntax).
71    ///
72    /// Unnamed path parameters are named positionally from the template, and
73    /// template parameters with no documented schema get a `string` schema so
74    /// the document stays valid.
75    pub fn add_operation(&mut self, path: &str, method: HttpMethod, mut op: Operation) {
76        name_path_parameters(path, &mut op);
77        let item = self.paths.entry(path.to_owned()).or_default();
78        if item.0.contains_key(method.as_str()) {
79            self.errors.push(OpenApiError::DuplicateOperation {
80                path: path.to_owned(),
81                method: method.as_str(),
82            });
83            return;
84        }
85        if op.responses.is_empty() {
86            op.add_response("default", "Response", None);
87        }
88        item.0.insert(method.as_str(), op);
89    }
90
91    /// Finish the document.
92    ///
93    /// # Errors
94    /// Returns the first structural error found (duplicate operations or
95    /// operation ids, schema name conflicts).
96    pub fn build(mut self) -> Result<OpenApi, OpenApiError> {
97        let mut ids = std::collections::BTreeSet::new();
98        for op in self.paths.values().flat_map(|p| p.0.values()) {
99            if let Some(id) = &op.operation_id
100                && !ids.insert(id.clone())
101            {
102                self.errors
103                    .push(OpenApiError::DuplicateOperationId(id.clone()));
104            }
105        }
106        if let Some(err) = self.errors.into_iter().next() {
107            return Err(err);
108        }
109        let security_schemes = self.registry.take_security_schemes();
110        let schemas = self
111            .registry
112            .into_components()
113            .map_err(OpenApiError::SchemaConflicts)?;
114        Ok(OpenApi {
115            openapi: OPENAPI_VERSION,
116            info: self.info,
117            paths: self.paths,
118            components: Components {
119                schemas,
120                security_schemes,
121            },
122        })
123    }
124}
125
126/// Parameter names in a `{param}` path template, in order.
127pub(crate) fn template_params(path: &str) -> Vec<&str> {
128    path.split('/')
129        .filter_map(|seg| seg.strip_prefix('{')?.strip_suffix('}'))
130        .map(|name| name.trim_start_matches('*'))
131        .collect()
132}
133
134fn name_path_parameters(path: &str, op: &mut Operation) {
135    let names = template_params(path);
136    let mut unnamed = names
137        .iter()
138        .filter(|n| {
139            !op.parameters
140                .iter()
141                .any(|p| p.location == ParameterLocation::Path && p.name == **n)
142        })
143        .copied()
144        .collect::<Vec<_>>()
145        .into_iter();
146    for p in op
147        .parameters
148        .iter_mut()
149        .filter(|p| p.location == ParameterLocation::Path && p.name.is_empty())
150    {
151        if let Some(name) = unnamed.next() {
152            p.name = name.to_owned();
153        }
154    }
155    // Drop placeholders that did not match a template segment.
156    op.parameters
157        .retain(|p| !(p.location == ParameterLocation::Path && p.name.is_empty()));
158    for name in names {
159        let documented = op
160            .parameters
161            .iter()
162            .any(|p| p.location == ParameterLocation::Path && p.name == name);
163        if !documented {
164            op.parameters.push(Parameter::new(
165                name,
166                ParameterLocation::Path,
167                true,
168                SchemaObject::of_type("string"),
169            ));
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn template_parsing() {
180        assert_eq!(template_params("/a/{id}/b/{*rest}"), vec!["id", "rest"]);
181        assert!(template_params("/plain").is_empty());
182    }
183
184    #[test]
185    fn unnamed_path_params_are_named_positionally() {
186        let mut op = Operation::default();
187        op.add_parameter(Parameter::new(
188            "",
189            ParameterLocation::Path,
190            true,
191            SchemaObject::of_type("integer"),
192        ));
193        let mut b = DocumentBuilder::new("t", "1");
194        b.add_operation("/orgs/{org}/users/{id}", HttpMethod::Get, op);
195        let doc = b.build().unwrap();
196        let params = &doc.paths["/orgs/{org}/users/{id}"].0["get"].parameters;
197        assert_eq!(params[0].name, "org");
198        assert_eq!(params[0].schema, SchemaObject::of_type("integer"));
199        assert_eq!(params[1].name, "id");
200        assert_eq!(params[1].schema, SchemaObject::of_type("string"));
201    }
202
203    #[test]
204    fn duplicates_are_errors() {
205        let mut b = DocumentBuilder::new("t", "1");
206        b.add_operation("/x", HttpMethod::Get, Operation::default());
207        b.add_operation("/x", HttpMethod::Get, Operation::default());
208        assert!(matches!(
209            b.build(),
210            Err(OpenApiError::DuplicateOperation { .. })
211        ));
212
213        let mut b = DocumentBuilder::new("t", "1");
214        let op = Operation {
215            operation_id: Some("a".into()),
216            ..Operation::default()
217        };
218        b.add_operation("/x", HttpMethod::Get, op.clone());
219        b.add_operation("/y", HttpMethod::Get, op);
220        assert_eq!(
221            b.build(),
222            Err(OpenApiError::DuplicateOperationId("a".into()))
223        );
224    }
225}