use openapiv3::{
Components, IntegerType, NumberType, ReferenceOr, Schema, SchemaKind, StringType, Type,
};
use thiserror::Error;
use crate::scalar::{Bounds, Limit, Scalar, Text};
#[derive(Debug, Clone, Error, PartialEq, Eq)]
#[error("`{reference}` does not resolve")]
pub struct RefError {
pub reference: String,
}
const MAX_HOPS: usize = 8;
pub fn resolve<'c, T>(
value: &'c ReferenceOr<T>,
section: impl Fn(&str) -> Option<&'c ReferenceOr<T>>,
name: &str,
) -> Result<&'c T, RefError> {
let prefix = format!("#/components/{name}/");
let mut current = value;
for _ in 0..MAX_HOPS {
match current {
ReferenceOr::Item(item) => return Ok(item),
ReferenceOr::Reference { reference } => {
current = reference
.strip_prefix(&prefix)
.and_then(§ion)
.ok_or_else(|| RefError {
reference: reference.clone(),
})?;
}
}
}
Err(RefError {
reference: "a reference cycle".to_owned(),
})
}
pub fn resolve_schema<'c>(
schema: &'c ReferenceOr<Schema>,
components: &'c Components,
) -> Result<&'c Schema, RefError> {
resolve(schema, |key| components.schemas.get(key), "schemas")
}
fn stated<'c>(
schema: &'c ReferenceOr<Schema>,
components: &'c Components,
) -> Result<&'c Schema, RefError> {
let mut current = resolve_schema(schema, components)?;
for _ in 0..MAX_HOPS {
let SchemaKind::AllOf { all_of } = ¤t.schema_kind else {
return Ok(current);
};
let [only] = all_of.as_slice() else {
return Ok(current);
};
current = resolve_schema(only, components)?;
}
Err(RefError {
reference: "a reference cycle".to_owned(),
})
}
pub fn scalar_of(
schema: &ReferenceOr<Schema>,
components: &Components,
) -> Result<Option<Scalar>, RefError> {
let schema = stated(schema, components)?;
let SchemaKind::Type(ty) = &schema.schema_kind else {
return Ok(None);
};
Ok(match ty {
Type::String(s) => Some(string_scalar(s)),
Type::Number(n) => Some(Scalar::Number(number_bounds(n))),
Type::Integer(i) => Some(Scalar::Integer(integer_bounds(i))),
Type::Boolean(_) => Some(Scalar::Boolean),
Type::Object(_) | Type::Array(_) => None,
})
}
pub fn description_of(
schema: &ReferenceOr<Schema>,
components: &Components,
) -> Result<Option<String>, RefError> {
let own = &resolve_schema(schema, components)?.schema_data.description;
if own.is_some() {
return Ok(own.clone());
}
Ok(stated(schema, components)?.schema_data.description.clone())
}
fn string_scalar(s: &StringType) -> Scalar {
let choices: Vec<String> = s.enumeration.iter().flatten().cloned().collect();
if choices.is_empty() {
Scalar::Text(Text {
pattern: s.pattern.clone(),
min_length: s.min_length,
max_length: s.max_length,
})
} else {
Scalar::Choice(choices)
}
}
fn number_bounds(n: &NumberType) -> Bounds<f64> {
Bounds {
low: limit(n.minimum, n.exclusive_minimum),
high: limit(n.maximum, n.exclusive_maximum),
multiple_of: n.multiple_of,
}
}
fn integer_bounds(i: &IntegerType) -> Bounds<i64> {
Bounds {
low: limit(i.minimum, i.exclusive_minimum),
high: limit(i.maximum, i.exclusive_maximum),
multiple_of: i.multiple_of,
}
}
fn limit<T>(value: Option<T>, exclusive: bool) -> Option<Limit<T>> {
value.map(|value| {
if exclusive {
Limit::Exclusive(value)
} else {
Limit::Inclusive(value)
}
})
}
#[must_use]
pub fn is_media_type(media_type: &str) -> bool {
essence(media_type)
.split_once('/')
.is_some_and(|(ty, subtype)| is_token(ty) && is_token(subtype))
}
fn is_token(word: &str) -> bool {
!word.is_empty()
&& word
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b))
}
#[must_use]
pub fn is_json(media_type: &str) -> bool {
essence(media_type) == "application/json" || essence(media_type).ends_with("+json")
}
#[must_use]
pub fn is_multipart(media_type: &str) -> bool {
essence(media_type) == "multipart/form-data"
}
fn essence(media_type: &str) -> String {
media_type
.split(';')
.next()
.unwrap_or(media_type)
.trim()
.to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_media_type_is_a_type_and_a_subtype_and_a_bare_word_is_neither() {
assert!(is_media_type("application/pdf"));
assert!(is_media_type("text/csv; charset=utf-8"));
assert!(is_media_type("application/vnd.api+json"));
assert!(is_media_type("multipart/form-data; boundary=x"));
assert!(is_media_type("application/x-www-form-urlencoded"));
assert!(is_media_type(r#"multipart/form-data; boundary="a/b;c""#));
assert!(!is_media_type("form-data"));
assert!(!is_media_type(""));
assert!(!is_media_type("application/"));
assert!(!is_media_type("/json"));
assert!(!is_media_type("application/ld/json"));
assert!(!is_media_type("application/json charset=utf-8"));
}
#[test]
fn json_is_recognised_through_suffixes_and_parameters() {
assert!(is_json("application/json"));
assert!(is_json("application/json; charset=utf-8"));
assert!(is_json("application/merge-patch+json"));
assert!(!is_json("form-data"));
assert!(!is_json("multipart/form-data"));
}
#[test]
fn only_the_correctly_spelled_multipart_type_is_assembled() {
assert!(is_multipart("multipart/form-data"));
assert!(is_multipart("Multipart/Form-Data; boundary=x"));
assert!(!is_multipart("form-data"));
}
}