Skip to main content

typed_openapi/
schema.rs

1//! Following `$ref`s, and deciding what one schema is worth on a command line.
2//!
3//! *Requires the `document` feature.*
4//!
5//! This is the whole of what this crate understands about JSON Schema: a schema
6//! either fits on one flag — it is a [`Scalar`] — or it does not, and then the
7//! body goes through a file. Nothing richer is modelled, because nothing richer
8//! has a command-line spelling.
9//!
10//! A `$ref` is followed before the schema is read, so a property pointed at a
11//! named schema carries that schema's rules onto the flag.
12
13use openapiv3::{
14    Components, IntegerType, NumberType, ReferenceOr, Schema, SchemaKind, StringType, Type,
15};
16use thiserror::Error;
17
18use crate::scalar::{Bounds, Limit, Scalar, Text};
19
20/// A `$ref` that does not lead anywhere.
21#[derive(Debug, Clone, Error, PartialEq, Eq)]
22#[error("`{reference}` does not resolve")]
23pub struct RefError {
24    pub reference: String,
25}
26
27/// How deep a chain of `$ref`s may go before it is called a cycle.
28const MAX_HOPS: usize = 8;
29
30/// Follow `#/components/<section>/<name>` hops until an item appears.
31pub fn resolve<'c, T>(
32    value: &'c ReferenceOr<T>,
33    section: impl Fn(&str) -> Option<&'c ReferenceOr<T>>,
34    name: &str,
35) -> Result<&'c T, RefError> {
36    let prefix = format!("#/components/{name}/");
37    let mut current = value;
38    for _ in 0..MAX_HOPS {
39        match current {
40            ReferenceOr::Item(item) => return Ok(item),
41            ReferenceOr::Reference { reference } => {
42                current = reference
43                    .strip_prefix(&prefix)
44                    .and_then(&section)
45                    .ok_or_else(|| RefError {
46                        reference: reference.clone(),
47                    })?;
48            }
49        }
50    }
51    Err(RefError {
52        reference: "a reference cycle".to_owned(),
53    })
54}
55
56pub fn resolve_schema<'c>(
57    schema: &'c ReferenceOr<Schema>,
58    components: &'c Components,
59) -> Result<&'c Schema, RefError> {
60    resolve(schema, |key| components.schemas.get(key), "schemas")
61}
62
63/// The schema that states the rules: `$ref` hops followed, and a single-element
64/// `allOf` read as the element it wraps.
65///
66/// OpenAPI 3.0 has a `$ref` erase everything written beside it, so a document
67/// that both names a rule and says something about the field pointing at it has
68/// exactly one spelling for the pair — the reference wrapped in an `allOf` of
69/// one element, with the sentence outside the wrapper. That wrapper composes
70/// nothing; it is a `$ref` that kept its siblings, and what it describes is
71/// what its element describes.
72///
73/// One element is where this stops. An `allOf` of two schemas is a real
74/// composition, and a composition is not a scalar: it comes back as itself, and
75/// [`scalar_of`] answers `None` for it.
76fn stated<'c>(
77    schema: &'c ReferenceOr<Schema>,
78    components: &'c Components,
79) -> Result<&'c Schema, RefError> {
80    let mut current = resolve_schema(schema, components)?;
81    for _ in 0..MAX_HOPS {
82        let SchemaKind::AllOf { all_of } = &current.schema_kind else {
83            return Ok(current);
84        };
85        let [only] = all_of.as_slice() else {
86            return Ok(current);
87        };
88        current = resolve_schema(only, components)?;
89    }
90    Err(RefError {
91        reference: "a reference cycle".to_owned(),
92    })
93}
94
95/// `Some(scalar)` when this schema fits on one flag, `None` when it does not.
96pub fn scalar_of(
97    schema: &ReferenceOr<Schema>,
98    components: &Components,
99) -> Result<Option<Scalar>, RefError> {
100    let schema = stated(schema, components)?;
101    let SchemaKind::Type(ty) = &schema.schema_kind else {
102        return Ok(None);
103    };
104    Ok(match ty {
105        Type::String(s) => Some(string_scalar(s)),
106        Type::Number(n) => Some(Scalar::Number(number_bounds(n))),
107        Type::Integer(i) => Some(Scalar::Integer(integer_bounds(i))),
108        Type::Boolean(_) => Some(Scalar::Boolean),
109        Type::Object(_) | Type::Array(_) => None,
110    })
111}
112
113/// What a property says about itself, and what it inherits by pointing
114/// somewhere else.
115///
116/// The field's own sentence wins: it is about this field, where the named
117/// schema's is about every field that shares the rule. A field that says
118/// nothing of its own takes the named schema's, which is better than nothing
119/// and is all a bare `$ref` can leave behind.
120pub fn description_of(
121    schema: &ReferenceOr<Schema>,
122    components: &Components,
123) -> Result<Option<String>, RefError> {
124    let own = &resolve_schema(schema, components)?.schema_data.description;
125    if own.is_some() {
126        return Ok(own.clone());
127    }
128    Ok(stated(schema, components)?.schema_data.description.clone())
129}
130
131/// An enumeration completes; everything else is text carrying the rules the
132/// document states about it.
133///
134/// `format` is not read at all. A format is a name for a rule, and a name is
135/// not a rule: the document that says what an amount looks like says so with
136/// `pattern`, which every consumer of the document can run.
137fn string_scalar(s: &StringType) -> Scalar {
138    let choices: Vec<String> = s.enumeration.iter().flatten().cloned().collect();
139    if choices.is_empty() {
140        Scalar::Text(Text {
141            pattern: s.pattern.clone(),
142            min_length: s.min_length,
143            max_length: s.max_length,
144        })
145    } else {
146        Scalar::Choice(choices)
147    }
148}
149
150fn number_bounds(n: &NumberType) -> Bounds<f64> {
151    Bounds {
152        low: limit(n.minimum, n.exclusive_minimum),
153        high: limit(n.maximum, n.exclusive_maximum),
154        multiple_of: n.multiple_of,
155    }
156}
157
158fn integer_bounds(i: &IntegerType) -> Bounds<i64> {
159    Bounds {
160        low: limit(i.minimum, i.exclusive_minimum),
161        high: limit(i.maximum, i.exclusive_maximum),
162        multiple_of: i.multiple_of,
163    }
164}
165
166/// One end of a range. OpenAPI 3.0 states exclusivity as a flag beside the
167/// number, so a flag with no number beside it states nothing.
168fn limit<T>(value: Option<T>, exclusive: bool) -> Option<Limit<T>> {
169    value.map(|value| {
170        if exclusive {
171            Limit::Exclusive(value)
172        } else {
173            Limit::Inclusive(value)
174        }
175    })
176}
177
178/// Whether this is a media type at all: `type/subtype`, with optional
179/// parameters after a `;`.
180///
181/// The question every other one here presumes. A `content` key with no `/` in
182/// it names nothing a server can read, so a body sent under it cannot arrive —
183/// which makes it a shape to refuse rather than one to carry.
184///
185/// Only the essence is held to a grammar, and it is RFC 9110's own: two
186/// non-empty `token`s either side of one `/`. That is deliberately the whole
187/// of the rule. The parameters after the `;` are not checked, because a
188/// parameter value may be a quoted string carrying a `;` or a `/`, and a rule
189/// strict enough to judge one would refuse bodies servers accept — the defect
190/// this question exists to catch is a key that was never a media type, not a
191/// parameter spelled unusually. The token rule is the wire's, not a register
192/// of types this crate knows: `application/x-www-form-urlencoded`,
193/// `application/vnd.api+json` and every vendor type anyone coins pass it.
194#[must_use]
195pub fn is_media_type(media_type: &str) -> bool {
196    essence(media_type)
197        .split_once('/')
198        .is_some_and(|(ty, subtype)| is_token(ty) && is_token(subtype))
199}
200
201/// RFC 9110's `token`: one or more of the characters a field value carries
202/// unquoted. `/` is not one of them, which is what makes the split above the
203/// whole of the parse.
204fn is_token(word: &str) -> bool {
205    !word.is_empty()
206        && word
207            .bytes()
208            .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b))
209}
210
211/// `application/json`, `application/merge-patch+json`, and anything with
212/// parameters after the essence.
213#[must_use]
214pub fn is_json(media_type: &str) -> bool {
215    essence(media_type) == "application/json" || essence(media_type).ends_with("+json")
216}
217
218/// A media type this crate can assemble from `--file` and `--field` parts.
219#[must_use]
220pub fn is_multipart(media_type: &str) -> bool {
221    essence(media_type) == "multipart/form-data"
222}
223
224fn essence(media_type: &str) -> String {
225    media_type
226        .split(';')
227        .next()
228        .unwrap_or(media_type)
229        .trim()
230        .to_ascii_lowercase()
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    /// The grammar has to be wide enough to admit everything a server reads
238    /// and narrow enough to catch a key that names no type at all. The first
239    /// half is the one worth guarding: a rule that turned away
240    /// `application/x-www-form-urlencoded` would be worse than the defect.
241    #[test]
242    fn a_media_type_is_a_type_and_a_subtype_and_a_bare_word_is_neither() {
243        assert!(is_media_type("application/pdf"));
244        assert!(is_media_type("text/csv; charset=utf-8"));
245        assert!(is_media_type("application/vnd.api+json"));
246        assert!(is_media_type("multipart/form-data; boundary=x"));
247        assert!(is_media_type("application/x-www-form-urlencoded"));
248        // A parameter is the vendor's to spell, and is read no further than
249        // the `;` that starts it.
250        assert!(is_media_type(r#"multipart/form-data; boundary="a/b;c""#));
251
252        // The vendor's misspelling: one word, and a word is not a type over a
253        // subtype.
254        assert!(!is_media_type("form-data"));
255        assert!(!is_media_type(""));
256        assert!(!is_media_type("application/"));
257        assert!(!is_media_type("/json"));
258        assert!(!is_media_type("application/ld/json"));
259        // A space is not a `token` character, so neither of these is one type.
260        assert!(!is_media_type("application/json charset=utf-8"));
261    }
262
263    #[test]
264    fn json_is_recognised_through_suffixes_and_parameters() {
265        assert!(is_json("application/json"));
266        assert!(is_json("application/json; charset=utf-8"));
267        assert!(is_json("application/merge-patch+json"));
268        assert!(!is_json("form-data"));
269        assert!(!is_json("multipart/form-data"));
270    }
271
272    #[test]
273    fn only_the_correctly_spelled_multipart_type_is_assembled() {
274        assert!(is_multipart("multipart/form-data"));
275        assert!(is_multipart("Multipart/Form-Data; boundary=x"));
276        // The vendor's misspelling. It is not multipart, and the CLI says so
277        // rather than guessing what the vendor meant.
278        assert!(!is_multipart("form-data"));
279    }
280}