Skip to main content

topcoat_openapi/
operation.rs

1use std::{any::type_name, borrow::Cow, collections::BTreeMap};
2
3use schemars::JsonSchema;
4use topcoat_router::StatusCode;
5
6use crate::{
7    Content, Parameter,
8    content::{ContentType, SchemaFn},
9};
10
11/// Describes a route in the API document.
12///
13/// Add it to a route with `with(...)`. A route appears in the document when
14/// an `Operation` is attached to it or to a layer that wraps it:
15///
16/// ```
17/// use serde::{Deserialize, Serialize};
18/// use topcoat::{
19///     Result,
20///     context::Cx,
21///     openapi::{Operation, schemars::JsonSchema},
22///     router::{Body, Next, StatusCode, content::Json, layer, response::Response, route},
23/// };
24///
25/// #[derive(Deserialize, JsonSchema)]
26/// struct CreateUser {
27///     name: String,
28/// }
29///
30/// #[derive(Serialize, JsonSchema)]
31/// struct User {
32///     id: u64,
33///     name: String,
34/// }
35///
36/// // Every route under `/api` is documented and tagged.
37/// #[layer("/api", with(Operation::new().tag("api")))]
38/// async fn api(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
39///     next.run(cx, body).await
40/// }
41///
42/// #[route(POST "/api/users", with(
43///     Operation::new()
44///         .summary("Create a user")
45///         .request::<Json<CreateUser>>()
46///         .response_with::<Json<User>>(StatusCode::CREATED, "The new user.")
47/// ))]
48/// async fn create_user(
49///     cx: &Cx,
50///     Json(input): Json<CreateUser>,
51/// ) -> Result<(StatusCode, Json<User>)> {
52///     Ok((
53///         StatusCode::CREATED,
54///         Json(User {
55///             id: 1,
56///             name: input.name,
57///         }),
58///     ))
59/// }
60/// ```
61///
62/// A route combines its own operation with those of its layers, and the
63/// closest value of each setting takes priority. Tags, parameters, and
64/// responses add up instead: a route keeps the ones its layers declare and
65/// replaces only a parameter or response status it declares itself.
66#[derive(Debug, Clone, Default)]
67pub struct Operation {
68    pub(crate) summary: Option<Cow<'static, str>>,
69    pub(crate) description: Option<Cow<'static, str>>,
70    pub(crate) id: Option<Cow<'static, str>>,
71    pub(crate) deprecated: Option<bool>,
72    pub(crate) hidden: Option<bool>,
73    pub(crate) tags: Vec<Cow<'static, str>>,
74    pub(crate) parameters: Vec<Parameter>,
75    pub(crate) queries: Vec<QueryType>,
76    pub(crate) request: Option<ContentType>,
77    pub(crate) responses: BTreeMap<u16, ResponseType>,
78    pub(crate) security: Option<Vec<Cow<'static, str>>>,
79}
80
81impl Operation {
82    /// Creates an operation that inherits every setting.
83    #[must_use]
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    /// Sets a short summary of what the route does.
89    #[must_use]
90    pub fn summary(mut self, summary: impl Into<Cow<'static, str>>) -> Self {
91        self.summary = Some(summary.into());
92        self
93    }
94
95    /// Sets a longer explanation of the route's behavior.
96    #[must_use]
97    pub fn description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
98        self.description = Some(description.into());
99        self
100    }
101
102    /// Sets the name that identifies the operation, which client generators
103    /// use to name their functions.
104    ///
105    /// An ID has to be unique in the document, so set it on a route that
106    /// serves a single method rather than on a layer.
107    #[must_use]
108    pub fn operation_id(mut self, operation_id: impl Into<Cow<'static, str>>) -> Self {
109        self.id = Some(operation_id.into());
110        self
111    }
112
113    /// Marks the route as deprecated, so clients know to move away from it.
114    #[must_use]
115    pub fn deprecated(mut self, deprecated: bool) -> Self {
116        self.deprecated = Some(deprecated);
117        self
118    }
119
120    /// Chooses whether to leave the route out of the document.
121    ///
122    /// Pass `true` to hide a route that a layer documents. Pass `false` to
123    /// show a route below a layer that hides its routes.
124    #[must_use]
125    pub fn hidden(mut self, hidden: bool) -> Self {
126        self.hidden = Some(hidden);
127        self
128    }
129
130    /// Adds a tag, which documentation tools use to group routes.
131    #[must_use]
132    pub fn tag(mut self, tag: impl Into<Cow<'static, str>>) -> Self {
133        let tag = tag.into();
134        if !self.tags.contains(&tag) {
135            self.tags.push(tag);
136        }
137        self
138    }
139
140    /// Describes a path, query, header, or cookie parameter.
141    ///
142    /// Describing the same parameter again replaces the earlier entry.
143    #[must_use]
144    pub fn parameter(mut self, parameter: Parameter) -> Self {
145        self.parameters.retain(|other| !other.same_as(&parameter));
146        self.parameters.push(parameter);
147        self
148    }
149
150    /// Documents every field of the struct `T` as a query parameter.
151    ///
152    /// This suits a struct read with `query_params`. Optional fields become
153    /// optional parameters, and doc comments on fields become their
154    /// descriptions:
155    ///
156    /// ```
157    /// use topcoat::{
158    ///     openapi::{Operation, schemars::JsonSchema},
159    ///     router::query_params,
160    /// };
161    ///
162    /// #[query_params]
163    /// #[derive(JsonSchema)]
164    /// struct PostsQuery {
165    ///     /// The page to show, starting at 1.
166    ///     page: Option<u32>,
167    /// }
168    ///
169    /// let operation = Operation::new().query::<PostsQuery>();
170    /// ```
171    #[must_use]
172    pub fn query<T: JsonSchema>(mut self) -> Self {
173        self.queries.push(QueryType {
174            name: type_name::<T>(),
175            schema: T::json_schema,
176        });
177        self
178    }
179
180    /// Documents the request body, for example
181    /// `.request::<Json<CreateUser>>()`.
182    ///
183    /// Wrap the type in [`Option`] for a body that requests may leave out.
184    #[must_use]
185    pub fn request<T: Content>(mut self) -> Self {
186        self.request = Some(ContentType::of::<T>());
187        self
188    }
189
190    /// Documents a response without a body, for example
191    /// `.response(StatusCode::NO_CONTENT, "The user was deleted.")`.
192    ///
193    /// Declaring a status again replaces its earlier response.
194    #[must_use]
195    pub fn response(
196        mut self,
197        status: StatusCode,
198        description: impl Into<Cow<'static, str>>,
199    ) -> Self {
200        self.responses.insert(
201            status.as_u16(),
202            ResponseType {
203                description: description.into(),
204                content: None,
205            },
206        );
207        self
208    }
209
210    /// Documents a response with a body, for example
211    /// `.response_with::<Json<User>>(StatusCode::OK, "The user.")`.
212    ///
213    /// Declaring a status again replaces its earlier response.
214    #[must_use]
215    pub fn response_with<T: Content>(
216        mut self,
217        status: StatusCode,
218        description: impl Into<Cow<'static, str>>,
219    ) -> Self {
220        self.responses.insert(
221            status.as_u16(),
222            ResponseType {
223                description: description.into(),
224                content: Some(ContentType::of::<T>()),
225            },
226        );
227        self
228    }
229
230    /// Requires clients to authenticate with the security scheme registered
231    /// under `scheme`.
232    ///
233    /// Calling this again adds an alternative, so clients may use either
234    /// scheme. The requirements of an operation replace those of its layers
235    /// as a whole.
236    ///
237    /// See [`OpenApi::security_scheme`](crate::OpenApi::security_scheme) for
238    /// registering a scheme.
239    #[must_use]
240    pub fn security(mut self, scheme: impl Into<Cow<'static, str>>) -> Self {
241        self.security.get_or_insert_default().push(scheme.into());
242        self
243    }
244
245    /// Documents the route as usable without authentication, even when a
246    /// layer requires it.
247    #[must_use]
248    pub fn no_security(mut self) -> Self {
249        self.security = Some(Vec::new());
250        self
251    }
252
253    /// Combines the operations attached to a route into one. Pass them
254    /// closest first, so the route's own operation precedes those of its
255    /// layers. Returns `None` when there are none.
256    pub(crate) fn combine<'a>(levels: impl IntoIterator<Item = &'a Operation>) -> Option<Self> {
257        let mut levels = levels.into_iter();
258        let mut combined = levels.next()?.clone();
259        for outer in levels {
260            combined.inherit(outer);
261        }
262        Some(combined)
263    }
264
265    /// Fills the settings this operation leaves unset from `outer`, and
266    /// places the tags, parameters, and responses of `outer` before its own.
267    fn inherit(&mut self, outer: &Operation) {
268        fn fill<T: Clone>(field: &mut Option<T>, outer: Option<&T>) {
269            if field.is_none() {
270                *field = outer.cloned();
271            }
272        }
273
274        fill(&mut self.summary, outer.summary.as_ref());
275        fill(&mut self.description, outer.description.as_ref());
276        fill(&mut self.id, outer.id.as_ref());
277        fill(&mut self.deprecated, outer.deprecated.as_ref());
278        fill(&mut self.hidden, outer.hidden.as_ref());
279        fill(&mut self.request, outer.request.as_ref());
280        fill(&mut self.security, outer.security.as_ref());
281
282        let mut tags = outer.tags.clone();
283        for tag in self.tags.drain(..) {
284            if !tags.contains(&tag) {
285                tags.push(tag);
286            }
287        }
288        self.tags = tags;
289
290        let mut parameters: Vec<Parameter> = outer
291            .parameters
292            .iter()
293            .filter(|parameter| !self.parameters.iter().any(|own| own.same_as(parameter)))
294            .cloned()
295            .collect();
296        parameters.append(&mut self.parameters);
297        self.parameters = parameters;
298
299        let mut queries = outer.queries.clone();
300        queries.append(&mut self.queries);
301        self.queries = queries;
302
303        for (status, response) in &outer.responses {
304            self.responses
305                .entry(*status)
306                .or_insert_with(|| response.clone());
307        }
308    }
309}
310
311/// A struct whose fields are documented as query parameters.
312#[derive(Debug, Clone, Copy)]
313pub(crate) struct QueryType {
314    /// The Rust name of the struct, for error messages.
315    pub(crate) name: &'static str,
316    pub(crate) schema: SchemaFn,
317}
318
319/// A documented response to a route.
320#[derive(Debug, Clone)]
321pub(crate) struct ResponseType {
322    pub(crate) description: Cow<'static, str>,
323    pub(crate) content: Option<ContentType>,
324}
325
326#[cfg(test)]
327mod tests {
328    use topcoat_router::content::Json;
329
330    use super::*;
331
332    /// Combines operations given closest first.
333    fn combine(levels: &[Operation]) -> Operation {
334        Operation::combine(levels).unwrap()
335    }
336
337    #[test]
338    fn combining_nothing_yields_no_operation() {
339        assert!(Operation::combine(&[]).is_none());
340    }
341
342    #[test]
343    fn the_closest_value_of_a_setting_takes_priority() {
344        let layer = Operation::new()
345            .summary("Layer")
346            .description("From the layer");
347        let route = Operation::new().summary("Route");
348
349        let combined = combine(&[route, layer]);
350
351        assert_eq!(combined.summary.as_deref(), Some("Route"));
352        assert_eq!(combined.description.as_deref(), Some("From the layer"));
353    }
354
355    #[test]
356    fn a_route_can_show_itself_below_a_layer_that_hides_its_routes() {
357        let layer = Operation::new().hidden(true);
358
359        assert_eq!(
360            combine(&[Operation::new(), layer.clone()]).hidden,
361            Some(true)
362        );
363        assert_eq!(
364            combine(&[Operation::new().hidden(false), layer]).hidden,
365            Some(false)
366        );
367    }
368
369    #[test]
370    fn tags_add_up_from_the_outermost_level_without_repeating() {
371        let app = Operation::new().tag("api");
372        let layer = Operation::new().tag("users");
373        let route = Operation::new().tag("api").tag("admin");
374
375        let combined = combine(&[route, layer, app]);
376
377        assert_eq!(combined.tags, ["api", "users", "admin"]);
378    }
379
380    #[test]
381    fn responses_add_up_and_the_closest_one_wins_per_status() {
382        let layer = Operation::new()
383            .response(StatusCode::UNAUTHORIZED, "Not signed in.")
384            .response(StatusCode::NOT_FOUND, "Layer");
385        let route = Operation::new()
386            .response(StatusCode::NOT_FOUND, "Route")
387            .response_with::<Json<String>>(StatusCode::OK, "Found.");
388
389        let combined = combine(&[route, layer]);
390
391        let statuses: Vec<u16> = combined.responses.keys().copied().collect();
392        assert_eq!(statuses, [200, 401, 404]);
393        assert_eq!(combined.responses[&404].description, "Route");
394        assert!(combined.responses[&200].content.is_some());
395        assert!(combined.responses[&401].content.is_none());
396    }
397
398    #[test]
399    fn parameters_add_up_and_the_closest_one_wins_per_name_and_location() {
400        let layer = Operation::new()
401            .parameter(Parameter::header("x-tenant").description("Layer"))
402            .parameter(Parameter::query("page"));
403        let route = Operation::new()
404            .parameter(Parameter::header("x-tenant").description("Route"))
405            // Shares its name with the query parameter, but not its location.
406            .parameter(Parameter::cookie("page"));
407
408        let combined = combine(&[route, layer]);
409
410        let described: Vec<_> = combined
411            .parameters
412            .iter()
413            .map(|parameter| {
414                (
415                    parameter.location,
416                    &*parameter.name,
417                    parameter.description.as_deref(),
418                )
419            })
420            .collect();
421        assert_eq!(
422            described,
423            [
424                (crate::parameter::ParameterLocation::Query, "page", None),
425                (
426                    crate::parameter::ParameterLocation::Header,
427                    "x-tenant",
428                    Some("Route")
429                ),
430                (crate::parameter::ParameterLocation::Cookie, "page", None),
431            ]
432        );
433    }
434
435    #[test]
436    fn describing_a_parameter_again_replaces_it() {
437        let operation = Operation::new()
438            .parameter(Parameter::query("page").description("First"))
439            .parameter(Parameter::query("page").description("Second"));
440
441        assert_eq!(operation.parameters.len(), 1);
442        assert_eq!(
443            operation.parameters[0].description.as_deref(),
444            Some("Second")
445        );
446    }
447
448    #[test]
449    fn security_requirements_replace_those_of_the_layers_as_a_whole() {
450        let layer = Operation::new().security("token").security("key");
451
452        let inherited = combine(&[Operation::new(), layer.clone()]);
453        assert_eq!(inherited.security.unwrap(), ["token", "key"]);
454
455        let replaced = combine(&[Operation::new().security("session"), layer.clone()]);
456        assert_eq!(replaced.security.unwrap(), ["session"]);
457
458        let public = combine(&[Operation::new().no_security(), layer]);
459        assert_eq!(public.security, Some(Vec::new()));
460    }
461}