siderite_openapi/
builder.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, Error)]
13#[non_exhaustive]
14pub enum OpenApiError {
15 #[error("duplicate operation {method} {path}")]
17 DuplicateOperation {
18 path: String,
20 method: &'static str,
22 },
23 #[error("duplicate operationId `{0}`")]
25 DuplicateOperationId(String),
26 #[error("schema name conflicts: {0:?}")]
28 SchemaConflicts(Vec<SchemaConflict>),
29}
30
31#[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 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 #[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 pub fn registry(&mut self) -> &mut SchemaRegistry {
67 &mut self.registry
68 }
69
70 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 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
126pub(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 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}