use std::cell::RefCell;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::rc::Rc;
use indexmap::IndexMap;
use openapiv3::OpenAPI;
use openapiv3::Parameter;
use openapiv3::ReferenceOr;
use openapiv3::RequestBody;
use openapiv3::Response;
use openapiv3::Schema;
use crate::error::Error;
use crate::error::Result;
use crate::lower::direction::REQUEST_SUFFIX;
use crate::lower::direction::RESPONSE_SUFFIX;
pub(crate) const MAX_REF_DEPTH: usize = 32;
const SUPPORTED_SPEC_VERSIONS: [&str; 1] = ["3.0"];
const UNSUPPORTED_TOP_LEVEL_KEYS: [&str; 1] = ["webhooks"];
static EMPTY_SCHEMAS: std::sync::OnceLock<IndexMap<String, ReferenceOr<Schema>>> = std::sync::OnceLock::new();
static EMPTY_SECURITY_SCHEMES: std::sync::OnceLock<IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>>> =
std::sync::OnceLock::new();
#[derive(Debug, Clone)]
pub struct Resolved<T> {
pub value: T,
pub origin: Option<String>,
}
#[derive(Debug)]
pub struct Spec {
inner: OpenAPI,
source: PathBuf,
docs: RefCell<HashMap<PathBuf, Rc<OpenAPI>>>,
run: crate::coverage::Run,
external_uses: Vec<(String, String)>,
raw: RawDocuments,
}
#[derive(Debug)]
struct RawDocuments {
base: PathBuf,
read: RefCell<HashMap<PathBuf, Option<Rc<serde_yaml::Value>>>>,
}
impl RawDocuments {
fn new(source: &Path) -> Self {
let base = source.parent().unwrap_or_else(|| return Path::new(".")).to_path_buf();
return Self {
base,
read: RefCell::new(HashMap::new()),
};
}
fn path_of(&self, file: &str) -> PathBuf {
return self.base.join(file);
}
fn same_document(&self, file: &str, other: &str) -> bool {
return self.path_of(file) == self.path_of(other);
}
fn document(&self, file: &str) -> Option<Rc<serde_yaml::Value>> {
let path = self.path_of(file);
if let Some(known) = self.read.borrow().get(&path) {
return known.clone();
}
let value = std::fs::read_to_string(&path)
.ok()
.and_then(|text| return serde_yaml::from_str::<serde_yaml::Value>(&text).ok())
.map(Rc::new);
self.read.borrow_mut().insert(path, value.clone());
return value;
}
fn uses_of(&self, uses: &[(String, String)], file: &str) -> Vec<String> {
return uses
.iter()
.filter(|(from, _)| return self.same_document(from, file))
.map(|(_, path)| return path.clone())
.collect();
}
}
impl crate::coverage::Lookup for RawDocuments {
fn component(&self, file: &str, kind: &str, name: &str) -> Option<serde_yaml::Value> {
return self.document(file)?.get("components")?.get(kind)?.get(name).cloned();
}
}
fn settle_external_uses(
mut uses: Vec<(String, String)>,
run: &crate::coverage::Run,
raw: &RawDocuments,
) -> Vec<(String, String)> {
for _ in 0..MAX_REF_DEPTH {
let mut grew = false;
let files: std::collections::BTreeSet<String> = uses.iter().map(|(file, _)| return file.clone()).collect();
for file in files {
let Some(document) = raw.document(&file) else {
continue;
};
let run = crate::coverage::Run {
referenced: true,
referenced_uses: raw.uses_of(&uses, &file),
..run.clone()
};
for found in crate::coverage::external_uses(&document, &run, raw) {
let known = uses
.iter()
.any(|(from, path)| return *path == found.1 && raw.same_document(from, &found.0));
if !known {
uses.push(found);
grew = true;
}
}
}
if !grew {
break;
}
}
return uses;
}
impl Spec {
pub fn load(path: &Path) -> Result<Self> {
return Self::load_with(
path,
&crate::config::Generate::default(),
&crate::config::OutputOptions::default(),
);
}
pub fn load_for(path: &Path, server: bool, options: &crate::config::OutputOptions) -> Result<Self> {
let generate = crate::config::Generate {
std_http_server: server,
..crate::config::Generate::default()
};
return Self::load_with(path, &generate, options);
}
pub fn load_with(
path: &Path,
generate: &crate::config::Generate,
options: &crate::config::OutputOptions,
) -> Result<Self> {
let run = crate::coverage::Run {
server: generate.std_http_server,
operations: generate.std_http_server || generate.client,
referenced: false,
referenced_uses: Vec::new(),
filters: options.clone(),
};
let text = std::fs::read_to_string(path).map_err(|source| {
return Error::ReadSpec {
path: path.display().to_string(),
source,
};
})?;
let document = path.display().to_string();
let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
return Error::ParseSpec {
path: document.clone(),
source,
};
})?;
check_spec_version(&document, &value)?;
check_top_level_keys(&value)?;
let raw = RawDocuments::new(path);
let inspected = crate::coverage::check(&document, &value, &run, &raw)?;
let external_uses = settle_external_uses(inspected.external_uses, &run, &raw);
let mut value = value;
crate::coverage::wrap_described_refs(&mut value, &inspected.described_refs);
let inner: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
return Error::ParseSpec {
path: document.clone(),
source,
};
})?;
return Ok(Spec {
inner,
source: path.to_path_buf(),
docs: RefCell::new(HashMap::new()),
run,
external_uses,
raw,
});
}
pub fn from_parts(inner: OpenAPI, source: PathBuf) -> Self {
let raw = RawDocuments::new(&source);
return Spec {
inner,
source,
docs: RefCell::new(HashMap::new()),
run: crate::coverage::Run::default(),
external_uses: Vec::new(),
raw,
};
}
fn document_for(&self, file: &str) -> Result<Rc<OpenAPI>> {
let base = self.source.parent().unwrap_or_else(|| {
return Path::new(".");
});
let path = base.join(file);
if let Some(doc) = self.docs.borrow().get(&path) {
return Ok(Rc::clone(doc));
}
let text = std::fs::read_to_string(&path).map_err(|source| {
return Error::ReadRefFile {
file: file.to_owned(),
source,
};
})?;
let value: serde_yaml::Value = serde_yaml::from_str(&text).map_err(|source| {
return Error::ParseRefFile {
file: file.to_owned(),
source,
};
})?;
check_spec_version(file, &value)?;
check_top_level_keys(&value)?;
let referenced_uses = self.raw.uses_of(&self.external_uses, file);
let run = crate::coverage::Run {
referenced: true,
referenced_uses,
..self.run.clone()
};
let inspected = crate::coverage::check(file, &value, &run, &self.raw)?;
let mut value = value;
crate::coverage::wrap_described_refs(&mut value, &inspected.described_refs);
let parsed: OpenAPI = serde_yaml::from_value(value).map_err(|source| {
return Error::ParseRefFile {
file: file.to_owned(),
source,
};
})?;
let doc = Rc::new(parsed);
self.docs.borrow_mut().insert(path, Rc::clone(&doc));
return Ok(doc);
}
pub fn source(&self) -> &Path {
return &self.source;
}
pub fn apply_filters(&mut self, opts: &crate::config::OutputOptions) {
crate::filter::apply(&mut self.inner, opts);
}
pub fn schemas(&self) -> &IndexMap<String, ReferenceOr<Schema>> {
let empty = EMPTY_SCHEMAS.get_or_init(IndexMap::new);
let schemas = self
.inner
.components
.as_ref()
.map(|c| {
return &c.schemas;
})
.unwrap_or(empty);
return schemas;
}
pub fn paths(&self) -> &openapiv3::Paths {
return &self.inner.paths;
}
pub fn servers(&self) -> &[openapiv3::Server] {
return &self.inner.servers;
}
pub fn global_security(&self) -> Option<&[openapiv3::SecurityRequirement]> {
return self.inner.security.as_deref();
}
pub fn security_schemes(&self) -> &IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>> {
let empty = EMPTY_SECURITY_SCHEMES.get_or_init(IndexMap::new);
let schemes = self
.inner
.components
.as_ref()
.map(|c| {
return &c.security_schemes;
})
.unwrap_or(empty);
return schemes;
}
pub fn resolve_response(&self, reference: &str) -> Result<Resolved<Response>> {
let mut current = reference.to_owned();
let mut origin: Option<String> = None;
for _ in 0..MAX_REF_DEPTH {
if let Some(file) = ref_file_part(¤t) {
origin = Some(file.to_owned());
}
let name = ref_component_name(¤t, "responses").ok_or_else(|| {
return Error::UnsupportedRef {
reference: current.clone(),
reason: "only `#/components/responses/<name>` references are supported".to_owned(),
};
})?;
let entry = self.component_response(origin.as_deref(), ¤t, name)?;
match entry {
ReferenceOr::Item(response) => {
return Ok(Resolved {
value: response,
origin,
});
}
ReferenceOr::Reference { reference } => {
current = reference;
}
}
}
return Err(Error::UnresolvedRef(reference.to_owned()));
}
pub fn resolve_parameter(&self, reference: &str) -> Result<Resolved<Parameter>> {
let mut current = reference.to_owned();
let mut origin: Option<String> = None;
for _ in 0..MAX_REF_DEPTH {
if let Some(file) = ref_file_part(¤t) {
origin = Some(file.to_owned());
}
let name = ref_component_name(¤t, "parameters").ok_or_else(|| {
return Error::UnsupportedRef {
reference: current.clone(),
reason: "only `#/components/parameters/<name>` references are supported".to_owned(),
};
})?;
let entry = self.component_parameter(origin.as_deref(), ¤t, name)?;
match entry {
ReferenceOr::Item(parameter) => {
return Ok(Resolved {
value: parameter,
origin,
});
}
ReferenceOr::Reference { reference } => {
current = reference;
}
}
}
return Err(Error::UnresolvedRef(reference.to_owned()));
}
pub fn resolve_request_body(&self, reference: &str) -> Result<Resolved<RequestBody>> {
let mut current = reference.to_owned();
let mut origin: Option<String> = None;
for _ in 0..MAX_REF_DEPTH {
if let Some(file) = ref_file_part(¤t) {
origin = Some(file.to_owned());
}
let name = ref_component_name(¤t, "requestBodies").ok_or_else(|| {
return Error::UnsupportedRef {
reference: current.clone(),
reason: "only `#/components/requestBodies/<name>` references are supported".to_owned(),
};
})?;
let entry = self.component_request_body(origin.as_deref(), ¤t, name)?;
match entry {
ReferenceOr::Item(body) => {
return Ok(Resolved { value: body, origin });
}
ReferenceOr::Reference { reference } => {
current = reference;
}
}
}
return Err(Error::UnresolvedRef(reference.to_owned()));
}
pub fn resolve(&self, reference: &str) -> Result<&Schema> {
let mut current = reference.to_owned();
for _ in 0..MAX_REF_DEPTH {
let name = ref_target_name(¤t).ok_or_else(|| {
return Error::UnsupportedRef {
reference: current.clone(),
reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
};
})?;
let entry = self
.schemas()
.get(name)
.ok_or_else(|| return Error::UnresolvedRef(current.clone()))?;
match entry {
ReferenceOr::Item(schema) => {
return Ok(schema);
}
ReferenceOr::Reference { reference } => {
current = reference.clone();
}
}
}
return Err(Error::UnresolvedRef(reference.to_owned()));
}
pub fn resolve_schema(&self, origin: Option<&str>, reference: &str) -> Result<Schema> {
let mut current = reference.to_owned();
for _ in 0..MAX_REF_DEPTH {
if ref_file_part(¤t).is_some() {
return Err(Error::UnsupportedRef {
reference: current.clone(),
reason: "cross-file schema `$ref`s are not supported here".to_owned(),
});
}
let name = ref_component_name(¤t, "schemas").ok_or_else(|| {
return Error::UnsupportedRef {
reference: current.clone(),
reason: "only `#/components/schemas/<name>` references are supported".to_owned(),
};
})?;
let entry = self.component_schema(origin, ¤t, name)?;
match entry {
ReferenceOr::Item(schema) => {
return Ok(schema);
}
ReferenceOr::Reference { reference } => {
current = reference;
}
}
}
return Err(Error::UnresolvedRef(reference.to_owned()));
}
fn component_lookup<T>(
&self,
origin: Option<&str>,
reference: &str,
select: impl Fn(&OpenAPI) -> Option<ReferenceOr<T>>,
) -> Result<ReferenceOr<T>> {
let entry = match origin {
None => select(&self.inner),
Some(file) => {
let doc = self.document_for(file)?;
select(doc.as_ref())
}
};
return entry.ok_or_else(|| return Error::UnresolvedRef(reference.to_owned()));
}
fn component_response(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Response>> {
return self.component_lookup(origin, reference, |doc| {
return doc.components.as_ref().and_then(|components| {
return components.responses.get(name).cloned();
});
});
}
fn component_parameter(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Parameter>> {
return self.component_lookup(origin, reference, |doc| {
return doc.components.as_ref().and_then(|components| {
return components.parameters.get(name).cloned();
});
});
}
fn component_request_body(
&self,
origin: Option<&str>,
reference: &str,
name: &str,
) -> Result<ReferenceOr<RequestBody>> {
return self.component_lookup(origin, reference, |doc| {
return doc.components.as_ref().and_then(|components| {
return components.request_bodies.get(name).cloned();
});
});
}
fn component_schema(&self, origin: Option<&str>, reference: &str, name: &str) -> Result<ReferenceOr<Schema>> {
return self.component_lookup(origin, reference, |doc| {
return doc.components.as_ref().and_then(|components| {
return components.schemas.get(name).cloned();
});
});
}
pub fn external_schema_name(&self, file: &str, name: &str, reference: &str) -> Result<String> {
let entry = self.component_schema(Some(file), reference, name)?;
let chosen = external_name_of(&entry, name)?;
let doc = self.document_for(file)?;
let schemas = doc.components.as_ref().map(|components| return &components.schemas);
let ident = crate::naming::to_ident(&chosen, crate::naming::Case::Pascal);
if direction_split_schemas(&doc).contains(name) {
let shape = ident.logical();
return Err(Error::UnsupportedRef {
reference: reference.to_owned(),
reason: format!(
"`{name}` in `{file}` marks a property `readOnly` or `writeOnly`, so the run that \
generates that file emits it as `{shape}{REQUEST_SUFFIX}` and `{shape}{RESPONSE_SUFFIX}`. \
This run cannot tell which of the two an `import-mapping` reference means. Declare \
the schema in this document instead, or drop the mark"
),
});
}
for (other, other_entry) in schemas.into_iter().flatten() {
if other == name {
continue;
}
let taken = external_name_of(other_entry, other)?;
if crate::naming::to_ident(&taken, crate::naming::Case::Pascal).logical() == ident.logical() {
return Err(Error::UnsupportedRef {
reference: reference.to_owned(),
reason: format!(
"`{file}` gives `{name}` and `{other}` the one Rust name `{}`",
ident.logical()
),
});
}
}
return Ok(chosen);
}
}
fn direction_split_schemas(doc: &OpenAPI) -> std::collections::BTreeSet<String> {
let mut marked = std::collections::BTreeSet::new();
let Some(components) = doc.components.as_ref() else {
return marked;
};
let directional: std::collections::BTreeSet<String> = components
.schemas
.keys()
.filter(|name| {
return resolved_component(&components.schemas, name)
.is_some_and(|schema| return schema.schema_data.read_only || schema.schema_data.write_only);
})
.cloned()
.collect();
let mut referrers: HashMap<String, Vec<String>> = HashMap::new();
for (name, entry) in &components.schemas {
match entry {
ReferenceOr::Item(schema) => {
if schema_marks_a_direction(schema, &directional) {
marked.insert(name.clone());
}
let mut targets = Vec::new();
schema_local_refs(schema, &mut targets);
for target in targets {
referrers.entry(target).or_default().push(name.clone());
}
}
ReferenceOr::Reference { reference } => {
if let Some(target) = ref_target_name(reference) {
referrers.entry(target.to_owned()).or_default().push(name.clone());
}
}
}
}
let mut stack: Vec<String> = marked.iter().cloned().collect();
while let Some(name) = stack.pop() {
let Some(parents) = referrers.get(&name) else {
continue;
};
for parent in parents {
if marked.insert(parent.clone()) {
stack.push(parent.clone());
}
}
}
return marked;
}
fn schema_marks_a_direction(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
let mut found = declares_a_directional_property(schema, directional);
walk_inline_schemas(schema, &mut |inner| {
found = found || declares_a_directional_property(inner, directional);
});
return found;
}
fn declares_a_directional_property(schema: &Schema, directional: &std::collections::BTreeSet<String>) -> bool {
let names_a_marked_schema = |reference: &str| {
return ref_file_part(reference).is_none()
&& ref_component_name(reference, "schemas").is_some_and(|name| return directional.contains(name));
};
return object_properties(schema).iter().any(|entry| {
return match entry {
ReferenceOr::Item(inner) => {
inner.schema_data.read_only
|| inner.schema_data.write_only
|| single_ref_member(inner).is_some_and(names_a_marked_schema)
}
ReferenceOr::Reference { reference } => names_a_marked_schema(reference),
};
});
}
fn resolved_component<'a>(schemas: &'a IndexMap<String, ReferenceOr<Schema>>, name: &str) -> Option<&'a Schema> {
let mut current = name;
for _ in 0..MAX_REF_DEPTH {
match schemas.get(current)? {
ReferenceOr::Item(schema) => return Some(schema),
ReferenceOr::Reference { reference } => {
if ref_file_part(reference).is_some() {
return None;
}
current = ref_component_name(reference, "schemas")?;
}
}
}
return None;
}
pub(crate) fn single_ref_member(schema: &Schema) -> Option<&str> {
let openapiv3::SchemaKind::AllOf { all_of } = &schema.schema_kind else {
return None;
};
let [ReferenceOr::Reference { reference }] = all_of.as_slice() else {
return None;
};
return Some(reference);
}
fn object_properties(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
let unbox = |entry: &ReferenceOr<Box<Schema>>| {
return match entry {
ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
ReferenceOr::Reference { reference } => ReferenceOr::Reference {
reference: reference.clone(),
},
};
};
return match &schema.schema_kind {
openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
return object.properties.values().map(&unbox).collect();
}
openapiv3::SchemaKind::Any(any) => return any.properties.values().map(&unbox).collect(),
openapiv3::SchemaKind::Type(_)
| openapiv3::SchemaKind::OneOf { .. }
| openapiv3::SchemaKind::AllOf { .. }
| openapiv3::SchemaKind::AnyOf { .. }
| openapiv3::SchemaKind::Not { .. } => Vec::new(),
};
}
fn schema_local_refs(schema: &Schema, out: &mut Vec<String>) {
let mut collect = |entry: &ReferenceOr<Schema>| {
if let ReferenceOr::Reference { reference } = entry
&& ref_file_part(reference).is_none()
&& let Some(name) = ref_component_name(reference, "schemas")
{
out.push(name.to_owned());
}
};
for entry in inline_members(schema) {
collect(&entry);
}
walk_inline_schemas(schema, &mut |inner| {
for entry in inline_members(inner) {
collect(&entry);
}
});
}
fn walk_inline_schemas(schema: &Schema, visit: &mut impl FnMut(&Schema)) {
for entry in inline_members(schema) {
if let ReferenceOr::Item(inner) = entry {
visit(&inner);
walk_inline_schemas(&inner, visit);
}
}
}
fn inline_members(schema: &Schema) -> Vec<ReferenceOr<Schema>> {
let unbox = |entry: &ReferenceOr<Box<Schema>>| {
return match entry {
ReferenceOr::Item(inner) => ReferenceOr::Item((**inner).clone()),
ReferenceOr::Reference { reference } => ReferenceOr::Reference {
reference: reference.clone(),
},
};
};
let additional = |entry: &Option<openapiv3::AdditionalProperties>| {
return match entry {
Some(openapiv3::AdditionalProperties::Schema(inner)) => vec![(**inner).clone()],
Some(openapiv3::AdditionalProperties::Any(_)) | None => Vec::new(),
};
};
let mut members = Vec::new();
match &schema.schema_kind {
openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
members.extend(object.properties.values().map(&unbox));
members.extend(additional(&object.additional_properties));
}
openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
members.extend(array.items.as_ref().map(unbox));
}
openapiv3::SchemaKind::Type(_) => {}
openapiv3::SchemaKind::OneOf { one_of } => members.extend(one_of.iter().cloned()),
openapiv3::SchemaKind::AllOf { all_of } => members.extend(all_of.iter().cloned()),
openapiv3::SchemaKind::AnyOf { any_of } => members.extend(any_of.iter().cloned()),
openapiv3::SchemaKind::Not { not } => members.push((**not).clone()),
openapiv3::SchemaKind::Any(any) => {
members.extend(any.properties.values().map(&unbox));
members.extend(additional(&any.additional_properties));
members.extend(any.items.as_ref().map(unbox));
members.extend(any.one_of.iter().cloned());
members.extend(any.all_of.iter().cloned());
members.extend(any.any_of.iter().cloned());
members.extend(any.not.as_ref().map(|not| return (**not).clone()));
}
}
return members;
}
fn external_name_of(entry: &ReferenceOr<Schema>, name: &str) -> Result<String> {
let renamed = match entry {
ReferenceOr::Item(schema) => {
crate::lower::extension::str_value(&schema.schema_data.extensions, crate::naming::X_RUST_NAME, name)?
}
ReferenceOr::Reference { .. } => None,
};
return Ok(renamed.unwrap_or(name).to_owned());
}
fn check_spec_version(document: &str, value: &serde_yaml::Value) -> Result<()> {
let Some(version) = value.get("openapi").and_then(serde_yaml::Value::as_str) else {
return Ok(());
};
let version = version.trim();
let supported = SUPPORTED_SPEC_VERSIONS.iter().any(|minor| {
return version == *minor || version.starts_with(&format!("{minor}."));
});
if supported {
return Ok(());
}
let reads = SUPPORTED_SPEC_VERSIONS
.iter()
.map(|minor| {
return format!("{minor}.x");
})
.collect::<Vec<_>>()
.join(", ");
return Err(Error::UnsupportedSpecVersion {
document: document.to_owned(),
version: version.to_owned(),
hint: format!("The generator reads OpenAPI {reads} only."),
});
}
fn check_top_level_keys(value: &serde_yaml::Value) -> Result<()> {
let Some(mapping) = value.as_mapping() else {
return Ok(());
};
for key in UNSUPPORTED_TOP_LEVEL_KEYS {
if mapping.contains_key(serde_yaml::Value::String(key.to_owned())) {
return Err(Error::UnsupportedSpecKey {
key: key.to_owned(),
reason: "declares operations the generator cannot emit".to_owned(),
hint: format!(
"Remove `{key}:`, or move its operations under `paths:`. Silently ignoring it emits no handler for any operation it holds."
),
});
}
}
return Ok(());
}
pub fn ref_target_name(reference: &str) -> Option<&str> {
if ref_file_part(reference).is_some() {
return None;
}
return ref_component_name(reference, "schemas");
}
pub fn schema_ref_reason(reference: &str, site: &str) -> String {
if ref_file_part(reference).is_some() {
return format!("a cross-file ref does not resolve at {site}");
}
return format!("{site} must reference `#/components/schemas/<name>`");
}
pub fn ref_component_name<'a>(reference: &'a str, kind: &str) -> Option<&'a str> {
let fragment = reference.split('#').nth(1).unwrap_or(reference);
let prefix = match kind {
"schemas" => "/components/schemas/",
"responses" => "/components/responses/",
"parameters" => "/components/parameters/",
"requestBodies" => "/components/requestBodies/",
_ => return None,
};
let name = fragment.strip_prefix(prefix)?;
if name.is_empty() || name.contains('/') {
return None;
}
return Some(name);
}
pub fn ref_file_part(reference: &str) -> Option<&str> {
return match reference.split_once('#') {
Some((file, _fragment)) if !file.is_empty() => Some(file),
_ => None,
};
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new(test_name: &str) -> Self {
let unique = format!(
"oapi-codegen-loader-{test_name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after Unix epoch")
.as_nanos(),
);
let path = std::env::temp_dir().join(unique);
std::fs::create_dir_all(&path).expect("create test directory");
return Self { path };
}
fn write(&self, file: &str, contents: &str) -> PathBuf {
let path = self.path.join(file);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent directory");
}
std::fs::write(&path, contents).expect("write test file");
return path;
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn parse_openapi(yaml: &str) -> OpenAPI {
return serde_yaml::from_str(yaml).expect("parse OpenAPI document");
}
fn assert_query_parameter_name(parameter: &Parameter, expected: &str) {
match parameter {
Parameter::Query { parameter_data, .. } => {
assert_eq!(parameter_data.name, expected);
}
_ => panic!("expected a query parameter"),
}
}
fn minimal_doc() -> &'static str {
return "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\n";
}
fn shared_parameter_doc(name: &str) -> String {
return format!(
"openapi: 3.0.3\ninfo:\n title: shared\n version: '1'\npaths: {{}}\ncomponents:\n parameters:\n PageSize:\n name: {name}\n in: query\n schema:\n type: integer\n",
);
}
#[test]
fn ref_target_name_is_same_document_schemas_only() {
assert_eq!(ref_target_name("#/components/schemas/Foo"), Some("Foo"));
assert_eq!(
ref_target_name("schemas/common.yaml#/components/schemas/ErrorResponse"),
None
);
assert_eq!(ref_target_name("#/components/responses/Bar"), None);
}
#[test]
fn a_schema_ref_reason_names_the_fault() {
assert_eq!(
schema_ref_reason("common.yaml#/components/schemas/X", "a property"),
"a cross-file ref does not resolve at a property"
);
assert_eq!(
schema_ref_reason("#/components/responses/Bar", "a property"),
"a property must reference `#/components/schemas/<name>`"
);
}
#[test]
fn extracts_component_responses_and_file_parts() {
assert_eq!(
ref_component_name("#/components/responses/Bar", "responses"),
Some("Bar")
);
assert_eq!(ref_component_name("#/components/schemas/Foo", "responses"), None);
assert_eq!(ref_file_part("#/components/schemas/Foo"), None);
assert_eq!(
ref_file_part("schemas/common.yaml#/components/schemas/ErrorResponse"),
Some("schemas/common.yaml"),
);
}
#[test]
fn ref_component_name_recognizes_parameters_and_request_bodies() {
assert_eq!(
ref_component_name("#/components/parameters/PageSize", "parameters"),
Some("PageSize")
);
assert_eq!(
ref_component_name("#/components/requestBodies/CreateWidget", "requestBodies"),
Some("CreateWidget")
);
assert_eq!(ref_component_name("#/components/schemas/Foo", "parameters"), None);
}
#[test]
fn resolves_same_document_component_parameter() {
let yaml = "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n parameters:\n PageSize:\n name: pageSize\n in: query\n schema:\n type: integer\n";
let doc = parse_openapi(yaml);
let spec = Spec::from_parts(doc, std::path::PathBuf::from("inline.yaml"));
let param = spec
.resolve_parameter("#/components/parameters/PageSize")
.expect("resolve");
assert!(param.origin.is_none());
assert_query_parameter_name(¶m.value, "pageSize");
}
#[test]
fn errors_on_cross_file_ref_to_missing_file() {
let source = std::env::temp_dir().join(format!(
"oapi-codegen-loader-missing-{}-{}.yaml",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after Unix epoch")
.as_nanos(),
));
let spec = Spec::from_parts(parse_openapi(minimal_doc()), source);
let result = spec.resolve_parameter("common.yaml#/components/parameters/PageSize");
assert!(matches!(result, Err(Error::ReadRefFile { .. })));
}
#[test]
fn unresolved_component_error_preserves_the_full_reference() {
let yaml = "openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n parameters: {}\n";
let spec = Spec::from_parts(parse_openapi(yaml), std::path::PathBuf::from("inline.yaml"));
let result = spec.resolve_parameter("#/components/parameters/Missing");
match result {
Err(Error::UnresolvedRef(reference)) => {
assert_eq!(reference, "#/components/parameters/Missing");
}
other => panic!("expected UnresolvedRef, got {other:?}"),
}
let dir = TestDir::new("unresolved-cross-file");
let main = dir.write("main.yaml", minimal_doc());
dir.write("shared.yaml", minimal_doc());
let spec = Spec::load(&main).expect("load main spec");
let result = spec.resolve_parameter("shared.yaml#/components/parameters/Missing");
match result {
Err(Error::UnresolvedRef(reference)) => {
assert_eq!(reference, "shared.yaml#/components/parameters/Missing");
}
other => panic!("expected UnresolvedRef, got {other:?}"),
}
}
#[test]
fn document_for_reads_sibling_and_caches() {
let dir = TestDir::new("document-for-caches");
let main = dir.write("main.yaml", minimal_doc());
dir.write("shared.yaml", &shared_parameter_doc("pageSize"));
let spec = Spec::load(&main).expect("load main spec");
let param = spec
.resolve_parameter("shared.yaml#/components/parameters/PageSize")
.expect("resolve cross-file parameter");
assert_eq!(param.origin.as_deref(), Some("shared.yaml"));
assert_query_parameter_name(¶m.value, "pageSize");
assert_eq!(spec.docs.borrow().len(), 1);
let second = spec
.resolve_parameter("shared.yaml#/components/parameters/PageSize")
.expect("resolve cached cross-file parameter");
assert_eq!(second.origin.as_deref(), Some("shared.yaml"));
assert_query_parameter_name(&second.value, "pageSize");
assert_eq!(spec.docs.borrow().len(), 1);
}
#[test]
fn external_uses_settle_across_documents_before_inspection() {
let dir = TestDir::new("settled-uses");
let main = dir.write(
"main.yaml",
"openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths:\n /a:\n get:\n parameters:\n - $ref: 'a.yaml#/components/parameters/N'\n - $ref: 'b.yaml#/components/parameters/M'\n responses: {}\n",
);
dir.write(
"a.yaml",
"openapi: 3.0.3\ninfo:\n title: a\n version: '1'\npaths: {}\ncomponents:\n parameters:\n N:\n name: n\n in: query\n schema:\n type: string\n K:\n name: k\n in: query\n schema:\n type: string\n",
);
dir.write(
"b.yaml",
"openapi: 3.0.3\ninfo:\n title: b\n version: '1'\npaths: {}\ncomponents:\n parameters:\n M:\n $ref: 'a.yaml#/components/parameters/K'\n",
);
let generate = crate::config::Generate {
std_http_server: true,
..crate::config::Generate::default()
};
let spec = Spec::load_with(&main, &generate, &crate::config::OutputOptions::default()).expect("load main spec");
let mut uses = spec.external_uses.clone();
uses.sort();
assert_eq!(
uses,
vec![
("a.yaml".to_owned(), "/components/parameters/K".to_owned()),
("a.yaml".to_owned(), "/components/parameters/N".to_owned()),
("b.yaml".to_owned(), "/components/parameters/M".to_owned()),
]
);
}
#[test]
fn uses_under_two_spellings_of_a_file_reach_its_inspection() {
for first in ["shared.yaml", "./shared.yaml"] {
let second = if first == "shared.yaml" {
"./shared.yaml"
} else {
"shared.yaml"
};
let dir = TestDir::new("spellings");
let main = dir.write(
"main.yaml",
&format!(
"openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths:\n /a:\n get:\n parameters:\n - $ref: '{first}#/components/parameters/N'\n - $ref: '{second}#/components/parameters/M'\n responses: {{}}\n"
),
);
dir.write(
"shared.yaml",
"openapi: 3.0.3\ninfo:\n title: s\n version: '1'\npaths: {}\ncomponents:\n parameters:\n N:\n name: n\n in: query\n schema:\n type: string\n M:\n name: m\n in: query\n schema:\n type: string\n",
);
let generate = crate::config::Generate {
std_http_server: true,
..crate::config::Generate::default()
};
let spec = Spec::load_with(&main, &generate, &crate::config::OutputOptions::default()).expect("load");
let mut uses = spec.raw.uses_of(&spec.external_uses, first);
uses.sort();
assert_eq!(
uses,
vec!["/components/parameters/M", "/components/parameters/N"],
"{first}"
);
}
}
#[test]
fn cross_file_chain_across_two_files_resolves() {
let dir = TestDir::new("cross-file-chain");
let main = dir.write("main.yaml", minimal_doc());
dir.write(
"a.yaml",
"openapi: 3.0.3\ninfo:\n title: a\n version: '1'\npaths: {}\ncomponents:\n parameters:\n X:\n $ref: \"b.yaml#/components/parameters/Y\"\n",
);
dir.write(
"b.yaml",
"openapi: 3.0.3\ninfo:\n title: b\n version: '1'\npaths: {}\ncomponents:\n parameters:\n Y:\n name: cursor\n in: query\n schema:\n type: string\n",
);
let spec = Spec::load(&main).expect("load main spec");
let param = spec
.resolve_parameter("a.yaml#/components/parameters/X")
.expect("resolve cross-file chain");
assert_eq!(param.origin.as_deref(), Some("b.yaml"));
assert_query_parameter_name(¶m.value, "cursor");
}
#[test]
fn cross_file_cycle_terminates() {
let dir = TestDir::new("cross-file-cycle");
let main = dir.write("main.yaml", minimal_doc());
dir.write(
"a.yaml",
"openapi: 3.0.3\ninfo:\n title: a\n version: '1'\npaths: {}\ncomponents:\n parameters:\n X:\n $ref: \"b.yaml#/components/parameters/Y\"\n",
);
dir.write(
"b.yaml",
"openapi: 3.0.3\ninfo:\n title: b\n version: '1'\npaths: {}\ncomponents:\n parameters:\n Y:\n $ref: \"a.yaml#/components/parameters/X\"\n",
);
let spec = Spec::load(&main).expect("load main spec");
let result = spec.resolve_parameter("a.yaml#/components/parameters/X");
assert!(matches!(result, Err(Error::UnresolvedRef(_))));
}
#[test]
fn resolve_schema_against_origin() {
let dir = TestDir::new("resolve-schema-origin");
let main = dir.write("main.yaml", minimal_doc());
dir.write(
"shared.yaml",
"openapi: 3.0.3\ninfo:\n title: shared\n version: '1'\npaths: {}\ncomponents:\n schemas:\n PageInfo:\n type: integer\n format: int32\n",
);
let spec = Spec::load(&main).expect("load main spec");
let schema = spec
.resolve_schema(Some("shared.yaml"), "#/components/schemas/PageInfo")
.expect("resolve schema from origin");
assert!(matches!(
schema.schema_kind,
openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_))
));
}
#[test]
fn a_schema_level_mark_splits_only_the_referrers() {
let doc: OpenAPI = serde_yaml::from_str(
"openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Marked:\n type: string\n readOnly: true\n Holder:\n type: object\n properties:\n a:\n $ref: '#/components/schemas/Marked'\n Bystander:\n type: object\n properties:\n b:\n type: string\n",
)
.expect("parse doc");
let split = direction_split_schemas(&doc);
assert!(split.contains("Holder"), "a property that names a marked schema splits");
assert!(!split.contains("Marked"), "a marked primitive keeps its one name");
assert!(!split.contains("Bystander"), "an unrelated schema keeps its one name");
}
#[test]
fn an_alias_to_a_split_model_splits_as_well() {
let doc: OpenAPI = serde_yaml::from_str(
"openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Marked:\n type: object\n properties:\n id:\n type: string\n readOnly: true\n Alias:\n $ref: '#/components/schemas/Marked'\n Plain:\n type: object\n properties:\n a:\n type: string\n PlainAlias:\n $ref: '#/components/schemas/Plain'\n",
)
.expect("parse doc");
let split = direction_split_schemas(&doc);
assert!(split.contains("Marked"), "the model that declares the mark splits");
assert!(split.contains("Alias"), "an alias to a split model splits");
assert!(!split.contains("Plain"), "an unmarked model keeps its one name");
assert!(
!split.contains("PlainAlias"),
"an alias to an unmarked model keeps its one name"
);
}
#[test]
fn an_alias_chain_to_a_marked_schema_splits_the_referrer() {
let doc: OpenAPI = serde_yaml::from_str(
"openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Marked:\n type: string\n readOnly: true\n Alias:\n $ref: '#/components/schemas/Marked'\n Twice:\n $ref: '#/components/schemas/Alias'\n Holder:\n type: object\n properties:\n a:\n $ref: '#/components/schemas/Alias'\n Wrapped:\n type: object\n properties:\n a:\n allOf:\n - $ref: '#/components/schemas/Twice'\n",
)
.expect("parse doc");
let split = direction_split_schemas(&doc);
assert!(split.contains("Holder"), "a name of an alias to a marked schema splits");
assert!(split.contains("Wrapped"), "a wrapped name of a two-step alias splits");
assert!(
!split.contains("Alias") && !split.contains("Twice"),
"an alias to a primitive keeps its one name"
);
}
#[test]
fn an_alias_chain_at_the_depth_limit_splits_and_one_link_longer_does_not() {
let chain = |links: usize, reversed: bool| {
let mut schemas: Vec<String> = vec![" Marked:\n type: string\n readOnly: true\n".to_owned()];
for index in 1..=links {
let target = if index == links {
"Marked".to_owned()
} else {
format!("Alias{}", index + 1)
};
schemas.push(format!(
" Alias{index}:\n $ref: '#/components/schemas/{target}'\n"
));
}
if reversed {
schemas.reverse();
}
return format!(
"openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {{}}\ncomponents:\n schemas:\n{} Holder:\n type: object\n properties:\n a:\n $ref: '#/components/schemas/Alias1'\n",
schemas.concat()
);
};
for (links, splits) in [(MAX_REF_DEPTH - 1, true), (MAX_REF_DEPTH, false)] {
for reversed in [false, true] {
let doc: OpenAPI = serde_yaml::from_str(&chain(links, reversed)).expect("parse doc");
let split = direction_split_schemas(&doc);
assert_eq!(split.contains("Holder"), splits, "{links} links, reversed: {reversed}");
}
}
}
#[test]
fn a_wrapped_reference_to_a_marked_schema_splits_the_referrer() {
let doc: OpenAPI = serde_yaml::from_str(
"openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Marked:\n type: string\n readOnly: true\n Plain:\n type: string\n Holder:\n type: object\n properties:\n a:\n allOf:\n - $ref: '#/components/schemas/Marked'\n description: wrapped\n Bystander:\n type: object\n properties:\n b:\n allOf:\n - $ref: '#/components/schemas/Plain'\n",
)
.expect("parse doc");
let split = direction_split_schemas(&doc);
assert!(split.contains("Holder"), "a wrapped name of a marked schema splits");
assert!(
!split.contains("Bystander"),
"a wrapped name of a plain schema does not"
);
}
#[test]
fn a_property_mark_splits_the_declaring_model_and_its_referrers() {
let doc: OpenAPI = serde_yaml::from_str(
"openapi: 3.0.3\ninfo:\n title: t\n version: '1'\npaths: {}\ncomponents:\n schemas:\n Leaf:\n type: object\n properties:\n id:\n type: string\n readOnly: true\n Parent:\n type: object\n properties:\n leaf:\n $ref: '#/components/schemas/Leaf'\n",
)
.expect("parse doc");
let split = direction_split_schemas(&doc);
assert!(split.contains("Leaf"), "the model that declares the mark splits");
assert!(split.contains("Parent"), "a model that reaches the mark splits");
}
}