use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use indexmap::IndexMap;
use crate::error::{Error, Result};
use crate::openapi::{
for_each_path_item_schema, for_each_root_schema, for_each_schema_in,
referenced_component_schema, AdditionalProperties, OpenApi, Parameter, PathItem, Schema,
SecurityScheme,
};
const FETCH_TIMEOUT_SECONDS: u32 = 60;
pub trait RemoteFetcher {
fn fetch(&self, url: &str) -> std::result::Result<String, String>;
}
pub struct CurlFetcher;
impl RemoteFetcher for CurlFetcher {
fn fetch(&self, url: &str) -> std::result::Result<String, String> {
curl_fetch(CURL, url)
}
}
const CURL: &str = "curl";
fn curl_fetch(program: &str, url: &str) -> std::result::Result<String, String> {
let output = Command::new(program)
.arg("--silent")
.arg("--show-error")
.arg("--fail")
.arg("--location")
.arg("--max-time")
.arg(FETCH_TIMEOUT_SECONDS.to_string())
.arg("--")
.arg(url)
.output()
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => "`curl` was not found on PATH; install it — \
crozier fetches documents named by an absolute-URL `$ref` with curl"
.to_string(),
_ => format!("could not run `curl`: {error}"),
})?;
if !output.status.success() {
return Err(format!(
"curl failed ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
String::from_utf8(output.stdout).map_err(|error| format!("not valid UTF-8: {error}"))
}
pub fn resolve(
doc: &mut OpenApi,
fetcher: &dyn RemoteFetcher,
spec: &Path,
) -> Result<BTreeSet<String>> {
let mut resolver = Resolver {
fetcher,
spec,
documents: HashMap::new(),
active: Vec::new(),
imported: IndexMap::new(),
importing: HashSet::new(),
root_schemas: doc.components.schemas.keys().cloned().collect(),
};
let remote_origin = register_component_schemas(doc, &mut resolver)?;
for scheme in doc.components.security_schemes.values_mut() {
if let Some(resolved) = resolver.resolve_security_scheme(scheme) {
*scheme = resolved;
}
}
let root_spec = DocumentLocation::Local(spec.to_path_buf());
for parameter in doc.components.parameters.values_mut() {
resolver.resolve_parameter(parameter, &root_spec)?;
}
for item in doc.paths.values_mut() {
resolver.resolve_path_item(item, &root_spec)?;
}
doc.components
.schemas
.extend(std::mem::take(&mut resolver.imported));
let mut failure = None;
for_each_root_schema(doc, &mut |schema| {
if failure.is_none() {
if let Err(error) = resolver.resolve_schema(schema, &root_spec) {
failure = Some(error);
}
}
});
if let Some(error) = failure {
return Err(error);
}
while !resolver.imported.is_empty() {
let mut imported = std::mem::take(&mut resolver.imported);
for schema in imported.values_mut() {
resolver.resolve_schema(schema, &root_spec)?;
}
for (name, schema) in imported {
doc.components.schemas.entry(name).or_insert(schema);
}
}
Ok(remote_origin)
}
fn register_component_schemas(
doc: &mut OpenApi,
resolver: &mut Resolver,
) -> Result<BTreeSet<String>> {
let names: Vec<String> = doc.components.schemas.keys().cloned().collect();
let mut fetched: Vec<(String, Schema)> = Vec::new();
let mut renamed: Vec<(String, String)> = Vec::new();
let mut remote_origin: BTreeSet<String> = BTreeSet::new();
for name in names {
let Some(reference) = doc.components.schemas[&name].reference.clone() else {
continue;
};
let Some((_, fragment)) = split_external(&reference) else {
continue;
};
let target = fragment.rsplit('/').next().unwrap_or_default().to_string();
let source = DocumentLocation::Local(resolver.spec.to_path_buf());
let resolved = resolver.resolve_reference(&reference, &source)?;
if target.is_empty()
|| target == name
|| doc.components.schemas.contains_key(&target)
|| fetched.iter().any(|(taken, _)| *taken == target)
{
doc.components.schemas[&name] = resolved;
remote_origin.insert(name);
} else {
doc.components.schemas[&name] = Schema {
reference: Some(format!("#/components/schemas/{target}")),
..Schema::default()
};
renamed.push((name, target.clone()));
remote_origin.insert(target.clone());
fetched.push((target, resolved));
}
}
doc.components.schemas.extend(fetched);
rename_references(doc, &renamed);
Ok(remote_origin)
}
fn rename_references(doc: &mut OpenApi, renamed: &[(String, String)]) {
if renamed.is_empty() {
return;
}
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
let Some(reference) = node.reference.as_deref() else {
return;
};
let Some(name) = referenced_component_schema(reference) else {
return;
};
let Some((_, target)) = renamed.iter().find(|(from, _)| from == name) else {
return;
};
let suffix = &reference["#/components/schemas/".len() + name.len()..];
node.reference = Some(format!("#/components/schemas/{target}{suffix}"));
});
});
}
fn split_remote(reference: &str) -> Option<(&str, &str)> {
if !(reference.starts_with("http://") || reference.starts_with("https://")) {
return None;
}
Some(match reference.split_once('#') {
Some((url, fragment)) => (url, fragment),
None => (reference, ""),
})
}
fn split_external(reference: &str) -> Option<(&str, &str)> {
if let Some(remote) = split_remote(reference) {
return Some(remote);
}
let (path, fragment) = reference.split_once('#').unwrap_or((reference, ""));
if path.is_empty() || path.contains(':') {
return None;
}
Some((path, fragment))
}
#[derive(Clone)]
enum DocumentLocation {
Remote(String),
Local(PathBuf),
}
impl DocumentLocation {
fn from_reference(reference: &str, source: &Self) -> Self {
let (address, _) = split_external(reference).expect("external reference");
if split_remote(reference).is_some() {
return Self::Remote(address.to_string());
}
match source {
Self::Local(path) => Self::Local(
path.parent()
.unwrap_or_else(|| Path::new("."))
.join(address),
),
Self::Remote(url) => {
let (scheme, rest) = url.split_once("://").expect("HTTP(S) URL");
let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
let mut segments: Vec<&str> = path.split('/').collect();
segments.pop();
for segment in address.split('/') {
match segment {
"." | "" => {}
".." => {
segments.pop();
}
value => segments.push(value),
}
}
Self::Remote(format!("{scheme}://{authority}/{}", segments.join("/")))
}
}
}
fn key(&self) -> String {
match self {
Self::Remote(url) => url.clone(),
Self::Local(path) => path.to_string_lossy().into_owned(),
}
}
}
struct Resolver<'a> {
fetcher: &'a dyn RemoteFetcher,
spec: &'a Path,
documents: HashMap<String, serde_json::Value>,
active: Vec<String>,
imported: IndexMap<String, Schema>,
importing: HashSet<(String, String)>,
root_schemas: HashSet<String>,
}
impl Resolver<'_> {
fn deserialize_object<T: serde::de::DeserializeOwned>(
&self,
node: serde_json::Value,
reference: &str,
kind: &str,
) -> Result<T> {
if !node.is_object() {
return Err(self.error(reference, &format!("not {kind}: expected an object")));
}
serde_json::from_value(node)
.map_err(|error| self.error(reference, &format!("not {kind}: {error}")))
}
fn resolve_path_item(&mut self, item: &mut PathItem, source: &DocumentLocation) -> Result<()> {
let mut source_path = source.clone();
if let Some(reference) = item.reference.clone() {
if let Some((_, fragment)) = split_external(&reference) {
source_path = DocumentLocation::from_reference(&reference, source);
if let DocumentLocation::Local(file) = &source_path {
if matches!(std::fs::metadata(file), Err(error) if error.kind() == std::io::ErrorKind::NotFound)
{
*item = PathItem::default();
return Ok(());
}
}
let node = pointer(self.document(&source_path, &reference)?, fragment)
.cloned()
.ok_or_else(|| {
self.error(
&reference,
"the referenced document has no Path Item at that pointer",
)
})?;
*item = self.deserialize_object(node, &reference, "a Path Item")?;
}
}
for parameter in &mut item.parameters {
self.resolve_parameter(parameter, &source_path)?;
}
for slot in item.operation_slots() {
if let Some(operation) = slot.as_mut() {
for parameter in &mut operation.parameters {
self.resolve_parameter(parameter, &source_path)?;
}
}
}
let mut failure = None;
for_each_path_item_schema(item, &mut |schema| {
if failure.is_none() {
if let Err(error) = self.resolve_schema(schema, &source_path) {
failure = Some(error);
}
}
});
if let Some(error) = failure {
return Err(error);
}
Ok(())
}
fn resolve_security_scheme(&mut self, scheme: &SecurityScheme) -> Option<SecurityScheme> {
let mut reference = scheme.reference.clone()?;
if split_external(&reference).is_none() || split_remote(&reference).is_some() {
return None;
}
let mut source = DocumentLocation::Local(self.spec.to_path_buf());
let mut seen = HashSet::new();
loop {
let location = if reference.starts_with('#') {
source.clone()
} else if split_remote(&reference).is_some() {
return None;
} else {
DocumentLocation::from_reference(&reference, &source)
};
if !seen.insert((location.key(), reference.clone())) {
return None;
}
let fragment = reference
.split_once('#')
.map_or("", |(_, fragment)| fragment);
let node = pointer(self.document(&location, &reference).ok()?, fragment)?.clone();
let resolved: SecurityScheme = self
.deserialize_object(node, &reference, "a security scheme")
.ok()?;
let Some(next) = resolved.reference.clone() else {
return Some(resolved);
};
source = location;
reference = next;
}
}
fn resolve_parameter(
&mut self,
parameter: &mut Parameter,
source: &DocumentLocation,
) -> Result<()> {
let Some(reference) = parameter.reference.clone() else {
return Ok(());
};
let Some((_, fragment)) = split_external(&reference) else {
return Ok(());
};
let path = DocumentLocation::from_reference(&reference, source);
let node = pointer(self.document(&path, &reference)?, fragment)
.cloned()
.ok_or_else(|| {
self.error(
&reference,
"the referenced document has no parameter at that pointer",
)
})?;
*parameter = self.deserialize_object(node, &reference, "a parameter")?;
Ok(())
}
fn resolve_schema(&mut self, schema: &mut Schema, source: &DocumentLocation) -> Result<()> {
if let Some(reference) = schema.reference.clone() {
if matches!(source, DocumentLocation::Remote(_)) {
if let Some(fragment) = reference.strip_prefix('#') {
if let Some(name) = fragment
.strip_prefix("/components/schemas/")
.filter(|name| !name.is_empty() && !name.contains('/'))
{
if pointer(self.document(source, &reference)?, fragment).is_some() {
self.import_pointer_at_path(source, name, fragment, None, &reference)?;
schema.reference = Some(format!("#/components/schemas/{name}"));
return Ok(());
}
}
}
}
if let Some((_, fragment)) = split_external(&reference) {
if fragment.starts_with("/components/schemas/") {
let path = DocumentLocation::from_reference(&reference, source);
if let DocumentLocation::Local(file) = path {
if matches!(std::fs::metadata(file), Err(error) if error.kind() == std::io::ErrorKind::NotFound)
{
*schema = Schema::default();
return Ok(());
}
}
}
if let Some(name) = self.import_external_component(&reference, source)? {
schema.reference = Some(format!("#/components/schemas/{name}"));
return Ok(());
}
if let Some(name) = self.import_named_schema(&reference, source)? {
schema.reference = Some(format!("#/components/schemas/{name}"));
return Ok(());
}
if let Some(name) = self.import_external_document(&reference, source)? {
schema.reference = Some(format!("#/components/schemas/{name}"));
return Ok(());
}
*schema = self.resolve_reference(&reference, source)?;
return Ok(());
}
}
for property in schema.properties.values_mut() {
self.resolve_schema(property, source)?;
}
if let Some(items) = &mut schema.items {
self.resolve_schema(items, source)?;
}
if let Some(AdditionalProperties::Schema(value)) = &mut schema.additional_properties {
self.resolve_schema(value, source)?;
}
for members in [&mut schema.one_of, &mut schema.any_of, &mut schema.all_of] {
for member in members.iter_mut().flatten() {
self.resolve_schema(member, source)?;
}
}
Ok(())
}
fn import_external_component(
&mut self,
reference: &str,
source: &DocumentLocation,
) -> Result<Option<String>> {
let Some((_, fragment)) = split_external(reference) else {
return Ok(None);
};
if matches!(source, DocumentLocation::Remote(_)) {
return Ok(None);
}
let Some(name) = fragment.strip_prefix("/components/schemas/") else {
return Ok(None);
};
if name.is_empty() || name.contains('/') || self.root_schemas.contains(name) {
return Ok(None);
}
let path = DocumentLocation::from_reference(reference, source);
self.import_pointer_at_path(&path, name, fragment, None, reference)
}
fn import_named_schema(
&mut self,
reference: &str,
source: &DocumentLocation,
) -> Result<Option<String>> {
let Some((_, fragment)) = split_external(reference) else {
return Ok(None);
};
if split_remote(reference).is_some() {
return Ok(None);
}
let Some(name) = fragment.strip_prefix('/') else {
return Ok(None);
};
if name.is_empty() || name.contains('/') {
return Ok(None);
}
let path = DocumentLocation::from_reference(reference, source);
self.import_named_schema_at_path(&path, name, reference)
}
fn import_named_schema_at_path(
&mut self,
path: &DocumentLocation,
name: &str,
reference: &str,
) -> Result<Option<String>> {
self.import_pointer_at_path(path, name, &format!("/{name}"), None, reference)
}
fn import_external_document(
&mut self,
reference: &str,
source: &DocumentLocation,
) -> Result<Option<String>> {
let Some((address, fragment)) = split_external(reference) else {
return Ok(None);
};
if !(fragment.is_empty() || fragment == "/") {
return Ok(None);
}
let file = address.rsplit('/').next().unwrap_or_default();
let name = file.split('.').next().unwrap_or_default();
if name.is_empty() {
return Ok(None);
}
let path = DocumentLocation::from_reference(reference, source);
self.import_pointer_at_path(&path, name, "", Some(name), reference)
}
fn import_pointer_at_path(
&mut self,
path: &DocumentLocation,
name: &str,
fragment: &str,
document: Option<&str>,
reference: &str,
) -> Result<Option<String>> {
let Some(node) = pointer(self.document(path, reference)?, fragment).cloned() else {
return Ok(None);
};
let identity = (path.key(), name.to_string());
if self.imported.contains_key(name) || !self.importing.insert(identity.clone()) {
return Ok(Some(name.to_string()));
}
let mut schema: Schema = self.deserialize_object(node, reference, "a schema")?;
let mut locals: Vec<(String, String)> = Vec::new();
for_each_schema_in(&mut schema, &mut |node| {
let Some(value) = node.reference.as_deref() else {
return;
};
if let Some(document) = document {
if value == "#" || value == "#/" {
node.reference = Some(format!("#/components/schemas/{document}"));
return;
}
if let Some(local) = value.strip_prefix("#/definitions/") {
if !local.is_empty() && !local.contains('/') {
locals.push((local.to_string(), format!("/definitions/{local}")));
node.reference = Some(format!("#/components/schemas/{local}"));
}
return;
}
}
if fragment.starts_with("/components/schemas/") {
if let Some(local) = value.strip_prefix("#/components/schemas/") {
if !local.is_empty() && !local.contains('/') {
locals.push((local.to_string(), format!("/components/schemas/{local}")));
node.reference = Some(format!("#/components/schemas/{local}"));
}
return;
}
}
if let Some(local) = value.strip_prefix("#/") {
if !local.is_empty() && !local.contains('/') {
locals.push((local.to_string(), format!("/{local}")));
node.reference = Some(format!("#/components/schemas/{local}"));
}
}
});
for (local, local_fragment) in locals {
self.import_pointer_at_path(path, &local, &local_fragment, document, reference)?;
}
self.resolve_schema(&mut schema, path)?;
self.imported.insert(name.to_string(), schema);
self.importing.remove(&identity);
Ok(Some(name.to_string()))
}
fn resolve_reference(&mut self, reference: &str, source: &DocumentLocation) -> Result<Schema> {
if self.active.iter().any(|active| active == reference) {
return Ok(Schema::default());
}
let (_, fragment) =
split_external(reference).expect("caller checked the reference is external");
let path = DocumentLocation::from_reference(reference, source);
let node = pointer(self.document(&path, reference)?, fragment).cloned();
let node = node.ok_or_else(|| {
self.error(
reference,
"the referenced document has no node at that pointer",
)
})?;
let mut resolved: Schema = self.deserialize_object(node, reference, "a schema")?;
self.active.push(reference.to_string());
let nested = self.resolve_schema(&mut resolved, &path);
self.active.pop();
nested?;
Ok(resolved)
}
fn document(&mut self, path: &DocumentLocation, reference: &str) -> Result<&serde_json::Value> {
let key = path.key();
if !self.documents.contains_key(&key) {
let text = match path {
DocumentLocation::Remote(url) => self
.fetcher
.fetch(url)
.map_err(|message| self.error(reference, &message))?,
DocumentLocation::Local(file) => {
std::fs::read_to_string(file).map_err(|error| {
self.error(
reference,
&format!("could not read {}: {error}", file.display()),
)
})?
}
};
let value = crate::yaml::from_str(&text)
.map_err(|error| self.error(reference, &format!("could not parse it: {error}")))?;
self.documents.insert(key.clone(), value);
}
Ok(&self.documents[&key])
}
fn error(&self, reference: &str, message: &str) -> Error {
Error::RemoteRef {
path: self.spec.to_path_buf(),
reference: reference.to_string(),
message: message.to_string(),
}
}
}
fn pointer<'a>(document: &'a serde_json::Value, fragment: &str) -> Option<&'a serde_json::Value> {
let mut node = document;
for segment in fragment.split('/').skip_while(|segment| segment.is_empty()) {
let key = segment.replace("~1", "/").replace("~0", "~");
node = match node {
serde_json::Value::Object(map) => map.get(&key)?,
serde_json::Value::Array(items) => items.get(key.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(node)
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeFetcher {
documents: HashMap<String, String>,
fetched: std::cell::RefCell<Vec<String>>,
}
impl FakeFetcher {
fn new(documents: &[(&str, &str)]) -> Self {
Self {
documents: documents
.iter()
.map(|(url, body)| ((*url).to_string(), (*body).to_string()))
.collect(),
fetched: std::cell::RefCell::new(Vec::new()),
}
}
}
impl RemoteFetcher for FakeFetcher {
fn fetch(&self, url: &str) -> std::result::Result<String, String> {
self.fetched.borrow_mut().push(url.to_string());
self.documents
.get(url)
.cloned()
.ok_or_else(|| format!("no such document {url}"))
}
}
fn parse(spec: &str) -> OpenApi {
crate::yaml::from_str(spec).expect("valid spec")
}
fn resolved(spec: &str, documents: &[(&str, &str)]) -> (OpenApi, Vec<String>) {
let fetcher = FakeFetcher::new(documents);
let mut doc = parse(spec);
resolve(&mut doc, &fetcher, Path::new("spec.yml")).expect("resolvable spec");
let fetched = fetcher.fetched.borrow().clone();
(doc, fetched)
}
const REMOTE: &str = "https://example.test/schemas.yaml";
#[test]
fn a_component_schema_becomes_the_referenced_document_s_schema() {
let (doc, fetched) = resolved(
&format!(
r#"
openapi: 3.0.0
components:
schemas:
address:
$ref: {REMOTE}#/address
"#
),
&[(REMOTE, "address:\n type: string\n pattern: '^0x'\n")],
);
let address = &doc.components.schemas["address"];
assert_eq!(address.reference, None);
assert_eq!(
address.ty.as_ref().and_then(|t| t.primary()),
Some("string")
);
assert_eq!(fetched, vec![REMOTE.to_string()]);
}
#[test]
fn a_fetched_schema_takes_its_pointer_s_name_and_the_local_key_aliases_it() {
let (doc, _) = resolved(
&format!(
r##"
openapi: 3.0.0
components:
schemas:
TransactionReceipt:
$ref: {REMOTE}#/ReceiptInfo
Receipts:
type: array
items:
$ref: "#/components/schemas/TransactionReceipt"
"##
),
&[(
REMOTE,
"ReceiptInfo:\n type: object\n properties:\n status:\n type: string\n",
)],
);
assert!(doc.components.schemas["ReceiptInfo"]
.properties
.contains_key("status"));
assert_eq!(
doc.components.schemas["TransactionReceipt"]
.reference
.as_deref(),
Some("#/components/schemas/ReceiptInfo")
);
assert_eq!(
doc.components.schemas["Receipts"]
.items
.as_ref()
.and_then(|items| items.reference.as_deref()),
Some("#/components/schemas/ReceiptInfo")
);
}
#[test]
fn a_pointer_name_the_document_already_declares_keeps_the_schema_in_place() {
let (doc, _) = resolved(
&format!(
r#"
openapi: 3.0.0
components:
schemas:
Alias:
$ref: {REMOTE}#/Taken
Taken:
type: boolean
"#
),
&[(REMOTE, "Taken:\n type: string\n")],
);
assert_eq!(
doc.components.schemas["Alias"]
.ty
.as_ref()
.and_then(|t| t.primary()),
Some("string")
);
assert_eq!(
doc.components.schemas["Taken"]
.ty
.as_ref()
.and_then(|t| t.primary()),
Some("boolean")
);
}
#[test]
fn resolution_is_transitive_across_documents() {
let other = "https://example.test/other.yaml";
let (doc, fetched) = resolved(
&format!(
r#"
openapi: 3.0.0
components:
schemas:
wrapper:
$ref: {REMOTE}#/wrapper
"#
),
&[
(REMOTE, &format!("wrapper:\n $ref: {other}#/leaf\n")),
(other, "leaf:\n type: integer\n"),
],
);
assert_eq!(
doc.components.schemas["wrapper"]
.ty
.as_ref()
.and_then(|t| t.primary()),
Some("integer")
);
assert_eq!(fetched, vec![REMOTE.to_string(), other.to_string()]);
}
#[test]
fn a_relative_ref_inside_a_remote_document_uses_that_documents_url() {
let root_url = "https://example.test/api/schemas/root.yml";
let leaf_url = "https://example.test/api/common/leaf.yml";
let (doc, fetched) = resolved(
&format!(
"openapi: 3.0.3\ncomponents:\n schemas:\n Wrapper:\n $ref: {root_url}#/Wrapper\n"
),
&[
(
root_url,
"Wrapper:\n properties:\n leaf:\n $ref: ../common/leaf.yml#/components/schemas/Leaf\n",
),
(
leaf_url,
"components:\n schemas:\n Leaf:\n type: integer\n",
),
],
);
assert_eq!(
doc.components.schemas["Wrapper"].properties["leaf"]
.ty
.as_ref()
.and_then(|ty| ty.primary()),
Some("integer")
);
assert_eq!(fetched, vec![root_url.to_string(), leaf_url.to_string()]);
}
#[test]
fn an_external_component_inside_a_remote_document_keeps_its_inline_identity() {
let leaf_url = "https://example.test/leaf.yml";
let (doc, fetched) = resolved(
&format!(
"openapi: 3.0.3\ncomponents:\n schemas:\n Wrapper:\n $ref: {REMOTE}#/Wrapper\n"
),
&[
(
REMOTE,
&format!(
"Wrapper:\n properties:\n leaf:\n $ref: {leaf_url}#/components/schemas/Leaf\n"
),
),
(leaf_url, "components:\n schemas:\n Leaf:\n type: integer\n"),
],
);
let leaf = &doc.components.schemas["Wrapper"].properties["leaf"];
assert!(leaf.reference.is_none());
assert_eq!(
leaf.ty.as_ref().and_then(|ty| ty.primary()),
Some("integer")
);
assert!(!doc.components.schemas.contains_key("Leaf"));
assert_eq!(fetched, vec![REMOTE.to_string(), leaf_url.to_string()]);
}
#[test]
fn each_referenced_document_is_fetched_once_however_many_refs_name_it() {
let (doc, fetched) = resolved(
&format!(
r#"
openapi: 3.0.0
components:
schemas:
a:
$ref: {REMOTE}#/a
b:
$ref: {REMOTE}#/b
c:
$ref: {REMOTE}#/a
"#
),
&[(REMOTE, "a:\n type: string\nb:\n type: boolean\n")],
);
assert_eq!(doc.components.schemas.len(), 3);
assert_eq!(fetched, vec![REMOTE.to_string()]);
}
#[test]
fn a_local_pointer_inside_a_fetched_schema_is_left_for_the_root_document() {
let (doc, _) = resolved(
&format!(
r#"
openapi: 3.0.0
components:
schemas:
addresses:
$ref: {REMOTE}#/addresses
Address:
type: string
"#
),
&[(
REMOTE,
"addresses:\n type: array\n items:\n $ref: '#/components/schemas/Address'\n",
)],
);
let items = doc.components.schemas["addresses"]
.items
.as_ref()
.expect("array items");
assert_eq!(
items.reference.as_deref(),
Some("#/components/schemas/Address")
);
}
#[test]
fn a_remote_ref_nested_in_a_composition_resolves() {
let (doc, _) = resolved(
&format!(
r#"
openapi: 3.0.0
components:
schemas:
Either:
oneOf:
- $ref: {REMOTE}#/left
- type: string
"#
),
&[(REMOTE, "left:\n type: number\n")],
);
let variants = doc.components.schemas["Either"]
.one_of
.as_ref()
.expect("oneOf variants");
assert_eq!(
variants[0].ty.as_ref().and_then(|t| t.primary()),
Some("number")
);
}
#[test]
fn remote_refs_resolve_in_parameters_bodies_and_responses() {
let (doc, _) = resolved(
&format!(
r#"
openapi: 3.0.0
paths:
/items:
parameters:
- name: shared
in: query
schema:
$ref: {REMOTE}#/leaf
post:
parameters:
- name: filter
in: query
schema:
$ref: {REMOTE}#/leaf
requestBody:
content:
application/json:
schema:
$ref: {REMOTE}#/leaf
responses:
"200":
description: ok
content:
application/json:
schema:
$ref: {REMOTE}#/leaf
components:
parameters:
Reusable:
name: reusable
in: query
schema:
$ref: {REMOTE}#/leaf
requestBodies:
Body:
content:
application/json:
schema:
$ref: {REMOTE}#/leaf
responses:
Ok:
description: ok
content:
application/json:
schema:
$ref: {REMOTE}#/leaf
"#
),
&[(REMOTE, "leaf:\n type: string\n")],
);
let item = &doc.paths["/items"];
let is_leaf =
|schema: &Schema| schema.ty.as_ref().and_then(|t| t.primary()) == Some("string");
assert!(is_leaf(
item.parameters[0]
.schema
.as_ref()
.expect("path parameter schema")
));
let post = item.post.as_ref().expect("post operation");
assert!(is_leaf(
post.parameters[0]
.schema
.as_ref()
.expect("operation parameter schema")
));
assert!(is_leaf(
post.request_body.as_ref().expect("request body").content["application/json"]
.schema
.as_ref()
.expect("body schema")
));
assert!(is_leaf(
post.responses["200"].content["application/json"]
.schema
.as_ref()
.expect("response schema")
));
assert!(is_leaf(
doc.components.parameters["Reusable"]
.schema
.as_ref()
.expect("component parameter schema")
));
assert!(is_leaf(
doc.components.request_bodies["Body"].content["application/json"]
.schema
.as_ref()
.expect("component body schema")
));
assert!(is_leaf(
doc.components.responses["Ok"].content["application/json"]
.schema
.as_ref()
.expect("component response schema")
));
}
#[test]
fn a_remote_ref_inside_a_content_parameter_resolves() {
let (doc, _) = resolved(
&format!(
r#"
openapi: 3.0.0
components:
parameters:
Query:
name: query
in: query
content:
application/json:
schema:
$ref: {REMOTE}#/leaf
"#
),
&[(REMOTE, "leaf:\n type: string\n")],
);
assert_eq!(
doc.components.parameters["Query"].content["application/json"]
.schema
.as_ref()
.and_then(|s| s.ty.as_ref())
.and_then(|t| t.primary()),
Some("string")
);
}
#[test]
fn a_reference_cycle_across_documents_degrades_to_the_unknown_type() {
let (doc, _) = resolved(
&format!(
r#"
openapi: 3.0.0
components:
schemas:
node:
$ref: {REMOTE}#/node
"#
),
&[(
REMOTE,
&format!(
"node:\n type: object\n properties:\n next:\n $ref: {REMOTE}#/node\n"
),
)],
);
let next = &doc.components.schemas["node"].properties["next"];
assert_eq!(next.reference, None);
assert_eq!(next.ty.as_ref().and_then(|t| t.primary()), None);
}
#[test]
fn a_self_contained_document_fetches_nothing() {
let (_, fetched) = resolved(
r##"
openapi: 3.0.0
components:
schemas:
Local:
$ref: "#/components/schemas/Other"
Other:
type: string
"##,
&[],
);
assert!(fetched.is_empty(), "fetched {fetched:?}");
}
fn resolve_error(spec: &str, documents: &[(&str, &str)]) -> String {
let fetcher = FakeFetcher::new(documents);
let mut doc = parse(spec);
resolve(&mut doc, &fetcher, Path::new("spec.yml"))
.expect_err("unresolvable spec")
.to_string()
}
#[test]
fn a_document_that_cannot_be_fetched_is_an_actionable_error() {
let message = resolve_error(
&format!("openapi: 3.0.0\ncomponents:\n schemas:\n A:\n $ref: {REMOTE}#/a\n"),
&[],
);
assert!(message.contains(REMOTE), "{message}");
assert!(message.contains("no such document"), "{message}");
assert!(message.contains("spec.yml"), "{message}");
}
#[test]
fn a_missing_pointer_in_a_fetched_document_is_an_actionable_error() {
let message = resolve_error(
&format!(
"openapi: 3.0.0\ncomponents:\n schemas:\n A:\n $ref: {REMOTE}#/missing\n"
),
&[(REMOTE, "present:\n type: string\n")],
);
assert!(message.contains("no node at that pointer"), "{message}");
}
#[test]
fn a_document_that_is_not_parseable_is_an_actionable_error() {
let message = resolve_error(
&format!("openapi: 3.0.0\ncomponents:\n schemas:\n A:\n $ref: {REMOTE}#/a\n"),
&[(REMOTE, "a:\n - unbalanced: [\n")],
);
assert!(message.contains("could not parse it"), "{message}");
}
#[test]
fn a_pointer_at_a_non_schema_node_is_an_actionable_error() {
let message = resolve_error(
&format!("openapi: 3.0.0\ncomponents:\n schemas:\n A:\n $ref: {REMOTE}#/a\n"),
&[(REMOTE, "a: 7\n")],
);
assert!(message.contains("not a schema"), "{message}");
}
#[test]
fn a_pointer_walks_sequences_escapes_and_the_whole_document() {
let document: serde_json::Value =
crate::yaml::from_str("a/b:\n - first\n - second\n").expect("valid yaml");
assert_eq!(
pointer(&document, "/a~1b/1").and_then(serde_json::Value::as_str),
Some("second")
);
assert!(pointer(&document, "").is_some());
assert_eq!(pointer(&document, "/a~1b/9"), None);
assert_eq!(pointer(&document, "/a~1b/nope"), None);
assert_eq!(pointer(&document, "/missing"), None);
assert_eq!(pointer(&document, "/a~1b/0/deeper"), None);
}
fn serve_once(status: &'static str, body: &'static str) -> String {
use std::io::{BufRead, BufReader, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind a loopback port");
let url = format!(
"http://{}/doc.yaml",
listener.local_addr().expect("address")
);
std::thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let mut reader = BufReader::new(stream.try_clone().expect("clone the socket"));
let mut line = String::new();
while reader.read_line(&mut line).is_ok() {
if line.trim().is_empty() {
break;
}
line.clear();
}
let response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
});
url
}
#[test]
fn the_curl_fetcher_returns_the_served_document() {
let url = serve_once("200 OK", "address:\n type: string\n");
assert_eq!(
CurlFetcher.fetch(&url).expect("a served document"),
"address:\n type: string\n"
);
}
#[test]
fn the_curl_fetcher_reports_an_error_status_rather_than_its_body() {
let url = serve_once("404 Not Found", "<html>nope</html>");
let message = curl_fetch(CURL, &url).expect_err("a 404 is not a document");
assert!(message.starts_with("curl failed"), "{message}");
assert!(!message.contains("<html>"), "{message}");
}
#[test]
fn a_missing_curl_is_an_actionable_error() {
let message = curl_fetch(
"crozier-no-such-fetcher-on-path",
"https://example.test/doc.yaml",
)
.expect_err("the program does not exist");
assert!(message.contains("was not found on PATH"), "{message}");
}
#[test]
fn only_http_and_https_references_are_fetched() {
assert_eq!(
split_remote("https://example.test/s.yaml#/a"),
Some(("https://example.test/s.yaml", "/a"))
);
assert_eq!(
split_remote("http://example.test/s.yaml"),
Some(("http://example.test/s.yaml", ""))
);
assert_eq!(split_remote("#/components/schemas/A"), None);
assert_eq!(split_remote("file:///etc/passwd"), None);
assert_eq!(split_remote("./other.yaml#/A"), None);
}
#[test]
fn a_path_item_in_a_sibling_file_imports_its_named_schema_dependencies() {
let directory = tempfile::tempdir().expect("temporary spec tree");
let root = directory.path().join("openapi.yml");
std::fs::create_dir(directory.path().join("paths")).expect("paths directory");
std::fs::create_dir(directory.path().join("schemas")).expect("schemas directory");
std::fs::write(
&root,
"openapi: 3.0.3\ninfo: {title: Real Tree, version: '1'}\npaths:\n /health:\n $ref: paths/health.yml\n",
)
.expect("root");
std::fs::write(
directory.path().join("paths/health.yml"),
"get:\n operationId: getHealth\n responses:\n '200':\n description: OK\n content:\n application/json:\n schema:\n $ref: ../schemas/models.yml#/HealthResponse\n",
)
.expect("path item");
std::fs::write(
directory.path().join("schemas/models.yml"),
"HealthResponse:\n type: object\n properties:\n status:\n $ref: '#/Status'\nStatus:\n type: string\n enum: [ok, down]\n",
)
.expect("schema file");
let mut document = parse(&std::fs::read_to_string(&root).expect("root bytes"));
resolve(&mut document, &CurlFetcher, &root).expect("resolve the complete tree");
let item = &document.paths["/health"];
assert!(item.get.is_some(), "the sibling supplied an operation");
let response = item.get.as_ref().expect("get").responses["200"].content["application/json"]
.schema
.as_ref()
.expect("response schema");
assert_eq!(
response.reference.as_deref(),
Some("#/components/schemas/HealthResponse")
);
assert_eq!(
document.components.schemas["HealthResponse"].properties["status"]
.reference
.as_deref(),
Some("#/components/schemas/Status")
);
assert!(document.components.schemas.contains_key("Status"));
}
#[test]
fn a_sibling_path_item_resolves_its_parameter_from_a_third_file() {
let directory = tempfile::tempdir().expect("temporary spec tree");
let root = directory.path().join("openapi.yml");
std::fs::create_dir(directory.path().join("paths")).expect("paths directory");
std::fs::create_dir(directory.path().join("parameters")).expect("parameters directory");
std::fs::write(
&root,
"openapi: 3.0.3\ninfo: {title: Real Tree, version: '1'}\npaths:\n /health:\n $ref: paths/health.yml#/Health\n",
)
.expect("root");
std::fs::write(
directory.path().join("paths/health.yml"),
"Health:\n parameters:\n - $ref: ../parameters/query.yml#/Query\n get:\n operationId: getHealth\n responses:\n '200': {description: OK}\n",
)
.expect("path item");
let parameter_file = directory.path().join("parameters/query.yml");
std::fs::write(
¶meter_file,
"Query:\n name: verbose\n in: query\n schema: {type: boolean}\n",
)
.expect("parameter");
let mut document = parse(&std::fs::read_to_string(&root).expect("root bytes"));
resolve(&mut document, &CurlFetcher, &root).expect("resolve the complete tree");
assert_eq!(document.paths["/health"].parameters[0].name, "verbose");
std::fs::write(¶meter_file, "Other: {name: other, in: query}\n")
.expect("replace parameter");
let mut document = parse(&std::fs::read_to_string(&root).expect("root bytes"));
let error = resolve(&mut document, &CurlFetcher, &root)
.expect_err("missing pointer in a present sibling document");
assert!(
error.to_string().contains("no parameter at that pointer"),
"{error}"
);
}
#[test]
fn a_present_sibling_without_the_named_path_item_reports_the_pointer() {
let directory = tempfile::tempdir().expect("temporary spec tree");
let root = directory.path().join("openapi.yml");
std::fs::write(
&root,
"openapi: 3.0.3\ninfo: {title: Real Tree, version: '1'}\npaths:\n /health:\n $ref: paths.yml#/Health\n",
)
.expect("root");
std::fs::write(
directory.path().join("paths.yml"),
"Other: {get: {responses: {}}}\n",
)
.expect("present sibling");
let mut document = parse(&std::fs::read_to_string(&root).expect("root bytes"));
let error = resolve(&mut document, &CurlFetcher, &root)
.expect_err("missing Path Item pointer must be diagnosed");
let message = error.to_string();
assert!(message.contains("paths.yml#/Health"), "{message}");
assert!(
message.contains("no Path Item at that pointer"),
"{message}"
);
}
#[test]
fn a_sibling_path_item_reports_wrong_shapes_and_broken_nested_references() {
let directory = tempfile::tempdir().expect("temporary spec tree");
let root = directory.path().join("openapi.yml");
let sibling = directory.path().join("paths.yml");
std::fs::write(
&root,
"openapi: 3.0.3\ninfo: {title: Real Tree, version: '1'}\npaths:\n /health:\n $ref: paths.yml#/Health\n",
)
.expect("root");
for (body, expected) in [
("Health: []\n", "not a Path Item"),
("Health: 7\n", "not a Path Item"),
(
"Health:\n get:\n responses:\n '200':\n description: OK\n content:\n application/json:\n schema:\n $ref: missing.yml#/Model\n",
"could not read",
),
] {
std::fs::write(&sibling, body).expect("sibling");
let mut document = parse(&std::fs::read_to_string(&root).expect("root bytes"));
let error = resolve(&mut document, &CurlFetcher, &root)
.expect_err("a malformed sibling must be diagnosed");
assert!(error.to_string().contains(expected), "{error}");
}
}
#[test]
fn a_windows_path_keeps_local_schema_dependencies_in_the_same_document() {
let fetcher = FakeFetcher::new(&[]);
let spec = Path::new("openapi.yml");
let path = DocumentLocation::Local(PathBuf::from(r"C:\api\schemas\models.yml"));
let mut resolver = Resolver {
fetcher: &fetcher,
spec,
documents: HashMap::from([(
path.key(),
crate::yaml::from_str(
"HealthResponse:\n type: object\n properties:\n status:\n $ref: '#/Status'\nStatus:\n type: string\n enum: [ok, down]\n",
)
.expect("sibling document"),
)]),
active: Vec::new(),
imported: IndexMap::new(),
importing: HashSet::new(),
root_schemas: HashSet::new(),
};
assert_eq!(
resolver
.import_named_schema_at_path(&path, "HealthResponse", "models.yml#/HealthResponse")
.expect("import the schema and its local dependency"),
Some("HealthResponse".to_string())
);
assert_eq!(
resolver.imported["HealthResponse"].properties["status"]
.reference
.as_deref(),
Some("#/components/schemas/Status")
);
assert!(resolver.imported.contains_key("Status"));
assert!(fetcher.fetched.borrow().is_empty());
}
#[test]
fn a_missing_relative_path_item_is_discarded_like_fern() {
let directory = tempfile::tempdir().expect("temporary spec tree");
let root = directory.path().join("openapi.yml");
std::fs::write(
&root,
"openapi: 3.0.3\ninfo: {title: Missing, version: '1'}\npaths:\n /health:\n $ref: paths/absent.yml\n",
)
.expect("root");
let mut document = parse(&std::fs::read_to_string(&root).expect("root bytes"));
resolve(&mut document, &CurlFetcher, &root).expect("Fern discards a missing Path Item");
assert!(document.paths["/health"].get.is_none());
}
#[test]
fn a_missing_relative_schema_is_an_actionable_error() {
let directory = tempfile::tempdir().expect("temporary spec tree");
let root = directory.path().join("openapi.yml");
std::fs::write(
&root,
"openapi: 3.0.3\ninfo: {title: Missing, version: '1'}\npaths: {}\ncomponents:\n schemas:\n Missing:\n $ref: schemas/absent.yml#/Missing\n",
)
.expect("root");
let mut document = parse(&std::fs::read_to_string(&root).expect("root bytes"));
let error = resolve(&mut document, &CurlFetcher, &root).expect_err("missing schema");
let message = error.to_string();
assert!(message.contains("schemas/absent.yml"), "{message}");
assert!(message.contains("could not read"), "{message}");
}
#[test]
fn a_missing_sibling_schema_pointer_in_a_response_field_becomes_unknown() {
let directory = tempfile::tempdir().expect("temporary spec tree");
let root = directory.path().join("openapi.yml");
std::fs::write(
&root,
"openapi: 3.0.3\ninfo: {title: Missing, version: '1'}\npaths:\n /feature:\n get:\n operationId: getFeature\n responses:\n '200':\n description: OK\n content:\n application/json:\n schema:\n type: object\n properties:\n geometry:\n $ref: ./geometry.yml#/components/schemas/Geometry\n",
)
.expect("root");
let mut document = parse(&std::fs::read_to_string(&root).expect("root bytes"));
resolve(&mut document, &CurlFetcher, &root)
.expect("Fern types the missing field as unknown");
let geometry = &document.paths["/feature"]
.get
.as_ref()
.expect("get")
.responses["200"]
.content["application/json"]
.schema
.as_ref()
.expect("response schema")
.properties["geometry"];
assert!(geometry.reference.is_none());
assert!(geometry.ty.is_none());
}
#[test]
fn a_present_sibling_schema_pointer_in_a_response_field_resolves() {
let directory = tempfile::tempdir().expect("temporary spec tree");
let root = directory.path().join("openapi.yml");
std::fs::write(
&root,
"openapi: 3.0.3\ninfo: {title: Present, version: '1'}\npaths:\n /feature:\n get:\n operationId: getFeature\n responses:\n '200':\n description: OK\n content:\n application/json:\n schema:\n type: object\n properties:\n geometry:\n $ref: ./geometry.yml#/components/schemas/Geometry\n",
)
.expect("root");
std::fs::write(
directory.path().join("geometry.yml"),
"components:\n schemas:\n Geometry:\n type: string\n",
)
.expect("sibling schema");
let mut document = parse(&std::fs::read_to_string(&root).expect("root bytes"));
resolve(&mut document, &CurlFetcher, &root).expect("resolve present sibling schema");
let geometry = &document.paths["/feature"]
.get
.as_ref()
.expect("get")
.responses["200"]
.content["application/json"]
.schema
.as_ref()
.expect("response schema")
.properties["geometry"];
assert_eq!(
geometry.reference.as_deref(),
Some("#/components/schemas/Geometry")
);
assert_eq!(
document.components.schemas["Geometry"]
.ty
.as_ref()
.and_then(|ty| ty.primary()),
Some("string")
);
}
fn resolved_tree(root: &str, siblings: &[(&str, &str)]) -> (tempfile::TempDir, OpenApi) {
let directory = tempfile::tempdir().expect("temporary spec tree");
let spec = directory.path().join("api").join("openapi.yml");
std::fs::create_dir_all(spec.parent().expect("root directory")).expect("root directory");
std::fs::write(&spec, root).expect("root document");
for (path, text) in siblings {
let file = directory.path().join("api").join(path);
std::fs::create_dir_all(file.parent().expect("sibling directory"))
.expect("sibling directory");
std::fs::write(file, text).expect("sibling document");
}
let mut document = parse(root);
resolve(&mut document, &FakeFetcher::new(&[]), &spec).expect("resolvable tree");
(directory, document)
}
const SCHEME_ROOT: &str = "openapi: 3.0.3\ncomponents:\n securitySchemes:\n BearerAuth:\n $ref: '../components.yaml#/components/securitySchemes/BearerAuth'\n";
#[test]
fn an_external_security_scheme_reference_resolves_to_its_declaration() {
let (_tree, doc) = resolved_tree(
SCHEME_ROOT,
&[(
"../components.yaml",
"components:\n securitySchemes:\n BearerAuth: {type: http, scheme: bearer}\n",
)],
);
let scheme = &doc.components.security_schemes["BearerAuth"];
assert!(scheme.reference.is_none());
assert_eq!(scheme.ty, crate::openapi::SecuritySchemeType::Http);
assert_eq!(scheme.scheme, Some(crate::openapi::HttpAuthScheme::Bearer));
}
#[test]
fn an_external_security_scheme_chain_is_followed_inside_the_target_document() {
let (_tree, doc) = resolved_tree(
SCHEME_ROOT,
&[(
"../components.yaml",
"components:\n securitySchemes:\n BearerAuth: {$ref: '#/components/securitySchemes/Actual'}\n Actual: {type: apiKey, in: header, name: X-Key}\n",
)],
);
let scheme = &doc.components.security_schemes["BearerAuth"];
assert_eq!(scheme.ty, crate::openapi::SecuritySchemeType::ApiKey);
assert_eq!(scheme.name.as_deref(), Some("X-Key"));
}
#[test]
fn an_unresolvable_security_scheme_reference_is_left_as_written() {
for target in [
"components:\n securitySchemes:\n BearerAuth: {$ref: '#/components/securitySchemes/BearerAuth'}\n",
"components:\n securitySchemes: {}\n",
"components:\n securitySchemes:\n BearerAuth: [not, a, scheme]\n",
] {
let (_tree, doc) = resolved_tree(SCHEME_ROOT, &[("../components.yaml", target)]);
assert_eq!(
doc.components.security_schemes["BearerAuth"].reference.as_deref(),
Some("../components.yaml#/components/securitySchemes/BearerAuth"),
"{target}"
);
}
}
#[test]
fn in_document_and_absolute_security_scheme_references_are_not_followed_here() {
let (doc, fetched) = resolved(
&format!(
"openapi: 3.0.3\ncomponents:\n securitySchemes:\n Local: {{$ref: '#/components/securitySchemes/Actual'}}\n Actual: {{type: http, scheme: bearer}}\n Remote: {{$ref: '{REMOTE}#/Bearer'}}\n"
),
&[(REMOTE, "Bearer: {type: http, scheme: bearer}\n")],
);
assert!(fetched.is_empty());
for name in ["Local", "Remote"] {
assert!(
doc.components.security_schemes[name].reference.is_some(),
"{name}"
);
}
}
#[test]
fn a_sibling_component_is_imported_with_the_components_it_names() {
let (_tree, doc) = resolved_tree(
"openapi: 3.0.3\npaths:\n /x:\n get:\n responses:\n '400':\n description: Bad\n content:\n application/json:\n schema:\n $ref: '../components.yaml#/components/schemas/ErrorResponse'\n",
&[(
"../components.yaml",
"components:\n schemas:\n ErrorResponse:\n type: object\n properties:\n error: {$ref: '#/components/schemas/Error'}\n Error:\n type: object\n properties:\n code: {type: string}\n",
)],
);
let schema = doc.paths["/x"].get.as_ref().expect("get").responses["400"].content
["application/json"]
.schema
.as_ref()
.expect("error schema");
assert_eq!(
schema.reference.as_deref(),
Some("#/components/schemas/ErrorResponse")
);
assert_eq!(
doc.components.schemas["ErrorResponse"].properties["error"]
.reference
.as_deref(),
Some("#/components/schemas/Error")
);
assert!(doc.components.schemas["Error"]
.properties
.contains_key("code"));
}
#[test]
fn a_sibling_component_never_displaces_a_root_component_of_its_name() {
let (_tree, doc) = resolved_tree(
"openapi: 3.0.3\ncomponents:\n schemas:\n Error: {type: integer}\n Wrapper:\n properties:\n error: {$ref: '../components.yaml#/components/schemas/Error'}\n",
&[(
"../components.yaml",
"components:\n schemas:\n Error: {type: string}\n",
)],
);
assert_eq!(
doc.components.schemas["Error"]
.ty
.as_ref()
.and_then(|ty| ty.primary()),
Some("integer")
);
let error = &doc.components.schemas["Wrapper"].properties["error"];
assert!(error.reference.is_none());
assert_eq!(
error.ty.as_ref().and_then(|ty| ty.primary()),
Some("string")
);
}
}