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/// `Some(scalar)` when this schema fits on one flag, `None` when it does not.
64pub fn scalar_of(
65    schema: &ReferenceOr<Schema>,
66    components: &Components,
67) -> Result<Option<Scalar>, RefError> {
68    let schema = resolve_schema(schema, components)?;
69    let SchemaKind::Type(ty) = &schema.schema_kind else {
70        return Ok(None);
71    };
72    Ok(match ty {
73        Type::String(s) => Some(string_scalar(s)),
74        Type::Number(n) => Some(Scalar::Number(number_bounds(n))),
75        Type::Integer(i) => Some(Scalar::Integer(integer_bounds(i))),
76        Type::Boolean(_) => Some(Scalar::Boolean),
77        Type::Object(_) | Type::Array(_) => None,
78    })
79}
80
81/// An enumeration completes; everything else is text carrying the rules the
82/// document states about it.
83///
84/// `format` is not read at all. A format is a name for a rule, and a name is
85/// not a rule: the document that says what an amount looks like says so with
86/// `pattern`, which every consumer of the document can run.
87fn string_scalar(s: &StringType) -> Scalar {
88    let choices: Vec<String> = s.enumeration.iter().flatten().cloned().collect();
89    if choices.is_empty() {
90        Scalar::Text(Text {
91            pattern: s.pattern.clone(),
92            min_length: s.min_length,
93            max_length: s.max_length,
94        })
95    } else {
96        Scalar::Choice(choices)
97    }
98}
99
100fn number_bounds(n: &NumberType) -> Bounds<f64> {
101    Bounds {
102        low: limit(n.minimum, n.exclusive_minimum),
103        high: limit(n.maximum, n.exclusive_maximum),
104        multiple_of: n.multiple_of,
105    }
106}
107
108fn integer_bounds(i: &IntegerType) -> Bounds<i64> {
109    Bounds {
110        low: limit(i.minimum, i.exclusive_minimum),
111        high: limit(i.maximum, i.exclusive_maximum),
112        multiple_of: i.multiple_of,
113    }
114}
115
116/// One end of a range. OpenAPI 3.0 states exclusivity as a flag beside the
117/// number, so a flag with no number beside it states nothing.
118fn limit<T>(value: Option<T>, exclusive: bool) -> Option<Limit<T>> {
119    value.map(|value| {
120        if exclusive {
121            Limit::Exclusive(value)
122        } else {
123            Limit::Inclusive(value)
124        }
125    })
126}
127
128/// `application/json`, `application/merge-patch+json`, and anything with
129/// parameters after the essence.
130#[must_use]
131pub fn is_json(media_type: &str) -> bool {
132    essence(media_type) == "application/json" || essence(media_type).ends_with("+json")
133}
134
135/// A media type this crate can assemble from `--file` and `--field` parts.
136#[must_use]
137pub fn is_multipart(media_type: &str) -> bool {
138    essence(media_type) == "multipart/form-data"
139}
140
141fn essence(media_type: &str) -> String {
142    media_type
143        .split(';')
144        .next()
145        .unwrap_or(media_type)
146        .trim()
147        .to_ascii_lowercase()
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn json_is_recognised_through_suffixes_and_parameters() {
156        assert!(is_json("application/json"));
157        assert!(is_json("application/json; charset=utf-8"));
158        assert!(is_json("application/merge-patch+json"));
159        assert!(!is_json("form-data"));
160        assert!(!is_json("multipart/form-data"));
161    }
162
163    #[test]
164    fn only_the_correctly_spelled_multipart_type_is_assembled() {
165        assert!(is_multipart("multipart/form-data"));
166        assert!(is_multipart("Multipart/Form-Data; boundary=x"));
167        // The vendor's misspelling. It is not multipart, and the CLI says so
168        // rather than guessing what the vendor meant.
169        assert!(!is_multipart("form-data"));
170    }
171}