Skip to main content

salvo_oapi/openapi/
path.rs

1//! Implements [OpenAPI Path Object][paths] types.
2//!
3//! [paths]: https://spec.openapis.org/oas/latest.html#paths-object
4use std::iter;
5use std::ops::{Deref, DerefMut};
6
7use serde::{Deserialize, Serialize};
8
9use super::{Operation, Operations, Parameter, Parameters, PathMap, PropMap, Server, Servers};
10
11/// Implements [OpenAPI Path Object][paths] types.
12///
13/// [paths]: https://spec.openapis.org/oas/latest.html#paths-object
14#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)]
15pub struct Paths(PathMap<String, PathItem>);
16impl Deref for Paths {
17    type Target = PathMap<String, PathItem>;
18
19    fn deref(&self) -> &Self::Target {
20        &self.0
21    }
22}
23impl DerefMut for Paths {
24    fn deref_mut(&mut self) -> &mut Self::Target {
25        &mut self.0
26    }
27}
28impl Paths {
29    /// Construct a new empty [`Paths`]. This is effectively same as calling [`Paths::default`].
30    #[must_use]
31    pub fn new() -> Self {
32        Default::default()
33    }
34    /// Inserts a key-value pair into the instance and returns `self`.
35    #[must_use]
36    pub fn path<K: Into<String>, V: Into<PathItem>>(mut self, key: K, value: V) -> Self {
37        self.insert(key, value);
38        self
39    }
40    /// Inserts a key-value pair into the instance.
41    pub fn insert<K: Into<String>, V: Into<PathItem>>(&mut self, key: K, value: V) {
42        let key = key.into();
43        let mut value = value.into();
44        self.0
45            .entry(key)
46            .and_modify(|item| {
47                if value.ref_location.is_some() {
48                    item.ref_location = value.ref_location.take();
49                }
50                if value.summary.is_some() {
51                    item.summary = value.summary.take();
52                }
53                if value.description.is_some() {
54                    item.description = value.description.take();
55                }
56                item.servers.append(&mut value.servers);
57                item.parameters.append(&mut value.parameters);
58                item.operations.append(&mut value.operations);
59            })
60            .or_insert(value);
61    }
62    /// Moves all elements from `other` into `self`, leaving `other` empty.
63    ///
64    /// If a key from `other` is already present in `self`, the two [`PathItem`]s are
65    /// merged field by field (see [`insert`](Self::insert)): `servers`, `parameters`
66    /// and `operations` are appended, while the scalar fields (`ref_location`,
67    /// `summary`, `description`) are overwritten by `other`'s non-empty values.
68    pub fn append(&mut self, other: &mut Self) {
69        let items = std::mem::take(&mut other.0);
70        for item in items {
71            self.insert(item.0, item.1);
72        }
73    }
74    /// Extends a collection with the contents of an iterator.
75    pub fn extend<I, K, V>(&mut self, iter: I)
76    where
77        I: IntoIterator<Item = (K, V)>,
78        K: Into<String>,
79        V: Into<PathItem>,
80    {
81        for (k, v) in iter.into_iter() {
82            self.insert(k, v);
83        }
84    }
85}
86
87/// Implements [OpenAPI Path Item Object][path_item] what describes [`Operation`]s available on
88/// a single path.
89///
90/// [path_item]: https://spec.openapis.org/oas/latest.html#path-item-object
91#[non_exhaustive]
92#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)]
93#[serde(rename_all = "camelCase")]
94pub struct PathItem {
95    /// External reference to a Path Item Object defined elsewhere.
96    ///
97    /// In OpenAPI 3.1 a Path Item Object can carry its own `$ref` field that delegates to
98    /// another Path Item definition. When set, sibling fields' behavior is undefined per
99    /// spec — most consumers resolve the reference and ignore them.
100    ///
101    /// See <https://spec.openapis.org/oas/v3.1.0#path-item-object>.
102    #[serde(rename = "$ref", skip_serializing_if = "Option::is_none", default)]
103    pub ref_location: Option<String>,
104
105    /// Optional summary intended to apply all operations in this [`PathItem`].
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub summary: Option<String>,
108
109    /// Optional description intended to apply all operations in this [`PathItem`].
110    /// Description supports markdown syntax.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub description: Option<String>,
113
114    /// Alternative [`Server`] array to serve all [`Operation`]s in this [`PathItem`] overriding
115    /// the global server array.
116    #[serde(skip_serializing_if = "Servers::is_empty", default)]
117    pub servers: Servers,
118
119    /// List of [`Parameter`]s common to all [`Operation`]s in this [`PathItem`]. Parameters cannot
120    /// contain duplicate parameters. They can be overridden in [`Operation`] level but cannot be
121    /// removed there.
122    #[serde(skip_serializing_if = "Parameters::is_empty", default)]
123    pub parameters: Parameters,
124
125    /// Map of operations in this [`PathItem`]. Operations can hold only one operation
126    /// per [`PathItemType`].
127    #[serde(flatten, default)]
128    pub operations: Operations,
129
130    /// Optional extensions "x-something"
131    #[serde(skip_serializing_if = "PropMap::is_empty", flatten)]
132    pub extensions: PropMap<String, serde_json::Value>,
133}
134
135impl PathItem {
136    /// Construct a new [`PathItem`] with provided [`Operation`] mapped to given [`PathItemType`].
137    pub fn new<O: Into<Operation>>(path_item_type: PathItemType, operation: O) -> Self {
138        let operations = PropMap::from_iter(iter::once((path_item_type, operation.into())));
139
140        Self {
141            operations: Operations(operations),
142            ..Default::default()
143        }
144    }
145
146    /// Construct a [`PathItem`] that is purely a reference to another Path Item, e.g. one
147    /// defined under `components.pathItems`.
148    ///
149    /// ```
150    /// # use salvo_oapi::PathItem;
151    /// let item = PathItem::from_ref("#/components/pathItems/PingWebhook");
152    /// ```
153    #[must_use]
154    pub fn from_ref<S: Into<String>>(ref_location: S) -> Self {
155        Self {
156            ref_location: Some(ref_location.into()),
157            ..Default::default()
158        }
159    }
160
161    /// Set the `$ref` location for this [`PathItem`] and return `self`.
162    #[must_use]
163    pub fn ref_location<S: Into<String>>(mut self, ref_location: S) -> Self {
164        self.ref_location = Some(ref_location.into());
165        self
166    }
167    /// Moves all elements from `other` into `self`, leaving `other` empty.
168    ///
169    /// If a key from `other` is already present in `self`, the respective
170    /// value from `self` will be overwritten with the respective value from `other`.
171    pub fn append(&mut self, other: &mut Self) {
172        self.operations.append(&mut other.operations);
173        self.servers.append(&mut other.servers);
174        self.parameters.append(&mut other.parameters);
175        if other.description.is_some() {
176            self.description = other.description.take();
177        }
178        if other.summary.is_some() {
179            self.summary = other.summary.take();
180        }
181        if other.ref_location.is_some() {
182            self.ref_location = other.ref_location.take();
183        }
184        other
185            .extensions
186            .retain(|name, _| !self.extensions.contains_key(name));
187        self.extensions.append(&mut other.extensions);
188    }
189
190    /// Append a new [`Operation`] by [`PathItemType`] to this [`PathItem`]. Operations can
191    /// hold only one operation per [`PathItemType`].
192    #[must_use]
193    pub fn add_operation<O: Into<Operation>>(
194        mut self,
195        path_item_type: PathItemType,
196        operation: O,
197    ) -> Self {
198        self.operations.insert(path_item_type, operation.into());
199        self
200    }
201
202    /// Add or change summary intended to apply all operations in this [`PathItem`].
203    #[must_use]
204    pub fn summary<S: Into<String>>(mut self, summary: S) -> Self {
205        self.summary = Some(summary.into());
206        self
207    }
208
209    /// Add or change optional description intended to apply all operations in this [`PathItem`].
210    /// Description supports markdown syntax.
211    #[must_use]
212    pub fn description<S: Into<String>>(mut self, description: S) -> Self {
213        self.description = Some(description.into());
214        self
215    }
216
217    /// Add list of alternative [`Server`]s to serve all [`Operation`]s in this [`PathItem`]
218    /// overriding the global server array.
219    #[must_use]
220    pub fn servers<I: IntoIterator<Item = Server>>(mut self, servers: I) -> Self {
221        self.servers = Servers(servers.into_iter().collect());
222        self
223    }
224
225    /// Append list of [`Parameter`]s common to all [`Operation`]s to this [`PathItem`].
226    #[must_use]
227    pub fn parameters<I: IntoIterator<Item = Parameter>>(mut self, parameters: I) -> Self {
228        self.parameters = Parameters(parameters.into_iter().collect());
229        self
230    }
231
232    /// Add openapi extensions (`x-something`) for [`PathItem`].
233    #[must_use]
234    pub fn extensions(mut self, extensions: PropMap<String, serde_json::Value>) -> Self {
235        self.extensions = extensions;
236        self
237    }
238}
239
240/// Path item operation type.
241///
242/// Mirrors the HTTP methods supported by the OpenAPI 3.1 [Path Item Object][path_item];
243/// note that the spec deliberately does not list `CONNECT`, so it is intentionally absent
244/// here as well.
245///
246/// [path_item]: https://spec.openapis.org/oas/latest.html#path-item-object
247#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord, Clone, Copy, Debug)]
248#[serde(rename_all = "lowercase")]
249pub enum PathItemType {
250    /// Type mapping for HTTP _GET_ request.
251    Get,
252    /// Type mapping for HTTP _POST_ request.
253    Post,
254    /// Type mapping for HTTP _PUT_ request.
255    Put,
256    /// Type mapping for HTTP _DELETE_ request.
257    Delete,
258    /// Type mapping for HTTP _OPTIONS_ request.
259    Options,
260    /// Type mapping for HTTP _HEAD_ request.
261    Head,
262    /// Type mapping for HTTP _PATCH_ request.
263    Patch,
264    /// Type mapping for HTTP _TRACE_ request.
265    Trace,
266}
267
268#[cfg(test)]
269mod tests {
270    use assert_json_diff::assert_json_eq;
271    use serde_json::json;
272
273    use super::*;
274    use crate::oapi::response::Response;
275
276    #[test]
277    fn test_build_path_item() {
278        let path_item = PathItem::new(PathItemType::Get, Operation::new())
279            .summary("summary")
280            .description("description")
281            .servers(Servers::new())
282            .parameters(Parameters::new());
283
284        assert_json_eq!(
285            path_item,
286            json!({
287                "description": "description",
288                "summary": "summary",
289                "get": {
290                    "responses": {}
291                }
292            })
293        )
294    }
295
296    #[test]
297    fn path_item_ref_serializes_as_dollar_ref() {
298        let item = PathItem::from_ref("#/components/pathItems/PingWebhook");
299        assert_json_eq!(
300            item,
301            json!({ "$ref": "#/components/pathItems/PingWebhook" })
302        );
303    }
304
305    #[test]
306    fn path_item_ref_round_trips_via_serde() {
307        let raw = json!({ "$ref": "#/components/pathItems/PingWebhook" });
308        let item: PathItem = serde_json::from_value(raw.clone()).expect("deserialize");
309        assert_eq!(
310            item.ref_location.as_deref(),
311            Some("#/components/pathItems/PingWebhook")
312        );
313        // Other fields are absent — sibling fields with $ref are spec-undefined, so we
314        // expect an otherwise empty PathItem.
315        assert!(item.summary.is_none());
316        assert!(item.operations.is_empty());
317
318        let reserialized = serde_json::to_value(&item).expect("serialize");
319        assert_eq!(reserialized, raw);
320    }
321
322    #[test]
323    fn path_item_ref_setter_overrides_plain_construction() {
324        let item = PathItem::new(PathItemType::Get, Operation::new())
325            .ref_location("#/components/pathItems/Other");
326
327        let value = serde_json::to_value(&item).expect("serialize");
328        assert_eq!(
329            value["$ref"],
330            json!("#/components/pathItems/Other"),
331            "$ref should serialize when set"
332        );
333        // The previously-attached operation is still there in this object form;
334        // consumers will resolve $ref and ignore siblings, but the type doesn't
335        // suppress them.
336        assert!(value.get("get").is_some());
337    }
338
339    #[test]
340    fn path_item_parameters_serialize_as_named_field() {
341        use crate::Parameter;
342
343        let path_item = PathItem::new(PathItemType::Get, Operation::new())
344            .parameters([Parameter::new("id").location(crate::ParameterIn::Path)]);
345
346        assert_json_eq!(
347            path_item,
348            json!({
349                "parameters": [
350                    {
351                        "name": "id",
352                        "in": "path",
353                        "required": true
354                    }
355                ],
356                "get": {
357                    "responses": {}
358                }
359            })
360        );
361    }
362
363    #[test]
364    fn test_path_item_append() {
365        let mut path_item = PathItem::new(
366            PathItemType::Get,
367            Operation::new().add_response("200", Response::new("Get success")),
368        );
369        let mut other_path_item = PathItem::new(
370            PathItemType::Post,
371            Operation::new().add_response("200", Response::new("Post success")),
372        )
373        .description("description")
374        .summary("summary");
375        path_item.append(&mut other_path_item);
376
377        assert_json_eq!(
378            path_item,
379            json!({
380                "description": "description",
381                "summary": "summary",
382                "get": {
383                    "responses": {
384                        "200": {
385                            "description": "Get success"
386                        }
387                    }
388                },
389                "post": {
390                    "responses": {
391                        "200": {
392                            "description": "Post success"
393                        }
394                    }
395                }
396            })
397        )
398    }
399
400    #[test]
401    fn test_path_item_add_operation() {
402        let path_item = PathItem::new(
403            PathItemType::Get,
404            Operation::new().add_response("200", Response::new("Get success")),
405        )
406        .add_operation(
407            PathItemType::Post,
408            Operation::new().add_response("200", Response::new("Post success")),
409        );
410
411        assert_json_eq!(
412            path_item,
413            json!({
414                "get": {
415                    "responses": {
416                        "200": {
417                            "description": "Get success"
418                        }
419                    }
420                },
421                "post": {
422                    "responses": {
423                        "200": {
424                            "description": "Post success"
425                        }
426                    }
427                }
428            })
429        )
430    }
431
432    #[test]
433    fn test_paths_extend() {
434        let mut paths = Paths::new().path(
435            "/api/do_something",
436            PathItem::new(
437                PathItemType::Get,
438                Operation::new().add_response("200", Response::new("Get success")),
439            ),
440        );
441        paths.extend([(
442            "/api/do_something",
443            PathItem::new(
444                PathItemType::Post,
445                Operation::new().add_response("200", Response::new("Post success")),
446            )
447            .summary("summary")
448            .description("description"),
449        )]);
450
451        assert_json_eq!(
452            paths,
453            json!({
454                "/api/do_something": {
455                    "description": "description",
456                    "summary": "summary",
457                    "get": {
458                        "responses": {
459                            "200": {
460                                "description": "Get success"
461                            }
462                        }
463                    },
464                    "post": {
465                        "responses": {
466                            "200": {
467                                "description": "Post success"
468                            }
469                        }
470                    }
471                }
472            })
473        );
474    }
475
476    #[test]
477    fn test_paths_deref() {
478        let paths = Paths::new();
479        assert_eq!(0, paths.len());
480    }
481}