use crate::common::pointer;
use crate::common::reference::RefOr;
use crate::validation::Context;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::collections::BTreeSet;
#[derive(Debug)]
pub(crate) enum Resolution<'a, T> {
Found(&'a T),
Opaque,
Missing,
Cycle,
Unrecognized,
WrongKind,
}
impl<'a, T> Resolution<'a, T> {
#[must_use]
pub(crate) fn found(self) -> Option<&'a T> {
match self {
Resolution::Found(item) => Some(item),
_ => None,
}
}
#[must_use]
pub(crate) fn problem(&self) -> Option<&'static str> {
match self {
Resolution::Found(_) | Resolution::Opaque => None,
Resolution::Missing => Some("names nothing in this document"),
Resolution::Cycle => Some("is part of a reference cycle"),
Resolution::Unrecognized => Some("is not a usable JSON Pointer"),
Resolution::WrongKind => Some("does not point at an object of the expected kind"),
}
}
}
pub(crate) trait Kind {
const KIND: Option<&'static str>;
}
macro_rules! kinds {
($( $ty:ty => $kind:expr ),+ $(,)?) => {
$(
impl $crate::common::resolve::Kind for $ty {
const KIND: Option<&'static str> = $kind;
}
)+
};
}
pub(crate) use kinds;
pub(crate) fn check_names_kind<T>(ctx: &mut Context, reference: &str, expected: &str)
where
T: DeserializeOwned,
{
let (Some(local), Some(document)) = (reference.strip_prefix('#'), ctx.document()) else {
return;
};
let Some(path) = pointer::tokens(local) else {
return;
};
if !wrong_kind::<T>(document, &path, expected) {
return;
}
if !ctx.has_error_at_field("$ref") {
ctx.error_field(
"$ref",
format!("`{reference}` does not point at an object of the expected kind"),
);
}
}
fn wrong_kind<T>(root: &serde_json::Value, path: &[String], expected: &str) -> bool
where
T: DeserializeOwned,
{
if names_something_else(path, expected) {
return true;
}
let Some(target) = pointer::walk(root, path) else {
return false;
};
let Some((terminal, target)) = follow_json(root, path, target) else {
return false;
};
if names_something_else(&terminal, expected) {
return true;
}
if target.is_boolean() && expected == "schemas" {
return false;
}
serde_json::from_value::<T>(target.clone()).is_err()
}
const KINDS: &[&str] = &[
"servers",
"channels",
"operations",
"messages",
"schemas",
"securitySchemes",
"serverVariables",
"parameters",
"correlationIds",
"replies",
"replyAddresses",
"externalDocs",
"tags",
"operationTraits",
"messageTraits",
"serverBindings",
"channelBindings",
"operationBindings",
"messageBindings",
];
const SINGLETONS: &[&str] = &["info", "asyncapi", "id", "defaultContentType"];
#[derive(Clone, Copy)]
enum Role<'a> {
Collection(Option<&'a str>),
Object(Option<&'a str>),
Entry(Option<&'a str>),
Either(&'a str),
Opaque,
}
const MEMBERS: &[(&str, Role<'static>)] = &[
("properties", Role::Collection(Some("schemas"))),
("patternProperties", Role::Collection(Some("schemas"))),
("definitions", Role::Collection(Some("schemas"))),
("allOf", Role::Collection(Some("schemas"))),
("anyOf", Role::Collection(Some("schemas"))),
("oneOf", Role::Collection(Some("schemas"))),
("dependencies", Role::Collection(Some("schemas"))),
("items", Role::Either("schemas")),
("additionalItems", Role::Object(Some("schemas"))),
("additionalProperties", Role::Object(Some("schemas"))),
("propertyNames", Role::Object(Some("schemas"))),
("contains", Role::Object(Some("schemas"))),
("not", Role::Object(Some("schemas"))),
("if", Role::Object(Some("schemas"))),
("then", Role::Object(Some("schemas"))),
("else", Role::Object(Some("schemas"))),
("payload", Role::Object(Some("schemas"))),
("headers", Role::Object(Some("schemas"))),
("variables", Role::Collection(Some("serverVariables"))),
("messages", Role::Collection(Some("messages"))),
("parameters", Role::Collection(Some("parameters"))),
("security", Role::Collection(Some("securitySchemes"))),
("tags", Role::Collection(Some("tags"))),
("servers", Role::Collection(Some("servers"))),
("channel", Role::Object(Some("channels"))),
("correlationId", Role::Object(Some("correlationIds"))),
("externalDocs", Role::Object(Some("externalDocs"))),
("reply", Role::Object(Some("replies"))),
("address", Role::Object(Some("replyAddresses"))),
];
fn member_within<'a>(parent: Option<&str>, member: &str) -> Option<Role<'a>> {
Some(match (parent?, member) {
("messages" | "messageTraits", "traits") => Role::Collection(Some("messageTraits")),
("operations" | "operationTraits", "traits") => Role::Collection(Some("operationTraits")),
("servers", "bindings") => Role::Object(Some("serverBindings")),
("channels", "bindings") => Role::Object(Some("channelBindings")),
("operations" | "operationTraits", "bindings") => Role::Object(Some("operationBindings")),
("messages" | "messageTraits", "bindings") => Role::Object(Some("messageBindings")),
("channels", "publish" | "subscribe") => Role::Object(Some("operations")),
("operations" | "operationTraits", "message") => Role::Object(Some("messages")),
("messages" | "messageTraits", "oneOf") => Role::Collection(Some("messages")),
_ => return None,
})
}
fn role_after<'a>(previous: Role<'a>, token: &'a str) -> Role<'a> {
let parent = match previous {
Role::Collection(kind) => return Role::Entry(kind),
Role::Opaque | Role::Entry(None) => return Role::Opaque,
Role::Either(kind) if pointer::array_index(token).is_some() => {
return Role::Entry(Some(kind));
}
Role::Either(kind) => Some(kind),
Role::Object(kind) | Role::Entry(kind) => kind,
};
if parent == Some("components") && KINDS.contains(&token) {
return Role::Collection(Some(token));
}
if token.starts_with("x-") {
return Role::Opaque;
}
if let Some(role) = member_within(parent, token) {
return role;
}
MEMBERS
.iter()
.find(|(name, _)| *name == token)
.map_or(Role::Opaque, |(_, role)| *role)
}
fn names_something_else(path: &[String], expected: &str) -> bool {
let Some(role) = role_of(path) else {
return false;
};
match role {
Role::Collection(_) => true,
Role::Either(kind) => kind != expected,
Role::Entry(Some(kind)) | Role::Object(Some(kind)) => kind != expected,
Role::Entry(None) | Role::Object(None) | Role::Opaque => false,
}
}
fn role_of(path: &[String]) -> Option<Role<'_>> {
let Some(first) = path.first() else {
return Some(Role::Collection(None));
};
let mut role = match first.as_str() {
"components" => Role::Object(Some("components")),
kind if KINDS.contains(&kind) => Role::Collection(Some(kind)),
single if SINGLETONS.contains(&single) => Role::Object(Some(single)),
_ => return None,
};
for token in &path[1..] {
role = role_after(role, token);
}
Some(role)
}
pub(crate) fn classify_unresolved<'a, D, T>(
document: &D,
local_pointer: &str,
expected_kind: &str,
) -> Resolution<'a, T>
where
D: Serialize,
T: DeserializeOwned,
{
let Some(path) = pointer::tokens(local_pointer) else {
return Resolution::Unrecognized;
};
if names_something_else(&path, expected_kind) {
return Resolution::WrongKind;
}
let snapshot = serde_json::to_value(document).unwrap_or_default();
if pointer::walk(&snapshot, &path).is_none() {
return Resolution::Missing;
}
if wrong_kind::<T>(&snapshot, &path, expected_kind) {
return Resolution::WrongKind;
}
Resolution::Opaque
}
fn follow_json<'v>(
root: &'v serde_json::Value,
at: &[String],
start: &'v serde_json::Value,
) -> Option<(Vec<String>, &'v serde_json::Value)> {
let mut current = start;
let mut terminal = at.to_vec();
let mut seen: BTreeSet<String> = BTreeSet::new();
while let Some(reference) = current.get("$ref").and_then(serde_json::Value::as_str) {
let local = reference.strip_prefix('#')?;
if !seen.insert(local.to_owned()) {
return None;
}
let path = pointer::tokens(local)?;
current = pointer::walk(root, &path)?;
terminal = path;
}
Some((terminal, current))
}
#[cfg(any(feature = "v2_6", test))]
pub(crate) fn follow<'a, D, T, F>(
document: &D,
start: &'a RefOr<T>,
expected_kind: &str,
lookup: F,
) -> Resolution<'a, T>
where
D: Serialize,
T: DeserializeOwned,
F: Fn(&[String]) -> Option<&'a RefOr<T>>,
{
follow_tracked(document, Vec::new(), start, expected_kind, lookup).1
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct Terminus {
pub(crate) resource: String,
pub(crate) at: Vec<String>,
}
impl Terminus {
pub(crate) fn parse(reference: &str) -> Option<Self> {
let (resource, fragment) = match reference.split_once('#') {
Some((resource, fragment)) => (resource, fragment),
None => (reference, ""),
};
Some(Self {
resource: remove_dot_segments(resource),
at: pointer::tokens(fragment)?,
})
}
#[cfg(any(feature = "v3_0", feature = "v3_1", test))]
pub(crate) fn child_key<'o>(&self, field: &str, other: &'o Self) -> Option<&'o String> {
if self.resource != other.resource {
return None;
}
let (prefix, tail) = other.at.split_at(other.at.len().checked_sub(2)?);
match tail {
[map, key] if prefix == self.at && map == field => Some(key),
_ => None,
}
}
}
impl std::fmt::Display for Terminus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}#", self.resource)?;
for token in &self.at {
write!(f, "/{}", token.replace('~', "~0").replace('/', "~1"))?;
}
Ok(())
}
}
fn normalize_percent_encoding(reference: &str) -> String {
let bytes = reference.as_bytes();
let mut out = String::with_capacity(reference.len());
let mut i = 0;
while i < bytes.len() {
let escape = (bytes[i] == b'%')
.then(|| reference.get(i + 1..i + 3))
.flatten()
.and_then(|hex| u8::from_str_radix(hex, 16).ok().map(|byte| (hex, byte)));
match escape {
Some((_, byte))
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') =>
{
out.push(char::from(byte));
i += 3;
}
Some((hex, _)) => {
out.push('%');
out.push_str(&hex.to_ascii_uppercase());
i += 3;
}
None => {
out.push(char::from(bytes[i]));
i += 1;
}
}
}
out
}
fn normalize_case(prefix: &str) -> String {
let Some((scheme, authority)) = prefix.split_once("//") else {
return prefix.to_ascii_lowercase();
};
let host = match authority.split_once('@') {
Some((userinfo, host)) => format!("{userinfo}@{}", host.to_ascii_lowercase()),
None => authority.to_ascii_lowercase(),
};
format!("{}//{host}", scheme.to_ascii_lowercase())
}
fn remove_dot_segments(reference: &str) -> String {
let normalized = normalize_percent_encoding(reference);
let reference = normalized.as_str();
let (before_query, query) = match reference.find('?') {
Some(cut) => reference.split_at(cut),
None => (reference, ""),
};
let path_start = match before_query.find("//") {
Some(slashes) if slashes == 0 || before_query[..slashes].ends_with(':') => before_query
[slashes + 2..]
.find('/')
.map_or(before_query.len(), |offset| slashes + 2 + offset),
_ => before_query.find(':').map_or(0, |colon| colon + 1),
};
let (prefix, path) = before_query.split_at(path_start);
let prefix = normalize_case(prefix);
if path.is_empty() {
return format!("{prefix}{query}");
}
let absolute = path.starts_with('/');
let mut out: Vec<&str> = Vec::new();
let mut segments = path.strip_prefix('/').unwrap_or(path).split('/').peekable();
while let Some(segment) = segments.next() {
let last = segments.peek().is_none();
match segment {
"." | ".." => {
if segment == ".." {
match out.last() {
Some(&previous) if previous != ".." => {
out.pop();
}
_ if !absolute => out.push(".."),
_ => {}
}
}
if last {
out.push("");
}
}
segment => out.push(segment),
}
}
let mut normalized = String::new();
if absolute {
normalized.push('/');
}
normalized.push_str(&out.join("/"));
if normalized.is_empty() {
normalized.push_str("./");
}
format!("{prefix}{normalized}{query}")
}
pub(crate) fn follow_tracked<'a, D, T, F>(
document: &D,
start_path: Vec<String>,
start: &'a RefOr<T>,
expected_kind: &str,
lookup: F,
) -> (Terminus, Resolution<'a, T>)
where
D: Serialize,
T: DeserializeOwned,
F: Fn(&[String]) -> Option<&'a RefOr<T>>,
{
let mut current = start;
let mut at = Terminus {
resource: String::new(),
at: start_path,
};
let mut seen: BTreeSet<&str> = BTreeSet::new();
loop {
let reference = match current {
RefOr::Item(item) => return (at, Resolution::Found(item)),
RefOr::Reference(reference) => reference,
};
if reference.is_external() {
let terminus = Terminus::parse(&reference.reference);
return match terminus {
Some(terminus) => (terminus, Resolution::Opaque),
None => (at, Resolution::Unrecognized),
};
}
let Some(local) = reference.local_pointer() else {
return (at, Resolution::Unrecognized);
};
if !seen.insert(local) {
return (at, Resolution::Cycle);
}
let Some(path) = pointer::tokens(local) else {
return (at, Resolution::Unrecognized);
};
match lookup(&path) {
Some(next) => {
current = next;
at = Terminus {
resource: String::new(),
at: path,
};
}
None => {
let terminus = Terminus {
resource: String::new(),
at: path,
};
return (
terminus,
classify_unresolved(document, local, expected_kind),
);
}
}
}
}
pub(crate) fn check_names_something(ctx: &mut Context, reference: &str) {
let (Some(local), Some(document)) = (reference.strip_prefix('#'), ctx.document()) else {
return;
};
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut pointer = local.to_owned();
let problem = loop {
if !seen.insert(pointer.clone()) {
break "is part of a reference cycle";
}
match walk_as_written(document, &pointer) {
Walk::Missing => break "names nothing in this document",
Walk::Unrecognized => break "is not a usable JSON Pointer",
Walk::Landed(target) => match target.get("$ref").and_then(serde_json::Value::as_str) {
Some(next) => match next.strip_prefix('#') {
Some(next) => pointer = next.to_owned(),
None => return,
},
None => return,
},
}
};
if !ctx.has_error_at_field("$ref") {
ctx.error_field("$ref", format!("`{reference}` {problem}"));
}
}
enum Walk<'v> {
Landed(&'v serde_json::Value),
Missing,
Unrecognized,
}
fn walk_as_written<'v>(root: &'v serde_json::Value, pointer: &str) -> Walk<'v> {
let Some(tokens) = pointer::tokens(pointer) else {
return Walk::Unrecognized;
};
let mut current = root;
for token in &tokens {
let next = match current {
serde_json::Value::Object(map) => map.get(token),
serde_json::Value::Array(items) => {
pointer::array_index(token).and_then(|index| items.get(index))
}
_ => None,
};
match next {
Some(value) => current = value,
None => return Walk::Missing,
}
}
Walk::Landed(current)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::reference::Reference;
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct Demo {
name: String,
}
#[derive(Serialize)]
struct Doc {
entries: BTreeMap<String, RefOr<Demo>>,
#[serde(rename = "x-extra")]
extra: serde_json::Value,
#[serde(rename = "x-scalar")]
scalar: serde_json::Value,
#[serde(rename = "x-outward")]
outward: serde_json::Value,
#[serde(rename = "x-loop")]
looping: serde_json::Value,
}
fn reference(target: &str) -> RefOr<Demo> {
RefOr::Reference(Reference {
reference: target.to_owned(),
})
}
fn document() -> Doc {
let mut entries = BTreeMap::new();
entries.insert(
"real".to_owned(),
RefOr::Item(Demo {
name: "real".to_owned(),
}),
);
entries.insert("alias".to_owned(), reference("#/entries/real"));
entries.insert("hop".to_owned(), reference("#/entries/alias"));
entries.insert("loop-a".to_owned(), reference("#/entries/loop-b"));
entries.insert("loop-b".to_owned(), reference("#/entries/loop-a"));
entries.insert(
"outside".to_owned(),
reference("./other.yaml#/entries/real"),
);
entries.insert("ghost".to_owned(), reference("#/entries/nope"));
entries.insert("unmodeled".to_owned(), reference("#/x-extra"));
entries.insert("malformed".to_owned(), reference("#/entries/bad~2escape"));
entries.insert("empty".to_owned(), reference(""));
Doc {
entries,
extra: serde_json::json!({ "name": "shared" }),
scalar: serde_json::json!("not an object"),
outward: serde_json::json!({ "$ref": "./other.yaml#/entries/real" }),
looping: serde_json::json!({ "$ref": "#/x-loop" }),
}
}
fn resolve<'a>(doc: &'a Doc, entry: &'a RefOr<Demo>) -> Resolution<'a, Demo> {
follow(doc, entry, "entries", |path| match path {
[entries, key] if entries == "entries" => doc.entries.get(key),
_ => None,
})
}
#[test]
fn follows_a_chain_to_its_object() {
let doc = document();
let resolved = resolve(&doc, &doc.entries["hop"])
.found()
.expect("resolves");
assert_eq!(resolved.name, "real");
assert!(resolve(&doc, &doc.entries["real"]).found().is_some());
}
#[test]
fn each_outcome_is_told_apart() {
let doc = document();
for (key, problem) in [
("ghost", Some("names nothing in this document")),
("loop-a", Some("is part of a reference cycle")),
("malformed", Some("is not a usable JSON Pointer")),
("empty", Some("is not a usable JSON Pointer")),
("outside", None),
("unmodeled", None),
] {
assert_eq!(
resolve(&doc, &doc.entries[key]).problem(),
problem,
"entry `{key}`",
);
}
}
#[test]
fn an_unmodeled_but_real_location_is_opaque_not_found() {
let doc = document();
let resolution = resolve(&doc, &doc.entries["unmodeled"]);
assert!(matches!(resolution, Resolution::Opaque));
assert!(resolution.found().is_none());
}
#[test]
fn a_target_that_cannot_be_the_expected_kind_is_wrong_not_opaque() {
let doc = document();
assert!(matches!(
classify_unresolved::<_, Demo>(&doc, "/x-extra", "entries"),
Resolution::Opaque
));
assert!(matches!(
classify_unresolved::<_, Demo>(&doc, "/x-scalar", "entries"),
Resolution::WrongKind
));
assert!(matches!(
classify_unresolved::<_, Demo>(&doc, "/info", "entries"),
Resolution::WrongKind
));
for pointer in ["/x-outward", "/x-loop"] {
assert_eq!(
classify_unresolved::<_, Demo>(&doc, pointer, "entries").problem(),
None,
"{pointer}",
);
}
}
#[test]
fn a_resource_keeps_everything_but_its_dot_segments() {
for (written, resource) in [
("dir/./spec.yaml?v=1", "dir/spec.yaml?v=1"),
("./dir/", "dir/"),
("dir/sub/../", "dir/"),
("/a/b/../c", "/a/c"),
("/../a", "/a"),
("", ""),
("a//b.yaml", "a//b.yaml"),
("a//../b.yaml", "a/b.yaml"),
("http://host?x=/a/../b", "http://host?x=/a/../b"),
("HTTP://EXAMPLE.COM/A.yaml", "http://example.com/A.yaml"),
(
"http://User:Pass@EXAMPLE.com/a",
"http://User:Pass@example.com/a",
),
("a/%62.yaml", "a/b.yaml"),
("a%2Fb.yaml", "a%2Fb.yaml"),
("a/%2e%2e/b.yaml", "b.yaml"),
("http://host/a/../b?q=/x/../y", "http://host/b?q=/x/../y"),
("././", "./"),
("./", "./"),
(".", "./"),
] {
assert_eq!(
Terminus::parse(written).expect("a resource").resource,
resource,
"{written}",
);
}
}
#[test]
fn a_terminus_is_a_resource_and_a_pointer() {
let local = Terminus::parse("#/channels/user").expect("a pointer");
assert_eq!(local.resource, "");
assert_eq!(local.to_string(), "#/channels/user");
let whole = Terminus::parse("./other.yaml").expect("a resource");
assert!(whole.at.is_empty());
assert_eq!(whole.to_string(), "other.yaml#");
for spelling in [
"./channels.yaml",
"channels.yaml",
"a/../channels.yaml",
"././channels.yaml",
] {
assert_eq!(
Terminus::parse(spelling).expect("a resource").resource,
"channels.yaml",
"{spelling}",
);
}
for spelling in [
"../channels.yaml",
"/channels.yaml",
"other.yaml",
"channels.yaml/",
"a//channels.yaml",
] {
assert_ne!(
Terminus::parse(spelling).expect("a resource").resource,
"channels.yaml",
"{spelling}",
);
}
assert_eq!(
Terminus::parse("https://example.com/a/../b/spec.yaml#/c")
.expect("a resource")
.resource,
"https://example.com/b/spec.yaml",
);
let channel = Terminus::parse("./channels.yaml#/user").expect("a pointer");
let message = Terminus::parse("channels.yaml#/user/messages/signup").expect("a pointer");
assert_eq!(
channel.child_key("messages", &message).map(String::as_str),
Some("signup"),
);
let here = Terminus::parse("#/user/messages/signup").expect("a pointer");
assert!(channel.child_key("messages", &here).is_none());
assert!(Terminus::parse("./other.yaml#bad").is_none());
}
#[test]
fn a_chain_reports_where_it_ended_not_where_it_began() {
let doc = document();
let start = pointer::tokens("/entries/hop").expect("a pointer");
let lookup = |path: &[String]| match path {
[entries, key] if entries == "entries" => doc.entries.get(key),
_ => None,
};
let (terminal, resolution) =
follow_tracked(&doc, start, &doc.entries["hop"], "entries", lookup);
assert_eq!(
terminal.to_string(),
"#/entries/real",
"hop -> alias -> real"
);
assert!(resolution.found().is_some());
let start = pointer::tokens("/entries/unmodeled").expect("a pointer");
let (terminal, resolution) =
follow_tracked(&doc, start, &doc.entries["unmodeled"], "entries", lookup);
assert_eq!(terminal.to_string(), "#/x-extra");
assert!(matches!(resolution, Resolution::Opaque));
let start = pointer::tokens("/entries/real").expect("a pointer");
let (terminal, _) = follow_tracked(&doc, start, &doc.entries["real"], "entries", |_| None);
assert_eq!(terminal.to_string(), "#/entries/real");
}
#[test]
fn classify_separates_dangling_from_unmodeled_and_malformed() {
let doc = document();
assert!(matches!(
classify_unresolved::<_, Demo>(&doc, "/x-extra", "entries"),
Resolution::Opaque
));
assert!(matches!(
classify_unresolved::<_, Demo>(&doc, "/nothing/here", "entries"),
Resolution::Missing
));
assert!(matches!(
classify_unresolved::<_, Demo>(&doc, "/bad~2escape", "entries"),
Resolution::Unrecognized
));
assert!(matches!(
classify_unresolved::<_, Demo>(&doc, "/servers/prod", "channels"),
Resolution::WrongKind
));
}
}