okapi_operation/
builder.rs

1use anyhow::{Context, bail};
2use http::Method;
3use indexmap::IndexMap;
4use okapi::openapi3::{
5    Contact, ExternalDocs, License, OpenApi, SecurityRequirement, SecurityScheme, Server, Tag,
6};
7
8use crate::{OperationGenerator, components::Components};
9
10/// OpenAPI specificatrion builder.
11#[derive(Clone)]
12pub struct OpenApiBuilder {
13    spec: OpenApi,
14    components: Components,
15    operations: IndexMap<(String, Method), OperationGenerator>,
16}
17
18impl Default for OpenApiBuilder {
19    fn default() -> Self {
20        let spec = OpenApi {
21            openapi: OpenApi::default_version(),
22            ..Default::default()
23        };
24        Self {
25            spec,
26            components: Components::new(Default::default()),
27            operations: IndexMap::new(),
28        }
29    }
30}
31
32impl OpenApiBuilder {
33    /// Create new builder with specified title and version
34    pub fn new(title: &str, version: &str) -> Self {
35        let mut this = Self::default();
36        this.title(title);
37        this.version(version);
38        this
39    }
40
41    /// Alter default [`Components`].
42    ///
43    /// ## NOTE
44    ///
45    /// This will override existing components in builder. Use this before adding anything to
46    /// the builder.
47    pub fn set_components(&mut self, new_components: Components) -> &mut Self {
48        self.components = new_components;
49        self
50    }
51
52    /// Add single operation.
53    ///
54    /// Throws an error if (path, method) pair is already present.
55    pub fn try_operation<T>(
56        &mut self,
57        path: T,
58        method: Method,
59        generator: OperationGenerator,
60    ) -> Result<&mut Self, anyhow::Error>
61    where
62        T: Into<String>,
63    {
64        let path = path.into();
65        if self
66            .operations
67            .insert((path.clone(), method.clone()), generator)
68            .is_some()
69        {
70            bail!("{method} {path} is already present in specification");
71        };
72        Ok(self)
73    }
74
75    /// Add multiple operations.
76    ///
77    /// Throws an error if any (path, method) pair is already present.
78    pub fn try_operations<I, S>(&mut self, operations: I) -> Result<&mut Self, anyhow::Error>
79    where
80        I: Iterator<Item = (S, Method, OperationGenerator)>,
81        S: Into<String>,
82    {
83        for (path, method, f) in operations {
84            self.try_operation(path, method, f)?;
85        }
86        Ok(self)
87    }
88
89    /// Add single operation.
90    ///
91    /// Replaces operation if (path, method) pair is already present.
92    pub fn operation<T>(
93        &mut self,
94        path: T,
95        method: Method,
96        generator: OperationGenerator,
97    ) -> &mut Self
98    where
99        T: Into<String>,
100    {
101        let _ = self.try_operation(path, method, generator);
102        self
103    }
104
105    /// Add multiple operations.
106    ///
107    /// Replaces operation if (path, method) pair is already present.
108    pub fn operations<I, S>(&mut self, operations: I) -> &mut Self
109    where
110        I: Iterator<Item = (S, Method, OperationGenerator)>,
111        S: Into<String>,
112    {
113        for (path, method, f) in operations {
114            self.operation(path, method, f);
115        }
116        self
117    }
118
119    /// Access inner [`okapi::openapi3::OpenApi`].
120    ///
121    /// **Warning!** This allows raw access to underlying `OpenApi` object,
122    /// which might break generated specification.
123    ///
124    /// # NOTE
125    ///
126    /// Components are overwritten on building specification.
127    pub fn spec_mut(&mut self) -> &mut OpenApi {
128        &mut self.spec
129    }
130
131    /// Apply security scheme globally.
132    pub fn apply_global_security<N, S>(&mut self, name: N, scopes: S) -> &mut Self
133    where
134        N: Into<String>,
135        S: IntoIterator<Item = String>,
136    {
137        let mut sec = SecurityRequirement::new();
138        sec.insert(name.into(), scopes.into_iter().collect());
139        self.spec.security.push(sec);
140        self
141    }
142
143    /// Generate [`okapi::openapi3::OpenApi`] specification.
144    ///
145    /// This method can be called repeatedly on the same object.
146    pub fn build(&mut self) -> Result<OpenApi, anyhow::Error> {
147        let mut spec = self.spec.clone();
148
149        self.operations.sort_by(|lkey, _, rkey, _| {
150            let lkey_str = (&lkey.0, lkey.1.as_str());
151            let rkey_str = (&rkey.0, rkey.1.as_str());
152            lkey_str.cmp(&rkey_str)
153        });
154
155        for ((path, method), generator) in &self.operations {
156            try_add_path(
157                &mut spec,
158                &mut self.components,
159                path,
160                method.clone(),
161                *generator,
162            )
163            .with_context(|| format!("Failed to add {method} {path}"))?;
164        }
165
166        spec.components = Some(self.components.okapi_components()?);
167
168        Ok(spec)
169    }
170
171    // Helpers to set OpenApi info/servers/tags/... as is
172
173    /// Set specification title.
174    ///
175    /// Empty string by default.
176    pub fn title(&mut self, title: impl Into<String>) -> &mut Self {
177        self.spec.info.title = title.into();
178        self
179    }
180
181    /// Set specification version.
182    ///
183    /// Empty string by default.
184    pub fn version(&mut self, version: impl Into<String>) -> &mut Self {
185        self.spec.info.version = version.into();
186        self
187    }
188
189    /// Add description to specification.
190    pub fn description(&mut self, description: impl Into<String>) -> &mut Self {
191        self.spec.info.description = Some(description.into());
192        self
193    }
194
195    /// Add contact to specification.
196    pub fn contact(&mut self, contact: Contact) -> &mut Self {
197        self.spec.info.contact = Some(contact);
198        self
199    }
200
201    /// Add license to specification.
202    pub fn license(&mut self, license: License) -> &mut Self {
203        self.spec.info.license = Some(license);
204        self
205    }
206
207    /// Add terms_of_service to specification.
208    pub fn terms_of_service(&mut self, terms_of_service: impl Into<String>) -> &mut Self {
209        self.spec.info.terms_of_service = Some(terms_of_service.into());
210        self
211    }
212
213    /// Add server to specification.
214    pub fn server(&mut self, server: Server) -> &mut Self {
215        self.spec.servers.push(server);
216        self
217    }
218
219    /// Add tag to specification.
220    pub fn tag(&mut self, tag: Tag) -> &mut Self {
221        self.spec.tags.push(tag);
222        self
223    }
224
225    /// Set external documentation for specification.
226    pub fn external_docs(&mut self, docs: ExternalDocs) -> &mut Self {
227        let _ = self.spec.external_docs.insert(docs);
228        self
229    }
230
231    /// Add security scheme definition to specification.
232    pub fn security_scheme<N>(&mut self, name: N, sec: SecurityScheme) -> &mut Self
233    where
234        N: Into<String>,
235    {
236        self.components.add_security_scheme(name, sec);
237        self
238    }
239}
240
241fn try_add_path(
242    spec: &mut OpenApi,
243    components: &mut Components,
244    path: &str,
245    method: Method,
246    generator: OperationGenerator,
247) -> Result<(), anyhow::Error> {
248    let operation_schema = generator(components)?;
249    let path_str = path;
250    let path = spec.paths.entry(path.into()).or_default();
251    if method == Method::DELETE {
252        path.delete = Some(operation_schema);
253    } else if method == Method::GET {
254        path.get = Some(operation_schema);
255    } else if method == Method::HEAD {
256        path.head = Some(operation_schema);
257    } else if method == Method::OPTIONS {
258        path.options = Some(operation_schema);
259    } else if method == Method::PATCH {
260        path.patch = Some(operation_schema);
261    } else if method == Method::POST {
262        path.post = Some(operation_schema);
263    } else if method == Method::PUT {
264        path.put = Some(operation_schema);
265    } else if method == Method::TRACE {
266        path.trace = Some(operation_schema);
267    } else {
268        return Err(anyhow::anyhow!(
269            "Unsupported method {method} (at {path_str})"
270        ));
271    }
272    Ok(())
273}
274
275/// Ensures that a builder always generates the same file every time, by not relying on
276/// internal data structures that may contain random ordering, e.g. [`std::collections::HashMap`].
277#[test]
278fn ensure_builder_deterministic() {
279    use okapi::openapi3::Operation;
280
281    let mut built_specs = Vec::new();
282
283    // generate 100 specs
284    for _ in 0..100 {
285        let mut builder = OpenApiBuilder::new("title", "version");
286        for i in 0..2 {
287            builder.operation(format!("/path/{}", i), Method::GET, |_| {
288                Ok(Operation::default())
289            });
290        }
291
292        let spec = builder
293            .build()
294            .map(|x| format!("{:?}", x))
295            .expect("Failed to build spec");
296        built_specs.push(spec);
297    }
298
299    // ensure all specs are the same
300    for i in 1..built_specs.len() {
301        assert_eq!(built_specs[i - 1], built_specs[i]);
302    }
303}