Skip to main content

openapiv3_resolve/
reference.rs

1use crate::ResolveError;
2use std::borrow::Cow;
3use std::fmt;
4
5/// Declares the sections in one place so the variant list, [`Section::ALL`]
6/// and the wire spellings cannot drift apart.
7macro_rules! sections {
8    ($( $(#[$meta:meta])* $variant:ident => $pointer:literal ),+ $(,)?) => {
9        /// A section of an OpenAPI document that `$ref` pointers can name.
10        ///
11        /// The string form is the *wire* spelling used in a document
12        /// (`components/requestBodies`), which is not the same as the Rust
13        /// field name on [`openapiv3::Components`] (`request_bodies`).
14        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15        #[non_exhaustive]
16        pub enum Section {
17            $( $(#[$meta])* $variant, )+
18        }
19
20        impl Section {
21            /// Every section this crate can resolve into.
22            pub const ALL: &'static [Section] = &[ $( Section::$variant, )+ ];
23
24            /// The pointer body for this section, without the leading `#/`.
25            pub const fn as_pointer(self) -> &'static str {
26                match self { $( Self::$variant => $pointer, )+ }
27            }
28        }
29    };
30}
31
32sections! {
33    /// `#/components/callbacks`
34    Callbacks => "components/callbacks",
35    /// `#/components/examples`
36    Examples => "components/examples",
37    /// `#/components/headers`
38    Headers => "components/headers",
39    /// `#/components/links`
40    Links => "components/links",
41    /// `#/components/parameters`
42    Parameters => "components/parameters",
43    /// `#/components/requestBodies`
44    RequestBodies => "components/requestBodies",
45    /// `#/components/responses`
46    Responses => "components/responses",
47    /// `#/components/schemas`
48    Schemas => "components/schemas",
49    /// `#/components/securitySchemes`
50    SecuritySchemes => "components/securitySchemes",
51    /// `#/paths`
52    Paths => "paths",
53}
54
55impl Section {
56    fn from_components_segment(segment: &str) -> Option<Self> {
57        Self::ALL
58            .iter()
59            .copied()
60            .find(|section| section.as_pointer().strip_prefix("components/") == Some(segment))
61    }
62}
63
64impl fmt::Display for Section {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str(self.as_pointer())
67    }
68}
69
70/// A parsed `$ref` pointer: the section it names and the name within it.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub(crate) struct ComponentRef<'a> {
73    pub section: Section,
74    /// The component name, percent-decoded and JSON pointer unescaped.
75    pub name: Cow<'a, str>,
76}
77
78impl<'a> ComponentRef<'a> {
79    /// Parses a `$ref` string such as `#/components/schemas/Pet`.
80    ///
81    /// A `$ref` is a URI reference, so the fragment is percent-decoded before
82    /// it is read as a JSON pointer: `#/paths/~1pets~1%7Bid%7D` names the path
83    /// `/pets/{id}`. Borrows from `reference` when neither step has anything
84    /// to do, which is every `#/components/...` pointer in practice.
85    pub(crate) fn parse(reference: &'a str) -> Result<Self, ResolveError> {
86        match percent_decode(reference) {
87            Cow::Borrowed(decoded) => Self::parse_decoded(decoded, reference),
88            Cow::Owned(decoded) => {
89                let parsed = Self::parse_decoded(&decoded, reference)?;
90                Ok(ComponentRef {
91                    section: parsed.section,
92                    name: Cow::Owned(parsed.name.into_owned()),
93                })
94            }
95        }
96    }
97
98    fn parse_decoded<'s>(
99        decoded: &'s str,
100        reference: &str,
101    ) -> Result<ComponentRef<'s>, ResolveError> {
102        let Some(pointer) = decoded.strip_prefix("#/") else {
103            return Err(match decoded.split_once('#') {
104                Some((document, _)) if !document.is_empty() => ResolveError::ExternalDocument {
105                    document: document.to_owned(),
106                },
107                _ => ResolveError::NotALocalReference {
108                    reference: reference.to_owned(),
109                },
110            });
111        };
112
113        let malformed = || ResolveError::MalformedPointer {
114            reference: reference.to_owned(),
115        };
116        let (root, rest) = pointer.split_once('/').ok_or_else(malformed)?;
117
118        let (section, name) = match root {
119            "components" => {
120                let (segment, name) = rest.split_once('/').ok_or_else(malformed)?;
121                let section = Section::from_components_segment(segment).ok_or_else(|| {
122                    ResolveError::UnknownSection {
123                        section: segment.to_owned(),
124                    }
125                })?;
126                (section, name)
127            }
128            root if root == Section::Paths.as_pointer() => (Section::Paths, rest),
129            other => {
130                return Err(ResolveError::UnsupportedRootSection {
131                    section: other.to_owned(),
132                })
133            }
134        };
135
136        if name.contains('/') {
137            return Err(ResolveError::PointerTooDeep {
138                reference: reference.to_owned(),
139            });
140        }
141
142        Ok(ComponentRef {
143            section,
144            name: unescape(name),
145        })
146    }
147}
148
149/// Resolves `%XX` escapes, leaving anything that is not a complete hex pair
150/// alone. Returns the input untouched if decoding produces invalid UTF-8.
151fn percent_decode(input: &str) -> Cow<'_, str> {
152    if !input.contains('%') {
153        return Cow::Borrowed(input);
154    }
155
156    let mut decoded = Vec::with_capacity(input.len());
157    let mut bytes = input.bytes();
158    while let Some(byte) = bytes.next() {
159        if byte != b'%' {
160            decoded.push(byte);
161            continue;
162        }
163        let mut probe = bytes.clone();
164        match (
165            probe.next().and_then(hex_digit),
166            probe.next().and_then(hex_digit),
167        ) {
168            (Some(high), Some(low)) => {
169                decoded.push(high * 16 + low);
170                bytes = probe;
171            }
172            _ => decoded.push(b'%'),
173        }
174    }
175
176    match String::from_utf8(decoded) {
177        Ok(decoded) => Cow::Owned(decoded),
178        Err(_) => Cow::Borrowed(input),
179    }
180}
181
182fn hex_digit(byte: u8) -> Option<u8> {
183    char::from(byte).to_digit(16).map(|digit| digit as u8)
184}
185
186/// Resolves RFC 6901 escapes in one pass. `~1` becomes `/` and `~0` becomes
187/// `~`; because it is a single pass, `~01` yields `~1` rather than `/`.
188fn unescape(segment: &str) -> Cow<'_, str> {
189    if !segment.contains('~') {
190        return Cow::Borrowed(segment);
191    }
192
193    let mut unescaped = String::with_capacity(segment.len());
194    let mut characters = segment.chars();
195    while let Some(character) = characters.next() {
196        if character != '~' {
197            unescaped.push(character);
198            continue;
199        }
200        match characters.next() {
201            Some('0') => unescaped.push('~'),
202            Some('1') => unescaped.push('/'),
203            Some(other) => {
204                unescaped.push('~');
205                unescaped.push(other);
206            }
207            None => unescaped.push('~'),
208        }
209    }
210
211    Cow::Owned(unescaped)
212}
213
214/// Inverse of [`unescape`]: spells `name` as a single JSON pointer segment.
215pub(crate) fn escape(name: &str) -> Cow<'_, str> {
216    if !name.contains(['~', '/']) {
217        return Cow::Borrowed(name);
218    }
219    Cow::Owned(name.replace('~', "~0").replace('/', "~1"))
220}
221
222#[cfg(test)]
223mod tests {
224    #![allow(clippy::unwrap_used)]
225
226    use super::*;
227
228    #[test]
229    fn every_section_parses_back_from_its_own_pointer() {
230        // Drives `Section::ALL`, so a new variant that `parse` does not handle
231        // fails here rather than becoming quietly unresolvable.
232        for section in Section::ALL.iter().copied() {
233            let reference = format!("#/{}/Thing", section.as_pointer());
234            let parsed = ComponentRef::parse(&reference);
235            assert_eq!(
236                parsed,
237                Ok(ComponentRef {
238                    section,
239                    name: Cow::Borrowed("Thing")
240                })
241            );
242        }
243    }
244
245    #[test]
246    fn section_pointers_use_wire_spelling_not_rust_field_names() {
247        assert_eq!(
248            Section::RequestBodies.as_pointer(),
249            "components/requestBodies"
250        );
251        assert_eq!(
252            Section::SecuritySchemes.as_pointer(),
253            "components/securitySchemes"
254        );
255    }
256
257    #[test]
258    fn borrows_the_name_when_there_is_nothing_to_decode() {
259        let parsed = ComponentRef::parse("#/components/schemas/Pet").unwrap();
260        assert!(matches!(parsed.name, Cow::Borrowed("Pet")));
261    }
262
263    #[test]
264    fn unescapes_a_slash_in_a_path_name() {
265        let parsed = ComponentRef::parse("#/paths/~1pets").unwrap();
266        assert_eq!(parsed.name, "/pets");
267    }
268
269    #[test]
270    fn unescapes_tilde_after_slash_so_escaped_escapes_survive() {
271        // `~01` must become `~1`, not `/`.
272        let parsed = ComponentRef::parse("#/components/schemas/a~01b").unwrap();
273        assert_eq!(parsed.name, "a~1b");
274    }
275
276    #[test]
277    fn keeps_a_trailing_tilde_that_escapes_nothing() {
278        let parsed = ComponentRef::parse("#/components/schemas/a~").unwrap();
279        assert_eq!(parsed.name, "a~");
280    }
281
282    #[test]
283    fn percent_decodes_the_fragment_before_reading_it_as_a_pointer() {
284        // `{` and `}` are not legal fragment characters, so this is the
285        // conformant spelling of a reference to the path `/pets/{id}`.
286        let parsed = ComponentRef::parse("#/paths/~1pets~1%7Bid%7D").unwrap();
287        assert_eq!(parsed.name, "/pets/{id}");
288    }
289
290    #[test]
291    fn percent_decodes_multi_byte_utf8() {
292        let parsed = ComponentRef::parse("#/components/schemas/caf%C3%A9").unwrap();
293        assert_eq!(parsed.name, "café");
294    }
295
296    #[test]
297    fn leaves_an_incomplete_percent_escape_alone() {
298        let parsed = ComponentRef::parse("#/components/schemas/100%25%zz").unwrap();
299        assert_eq!(parsed.name, "100%%zz");
300    }
301
302    #[test]
303    fn escape_round_trips_through_unescape() {
304        for name in ["plain", "/pets/{id}", "a~1b", "~/", "~0"] {
305            assert_eq!(unescape(&escape(name)), name);
306        }
307    }
308
309    #[test]
310    fn escape_borrows_a_name_with_nothing_to_escape() {
311        assert!(matches!(escape("Pet"), Cow::Borrowed("Pet")));
312    }
313
314    #[test]
315    fn rejects_a_reference_without_the_local_prefix() {
316        assert_eq!(
317            ComponentRef::parse("Pet"),
318            Err(ResolveError::NotALocalReference {
319                reference: "Pet".to_owned()
320            })
321        );
322    }
323
324    #[test]
325    fn rejects_a_reference_into_another_document() {
326        assert_eq!(
327            ComponentRef::parse("common.yaml#/components/schemas/Pet"),
328            Err(ResolveError::ExternalDocument {
329                document: "common.yaml".to_owned()
330            })
331        );
332    }
333
334    #[test]
335    fn rejects_a_pointer_with_too_few_segments() {
336        assert_eq!(
337            ComponentRef::parse("#/components/schemas"),
338            Err(ResolveError::MalformedPointer {
339                reference: "#/components/schemas".to_owned()
340            })
341        );
342    }
343
344    #[test]
345    fn rejects_a_root_section_that_cannot_hold_targets() {
346        assert_eq!(
347            ComponentRef::parse("#/info/title"),
348            Err(ResolveError::UnsupportedRootSection {
349                section: "info".to_owned()
350            })
351        );
352    }
353
354    #[test]
355    fn rejects_a_rust_field_name_used_as_a_section() {
356        assert_eq!(
357            ComponentRef::parse("#/components/request_bodies/CreatePet"),
358            Err(ResolveError::UnknownSection {
359                section: "request_bodies".to_owned()
360            })
361        );
362    }
363
364    #[test]
365    fn rejects_a_pointer_into_the_middle_of_a_component() {
366        let reference = "#/components/schemas/Pet/properties/name";
367        assert_eq!(
368            ComponentRef::parse(reference),
369            Err(ResolveError::PointerTooDeep {
370                reference: reference.to_owned()
371            })
372        );
373    }
374}