Skip to main content

salvo_oapi/
routing.rs

1use std::any::TypeId;
2use std::collections::{BTreeSet, HashMap};
3use std::sync::{LazyLock, RwLock};
4
5use salvo_core::Router;
6use salvo_core::http::Method;
7use salvo_core::routing::FilterInfo;
8
9use crate::SecurityRequirement;
10use crate::path::PathItemType;
11
12fn normalize_oapi_path(path: &str) -> String {
13    let mut normalized = String::with_capacity(path.len());
14    let mut chars = path.char_indices().peekable();
15
16    while let Some((start, ch)) = chars.next() {
17        if ch != '{' {
18            normalized.push(ch);
19            continue;
20        }
21        // Keep escaped literal braces (`{{`) as-is.
22        if chars.peek().map(|(_, next)| *next) == Some('{') {
23            normalized.push('{');
24            normalized.push('{');
25            chars.next();
26            continue;
27        }
28
29        let content_start = start + ch.len_utf8();
30        let mut braces_depth = 0usize;
31        let mut escaping = false;
32        let mut param_end = None;
33
34        for (idx, current) in chars.by_ref() {
35            if escaping {
36                escaping = false;
37                continue;
38            }
39            match current {
40                '\\' => escaping = true,
41                '{' => braces_depth += 1,
42                '}' => {
43                    if braces_depth == 0 {
44                        param_end = Some(idx);
45                        break;
46                    }
47                    braces_depth -= 1;
48                }
49                _ => {}
50            }
51        }
52
53        if let Some(param_end) = param_end {
54            let Some(content) = path.get(content_start..param_end) else {
55                break;
56            };
57            if let Some(name_end) = content.find([':', '|']) {
58                normalized.push('{');
59                let Some(name) = content.get(..name_end) else {
60                    break;
61                };
62                normalized.push_str(name);
63                normalized.push('}');
64            } else {
65                normalized.push('{');
66                normalized.push_str(content);
67                normalized.push('}');
68            }
69        } else {
70            if let Some(rest) = path.get(start..) {
71                normalized.push_str(rest);
72            }
73            break;
74        }
75    }
76    normalized
77}
78
79/// Where an operation discovered on a route belongs inside a Path Item Object.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub(crate) enum OperationSlot {
82    /// A method with a dedicated Path Item field.
83    Standard(PathItemType),
84    /// A method with no dedicated field, emitted under `additionalOperations`. The value is
85    /// the method name with the capitalization sent in the request. Requires OpenAPI 3.2.
86    Additional(String),
87}
88
89#[derive(Debug, Default)]
90pub(crate) struct NormNode {
91    // pub(crate) router_id: usize,
92    pub(crate) handler_type_id: Option<TypeId>,
93    pub(crate) handler_type_name: Option<&'static str>,
94    pub(crate) method: Option<OperationSlot>,
95    pub(crate) path: Option<String>,
96    pub(crate) children: Vec<Self>,
97    pub(crate) metadata: Metadata,
98}
99
100impl NormNode {
101    pub(crate) fn new(router: &Router, inherited_metadata: Metadata) -> Self {
102        let mut node = Self {
103            // router_id: router.id,
104            metadata: inherited_metadata,
105            ..Self::default()
106        };
107        let registry = METADATA_REGISTRY
108            .read()
109            .expect("failed to lock METADATA_REGISTRY for read");
110        if let Some(metadata) = registry.get(&router.id) {
111            node.metadata.tags.extend(metadata.tags.iter().cloned());
112            node.metadata
113                .securities
114                .extend(metadata.securities.iter().cloned());
115        }
116
117        for filter in router.filters() {
118            match filter.info() {
119                FilterInfo::Path(path) => {
120                    node.path = Some(normalize_oapi_path(&path));
121                }
122                FilterInfo::Method(method) => {
123                    // Methods with a dedicated Path Item field map to `PathItemType`;
124                    // everything else (CONNECT and custom/extension methods) goes to
125                    // `additionalOperations`, which requires OpenAPI 3.2. Whether such a
126                    // slot is actually emitted is decided later, when the document version
127                    // is known.
128                    let item = match method {
129                        Method::GET => OperationSlot::Standard(PathItemType::Get),
130                        Method::POST => OperationSlot::Standard(PathItemType::Post),
131                        Method::PUT => OperationSlot::Standard(PathItemType::Put),
132                        Method::DELETE => OperationSlot::Standard(PathItemType::Delete),
133                        Method::HEAD => OperationSlot::Standard(PathItemType::Head),
134                        Method::OPTIONS => OperationSlot::Standard(PathItemType::Options),
135                        Method::TRACE => OperationSlot::Standard(PathItemType::Trace),
136                        Method::PATCH => OperationSlot::Standard(PathItemType::Patch),
137                        Method::QUERY => OperationSlot::Standard(PathItemType::Query),
138                        other => OperationSlot::Additional(other.as_str().to_owned()),
139                    };
140                    // A standard method never loses to a custom one, so combining a
141                    // standard method filter with a custom one does not erase the standard
142                    // mapping (the previous string-parsing path had the same behavior via
143                    // its `_ => {}` arm).
144                    if matches!(item, OperationSlot::Standard(_))
145                        || !matches!(node.method, Some(OperationSlot::Standard(_)))
146                    {
147                        node.method = Some(item);
148                    }
149                }
150                // Other filter kinds (Scheme/Host/Port/Other) do not carry
151                // information that maps to OpenAPI path items.
152                _ => {}
153            }
154        }
155        node.handler_type_id = router.goal.as_ref().map(|h| h.type_id());
156        node.handler_type_name = router.goal.as_ref().map(|h| h.type_name());
157        let routers = router.routers();
158        if !routers.is_empty() {
159            for router in routers {
160                node.children.push(Self::new(router, node.metadata.clone()));
161            }
162        }
163        node
164    }
165}
166
167/// A component for save router metadata.
168type MetadataMap = RwLock<HashMap<usize, Metadata>>;
169static METADATA_REGISTRY: LazyLock<MetadataMap> = LazyLock::new(MetadataMap::default);
170
171/// Router extension trait for openapi metadata.
172pub trait RouterExt {
173    /// Add security requirement to the router.
174    ///
175    /// All endpoints in the router and its descendants will inherit this security requirement.
176    #[must_use]
177    fn oapi_security(self, security: SecurityRequirement) -> Self;
178
179    /// Add security requirements to the router.
180    ///
181    /// All endpoints in the router and its descendants will inherit these security requirements.
182    #[must_use]
183    fn oapi_securities<I>(self, security: I) -> Self
184    where
185        I: IntoIterator<Item = SecurityRequirement>;
186
187    /// Add tag to the router.
188    ///
189    /// All endpoints in the router and its descendants will inherit this tag.
190    #[must_use]
191    fn oapi_tag(self, tag: impl Into<String>) -> Self;
192
193    /// Add tags to the router.
194    ///
195    /// All endpoints in the router and its descendants will inherit these tags.
196    #[must_use]
197    fn oapi_tags<I, V>(self, tags: I) -> Self
198    where
199        I: IntoIterator<Item = V>,
200        V: Into<String>;
201}
202
203impl RouterExt for Router {
204    fn oapi_security(self, security: SecurityRequirement) -> Self {
205        let mut guard = METADATA_REGISTRY
206            .write()
207            .expect("failed to lock METADATA_REGISTRY for write");
208        let metadata = guard.entry(self.id).or_default();
209        metadata.securities.push(security);
210        self
211    }
212    fn oapi_securities<I>(self, iter: I) -> Self
213    where
214        I: IntoIterator<Item = SecurityRequirement>,
215    {
216        let mut guard = METADATA_REGISTRY
217            .write()
218            .expect("failed to lock METADATA_REGISTRY for write");
219        let metadata = guard.entry(self.id).or_default();
220        metadata.securities.extend(iter);
221        self
222    }
223    fn oapi_tag(self, tag: impl Into<String>) -> Self {
224        let mut guard = METADATA_REGISTRY
225            .write()
226            .expect("failed to lock METADATA_REGISTRY for write");
227        let metadata = guard.entry(self.id).or_default();
228        metadata.tags.insert(tag.into());
229        self
230    }
231    fn oapi_tags<I, V>(self, iter: I) -> Self
232    where
233        I: IntoIterator<Item = V>,
234        V: Into<String>,
235    {
236        let mut guard = METADATA_REGISTRY
237            .write()
238            .expect("failed to lock METADATA_REGISTRY for write");
239        let metadata = guard.entry(self.id).or_default();
240        metadata.tags.extend(iter.into_iter().map(Into::into));
241        self
242    }
243}
244
245#[non_exhaustive]
246#[derive(Default, Clone, Debug)]
247pub(crate) struct Metadata {
248    pub(crate) tags: BTreeSet<String>,
249    pub(crate) securities: Vec<SecurityRequirement>,
250}
251
252#[cfg(test)]
253mod tests {
254    use super::normalize_oapi_path;
255
256    #[test]
257    fn normalize_braced_path_constraints() {
258        assert_eq!(normalize_oapi_path("/posts/{id}"), "/posts/{id}");
259        assert_eq!(normalize_oapi_path("/posts/{id:num}"), "/posts/{id}");
260        assert_eq!(
261            normalize_oapi_path("/posts/{id:num(3..=10)}"),
262            "/posts/{id}"
263        );
264        assert_eq!(normalize_oapi_path(r"/posts/{id|\d+}"), "/posts/{id}");
265        assert_eq!(normalize_oapi_path("/posts/{id|[a-z]{2}}"), "/posts/{id}");
266        assert_eq!(
267            normalize_oapi_path("/posts/article_{id:num}"),
268            "/posts/article_{id}"
269        );
270    }
271}