#[cfg(all(feature = "client", feature = "openapi"))]
use std::collections::HashSet;
use std::collections::{BTreeMap, HashMap};
use crate::adapters::input_validation::bounded_join;
use alkcall::client::AdapterError;
use serde_json::Value;
#[cfg(feature = "openapi")]
use yaml_serde::Value as YamlValue;
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) const MAX_REF_RESOLUTION_DEPTH: usize = 128;
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) const MAX_REF_HOP_DEPTH: usize = 64;
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) const MAX_REF_EXPANSION_NODES: usize = 1_000_000;
pub(crate) const HTTP_METHODS: &[&str] =
&["get", "post", "put", "patch", "delete", "head", "options"];
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) const SUCCESS_RESPONSE_KEYS: &[&str] = &[
"200", "201", "202", "203", "204", "205", "206", "226", "2XX", "default",
];
#[derive(Clone, Debug)]
pub struct OpenAPIInfo {
pub title: String,
pub version: String,
}
#[derive(Clone, Debug)]
pub struct PathItem {
pub operations: Vec<(String, Operation)>,
pub parameters: Vec<Parameter>,
}
#[derive(Clone, Debug)]
pub struct Operation {
pub operation_id: Option<String>,
pub parameters: Vec<Parameter>,
pub request_body: Option<RequestBody>,
pub responses: BTreeMap<String, Response>,
}
#[derive(Clone, Debug)]
pub struct Parameter {
pub name: String,
pub in_: String,
pub required: bool,
pub schema: Option<Value>,
}
#[derive(Clone, Debug)]
pub struct RequestBody {
pub content: BTreeMap<String, Value>,
}
#[derive(Clone, Debug)]
pub struct Response {
pub content: BTreeMap<String, Value>,
}
#[derive(Clone, Debug)]
pub struct Components {
pub schemas: HashMap<String, Value>,
pub parameters: HashMap<String, Value>,
pub request_bodies: HashMap<String, Value>,
}
#[cfg(feature = "openapi")]
fn yaml_to_json_value(value: &YamlValue) -> Result<Value, String> {
match value {
YamlValue::Null => Ok(Value::Null),
YamlValue::Bool(b) => Ok(Value::Bool(*b)),
YamlValue::Number(n) => yaml_number_to_json(n),
YamlValue::String(s) => Ok(Value::String(s.clone())),
YamlValue::Tagged(tagged) => yaml_to_json_value(&tagged.value),
YamlValue::Sequence(items) => {
let mut out = Vec::with_capacity(items.len());
for (index, item) in items.iter().enumerate() {
out.push(
yaml_to_json_value(item)
.map_err(|detail| format!("/<sequence-element-{index}>: {detail}"))?,
);
}
Ok(Value::Array(out))
}
YamlValue::Mapping(map) => {
let mut out = serde_json::Map::new();
for (key, val) in map {
let ptr = yaml_key_pointer(key)?;
let converted =
yaml_to_json_value(val).map_err(|detail| format!("{ptr}: {detail}"))?;
out.insert(ptr, converted);
}
Ok(Value::Object(out))
}
}
}
#[cfg(feature = "openapi")]
fn yaml_number_to_json(number: &yaml_serde::Number) -> Result<Value, String> {
if let Some(integer) = number.as_i64() {
return Ok(Value::Number(serde_json::Number::from(integer)));
}
if let Some(unsigned) = number.as_u64() {
return Ok(Value::Number(serde_json::Number::from(unsigned)));
}
let float = number.as_f64().ok_or_else(|| {
"number is out of range for JSON (neither i64, u64, nor a finite f64)".to_string()
})?;
let finite = serde_json::Number::from_f64(float)
.ok_or_else(|| format!("{float} is not representable in JSON (.inf/.nan)"))?;
Ok(Value::Number(finite))
}
#[cfg(feature = "openapi")]
fn yaml_key_pointer(key: &YamlValue) -> Result<String, String> {
match key {
YamlValue::String(s) => Ok(s.clone()),
YamlValue::Bool(b) => Ok(b.to_string()),
YamlValue::Number(n) => {
if let Some(integer) = n.as_i64() {
return Ok(integer.to_string());
}
if let Some(unsigned) = n.as_u64() {
return Ok(unsigned.to_string());
}
match n.as_f64().and_then(serde_json::Number::from_f64) {
Some(finite) => Ok(finite.to_string()),
None => Err(format!(
"mapping key {n:?} is a non-finite number with no string form"
)),
}
}
YamlValue::Tagged(tagged) => yaml_key_pointer(&tagged.value),
other => Err(format!(
"mapping key {other:?} has no string form; only string, number, and boolean \
keys can be represented in an OpenAPI document"
)),
}
}
fn index_component_map(raw: Option<&Value>) -> HashMap<String, Value> {
let mut map = HashMap::new();
if let Some(obj) = raw.and_then(|m| m.as_object()) {
for (k, v) in obj {
map.insert(k.clone(), v.clone());
}
}
map
}
#[derive(Debug)]
pub struct OpenAPISpec {
pub info: OpenAPIInfo,
pub paths: BTreeMap<String, PathItem>,
pub components: Option<Components>,
pub raw: Value,
}
impl OpenAPISpec {
pub fn from_json(doc: &str) -> Result<Self, AdapterError> {
let raw: Value = serde_json::from_str(doc).map_err(|e| AdapterError::SchemaParse {
message: format!("invalid JSON: {e}"),
})?;
Self::from_value(raw)
}
#[cfg(feature = "openapi")]
pub fn from_yaml(doc: &str) -> Result<Self, AdapterError> {
let yaml: YamlValue = yaml_serde::from_str(doc).map_err(|e| AdapterError::SchemaParse {
message: format!("invalid YAML: {e}"),
})?;
let mut yaml = yaml;
yaml.apply_merge().map_err(|e| AdapterError::SchemaParse {
message: format!("invalid YAML merge key: {e}"),
})?;
let raw = yaml_to_json_value(&yaml).map_err(|message| AdapterError::SchemaParse {
message: format!("invalid YAML: {message}"),
})?;
Self::from_value(raw)
}
#[allow(
clippy::should_implement_trait,
reason = "ADR-051 §1 names this an inherent constructor `from_str`, not a FromStr impl"
)]
pub fn from_str(doc: &str) -> Result<Self, AdapterError> {
match serde_json::from_str::<Value>(doc) {
Ok(raw) => Self::from_value(raw),
#[cfg(feature = "openapi")]
Err(_) => Self::from_yaml(doc),
#[cfg(not(feature = "openapi"))]
Err(_) => Self::from_json(doc),
}
}
pub fn from_value(raw: Value) -> Result<Self, AdapterError> {
if !raw.is_object() {
return Err(AdapterError::SchemaParse {
message: "OpenAPI document must be a JSON object".into(),
});
}
let info_obj = raw.get("info").ok_or_else(|| AdapterError::SchemaParse {
message: "OpenAPI document missing `info`".into(),
})?;
let info = OpenAPIInfo {
title: info_obj
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
version: info_obj
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("1.0.0")
.to_string(),
};
let paths_raw = raw.get("paths").ok_or_else(|| AdapterError::SchemaParse {
message: "OpenAPI document missing `paths`".into(),
})?;
if !paths_raw.is_object() {
return Err(AdapterError::SchemaParse {
message: "`paths` must be a JSON object".into(),
});
}
let provisional = Self {
info: info.clone(),
paths: BTreeMap::new(),
components: Some(Components {
schemas: index_component_map(raw.get("components").and_then(|c| c.get("schemas"))),
parameters: index_component_map(
raw.get("components").and_then(|c| c.get("parameters")),
),
request_bodies: index_component_map(
raw.get("components").and_then(|c| c.get("requestBodies")),
),
}),
raw: raw.clone(),
};
let mut servers_locations: Vec<String> = Vec::new();
if raw.get("servers").is_some() {
servers_locations.push("document".to_string());
}
if raw.get("webhooks").is_some() {
return Err(AdapterError::SchemaParse {
message: "the document declares top-level `webhooks`, which the HTTP \
adapter cannot import: webhooks are server-initiated \
callbacks (inbound), while the adapter registers outbound \
forwarding operations only — split the webhooks into their \
own service definition or remove the `webhooks` key \
(review 002 OAI-13)"
.into(),
});
}
if let Some(paths_obj) = paths_raw.as_object() {
for (path, item) in paths_obj {
if item.as_object().is_some_and(|o| o.contains_key("servers")) {
servers_locations.push(format!("path {path}"));
continue;
}
for method in HTTP_METHODS {
if item
.get(method)
.and_then(|op| op.as_object())
.is_some_and(|op| op.contains_key("servers"))
{
servers_locations.push(format!("{method} {path}"));
}
}
}
}
if !servers_locations.is_empty() {
return Err(AdapterError::SchemaParse {
message: format!(
"the document declares `servers` override(s) at: {}. The HTTP adapter \
routes all operations of a service through the single `base_url` \
configured at assembly time and cannot honor per-location `servers` — \
remove the `servers` entries or split the service into one import per \
base URL (review 001 OAI-06)",
bounded_join(&servers_locations)
),
});
}
let mut paths = BTreeMap::new();
if let Some(paths_obj) = paths_raw.as_object() {
for (path, item) in paths_obj {
if !item.is_object() {
continue;
}
let mut operations = Vec::new();
for method in HTTP_METHODS {
if let Some(op_raw) = item.get(*method) {
let locator = format!("{method} {path}");
match parse_operation(op_raw, &provisional, &locator) {
Ok(Some(op)) => operations.push((method.to_string(), op)),
Ok(None) => {
return Err(AdapterError::SchemaParse {
message: format!(
"unresolvable $ref or missing `name`/`in` in parameter of \
{method} {path}, or an unresolvable/content-less \
`requestBody` on the operation (review 001 OAI-04, \
review 002 OAI-15)"
),
});
}
Err(style_error) => {
return Err(AdapterError::SchemaParse {
message: format!(
"parameter `{}` on {method} {path} {}: (review 001 OAI-06)",
style_error.parameter, style_error.detail
),
});
}
}
}
}
if operations.is_empty() {
let skipped: Vec<&str> = item
.as_object()
.map(|o| {
o.keys()
.map(|k| k.as_str())
.filter(|k| {
!HTTP_METHODS.contains(k)
&& *k != "parameters"
&& *k != "servers"
&& *k != "summary"
&& *k != "description"
})
.collect()
})
.unwrap_or_default();
if !skipped.is_empty() {
tracing::warn!(
path = %path,
methods = %skipped.join(", "),
"path declares only unsupported HTTP methods; skipping it \
(review 001 OAI-06)"
);
}
continue;
}
let merged = ItemParameters::parse(item, &provisional).map_err(|style_error| {
AdapterError::SchemaParse {
message: format!(
"parameter `{}` on path item {path} {}: (review 001 OAI-06)",
style_error.parameter, style_error.detail
),
}
})?;
paths.insert(
path.clone(),
PathItem {
operations,
parameters: merged,
},
);
}
}
let components = raw.get("components").map(|c| Components {
schemas: index_component_map(c.get("schemas")),
parameters: index_component_map(c.get("parameters")),
request_bodies: index_component_map(c.get("requestBodies")),
});
Ok(Self {
info,
paths,
components,
raw,
})
}
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) fn validate_import_loud_features(&self) -> Result<(), AdapterError> {
let mut callback_locations: Vec<String> = Vec::new();
let mut security_locations: Vec<String> = Vec::new();
if self.raw.get("callbacks").is_some() {
callback_locations.push("document".to_string());
}
if self.raw.get("security").is_some() {
security_locations.push("document".to_string());
}
if let Some(paths_obj) = self.raw.get("paths").and_then(|p| p.as_object()) {
for (path, item) in paths_obj {
let Some(item_obj) = item.as_object() else {
continue;
};
for method in HTTP_METHODS {
let Some(op) = item_obj.get(*method).and_then(|op| op.as_object()) else {
continue;
};
if op.contains_key("callbacks") {
callback_locations.push(format!("{method} {path}"));
}
if op.contains_key("security") {
security_locations.push(format!("{method} {path}"));
}
}
}
}
if !callback_locations.is_empty() {
return Err(AdapterError::SchemaParse {
message: format!(
"the document declares `callbacks` at: {}. Callbacks are \
server-initiated outbound calls (inbound to this service) that the \
single-endpoint HTTP adapter does not model — remove the \
`callbacks` entries or split those operations into their own \
service definition (review 002 OAI-14)",
bounded_join(&callback_locations)
),
});
}
if !security_locations.is_empty() {
return Err(AdapterError::SchemaParse {
message: format!(
"the document declares `security` requirement(s) at: {}. The HTTP \
adapter injects credentials exclusively through Capabilities per \
the declared auth scheme (review 001 OAI-06 posture); OpenAPI \
security requirements would silently change nothing at call time — \
remove the `security` blocks or set the `auth` field on the service \
config instead (review 002 OAI-14)",
bounded_join(&security_locations)
),
});
}
Ok(())
}
pub(crate) fn resolve_ref(&self, reference: &str) -> Result<Value, AdapterError> {
let bounded = |r: &str| {
if r.chars().count() > 128 {
format!("{}…", r.chars().take(128).collect::<String>())
} else {
r.to_string()
}
};
if !reference.starts_with("#/") {
return Err(AdapterError::SchemaParse {
message: format!("external $ref not supported: {}", bounded(reference)),
});
}
let mut current: &Value = &self.raw;
for part in reference.trim_start_matches("#/").split('/') {
current = current.get(part).ok_or_else(|| AdapterError::SchemaParse {
message: format!("cannot resolve $ref: {}", bounded(reference)),
})?;
}
Ok(current.clone())
}
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) fn resolve_refs_recursive(&self, schema: &Value) -> Result<Value, AdapterError> {
self.resolve_refs_bounded(schema, &mut RefResolution::default(), 0, 0)
}
#[cfg(all(feature = "client", feature = "openapi"))]
fn resolve_refs_bounded(
&self,
schema: &Value,
state: &mut RefResolution,
depth: usize,
hops: usize,
) -> Result<Value, AdapterError> {
if depth > MAX_REF_RESOLUTION_DEPTH {
return Err(AdapterError::SchemaParse {
message: format!(
"$ref resolution exceeded depth budget of {MAX_REF_RESOLUTION_DEPTH} \
(self-referential or pathologically nested schema)"
),
});
}
state.nodes = state.nodes.saturating_add(1);
if state.nodes > MAX_REF_EXPANSION_NODES {
return Err(AdapterError::SchemaParse {
message: format!(
"$ref expansion exceeded node budget of {MAX_REF_EXPANSION_NODES} \
(acyclic shared-$ref chain expanding exponentially; the schema is \
valid but its full inline expansion is too large to materialize)"
),
});
}
match schema {
Value::Object(obj) => {
if let Some(Value::String(reference)) = obj.get("$ref") {
if let Some(resolved) = state.memo.get(reference) {
let nodes = count_nodes(resolved);
state.nodes = state.nodes.saturating_add(nodes);
if state.nodes > MAX_REF_EXPANSION_NODES {
return Err(AdapterError::SchemaParse {
message: format!(
"$ref expansion exceeded node budget of \
{MAX_REF_EXPANSION_NODES} \
(acyclic shared-$ref chain expanding exponentially; \
the schema is valid but its full inline expansion is \
too large to materialize)"
),
});
}
return Ok(resolved.clone());
}
if !state.resolving.insert(reference.clone()) {
return Err(AdapterError::SchemaParse {
message: format!(
"circular $ref detected at depth {depth}: {reference}"
),
});
}
let resolved = self.resolve_ref(reference)?;
let hops = hops + 1;
if hops > MAX_REF_HOP_DEPTH {
return Err(AdapterError::SchemaParse {
message: format!(
"$ref resolution exceeded hop budget of {MAX_REF_HOP_DEPTH} \
(runaway $ref chain)"
),
});
}
let out = self.resolve_refs_bounded(&resolved, state, depth + 1, hops);
state.resolving.remove(reference);
let value = out?;
state.memo.insert(reference.clone(), value.clone());
return Ok(value);
}
if depth + 1 > MAX_REF_RESOLUTION_DEPTH {
return Err(AdapterError::SchemaParse {
message: format!(
"schema nesting exceeded depth budget of {MAX_REF_RESOLUTION_DEPTH} \
(self-referential or pathologically nested schema)"
),
});
}
let mut out = serde_json::Map::new();
for (k, v) in obj {
out.insert(
k.clone(),
self.resolve_refs_bounded(v, state, depth + 1, hops)?,
);
}
Ok(Value::Object(out))
}
Value::Array(arr) => {
let mut out = Vec::with_capacity(arr.len());
for v in arr {
out.push(self.resolve_refs_bounded(v, state, depth + 1, hops)?);
}
Ok(Value::Array(out))
}
other => Ok(other.clone()),
}
}
}
#[derive(Default)]
#[cfg(all(feature = "client", feature = "openapi"))]
struct RefResolution {
resolving: HashSet<String>,
memo: HashMap<String, Value>,
nodes: usize,
}
#[cfg(all(feature = "client", feature = "openapi"))]
fn count_nodes(value: &Value) -> usize {
match value {
Value::Object(map) => 1 + map.values().map(count_nodes).sum::<usize>(),
Value::Array(items) => 1 + items.iter().map(count_nodes).sum::<usize>(),
_ => 1,
}
}
#[cfg(all(feature = "client", feature = "openapi"))]
pub(crate) fn collect_ignored_schema_keys(value: &Value, found: &mut Vec<String>) {
let mut stack = vec![value];
while let Some(current) = stack.pop() {
match current {
Value::Object(map) => {
for (k, v) in map {
if k == "discriminator" || k == "xml" {
found.push(k.clone());
}
stack.push(v);
}
}
Value::Array(items) => {
stack.extend(items.iter());
}
_ => {}
}
}
}
fn warn_ref_siblings(context: &str, holder: &Value) {
if let Some(obj) = holder.as_object() {
let siblings: Vec<&String> = obj.keys().filter(|k| *k != "$ref").collect();
if !siblings.is_empty() {
let names: Vec<String> = siblings.iter().map(|s| s.as_str().to_string()).collect();
tracing::warn!(
location = %context,
siblings = %names.join(", "),
"$ref carries sibling keys; OpenAPI 3.0 semantics apply — the \
siblings are ignored (not merged into the resolved target as \
3.1 would do), so constraints authored beside the $ref are \
not enforced at call time (review 002 OAI-10)"
);
}
}
}
fn parse_operation(
raw: &Value,
spec: &OpenAPISpec,
locator: &str,
) -> Result<Option<Operation>, ParameterStyleError> {
if !raw.is_object() {
return Ok(None);
}
let operation_id = raw
.get("operationId")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let mut parameters = Vec::new();
if let Some(arr) = raw.get("parameters").and_then(|v| v.as_array()) {
for (index, p) in arr.iter().enumerate() {
let p = match p.get("$ref").and_then(|r| r.as_str()) {
Some(reference) => {
warn_ref_siblings(&format!("parameter[{index}] $ref {reference}"), p);
match spec.resolve_ref(reference) {
Ok(resolved) => resolved,
Err(_) => return Ok(None),
}
}
None => p.clone(),
};
let Some(name) = p.get("name").and_then(|v| v.as_str()) else {
return Ok(None);
};
let Some(in_) = p.get("in").and_then(|v| v.as_str()) else {
return Ok(None);
};
check_parameter_style(name, in_, &p)?;
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
let schema = p.get("schema").cloned();
parameters.push(Parameter {
name: name.to_string(),
in_: in_.to_string(),
required,
schema,
});
}
}
let request_body = match raw.get("requestBody") {
Some(rb) => {
let body = match rb.get("$ref").and_then(|r| r.as_str()) {
Some(reference) => {
warn_ref_siblings(&format!("requestBody $ref {reference}"), rb);
match spec.resolve_ref(reference) {
Ok(resolved) => resolved,
Err(_) => return Ok(None),
}
}
None => rb.clone(),
};
if body.get("$ref").is_some() || !body.get("content").is_some_and(|c| c.is_object()) {
return Ok(None);
}
if body.get("oneOf").is_some() {
return Err(ParameterStyleError {
parameter: "requestBody".to_string(),
detail: format!(
"on {locator}: uses a top-level `oneOf` requestBody (no \
`content` map), which the HTTP adapter cannot turn into the \
gateway's media-typed body contract — wrap each variant in a \
`content` entry (e.g. application/json) or split into separate \
operations (review 002 OAI-14)"
),
});
}
let Some(content_obj) = body.get("content").and_then(|v| v.as_object()) else {
return Ok(None);
};
let mut content = BTreeMap::new();
for (k, v) in content_obj {
let schema = v.get("schema").cloned().unwrap_or(Value::Null);
content.insert(k.clone(), schema);
}
Some(RequestBody { content })
}
None => None,
};
let mut responses = BTreeMap::new();
if let Some(resp_obj) = raw.get("responses").and_then(|v| v.as_object()) {
for (code, body) in resp_obj {
let content_obj = body.get("content").and_then(|v| v.as_object());
let mut content = BTreeMap::new();
if let Some(content_obj) = content_obj {
for (k, v) in content_obj {
let schema = v.get("schema").cloned().unwrap_or(Value::Null);
content.insert(k.clone(), schema);
}
}
responses.insert(code.clone(), Response { content });
}
}
Ok(Some(Operation {
operation_id,
parameters,
request_body,
responses,
}))
}
struct ItemParameters;
impl ItemParameters {
fn parse(item: &Value, spec: &OpenAPISpec) -> Result<Vec<Parameter>, ParameterStyleError> {
let path_style_error = |detail: String| ParameterStyleError {
parameter: "path-item parameters".to_string(),
detail,
};
let mut out = Vec::new();
let Some(arr) = item.get("parameters").and_then(|v| v.as_array()) else {
return Ok(out);
};
for (index, p) in arr.iter().enumerate() {
let p = match p.get("$ref").and_then(|r| r.as_str()) {
Some(reference) => {
warn_ref_siblings(&format!("path-item parameter[{index}] $ref {reference}"), p);
spec.resolve_ref(reference)
.map_err(|_| path_style_error(format!("unresolvable $ref: {reference}")))?
}
None => p.clone(),
};
let Some(name) = p.get("name").and_then(|v| v.as_str()) else {
return Err(path_style_error(
"path-item parameter is missing `name`".to_string(),
));
};
let Some(in_) = p.get("in").and_then(|v| v.as_str()) else {
return Err(path_style_error(format!(
"path-item parameter `{name}` is missing `in`"
)));
};
check_parameter_style(name, in_, &p)?;
out.push(Parameter {
name: name.to_string(),
in_: in_.to_string(),
required: p.get("required").and_then(|v| v.as_bool()).unwrap_or(false),
schema: p.get("schema").cloned(),
});
}
Ok(out)
}
}
#[derive(Debug)]
pub(crate) struct ParameterStyleError {
pub parameter: String,
pub detail: String,
}
fn check_parameter_style(
name: &str,
parameter_in: &str,
p: &Value,
) -> Result<(), ParameterStyleError> {
let _ = parameter_in;
let unsupported = |detail: String| ParameterStyleError {
parameter: name.to_string(),
detail,
};
let style = p.get("style").and_then(|v| v.as_str());
let explode = p.get("explode");
match (style, explode) {
(None, _) => Ok(()),
(Some("form"), None | Some(Value::Bool(true))) => Ok(()),
(Some("simple"), None | Some(Value::Bool(false))) => Ok(()),
(Some("form"), Some(Value::Bool(false))) => Err(unsupported(
"sets `style: form` with `explode: false`, which would comma-glue arrays \
(`?a=1,2`); the adapter emits the exploded default (repeated keys) — remove \
the `explode: false` or move the aggregation into the request body"
.to_string(),
)),
(Some("simple"), Some(Value::Bool(true))) => Err(unsupported(
"sets `style: simple` with `explode: true`; `simple` applies to headers and \
path segments where `explode` has no meaning for the adapter's emitter — \
remove the `explode: true`"
.to_string(),
)),
(Some(other), _) => Err(unsupported(format!(
"uses `style: {other}`, which the HTTP adapter does not serialize; the adapter \
emits query/path parameters in the form default (repeated keys for arrays) — \
drop the `style` declaration or serialize client-side"
))),
}
}
#[cfg(all(test, feature = "client", feature = "openapi"))]
mod tests {
use super::*;
use crate::adapters::{FromOpenAPI, HttpServiceConfig};
use crate::client::{HttpClientConfig, SharedHttpClient};
use alkcall::client::OperationAdapter;
use serde_json::json;
use std::sync::Arc;
fn wrap_spec(raw: Value) -> OpenAPISpec {
OpenAPISpec::from_value(raw).expect("test spec is valid")
}
fn schema_test_spec(schema: Value) -> OpenAPISpec {
wrap_spec(json!({
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/x": {"get": {"operationId": "x", "responses": {
"200": {"content": {"application/json": {"schema": {}}}
}}}}
},
"components": {"schemas": schema}
}))
}
fn nested_object(depth: usize) -> Value {
let mut current = json!({"type": "string"});
for _ in 0..depth {
current = json!({"type": "object", "properties": {"child": current}});
}
current
}
#[test]
fn parameter_ref_to_components_resolves_into_operation() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"components": {
"parameters": {
"Id": {
"name": "id",
"in": "path",
"required": true,
"schema": {"type": "string", "pattern": "^[0-9]+$"}
},
"Q": {"name": "q", "in": "query", "schema": {"type": "boolean"}}
}
},
"paths": {
"/users/{id}": {"get": {
"operationId": "getUser",
"parameters": [
{"$ref": "#/components/parameters/Id"},
{"$ref": "#/components/parameters/Q"}
],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).unwrap();
let item = spec.paths.get("/users/{id}").expect("path present");
let (_, op) = &item.operations[0];
assert_eq!(op.operation_id.as_deref(), Some("getUser"));
assert_eq!(op.parameters.len(), 2);
assert_eq!(op.parameters[0].name, "id");
assert_eq!(op.parameters[0].in_, "path");
assert!(op.parameters[0].required);
let schema = op.parameters[0].schema.as_ref().expect("schema present");
assert_eq!(schema["pattern"], "^[0-9]+$");
assert_eq!(op.parameters[1].name, "q");
assert_eq!(op.parameters[1].in_, "query");
assert!(!op.parameters[1].required);
}
#[test]
fn request_body_ref_to_components_resolves() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"components": {
"requestBodies": {
"WidgetInput": {
"content": {
"application/json": {
"schema": {"type": "object", "properties": {"name": {"type": "string"}}}
}
},
"required": true
}
}
},
"paths": {
"/widgets": {"post": {
"operationId": "createWidget",
"requestBody": {"$ref": "#/components/requestBodies/WidgetInput"},
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).unwrap();
let item = spec.paths.get("/widgets").expect("path present");
let (_, op) = &item.operations[0];
let rb = op.request_body.as_ref().expect("requestBody resolved");
let schema = rb.content.get("application/json").expect("json content");
let props = schema.get("properties").expect("schema expanded");
assert!(props.get("name").is_some());
}
#[test]
fn from_value_structural_rejects_name_the_missing_member() {
for (raw, expected_fragment) in [
(json!("just a string"), "must be a JSON object"),
(json!([1, 2, 3]), "must be a JSON object"),
(json!({"paths": {}}), "missing `info`"),
(
json!({"info": {"title": "T", "version": "1"}}),
"missing `paths`",
),
(
json!({
"info": {"title": "T", "version": "1"},
"paths": ["/x"]
}),
"`paths` must be a JSON object",
),
] {
match OpenAPISpec::from_value(raw) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains(expected_fragment),
"message `{message}` lacks `{expected_fragment}`"
);
}
Ok(_) => panic!("the malformed document must be rejected"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
}
#[test]
fn request_body_self_ref_fails_import_not_silent_bodyless_op() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/widgets": {"post": {
"operationId": "createWidget",
"requestBody": {"$ref": "#/paths/~1widgets/post/requestBody"},
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("requestBody"),
"the error must name the requestBody: {message}"
);
assert!(message.contains("OAI-15"), "message was: {message}");
}
Ok(spec) => {
let body = &spec.paths["/widgets"].operations[0].1.request_body;
assert!(
body.is_none(),
"a self-ref'd requestBody must not silently import as body-less (OAI-15)"
);
panic!("self-$ref'd requestBody must fail import loudly (OAI-15)");
}
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn request_body_ref_to_missing_component_fails_import_loudly() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/widgets": {"post": {
"operationId": "createWidget",
"requestBody": {"$ref": "#/components/requestBodies/Missing"},
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("requestBody") || message.contains("unresolvable"),
"message was: {message}"
);
assert!(message.contains("post /widgets"), "message was: {message}");
}
Ok(_) => panic!("unresolvable requestBody $ref must fail import loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn content_less_request_body_fails_import_not_silent_bodyless_op() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"components": {
"requestBodies": {
"DescriptionOnly": {"description": "no content map"}
}
},
"paths": {
"/widgets": {"post": {
"operationId": "createWidget",
"requestBody": {"$ref": "#/components/requestBodies/DescriptionOnly"},
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("requestBody") || message.contains("unresolvable"),
"message was: {message}"
);
}
Ok(spec) => {
let body = &spec.paths["/widgets"].operations[0].1.request_body;
assert!(body.is_none(), "content-less body must not silently drop");
panic!("content-less requestBody must fail import loudly (OAI-15)");
}
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn parameter_ref_to_missing_component_fails_import_loudly() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/x": {"get": {
"operationId": "x",
"parameters": [{"$ref": "#/components/parameters/Missing"}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc);
match spec {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("unresolvable $ref"),
"message was: {message}"
);
assert!(message.contains("get /x"), "message was: {message}");
}
Ok(_) => panic!("expected unresolvable-$ref error, spec parsed happily"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn self_referential_ref_errors_instead_of_aborting() {
let spec = schema_test_spec(json!({
"Node": {
"type": "object",
"properties": {
"next": {"$ref": "#/components/schemas/Node"}
}
}
}));
let schema = spec
.components
.as_ref()
.and_then(|c| c.schemas.get("Node"))
.expect("test spec has Node")
.clone();
let result = spec.resolve_refs_recursive(&schema);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("circular $ref"), "message was: {message}");
assert!(message.contains("Node"), "message was: {message}");
}
other => panic!("expected circular-$ref SchemaParse error, got {other:?}"),
}
}
#[test]
fn mutually_recursive_refs_error_on_cycle() {
let spec = schema_test_spec(json!({
"A": {"properties": {"b": {"$ref": "#/components/schemas/B"}}},
"B": {"properties": {"a": {"$ref": "#/components/schemas/A"}}}
}));
let schema = spec
.components
.as_ref()
.and_then(|c| c.schemas.get("A"))
.expect("test spec has A")
.clone();
let result = spec.resolve_refs_recursive(&schema);
assert!(matches!(result, Err(AdapterError::SchemaParse { .. })));
}
#[test]
fn over_deep_non_circular_spec_errors_cleanly() {
let depth = MAX_REF_RESOLUTION_DEPTH * 4;
let spec = schema_test_spec(json!({"Deep": nested_object(depth)}));
let schema = spec
.components
.as_ref()
.and_then(|c| c.schemas.get("Deep"))
.expect("test spec has Deep")
.clone();
let result = spec.resolve_refs_recursive(&schema);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("depth budget"), "message was: {message}");
}
other => panic!("expected depth-budget SchemaParse error, got {other:?}"),
}
}
#[test]
fn shared_refs_to_common_schema_import_identically() {
let spec = schema_test_spec(json!({
"Money": {"type": "object", "properties": {"amount": {"type": "number"}}},
"Order": {
"type": "object",
"properties": {"total": {"$ref": "#/components/schemas/Money"}}
},
"Refund": {
"type": "object",
"properties": {"amount": {"$ref": "#/components/schemas/Money"}}
}
}));
let schemas = &spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas;
for name in ["Order", "Refund"] {
let schema = schemas.get(name).expect("test schema present").clone();
let resolved = spec
.resolve_refs_recursive(&schema)
.expect("no false cycle");
let money = &resolved["properties"][if name == "Order" { "total" } else { "amount" }];
assert_eq!(money["type"], "object");
assert_eq!(money["properties"]["amount"]["type"], "number");
}
}
#[test]
fn diamond_ref_reuse_within_one_schema_does_not_trip_cycle_guard() {
let spec = schema_test_spec(json!({
"Id": {"type": "string"},
"Wrapper": {
"type": "object",
"properties": {
"a": {"$ref": "#/components/schemas/Id"},
"b": {"$ref": "#/components/schemas/Id"}
}
}
}));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("Wrapper")
.expect("test schema present")
.clone();
let resolved = spec
.resolve_refs_recursive(&schema)
.expect("no false cycle");
assert_eq!(resolved["properties"]["a"]["type"], "string");
}
#[test]
fn thirty_level_shared_chain_returns_bounded_with_clean_budget_error() {
let levels = 32usize;
let mut components = serde_json::Map::new();
for i in 0..levels {
let next_ref = if i + 1 < levels {
json!({"$ref": format!("#/components/schemas/S{}", i + 1)})
} else {
json!({"type": "string"})
};
components.insert(
format!("S{i}"),
json!({"a": next_ref.clone(), "b": next_ref}),
);
}
let spec = schema_test_spec(Value::Object(components));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("S0")
.expect("test schema present")
.clone();
let result = spec.resolve_refs_recursive(&schema);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("node budget"), "message was: {message}");
}
other => panic!("expected node-budget SchemaParse error, got {other:?}"),
}
}
#[test]
fn forty_level_single_chain_imports_fast_under_memoization() {
let levels = 40usize;
let mut components = serde_json::Map::new();
for i in 0..levels {
let next = if i + 1 < levels {
json!({"$ref": format!("#/components/schemas/S{}", i + 1)})
} else {
json!({"type": "string"})
};
components.insert(format!("S{i}"), json!({"next": next}));
}
let spec = schema_test_spec(Value::Object(components));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("S0")
.expect("test schema present")
.clone();
let resolved = spec
.resolve_refs_recursive(&schema)
.expect("acyclic chain resolves");
let mut cursor = &resolved;
for _ in 1..=levels {
cursor = &cursor["next"];
}
assert_eq!(cursor["type"], "string", "innermost level fully expanded");
}
#[test]
fn memoized_expansion_matches_pre_memoization_golden() {
let spec = schema_test_spec(json!({
"Id": {"type": "string", "maxLength": 4},
"Stamp": {"type": "object", "required": ["at"]},
"Chain": {
"type": "object",
"properties": {
"id": {"$ref": "#/components/schemas/Id"},
"next": {
"type": "object",
"properties": {
"id": {"$ref": "#/components/schemas/Id"},
"stamp": {"$ref": "#/components/schemas/Stamp"},
"again": {"$ref": "#/components/schemas/Id"}
}
},
"stamp": {"$ref": "#/components/schemas/Stamp"}
}
}
}));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("Chain")
.expect("test schema present")
.clone();
let resolved = spec
.resolve_refs_recursive(&schema)
.expect("expansion succeeds");
let expected = json!({
"type": "object",
"properties": {
"id": {"type": "string", "maxLength": 4},
"next": {
"type": "object",
"properties": {
"id": {"type": "string", "maxLength": 4},
"stamp": {"type": "object", "required": ["at"]},
"again": {"type": "string", "maxLength": 4}
}
},
"stamp": {"type": "object", "required": ["at"]}
}
});
assert_eq!(resolved, expected);
}
#[test]
fn cycle_through_shared_node_still_errors() {
let spec = schema_test_spec(json!({
"Shared": {"properties": {
"back": {"$ref": "#/components/schemas/Loop"}
}},
"Loop": {"properties": {
"shared": {"$ref": "#/components/schemas/Shared"}
}}
}));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("Loop")
.expect("test schema present")
.clone();
let result = spec.resolve_refs_recursive(&schema);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("circular $ref"), "message was: {message}");
}
other => panic!("expected circular-$ref SchemaParse error, got {other:?}"),
}
}
#[test]
fn cycle_via_array_items_errors() {
let spec = schema_test_spec(json!({
"Node": {"type": "array", "items": {"$ref": "#/components/schemas/Node"}}
}));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("Node")
.expect("test schema present")
.clone();
let result = spec.resolve_refs_recursive(&schema);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("circular $ref"), "message was: {message}");
assert!(message.contains("Node"), "message was: {message}");
}
other => panic!("expected circular-$ref SchemaParse error, got {other:?}"),
}
}
#[test]
fn array_of_ref_resolves_each_item() {
let spec = schema_test_spec(json!({
"Tag": {"type": "string", "minLength": 1},
"Tags": {"type": "array", "items": {"$ref": "#/components/schemas/Tag"}}
}));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("Tags")
.expect("test schema present")
.clone();
let resolved = spec
.resolve_refs_recursive(&schema)
.expect("array-of-$ref is acyclic and must resolve");
assert_eq!(resolved["type"], "array");
assert_eq!(
resolved["items"],
serde_json::json!({"type": "string", "minLength": 1}),
"the items $ref must expand to the Tag component schema"
);
let resolved_twice = spec
.resolve_refs_recursive(&schema)
.expect("second resolution hits the memo");
assert_eq!(resolved, resolved_twice);
}
#[test]
fn cycle_via_all_of_errors() {
let spec = schema_test_spec(json!({
"Node": {"allOf": [{"$ref": "#/components/schemas/Node"}]}
}));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("Node")
.expect("test schema present")
.clone();
let result = spec.resolve_refs_recursive(&schema);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("circular $ref"), "message was: {message}");
assert!(message.contains("Node"), "message was: {message}");
}
other => panic!("expected circular-$ref SchemaParse error, got {other:?}"),
}
}
#[test]
fn cycle_via_additional_properties_errors() {
let spec = schema_test_spec(json!({
"Node": {"type": "object", "additionalProperties": {
"$ref": "#/components/schemas/Node"
}}
}));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("Node")
.expect("test schema present")
.clone();
let result = spec.resolve_refs_recursive(&schema);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("circular $ref"), "message was: {message}");
assert!(message.contains("Node"), "message was: {message}");
}
other => panic!("expected circular-$ref SchemaParse error, got {other:?}"),
}
}
#[test]
fn cycle_via_ref_sibling_errors() {
let spec = schema_test_spec(json!({
"Node": {
"$ref": "#/components/schemas/Node",
"minLength": 3
}
}));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("Node")
.expect("test schema present")
.clone();
let result = spec.resolve_refs_recursive(&schema);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("circular $ref"), "message was: {message}");
assert!(message.contains("Node"), "message was: {message}");
}
other => panic!("expected circular-$ref SchemaParse error, got {other:?}"),
}
}
#[test]
fn memo_hit_matches_fresh_expansion_for_acyclic_shared_refs() {
let spec = schema_test_spec(json!({
"Id": {"type": "string", "maxLength": 4},
"Wrapper": {
"type": "object",
"properties": {
"a": {"$ref": "#/components/schemas/Id"},
"b": {"$ref": "#/components/schemas/Id"}
}
}
}));
let schema = spec
.components
.as_ref()
.expect("test spec has schemas")
.schemas
.get("Wrapper")
.expect("test schema present")
.clone();
let resolved = spec
.resolve_refs_recursive(&schema)
.expect("no false cycle");
assert_eq!(resolved["properties"]["a"], resolved["properties"]["b"]);
assert_eq!(resolved["properties"]["a"]["type"], "string");
assert_eq!(resolved["properties"]["a"]["maxLength"], 4);
}
#[test]
fn import_of_self_referential_spec_returns_error_not_abort() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"components": {"schemas": {
"Node": {
"type": "object",
"properties": {"next": {"$ref": "#/components/schemas/Node"}}
}
}},
"paths": {
"/nodes": {"get": {"operationId": "listNodes", "responses": {
"200": {"content": {"application/json": {
"schema": {"$ref": "#/components/schemas/Node"}
}}}
}}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).unwrap();
let client = SharedHttpClient::new(HttpClientConfig::default()).unwrap();
let adapter = FromOpenAPI::new(
spec,
HttpServiceConfig {
namespace: "svc".to_string(),
base_url: "https://x".to_string(),
auth: None,
default_headers: HashMap::new(),
},
Arc::new(client),
);
let result = adapter.import();
match futures::executor::block_on(result) {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("circular $ref"), "message was: {message}");
}
Ok(bundles) => panic!(
"expected import error for recursive spec, got {} bundles",
bundles.len()
),
Err(e) => panic!("expected circular-$ref SchemaParse, got {e}"),
}
}
#[test]
fn non_default_style_parameter_fails_import_naming_the_feature() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/tags": {"get": {
"operationId": "listTags",
"parameters": [{
"name": "ids",
"in": "query",
"style": "spaceDelimited",
"schema": {"type": "array", "items": {"type": "integer"}}
}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let result = OpenAPISpec::from_json(doc);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("ids"), "message was: {message}");
assert!(
message.contains("spaceDelimited"),
"the error must name the offending style: {message}"
);
assert!(message.contains("OAI-06"), "message was: {message}");
}
Ok(_) => panic!("non-default style must fail import loudly, got a spec"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn deep_object_style_is_rejected_like_the_other_non_default_forms() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/f": {"get": {
"operationId": "f",
"parameters": [{
"name": "filter",
"in": "query",
"style": "deepObject",
"schema": {"type": "object"}
}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let result = OpenAPISpec::from_json(doc);
assert!(matches!(result, Err(AdapterError::SchemaParse { .. })));
}
#[test]
fn form_style_with_explode_false_is_rejected() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/ids": {"get": {
"operationId": "ids",
"parameters": [{
"name": "ids",
"in": "query",
"style": "form",
"explode": false,
"schema": {"type": "array", "items": {"type": "integer"}}
}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("explode") && message.contains("form"),
"the error must name the form+explode conflict: {message}"
);
assert!(message.contains("ids"), "message was: {message}");
}
Ok(_) => panic!("form+explode=false mis-serializes arrays; must fail loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn simple_style_with_explode_true_is_rejected() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/f": {"get": {
"operationId": "f",
"parameters": [{
"name": "X-Id",
"in": "header",
"style": "simple",
"explode": true,
"schema": {"type": "string"}
}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("simple") && message.contains("explode"),
"the error must name the simple+explode conflict: {message}"
);
assert!(message.contains("X-Id"), "message was: {message}");
assert!(message.contains("OAI-06"), "message was: {message}");
}
Ok(_) => panic!("simple+explode=true has no wire meaning here; must fail loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn default_style_and_explode_forms_still_import() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/x": {"get": {
"operationId": "x",
"parameters": [
{"name": "q", "in": "query", "style": "form", "explode": true, "schema": {"type": "string"}},
{"name": "X-Trace", "in": "header", "style": "simple", "explode": false, "schema": {"type": "string"}}
],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).expect("default style declarations import");
let op = &spec.paths["/x"].operations[0].1;
assert_eq!(op.parameters.len(), 2);
}
#[test]
fn servers_override_at_document_level_fails_import() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/x": {"get": {"operationId": "x", "responses": {
"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("servers"), "message was: {message}");
assert!(message.contains("base_url"), "message was: {message}");
assert!(message.contains("document"), "message was: {message}");
assert!(message.contains("OAI-06"), "message was: {message}");
}
Ok(_) => panic!("document-level servers must fail import loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn servers_override_at_path_and_operation_level_fails_import() {
let path_level = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/x": {
"servers": [{"url": "https://other.example.com"}],
"get": {"operationId": "x", "responses": {
"200": {"content": {"application/json": {"schema": {}}}}}
}
}
}
}"##;
match OpenAPISpec::from_json(path_level) {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("path /x"), "message was: {message}");
}
Ok(_) => panic!("path-level servers must fail import loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
let op_level = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/y": {"get": {
"operationId": "y",
"servers": [{"url": "https://other.example.com"}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(op_level) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("get /y"),
"the error must locate the operation-level override: {message}"
);
}
Ok(_) => panic!("operation-level servers must fail import loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn servers_absent_baseline_still_imports() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/x": {"get": {"operationId": "x", "responses": {
"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).expect("no servers means no conflict");
assert_eq!(spec.paths.len(), 1);
}
#[test]
fn trace_only_path_is_skipped_and_documented_inert() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/debug": {"trace": {"operationId": "debug", "responses": {
"200": {"content": {"application/json": {"schema": {}}}}}
}},
"/x": {"get": {"operationId": "x", "responses": {
"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).expect("trace skip is not an error");
assert!(
!spec.paths.contains_key("/debug"),
"the trace-only path is not imported"
);
assert!(
spec.paths.contains_key("/x"),
"the supported path still imports alongside the skipped one"
);
}
#[test]
fn ref_sibling_keys_import_with_3_0_reading_and_warn() {
let doc = r##"{
"openapi": "3.0.3",
"info": {"title": "T", "version": "1"},
"components": {"parameters": {
"Id": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}
}},
"paths": {
"/users/{id}": {"get": {
"operationId": "getUser",
"parameters": [
{"$ref": "#/components/parameters/Id", "description": "the user id", "deprecated": false}
],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).expect("sibling keys do not fail the import");
let item = spec.paths.get("/users/{id}").expect("path present");
let param = &item.operations[0].1.parameters[0];
assert_eq!(param.name, "id", "the resolved 3.0 reading wins");
assert_eq!(
param.in_, "path",
"the resolved target's fields are what imports"
);
}
#[test]
fn callbacks_at_operation_level_fail_import_naming_the_feature() {
let doc = r#"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/orders": {"post": {
"operationId": "createOrder",
"callbacks": {
"orderEvent": {"{$request.body#/callbackUrl}": {"post": {
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}}
},
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"#;
run_import_expect_oai14(doc, "callbacks", "post /orders");
}
#[test]
fn security_requirements_fail_import_naming_the_remediation() {
let doc_level = r#"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"security": [{"bearerAuth": []}],
"paths": {"/x": {"get": {"operationId": "x", "responses": {
"200": {"content": {"application/json": {"schema": {}}}}}
}}}}
"#;
run_import_expect_oai14(doc_level, "security", "document");
let op_level = r#"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {"/y": {"get": {
"operationId": "y",
"security": [{"apiKey": []}],
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}}}
"#;
run_import_expect_oai14(op_level, "security", "get /y");
}
#[test]
fn top_level_oneof_request_body_fails_import_naming_the_feature() {
let doc = r#"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/x": {"post": {
"operationId": "x",
"requestBody": {
"oneOf": [
{"content": {"application/json": {"schema": {"type": "object"}}}},
{"content": {"text/plain": {"schema": {"type": "string"}}}}
]
},
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"#;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("requestBody") && message.contains("unresolvable"),
"the oneOf body (no content map) fails via the OAI-15 arm: {message}"
);
assert!(message.contains("OAI-15"), "message was: {message}");
}
Ok(_) => panic!("top-level oneOf requestBody must fail import loudly (OAI-14/15)"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
fn run_import_expect_oai14(doc: &str, feature: &str, location: &str) {
let spec = OpenAPISpec::from_json(doc).expect("structural parse passes");
let client = SharedHttpClient::new(HttpClientConfig::default()).expect("client");
let adapter = FromOpenAPI::new(
spec,
HttpServiceConfig {
namespace: "svc".to_string(),
base_url: "https://x".to_string(),
auth: None,
default_headers: HashMap::new(),
},
Arc::new(client),
);
match futures::executor::block_on(adapter.import()) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains(feature),
"the error must name {feature}: {message}"
);
assert!(
message.contains(location),
"the error must locate {location}: {message}"
);
assert!(message.contains("OAI-14"), "message was: {message}");
}
Ok(bundles) => panic!(
"{feature} at {location} must fail import loudly (OAI-14), got {} bundles",
bundles.len()
),
Err(e) => panic!("expected SchemaParse, got {e}"),
}
}
#[test]
fn hundred_thousand_servers_overrides_produce_bounded_error_message() {
let mut paths = String::from("{");
for i in 0..100_000 {
let entry = r#""/p0": {"servers": [{"url": "https://h.example.com"}], "get": {"operationId": "op0", "responses": {"200": {"content": {"application/json": {"schema": {}}}}}}},"#
.replace("p0", &format!("p{i}"))
.replace("h.example.com", &format!("h{i}.example.com"))
.replace("op0", &format!("op{i}"));
paths.push_str(&entry);
}
paths.push_str(r#""/final": {"servers": [{"url": "https://z.example.com"}]}}"#);
let doc = format!(
r#"{{"openapi": "3.0.0", "info": {{"title": "T", "version": "1"}}, "paths": {paths}}}"#
);
let started = std::time::Instant::now();
match OpenAPISpec::from_json(&doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.len() < 4096,
"servers error must stay bounded (OAI-17), got {} bytes",
message.len()
);
assert!(
message.contains('+') && message.contains("more"),
"the bounded join must name the suppressed count: {message}"
);
}
Ok(_) => panic!("100k servers overrides must fail import"),
other => panic!("expected SchemaParse, got {other:?}"),
}
assert!(
started.elapsed().as_secs() < 30,
"the fixtures stay linear/bounded; this took {:?}",
started.elapsed()
);
}
#[test]
fn bounded_join_truncates_both_count_and_width() {
let many: Vec<String> = (0..50).map(|i| format!("item{i}")).collect();
let joined = bounded_join(&many);
assert!(
joined.contains("item0") && joined.contains("item7"),
"first 8 shown: {joined}"
);
assert!(!joined.contains("item8,"), "9th item suppressed: {joined}");
assert!(joined.contains("+42 more"), "count named: {joined}");
let wide = vec!["x".repeat(500)];
let joined = bounded_join(&wide);
assert!(
joined.len() < 200,
"each item truncated to the width cap: {} bytes",
joined.len()
);
assert!(joined.ends_with('…'), "truncation marker: {joined}");
let small = vec!["a".to_string(), "b".to_string()];
assert_eq!(bounded_join(&small), "a, b", "under cap is unchanged");
}
const OAI12_HEADER: &str = r#"
openapi: 3.0.0
info:
title: T
version: "1"
"#;
#[test]
fn from_yaml_duplicate_keys_fail_loudly_not_last_win() {
let doc = r#"
openapi: 3.0.0
info:
title: T
version: "1"
description: first
description: second
paths:
/x:
get:
operationId: x
responses:
"200":
content:
application/json:
schema: {}
"#;
match OpenAPISpec::from_yaml(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("duplicate"),
"the error must name the duplicate: {message}"
);
assert!(
message.contains("line") || message.contains("column"),
"the error must carry the document position: {message}"
);
}
Ok(_) => panic!("duplicate YAML keys must fail loudly, not last-win"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_duplicate_keys_stricter_than_json_path() {
let dup_json = r#"{"a":1,"a":2}"#;
let json_last_wins = serde_json::from_str::<Value>(dup_json)
.expect("serde_json's visit_map last-wins (verified against 1.0.151)");
assert_eq!(
json_last_wins["a"], 2,
"the JSON path silently last-wins; this assertion documents the asymmetry \
the YAML path refuses to reproduce"
);
let yaml_doc = "a: 1\na: 2";
match OpenAPISpec::from_yaml(yaml_doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("duplicate"), "message was: {message}");
}
Ok(_) => panic!("YAML duplicate keys must fail loudly, not last-win"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_inf_maximum_fails_loudly_not_silent_null() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Price:
type: object
properties:
cap:
type: number
maximum: .inf
"#
);
match OpenAPISpec::from_yaml(&doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains(".inf") || message.contains("inf"),
"the error must name the non-finite value: {message}"
);
assert!(
message.contains("maximum") || message.contains("schemas"),
"the error must carry the value's JSON pointer: {message}"
);
}
Ok(spec) => {
let maximum =
&spec.raw["components"]["schemas"]["Price"]["properties"]["cap"]["maximum"];
assert_ne!(
*maximum,
Value::Null,
"maximum: .inf silently nulled — the corruption OAI-12 fixes"
);
panic!("non-finite float must fail loudly, not null");
}
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_nan_value_fails_loudly() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Ratio:
type: object
properties:
value:
type: number
example: .nan
"#
);
match OpenAPISpec::from_yaml(&doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains(".nan") || message.contains("NaN"),
"the error must name the non-finite value: {message}"
);
}
Ok(_) => panic!(".nan must fail loudly, not null"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_merge_key_is_applied_not_advertised() {
let doc = r#"
openapi: 3.0.0
info:
title: T
version: "1"
components:
schemas:
Base: &Base
type: object
required: [id]
properties:
id:
type: string
Widget:
<<: *Base
title: Widget
paths:
/x:
get:
operationId: x
responses:
"200":
content:
application/json:
schema: {}
"#;
let spec = OpenAPISpec::from_yaml(doc).expect("merge keys apply");
let widget = &spec.raw["components"]["schemas"]["Widget"];
assert!(
widget.get("<<").is_none(),
"<< must never survive as a literal property"
);
assert_eq!(widget["type"], "object", "merged from Base");
let required = widget["required"].as_array().expect("required merged");
assert_eq!(required[0], "id");
let props = widget["properties"].as_object().expect("props merged");
assert_eq!(props["id"]["type"], "string", "base properties merged in");
assert_eq!(
widget["title"], "Widget",
"the referencing mapping's own keys are kept"
);
}
#[test]
fn from_yaml_merge_key_local_override_wins_over_base() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Base: &Base
type: object
description: base description
properties:
id:
type: string
Widget:
<<: *Base
description: widget description
paths: {{}}
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("merge keys apply");
let widget = &spec.raw["components"]["schemas"]["Widget"];
assert_eq!(widget["description"], "widget description");
assert_eq!(
widget["type"], "object",
"keys the referencing mapping does not declare come from the merge"
);
}
#[test]
fn from_yaml_scalar_merge_value_fails_loudly() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Broken:
<<: 5
paths: {{}}
"#
);
match OpenAPISpec::from_yaml(&doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("merge"),
"the error must name the merge-key failure: {message}"
);
}
Ok(_) => panic!("`<<:` with a scalar value must fail loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_quoted_status_keys_survive_unchanged() {
let doc = r#"
openapi: 3.0.0
info:
title: T
version: "1"
paths:
/x:
get:
operationId: x
responses:
"200":
description: ok
content:
application/json:
schema: {}
"404":
description: missing
"#;
let spec = OpenAPISpec::from_yaml(doc).expect("quoted keys unchanged");
let responses = &spec.paths["/x"].operations[0].1.responses;
assert!(responses.contains_key("200"));
assert!(responses.contains_key("404"));
assert_eq!(
&spec.raw["paths"]["/x"]["get"]["responses"]["200"]["description"],
"ok"
);
}
#[test]
fn from_yaml_unquoted_status_keys_match_json_path_shape() {
let doc = format!(
r#"{OAI12_HEADER}
paths:
/x:
get:
operationId: x
responses:
200:
description: ok
404:
description: missing
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("bare numeric keys round-trip");
let responses = &spec.paths["/x"].operations[0].1.responses;
assert!(
responses.contains_key("200") && responses.contains_key("404"),
"unquoted response codes must mean the same thing as quoted/JSON ones"
);
}
#[test]
fn from_yaml_null_and_collection_keys_fail_loudly() {
let doc = "~: 1";
match OpenAPISpec::from_yaml(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("no string form") || message.contains("key"),
"the error must name the unusable key: {message}"
);
}
Ok(_) => panic!("null mapping key must fail loudly, not stringily as \"~\""),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn from_yaml_float_key_preserves_rendering() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Mapped:
type: object
additionalProperties:
type: string
x-key-map:
1.5: one-point-five
paths: {{}}
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("finite float key stringifies");
let key_map = &spec.raw["components"]["schemas"]["Mapped"]["x-key-map"];
assert_eq!(key_map["1.5"], "one-point-five");
}
#[test]
fn from_yaml_bool_key_stringifies() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
Mapped:
type: object
x-flags:
true: enabled
paths: {{}}
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("bool key stringifies");
assert_eq!(
&spec.raw["components"]["schemas"]["Mapped"]["x-flags"]["true"],
"enabled"
);
}
#[test]
fn from_yaml_plain_scalars_unaffected_by_normalization() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
All:
type: object
properties:
yes:
type: string
default: yes
n:
type: integer
maximum: 9007199254740993
f:
type: number
example: 1.5
s:
type: string
example: "200"
paths: {{}}
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("normal doc unaffected");
let props = &spec.raw["components"]["schemas"]["All"]["properties"];
assert_eq!(props["yes"]["default"], "yes");
assert_eq!(
props["n"]["maximum"], 9007199254740993i64,
"i64 range survives exactly"
);
assert_eq!(props["f"]["example"], 1.5);
assert_eq!(props["s"]["example"], "200");
}
#[test]
fn from_yaml_untagged_bare_scalars_still_round_trip() {
let doc = format!(
r#"{OAI12_HEADER}
components:
schemas:
T:
type: object
properties:
a:
type: string
default: !!str 200
paths: {{}}
"#
);
let spec = OpenAPISpec::from_yaml(&doc).expect("tagged scalar passes through");
let props = &spec.raw["components"]["schemas"]["T"]["properties"];
assert_eq!(props["a"]["default"], "200");
}
#[test]
fn path_item_parameters_merge_into_operations() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/users/{id}/posts": {
"parameters": [
{"name": "id", "in": "path", "required": true,
"schema": {"type": "string", "pattern": "^u-"}},
{"name": "verbose", "in": "query", "schema": {"type": "boolean"}}
],
"get": {
"operationId": "listPosts",
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
},
"post": {
"operationId": "createPost",
"requestBody": {"content": {"application/json": {"schema": {}}}},
"responses": {"201": {"content": {"application/json": {"schema": {}}}}}
}
}
}
}"##;
let spec = OpenAPISpec::from_json(doc).expect("shared path-item params import");
let item = spec.paths.get("/users/{id}/posts").expect("path present");
assert_eq!(item.parameters.len(), 2, "path-item params retained");
assert_eq!(item.parameters[0].name, "id");
assert_eq!(item.parameters[0].in_, "path");
assert!(item.parameters[0].required);
assert_eq!(item.operations.len(), 2);
}
#[test]
fn path_item_parameter_refs_to_components_resolve() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"components": {
"parameters": {
"Id": {"name": "id", "in": "path", "required": true,
"schema": {"type": "string"}}
}
},
"paths": {
"/users/{id}": {
"parameters": [{"$ref": "#/components/parameters/Id"}],
"get": {
"operationId": "getUser",
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}
}
}
}"##;
let spec = OpenAPISpec::from_json(doc).expect("ref'd path-item params import");
let item = spec.paths.get("/users/{id}").expect("path present");
assert_eq!(item.parameters.len(), 1);
assert_eq!(item.parameters[0].name, "id");
}
#[test]
fn path_item_parameter_missing_in_fails_import_naming_the_cause() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/users/{id}": {
"parameters": [{"name": "id", "schema": {"type": "string"}}],
"get": {
"operationId": "getUser",
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}
}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(
message.contains("path-item parameter"),
"the error must name the path-item parameter level: {message}"
);
assert!(message.contains("id"), "message was: {message}");
}
Ok(_) => panic!("path-item parameter missing `in` must fail loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn path_item_parameter_style_gate_applies() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/tags": {
"parameters": [{
"name": "ids", "in": "query", "style": "deepObject",
"schema": {"type": "object"}
}],
"get": {
"operationId": "listTags",
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}
}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("deepObject"), "message was: {message}");
assert!(message.contains("OAI-06"), "message was: {message}");
}
Ok(_) => panic!("path-item non-default style must fail loudly"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn webhooks_mixed_document_fails_import_naming_the_feature() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"webhooks": {
"newPet": {"post": {
"operationId": "newPet",
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
},
"paths": {
"/pets": {"get": {
"operationId": "listPets",
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
match OpenAPISpec::from_json(doc) {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("webhooks"), "message was: {message}");
assert!(message.contains("OAI-13"), "message was: {message}");
}
Ok(_) => panic!("mixed webhooks+paths doc must fail loudly (OAI-13)"),
other => panic!("expected SchemaParse, got {other:?}"),
}
}
#[test]
fn webhooks_missing_document_still_imports() {
let doc = r##"{
"openapi": "3.0.0",
"info": {"title": "T", "version": "1"},
"paths": {
"/pets": {"get": {
"operationId": "listPets",
"responses": {"200": {"content": {"application/json": {"schema": {}}}}}
}}
}
}"##;
let spec = OpenAPISpec::from_json(doc).expect("no webhooks key is the normal case");
assert_eq!(spec.paths.len(), 1);
}
}