1use openapiv3::{
14 Components, IntegerType, NumberType, ReferenceOr, Schema, SchemaKind, StringType, Type,
15};
16use thiserror::Error;
17
18use crate::scalar::{Bounds, Limit, Scalar, Text};
19
20#[derive(Debug, Clone, Error, PartialEq, Eq)]
22#[error("`{reference}` does not resolve")]
23pub struct RefError {
24 pub reference: String,
25}
26
27const MAX_HOPS: usize = 8;
29
30pub 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(§ion)
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
63pub 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
81fn 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
116fn 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#[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#[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 assert!(!is_multipart("form-data"));
170 }
171}