use std::ops::{Deref, DerefMut};
use std::path::Path;
use indexmap::IndexMap;
use serde::de::{IgnoredAny, MapAccess, Visitor};
use serde::Deserialize;
use crate::error::{Error, Result};
#[derive(Debug, Deserialize)]
pub struct OpenApi {
#[serde(skip)]
pub yaml_unquoted_timestamps: Option<std::collections::BTreeSet<String>>,
#[serde(default)]
pub openapi: String,
#[serde(rename = "x-crozier-pagination", default)]
pub(crate) pagination_crozier: Option<Pagination>,
#[serde(rename = "x-fern-pagination", default)]
pub(crate) pagination_fern: Option<Pagination>,
#[serde(rename = "x-crozier-idempotency-headers", default)]
pub(crate) idempotency_headers_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-idempotency-headers", default)]
pub(crate) idempotency_headers_fern: Option<serde_json::Value>,
#[serde(default)]
pub info: Info,
#[serde(default)]
pub components: Components,
#[serde(default, deserialize_with = "deserialize_paths")]
pub paths: IndexMap<String, PathItem>,
#[serde(default)]
pub webhooks: IndexMap<String, PathItem>,
#[serde(default)]
pub security: Option<Vec<SecurityRequirement>>,
#[serde(default, deserialize_with = "de_servers")]
pub servers: Vec<Server>,
#[serde(default)]
pub tags: Vec<ApiTag>,
#[serde(rename = "x-crozier-base-path", default)]
pub base_path_crozier: Option<BasePath>,
#[serde(rename = "x-fern-base-path", default)]
pub base_path_fern: Option<BasePath>,
#[serde(rename = "x-crozier-global-headers", default)]
pub global_headers_crozier: Option<Vec<GlobalHeaderExtension>>,
#[serde(rename = "x-fern-global-headers", default)]
pub global_headers_fern: Option<Vec<GlobalHeaderExtension>>,
#[serde(rename = "x-crozier-sdk-variables", default)]
pub sdk_variables_crozier: Option<IndexMap<String, serde_json::Value>>,
#[serde(rename = "x-fern-sdk-variables", default)]
pub sdk_variables_fern: Option<IndexMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GlobalHeaderExtension {
pub header: String, pub name: String, }
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum BasePath {
Path(String),
Object {
#[serde(default)]
path: String,
#[serde(rename = "paths-include-base-path", default)]
paths_include_base_path: bool,
#[serde(default)]
parameters: Option<BasePathParameters>,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum BasePathParameters {
Map(IndexMap<String, BasePathParameterSchema>),
List(Vec<serde_json::Value>),
Other(serde_json::Value),
}
#[derive(Debug, Clone, Deserialize)]
pub struct BasePathParameterSchema {
#[serde(default)]
pub default: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BasePathParameter {
pub name: String,
pub default: Option<String>,
}
impl BasePath {
#[must_use]
pub fn path(&self) -> (&str, bool) {
match self {
Self::Path(path) => (path, false),
Self::Object {
path,
paths_include_base_path,
..
} => (path, *paths_include_base_path),
}
}
#[must_use]
pub fn route_prefix(&self) -> &str {
match self.path() {
(_, true) => "",
(path, false) => path.trim_end_matches('/'),
}
}
#[must_use]
pub fn parameters(&self) -> Vec<BasePathParameter> {
let declared = match self {
Self::Object {
parameters: Some(BasePathParameters::Map(map)),
..
} => Some(map),
_ => None,
};
let mut rest = self.path().0;
let mut out = Vec::new();
while let Some(open) = rest.find('{') {
let Some(close) = rest[open..].find('}') else {
break;
};
let name = &rest[open + 1..open + close];
let default = declared
.and_then(|map| map.get(name))
.and_then(|schema| schema.default.as_ref())
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
if !name.is_empty() && out.iter().all(|p: &BasePathParameter| p.name != name) {
out.push(BasePathParameter {
name: name.to_owned(),
default,
});
}
rest = &rest[open + close + 1..];
}
out
}
}
impl OpenApi {
#[must_use]
pub fn idempotency_headers(&self) -> Vec<&str> {
self.idempotency_headers_crozier
.as_ref()
.or(self.idempotency_headers_fern.as_ref())
.and_then(serde_json::Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|entry| entry.get("header")?.as_str())
.map(str::trim)
.filter(|header| !header.is_empty())
.collect()
})
.unwrap_or_default()
}
#[must_use]
pub fn base_path(&self) -> Option<&BasePath> {
self.base_path_crozier
.as_ref()
.or(self.base_path_fern.as_ref())
}
#[must_use]
pub fn global_header_extensions(&self) -> &[GlobalHeaderExtension] {
self.global_headers_crozier
.as_deref()
.or(self.global_headers_fern.as_deref())
.unwrap_or_default()
}
#[must_use]
pub fn sdk_variables(&self) -> Option<&IndexMap<String, serde_json::Value>> {
self.sdk_variables_crozier
.as_ref()
.or(self.sdk_variables_fern.as_ref())
}
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct ApiTag {
#[serde(default)]
pub name: String,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct Server {
#[serde(default)]
pub url: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub variables: IndexMap<String, ServerVariable>,
#[serde(rename = "x-crozier-server-name", default)]
pub(crate) server_name_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-server-name", default)]
pub(crate) server_name_fern: Option<serde_json::Value>,
#[serde(rename = "x-crozier-default-url", default)]
pub(crate) default_url_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-default-url", default)]
pub(crate) default_url_fern: Option<serde_json::Value>,
}
impl Server {
fn extension_text<'a>(
crozier: Option<&'a serde_json::Value>,
fern: Option<&'a serde_json::Value>,
) -> Option<&'a str> {
crozier
.or(fern)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
}
#[must_use]
pub fn server_name(&self) -> Option<&str> {
Self::extension_text(
self.server_name_crozier.as_ref(),
self.server_name_fern.as_ref(),
)
}
#[must_use]
pub fn default_url(&self) -> Option<&str> {
Self::extension_text(
self.default_url_crozier.as_ref(),
self.default_url_fern.as_ref(),
)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServerVariable {
#[serde(default)]
pub default: String,
}
fn deserialize_paths<'de, D>(
deserializer: D,
) -> std::result::Result<IndexMap<String, PathItem>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct PathsVisitor;
impl<'de> Visitor<'de> for PathsVisitor {
type Value = IndexMap<String, PathItem>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("an OpenAPI paths object")
}
fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut paths = IndexMap::new();
while let Some(key) = map.next_key::<String>()? {
if key.starts_with("x-") {
map.next_value::<IgnoredAny>()?;
} else {
paths.insert(key, map.next_value()?);
}
}
Ok(paths)
}
}
deserializer.deserialize_map(PathsVisitor)
}
pub type SecurityRequirement = IndexMap<String, Vec<String>>;
#[derive(Debug, Default, Clone, Deserialize)]
pub struct SecurityScheme {
#[serde(rename = "$ref", default)]
pub reference: Option<String>,
#[serde(rename = "type", default)]
pub ty: SecuritySchemeType,
#[serde(default)]
pub scheme: Option<HttpAuthScheme>,
#[serde(default)]
pub name: Option<String>,
#[serde(rename = "in", default)]
pub location: Option<ParameterLocation>,
#[serde(default)]
pub flows: Option<OAuthFlows>,
#[serde(rename = "x-crozier-header", default)]
pub(crate) header_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-header", default)]
pub(crate) header_fern: Option<serde_json::Value>,
#[serde(rename = "x-crozier-bearer", default)]
pub(crate) bearer_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-bearer", default)]
pub(crate) bearer_fern: Option<serde_json::Value>,
#[serde(rename = "x-crozier-basic", default)]
pub(crate) basic_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-basic", default)]
pub(crate) basic_fern: Option<serde_json::Value>,
#[serde(rename = "x-crozier-token-variable-name", default)]
pub(crate) token_variable_name_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-token-variable-name", default)]
pub(crate) token_variable_name_fern: Option<serde_json::Value>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CredentialNaming {
pub name: Option<String>,
pub env: Option<String>,
pub prefix: Option<String>,
}
impl CredentialNaming {
fn from_value(value: Option<&serde_json::Value>) -> Self {
let field = |key: &str| {
value
.and_then(|value| value.get(key))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string)
};
CredentialNaming {
name: field("name"),
env: field("env"),
prefix: field("prefix"),
}
}
}
impl SecurityScheme {
#[must_use]
pub fn header_credential(&self) -> CredentialNaming {
CredentialNaming::from_value(self.header_crozier.as_ref().or(self.header_fern.as_ref()))
}
#[must_use]
pub fn bearer_credential(&self) -> CredentialNaming {
let mut naming = CredentialNaming::from_value(
self.bearer_crozier.as_ref().or(self.bearer_fern.as_ref()),
);
naming.prefix = None;
if naming.name.is_none() {
naming.name = self
.token_variable_name_crozier
.as_ref()
.or(self.token_variable_name_fern.as_ref())
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string);
}
naming
}
#[must_use]
pub fn basic_credentials(&self) -> (CredentialNaming, CredentialNaming) {
let declared = self.basic_crozier.as_ref().or(self.basic_fern.as_ref());
let part = |key: &str| {
let mut naming =
CredentialNaming::from_value(declared.and_then(|value| value.get(key)));
naming.prefix = None;
naming
};
(part("username"), part("password"))
}
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct OAuthFlows {
#[serde(rename = "authorizationCode", default)]
pub authorization_code: Option<OAuthFlow>,
#[serde(rename = "clientCredentials", default)]
pub client_credentials: Option<OAuthFlow>,
#[serde(default)]
pub implicit: Option<OAuthFlow>,
#[serde(default)]
pub password: Option<OAuthFlow>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct OAuthFlow {
#[serde(default, deserialize_with = "de_oauth_scopes")]
pub scopes: IndexMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
pub enum SecuritySchemeType {
#[serde(rename = "apiKey")]
ApiKey,
#[serde(rename = "http")]
Http,
#[serde(rename = "oauth2")]
OAuth2,
#[serde(rename = "openIdConnect")]
OpenIdConnect,
#[serde(rename = "mutualTLS")]
MutualTls,
#[serde(other)]
#[default]
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpAuthScheme {
Bearer,
Basic,
Other,
}
impl<'de> Deserialize<'de> for HttpAuthScheme {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
Ok(match name.to_ascii_lowercase().as_str() {
"bearer" => HttpAuthScheme::Bearer,
"basic" => HttpAuthScheme::Basic,
_ => HttpAuthScheme::Other,
})
}
}
#[derive(Debug, Default, Deserialize)]
pub struct PathItem {
#[serde(rename = "$ref", default)]
pub reference: Option<String>,
#[serde(default)]
pub get: Option<Operation>,
#[serde(default)]
pub post: Option<Operation>,
#[serde(default)]
pub put: Option<Operation>,
#[serde(default)]
pub delete: Option<Operation>,
#[serde(default)]
pub patch: Option<Operation>,
#[serde(default)]
pub head: Option<Operation>,
#[serde(default)]
pub parameters: Vec<Parameter>,
}
impl PathItem {
#[must_use]
pub fn operations(&self) -> Vec<(&'static str, &Operation)> {
[
("GET", &self.get),
("POST", &self.post),
("PUT", &self.put),
("DELETE", &self.delete),
("PATCH", &self.patch),
("HEAD", &self.head),
]
.into_iter()
.filter_map(|(method, op)| op.as_ref().map(|o| (method, o)))
.collect()
}
pub(crate) fn operation_slots(&mut self) -> [&mut Option<Operation>; 6] {
[
&mut self.get,
&mut self.post,
&mut self.put,
&mut self.delete,
&mut self.patch,
&mut self.head,
]
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Operation {
#[serde(skip)]
pub path_level_parameters: bool,
#[serde(rename = "operationId", default)]
pub operation_id: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(rename = "x-crozier-audiences", default)]
audiences_crozier: Option<Vec<String>>,
#[serde(rename = "x-fern-audiences", default)]
audiences_fern: Option<Vec<String>>,
#[serde(rename = "x-crozier-ignore", default, deserialize_with = "de_boolean")]
ignore_crozier: Option<bool>,
#[serde(rename = "x-fern-ignore", default, deserialize_with = "de_boolean")]
ignore_fern: Option<bool>,
#[serde(rename = "x-crozier-sdk-group-name", default)]
sdk_group_name_crozier: Option<SdkGroupName>,
#[serde(rename = "x-fern-sdk-group-name", default)]
sdk_group_name_fern: Option<SdkGroupName>,
#[serde(rename = "x-crozier-idempotent", default)]
pub(crate) idempotent_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-idempotent", default)]
pub(crate) idempotent_fern: Option<serde_json::Value>,
#[serde(default, deserialize_with = "de_servers")]
pub servers: Vec<Server>,
#[serde(rename = "x-crozier-webhook", default)]
pub(crate) webhook_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-webhook", default)]
pub(crate) webhook_fern: Option<serde_json::Value>,
#[serde(rename = "x-crozier-retries", default)]
pub(crate) retries_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-retries", default)]
pub(crate) retries_fern: Option<serde_json::Value>,
#[serde(
rename = "x-crozier-sdk-method-name",
default,
deserialize_with = "de_sdk_method_name"
)]
sdk_method_name_crozier: Option<String>,
#[serde(
rename = "x-fern-sdk-method-name",
default,
deserialize_with = "de_sdk_method_name"
)]
sdk_method_name_fern: Option<String>,
#[serde(rename = "x-crozier-streaming", default)]
streaming_crozier: Option<Box<Streaming>>,
#[serde(rename = "x-fern-streaming", default)]
streaming_fern: Option<Box<Streaming>>,
#[serde(rename = "x-crozier-pagination", default)]
pagination_crozier: Option<DeclaredPagination>,
#[serde(rename = "x-fern-pagination", default)]
pagination_fern: Option<DeclaredPagination>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub parameters: Vec<Parameter>,
#[serde(rename = "requestBody", default)]
pub request_body: Option<RequestBody>,
#[serde(rename = "x-codegen-request-body-name", default)]
pub codegen_request_body_name: Option<String>,
#[serde(default, deserialize_with = "deserialize_responses")]
pub responses: IndexMap<String, Response>,
#[serde(default)]
pub security: Option<Vec<SecurityRequirement>>,
}
fn deserialize_responses<'de, D>(
deserializer: D,
) -> std::result::Result<IndexMap<String, Response>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ResponsesVisitor;
impl<'de> Visitor<'de> for ResponsesVisitor {
type Value = IndexMap<String, Response>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("an OpenAPI responses object")
}
fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut responses = IndexMap::new();
while let Some(key) = map.next_key::<String>()? {
if key.starts_with("x-") {
map.next_value::<IgnoredAny>()?;
} else {
responses.insert(key, map.next_value()?);
}
}
Ok(responses)
}
}
deserializer.deserialize_map(ResponsesVisitor)
}
impl Operation {
#[must_use]
pub fn audiences(&self) -> &[String] {
self.audiences_crozier
.as_deref()
.or(self.audiences_fern.as_deref())
.unwrap_or(&[])
}
#[must_use]
pub fn ignored(&self) -> bool {
self.ignore_crozier.or(self.ignore_fern).unwrap_or(false)
}
#[must_use]
pub fn sdk_group_name(&self) -> Option<Vec<&str>> {
let declared = self
.sdk_group_name_crozier
.as_ref()
.or(self.sdk_group_name_fern.as_ref())?;
let segments: Vec<&str> = match declared {
SdkGroupName::One(name) => vec![name.trim()],
SdkGroupName::Nested(names) => names.iter().map(|name| name.trim()).collect(),
};
let segments: Vec<&str> = segments
.into_iter()
.filter(|segment| !segment.is_empty())
.collect();
(!segments.is_empty()).then_some(segments)
}
#[must_use]
pub fn idempotent(&self) -> bool {
self.idempotent_crozier
.as_ref()
.or(self.idempotent_fern.as_ref())
== Some(&serde_json::Value::Bool(true))
}
#[must_use]
pub fn webhook_marked(&self) -> bool {
self.webhook_crozier.as_ref().or(self.webhook_fern.as_ref())
== Some(&serde_json::Value::Bool(true))
}
#[must_use]
pub fn retries_disabled(&self) -> bool {
self.retries_crozier
.as_ref()
.or(self.retries_fern.as_ref())
.and_then(|retries| retries.get("disabled"))
== Some(&serde_json::Value::Bool(true))
}
#[must_use]
pub fn sdk_method_name(&self) -> Option<&str> {
self.sdk_method_name_crozier
.as_deref()
.or(self.sdk_method_name_fern.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
}
pub(crate) fn with_sdk_method_name(&self, name: &str) -> Self {
let mut view = self.clone();
view.sdk_method_name_crozier = Some(name.to_string());
view
}
#[must_use]
pub fn pagination(&self) -> Option<&Pagination> {
match self
.pagination_crozier
.as_ref()
.or(self.pagination_fern.as_ref())?
{
DeclaredPagination::Contract(contract) => Some(contract),
DeclaredPagination::Root(_) => None,
}
}
#[must_use]
pub fn streaming(&self) -> Option<&Streaming> {
self.streaming_crozier
.as_deref()
.or(self.streaming_fern.as_deref())
.filter(|streaming| streaming.declares_stream())
}
pub(crate) fn set_streaming(&mut self, streaming: Option<Streaming>) {
self.streaming_crozier = streaming.map(Box::new);
self.streaming_fern = None;
}
pub(crate) fn take_crozier_naming(&mut self) -> Option<CrozierNaming> {
if self.sdk_group_name_crozier.is_none() && self.sdk_method_name_crozier.is_none() {
return None;
}
Some(CrozierNaming {
group: self.sdk_group_name_crozier.take(),
method: self.sdk_method_name_crozier.take(),
})
}
pub(crate) fn restore_crozier_naming(&mut self, naming: CrozierNaming) {
self.sdk_group_name_crozier = naming.group;
self.sdk_method_name_crozier = naming.method;
}
}
pub(crate) struct CrozierNaming {
group: Option<SdkGroupName>,
method: Option<String>,
}
#[derive(Debug, Clone)]
pub enum Streaming {
Flag(bool),
Mapping(Box<StreamingMapping>),
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct StreamingMapping {
#[serde(default)]
pub format: Option<StreamFormat>,
#[serde(default)]
pub response: Option<Schema>,
#[serde(rename = "response-stream", default)]
pub response_stream: Option<Schema>,
#[serde(rename = "stream-condition", default)]
pub stream_condition: Option<String>,
#[serde(default)]
pub terminator: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StreamFormat {
Sse,
Json,
}
impl<'de> Deserialize<'de> for Streaming {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
struct StreamingVisitor;
impl<'de> Visitor<'de> for StreamingVisitor {
type Value = Streaming;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a boolean or a streaming mapping")
}
fn visit_bool<E: serde::de::Error>(
self,
flag: bool,
) -> std::result::Result<Streaming, E> {
Ok(Streaming::Flag(flag))
}
fn visit_map<A: MapAccess<'de>>(
self,
map: A,
) -> std::result::Result<Streaming, A::Error> {
StreamingMapping::deserialize(serde::de::value::MapAccessDeserializer::new(map))
.map(|mapping| Streaming::Mapping(Box::new(mapping)))
}
}
deserializer.deserialize_any(StreamingVisitor)
}
}
impl Streaming {
#[must_use]
pub fn declares_stream(&self) -> bool {
!matches!(self, Streaming::Flag(false))
}
#[must_use]
pub fn mapping(&self) -> Option<&StreamingMapping> {
match self {
Streaming::Mapping(mapping) => Some(mapping),
Streaming::Flag(_) => None,
}
}
#[must_use]
pub fn is_sse(&self) -> bool {
self.mapping()
.is_some_and(|mapping| mapping.format == Some(StreamFormat::Sse))
}
#[must_use]
pub fn streams_unconditionally(&self) -> bool {
match self {
Streaming::Flag(flag) => *flag,
Streaming::Mapping(mapping) => {
self.condition_property().is_none() && mapping.format.is_some()
}
}
}
#[must_use]
pub fn condition_property(&self) -> Option<&str> {
self.mapping()?
.stream_condition
.as_deref()
.map(strip_selector_prefix)
.filter(|property| !property.is_empty())
}
#[must_use]
pub fn terminator(&self) -> Option<&str> {
self.mapping()?.terminator.as_deref()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum SdkGroupName {
One(String),
Nested(Vec<String>),
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
pub struct EnumValueName {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) enum DeclaredPagination {
Contract(Pagination),
Root(bool),
}
impl<'de> Deserialize<'de> for DeclaredPagination {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
struct Declared;
impl<'de> serde::de::Visitor<'de> for Declared {
type Value = DeclaredPagination;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(
"a pagination contract, or a boolean taking the document's root contract",
)
}
fn visit_bool<E: serde::de::Error>(
self,
value: bool,
) -> std::result::Result<Self::Value, E> {
Ok(DeclaredPagination::Root(value))
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
map: A,
) -> std::result::Result<Self::Value, A::Error> {
Pagination::deserialize(serde::de::value::MapAccessDeserializer::new(map))
.map(DeclaredPagination::Contract)
}
}
deserializer.deserialize_any(Declared)
}
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct Pagination {
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub next_cursor: Option<String>,
#[serde(default)]
pub results: Option<String>,
#[serde(default)]
pub offset: Option<String>,
}
impl Pagination {
#[must_use]
pub fn cursor_property(&self) -> Option<&str> {
self.cursor.as_deref().map(strip_selector_prefix)
}
#[must_use]
pub fn next_cursor_property(&self) -> Option<&str> {
self.next_cursor.as_deref().map(strip_selector_prefix)
}
#[must_use]
pub fn results_property(&self) -> Option<&str> {
self.results.as_deref().map(strip_selector_prefix)
}
#[must_use]
pub fn offset_property(&self) -> Option<&str> {
self.offset.as_deref().map(strip_selector_prefix)
}
}
fn strip_selector_prefix(path: &str) -> &str {
path.split_once('.').map_or(path, |(_, rest)| rest)
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct Parameter {
#[serde(rename = "$ref", default)]
pub reference: Option<String>,
#[serde(skip)]
pub component_ref: bool,
#[serde(default)]
pub name: String,
#[serde(rename = "in", default)]
pub location: Option<ParameterLocation>,
#[serde(default, deserialize_with = "de_boolean")]
pub required: Option<bool>,
#[serde(default, deserialize_with = "de_boolean")]
pub explode: Option<bool>,
#[serde(default)]
pub style: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default, deserialize_with = "de_optional_schema")]
pub schema: Option<Schema>,
#[serde(default)]
pub content: IndexMap<String, MediaType>,
#[serde(default)]
pub example: Option<serde_json::Value>,
#[serde(default)]
pub examples: IndexMap<String, ParameterExample>,
#[serde(rename = "x-crozier-ignore", default, deserialize_with = "de_boolean")]
pub ignore_crozier: Option<bool>,
#[serde(rename = "x-fern-ignore", default, deserialize_with = "de_boolean")]
pub ignore_fern: Option<bool>,
#[serde(rename = "x-crozier-parameter-name", default)]
pub parameter_name_crozier: Option<String>, #[serde(rename = "x-fern-parameter-name", default)]
pub parameter_name_fern: Option<String>, #[serde(rename = "x-crozier-default", default)]
pub default_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-default", default)]
pub default_fern: Option<serde_json::Value>,
#[serde(rename = "x-crozier-sdk-variable", default)]
pub sdk_variable_crozier: Option<String>, #[serde(rename = "x-fern-sdk-variable", default)]
pub sdk_variable_fern: Option<String>, }
impl Parameter {
#[must_use]
pub fn ignored(&self) -> bool {
self.ignore_crozier.or(self.ignore_fern).unwrap_or(false)
}
#[must_use]
pub fn sdk_name(&self) -> &str {
self.parameter_name_crozier
.as_deref()
.or(self.parameter_name_fern.as_deref())
.unwrap_or(&self.name)
}
#[must_use]
pub fn sdk_default(&self) -> Option<&serde_json::Value> {
self.default_crozier.as_ref().or(self.default_fern.as_ref())
}
#[must_use]
pub fn sdk_variable(&self) -> Option<&str> {
self.sdk_variable_crozier
.as_deref()
.or(self.sdk_variable_fern.as_deref())
}
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct ParameterExample {
#[serde(rename = "$ref", default)]
pub reference: Option<String>,
#[serde(default)]
pub value: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParameterLocation {
Path,
Query,
Header,
Cookie,
#[serde(other)]
Other,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct RequestBody {
#[serde(rename = "$ref", default)]
pub reference: Option<String>,
#[serde(skip)]
pub component_ref: bool,
#[serde(default, deserialize_with = "de_boolean")]
pub required: Option<bool>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub content: IndexMap<String, MediaType>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct Response {
#[serde(rename = "$ref", default)]
pub reference: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub content: IndexMap<String, MediaType>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(from = "MediaTypeDecl")]
pub struct MediaType {
sdk_method_name_crozier: Option<String>,
sdk_method_name_fern: Option<String>,
pub schema: Option<Schema>,
item_schema: Option<Schema>,
pub example: Option<serde_json::Value>,
pub examples: IndexMap<String, ParameterExample>,
pub encoding: IndexMap<String, Encoding>,
}
#[derive(Deserialize)]
struct MediaTypeDecl {
#[serde(rename = "x-crozier-sdk-method-name", default)]
sdk_method_name_crozier: Option<String>,
#[serde(rename = "x-fern-sdk-method-name", default)]
sdk_method_name_fern: Option<String>,
#[serde(default)]
schema: Option<Schema>,
#[serde(rename = "itemSchema", default)]
item_schema: Option<Schema>,
#[serde(default)]
example: Option<serde_json::Value>,
#[serde(default)]
examples: IndexMap<String, ParameterExample>,
#[serde(default)]
encoding: IndexMap<String, Encoding>,
}
impl From<MediaTypeDecl> for MediaType {
fn from(decl: MediaTypeDecl) -> Self {
MediaType {
sdk_method_name_crozier: decl.sdk_method_name_crozier,
sdk_method_name_fern: decl.sdk_method_name_fern,
schema: decl.schema,
item_schema: decl.item_schema,
example: decl.example,
examples: decl.examples,
encoding: decl.encoding,
}
}
}
impl MediaType {
#[must_use]
pub fn sdk_method_name(&self) -> Option<&str> {
self.sdk_method_name_crozier
.as_deref()
.or(self.sdk_method_name_fern.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
}
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct Encoding {
#[serde(rename = "contentType", default)]
pub content_type: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct Info {
#[serde(default)]
pub title: String,
#[serde(default)]
pub version: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct Components {
#[serde(default)]
pub schemas: IndexMap<String, Schema>,
#[serde(default)]
pub parameters: IndexMap<String, Parameter>,
#[serde(default)]
pub responses: IndexMap<String, Response>,
#[serde(rename = "requestBodies", default)]
pub request_bodies: IndexMap<String, RequestBody>,
#[serde(default)]
pub examples: IndexMap<String, ParameterExample>,
#[serde(rename = "securitySchemes", default)]
pub security_schemes: IndexMap<String, SecurityScheme>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(from = "String")]
pub(crate) enum LiteralTypeOverride {
True,
False,
Unsupported,
}
impl From<String> for LiteralTypeOverride {
fn from(value: String) -> Self {
match value.as_str() {
"literal<true>" => Self::True,
"literal<false>" => Self::False,
_ => Self::Unsupported,
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
pub struct Schema {
#[serde(rename = "$ref", default)]
pub reference: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(rename = "type", default)]
pub ty: Option<TypeField>,
#[serde(default)]
pub format: Option<String>,
#[serde(rename = "x-crozier-type", default)]
pub(crate) type_crozier: Option<LiteralTypeOverride>,
#[serde(rename = "x-fern-type", default)]
pub(crate) type_fern: Option<LiteralTypeOverride>,
#[serde(rename = "contentMediaType", default)]
pub content_media_type: Option<String>,
#[serde(default, deserialize_with = "de_properties")]
pub properties: SchemaProperties,
#[serde(default, deserialize_with = "de_required")]
pub required: RequiredNames,
#[serde(default, deserialize_with = "de_items")]
pub items: Option<Box<Schema>>,
#[serde(rename = "$defs", default, deserialize_with = "de_defs")]
pub defs: IndexMap<String, Schema>,
#[serde(rename = "uniqueItems", default, deserialize_with = "de_boolean")]
pub unique_items: Option<bool>,
#[serde(default)]
pub default: Option<serde_json::Value>,
#[serde(rename = "additionalProperties", default)]
pub additional_properties: Option<AdditionalProperties>,
#[serde(rename = "patternProperties", default)]
pub pattern_properties: Option<serde_json::Value>,
#[serde(rename = "enum", default)]
pub enum_values: Option<Vec<serde_json::Value>>,
#[serde(rename = "const", default)]
pub const_value: Option<serde_json::Value>,
#[serde(rename = "oneOf", default, deserialize_with = "de_composition")]
pub one_of: Option<Vec<Schema>>,
#[serde(rename = "anyOf", default, deserialize_with = "de_composition")]
pub any_of: Option<Vec<Schema>>,
#[serde(rename = "allOf", default, deserialize_with = "de_composition")]
pub all_of: Option<Vec<Schema>>,
#[serde(rename = "not", default)]
pub(crate) not_schema: Option<serde_json::Value>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub example: Option<serde_json::Value>,
#[serde(default, deserialize_with = "de_schema_examples")]
pub examples: Vec<serde_json::Value>,
#[serde(default)]
pub minimum: Option<serde_json::Value>,
#[serde(rename = "minLength", default)]
pub min_length: Option<u64>,
#[serde(rename = "maxLength", default)]
pub max_length: Option<u64>,
#[serde(default)]
pub pattern: Option<String>,
#[serde(rename = "x-is-beta", default, deserialize_with = "de_boolean")]
pub is_beta: Option<bool>,
#[serde(default, deserialize_with = "de_boolean")]
pub nullable: Option<bool>,
#[serde(skip)]
pub(crate) reference_nullable: bool,
#[serde(default, deserialize_with = "de_deprecated")]
pub deprecated: bool,
#[serde(rename = "readOnly", default, deserialize_with = "de_boolean")]
pub read_only: Option<bool>,
#[serde(default)]
pub discriminator: Option<Discriminator>,
#[serde(rename = "x-crozier-ignore", default, deserialize_with = "de_boolean")]
pub ignore_crozier: Option<bool>,
#[serde(rename = "x-fern-ignore", default, deserialize_with = "de_boolean")]
pub ignore_fern: Option<bool>,
#[serde(rename = "x-crozier-type-name", default)]
pub(crate) type_name_crozier: Option<String>,
#[serde(rename = "x-fern-type-name", default)]
pub(crate) type_name_fern: Option<String>,
#[serde(rename = "x-crozier-sdk-group-name", default)]
pub(crate) sdk_group_name_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-sdk-group-name", default)]
pub(crate) sdk_group_name_fern: Option<serde_json::Value>,
#[serde(rename = "x-tags", default)]
pub(crate) x_tags: Option<serde_json::Value>,
#[serde(rename = "x-crozier-enum", default)]
pub(crate) enum_names_crozier: Option<IndexMap<String, EnumValueName>>,
#[serde(rename = "x-fern-enum", default)]
pub(crate) enum_names_fern: Option<IndexMap<String, EnumValueName>>,
#[serde(rename = "x-enum-varnames", default)]
pub(crate) enum_varnames: Option<Vec<String>>,
#[serde(rename = "x-crozier-property-name", default)]
pub(crate) property_name_crozier: Option<String>,
#[serde(rename = "x-fern-property-name", default)]
pub(crate) property_name_fern: Option<String>,
#[serde(
rename = "x-crozier-discriminated",
default,
deserialize_with = "de_boolean"
)]
pub(crate) discriminated_crozier: Option<bool>,
#[serde(
rename = "x-fern-discriminated",
default,
deserialize_with = "de_boolean"
)]
pub(crate) discriminated_fern: Option<bool>,
#[serde(skip)]
pub multi_type_union: bool,
#[serde(skip)]
pub ref_origin: Option<String>,
#[serde(skip)]
pub malformed: bool,
#[serde(skip)]
pub(crate) boolean_stand_in: bool,
#[serde(skip)]
pub unresolved_reference: bool,
#[serde(skip)]
pub(crate) discarded_composition: bool,
#[serde(rename = "if", default, deserialize_with = "de_present")]
pub(crate) conditional: bool,
#[serde(rename = "$dynamicRef", default, deserialize_with = "de_present")]
pub(crate) dynamic_ref: bool,
#[serde(rename = "$recursiveRef", default, deserialize_with = "de_present")]
pub(crate) recursive_ref: bool,
#[serde(default)]
pub(crate) xml: Option<serde_json::Value>,
#[serde(rename = "x-crozier-xml-text", default)]
pub(crate) xml_text_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-xml-text", default)]
pub(crate) xml_text_fern: Option<serde_json::Value>,
#[serde(rename = "x-crozier-xml-list-separator", default)]
pub(crate) xml_list_separator_crozier: Option<serde_json::Value>,
#[serde(rename = "x-fern-xml-list-separator", default)]
pub(crate) xml_list_separator_fern: Option<serde_json::Value>,
}
#[must_use]
pub fn json_truthy(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Null => false,
serde_json::Value::Bool(flag) => *flag,
serde_json::Value::Number(number) => number.as_f64().is_some_and(|n| n != 0.0),
serde_json::Value::String(text) => !text.is_empty(),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => true,
}
}
fn de_present<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<bool, D::Error> {
IgnoredAny::deserialize(deserializer).map(|_| true)
}
impl Schema {
pub(crate) fn drops_applicator(&self) -> bool {
self.conditional || self.dynamic_ref || self.recursive_ref
}
#[must_use]
pub fn ignored(&self) -> bool {
self.ignore_crozier.or(self.ignore_fern).unwrap_or(false)
}
pub(crate) fn negative_only(&self) -> bool {
self.not_schema
.as_ref()
.and_then(serde_json::Value::as_object)
.is_some_and(serde_json::Map::is_empty)
&& self.reference.is_none()
&& self.ty.is_none()
&& self.properties.is_empty()
&& self.items.is_none()
&& self.additional_properties.is_none()
&& self.enum_values.is_none()
&& self.const_value.is_none()
&& self.all_of.is_none()
&& self.one_of.is_none()
&& self.any_of.is_none()
}
#[must_use]
pub fn property_name(&self) -> Option<&str> {
self.property_name_crozier
.as_deref()
.or(self.property_name_fern.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
}
#[must_use]
pub fn xml_object(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
self.xml.as_ref().and_then(serde_json::Value::as_object)
}
#[must_use]
pub fn xml_text(&self) -> Option<bool> {
self.xml_text_crozier
.as_ref()
.or(self.xml_text_fern.as_ref())
.filter(|value| !value.is_null())
.map(json_truthy)
}
#[must_use]
pub fn xml_list_separator(&self) -> Option<&str> {
self.xml_list_separator_crozier
.as_ref()
.filter(|value| !value.is_null())
.or(self.xml_list_separator_fern.as_ref())
.and_then(serde_json::Value::as_str)
}
#[must_use]
pub fn discriminated(&self) -> Option<bool> {
self.discriminated_crozier.or(self.discriminated_fern)
}
#[must_use]
pub fn sdk_group_name(&self) -> Option<Vec<&str>> {
let declared = self
.sdk_group_name_crozier
.as_ref()
.or(self.sdk_group_name_fern.as_ref())?;
let segments: Vec<&str> = match declared {
serde_json::Value::String(name) => vec![name.trim()],
serde_json::Value::Array(names) => names
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::trim)
.collect(),
_ => Vec::new(),
};
let segments: Vec<&str> = segments.into_iter().filter(|s| !s.is_empty()).collect();
(!segments.is_empty()).then_some(segments)
}
#[must_use]
pub fn placement_tag(&self) -> Option<&str> {
self.x_tags
.as_ref()?
.as_array()?
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::trim)
.find(|tag| !tag.is_empty())
}
#[must_use]
pub fn declared_type_name(&self) -> Option<&str> {
[&self.type_name_crozier, &self.type_name_fern]
.into_iter()
.flatten()
.map(String::as_str)
.find(|name| !name.trim().is_empty())
}
pub(crate) fn bool_literal(&self) -> Option<bool> {
match self.type_crozier.as_ref().or(self.type_fern.as_ref())? {
LiteralTypeOverride::True => Some(true),
LiteralTypeOverride::False => Some(false),
LiteralTypeOverride::Unsupported => None,
}
}
pub(crate) fn enum_member_description(&self, value: &str) -> Option<&str> {
self.enum_names_crozier
.as_ref()
.or(self.enum_names_fern.as_ref())?
.get(value)?
.description
.as_deref()
}
pub fn enum_member_names(&self) -> impl Iterator<Item = (&str, &str)> {
let varnames = self.enum_varnames.as_deref().unwrap_or_default();
let shared = match varnames {
[first, _, ..] => first
.char_indices()
.find(|&(index, ch)| {
varnames.iter().any(|name| {
name.get(index..).and_then(|rest| rest.chars().next()) != Some(ch)
})
})
.map_or(first.len(), |(index, _)| index),
_ => 0,
};
let mut names: IndexMap<&str, &str> = self
.enum_values
.iter()
.flatten()
.zip(
varnames
.iter()
.map(|name| name.get(shared..).unwrap_or_default()),
)
.filter_map(|(value, name)| Some((value.as_str()?, name.trim())))
.filter(|(_, name)| !name.is_empty())
.collect();
names.extend(
self.enum_names_crozier
.as_ref()
.or(self.enum_names_fern.as_ref())
.into_iter()
.flatten()
.filter_map(|(value, declared)| {
let name = declared.name.as_deref()?.trim();
(!name.is_empty()).then_some((value.as_str(), name))
}),
);
names.into_iter()
}
#[must_use]
pub fn explicitly_nullable(&self) -> bool {
let map_first = self.properties.is_empty()
&& self
.ty
.as_ref()
.is_none_or(|ty| ty.primary() == Some("object"))
&& matches!(
self.additional_properties,
Some(AdditionalProperties::Bool(true) | AdditionalProperties::Schema(_))
);
self.nullable == Some(true)
|| matches!(
self.ty.as_ref(),
Some(TypeField::Multiple(types)) if types.iter().any(|ty| ty == "null")
)
|| !map_first
&& self
.one_of
.as_ref()
.or(self.any_of.as_ref())
.is_some_and(|members| {
members.iter().any(|member| {
member.ty.as_ref().and_then(TypeField::primary) == Some("null")
})
})
}
#[must_use]
pub fn nullable_at_references(&self) -> bool {
self.explicitly_nullable()
|| self
.one_of
.iter()
.chain(self.any_of.iter())
.flatten()
.any(|member| {
member.reference.is_none()
&& matches!(&member.ty, Some(TypeField::Single(ty)) if ty == "null")
})
}
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct SchemaProperties {
values: IndexMap<String, Schema>,
declared: bool,
}
impl SchemaProperties {
#[must_use]
pub fn declared(&self) -> bool {
self.declared
}
}
impl Deref for SchemaProperties {
type Target = IndexMap<String, Schema>;
fn deref(&self) -> &Self::Target {
&self.values
}
}
impl DerefMut for SchemaProperties {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.values
}
}
impl<'a> IntoIterator for &'a SchemaProperties {
type Item = (&'a String, &'a Schema);
type IntoIter = indexmap::map::Iter<'a, String, Schema>;
fn into_iter(self) -> Self::IntoIter {
self.values.iter()
}
}
fn de_composition<'de, D>(deserializer: D) -> std::result::Result<Option<Vec<Schema>>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
#[derive(Deserialize)]
#[serde(untagged)]
enum Composition {
Sequence(Vec<Schema>),
Indexed(IndexMap<String, Schema>),
}
match Option::<Composition>::deserialize(deserializer)? {
None => Ok(None),
Some(Composition::Sequence(members)) => Ok(Some(members)),
Some(Composition::Indexed(members)) => {
let mut indexed = members
.into_iter()
.map(|(index, schema)| {
index.parse::<usize>().map(|i| (i, schema)).map_err(|_| {
D::Error::custom(format!(
"composition map key {index:?} is not a non-negative integer"
))
})
})
.collect::<std::result::Result<Vec<_>, D::Error>>()?;
indexed.sort_by_key(|(index, _)| *index);
Ok(Some(
indexed.into_iter().map(|(_, schema)| schema).collect(),
))
}
}
}
fn de_oauth_scopes<'de, D>(
deserializer: D,
) -> std::result::Result<IndexMap<String, String>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<IndexMap<String, String>>::deserialize(deserializer)?.unwrap_or_default())
}
fn de_sdk_method_name<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct MethodName;
impl<'de> serde::de::Visitor<'de> for MethodName {
type Value = Option<String>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a method name: a string, or a sequence of strings")
}
fn visit_str<E: serde::de::Error>(self, name: &str) -> std::result::Result<Self::Value, E> {
Ok(Some(name.to_string()))
}
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(None)
}
fn visit_none<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(None)
}
fn visit_some<S: serde::Deserializer<'de>>(
self,
deserializer: S,
) -> std::result::Result<Self::Value, S::Error> {
deserializer.deserialize_any(self)
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut sequence: A,
) -> std::result::Result<Self::Value, A::Error> {
let mut names = Vec::new();
while let Some(name) = sequence.next_element::<String>()? {
names.push(name);
}
if names.is_empty() {
return Err(serde::de::Error::invalid_length(0, &self));
}
Ok(Some(names.join(",")))
}
}
deserializer.deserialize_any(MethodName)
}
fn de_deprecated<'de, D>(deserializer: D) -> std::result::Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(document_boolean(&serde_json::Value::deserialize(deserializer)?) == Some(true))
}
fn de_servers<'de, D>(deserializer: D) -> std::result::Result<Vec<Server>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<Vec<Server>>::deserialize(deserializer)?.unwrap_or_default())
}
fn de_properties<'de, D>(deserializer: D) -> std::result::Result<SchemaProperties, D::Error>
where
D: serde::Deserializer<'de>,
{
let Some(raw) = Option::<IndexMap<String, MaybeSchema>>::deserialize(deserializer)? else {
return Ok(SchemaProperties::default());
};
Ok(SchemaProperties {
values: raw.into_iter().map(|(k, v)| (k, v.0)).collect(),
declared: true,
})
}
fn de_defs<'de, D>(deserializer: D) -> std::result::Result<IndexMap<String, Schema>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Defs {
Map(IndexMap<String, MaybeSchema>),
Other(serde::de::IgnoredAny),
}
Ok(match Defs::deserialize(deserializer)? {
Defs::Map(defs) => defs.into_iter().map(|(k, v)| (k, v.0)).collect(),
Defs::Other(_) => IndexMap::new(),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequiredNames {
Listed(Vec<String>),
Unlisted,
}
impl Default for RequiredNames {
fn default() -> Self {
Self::Listed(Vec::new())
}
}
impl RequiredNames {
#[must_use]
pub fn is_unlisted(&self) -> bool {
matches!(self, Self::Unlisted)
}
pub fn push(&mut self, name: String) {
match self {
Self::Listed(names) => names.push(name),
Self::Unlisted => *self = Self::Listed(vec![name]),
}
}
}
impl std::ops::Deref for RequiredNames {
type Target = [String];
fn deref(&self) -> &[String] {
match self {
Self::Listed(names) => names,
Self::Unlisted => &[],
}
}
}
impl<'a> IntoIterator for &'a RequiredNames {
type Item = &'a String;
type IntoIter = std::slice::Iter<'a, String>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl From<Vec<String>> for RequiredNames {
fn from(names: Vec<String>) -> Self {
Self::Listed(names)
}
}
fn de_required<'de, D>(deserializer: D) -> std::result::Result<RequiredNames, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(match serde_json::Value::deserialize(deserializer)? {
serde_json::Value::Array(values) => values
.into_iter()
.filter_map(|value| match value {
serde_json::Value::String(name) => Some(name),
_ => None,
})
.collect::<Vec<_>>()
.into(),
serde_json::Value::Bool(_)
| serde_json::Value::Number(_)
| serde_json::Value::Object(_) => RequiredNames::Unlisted,
_ => RequiredNames::default(),
})
}
#[must_use]
pub(crate) fn document_boolean(value: &serde_json::Value) -> Option<bool> {
match value {
serde_json::Value::Bool(flag) => Some(*flag),
serde_json::Value::Number(number) => match number.as_u64() {
Some(1) => Some(true),
Some(0) => Some(false),
_ => None,
},
_ => None,
}
}
fn de_boolean<'de, D>(deserializer: D) -> std::result::Result<Option<bool>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<DocumentBoolean>::deserialize(deserializer)?.map(|flag| flag.0))
}
fn de_present_boolean<'de, D>(deserializer: D) -> std::result::Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(DocumentBoolean::deserialize(deserializer)?.0)
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct DocumentBoolean(bool);
impl<'de> Deserialize<'de> for DocumentBoolean {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(DocumentBooleanVisitor)
}
}
struct DocumentBooleanVisitor;
impl DocumentBooleanVisitor {
fn yaml<E>() -> bool {
std::any::type_name::<E>() == std::any::type_name::<serde_saphyr::Error>()
}
fn refuse<E: serde::de::Error>(&self, unexpected: serde::de::Unexpected<'_>) -> E {
if Self::yaml::<E>() {
E::custom("invalid boolean (strict mode expects true/false)")
} else {
E::invalid_type(unexpected, self)
}
}
fn refuse_collection<E: serde::de::Error>(&self, unexpected: serde::de::Unexpected<'_>) -> E {
if Self::yaml::<E>() {
E::custom("expected string scalar")
} else {
E::invalid_type(unexpected, self)
}
}
fn integer<E: serde::de::Error>(
&self,
value: impl Into<serde_json::Value>,
unexpected: serde::de::Unexpected<'_>,
) -> std::result::Result<DocumentBoolean, E> {
document_boolean(&value.into())
.map(DocumentBoolean)
.ok_or_else(|| self.refuse(unexpected))
}
}
impl<'de> Visitor<'de> for DocumentBooleanVisitor {
type Value = DocumentBoolean;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a boolean")
}
fn visit_bool<E: serde::de::Error>(self, value: bool) -> std::result::Result<Self::Value, E> {
Ok(DocumentBoolean(value))
}
fn visit_u64<E: serde::de::Error>(self, value: u64) -> std::result::Result<Self::Value, E> {
self.integer(value, serde::de::Unexpected::Unsigned(value))
}
fn visit_i64<E: serde::de::Error>(self, value: i64) -> std::result::Result<Self::Value, E> {
self.integer(value, serde::de::Unexpected::Signed(value))
}
fn visit_f64<E: serde::de::Error>(self, value: f64) -> std::result::Result<Self::Value, E> {
Err(self.refuse(serde::de::Unexpected::Float(value)))
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<Self::Value, E> {
let text = value.trim();
if Self::yaml::<E>() && text.eq_ignore_ascii_case("true") {
Ok(DocumentBoolean(true))
} else if Self::yaml::<E>() && text.eq_ignore_ascii_case("false") {
Ok(DocumentBoolean(false))
} else {
Err(self.refuse(serde::de::Unexpected::Str(value)))
}
}
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Err(self.refuse(serde::de::Unexpected::Unit))
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
_: A,
) -> std::result::Result<Self::Value, A::Error> {
Err(self.refuse_collection(serde::de::Unexpected::Seq))
}
fn visit_map<A: MapAccess<'de>>(self, _: A) -> std::result::Result<Self::Value, A::Error> {
Err(self.refuse_collection(serde::de::Unexpected::Map))
}
}
fn de_schema_examples<'de, D>(
deserializer: D,
) -> std::result::Result<Vec<serde_json::Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
match serde_json::Value::deserialize(deserializer)? {
serde_json::Value::Array(values) => Ok(values),
serde_json::Value::Object(named) => Ok(named
.into_iter()
.map(|(_, example)| match example {
serde_json::Value::Object(mut fields) => {
fields.remove("value").unwrap_or(serde_json::Value::Null)
}
other => other,
})
.collect()),
serde_json::Value::Null => Ok(Vec::new()),
other => Ok(vec![other]),
}
}
fn de_items<'de, D>(deserializer: D) -> std::result::Result<Option<Box<Schema>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<MaybeSchema>::deserialize(deserializer)
.map(|value| value.map(|schema| Box::new(schema.0)))
}
fn de_optional_schema<'de, D>(deserializer: D) -> std::result::Result<Option<Schema>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<MaybeSchema>::deserialize(deserializer).map(|value| value.map(|schema| schema.0))
}
struct MaybeSchema(Schema);
impl<'de> Deserialize<'de> for MaybeSchema {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(MaybeSchemaVisitor)
}
}
struct MaybeSchemaVisitor;
impl<'de> serde::de::Visitor<'de> for MaybeSchemaVisitor {
type Value = MaybeSchema;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a schema object or a non-object value to degrade")
}
fn visit_map<A>(self, map: A) -> std::result::Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
Schema::deserialize(serde::de::value::MapAccessDeserializer::new(map)).map(MaybeSchema)
}
fn visit_seq<A>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(MaybeSchema(malformed_schema()))
}
fn visit_str<E>(self, v: &str) -> std::result::Result<Self::Value, E> {
Ok(MaybeSchema(Schema {
ty: Some(TypeField::Single(v.to_string())),
..Schema::default()
}))
}
fn visit_bool<E>(self, _v: bool) -> std::result::Result<Self::Value, E> {
Ok(MaybeSchema(Schema {
boolean_stand_in: true,
..malformed_schema()
}))
}
fn visit_i64<E>(self, _v: i64) -> std::result::Result<Self::Value, E> {
Ok(MaybeSchema(malformed_schema()))
}
fn visit_u64<E>(self, _v: u64) -> std::result::Result<Self::Value, E> {
Ok(MaybeSchema(malformed_schema()))
}
fn visit_f64<E>(self, _v: f64) -> std::result::Result<Self::Value, E> {
Ok(MaybeSchema(malformed_schema()))
}
fn visit_none<E>(self) -> std::result::Result<Self::Value, E> {
Ok(MaybeSchema(malformed_schema()))
}
fn visit_unit<E>(self) -> std::result::Result<Self::Value, E> {
Ok(MaybeSchema(malformed_schema()))
}
}
fn malformed_schema() -> Schema {
Schema {
malformed: true,
..Schema::default()
}
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize)]
pub struct Discriminator {
#[serde(rename = "propertyName", default)]
pub property_name: String,
#[serde(default)]
pub mapping: IndexMap<String, String>,
#[serde(rename = "x-crozier-property-name", default)]
pub(crate) property_name_crozier: Option<String>,
#[serde(rename = "x-fern-property-name", default)]
pub(crate) property_name_fern: Option<String>,
}
impl Discriminator {
#[must_use]
pub fn declared_property_name(&self) -> Option<&str> {
self.property_name_crozier
.as_deref()
.or(self.property_name_fern.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(untagged)]
pub enum TypeField {
Single(String),
Multiple(Vec<String>),
}
impl TypeField {
#[must_use]
pub fn primary(&self) -> Option<&str> {
match self {
TypeField::Single(s) => Some(s.as_str()),
TypeField::Multiple(v) => v.iter().find(|s| *s != "null").map(String::as_str),
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(untagged)]
pub enum AdditionalProperties {
Bool(#[serde(deserialize_with = "de_present_boolean")] bool),
Schema(Box<Schema>),
}
pub(crate) fn refusal_version_header(node: &serde_json::Value) -> Option<&str> {
node.get("x-crozier-version")
.or_else(|| node.get("x-fern-version"))?
.get("header")
.and_then(serde_json::Value::as_str)
}
pub(crate) fn refusal_parameter_name(node: &serde_json::Value) -> Option<&str> {
node.get("x-crozier-parameter-name")
.and_then(serde_json::Value::as_str)
.or_else(|| {
node.get("x-fern-parameter-name")
.and_then(serde_json::Value::as_str)
})
}
pub(crate) fn refusal_property_name(node: &serde_json::Value) -> Option<&str> {
node.get("x-crozier-property-name")
.and_then(serde_json::Value::as_str)
.or_else(|| {
node.get("x-fern-property-name")
.and_then(serde_json::Value::as_str)
})
.map(str::trim)
.filter(|name| !name.is_empty())
}
pub(crate) fn refusal_type_name(node: &serde_json::Value) -> Option<&str> {
["x-crozier-type-name", "x-fern-type-name"]
.into_iter()
.filter_map(|key| node.get(key).and_then(serde_json::Value::as_str))
.find(|name| !name.trim().is_empty())
}
pub(crate) fn refusal_node_ignored(node: &serde_json::Value) -> bool {
let mut metadata = serde_json::Map::new();
for (key, value) in node.as_object().into_iter().flatten() {
if key.starts_with("x-") {
metadata.insert(key.clone(), value.clone());
}
}
serde_json::from_value::<Schema>(serde_json::Value::Object(metadata))
.is_ok_and(|schema| schema.ignored())
}
pub fn load(path: &Path) -> Result<OpenApi> {
let text = std::fs::read_to_string(path).map_err(|source| Error::ReadSpec {
path: path.to_path_buf(),
source,
})?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let is_yaml = matches!(ext.as_deref(), Some("yml" | "yaml"));
let mut doc: OpenApi = match ext.as_deref() {
Some("yml" | "yaml") => crate::yaml::from_str(&text).map_err(|e| Error::ParseSpec {
path: path.to_path_buf(),
message: e.to_string(),
})?,
Some("json") => serde_json::from_str(&text)
.or_else(|error| {
if error.to_string().starts_with("number out of range") {
crate::yaml::from_str(&text).map_err(|_| error)
} else {
Err(error)
}
})
.map_err(|e| Error::ParseSpec {
path: path.to_path_buf(),
message: e.to_string(),
})?,
_ => {
return Err(Error::UnknownSpecFormat {
path: path.to_path_buf(),
})
}
};
doc.yaml_unquoted_timestamps = is_yaml.then(|| unquoted_yaml_timestamps(&text));
if doc.openapi.is_empty() {
return Err(Error::InvalidSpec {
path: path.to_path_buf(),
message: "missing `openapi` version field; is this an OpenAPI document?".to_string(),
});
}
if !doc.openapi.starts_with('3') {
return Err(Error::InvalidSpec {
path: path.to_path_buf(),
message: format!(
"unsupported OpenAPI version `{}`; crozier supports 3.x",
doc.openapi
),
});
}
normalize_security_scheme_refs(&mut doc);
normalize_event_stream_item_schemas(&mut doc);
for (scheme_name, scheme) in &doc.components.security_schemes {
if scheme.ty == SecuritySchemeType::ApiKey
&& scheme.name.as_deref().unwrap_or("").trim().is_empty()
{
return Err(Error::InvalidSpec {
path: path.to_path_buf(),
message: format!(
"apiKey security scheme `{scheme_name}` is missing its required `name` (the parameter carrying the key)"
),
});
}
}
let remote_origin = crate::refs::resolve(&mut doc, &crate::refs::CurlFetcher, path)?;
normalize_float_type(&mut doc);
normalize_parameter_schema_refs(&mut doc);
normalize_webhook_operations(&mut doc);
normalize_comment_close_descriptions(&mut doc);
normalize_root_pagination(&mut doc);
normalize_declared_type_names(&mut doc);
normalize_component_response_schema_refs(&mut doc);
normalize_inline_declared_type_names(&mut doc);
normalize_schema_pointer_refs(&mut doc);
normalize_empty_compositions(&mut doc);
normalize_sibling_one_of_constraints(&mut doc);
normalize_boolean_property_objects(&mut doc);
normalize_unresolvable_schema_refs(&mut doc);
normalize_unresolved_schema_pointers(&mut doc);
normalize_error_class_schema_names(&mut doc);
normalize_same_primitive_unions(&mut doc);
normalize_multi_type_schemas(&mut doc);
normalize_undiscriminated_unions(&mut doc);
normalize_unlisted_required(&mut doc);
normalize_nullable_schema_refs(&mut doc);
normalize_parameters(&mut doc);
reject_unemittable_parameters(&doc, path)?;
reject_unemittable_operation_ids(&doc, path)?;
normalize_responses(&mut doc);
normalize_fetched_response_alias_refs(&mut doc, &remote_origin);
normalize_response_schema_refs(&mut doc);
normalize_request_bodies(&mut doc);
Ok(doc)
}
fn reject_unemittable_parameters(doc: &OpenApi, path: &Path) -> Result<()> {
for (route, item) in &doc.paths {
for (method, operation) in item.operations() {
for parameter in item.parameters.iter().chain(&operation.parameters) {
if parameter.location != Some(ParameterLocation::Header) {
continue;
}
let kind = parameter
.schema
.as_ref()
.and_then(|schema| schema.ty.as_ref())
.and_then(TypeField::primary);
let refused = match kind {
Some("object") => parameter
.schema
.as_ref()
.is_some_and(|schema| schema.properties.declared()),
_ => false,
};
if refused {
let kind = kind.expect("refused header has a schema kind");
return Err(Error::InvalidSpec {
path: path.to_path_buf(),
message: format!(
"{method} {route}: header parameter `{}` has an unsupported {kind} schema in Fern's Python generator",
parameter.name
),
});
}
}
}
}
Ok(())
}
fn reject_unemittable_operation_ids(doc: &OpenApi, path: &Path) -> Result<()> {
for (route, item) in &doc.paths {
for (method, operation) in item.operations() {
if let Some(id) = operation.operation_id.as_deref() {
if !id.is_ascii()
&& !id
.chars()
.any(|character| character.is_ascii_alphanumeric())
{
return Err(Error::InvalidSpec {
path: path.to_path_buf(),
message: format!(
"{method} {route}: operationId `{id}` has no ASCII identifier characters; Fern's Python generator emits invalid source"
),
});
}
}
}
}
Ok(())
}
fn normalize_security_scheme_refs(doc: &mut OpenApi) {
const PREFIX: &str = "#/components/securitySchemes/";
let resolved: Vec<(String, SecurityScheme)> = doc
.components
.security_schemes
.iter()
.filter_map(|(name, scheme)| {
let target = scheme.reference.as_deref()?.strip_prefix(PREFIX)?;
let target = doc.components.security_schemes.get(target)?;
target
.reference
.is_none()
.then(|| (name.clone(), target.clone()))
})
.collect();
for (name, scheme) in resolved {
doc.components.security_schemes[&name] = scheme;
}
}
pub(crate) fn for_each_root_schema<F: FnMut(&mut Schema)>(doc: &mut OpenApi, visit: &mut F) {
for schema in doc.components.schemas.values_mut() {
visit(schema);
}
for parameter in doc.components.parameters.values_mut() {
parameter_schemas(parameter, visit);
}
for response in doc.components.responses.values_mut() {
media_schemas(&mut response.content, visit);
}
for body in doc.components.request_bodies.values_mut() {
media_schemas(&mut body.content, visit);
}
for item in doc.paths.values_mut() {
for_each_path_item_schema(item, visit);
}
}
pub(crate) fn for_each_path_item_schema<F: FnMut(&mut Schema)>(item: &mut PathItem, visit: &mut F) {
for parameter in &mut item.parameters {
parameter_schemas(parameter, visit);
}
for slot in item.operation_slots() {
if let Some(operation) = slot.as_mut() {
operation_schemas(operation, visit);
}
}
}
fn operation_schemas<F: FnMut(&mut Schema)>(operation: &mut Operation, visit: &mut F) {
for parameter in &mut operation.parameters {
parameter_schemas(parameter, visit);
}
if let Some(body) = &mut operation.request_body {
request_body_schemas(body, visit);
}
for response in operation.responses.values_mut() {
response_schemas(response, visit);
}
}
fn parameter_schemas<F: FnMut(&mut Schema)>(parameter: &mut Parameter, visit: &mut F) {
if let Some(schema) = &mut parameter.schema {
visit(schema);
}
media_schemas(&mut parameter.content, visit);
}
fn request_body_schemas<F: FnMut(&mut Schema)>(body: &mut RequestBody, visit: &mut F) {
media_schemas(&mut body.content, visit);
}
fn response_schemas<F: FnMut(&mut Schema)>(response: &mut Response, visit: &mut F) {
media_schemas(&mut response.content, visit);
}
fn media_schemas<F: FnMut(&mut Schema)>(content: &mut IndexMap<String, MediaType>, visit: &mut F) {
for media in content.values_mut() {
if let Some(schema) = &mut media.schema {
visit(schema);
}
}
}
fn normalize_comment_close_descriptions(doc: &mut OpenApi) {
fn split(description: &mut Option<String>) {
if let Some(text) = description.as_mut().filter(|text| text.contains("*/")) {
*text = text.replace("*/", "* /");
}
}
fn split_parameter(parameter: &mut Parameter) {
split(&mut parameter.description);
}
fn split_operation(operation: &mut Operation) {
split(&mut operation.description);
operation.parameters.iter_mut().for_each(split_parameter);
if let Some(body) = &mut operation.request_body {
split(&mut body.description);
}
for response in operation.responses.values_mut() {
split(&mut response.description);
}
}
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| split(&mut node.description));
});
doc.components
.parameters
.values_mut()
.for_each(split_parameter);
for response in doc.components.responses.values_mut() {
split(&mut response.description);
}
for body in doc.components.request_bodies.values_mut() {
split(&mut body.description);
}
for item in doc.paths.values_mut() {
item.parameters.iter_mut().for_each(split_parameter);
for operation in item.operation_slots().into_iter().flatten() {
split_operation(operation);
}
}
}
pub(crate) fn for_each_schema_in<F: FnMut(&mut Schema)>(schema: &mut Schema, visit: &mut F) {
visit(schema);
for property in schema.properties.values_mut() {
for_each_schema_in(property, visit);
}
if let Some(items) = &mut schema.items {
for_each_schema_in(items, visit);
}
if let Some(AdditionalProperties::Schema(value)) = &mut schema.additional_properties {
for_each_schema_in(value, visit);
}
for members in [&mut schema.one_of, &mut schema.any_of, &mut schema.all_of] {
for member in members.iter_mut().flatten() {
for_each_schema_in(member, visit);
}
}
}
pub(crate) fn referenced_component_schema(reference: &str) -> Option<&str> {
reference
.strip_prefix("#/components/schemas/")
.map(|pointer| pointer.split('/').next().unwrap_or(pointer))
}
fn normalize_empty_compositions(doc: &mut OpenApi) {
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
for members in [&mut node.one_of, &mut node.any_of, &mut node.all_of] {
if members.as_ref().is_some_and(Vec::is_empty) {
*members = None;
}
}
if matches!(
node.ty.as_ref().and_then(TypeField::primary),
Some("string" | "integer" | "number" | "boolean")
) {
node.discarded_composition |= [&node.one_of, &node.any_of, &node.all_of]
.into_iter()
.any(Option::is_some);
node.one_of = None;
node.any_of = None;
node.all_of = None;
}
});
});
}
fn normalize_sibling_one_of_constraints(doc: &mut OpenApi) {
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
let Some(branches) = node.one_of.as_deref() else {
return;
};
if node.any_of.is_some()
|| node.all_of.is_some()
|| node.properties.is_empty()
|| node.required.is_unlisted()
|| node.discriminated() == Some(true)
|| !node.ty.as_ref().is_none_or(is_object_type_field)
|| !branches
.iter()
.all(|branch| constrains_siblings(branch, &node.properties))
{
return;
}
let lists: Vec<Vec<String>> = branches
.iter()
.map(|branch| match &branch.required {
RequiredNames::Listed(names) => names.clone(),
RequiredNames::Unlisted => Vec::new(),
})
.collect();
let (first, rest) = lists.split_first().expect("a non-empty oneOf");
for name in first {
let shared = rest.iter().all(|names| names.contains(name));
let listed =
matches!(&node.required, RequiredNames::Listed(names) if names.contains(name));
if shared && !listed {
node.required.push(name.clone());
}
}
node.one_of = None;
});
});
}
fn normalize_boolean_property_objects(doc: &mut OpenApi) {
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
if node
.properties
.values()
.any(|property| property.boolean_stand_in)
{
*node = Schema {
description: node.description.take(),
..Schema::default()
};
}
});
});
}
fn is_object_type_field(ty: &TypeField) -> bool {
match ty {
TypeField::Single(name) => name == "object",
TypeField::Multiple(names) => {
let mut named = names.iter().filter(|name| *name != "null");
named.next().is_some_and(|name| name == "object") && named.next().is_none()
}
}
}
fn constrains_siblings(branch: &Schema, siblings: &SchemaProperties) -> bool {
let mut rest = annotations_cleared(branch);
let ty = rest.ty.take();
let properties = std::mem::take(&mut rest.properties);
let required = std::mem::take(&mut rest.required);
let RequiredNames::Listed(required) = required else {
return false;
};
if rest != Schema::default() || !ty.as_ref().is_none_or(is_object_type_field) {
return false;
}
if properties.is_empty() && required.is_empty() {
return false;
}
required.iter().all(|name| siblings.contains_key(name))
&& properties.iter().all(|(name, restated)| {
siblings
.get(name)
.is_some_and(|sibling| restates_sibling(restated, sibling))
})
}
fn restates_sibling(restated: &Schema, sibling: &Schema) -> bool {
let mut rest = annotations_cleared(restated);
let sibling = annotations_cleared(sibling);
macro_rules! same {
($($field:ident),*) => {
$(
if rest.$field.is_some() {
if rest.$field != sibling.$field {
return false;
}
rest.$field = None;
}
)*
};
}
same!(
reference,
ty,
format,
enum_values,
const_value,
pattern,
min_length,
max_length,
minimum,
items,
nullable
);
rest == Schema::default()
}
fn annotations_cleared(schema: &Schema) -> Schema {
Schema {
title: None,
description: None,
default: None,
deprecated: false,
example: None,
examples: Vec::new(),
read_only: None,
..schema.clone()
}
}
fn normalize_component_response_schema_refs(doc: &mut OpenApi) {
let mut references = std::collections::BTreeSet::new();
for_each_root_schema(doc, &mut |root| {
for_each_schema_in(root, &mut |schema| {
if let Some(reference) = &schema.reference {
if reference.starts_with("#/components/responses/") {
references.insert(reference.clone());
}
}
});
});
let mut replacements = IndexMap::new();
for reference in references {
let segments: Vec<_> = reference.split('/').collect();
if segments.len() != 7 || segments[4] != "content" || segments[6] != "schema" {
continue;
}
let decode = |value: &str| value.replace("~1", "/").replace("~0", "~");
let name = decode(segments[3]);
let Some(schema) = doc
.components
.responses
.get(&name)
.and_then(|response| response.content.get(&decode(segments[5])))
.and_then(|media| media.schema.as_ref())
.filter(|schema| {
schema.reference.is_none()
&& (schema.ty.as_ref().and_then(TypeField::primary) == Some("object")
|| !schema.properties.is_empty())
})
.cloned()
else {
continue;
};
if !doc.components.schemas.contains_key(&name) {
doc.components.schemas.insert(name.clone(), schema);
}
replacements.insert(
reference,
format!("#/components/schemas/{}", json_pointer_segment(&name)),
);
}
for_each_root_schema(doc, &mut |root| {
for_each_schema_in(root, &mut |schema| {
if let Some(replacement) = schema
.reference
.as_ref()
.and_then(|reference| replacements.get(reference))
{
schema.reference = Some(replacement.clone());
}
});
});
}
fn normalize_schema_pointer_refs(doc: &mut OpenApi) {
let components = doc.components.schemas.clone();
let mut expanding = Vec::new();
for_each_root_schema(doc, &mut |schema| {
inline_schema_pointers(schema, &components, &mut expanding);
});
}
fn inline_schema_pointers(
schema: &mut Schema,
components: &IndexMap<String, Schema>,
expanding: &mut Vec<String>,
) {
if schema.reference.is_none()
&& schema.ty.is_none()
&& schema.properties.is_empty()
&& schema.one_of.is_none()
&& schema.any_of.is_none()
{
if let Some([member]) = schema.all_of.as_deref() {
if let Some(reference) = member.reference.clone() {
if !expanding.contains(&reference) {
if let Some(target) = schema_pointer_target(components, &reference) {
let description = schema.description.take();
let nullable = schema.nullable;
*schema = target.clone();
schema.description = description.or(schema.description.take());
schema.nullable = nullable.or(schema.nullable);
expanding.push(reference);
inline_schema_pointers(schema, components, expanding);
expanding.pop();
return;
}
}
}
}
}
if let Some(reference) = schema.reference.clone() {
if expanding.contains(&reference) && schema_pointer_target(components, &reference).is_some()
{
*schema = Schema {
description: schema.description.take(),
..Schema::default()
};
return;
}
if !expanding.contains(&reference) {
if let Some(target) = schema_pointer_target(components, &reference) {
*schema = target.clone();
expanding.push(reference);
inline_schema_pointers(schema, components, expanding);
expanding.pop();
return;
}
if reference.contains("properties") {
if let Some(target) = properties_reference_target(components, &reference) {
*schema = target.clone();
schema.ref_origin = Some(reference.clone());
expanding.push(reference);
inline_schema_pointers(schema, components, expanding);
expanding.pop();
return;
}
}
}
}
for property in schema.properties.values_mut() {
inline_schema_pointers(property, components, expanding);
}
if let Some(items) = &mut schema.items {
inline_schema_pointers(items, components, expanding);
}
if let Some(AdditionalProperties::Schema(value)) = &mut schema.additional_properties {
inline_schema_pointers(value, components, expanding);
}
for members in [&mut schema.one_of, &mut schema.any_of, &mut schema.all_of] {
for member in members.iter_mut().flatten() {
inline_schema_pointers(member, components, expanding);
}
}
}
fn schema_pointer_target<'a>(
components: &'a IndexMap<String, Schema>,
reference: &str,
) -> Option<&'a Schema> {
let pointer = reference.strip_prefix("#/components/schemas/")?;
let segments: Vec<String> = pointer
.split('/')
.map(|segment| segment.replace("~1", "/").replace("~0", "~"))
.collect();
let (name, path) = segments.split_first()?;
if path.is_empty() {
return None;
}
let mut schema = components.get(name)?;
let mut index = 0;
let mut ends_on_member = false;
while index < path.len() {
ends_on_member = false;
schema = match path[index].as_str() {
"properties" => {
index += 1;
schema.properties.get(path.get(index)?)?
}
"items" => schema.items.as_deref()?,
"additionalProperties" => match &schema.additional_properties {
Some(AdditionalProperties::Schema(value)) => value,
_ => return None,
},
composition @ ("allOf" | "oneOf" | "anyOf") => {
index += 1;
let members = match composition {
"allOf" => &schema.all_of,
"oneOf" => &schema.one_of,
_ => &schema.any_of,
};
ends_on_member = true;
members
.as_ref()?
.get(path.get(index)?.parse::<usize>().ok()?)?
}
_ => return None,
};
index += 1;
}
(!ends_on_member).then_some(schema)
}
fn properties_reference_target<'a>(
components: &'a IndexMap<String, Schema>,
reference: &str,
) -> Option<&'a Schema> {
let pointer = reference.strip_prefix("#/components/schemas/")?;
let segments: Vec<String> = pointer
.split('/')
.map(|segment| segment.replace("~1", "/").replace("~0", "~"))
.collect();
let (name, path) = segments.split_first()?;
let mut schema = components.get(name)?;
let mut index = 0;
while index < path.len() {
schema = match path[index].as_str() {
"properties" => {
index += 1;
schema.properties.get(path.get(index)?)?
}
"items" => schema.items.as_deref()?,
"additionalProperties" => match &schema.additional_properties {
Some(AdditionalProperties::Schema(value)) => value,
_ => return None,
},
"$defs" => {
index += 1;
schema.defs.get(path.get(index)?)?
}
composition @ ("allOf" | "oneOf" | "anyOf") => {
index += 1;
let members = match composition {
"allOf" => &schema.all_of,
"oneOf" => &schema.one_of,
_ => &schema.any_of,
};
members
.as_ref()?
.get(path.get(index)?.parse::<usize>().ok()?)?
}
_ => return None,
};
index += 1;
}
Some(schema)
}
fn normalize_unlisted_required(doc: &mut OpenApi) {
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
if node.required.is_unlisted()
&& node.reference.is_none()
&& (node.ty.as_ref().and_then(TypeField::primary) == Some("object")
|| !node.properties.is_empty())
{
*node = Schema {
description: node.description.take(),
nullable: node.nullable,
required: RequiredNames::Unlisted,
..Schema::default()
};
} else if node.required.is_unlisted() {
node.required = RequiredNames::default();
}
});
});
}
fn normalize_float_type(doc: &mut OpenApi) {
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
let renamed = match node.ty.as_mut() {
Some(TypeField::Single(ty)) if ty == "float" => {
*ty = "number".to_string();
true
}
Some(TypeField::Multiple(types)) if types.iter().any(|ty| ty == "float") => {
for ty in types.iter_mut().filter(|ty| *ty == "float") {
*ty = "number".to_string();
}
true
}
_ => false,
};
if renamed {
node.format = None;
}
});
});
}
fn normalize_multi_type_schemas(doc: &mut OpenApi) {
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
let Some(TypeField::Multiple(types)) = node.ty.as_ref() else {
return;
};
if node.one_of.is_some() || node.any_of.is_some() || node.all_of.is_some() {
return;
}
let nullable = types.iter().any(|ty| ty == "null");
let members: Vec<Schema> = types
.iter()
.filter(|ty| *ty != "null")
.map(|ty| Schema {
ty: Some(TypeField::Single(ty.clone())),
..Schema::default()
})
.collect();
if members.len() < 2 {
return;
}
node.ty = None;
node.any_of = Some(members);
node.multi_type_union = true;
if nullable {
node.nullable = Some(true);
}
});
});
}
pub(crate) fn normalize_undiscriminated_unions(doc: &mut OpenApi) {
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
if node.discriminated() == Some(false) {
node.discriminator = None;
}
});
});
}
const FERN_ERROR_NAMES: &[&str] = &[
"BadRequestError",
"UnauthorizedError",
"PaymentRequiredError",
"ForbiddenError",
"NotFoundError",
"MethodNotAllowedError",
"NotAcceptableError",
"ProxyAuthenticationRequiredError",
"RequestTimeoutError",
"ConflictError",
"GoneError",
"LengthRequiredError",
"PreconditionFailedError",
"ContentTooLargeError",
"URITooLongError",
"UnsupportedMediaTypeError",
"RangeNotSatisfiableError",
"ExpectationFailedError",
"ImATeapotError",
"AuthenticationTimeoutError",
"MethodFailureError",
"MisdirectedRequestError",
"UnprocessableEntityError",
"LockedError",
"FailedDependencyError",
"TooEarlyError",
"UpgradeRequiredError",
"PreconditionError",
"TooManyRequestsError",
"RequestHeaderFieldsTooLargeError",
"NoResponseError",
"RetryWithError",
"BlockedByWindowsParentalControlsError",
"UnavailableForLegalReasonsError",
"InvalidTokenError",
"ClientClosedRequestError",
"InternalServerError",
"NotImplementedError",
"BadGatewayError",
"ServiceUnavailableError",
"GatewayTimeoutError",
"HTTPVersionNotSupportedError",
"VariantAlsoNegotiatesError",
"InsufficientStorageError",
"LoopDetectedError",
"BandwidthLimitExceededError",
"NotExtendedError",
"NetworkAuthenticationRequiredError",
"ClientRequestError",
"ServerError",
];
fn normalize_error_class_schema_names(doc: &mut OpenApi) {
let renames: IndexMap<String, String> = doc
.components
.schemas
.iter()
.filter(|(name, schema)| {
schema.reference.is_none() && FERN_ERROR_NAMES.contains(&name.as_str())
})
.map(|(name, _)| (name.clone(), format!("{name}Body")))
.filter(|(_, renamed)| !doc.components.schemas.contains_key(renamed))
.collect();
rename_component_schemas(doc, &renames);
}
fn normalize_same_primitive_unions(doc: &mut OpenApi) {
let mut renames = IndexMap::new();
for (name, schema) in &doc.components.schemas {
let Some(last) = crate::ir::same_primitive_union_last(schema) else {
continue;
};
let renamed = format!("{name} {}", crate::ir::ordinal_word(last));
if !doc.components.schemas.contains_key(&renamed) {
renames.insert(name.clone(), renamed);
}
}
for name in renames.keys() {
if let Some(schema) = doc.components.schemas.get_mut(name) {
let alternatives = schema.one_of.take().or_else(|| schema.any_of.take());
if let Some(last) = alternatives.and_then(|mut alternatives| alternatives.pop()) {
*schema = last;
}
}
}
rename_component_schemas(doc, &renames);
}
fn normalize_declared_type_names(doc: &mut OpenApi) {
let renames: IndexMap<String, String> = doc
.components
.schemas
.iter()
.filter_map(|(name, schema)| {
let declared = declared_type_key(schema.declared_type_name()?);
(declared != *name).then(|| (name.clone(), declared))
})
.collect();
rename_component_schemas(doc, &renames);
}
fn normalize_root_pagination(doc: &mut OpenApi) {
let root = doc
.pagination_crozier
.clone()
.or_else(|| doc.pagination_fern.clone());
for item in doc.paths.values_mut().chain(doc.webhooks.values_mut()) {
for op in item.operation_slots().into_iter().flatten() {
if op.pagination_crozier.is_some() {
op.pagination_fern = None;
}
for declared in [&mut op.pagination_crozier, &mut op.pagination_fern] {
if let Some(DeclaredPagination::Root(take)) = declared {
*declared = take
.then(|| root.clone())
.flatten()
.map(DeclaredPagination::Contract);
}
}
}
}
}
fn normalize_webhook_operations(doc: &mut OpenApi) {
for item in doc.paths.values_mut() {
for slot in item.operation_slots() {
if slot.as_ref().is_some_and(Operation::webhook_marked) {
*slot = None;
}
}
}
}
fn normalize_inline_declared_type_names(doc: &mut OpenApi) {
type Taken = IndexMap<String, (Schema, Option<Schema>)>;
fn without_type_name(schema: &Schema) -> Schema {
Schema {
type_name_crozier: None,
type_name_fern: None,
..schema.clone()
}
}
fn lift(schema: &mut Schema, taken: &mut Taken) {
let lift_child = |child: &mut Schema, taken: &mut Taken| {
if child.reference.is_none() {
if let Some(key) = child.declared_type_name().map(declared_type_key) {
let shape = without_type_name(child);
let merges = taken.get(&key).is_some_and(|(held, _)| *held == shape);
if merges || !taken.contains_key(&key) {
let reference = Schema {
reference: Some(format!("#/components/schemas/{key}")),
..Schema::default()
};
let mut lifted = std::mem::replace(child, reference);
if merges {
return;
}
taken.insert(key.clone(), (shape.clone(), None));
lift(&mut lifted, taken);
taken.insert(key, (shape, Some(lifted)));
return;
}
}
}
lift(child, taken);
};
for child in schema.properties.values_mut() {
lift_child(child, taken);
}
if let Some(items) = schema.items.as_deref_mut() {
lift_child(items, taken);
}
if let Some(AdditionalProperties::Schema(values)) = schema.additional_properties.as_mut() {
lift_child(values, taken);
}
for members in [&mut schema.one_of, &mut schema.any_of, &mut schema.all_of]
.into_iter()
.flatten()
{
for member in members {
lift_child(member, taken);
}
}
}
let mut taken: Taken = doc
.components
.schemas
.iter()
.map(|(key, schema)| (key.clone(), (without_type_name(schema), None)))
.collect();
for schema in doc.components.schemas.values_mut() {
lift(schema, &mut taken);
}
for (url, item) in &mut doc.paths {
for op in item.operation_slots().into_iter().flatten() {
if !crate::ir::endpoint_module(op, url).is_empty() {
continue;
}
let bodies = op
.request_body
.iter_mut()
.flat_map(|body| body.content.values_mut())
.chain(
op.responses
.values_mut()
.flat_map(|response| response.content.values_mut()),
);
for media in bodies {
if let Some(schema) = media.schema.as_mut() {
lift(schema, &mut taken);
}
}
}
}
for (key, (_, lifted)) in taken {
if let Some(schema) = lifted {
doc.components.schemas.insert(key, schema);
}
}
}
pub(crate) fn declared_type_key(declared: &str) -> String {
declared.replace(['/', '~'], " ")
}
fn rename_component_schemas(doc: &mut OpenApi, renames: &IndexMap<String, String>) {
if renames.is_empty() {
return;
}
let mut schemas = IndexMap::new();
for (name, schema) in std::mem::take(&mut doc.components.schemas) {
schemas
.entry(renames.get(&name).cloned().unwrap_or(name))
.or_insert(schema);
}
doc.components.schemas = schemas;
let rewrite = |reference: &mut String| {
let Some(name) = referenced_component_schema(reference) else {
return;
};
if let Some(renamed) = renames.get(name) {
let rest = &reference["#/components/schemas/".len() + name.len()..];
*reference = format!("#/components/schemas/{renamed}{rest}");
}
};
let mut visit = |schema: &mut Schema| {
for_each_schema_in(schema, &mut |node| {
if let Some(reference) = node.reference.as_mut() {
rewrite(reference);
}
if let Some(discriminator) = node.discriminator.as_mut() {
for target in discriminator.mapping.values_mut() {
rewrite(target);
}
}
});
};
for_each_root_schema(doc, &mut visit);
for item in doc.webhooks.values_mut() {
for_each_path_item_schema(item, &mut visit);
}
}
fn normalize_nullable_schema_refs(doc: &mut OpenApi) {
let nullable: std::collections::BTreeSet<String> = doc
.components
.schemas
.iter()
.filter(|(_, schema)| {
schema.nullable_at_references()
|| matches!(&schema.ty, Some(TypeField::Single(ty)) if ty == "null")
})
.map(|(name, _)| name.clone())
.collect();
if nullable.is_empty() {
return;
}
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
let names = node
.reference
.as_deref()
.and_then(referenced_component_schema)
.is_some_and(|name| nullable.contains(name));
if names {
node.nullable = Some(true);
node.reference_nullable = true;
}
});
});
}
fn normalize_fetched_response_alias_refs(
doc: &mut OpenApi,
remote_origin: &std::collections::BTreeSet<String>,
) {
if remote_origin.is_empty() {
return;
}
let mut targets: IndexMap<String, String> = IndexMap::new();
for name in doc.components.schemas.keys() {
let mut seen = std::collections::BTreeSet::new();
let mut current = name.as_str();
while let Some(next) = doc
.components
.schemas
.get(current)
.and_then(|schema| schema.reference.as_deref())
.and_then(referenced_component_schema)
{
if next == name || !seen.insert(next.to_string()) {
break;
}
current = next;
}
if current != name && remote_origin.contains(current) {
targets.insert(name.clone(), current.to_string());
}
}
if targets.is_empty() {
return;
}
for item in doc.paths.values_mut() {
for slot in item.operation_slots() {
let Some(operation) = slot.as_mut() else {
continue;
};
for response in operation.responses.values_mut() {
for media in response.content.values_mut() {
let Some(schema) = &mut media.schema else {
continue;
};
let Some(target) = schema
.reference
.as_deref()
.filter(|reference| reference.starts_with("#/components/schemas/"))
.and_then(referenced_component_schema)
.and_then(|name| targets.get(name))
else {
continue;
};
schema.reference = Some(format!("#/components/schemas/{target}"));
}
}
}
}
}
fn unquoted_yaml_timestamps(text: &str) -> std::collections::BTreeSet<String> {
let bytes = text.as_bytes();
let mut found = std::collections::BTreeSet::new();
let mut index = 0;
while index + 10 <= bytes.len() {
let date = bytes[index..index + 10]
.iter()
.enumerate()
.all(|(offset, byte)| {
if offset == 4 || offset == 7 {
*byte == b'-'
} else {
byte.is_ascii_digit()
}
});
let boundary = index == 0 || !bytes[index - 1].is_ascii_alphanumeric();
let quoted = index > 0 && matches!(bytes[index - 1], b'"' | b'\'');
if date && boundary && !quoted {
let end = text[index..]
.find([',', '}', ']', '\n', '\r', '#', '"', '\''])
.map_or(text.len(), |offset| index + offset);
found.insert(text[index..end].trim_end().to_string());
index = end;
} else {
index += 1;
}
}
found
}
fn normalize_unresolvable_schema_refs(doc: &mut OpenApi) {
let declared: std::collections::BTreeSet<String> =
doc.components.schemas.keys().cloned().collect();
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
let unresolvable = node
.reference
.as_deref()
.and_then(referenced_component_schema)
.is_some_and(|name| !declared.contains(name));
if unresolvable {
node.reference = None;
node.unresolved_reference = true;
}
});
});
}
fn normalize_unresolved_schema_pointers(doc: &mut OpenApi) {
let mut references = std::collections::BTreeSet::new();
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
references.extend(node.reference.clone());
});
});
let unresolved: std::collections::BTreeSet<String> = references
.into_iter()
.filter(|reference| declared_head_walk_misses(&doc.components.schemas, reference))
.collect();
if unresolved.is_empty() {
return;
}
for_each_root_schema(doc, &mut |schema| {
degrade_unresolved_pointers(schema, &unresolved, false);
});
}
fn degrade_unresolved_pointers(
schema: &mut Schema,
unresolved: &std::collections::BTreeSet<String>,
array_item: bool,
) {
let lone_member = match schema.all_of.as_deref() {
Some([member]) => member.reference.as_ref(),
_ => None,
};
if schema.reference.is_none()
&& schema.ty.is_none()
&& schema.properties.is_empty()
&& schema.one_of.is_none()
&& schema.any_of.is_none()
&& lone_member.is_some_and(|reference| unresolved.contains(reference))
{
schema.all_of = None;
schema.unresolved_reference = true;
}
if !array_item
&& schema
.reference
.as_ref()
.is_some_and(|reference| unresolved.contains(reference))
{
schema.reference = None;
schema.unresolved_reference = true;
}
for property in schema.properties.values_mut() {
degrade_unresolved_pointers(property, unresolved, false);
}
if let Some(items) = &mut schema.items {
degrade_unresolved_pointers(items, unresolved, true);
}
if let Some(AdditionalProperties::Schema(value)) = &mut schema.additional_properties {
degrade_unresolved_pointers(value, unresolved, false);
}
for members in [&mut schema.one_of, &mut schema.any_of, &mut schema.all_of] {
for member in members.iter_mut().flatten() {
degrade_unresolved_pointers(member, unresolved, false);
}
}
}
fn declared_head_walk_misses(components: &IndexMap<String, Schema>, reference: &str) -> bool {
let Some(pointer) = reference.strip_prefix("#/components/schemas/") else {
return false;
};
let segments: Vec<String> = pointer
.split('/')
.map(|segment| segment.replace("~1", "/").replace("~0", "~"))
.collect();
let Some((name, path)) = segments.split_first() else {
return false;
};
let Some(mut schema) = components.get(name) else {
return false;
};
let mut index = 0;
while index < path.len() {
match path[index].as_str() {
"properties" if index + 1 < path.len() => index += 2,
"items" => index += 1,
"allOf" | "oneOf" | "anyOf" if index + 2 < path.len() => index += 2,
_ => return false,
}
}
let mut index = 0;
while index < path.len() {
let next = match path[index].as_str() {
"properties" => {
index += 1;
schema.properties.get(&path[index])
}
"items" => schema.items.as_deref(),
composition => {
index += 1;
let members = match composition {
"allOf" => &schema.all_of,
"oneOf" => &schema.one_of,
_ => &schema.any_of,
};
path[index]
.parse::<usize>()
.ok()
.and_then(|member| members.as_ref()?.get(member))
}
};
let Some(next) = next else {
return true;
};
schema = next;
index += 1;
}
false
}
fn normalize_response_schema_refs(doc: &mut OpenApi) {
let mut schemas = IndexMap::new();
for (path, item) in &doc.paths {
for (method, operation) in item.operations() {
for (status, response) in &operation.responses {
for (media_type, media) in &response.content {
let Some(schema) = &media.schema else {
continue;
};
let pointer = format!(
"#/paths/{}/{}/responses/{}/content/{}/schema",
json_pointer_segment(path),
method.to_ascii_lowercase(),
json_pointer_segment(status),
json_pointer_segment(media_type),
);
index_schema_pointers(schema, &pointer, &mut schemas);
}
}
}
}
for item in doc.paths.values_mut() {
for slot in item.operation_slots() {
let Some(operation) = slot else { continue };
for response in operation.responses.values_mut() {
for media in response.content.values_mut() {
if let Some(schema) = &mut media.schema {
resolve_indexed_schema_refs(
schema,
"#/paths/",
&schemas,
&mut std::collections::BTreeSet::new(),
);
}
}
}
}
}
}
fn normalize_parameter_schema_refs(doc: &mut OpenApi) {
let mut schemas = IndexMap::new();
for (name, parameter) in &doc.components.parameters {
if let Some(schema) = ¶meter.schema {
let pointer = format!(
"#/components/parameters/{}/schema",
json_pointer_segment(name)
);
index_schema_pointers(schema, &pointer, &mut schemas);
}
}
if schemas.is_empty() {
return;
}
for_each_root_schema(doc, &mut |schema| {
resolve_indexed_schema_refs(
schema,
"#/components/parameters/",
&schemas,
&mut std::collections::BTreeSet::new(),
);
});
}
fn json_pointer_segment(segment: &str) -> String {
segment.replace('~', "~0").replace('/', "~1")
}
fn index_schema_pointers(schema: &Schema, pointer: &str, schemas: &mut IndexMap<String, Schema>) {
schemas.insert(pointer.to_string(), schema.clone());
for (name, property) in &schema.properties {
index_schema_pointers(
property,
&format!("{pointer}/properties/{}", json_pointer_segment(name)),
schemas,
);
}
if let Some(items) = &schema.items {
index_schema_pointers(items, &format!("{pointer}/items"), schemas);
}
if let Some(AdditionalProperties::Schema(value)) = &schema.additional_properties {
index_schema_pointers(value, &format!("{pointer}/additionalProperties"), schemas);
}
for (name, members) in [
("oneOf", &schema.one_of),
("anyOf", &schema.any_of),
("allOf", &schema.all_of),
] {
for (index, member) in members.iter().flatten().enumerate() {
index_schema_pointers(member, &format!("{pointer}/{name}/{index}"), schemas);
}
}
}
fn resolve_indexed_schema_refs(
schema: &mut Schema,
prefix: &str,
schemas: &IndexMap<String, Schema>,
resolving: &mut std::collections::BTreeSet<String>,
) {
if let Some(reference) = schema
.reference
.as_deref()
.filter(|reference| reference.starts_with(prefix))
.map(str::to_string)
{
if resolving.insert(reference.clone()) {
if let Some(target) = schemas.get(&reference) {
*schema = target.clone();
resolve_indexed_schema_refs(schema, prefix, schemas, resolving);
}
resolving.remove(&reference);
}
return;
}
for property in schema.properties.values_mut() {
resolve_indexed_schema_refs(property, prefix, schemas, resolving);
}
if let Some(items) = &mut schema.items {
resolve_indexed_schema_refs(items, prefix, schemas, resolving);
}
if let Some(AdditionalProperties::Schema(value)) = &mut schema.additional_properties {
resolve_indexed_schema_refs(value, prefix, schemas, resolving);
}
for member in [&mut schema.one_of, &mut schema.any_of, &mut schema.all_of]
.into_iter()
.flatten()
.flatten()
{
resolve_indexed_schema_refs(member, prefix, schemas, resolving);
}
}
fn resolve_request_body(
request_body: &RequestBody,
defs: &IndexMap<String, RequestBody>,
) -> RequestBody {
if let Some(name) = request_body
.reference
.as_deref()
.and_then(|r| r.strip_prefix("#/components/requestBodies/"))
{
if let Some(target) = defs.get(name) {
let mut resolved = target.clone();
resolved.component_ref = true;
return resolved;
}
}
request_body.clone()
}
fn normalize_request_bodies(doc: &mut OpenApi) {
let defs = doc.components.request_bodies.clone();
for item in doc.paths.values_mut() {
for slot in item.operation_slots() {
let Some(op) = slot else { continue };
if let Some(request_body) = &mut op.request_body {
*request_body = resolve_request_body(request_body, &defs);
for (media_type, media) in &mut request_body.content {
if let Some(schema) = &mut media.schema {
collapse_sole_composition_member(
schema,
crate::ir::is_json_like_media_type(media_type),
);
}
}
}
}
}
}
fn collapse_sole_composition_member(schema: &mut Schema, json: bool) {
let sole = |members: &Option<Vec<Schema>>| {
members
.as_ref()
.filter(|members| members.len() == 1)
.map(|members| members[0].clone())
};
let sole_ref = || {
sole(&schema.all_of).filter(|member| {
json && member.reference.is_some()
&& schema.one_of.is_none()
&& schema.any_of.is_none()
&& schema.nullable != Some(true)
})
};
let (mut member, all_of) = match sole(&schema.one_of).or_else(|| sole(&schema.any_of)) {
Some(member) => (member, false),
None => match sole_ref() {
Some(member) => (member, true),
None => return,
},
};
if schema.reference.is_some()
|| (schema.all_of.is_some() && !all_of)
|| schema.ty.is_some()
|| schema.properties.declared()
|| schema.additional_properties.is_some()
|| schema.items.is_some()
|| schema.enum_values.is_some()
|| schema.const_value.is_some()
|| schema.discriminator.is_some()
{
return;
}
if all_of {
member.title.clone_from(&schema.title);
member.description.clone_from(&schema.description);
}
*schema = member;
}
fn resolve_response(response: &Response, defs: &IndexMap<String, Response>) -> Response {
if let Some(name) = response
.reference
.as_deref()
.and_then(|r| r.strip_prefix("#/components/responses/"))
{
if let Some(target) = defs.get(name) {
let mut resolved = target.clone();
resolved.reference.clone_from(&response.reference);
return resolved;
}
}
response.clone()
}
fn normalize_event_stream_item_schemas(doc: &mut OpenApi) {
let fold = |response: &mut Response| {
if let Some(media) = response.content.get_mut("text/event-stream") {
if media.schema.is_none() {
media.schema = media.item_schema.take();
}
}
};
for item in doc.paths.values_mut() {
for slot in item.operation_slots() {
let Some(op) = slot else { continue };
op.responses.values_mut().for_each(fold);
}
}
doc.components.responses.values_mut().for_each(fold);
}
fn normalize_responses(doc: &mut OpenApi) {
let defs = doc.components.responses.clone();
for item in doc.paths.values_mut() {
for slot in item.operation_slots() {
let Some(op) = slot else { continue };
for response in op.responses.values_mut() {
*response = resolve_response(response, &defs);
}
}
}
}
fn resolve_parameter(param: &Parameter, defs: &IndexMap<String, Parameter>) -> Parameter {
if let Some(name) = param
.reference
.as_deref()
.and_then(|r| r.strip_prefix("#/components/parameters/"))
{
if let Some(target) = defs.get(name) {
return target.clone();
}
}
param.clone()
}
fn normalize_parameters(doc: &mut OpenApi) {
let defs = doc.components.parameters.clone();
for item in doc.paths.values_mut() {
let shared: Vec<Parameter> = item
.parameters
.iter()
.map(|p| resolve_parameter(p, &defs))
.collect();
for slot in item.operation_slots() {
let Some(op) = slot else { continue };
op.path_level_parameters = !shared.is_empty();
let mut merged = shared.clone();
for own in &op.parameters {
let own = resolve_parameter(own, &defs);
if let Some(existing) = merged
.iter_mut()
.find(|p| p.name == own.name && p.location == own.location)
{
*existing = own;
} else {
merged.push(own);
}
}
op.parameters = merged;
}
item.parameters = Vec::new();
}
}
pub fn filter_by_audience(doc: &mut OpenApi, audiences: &[String], strict: bool) {
if audiences.is_empty() {
return;
}
let keep = |op: &Operation| {
let labels = op.audiences();
if labels.is_empty() {
!strict
} else {
labels.iter().any(|a| audiences.contains(a))
}
};
for item in doc.paths.values_mut() {
for slot in item.operation_slots() {
if slot.as_ref().is_some_and(|op| !keep(op)) {
*slot = None;
}
}
}
doc.paths.retain(|_, item| !item.operations().is_empty());
let seed = operation_schema_seed(doc.paths.values().flat_map(|i| i.operations()));
let reached = expand_schema_closure(doc, seed);
doc.components.schemas.retain(|k, _| reached.contains(k));
}
pub fn filter_ignored(doc: &mut OpenApi) {
for item in doc.paths.values_mut() {
item.parameters.retain(|parameter| !parameter.ignored());
for slot in item.operation_slots() {
if let Some(op) = slot.as_mut() {
op.parameters.retain(|parameter| !parameter.ignored());
}
if slot.as_ref().is_some_and(|op| op.ignored()) {
*slot = None;
}
}
}
doc.paths.retain(|_, item| !item.operations().is_empty());
let ignored_schemas: Vec<String> = doc
.components
.schemas
.iter()
.filter(|(_, schema)| schema.ignored())
.map(|(key, _)| key.clone())
.collect();
for key in &ignored_schemas {
doc.components.schemas.shift_remove(key);
}
for_each_root_schema(doc, &mut |schema| {
for_each_schema_in(schema, &mut |node| {
let ignored: std::collections::HashSet<String> = node
.properties
.iter()
.filter(|(_, property)| property.ignored())
.map(|(name, _)| name.clone())
.collect();
node.properties.retain(|name, _| !ignored.contains(name));
if let RequiredNames::Listed(names) = &mut node.required {
names.retain(|name| !ignored.contains(name));
}
});
});
}
fn operation_schema_seed<'a>(
ops: impl IntoIterator<Item = (&'static str, &'a Operation)>,
) -> std::collections::BTreeSet<String> {
let mut seed = std::collections::BTreeSet::new();
for (_, op) in ops {
for p in &op.parameters {
if let Some(s) = &p.schema {
collect_schema_refs(s, &mut seed);
}
}
let bodies = op
.request_body
.iter()
.flat_map(|rb| rb.content.values())
.chain(op.responses.values().flat_map(|r| r.content.values()));
for mt in bodies {
if let Some(s) = &mt.schema {
collect_schema_refs(s, &mut seed);
}
}
}
seed
}
fn expand_schema_closure(
doc: &OpenApi,
seed: std::collections::BTreeSet<String>,
) -> std::collections::BTreeSet<String> {
let mut reached = std::collections::BTreeSet::new();
let mut queue: Vec<String> = seed.into_iter().collect();
while let Some(key) = queue.pop() {
if !reached.insert(key.clone()) {
continue;
}
if let Some(schema) = doc.components.schemas.get(&key) {
let mut refs = std::collections::BTreeSet::new();
collect_schema_refs(schema, &mut refs);
queue.extend(refs.into_iter().filter(|r| !reached.contains(r)));
}
}
reached
}
pub(crate) fn collect_schema_refs(schema: &Schema, out: &mut std::collections::BTreeSet<String>) {
const PREFIX: &str = "#/components/schemas/";
if let Some(key) = schema
.reference
.as_deref()
.and_then(|r| r.strip_prefix(PREFIX))
{
out.insert(key.to_string());
}
for s in schema.properties.values() {
collect_schema_refs(s, out);
}
if let Some(items) = &schema.items {
collect_schema_refs(items, out);
}
if let Some(AdditionalProperties::Schema(s)) = &schema.additional_properties {
collect_schema_refs(s, out);
}
for member in [&schema.one_of, &schema.any_of, &schema.all_of]
.into_iter()
.flatten()
.flatten()
{
collect_schema_refs(member, out);
}
if let Some(disc) = &schema.discriminator {
for key in disc.mapping.values().filter_map(|r| r.strip_prefix(PREFIX)) {
out.insert(key.to_string());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_one_or_zero_nullable_reads_as_a_boolean_and_any_other_value_is_refused() {
let nullable = |value: &str| {
crate::yaml::from_str::<Schema>(&format!("type: string\nnullable: {value}\n"))
.map(|schema| schema.nullable)
};
assert_eq!(nullable("1"), Ok(Some(true)));
assert_eq!(nullable("0"), Ok(Some(false)));
assert_eq!(nullable("true"), Ok(Some(true)));
assert_eq!(nullable("~"), Ok(None));
for other in ["'yes'", "2", "0.5"] {
let error = nullable(other).unwrap_err();
assert!(
error.contains("invalid boolean (strict mode expects true/false)"),
"{other}: {error}"
);
}
let error = serde_json::from_str::<Schema>(r#"{"type": "string", "nullable": "yes"}"#)
.unwrap_err()
.to_string();
assert!(error.contains("expected a boolean"), "{error}");
}
#[test]
fn boolean_fields_read_one_and_zero_and_refuse_other_values() {
let yaml = |text: &str| crate::yaml::from_str::<Schema>(text);
let json = |text: &str| serde_json::from_str::<Schema>(text).map_err(|e| e.to_string());
for (one, zero) in [("1", "0"), ("true", "false")] {
let schema = yaml(&format!(
"type: object\nnullable: {one}\nreadOnly: {zero}\nuniqueItems: {one}\n\
deprecated: {one}\nadditionalProperties: {zero}\nx-fern-ignore: {zero}\n"
))
.unwrap();
assert_eq!(schema.nullable, Some(true));
assert_eq!(schema.read_only, Some(false));
assert_eq!(schema.unique_items, Some(true));
assert!(schema.deprecated);
assert_eq!(
schema.additional_properties,
Some(AdditionalProperties::Bool(false))
);
assert!(!schema.ignored());
let parameter = serde_json::from_str::<Parameter>(&format!(
r#"{{"name": "id", "in": "query", "required": {one}, "explode": {zero}}}"#
))
.unwrap();
assert_eq!(
(parameter.required, parameter.explode),
(Some(true), Some(false))
);
}
assert_eq!(yaml("nullable: ~\n").unwrap().nullable, None);
assert_eq!(json(r#"{"readOnly": null}"#).unwrap().read_only, None);
for value in ["2", "-1", "1.0", "'1'", "yes"] {
let error = yaml(&format!("readOnly: {value}\n")).unwrap_err();
assert!(
error
.contains("line 1 column 11: invalid boolean (strict mode expects true/false)"),
"{value}: {error}"
);
}
for (value, unexpected) in [
("2", "integer `2`"),
("-1", "integer `-1`"),
("1.0", "floating point `1.0`"),
(r#""1""#, r#"string "1""#),
("[1]", "sequence"),
] {
let error = json(&format!(r#"{{"uniqueItems": {value}}}"#)).unwrap_err();
assert!(
error.starts_with(&format!("invalid type: {unexpected}, expected a boolean")),
"{value}: {error}"
);
}
let error = yaml("additionalProperties: 2\n").unwrap_err();
assert!(error.contains("did not match any variant"), "{error}");
}
#[test]
fn a_refused_boolean_reads_as_the_readers_plain_boolean_refusal() {
#[derive(Deserialize)]
struct Plain {
flag: bool,
}
#[derive(Deserialize)]
struct Shared {
#[serde(deserialize_with = "de_present_boolean")]
flag: bool,
}
for value in ["2", "-1", "1.0", "'1'", "yes", "~", "[1]", "{a: 1}"] {
let text = format!("other: x\nflag: {value}\n");
assert_eq!(
crate::yaml::from_str::<Shared>(&text).map(|read| read.flag),
crate::yaml::from_str::<Plain>(&text).map(|read| read.flag),
"YAML {value}"
);
}
for value in ["2", "-1", "1.0", "\"1\"", "\"true\"", "null"] {
let text = format!("{{\"other\": \"x\",\n\"flag\": {value}}}");
assert_eq!(
serde_json::from_str::<Shared>(&text)
.map(|read| read.flag)
.map_err(|error| error.to_string()),
serde_json::from_str::<Plain>(&text)
.map(|read| read.flag)
.map_err(|error| error.to_string()),
"JSON {value}"
);
}
}
#[test]
fn a_deprecated_mark_reads_one_and_takes_any_other_value_as_no_mark() {
let deprecated = |value: &str| {
crate::yaml::from_str::<Schema>(&format!("deprecated: {value}\n"))
.unwrap()
.deprecated
};
assert!(deprecated("1"));
assert!(deprecated("true"));
for other in ["0", "2", "'yes'", "[1]"] {
assert!(!deprecated(other), "{other}");
}
}
#[test]
fn a_source_tree_boolean_reads_by_the_same_rule() {
let read = |text: &str| document_boolean(&serde_json::from_str(text).unwrap());
assert_eq!(read("true"), Some(true));
assert_eq!(read("1"), Some(true));
assert_eq!(read("0"), Some(false));
for other in ["2", "-1", "1.0", "\"true\"", "null", "[]"] {
assert_eq!(read(other), None, "{other}");
}
}
#[test]
fn fern_error_names_are_the_error_classes_crozier_names_and_five_more() {
use std::collections::BTreeSet;
let fern: BTreeSet<String> = FERN_ERROR_NAMES
.iter()
.map(|name| name.to_ascii_lowercase())
.collect();
assert_eq!(fern.len(), FERN_ERROR_NAMES.len(), "a name is listed twice");
let mut named: BTreeSet<String> = (100..=599)
.filter_map(crate::ir::error_class_name)
.map(str::to_ascii_lowercase)
.collect();
for range in [
crate::ir::ErrorStatus::ClientRange,
crate::ir::ErrorStatus::ServerRange,
] {
named.insert(
range
.class_name()
.expect("a named range")
.to_ascii_lowercase(),
);
}
let missing: Vec<&String> = named.difference(&fern).collect();
assert!(
missing.is_empty(),
"crozier names errors the importer list lacks: {missing:?}"
);
let importer_only: BTreeSet<String> = [
"AuthenticationTimeoutError",
"MethodFailureError",
"NoResponseError",
"RetryWithError",
"BlockedByWindowsParentalControlsError",
]
.iter()
.map(|name| name.to_ascii_lowercase())
.collect();
assert_eq!(
fern.difference(&named).cloned().collect::<BTreeSet<_>>(),
importer_only
);
}
#[test]
fn a_component_reads_its_group_and_placement_tag() {
let schema =
|value: serde_json::Value| -> Schema { serde_json::from_value(value).unwrap() };
let placed = schema(serde_json::json!({
"x-fern-sdk-group-name": "lexicon",
"x-crozier-sdk-group-name": [" glossary ", ""],
"x-tags": [" ", "Fermentation", "Cellar"],
}));
assert_eq!(placed.sdk_group_name(), Some(vec!["glossary"]));
assert_eq!(placed.placement_tag(), Some("Fermentation"));
let plain = schema(serde_json::json!({"x-fern-sdk-group-name": 3, "x-tags": "Cellar"}));
assert_eq!(plain.sdk_group_name(), None);
assert_eq!(plain.placement_tag(), None);
}
#[test]
fn a_boolean_pagination_takes_the_root_contract() {
let mut doc: OpenApi = serde_json::from_value(serde_json::json!({
"x-fern-pagination": {"offset": "$request.page", "results": "$response.items"},
"paths": {"/p": {
"get": {"x-fern-pagination": true, "responses": {}},
"put": {"x-fern-pagination": false, "responses": {}},
"post": {"x-crozier-pagination": {"cursor": "$request.c", "next_cursor": "$response.n",
"results": "$response.items"},
"x-fern-pagination": true, "responses": {}},
}},
}))
.expect("a document");
normalize_root_pagination(&mut doc);
let item = &doc.paths["/p"];
let get = item
.get
.as_ref()
.unwrap()
.pagination()
.expect("the root contract");
assert_eq!(get.offset_property(), Some("page"));
assert!(item.put.as_ref().unwrap().pagination().is_none());
let post = item
.post
.as_ref()
.unwrap()
.pagination()
.expect("its own contract");
assert_eq!(post.cursor_property(), Some("c"));
let refused: std::result::Result<Operation, _> =
serde_json::from_value(serde_json::json!({"x-fern-pagination": "yes"}));
assert!(refused
.expect_err("a string is no pagination")
.to_string()
.contains("a pagination contract, or a boolean"));
}
#[test]
fn a_lone_all_of_ref_json_body_is_its_component() {
let wrapper = |extra: serde_json::Value| {
let mut schema = serde_json::json!({
"allOf": [{ "$ref": "#/components/schemas/Schedule" }]
});
schema
.as_object_mut()
.expect("an object")
.extend(extra.as_object().expect("an object").clone());
schema
};
let operation = |media: &str, schema: serde_json::Value| {
serde_json::json!({ "post": {
"requestBody": { "content": { media: { "schema": schema } } },
"responses": { "200": { "description": "ok" } }
} })
};
let mut doc: OpenApi = serde_json::from_value(serde_json::json!({
"openapi": "3.1.0", "info": { "title": "Kiln", "version": "1" },
"paths": {
"/titled": operation("application/json", wrapper(serde_json::json!({
"title": "Params", "description": "The schedule"
}))),
"/defaulted": operation("application/vnd.kiln+json", wrapper(serde_json::json!({
"default": { "peak": 1200 }
}))),
"/nullable": operation("application/json", wrapper(serde_json::json!({
"nullable": true
}))),
"/typed": operation("application/json", wrapper(serde_json::json!({
"type": "object"
}))),
"/form": operation("application/x-www-form-urlencoded", wrapper(serde_json::json!({}))),
"/pair": operation("application/json", serde_json::json!({ "allOf": [
{ "$ref": "#/components/schemas/Schedule" },
{ "$ref": "#/components/schemas/Schedule" }
] }))
},
"components": { "schemas": { "Schedule": {
"type": "object", "properties": { "peak": { "type": "integer" } }
} } }
}))
.expect("a document");
normalize_request_bodies(&mut doc);
let body = |path: &str| {
let op = doc.paths[path].post.as_ref().expect("a post");
let media = op.request_body.as_ref().expect("a body").content.values();
media
.into_iter()
.next()
.and_then(|media| media.schema.clone())
.expect("a schema")
};
for path in ["/titled", "/defaulted"] {
let schema = body(path);
assert_eq!(
schema.reference.as_deref(),
Some("#/components/schemas/Schedule"),
"{path}"
);
assert!(schema.all_of.is_none(), "{path}");
}
let titled = body("/titled");
assert_eq!(titled.title.as_deref(), Some("Params"));
assert_eq!(titled.description.as_deref(), Some("The schedule"));
for path in ["/nullable", "/typed", "/form", "/pair"] {
let schema = body(path);
assert!(schema.reference.is_none(), "{path}");
assert!(schema.all_of.is_some(), "{path}");
}
}
#[test]
fn a_webhook_marked_operation_is_dropped_and_its_neighbour_kept() {
let mut doc: OpenApi = serde_json::from_value(serde_json::json!({
"paths": {"/hooks": {
"post": {"x-fern-webhook": true, "responses": {}},
"put": {"x-fern-webhook": true, "x-crozier-webhook": false, "responses": {}},
"get": {"x-fern-webhook": "yes", "responses": {}},
}},
}))
.expect("a document");
normalize_webhook_operations(&mut doc);
let methods: Vec<&str> = doc.paths["/hooks"]
.operations()
.into_iter()
.map(|(m, _)| m)
.collect();
assert_eq!(methods, ["GET", "PUT"]);
}
#[test]
fn an_inline_schema_declaring_a_type_name_is_lifted_to_that_component() {
let mut doc: OpenApi = serde_json::from_value(serde_json::json!({
"openapi": "3.0.3",
"paths": {"/s": {"get": {"responses": {"200": {"description": "ok", "content": {
"application/json": {"schema": {"type": "object", "properties": {
"mode": {"type": "string", "enum": ["a"], "x-fern-type-name": "ShotSize"},
"taken": {"type": "string", "enum": ["b"], "x-crozier-type-name": "Camera"},
"plain": {"type": "string", "enum": ["c"]},
}}}}}}}}},
"components": {"schemas": {"Camera": {"type": "object", "properties": {
"iso": {"type": "array", "items": {"type": "string", "enum": ["low"],
"x-fern-type-name": "IsoBand"}},
}}}},
}))
.expect("a document");
normalize_inline_declared_type_names(&mut doc);
let keys: Vec<&String> = doc.components.schemas.keys().collect();
assert_eq!(keys, ["Camera", "IsoBand", "ShotSize"]);
let response = &doc.paths["/s"].get.as_ref().unwrap().responses["200"].content
["application/json"]
.schema
.as_ref()
.unwrap();
assert_eq!(
response.properties["mode"].reference.as_deref(),
Some("#/components/schemas/ShotSize")
);
assert!(response.properties["taken"].reference.is_none());
assert!(response.properties["plain"].reference.is_none());
assert_eq!(
doc.components.schemas["Camera"].properties["iso"]
.items
.as_ref()
.unwrap()
.reference
.as_deref(),
Some("#/components/schemas/IsoBand")
);
}
#[test]
fn idempotency_and_retry_extensions_canonicalize_on_the_crozier_spelling() {
let op: Operation = serde_json::from_value(serde_json::json!({
"x-fern-idempotent": false,
"x-crozier-idempotent": true,
"x-fern-retries": {"disabled": true},
"x-crozier-retries": {"disabled": false},
}))
.expect("an operation");
assert!(op.idempotent());
assert!(!op.retries_disabled());
let op: Operation =
serde_json::from_value(serde_json::json!({"x-fern-retries": {"disabled": true}}))
.expect("an operation");
assert!(op.retries_disabled() && !op.idempotent());
let doc: OpenApi = serde_json::from_value(serde_json::json!({
"x-fern-idempotency-headers": [{"header": "X-Fern"}],
"x-crozier-idempotency-headers": [{"header": " X-Dedupe "}, {"name": "no-header"}, 7],
}))
.expect("a document");
assert_eq!(doc.idempotency_headers(), vec!["X-Dedupe"]);
}
#[test]
fn server_extensions_canonicalize_on_the_crozier_spelling() {
let server: Server = serde_json::from_value(serde_json::json!({
"url": "https://{r}.a.test",
"x-fern-server-name": "main",
"x-crozier-server-name": " primary ",
"x-fern-default-url": "https://fern.a.test",
}))
.expect("a server");
assert_eq!(server.server_name(), Some("primary"));
assert_eq!(server.default_url(), Some("https://fern.a.test"));
let blank: Server = serde_json::from_value(serde_json::json!({
"url": "https://a.test",
"x-fern-server-name": " ",
"x-crozier-default-url": 7,
}))
.expect("a server");
assert_eq!(blank.server_name(), None);
assert_eq!(blank.default_url(), None);
}
#[test]
fn an_http_scheme_name_reads_case_insensitively() {
let scheme = |name: &str| -> HttpAuthScheme {
serde_json::from_value(serde_json::json!(name)).expect("a scheme name")
};
assert_eq!(scheme("Bearer"), HttpAuthScheme::Bearer);
assert_eq!(scheme("BASIC"), HttpAuthScheme::Basic);
assert_eq!(scheme("bearer"), HttpAuthScheme::Bearer);
assert_eq!(scheme("Digest"), HttpAuthScheme::Other);
}
#[test]
fn a_method_name_sequence_reads_joined_as_fern_reads_it() {
let name = |extension: serde_json::Value| {
let op: Operation = serde_json::from_value(serde_json::json!({
"operationId": "getLockers",
"x-fern-sdk-method-name": extension,
}))
.expect("operation deserializes");
op.sdk_method_name().map(str::to_string)
};
assert_eq!(
name(serde_json::json!("vacancies")).as_deref(),
Some("vacancies")
);
assert_eq!(
name(serde_json::json!(["vacancies"])).as_deref(),
Some("vacancies")
);
assert_eq!(
name(serde_json::json!(["claim", "now"])).as_deref(),
Some("claim,now")
);
assert_eq!(name(serde_json::Value::Null), None);
let empty: std::result::Result<Operation, _> =
serde_json::from_value(serde_json::json!({"x-fern-sdk-method-name": []}));
assert!(empty
.expect_err("an empty sequence names no method")
.to_string()
.contains("invalid length 0"));
let mapping: std::result::Result<Operation, _> =
serde_json::from_value(serde_json::json!({
"x-crozier-sdk-method-name": {"name": "vacancies"},
}));
assert!(mapping
.expect_err("a mapping is no method name")
.to_string()
.contains("a string, or a sequence of strings"));
}
#[test]
fn a_base_path_reads_its_placeholders_and_their_map_form_defaults() {
let object: BasePath = serde_json::from_value(serde_json::json!({
"path": "/{edition}/{realm}/{edition}/",
"paths-include-base-path": true,
"parameters": {"edition": {"type": "string", "default": "v2"}, "realm": {"type": "string"}}
}))
.expect("the object form deserializes");
assert_eq!(object.route_prefix(), "");
assert_eq!(
object.parameters(),
[
BasePathParameter {
name: "edition".into(),
default: Some("v2".into())
},
BasePathParameter {
name: "realm".into(),
default: None
},
]
);
let list: BasePath = serde_json::from_value(serde_json::json!({
"path": "/{edition}",
"parameters": [{"name": "edition", "in": "path", "schema": {"default": "v2"}}]
}))
.expect("the list form deserializes");
assert_eq!(list.route_prefix(), "/{edition}");
assert_eq!(list.parameters()[0].default, None);
assert!(matches!(
list,
BasePath::Object {
parameters: Some(BasePathParameters::List(_)),
..
}
));
let other: BasePath = serde_json::from_value(serde_json::json!({
"path": "/{edition}",
"parameters": "edition"
}))
.expect("another spelling deserializes");
assert_eq!(
other.parameters(),
[BasePathParameter {
name: "edition".into(),
default: None
}]
);
let string: BasePath =
serde_json::from_value(serde_json::json!("/v1/{open")).expect("string form");
assert_eq!(string.route_prefix(), "/v1/{open");
assert!(string.parameters().is_empty());
}
#[test]
fn x_crozier_base_path_wins_over_x_fern_base_path() {
let both: OpenApi = crate::yaml::from_str(
"openapi: 3.0.3\nx-fern-base-path: /fern\nx-crozier-base-path: /crozier\npaths: {}\n",
)
.expect("document deserializes");
assert_eq!(
both.base_path().map(BasePath::route_prefix),
Some("/crozier")
);
let fern: OpenApi =
crate::yaml::from_str("openapi: 3.0.3\nx-fern-base-path: /fern\npaths: {}\n")
.expect("document deserializes");
assert_eq!(fern.base_path().map(BasePath::route_prefix), Some("/fern"));
}
#[test]
fn null_schema_nodes_degrade_to_malformed_unknowns() {
let parsed: MaybeSchema = serde_json::from_str("null").expect("null degrades");
assert!(parsed.0.malformed);
for value in ["1", "-1", "1.5", "true", "[]"] {
assert!(
serde_json::from_str::<MaybeSchema>(value)
.unwrap()
.0
.malformed
);
}
let named: MaybeSchema = serde_json::from_str("\"object\"").expect("a string names a type");
assert!(!named.0.malformed);
assert_eq!(
named.0.ty.as_ref().and_then(TypeField::primary),
Some("object")
);
let error = <serde::de::value::Error as serde::de::Error>::invalid_type(
serde::de::Unexpected::Unit,
&MaybeSchemaVisitor,
);
assert!(error.to_string().contains("schema object"));
}
fn parse(spec: &str) -> OpenApi {
crate::yaml::from_str(spec).expect("valid spec")
}
fn op_ids(doc: &OpenApi) -> Vec<String> {
doc.paths
.values()
.flat_map(|i| i.operations())
.map(|(_, op)| op.operation_id.clone().unwrap_or_default())
.collect()
}
fn schema_keys(doc: &OpenApi) -> Vec<String> {
doc.components.schemas.keys().cloned().collect()
}
#[test]
fn declared_type_name_prefers_the_canonical_spelling() {
let schema = |value| serde_json::from_value::<Schema>(value).unwrap();
let both = schema(serde_json::json!({
"x-fern-type-name": "Decoy", "x-crozier-type-name": "Gadget"
}));
assert_eq!(both.declared_type_name(), Some("Gadget"));
let fern = schema(serde_json::json!({ "x-fern-type-name": "Gadget" }));
assert_eq!(fern.declared_type_name(), Some("Gadget"));
let blank = schema(serde_json::json!({
"x-crozier-type-name": " ", "x-fern-type-name": "Gadget"
}));
assert_eq!(blank.declared_type_name(), Some("Gadget"));
assert_eq!(schema(serde_json::json!({})).declared_type_name(), None);
}
#[test]
fn float_type_reads_as_an_unnarrowed_number_and_its_siblings_stay_unknown() {
let mut doc = parse(
r"
openapi: 3.1.0
info: { title: t, version: '1' }
paths:
/gauges:
get:
parameters:
- { name: tolerance, in: query, schema: { type: float } }
responses:
'200':
description: ok
content:
application/json:
schema: { type: array, items: { type: float } }
components:
schemas:
Gauge:
type: object
properties:
plain: { type: float }
narrowed: { type: float, format: int32 }
nullable: { type: [float, 'null'] }
counted: { type: int, format: int32 }
real: { type: number, format: int64 }
",
);
normalize_float_type(&mut doc);
let gauge = &doc.components.schemas["Gauge"];
let field = |name: &str| {
let schema = gauge.properties.get(name).expect("property");
(
schema
.ty
.as_ref()
.and_then(TypeField::primary)
.map(str::to_owned),
schema.format.clone(),
)
};
assert_eq!(field("plain"), (Some("number".to_owned()), None));
assert_eq!(field("narrowed"), (Some("number".to_owned()), None));
assert_eq!(field("nullable"), (Some("number".to_owned()), None));
assert!(matches!(
&gauge.properties.get("nullable").unwrap().ty,
Some(TypeField::Multiple(types)) if types == &["number", "null"]
));
assert_eq!(
field("counted"),
(Some("int".to_owned()), Some("int32".to_owned()))
);
assert_eq!(
field("real"),
(Some("number".to_owned()), Some("int64".to_owned()))
);
let operation = doc.paths["/gauges"].get.as_ref().expect("get");
let parameter = operation.parameters[0].schema.as_ref().expect("schema");
assert_eq!(
parameter.ty.as_ref().and_then(TypeField::primary),
Some("number")
);
let response = operation.responses["200"].content["application/json"]
.schema
.as_ref()
.expect("schema");
let item = response.items.as_deref().expect("items");
assert_eq!(
item.ty.as_ref().and_then(TypeField::primary),
Some("number")
);
}
#[test]
fn same_primitive_unions_take_their_last_alternative_and_its_name() {
let mut doc = parse(
r"
openapi: 3.0.3
info: { title: t, version: '1' }
paths:
/bins:
post:
requestBody:
content:
application/json:
schema:
type: object
properties:
bin: { $ref: '#/components/schemas/BinLabel' }
responses:
'200':
description: ok
content:
application/json:
schema: { $ref: '#/components/schemas/Tally' }
components:
schemas:
BinLabel:
anyOf:
- { type: string, pattern: '^R[0-9]+$' }
- { type: string, pattern: '^Q[0-9]+$' }
Tally:
oneOf:
- { type: integer }
- { type: integer, maximum: 10 }
- { type: integer, minimum: 2 }
Mixed:
anyOf: [{ type: string }, { type: integer }]
Nullable:
anyOf: [{ type: string }, { type: string }, { nullable: true, type: string }]
Taken:
anyOf: [{ type: string }, { type: string }]
Taken One:
type: string
",
);
normalize_same_primitive_unions(&mut doc);
assert_eq!(
schema_keys(&doc),
[
"BinLabel One",
"Tally Two",
"Mixed",
"Nullable",
"Taken",
"Taken One"
]
);
let label = &doc.components.schemas["BinLabel One"];
assert!(label.any_of.is_none());
assert_eq!(label.pattern.as_deref(), Some("^Q[0-9]+$"));
assert_eq!(
doc.components.schemas["Tally Two"].minimum,
Some(serde_json::json!(2))
);
let operation = doc.paths["/bins"].post.as_ref().unwrap();
let body = operation.request_body.as_ref().unwrap().content["application/json"]
.schema
.as_ref()
.unwrap();
assert_eq!(
body.properties["bin"].reference.as_deref(),
Some("#/components/schemas/BinLabel One")
);
let response = operation.responses["200"].content["application/json"]
.schema
.as_ref()
.unwrap();
assert_eq!(
response.reference.as_deref(),
Some("#/components/schemas/Tally Two")
);
}
#[test]
fn declared_type_names_rename_components_and_every_reference() {
let mut doc = parse(
r"
openapi: 3.1.0
info: { title: t, version: '1' }
paths:
/w:
get:
responses:
'200':
description: ok
content:
application/json:
schema: { $ref: '#/components/schemas/Widget/properties/id' }
webhooks:
made:
post:
requestBody:
content:
application/json:
schema:
type: object
properties:
widget: { $ref: '#/components/schemas/Widget' }
components:
schemas:
Widget:
x-fern-type-name: Gadget
type: object
properties:
id: { type: string }
Holder:
type: object
discriminator:
propertyName: kind
mapping: { w: '#/components/schemas/Widget' }
properties:
widgets: { type: array, items: { $ref: '#/components/schemas/Widget' } }
Taken:
x-crozier-type-name: Holder
type: object
Same:
x-fern-type-name: Same
type: object
Slashed:
x-fern-type-name: a/b~c
type: object
First:
x-fern-type-name: Twin
type: object
Second:
x-fern-type-name: Twin
type: object
",
);
normalize_declared_type_names(&mut doc);
assert_eq!(
schema_keys(&doc),
["Gadget", "Holder", "Same", "a b c", "Twin"]
);
assert!(doc.components.schemas["Holder"].discriminator.is_some());
let holder = &doc.components.schemas["Holder"];
assert_eq!(
holder.properties["widgets"]
.items
.as_ref()
.and_then(|items| items.reference.as_deref()),
Some("#/components/schemas/Gadget")
);
assert_eq!(
holder.discriminator.as_ref().unwrap().mapping["w"],
"#/components/schemas/Gadget"
);
let response = doc.paths["/w"].get.as_ref().unwrap().responses["200"].content
["application/json"]
.schema
.as_ref()
.unwrap();
assert_eq!(
response.reference.as_deref(),
Some("#/components/schemas/Gadget/properties/id")
);
let payload = doc.webhooks["made"]
.post
.as_ref()
.unwrap()
.request_body
.as_ref()
.unwrap()
.content["application/json"]
.schema
.as_ref()
.unwrap();
assert_eq!(
payload.properties["widget"].reference.as_deref(),
Some("#/components/schemas/Gadget")
);
}
#[test]
fn indexed_compositions_are_sorted_and_preserved() {
let schema: Schema = serde_json::from_value(serde_json::json!({
"allOf": { "3": { "type": "string" }, "1": { "$ref": "#/components/schemas/Base" } }
}))
.expect("indexed allOf parses");
let members = schema.all_of.expect("allOf is retained");
assert_eq!(
members[0].reference.as_deref(),
Some("#/components/schemas/Base")
);
assert_eq!(
members[1].ty.as_ref().and_then(TypeField::primary),
Some("string")
);
}
#[test]
fn non_indexed_composition_maps_are_rejected() {
let error = serde_json::from_value::<Schema>(serde_json::json!({
"oneOf": { "variant": { "type": "string" } }
}))
.expect_err("non-indexed composition must fail");
assert!(
error.to_string().contains("not a non-negative integer"),
"{error}"
);
}
const IGNORE_SPEC: &str = r##"
openapi: 3.0.3
info: { title: Widget API }
paths:
/keep:
get:
operationId: keepOp
responses:
"200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Keep" } } } }
/fern:
get:
operationId: fernIgnoredOp
x-fern-ignore: true
responses:
"200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/OnlyFern" } } } }
/crozier:
get:
operationId: crozierIgnoredOp
x-crozier-ignore: true
responses:
"200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/OnlyCrozier" } } } }
components:
schemas:
Keep: { type: object, properties: { note: { type: string } } }
OnlyFern: { type: object, properties: { note: { type: string } } }
OnlyCrozier: { type: object, properties: { note: { type: string } } }
"##;
#[test]
fn ignore_extension_drops_ops_via_both_spellings_and_keeps_their_schemas() {
let mut doc = parse(IGNORE_SPEC);
filter_ignored(&mut doc);
assert_eq!(op_ids(&doc), ["keepOp"]);
assert_eq!(doc.paths.keys().cloned().collect::<Vec<_>>(), ["/keep"]);
assert_eq!(schema_keys(&doc), ["Keep", "OnlyFern", "OnlyCrozier"]);
}
#[test]
fn crozier_ignore_false_overrides_fern_ignore_true() {
let mut doc = parse(
r##"
openapi: 3.0.3
info: { title: T }
paths:
/keep:
get:
operationId: keptDespiteFern
x-fern-ignore: true
x-crozier-ignore: false
responses: { "200": { description: OK } }
"##,
);
filter_ignored(&mut doc);
assert_eq!(op_ids(&doc), ["keptDespiteFern"]);
}
#[test]
fn schema_level_ignore_drops_the_component_but_keeps_referenced_ones() {
let mut doc = parse(
r##"
openapi: 3.0.3
info: { title: T }
paths:
/keep:
get:
operationId: keepOp
responses:
"200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Shared" } } } }
/drop:
get:
operationId: dropOp
x-crozier-ignore: true
responses:
"200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Internal" } } } }
components:
schemas:
Shared: { type: object, properties: { note: { type: string } } }
Internal:
x-crozier-ignore: true
type: object
properties: { note: { type: string } }
OrphanKept: { type: object, properties: { note: { type: string } } }
"##,
);
filter_ignored(&mut doc);
assert_eq!(op_ids(&doc), ["keepOp"]);
let mut kept = schema_keys(&doc);
kept.sort();
assert_eq!(kept, ["OrphanKept", "Shared"]);
}
#[test]
fn schema_level_ignore_keeps_the_schemas_only_it_referenced() {
let mut doc = parse(
r##"
openapi: 3.0.3
info: { title: T }
paths:
/widgets:
get:
operationId: getWidget
responses:
"200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Widget" } } } }
components:
schemas:
Widget: { type: object, properties: { id: { type: string } } }
LegacyWidget:
x-fern-ignore: true
type: object
properties: { part: { $ref: "#/components/schemas/LegacyPart" } }
LegacyPart: { type: object, properties: { serial: { type: string } } }
"##,
);
filter_ignored(&mut doc);
assert_eq!(schema_keys(&doc), ["Widget", "LegacyPart"]);
}
#[test]
fn ignore_is_a_no_op_without_any_markers() {
let mut doc = parse(
r##"
openapi: 3.0.3
info: { title: T }
paths:
/keep:
get: { operationId: keepOp, responses: { "200": { description: OK } } }
components:
schemas:
Standalone: { type: object, properties: { note: { type: string } } }
"##,
);
filter_ignored(&mut doc);
assert_eq!(op_ids(&doc), ["keepOp"]);
assert_eq!(schema_keys(&doc), ["Standalone"]);
}
#[test]
fn audiences_accessor_prefers_crozier_then_falls_back_to_fern() {
let both = parse(
r##"
openapi: 3.0.3
info: { title: T }
paths:
/a:
get:
operationId: a
x-crozier-audiences: [public]
x-fern-audiences: [internal]
responses: { "200": { description: OK } }
"##,
);
let op = both.paths["/a"].get.as_ref().unwrap();
assert_eq!(op.audiences(), ["public"]);
let fern_only = parse(
r##"
openapi: 3.0.3
info: { title: T }
paths:
/a:
get:
operationId: a
x-fern-audiences: [internal]
responses: { "200": { description: OK } }
"##,
);
let op = fern_only.paths["/a"].get.as_ref().unwrap();
assert_eq!(op.audiences(), ["internal"]);
}
#[test]
fn fern_audiences_filter_prunes_like_the_crozier_spelling() {
let mut doc = parse(
r##"
openapi: 3.0.3
info: { title: T }
paths:
/pub:
get:
operationId: pub
x-fern-audiences: [public]
responses:
"200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Pub" } } } }
/int:
get:
operationId: int
x-fern-audiences: [internal]
responses:
"200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Int" } } } }
components:
schemas:
Pub: { type: object, properties: { note: { type: string } } }
Int: { type: object, properties: { note: { type: string } } }
"##,
);
filter_by_audience(&mut doc, &["public".to_string()], false);
assert_eq!(op_ids(&doc), ["pub"]);
assert_eq!(schema_keys(&doc), ["Pub"]);
}
const REF_PARAMS_SPEC: &str = r##"
openapi: 3.0.0
info: { title: T }
paths:
/items/{itemId}:
parameters:
- $ref: "#/components/parameters/ItemId"
get:
operationId: getItem
parameters:
- $ref: "#/components/parameters/Verbose"
- name: inline
in: query
schema: { type: string }
responses:
"200": { description: OK }
components:
parameters:
ItemId: { name: itemId, in: path, required: true, schema: { type: string } }
Verbose: { name: verbose, in: query, required: false, schema: { type: boolean } }
"##;
#[test]
fn normalize_parameters_resolves_and_merges_refs() {
let mut doc = parse(REF_PARAMS_SPEC);
normalize_parameters(&mut doc);
let op = doc.paths["/items/{itemId}"].get.as_ref().unwrap();
let resolved: Vec<(&str, Option<ParameterLocation>)> = op
.parameters
.iter()
.map(|p| (p.name.as_str(), p.location))
.collect();
assert_eq!(
resolved,
[
("itemId", Some(ParameterLocation::Path)),
("verbose", Some(ParameterLocation::Query)),
("inline", Some(ParameterLocation::Query)),
]
);
assert!(doc.paths["/items/{itemId}"].parameters.is_empty());
}
#[test]
fn normalize_responses_resolves_component_refs() {
let mut doc = parse(
r##"
openapi: 3.0.0
info: { title: T }
paths:
/items:
get:
operationId: getItems
responses:
"200": { $ref: "#/components/responses/ItemsResponse" }
components:
responses:
ItemsResponse:
description: A list of items
content:
application/json: { schema: { $ref: "#/components/schemas/Items" } }
"##,
);
normalize_responses(&mut doc);
let op = doc.paths["/items"].get.as_ref().unwrap();
let ok = &op.responses["200"];
assert_eq!(ok.description.as_deref(), Some("A list of items"));
assert!(ok.content.contains_key("application/json"));
assert_eq!(
ok.reference.as_deref(),
Some("#/components/responses/ItemsResponse")
);
}
#[test]
fn a_pointer_inside_a_component_is_copied_where_it_is_used() {
let mut doc = parse(
r##"
openapi: 3.1.0
info: { title: T }
paths: {}
components:
schemas:
Page:
type: object
properties:
page:
type: object
properties:
kind: { type: string }
tags:
type: array
items: { type: string, enum: [a, b] }
extra:
type: object
additionalProperties: { type: integer }
closed:
type: object
additionalProperties: false
choice:
anyOf:
- type: object
properties:
label: { type: string }
- type: string
revision:
type: object
properties:
page: { $ref: '#/components/schemas/Page/properties/page' }
tag: { $ref: '#/components/schemas/Page/properties/tags/items' }
count: { $ref: '#/components/schemas/Page/properties/extra/additionalProperties' }
label: { $ref: '#/components/schemas/Page/properties/choice/anyOf/0/properties/label' }
member: { $ref: '#/components/schemas/Page/properties/choice/anyOf/1' }
open: { $ref: '#/components/schemas/Page/properties/closed/additionalProperties' }
missing: { $ref: '#/components/schemas/Page/properties/nothing' }
defs: { $ref: '#/components/schemas/Page/$defs/thing' }
whole: { $ref: '#/components/schemas/Page' }
again:
allOf:
- $ref: '#/components/schemas/Page/properties/choice'
nullable: true
description: the reply
Loop:
type: object
properties:
next: { $ref: '#/components/schemas/Loop/properties/next' }
"##,
);
normalize_schema_pointer_refs(&mut doc);
let revision = &doc.components.schemas["Page"].properties["revision"];
assert!(revision.properties["page"].properties.contains_key("kind"));
assert!(revision.properties["page"].reference.is_none());
assert!(revision.properties["tag"].enum_values.is_some());
assert_eq!(
revision.properties["count"]
.ty
.as_ref()
.and_then(TypeField::primary),
Some("integer")
);
assert_eq!(
revision.properties["label"]
.ty
.as_ref()
.and_then(TypeField::primary),
Some("string")
);
let member = &revision.properties["member"];
assert!(member.reference.is_none());
assert_eq!(
member.ty.as_ref().and_then(TypeField::primary),
Some("string")
);
assert_eq!(
member.ref_origin.as_deref(),
Some("#/components/schemas/Page/properties/choice/anyOf/1")
);
for kept in ["open", "missing", "defs", "whole"] {
assert!(
revision.properties[kept].reference.is_some(),
"{kept} should keep its $ref"
);
}
let again = &revision.properties["again"];
assert!(again.all_of.is_none());
assert_eq!(again.any_of.as_ref().map(Vec::len), Some(2));
assert_eq!(again.nullable, Some(true));
assert_eq!(again.description.as_deref(), Some("the reply"));
let next = &doc.components.schemas["Loop"].properties["next"];
assert!(next.reference.is_none());
assert!(next.ty.is_none() && next.properties.is_empty() && next.one_of.is_none());
}
#[test]
fn a_properties_pointer_walks_through_defs() {
let mut doc = parse(
r##"
openapi: 3.1.0
info: { title: T }
paths: {}
components:
schemas:
Named:
type: object
$defs:
top: { type: integer }
properties:
label:
type: string
$defs:
inner: { type: string }
odd:
type: string
$defs: true
Holder:
type: object
properties:
names:
type: array
items: { $ref: '#/components/schemas/Named/properties/label/$defs/inner' }
inner: { $ref: '#/components/schemas/Named/properties/label/$defs/inner' }
missing: { $ref: '#/components/schemas/Named/properties/label/$defs/absent' }
top: { $ref: '#/components/schemas/Named/$defs/top' }
"##,
);
assert!(doc.components.schemas["Named"].properties["odd"]
.defs
.is_empty());
normalize_schema_pointer_refs(&mut doc);
let holder = &doc.components.schemas["Holder"];
for copied in [
holder.properties["names"].items.as_deref().unwrap(),
&holder.properties["inner"],
] {
assert!(copied.reference.is_none());
assert_eq!(
copied.ty.as_ref().and_then(TypeField::primary),
Some("string")
);
}
for kept in ["missing", "top"] {
assert!(holder.properties[kept].reference.is_some(), "{kept}");
}
}
#[test]
fn a_nested_pointer_naming_nothing_is_unknown_except_as_an_array_item() {
let mut doc = parse(
r##"
openapi: 3.1.0
info: { title: T }
paths:
/holder:
post:
requestBody:
content:
application/json:
schema: { $ref: '#/components/schemas/Named/properties/absent' }
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/Named/properties/absent' }
components:
schemas:
Named:
type: object
properties:
label: { type: string }
Choice:
oneOf:
- type: object
properties:
code: { type: integer }
Holder:
type: object
properties:
ghost: { $ref: '#/components/schemas/Named/properties/absent', description: gone }
itemless: { $ref: '#/components/schemas/Named/items' }
names:
type: array
items: { $ref: '#/components/schemas/Named/properties/absent' }
byName:
type: object
additionalProperties: { $ref: '#/components/schemas/Named/properties/absent' }
merged:
description: kept
allOf:
- $ref: '#/components/schemas/Named/properties/absent'
beside:
type: object
allOf:
- $ref: '#/components/schemas/Named/properties/absent'
outOfRange: { $ref: '#/components/schemas/Choice/oneOf/3/properties/code' }
badIndex: { $ref: '#/components/schemas/Choice/oneOf/x/properties/code' }
noComposition: { $ref: '#/components/schemas/Named/anyOf/0/properties/code' }
member: { $ref: '#/components/schemas/Choice/oneOf/0' }
defs: { $ref: '#/components/schemas/Named/$defs/thing' }
trailing: { $ref: '#/components/schemas/Named/properties' }
whole: { $ref: '#/components/schemas/Named' }
undeclared: { $ref: '#/components/schemas/Missing/properties/absent' }
found: { $ref: '#/components/schemas/Choice/oneOf/0/properties/code' }
foreign: { $ref: '#/definitions/Named/properties/absent' }
"##,
);
normalize_schema_pointer_refs(&mut doc);
normalize_unresolved_schema_pointers(&mut doc);
let holder = &doc.components.schemas["Holder"];
for unknown in [
"ghost",
"itemless",
"outOfRange",
"badIndex",
"noComposition",
] {
let node = &holder.properties[unknown];
assert!(
node.reference.is_none() && node.unresolved_reference,
"{unknown}"
);
}
assert_eq!(
holder.properties["ghost"].description.as_deref(),
Some("gone")
);
let value = match &holder.properties["byName"].additional_properties {
Some(AdditionalProperties::Schema(value)) => value,
other => panic!("{other:?}"),
};
assert!(value.reference.is_none() && value.unresolved_reference);
let merged = &holder.properties["merged"];
assert!(merged.all_of.is_none() && merged.unresolved_reference);
assert_eq!(merged.description.as_deref(), Some("kept"));
let beside = &holder.properties["beside"];
assert!(!beside.unresolved_reference);
let member = &beside.all_of.as_ref().unwrap()[0];
assert!(member.reference.is_none() && member.unresolved_reference);
let names = holder.properties["names"].items.as_deref().unwrap();
assert!(names.reference.is_some() && !names.unresolved_reference);
for kept in [
"member",
"defs",
"trailing",
"whole",
"undeclared",
"foreign",
] {
let node = &holder.properties[kept];
assert!(
node.reference.is_some() && !node.unresolved_reference,
"{kept}"
);
}
assert!(
holder.properties["found"].reference.is_none()
&& !holder.properties["found"].unresolved_reference
);
let operation = doc.paths["/holder"].post.as_ref().unwrap();
let body = operation.request_body.as_ref().unwrap().content["application/json"]
.schema
.as_ref()
.unwrap();
assert!(body.reference.is_none() && body.unresolved_reference);
let response = operation.responses["200"].content["application/json"]
.schema
.as_ref()
.unwrap();
assert!(response.reference.is_none() && response.unresolved_reference);
}
#[test]
fn a_document_whose_pointers_all_resolve_is_untouched() {
let mut doc = parse(
r##"
openapi: 3.1.0
info: { title: T }
paths: {}
components:
schemas:
Named:
type: object
properties:
label: { type: string }
Holder:
type: object
properties:
whole: { $ref: '#/components/schemas/Named' }
"##,
);
normalize_unresolved_schema_pointers(&mut doc);
let whole = &doc.components.schemas["Holder"].properties["whole"];
assert_eq!(
whole.reference.as_deref(),
Some("#/components/schemas/Named")
);
}
#[test]
fn an_object_whose_required_is_not_a_list_is_unknown() {
let mut doc = parse(
r##"
openapi: 3.0.0
info: { title: T }
paths: {}
components:
schemas:
Request:
type: object
required: [data]
properties:
data:
type: object
properties:
kind: { type: string, required: true }
attributes:
description: the attributes
type: object
required: false
properties:
funded: { type: boolean }
counted: { properties: { n: { type: integer } }, required: 3 }
mapped: { type: object, required: { a: 1 } }
nulled: { type: object, required: null }
"##,
);
normalize_unlisted_required(&mut doc);
let data = &doc.components.schemas["Request"].properties["data"];
let attributes = &data.properties["attributes"];
assert!(attributes.ty.is_none() && attributes.properties.is_empty());
assert_eq!(attributes.description.as_deref(), Some("the attributes"));
assert!(attributes.required.is_unlisted());
assert!(data.properties["counted"].properties.is_empty());
assert!(data.properties["mapped"].required.is_unlisted());
let kind = &data.properties["kind"];
assert_eq!(
kind.ty.as_ref().and_then(TypeField::primary),
Some("string")
);
assert!(!kind.required.is_unlisted());
assert!(!data.properties["nulled"].required.is_unlisted());
assert!(data.required.is_empty());
assert_eq!(
&doc.components.schemas["Request"].required[..],
&["data".to_string()]
);
let mut merged = RequiredNames::Unlisted;
merged.push("id".to_string());
assert_eq!(merged, RequiredNames::from(vec!["id".to_string()]));
}
#[test]
fn enum_varnames_lose_the_prefix_every_name_shares() {
let doc = parse(
r##"
openapi: 3.0.0
info: { title: T }
paths: {}
components:
schemas:
OperationKind:
type: string
enum: [profiling, tracing]
x-enum-varnames: [OperationKindProfiling, OperationKindTracing]
Single:
type: string
enum: ["1.0"]
x-enum-varnames: [WatchEventSpecVersion10]
Uneven:
type: string
enum: [a, b]
x-enum-varnames: [Ab, A]
"##,
);
let names = |name: &str| {
doc.components.schemas[name]
.enum_member_names()
.map(|(value, name)| format!("{value}={name}"))
.collect::<Vec<_>>()
};
assert_eq!(
names("OperationKind"),
["profiling=Profiling", "tracing=Tracing"]
);
assert_eq!(names("Single"), ["1.0=WatchEventSpecVersion10"]);
assert_eq!(names("Uneven"), ["a=b"]);
}
#[test]
fn normalize_empty_compositions_drops_the_empty_list() {
let mut doc = parse(
r##"
openapi: 3.0.0
info: { title: T }
paths: {}
components:
schemas:
ApplicationCommandHandler:
type: integer
format: int32
oneOf: []
Wrapper:
type: object
properties:
handler:
allOf: []
anyOf: []
type: string
"##,
);
normalize_empty_compositions(&mut doc);
let handler = &doc.components.schemas["ApplicationCommandHandler"];
assert!(handler.one_of.is_none());
assert_eq!(
handler.ty.as_ref().and_then(TypeField::primary),
Some("integer")
);
let nested = &doc.components.schemas["Wrapper"].properties["handler"];
assert!(nested.all_of.is_none() && nested.any_of.is_none());
}
#[test]
fn normalize_sibling_one_of_constraints_reads_the_constraint_as_an_object() {
let mut doc = parse(
r##"
openapi: 3.0.0
info: { title: T }
paths: {}
components:
schemas:
RanNode:
type: object
required: [plmn]
properties:
plmn: { type: string }
gnb: { type: string, pattern: "^[0-9]+$" }
ngeNb: { type: string }
oneOf:
- required: [gnb, plmn]
- { required: [ngeNb, plmn], properties: { ngeNb: { type: string, description: Restated. } } }
Wrapper:
type: object
properties:
envelope:
properties:
content: { type: string }
hash: { type: string }
oneOf:
- required: [content, hash]
- required: [hash]
UnknownSibling:
type: object
properties: { a: { type: string } }
oneOf: [{ required: [a] }, { required: [b] }]
Retyped:
type: object
properties: { a: { type: string } }
oneOf: [{ properties: { a: { type: integer } } }, { required: [a] }]
Referenced:
type: object
properties: { a: { type: string } }
oneOf: [{ $ref: '#/components/schemas/RanNode' }, { required: [a] }]
Discriminated:
type: object
x-crozier-discriminated: true
properties: { a: { type: string } }
oneOf: [{ required: [a] }]
Scalar:
type: string
properties: { a: { type: string } }
oneOf: [{ required: [a] }]
Bare:
oneOf: [{ required: [a] }]
"##,
);
normalize_sibling_one_of_constraints(&mut doc);
let schemas = &doc.components.schemas;
let node = &schemas["RanNode"];
assert!(node.one_of.is_none());
assert_eq!(node.required.iter().collect::<Vec<_>>(), ["plmn"]);
let envelope = &schemas["Wrapper"].properties["envelope"];
assert!(envelope.one_of.is_none());
assert_eq!(envelope.required.iter().collect::<Vec<_>>(), ["hash"]);
for kept in [
"UnknownSibling",
"Retyped",
"Referenced",
"Discriminated",
"Scalar",
"Bare",
] {
assert!(schemas[kept].one_of.is_some(), "{kept} stays a union");
}
}
#[test]
fn normalize_boolean_property_objects_reads_the_object_as_unknown() {
let mut doc = parse(
r##"
openapi: 3.0.0
info: { title: T }
paths: {}
components:
schemas:
Validations:
type: object
properties:
additionalProperties:
oneOf:
- type: string
- type: object
description: Open.
properties:
additionalProperties: true
Listed:
type: object
properties:
tags: [a, b]
"##,
);
normalize_boolean_property_objects(&mut doc);
let members = doc.components.schemas["Validations"].properties["additionalProperties"]
.one_of
.as_ref()
.expect("the union stays");
assert_eq!(
members[1],
Schema {
description: Some("Open.".to_string()),
..Schema::default()
}
);
assert!(doc.components.schemas["Listed"]
.properties
.contains_key("tags"));
}
#[test]
fn only_unquoted_yaml_dates_are_recorded_as_timestamps() {
let text = "a: 2022-01-23T19:00:00.000Z\nb: \"1909-04-05\"\nc: {x: 2001-02-03, y: 'x'}\nid: v2022-01-01\n";
let found = unquoted_yaml_timestamps(text);
assert!(found.contains("2022-01-23T19:00:00.000Z"));
assert!(found.contains("2001-02-03"));
assert!(!found.contains("1909-04-05"));
assert_eq!(found.len(), 2);
}
#[test]
fn normalize_unresolvable_schema_refs_degrades_to_the_unknown_type() {
let mut doc = parse(
r##"
openapi: 3.0.0
info: { title: T }
paths:
/items:
get:
operationId: getItems
responses:
"200":
description: OK
content:
application/json: { schema: { $ref: "#/components/schemas/Missing" } }
components:
schemas:
Item:
type: object
properties:
known: { $ref: "#/components/schemas/Known" }
unknown: { $ref: "#/components/schemas/NeverDeclared" }
allOf:
- { $ref: "#/components/schemas/NeverDeclared" }
Known:
type: string
"##,
);
normalize_unresolvable_schema_refs(&mut doc);
let item = &doc.components.schemas["Item"];
assert_eq!(
item.properties["known"].reference.as_deref(),
Some("#/components/schemas/Known")
);
assert_eq!(item.properties["unknown"].reference, None);
assert_eq!(item.all_of.as_ref().unwrap()[0].reference, None);
let response = &doc.paths["/items"].get.as_ref().unwrap().responses["200"];
assert_eq!(
response.content["application/json"]
.schema
.as_ref()
.unwrap()
.reference,
None
);
}
#[test]
fn normalize_nullable_schema_refs_marks_every_reference_nullable() {
let mut doc = parse(
r##"
openapi: 3.0.0
info: { title: T }
components:
schemas:
Topic:
oneOf:
- { type: "null" }
- { type: string }
Solid:
type: string
Holder:
type: object
properties:
topic: { $ref: "#/components/schemas/Topic" }
solid: { $ref: "#/components/schemas/Solid" }
topics:
type: array
items: { $ref: "#/components/schemas/Topic" }
"##,
);
normalize_nullable_schema_refs(&mut doc);
let holder = &doc.components.schemas["Holder"];
assert_eq!(holder.properties["topic"].nullable, Some(true));
assert_eq!(holder.properties["solid"].nullable, None);
assert_eq!(
holder.properties["topics"]
.items
.as_ref()
.expect("array items")
.nullable,
Some(true)
);
}
#[test]
fn an_in_document_security_scheme_ref_resolves_to_the_scheme_it_names() {
let mut doc = parse(
r##"
openapi: 3.0.3
info: { title: T }
paths: {}
components:
securitySchemes:
ProbeScheme: { $ref: "#/components/securitySchemes/ProbeApiKey" }
ProbeApiKey: { type: apiKey, name: X-Probe-Key, in: header }
"##,
);
normalize_security_scheme_refs(&mut doc);
let resolved = &doc.components.security_schemes["ProbeScheme"];
assert_eq!(resolved.ty, SecuritySchemeType::ApiKey);
assert_eq!(resolved.name.as_deref(), Some("X-Probe-Key"));
assert_eq!(resolved.location, Some(ParameterLocation::Header));
assert!(
resolved.reference.is_none(),
"the resolved entry is the scheme itself, not a reference to it"
);
}
#[test]
fn a_security_scheme_ref_crozier_cannot_follow_is_left_alone() {
let mut doc = parse(
r##"
openapi: 3.0.3
info: { title: T }
paths: {}
components:
securitySchemes:
Across: { $ref: "../../openapi.yaml#/components/securitySchemes/Bearer" }
Chained: { $ref: "#/components/securitySchemes/Across" }
Itself: { $ref: "#/components/securitySchemes/Itself" }
Missing: { $ref: "#/components/securitySchemes/Absent" }
"##,
);
normalize_security_scheme_refs(&mut doc);
for name in ["Across", "Chained", "Itself", "Missing"] {
let scheme = &doc.components.security_schemes[name];
assert_eq!(
scheme.ty,
SecuritySchemeType::Other,
"{name} names no scheme crozier can import"
);
assert!(scheme.reference.is_some(), "{name} keeps its reference");
}
}
#[test]
fn a_local_ref_alias_keeps_its_name_in_a_response() {
let mut doc = parse(
r##"
openapi: 3.0.0
info: { title: T }
paths:
/block:
get:
operationId: getBlock
responses:
"200":
description: OK
content:
application/json: { schema: { $ref: "#/components/schemas/BlockResponse" } }
components:
schemas:
BlockResponse: { $ref: "#/components/schemas/Block" }
Block:
type: object
properties:
hash: { type: string }
Holder:
type: object
properties:
block: { $ref: "#/components/schemas/BlockResponse" }
"##,
);
normalize_fetched_response_alias_refs(&mut doc, &std::collections::BTreeSet::new());
let response = &doc.paths["/block"].get.as_ref().unwrap().responses["200"];
assert_eq!(
response.content["application/json"]
.schema
.as_ref()
.unwrap()
.reference
.as_deref(),
Some("#/components/schemas/BlockResponse")
);
assert_eq!(
doc.components.schemas["BlockResponse"].reference.as_deref(),
Some("#/components/schemas/Block")
);
}
#[test]
fn a_response_alias_of_a_fetched_schema_resolves_to_the_fetched_name() {
let mut doc = parse(
r##"
openapi: 3.0.0
info: { title: T }
paths:
/block:
get:
operationId: getBlock
responses:
"200":
description: OK
content:
application/json: { schema: { $ref: "#/components/schemas/BlockResponse" } }
components:
schemas:
BlockResponse: { $ref: "#/components/schemas/Block" }
Block:
type: object
properties:
hash: { type: string }
Holder:
type: object
properties:
block: { $ref: "#/components/schemas/BlockResponse" }
"##,
);
let remote_origin = std::collections::BTreeSet::from(["Block".to_string()]);
normalize_fetched_response_alias_refs(&mut doc, &remote_origin);
let response = &doc.paths["/block"].get.as_ref().unwrap().responses["200"];
assert_eq!(
response.content["application/json"]
.schema
.as_ref()
.unwrap()
.reference
.as_deref(),
Some("#/components/schemas/Block")
);
assert_eq!(
doc.components.schemas["Holder"].properties["block"]
.reference
.as_deref(),
Some("#/components/schemas/BlockResponse")
);
assert_eq!(
doc.components.schemas["BlockResponse"].reference.as_deref(),
Some("#/components/schemas/Block")
);
}
#[test]
fn normalize_parameters_operation_overrides_path_level() {
let mut doc = parse(
r##"
openapi: 3.0.0
info: { title: T }
paths:
/x/{id}:
parameters:
- { name: id, in: path, required: true, description: shared, schema: { type: string } }
get:
operationId: getX
parameters:
- { name: id, in: path, required: true, description: overridden, schema: { type: string } }
responses:
"200": { description: OK }
"##,
);
normalize_parameters(&mut doc);
let op = doc.paths["/x/{id}"].get.as_ref().unwrap();
assert_eq!(op.parameters.len(), 1);
assert_eq!(op.parameters[0].description.as_deref(), Some("overridden"));
}
#[test]
fn path_item_stays_small_enough_for_a_1mib_windows_stack() {
let path_item = std::mem::size_of::<PathItem>();
assert!(
path_item <= 8_192,
"PathItem grew to {path_item} bytes (Operation {}); serde's visit_map \
frame scales with it and overflows the 1 MiB Windows main stack. Box a \
large rarely-present field rather than raising this ceiling.",
std::mem::size_of::<Operation>()
);
}
}
#[cfg(test)]
mod refusal_name_tests {
#[test]
fn declared_parameter_names_obey_dual_header_precedence() {
for (text, expected) in [
("x-fern-parameter-name: fernName", Some("fernName")),
(
"x-crozier-parameter-name: crozierName\nx-fern-parameter-name: fernName",
Some("crozierName"),
),
(
"x-crozier-parameter-name: null\nx-fern-parameter-name: fernName",
Some("fernName"),
),
("type: string", None),
] {
let node = crate::yaml::from_str(text).unwrap();
assert_eq!(super::refusal_parameter_name(&node), expected);
}
}
#[test]
fn declared_property_names_obey_dual_header_precedence() {
for (text, expected) in [
("x-fern-property-name: fern_name", Some("fern_name")),
(
"x-crozier-property-name: crozier_name",
Some("crozier_name"),
),
(
"x-crozier-property-name: crozier_name\nx-fern-property-name: fern_name",
Some("crozier_name"),
),
(
"x-crozier-property-name: null\nx-fern-property-name: fern_name",
Some("fern_name"),
),
("x-fern-property-name: ' '", None),
("type: string", None),
] {
let node: serde_json::Value = crate::yaml::from_str(text).unwrap();
assert_eq!(super::refusal_property_name(&node), expected, "{text}");
let schema: super::Schema = serde_json::from_value(node).unwrap();
assert_eq!(schema.property_name(), expected, "{text}");
}
}
}
#[test]
fn parameter_extensions_prefer_the_canonical_spelling() {
let parameter: Parameter = serde_json::from_value(serde_json::json!({
"name": "wire", "in": "query",
"x-fern-ignore": true, "x-crozier-ignore": false,
"x-fern-parameter-name": "old", "x-crozier-parameter-name": "new",
"x-fern-default": "10", "x-crozier-default": "20",
"x-fern-sdk-variable": "oldVariable", "x-crozier-sdk-variable": "newVariable"
}))
.unwrap();
assert!(!parameter.ignored());
assert_eq!(parameter.sdk_name(), "new");
assert_eq!(parameter.sdk_default(), Some(&serde_json::json!("20")));
assert_eq!(parameter.sdk_variable(), Some("newVariable"));
let fallback: Parameter = serde_json::from_value(serde_json::json!({"name": "wire"})).unwrap();
assert_eq!(fallback.sdk_name(), "wire");
assert!(fallback.sdk_default().is_none());
assert!(fallback.sdk_variable().is_none());
}
#[test]
fn ignored_parameters_leave_operations_and_path_items() {
let mut doc: OpenApi = serde_json::from_value(serde_json::json!({
"openapi": "3.0.3", "paths": {"/signals": {
"parameters": [{"name": "old", "in": "query", "x-fern-ignore": true}],
"get": {"parameters": [
{"name": "removed", "in": "query", "x-crozier-ignore": true},
{"name": "kept", "in": "query", "x-fern-ignore": true, "x-crozier-ignore": false}
], "responses": {"200": {"description": "Signals"}}}
}}
}))
.unwrap();
filter_ignored(&mut doc);
let item = &doc.paths["/signals"];
assert!(item.parameters.is_empty());
let operation = item.operations()[0].1;
assert_eq!(operation.parameters.len(), 1);
assert_eq!(operation.parameters[0].name, "kept");
}