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;
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,
}
impl Spec {
pub fn load(path: &Path) -> Result<Self> {
return Self::load_for(path, false, &crate::config::OutputOptions::default());
}
pub fn load_for(path: &Path, server: bool, options: &crate::config::OutputOptions) -> Result<Self> {
let run = crate::coverage::Run {
server,
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 described = crate::coverage::check(&document, &value, &run)?;
let mut value = value;
crate::coverage::wrap_described_refs(&mut value, &described);
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,
});
}
pub fn from_parts(inner: OpenAPI, source: PathBuf) -> Self {
return Spec {
inner,
source,
docs: RefCell::new(HashMap::new()),
run: crate::coverage::Run::default(),
};
}
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 described = crate::coverage::check(file, &value, &self.run)?;
let mut value = value;
crate::coverage::wrap_described_refs(&mut value, &described);
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 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");
}
}