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
63fn 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 } = ¤t.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
95pub 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
113pub 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
131fn 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
166fn 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#[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
201fn 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#[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#[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 #[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 assert!(is_media_type(r#"multipart/form-data; boundary="a/b;c""#));
251
252 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 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 assert!(!is_multipart("form-data"));
279 }
280}