feat: generate OpenAPI from API contracts
This commit is contained in:
Generated
+21
@@ -175,7 +175,9 @@ dependencies = [
|
|||||||
"api-macros-impl",
|
"api-macros-impl",
|
||||||
"axum",
|
"axum",
|
||||||
"futures",
|
"futures",
|
||||||
|
"oas3",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
@@ -1086,6 +1088,7 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
|
"unicode-xid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2806,6 +2809,24 @@ version = "0.2.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
|
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oas3"
|
||||||
|
version = "0.22.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da5a5aa72eddcc53edfd06f287a2c10f872d88e8b72c650234cd8a227572424a"
|
||||||
|
dependencies = [
|
||||||
|
"derive_more",
|
||||||
|
"http",
|
||||||
|
"indexmap",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"regex",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oid-registry"
|
name = "oid-registry"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ publish = false
|
|||||||
[lib]
|
[lib]
|
||||||
proc-macro = true
|
proc-macro = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
openapi = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
proc-macro-crate = "3"
|
proc-macro-crate = "3"
|
||||||
proc-macro2 = "1"
|
proc-macro2 = "1"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ enum Method {
|
|||||||
struct AdapterConfig {
|
struct AdapterConfig {
|
||||||
reqwest: bool,
|
reqwest: bool,
|
||||||
axum: bool,
|
axum: bool,
|
||||||
|
openapi: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Parse for AdapterConfig {
|
impl Parse for AdapterConfig {
|
||||||
@@ -51,13 +52,14 @@ impl Parse for AdapterConfig {
|
|||||||
match adapter.to_string().as_str() {
|
match adapter.to_string().as_str() {
|
||||||
"reqwest" if !config.reqwest => config.reqwest = true,
|
"reqwest" if !config.reqwest => config.reqwest = true,
|
||||||
"axum" if !config.axum => config.axum = true,
|
"axum" if !config.axum => config.axum = true,
|
||||||
"reqwest" | "axum" => {
|
"openapi" if !config.openapi => config.openapi = true,
|
||||||
|
"reqwest" | "axum" | "openapi" => {
|
||||||
return Err(syn::Error::new(adapter.span(), "duplicate API adapter"));
|
return Err(syn::Error::new(adapter.span(), "duplicate API adapter"));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
adapter.span(),
|
adapter.span(),
|
||||||
"unsupported API adapter; expected `reqwest` or `axum`",
|
"unsupported API adapter; expected `reqwest`, `axum`, or `openapi`",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1270,10 +1272,94 @@ fn axum_adapter_tokens(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn openapi_adapter_tokens(
|
||||||
|
api: &ApiDefinition,
|
||||||
|
api_crate: &proc_macro2::TokenStream,
|
||||||
|
) -> proc_macro2::TokenStream {
|
||||||
|
if !api.adapters.openapi {
|
||||||
|
return proc_macro2::TokenStream::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let function_ident = format_ident!(
|
||||||
|
"{}_openapi",
|
||||||
|
to_snake_case(&api.item.ident),
|
||||||
|
span = api.item.ident.span()
|
||||||
|
);
|
||||||
|
let operations = api.operations.iter().map(|operation| {
|
||||||
|
let method = method_name(operation.method);
|
||||||
|
let path = &operation.path;
|
||||||
|
let operation_id = &operation.operation_id;
|
||||||
|
let parameters = operation.parameters.iter().filter_map(|parameter| {
|
||||||
|
if matches!(parameter.location, Location::Body) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ty = ¶meter.ty;
|
||||||
|
let name = ¶meter.wire_name;
|
||||||
|
let location = match parameter.location {
|
||||||
|
Location::Path => "path",
|
||||||
|
Location::Query => "query",
|
||||||
|
Location::Header => "header",
|
||||||
|
Location::Body => unreachable!(),
|
||||||
|
};
|
||||||
|
Some(quote! {
|
||||||
|
operation.parameter::<#ty>(#name, #location)?;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let request = operation.request_body.as_ref().map(|ty| {
|
||||||
|
quote! {
|
||||||
|
operation.request_body::<#ty>("application/json")?;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let status = operation.response_status;
|
||||||
|
let response = match &operation.response_body {
|
||||||
|
Some(ty) => quote! {
|
||||||
|
operation.response::<#ty>(#status, "application/json", "Successful response")?;
|
||||||
|
},
|
||||||
|
None => quote! {
|
||||||
|
operation.empty_response(#status, "Successful response")?;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let error = operation
|
||||||
|
.error_status
|
||||||
|
.zip(operation.error_body.as_ref())
|
||||||
|
.map(|(status, ty)| {
|
||||||
|
quote! {
|
||||||
|
operation.response::<#ty>(#status, "application/json", "Error response")?;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
{
|
||||||
|
let mut operation = builder.operation(#method, #path, #operation_id)?;
|
||||||
|
#(#parameters)*
|
||||||
|
#request
|
||||||
|
#response
|
||||||
|
#error
|
||||||
|
operation.finish()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
#[doc = "Build this trait's deterministic, deployment-independent OpenAPI 3.1 document."]
|
||||||
|
pub fn #function_ident(
|
||||||
|
info: #api_crate::openapi::OpenApiInfo<'_>,
|
||||||
|
) -> ::core::result::Result<
|
||||||
|
#api_crate::openapi::OpenApiDocument,
|
||||||
|
#api_crate::openapi::OpenApiError,
|
||||||
|
> {
|
||||||
|
let mut builder = #api_crate::openapi::OpenApiBuilder::new(info)?;
|
||||||
|
#(#operations)*
|
||||||
|
builder.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
||||||
let api_crate = api_crate_path()?;
|
let api_crate = api_crate_path()?;
|
||||||
let reqwest_adapter = reqwest_adapter_tokens(&api, &api_crate);
|
let reqwest_adapter = reqwest_adapter_tokens(&api, &api_crate);
|
||||||
let axum_adapter = axum_adapter_tokens(&api, &api_crate);
|
let axum_adapter = axum_adapter_tokens(&api, &api_crate);
|
||||||
|
let openapi_adapter = openapi_adapter_tokens(&api, &api_crate);
|
||||||
let item = api.item;
|
let item = api.item;
|
||||||
let metadata_ident = api.metadata_ident;
|
let metadata_ident = api.metadata_ident;
|
||||||
let module_ident = api.operations_module_ident;
|
let module_ident = api.operations_module_ident;
|
||||||
@@ -1353,6 +1439,7 @@ fn expand_api(api: ApiDefinition) -> syn::Result<proc_macro2::TokenStream> {
|
|||||||
|
|
||||||
#reqwest_adapter
|
#reqwest_adapter
|
||||||
#axum_adapter
|
#axum_adapter
|
||||||
|
#openapi_adapter
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ publish = false
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
axum = ["dep:axum", "dep:serde"]
|
axum = ["dep:axum", "dep:serde"]
|
||||||
|
openapi = ["dep:schemars", "dep:serde_json", "api-macros-impl/openapi"]
|
||||||
reqwest = [
|
reqwest = [
|
||||||
"dep:futures",
|
"dep:futures",
|
||||||
"dep:reqwest",
|
"dep:reqwest",
|
||||||
@@ -22,12 +23,14 @@ api-macros-impl = { path = "../api-macros-impl" }
|
|||||||
axum = { workspace = true, optional = true }
|
axum = { workspace = true, optional = true }
|
||||||
futures = { workspace = true, optional = true }
|
futures = { workspace = true, optional = true }
|
||||||
reqwest = { version = "0.13", default-features = false, optional = true, features = ["stream", "rustls"] }
|
reqwest = { version = "0.13", default-features = false, optional = true, features = ["stream", "rustls"] }
|
||||||
|
schemars = { version = "1.2", optional = true }
|
||||||
serde = { workspace = true, optional = true }
|
serde = { workspace = true, optional = true }
|
||||||
serde_json = { workspace = true, optional = true }
|
serde_json = { workspace = true, optional = true }
|
||||||
serde_urlencoded = { version = "0.7", optional = true }
|
serde_urlencoded = { version = "0.7", optional = true }
|
||||||
url = { version = "2", optional = true }
|
url = { version = "2", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
oas3 = { version = "0.22", default-features = false }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["net"] }
|
tokio = { workspace = true, features = ["net"] }
|
||||||
|
|||||||
@@ -59,6 +59,24 @@
|
|||||||
//! explicit variants for future transport work; accepting one requires a deliberate macro and
|
//! explicit variants for future transport work; accepting one requires a deliberate macro and
|
||||||
//! adapter change rather than silently treating it as JSON.
|
//! adapter change rather than silently treating it as JSON.
|
||||||
//!
|
//!
|
||||||
|
//! # OpenAPI 3.1 export
|
||||||
|
//!
|
||||||
|
//! With the `openapi` feature, `#[api(openapi)]` emits a `<trait_name>_openapi` function from the
|
||||||
|
//! same normalized operation model used by the HTTP adapters. The caller supplies title, version,
|
||||||
|
//! and an opaque source digest through [`openapi::OpenApiInfo`], then either serves
|
||||||
|
//! [`openapi::OpenApiDocument::to_json`] or writes exactly those canonical bytes with
|
||||||
|
//! [`openapi::OpenApiDocument::write_json`] from a CLI. Documents contain no server URLs,
|
||||||
|
//! environment names, timestamps, or other deployment topology.
|
||||||
|
//!
|
||||||
|
//! Every exposed wire type must implement [`openapi::OpenApiSchema`] as an explicit assertion that
|
||||||
|
//! its `schemars::JsonSchema` contract matches Serde serialization. The trait's naming hook is the
|
||||||
|
//! public component-name contract. Different schemas claiming one name, unsafe-width integer
|
||||||
|
//! schemas, and ambiguous non-null `anyOf`/untagged representations fail closed. Use bounded
|
||||||
|
//! integers (for example `u32`) for JSON numeric fields, and model optionality separately from
|
||||||
|
//! nullability through object `required` membership and nullable schemas. A normalized
|
||||||
|
//! `#[header("authorization")]` string (or optional string) is projected as an HTTP bearer
|
||||||
|
//! security scheme and operation requirement rather than as a raw header parameter.
|
||||||
|
//!
|
||||||
//! # Optional HTTP adapters
|
//! # Optional HTTP adapters
|
||||||
//!
|
//!
|
||||||
//! `#[api(reqwest)]` generates `TraitNameClient` and enables a typed Reqwest client when this
|
//! `#[api(reqwest)]` generates `TraitNameClient` and enables a typed Reqwest client when this
|
||||||
@@ -84,6 +102,8 @@ pub use api_macros_impl::api;
|
|||||||
|
|
||||||
#[cfg(feature = "axum")]
|
#[cfg(feature = "axum")]
|
||||||
pub mod axum;
|
pub mod axum;
|
||||||
|
#[cfg(feature = "openapi")]
|
||||||
|
pub mod openapi;
|
||||||
#[cfg(feature = "reqwest")]
|
#[cfg(feature = "reqwest")]
|
||||||
pub mod reqwest;
|
pub mod reqwest;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,571 @@
|
|||||||
|
//! Deterministic OpenAPI 3.1 projection for `#[api(openapi)]` contracts.
|
||||||
|
//!
|
||||||
|
//! The projection deliberately has no deployment topology (`servers`) and no
|
||||||
|
//! generated timestamps. Callers supply an opaque source digest, which is
|
||||||
|
//! retained as provenance in `info.x-yoi-source-digest`.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
|
collections::{BTreeMap, BTreeSet},
|
||||||
|
fmt, fs,
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use schemars::{self, JsonSchema};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
|
||||||
|
|
||||||
|
/// An explicit assertion that `JsonSchema` describes the type's serialized
|
||||||
|
/// wire representation.
|
||||||
|
///
|
||||||
|
/// OpenAPI-enabled APIs require this trait instead of accepting every
|
||||||
|
/// `JsonSchema` implementation implicitly. This makes unusual Serde behavior
|
||||||
|
/// (for example `flatten`, `untagged`, or a custom serializer) fail closed
|
||||||
|
/// until the type owner supplies and reviews a matching schema implementation.
|
||||||
|
/// The default component name delegates to [`JsonSchema::schema_name`]; types
|
||||||
|
/// may override it to provide a stable public wire name.
|
||||||
|
pub trait OpenApiSchema: JsonSchema {
|
||||||
|
/// Whether a parameter of this type must be present on the wire.
|
||||||
|
///
|
||||||
|
/// This semantic hook intentionally lives on the resolved type, so aliases
|
||||||
|
/// such as `type Header = Option<String>` retain optionality.
|
||||||
|
const PARAMETER_REQUIRED: bool = true;
|
||||||
|
/// Whether an `Authorization` header of this type is a bearer credential.
|
||||||
|
const BEARER_CREDENTIAL: bool = false;
|
||||||
|
|
||||||
|
fn openapi_schema_name() -> Cow<'static, str> {
|
||||||
|
Self::schema_name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_openapi_schema {
|
||||||
|
($($ty:ty),* $(,)?) => {
|
||||||
|
$(impl OpenApiSchema for $ty {})*
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_openapi_schema!((), bool, char, i8, i16, i32, u8, u16, u32, f32, f64);
|
||||||
|
|
||||||
|
impl OpenApiSchema for String {
|
||||||
|
const BEARER_CREDENTIAL: bool = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: OpenApiSchema> OpenApiSchema for Option<T> {
|
||||||
|
const PARAMETER_REQUIRED: bool = false;
|
||||||
|
const BEARER_CREDENTIAL: bool = T::BEARER_CREDENTIAL;
|
||||||
|
}
|
||||||
|
impl<T: OpenApiSchema> OpenApiSchema for Vec<T> {}
|
||||||
|
impl<T: OpenApiSchema> OpenApiSchema for Box<T> {}
|
||||||
|
impl<T: OpenApiSchema> OpenApiSchema for std::sync::Arc<T> {}
|
||||||
|
impl<T: OpenApiSchema> OpenApiSchema for BTreeMap<String, T> {}
|
||||||
|
|
||||||
|
/// Stable, deployment-independent document identity supplied by an exporter.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct OpenApiInfo<'a> {
|
||||||
|
pub title: &'a str,
|
||||||
|
pub version: &'a str,
|
||||||
|
/// Opaque digest of the source revision used to build the contract.
|
||||||
|
pub source_digest: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An immutable OpenAPI 3.1 document with canonical JSON rendering.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct OpenApiDocument(Value);
|
||||||
|
|
||||||
|
impl OpenApiDocument {
|
||||||
|
pub fn as_value(&self) -> &Value {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render pretty JSON with recursively sorted object keys and a final LF.
|
||||||
|
pub fn to_json(&self) -> Result<String, OpenApiError> {
|
||||||
|
let mut rendered = serde_json::to_string_pretty(&self.0)?;
|
||||||
|
rendered.push('\n');
|
||||||
|
Ok(rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the same canonical bytes returned by [`Self::to_json`].
|
||||||
|
pub fn write_json(&self, path: impl AsRef<Path>) -> Result<(), OpenApiError> {
|
||||||
|
fs::write(path, self.to_json()?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum OpenApiError {
|
||||||
|
InvalidContract(String),
|
||||||
|
Io(std::io::Error),
|
||||||
|
Json(serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for OpenApiError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidContract(message) => formatter.write_str(message),
|
||||||
|
Self::Io(error) => write!(formatter, "failed to export OpenAPI document: {error}"),
|
||||||
|
Self::Json(error) => write!(formatter, "failed to serialize OpenAPI document: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for OpenApiError {}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for OpenApiError {
|
||||||
|
fn from(error: std::io::Error) -> Self {
|
||||||
|
Self::Io(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for OpenApiError {
|
||||||
|
fn from(error: serde_json::Error) -> Self {
|
||||||
|
Self::Json(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder used by code generated for an `#[api(openapi)]` trait.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub struct OpenApiBuilder {
|
||||||
|
info: Value,
|
||||||
|
paths: BTreeMap<String, BTreeMap<String, Value>>,
|
||||||
|
schemas: BTreeMap<String, Value>,
|
||||||
|
operation_ids: BTreeSet<String>,
|
||||||
|
uses_bearer_auth: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenApiBuilder {
|
||||||
|
pub fn new(info: OpenApiInfo<'_>) -> Result<Self, OpenApiError> {
|
||||||
|
for (field, value) in [
|
||||||
|
("title", info.title),
|
||||||
|
("version", info.version),
|
||||||
|
("source digest", info.source_digest),
|
||||||
|
] {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"OpenAPI {field} must not be empty"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
info: json!({
|
||||||
|
"title": info.title,
|
||||||
|
"version": info.version,
|
||||||
|
"x-yoi-source-digest": info.source_digest,
|
||||||
|
}),
|
||||||
|
paths: BTreeMap::new(),
|
||||||
|
schemas: BTreeMap::new(),
|
||||||
|
operation_ids: BTreeSet::new(),
|
||||||
|
uses_bearer_auth: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn operation<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
method: &'static str,
|
||||||
|
path: &'static str,
|
||||||
|
operation_id: &'static str,
|
||||||
|
) -> Result<OpenApiOperation<'a>, OpenApiError> {
|
||||||
|
let method = method.to_ascii_lowercase();
|
||||||
|
if !matches!(
|
||||||
|
method.as_str(),
|
||||||
|
"get" | "post" | "put" | "patch" | "delete" | "head" | "options" | "trace"
|
||||||
|
) {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"unsupported OpenAPI method `{method}` for `{operation_id}`"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !self.operation_ids.insert(operation_id.to_owned()) {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"duplicate operationId `{operation_id}`"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.paths
|
||||||
|
.get(path)
|
||||||
|
.is_some_and(|operations| operations.contains_key(&method))
|
||||||
|
{
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"duplicate operation for {method} {path}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(OpenApiOperation {
|
||||||
|
parent: self,
|
||||||
|
method,
|
||||||
|
path: path.to_owned(),
|
||||||
|
operation_id,
|
||||||
|
parameters: Vec::new(),
|
||||||
|
request_body: None,
|
||||||
|
responses: BTreeMap::new(),
|
||||||
|
bearer_security_required: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish(self) -> Result<OpenApiDocument, OpenApiError> {
|
||||||
|
let paths = self
|
||||||
|
.paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|(path, operations)| {
|
||||||
|
let operations = operations.into_iter().collect::<Map<_, _>>();
|
||||||
|
(path, Value::Object(operations))
|
||||||
|
})
|
||||||
|
.collect::<Map<_, _>>();
|
||||||
|
let schemas = self.schemas.into_iter().collect::<Map<_, _>>();
|
||||||
|
let mut components = Map::new();
|
||||||
|
components.insert("schemas".to_owned(), Value::Object(schemas));
|
||||||
|
if self.uses_bearer_auth {
|
||||||
|
components.insert(
|
||||||
|
"securitySchemes".to_owned(),
|
||||||
|
json!({
|
||||||
|
"bearerAuth": {
|
||||||
|
"type": "http",
|
||||||
|
"scheme": "bearer"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let document = canonicalize(json!({
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": self.info,
|
||||||
|
"paths": paths,
|
||||||
|
"components": components,
|
||||||
|
}));
|
||||||
|
validate_component_references(&document)?;
|
||||||
|
Ok(OpenApiDocument(document))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema_ref<T: OpenApiSchema>(&mut self) -> Result<Value, OpenApiError> {
|
||||||
|
let name = T::openapi_schema_name().into_owned();
|
||||||
|
validate_component_name(&name)?;
|
||||||
|
|
||||||
|
let mut schema = serde_json::to_value(schemars::schema_for!(T))?;
|
||||||
|
let definitions = schema
|
||||||
|
.as_object_mut()
|
||||||
|
.and_then(|object| object.remove("$defs"))
|
||||||
|
.unwrap_or_else(|| json!({}));
|
||||||
|
if let Some(object) = schema.as_object_mut() {
|
||||||
|
object.remove("$schema");
|
||||||
|
object.remove("title");
|
||||||
|
}
|
||||||
|
rewrite_definition_refs(&mut schema);
|
||||||
|
validate_schema(&schema, &name)?;
|
||||||
|
self.insert_schema(name.clone(), canonicalize(schema))?;
|
||||||
|
|
||||||
|
let definitions = definitions.as_object().ok_or_else(|| {
|
||||||
|
OpenApiError::InvalidContract(format!(
|
||||||
|
"schema `{name}` emitted non-object JSON Schema definitions"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
for (definition_name, definition) in definitions {
|
||||||
|
validate_component_name(definition_name)?;
|
||||||
|
let mut definition = definition.clone();
|
||||||
|
rewrite_definition_refs(&mut definition);
|
||||||
|
validate_schema(&definition, definition_name)?;
|
||||||
|
self.insert_schema(definition_name.clone(), canonicalize(definition))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({ "$ref": format!("#/components/schemas/{name}") }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_schema(&mut self, name: String, schema: Value) -> Result<(), OpenApiError> {
|
||||||
|
match self.schemas.get(&name) {
|
||||||
|
Some(existing) if existing != &schema => Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"OpenAPI component name collision for `{name}`"
|
||||||
|
))),
|
||||||
|
Some(_) => Ok(()),
|
||||||
|
None => {
|
||||||
|
self.schemas.insert(name, schema);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One operation under construction. Generated code adds its complete wire
|
||||||
|
/// metadata before atomically inserting it into the parent document.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub struct OpenApiOperation<'a> {
|
||||||
|
parent: &'a mut OpenApiBuilder,
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
operation_id: &'static str,
|
||||||
|
parameters: Vec<Value>,
|
||||||
|
request_body: Option<Value>,
|
||||||
|
responses: BTreeMap<String, Value>,
|
||||||
|
bearer_security_required: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenApiOperation<'_> {
|
||||||
|
pub fn parameter<T: OpenApiSchema>(
|
||||||
|
&mut self,
|
||||||
|
name: &'static str,
|
||||||
|
location: &'static str,
|
||||||
|
) -> Result<(), OpenApiError> {
|
||||||
|
if !matches!(location, "path" | "query" | "header") {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"unsupported parameter location `{location}` for `{}`",
|
||||||
|
self.operation_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let required = location == "path" || T::PARAMETER_REQUIRED;
|
||||||
|
if location == "header" && name.eq_ignore_ascii_case("authorization") {
|
||||||
|
if !T::BEARER_CREDENTIAL {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"Authorization header for `{}` must use a bearer credential schema",
|
||||||
|
self.operation_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if self.bearer_security_required.replace(required).is_some() {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"multiple Authorization headers for `{}`",
|
||||||
|
self.operation_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.parent.uses_bearer_auth = true;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let schema = self.parent.schema_ref::<T>()?;
|
||||||
|
self.parameters.push(json!({
|
||||||
|
"name": name,
|
||||||
|
"in": location,
|
||||||
|
"required": required,
|
||||||
|
"schema": schema,
|
||||||
|
"style": if location == "query" { "form" } else { "simple" },
|
||||||
|
"explode": location == "query",
|
||||||
|
}));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_body<T: OpenApiSchema>(
|
||||||
|
&mut self,
|
||||||
|
content_type: &'static str,
|
||||||
|
) -> Result<(), OpenApiError> {
|
||||||
|
if self.request_body.is_some() {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"multiple request bodies for `{}`",
|
||||||
|
self.operation_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let schema = self.parent.schema_ref::<T>()?;
|
||||||
|
self.request_body = Some(json!({
|
||||||
|
"required": true,
|
||||||
|
"content": { content_type: { "schema": schema } },
|
||||||
|
}));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn response<T: OpenApiSchema>(
|
||||||
|
&mut self,
|
||||||
|
status: u16,
|
||||||
|
content_type: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
) -> Result<(), OpenApiError> {
|
||||||
|
let schema = self.parent.schema_ref::<T>()?;
|
||||||
|
self.insert_response(
|
||||||
|
status,
|
||||||
|
json!({
|
||||||
|
"description": description,
|
||||||
|
"content": { content_type: { "schema": schema } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn empty_response(
|
||||||
|
&mut self,
|
||||||
|
status: u16,
|
||||||
|
description: &'static str,
|
||||||
|
) -> Result<(), OpenApiError> {
|
||||||
|
self.insert_response(status, json!({ "description": description }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_response(&mut self, status: u16, response: Value) -> Result<(), OpenApiError> {
|
||||||
|
if !(100..=599).contains(&status) {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"invalid HTTP response status `{status}` for `{}`",
|
||||||
|
self.operation_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let status = status.to_string();
|
||||||
|
match self.responses.get(&status) {
|
||||||
|
Some(existing) if existing != &response => Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"conflicting response status `{status}` for `{}`",
|
||||||
|
self.operation_id
|
||||||
|
))),
|
||||||
|
Some(_) => Ok(()),
|
||||||
|
None => {
|
||||||
|
self.responses.insert(status, response);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish(self) -> Result<(), OpenApiError> {
|
||||||
|
if self.responses.is_empty() {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"operation `{}` has no responses",
|
||||||
|
self.operation_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let responses = self.responses.into_iter().collect::<Map<_, _>>();
|
||||||
|
let mut operation = Map::new();
|
||||||
|
operation.insert("operationId".to_owned(), json!(self.operation_id));
|
||||||
|
operation.insert("responses".to_owned(), Value::Object(responses));
|
||||||
|
if !self.parameters.is_empty() {
|
||||||
|
operation.insert("parameters".to_owned(), Value::Array(self.parameters));
|
||||||
|
}
|
||||||
|
if let Some(request_body) = self.request_body {
|
||||||
|
operation.insert("requestBody".to_owned(), request_body);
|
||||||
|
}
|
||||||
|
if let Some(required) = self.bearer_security_required {
|
||||||
|
operation.insert(
|
||||||
|
"security".to_owned(),
|
||||||
|
if required {
|
||||||
|
json!([{ "bearerAuth": [] }])
|
||||||
|
} else {
|
||||||
|
json!([{}, { "bearerAuth": [] }])
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.parent
|
||||||
|
.paths
|
||||||
|
.entry(self.path)
|
||||||
|
.or_default()
|
||||||
|
.insert(self.method, Value::Object(operation));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_component_name(name: &str) -> Result<(), OpenApiError> {
|
||||||
|
if name.is_empty()
|
||||||
|
|| !name
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||||
|
{
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"OpenAPI component name `{name}` must match [A-Za-z0-9._-]+"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_component_references(document: &Value) -> Result<(), OpenApiError> {
|
||||||
|
let schemas = document["components"]["schemas"]
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| OpenApiError::InvalidContract("missing component schema map".to_owned()))?;
|
||||||
|
let mut references = Vec::new();
|
||||||
|
collect_references(document, &mut references);
|
||||||
|
for reference in references {
|
||||||
|
let Some(name) = reference.strip_prefix("#/components/schemas/") else {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"unsupported non-component schema reference `{reference}`"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
if !schemas.contains_key(name) {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"unresolved OpenAPI component reference `{reference}`"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_references<'a>(value: &'a Value, references: &mut Vec<&'a str>) {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
|
||||||
|
references.push(reference);
|
||||||
|
}
|
||||||
|
for value in object.values() {
|
||||||
|
collect_references(value, references);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(values) => {
|
||||||
|
for value in values {
|
||||||
|
collect_references(value, references);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rewrite_definition_refs(value: &mut Value) {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
if let Some(Value::String(reference)) = object.get_mut("$ref") {
|
||||||
|
if let Some(name) = reference.strip_prefix("#/$defs/") {
|
||||||
|
*reference = format!("#/components/schemas/{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for value in object.values_mut() {
|
||||||
|
rewrite_definition_refs(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(values) => values.iter_mut().for_each(rewrite_definition_refs),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_schema(value: &Value, component: &str) -> Result<(), OpenApiError> {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
if object.get("type").and_then(Value::as_str) == Some("integer") {
|
||||||
|
let safe_format = matches!(
|
||||||
|
object.get("format").and_then(Value::as_str),
|
||||||
|
Some("int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32")
|
||||||
|
);
|
||||||
|
let minimum = object.get("minimum").and_then(Value::as_f64);
|
||||||
|
let maximum = object.get("maximum").and_then(Value::as_f64);
|
||||||
|
if !safe_format
|
||||||
|
&& (minimum.is_none_or(|value| value < -(MAX_SAFE_INTEGER as f64))
|
||||||
|
|| maximum.is_none_or(|value| value > MAX_SAFE_INTEGER as f64))
|
||||||
|
{
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"integer schema in `{component}` exceeds the JSON safe-integer range; expose a bounded wire integer"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A non-null anyOf is commonly emitted for Serde's untagged enum
|
||||||
|
// representation. Its branch-selection semantics are not lossless
|
||||||
|
// enough for this projection. Nullable schemas remain supported.
|
||||||
|
if let Some(Value::Array(branches)) = object.get("anyOf") {
|
||||||
|
let null_branches = branches
|
||||||
|
.iter()
|
||||||
|
.filter(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
|
||||||
|
.count();
|
||||||
|
if null_branches != 1 || branches.len() != 2 {
|
||||||
|
return Err(OpenApiError::InvalidContract(format!(
|
||||||
|
"schema `{component}` uses unsupported ambiguous anyOf/untagged semantics"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for value in object.values() {
|
||||||
|
validate_schema(value, component)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(values) => {
|
||||||
|
for value in values {
|
||||||
|
validate_schema(value, component)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonicalize(value: Value) -> Value {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
let sorted = object
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| (key, canonicalize(value)))
|
||||||
|
.collect::<BTreeMap<_, _>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
Value::Object(sorted)
|
||||||
|
}
|
||||||
|
Value::Array(values) => Value::Array(values.into_iter().map(canonicalize).collect()),
|
||||||
|
scalar => scalar,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
#![cfg(feature = "openapi")]
|
||||||
|
#![allow(async_fn_in_trait, dead_code)]
|
||||||
|
|
||||||
|
use api_macros::{
|
||||||
|
api,
|
||||||
|
openapi::{OpenApiInfo, OpenApiSchema},
|
||||||
|
};
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
fn nullable_string_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||||
|
generator.subschema_for::<Option<String>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
|
||||||
|
pub struct GeoPoint {
|
||||||
|
latitude: f64,
|
||||||
|
longitude: f64,
|
||||||
|
}
|
||||||
|
impl OpenApiSchema for GeoPoint {}
|
||||||
|
|
||||||
|
/// A custom Serde wire representation admitted only after an explicit,
|
||||||
|
/// matching schema hook and `OpenApiSchema` attestation.
|
||||||
|
#[derive(Debug, JsonSchema)]
|
||||||
|
#[schemars(transparent)]
|
||||||
|
pub struct WireSlug(String);
|
||||||
|
|
||||||
|
impl Serialize for WireSlug {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for WireSlug {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
String::deserialize(deserializer).map(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl OpenApiSchema for WireSlug {}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
|
||||||
|
pub struct CreateWidget {
|
||||||
|
#[schemars(length(min = 1, max = 64))]
|
||||||
|
name: String,
|
||||||
|
#[schemars(range(min = 1, max = 1_000))]
|
||||||
|
count: u32,
|
||||||
|
#[schemars(range(min = -100, max = 100))]
|
||||||
|
bounded_balance: i64,
|
||||||
|
#[schemars(length(min = 1, max = 8))]
|
||||||
|
tags: Vec<String>,
|
||||||
|
slug: WireSlug,
|
||||||
|
/// Missing and explicit null are separate wire states.
|
||||||
|
#[schemars(default, schema_with = "nullable_string_schema")]
|
||||||
|
optional_label: Option<String>,
|
||||||
|
#[schemars(required, schema_with = "nullable_string_schema")]
|
||||||
|
required_nullable_label: Option<String>,
|
||||||
|
location: GeoPoint,
|
||||||
|
}
|
||||||
|
impl OpenApiSchema for CreateWidget {}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
|
||||||
|
pub struct Lookup {
|
||||||
|
detailed: bool,
|
||||||
|
}
|
||||||
|
impl OpenApiSchema for Lookup {}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
|
||||||
|
pub struct Widget {
|
||||||
|
id: u32,
|
||||||
|
request: CreateWidget,
|
||||||
|
}
|
||||||
|
impl OpenApiSchema for Widget {}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
|
||||||
|
#[serde(tag = "code", content = "details")]
|
||||||
|
pub enum PublicError {
|
||||||
|
Invalid { message: String },
|
||||||
|
}
|
||||||
|
impl OpenApiSchema for PublicError {}
|
||||||
|
|
||||||
|
type OptionalRequestId = Option<String>;
|
||||||
|
|
||||||
|
#[api(openapi)]
|
||||||
|
pub trait FixtureApi {
|
||||||
|
#[post(
|
||||||
|
"/widgets/{widget_id}",
|
||||||
|
operation_id = "widgets.create",
|
||||||
|
status = 201,
|
||||||
|
error_status = 422
|
||||||
|
)]
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
#[path] widget_id: u32,
|
||||||
|
#[query] lookup: Lookup,
|
||||||
|
#[header("authorization")] authorization: String,
|
||||||
|
#[header("x-request-id")] request_id: OptionalRequestId,
|
||||||
|
#[body] request: CreateWidget,
|
||||||
|
) -> Result<Widget, PublicError>;
|
||||||
|
|
||||||
|
#[delete("/widgets/{widget_id}", operation_id = "widgets.delete", status = 204)]
|
||||||
|
async fn delete(&self, #[path] widget_id: u32) -> ();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document() -> api_macros::openapi::OpenApiDocument {
|
||||||
|
fixture_api_openapi(OpenApiInfo {
|
||||||
|
title: "Fixture API",
|
||||||
|
version: "1.2.3",
|
||||||
|
source_digest: "git:0123456789abcdef",
|
||||||
|
})
|
||||||
|
.expect("valid OpenAPI projection")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_is_deterministic_and_accepted_by_an_independent_parser() {
|
||||||
|
let first = document().to_json().expect("render document");
|
||||||
|
let second = document().to_json().expect("render document again");
|
||||||
|
assert_eq!(first.as_bytes(), second.as_bytes());
|
||||||
|
|
||||||
|
let parsed = oas3::from_json(&first).expect("oas3 independently parses generated document");
|
||||||
|
assert_eq!(
|
||||||
|
parsed
|
||||||
|
.validate_version()
|
||||||
|
.expect("valid OpenAPI version")
|
||||||
|
.major,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
let value: Value = serde_json::from_str(&first).expect("valid JSON");
|
||||||
|
assert_eq!(value["openapi"], "3.1.0");
|
||||||
|
assert_eq!(value["info"]["x-yoi-source-digest"], "git:0123456789abcdef");
|
||||||
|
assert!(
|
||||||
|
value.get("servers").is_none(),
|
||||||
|
"deployment topology is omitted"
|
||||||
|
);
|
||||||
|
assert!(!first.contains("generatedAt"));
|
||||||
|
assert!(!first.contains("#/$defs/"));
|
||||||
|
|
||||||
|
let schemas = value["components"]["schemas"]
|
||||||
|
.as_object()
|
||||||
|
.expect("component map");
|
||||||
|
let mut references = Vec::new();
|
||||||
|
collect_schema_references(&value, &mut references);
|
||||||
|
assert!(!references.is_empty());
|
||||||
|
for reference in references {
|
||||||
|
let name = reference
|
||||||
|
.strip_prefix("#/components/schemas/")
|
||||||
|
.expect("only local component references are emitted");
|
||||||
|
assert!(
|
||||||
|
schemas.contains_key(name),
|
||||||
|
"unresolved reference {reference}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_schema_references<'a>(value: &'a Value, references: &mut Vec<&'a str>) {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
|
||||||
|
references.push(reference);
|
||||||
|
}
|
||||||
|
for value in object.values() {
|
||||||
|
collect_schema_references(value, references);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(values) => {
|
||||||
|
for value in values {
|
||||||
|
collect_schema_references(value, references);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operations_and_components_preserve_the_wire_contract() {
|
||||||
|
let value = document().as_value().clone();
|
||||||
|
let create = &value["paths"]["/widgets/{widget_id}"]["post"];
|
||||||
|
assert_eq!(create["operationId"], "widgets.create");
|
||||||
|
assert_eq!(create["security"], json!([{ "bearerAuth": [] }]));
|
||||||
|
assert_eq!(
|
||||||
|
create["responses"]["201"]["content"]["application/json"]["schema"],
|
||||||
|
json!({
|
||||||
|
"$ref": "#/components/schemas/Widget"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
create["responses"]["422"]["content"]["application/json"]["schema"],
|
||||||
|
json!({
|
||||||
|
"$ref": "#/components/schemas/PublicError"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
create["requestBody"]["content"]["application/json"]["schema"],
|
||||||
|
json!({ "$ref": "#/components/schemas/CreateWidget" })
|
||||||
|
);
|
||||||
|
|
||||||
|
let parameters = create["parameters"].as_array().expect("parameter list");
|
||||||
|
assert_eq!(parameters.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
(parameters[0]["name"].as_str(), parameters[0]["in"].as_str()),
|
||||||
|
(Some("widget_id"), Some("path"))
|
||||||
|
);
|
||||||
|
assert_eq!(parameters[0]["required"], true);
|
||||||
|
assert_eq!(
|
||||||
|
(parameters[1]["name"].as_str(), parameters[1]["in"].as_str()),
|
||||||
|
(Some("lookup"), Some("query"))
|
||||||
|
);
|
||||||
|
assert_eq!(parameters[1]["explode"], true);
|
||||||
|
assert_eq!(
|
||||||
|
(parameters[2]["name"].as_str(), parameters[2]["in"].as_str()),
|
||||||
|
(Some("x-request-id"), Some("header"))
|
||||||
|
);
|
||||||
|
assert_eq!(parameters[2]["required"], false);
|
||||||
|
|
||||||
|
let schemas = value["components"]["schemas"]
|
||||||
|
.as_object()
|
||||||
|
.expect("components");
|
||||||
|
assert_eq!(
|
||||||
|
value["components"]["securitySchemes"]["bearerAuth"],
|
||||||
|
json!({ "type": "http", "scheme": "bearer" })
|
||||||
|
);
|
||||||
|
for name in [
|
||||||
|
"CreateWidget",
|
||||||
|
"GeoPoint",
|
||||||
|
"Lookup",
|
||||||
|
"PublicError",
|
||||||
|
"Widget",
|
||||||
|
"WireSlug",
|
||||||
|
"uint32",
|
||||||
|
"Nullable_string",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
schemas.contains_key(name),
|
||||||
|
"missing stable schema component {name}; found {:?}",
|
||||||
|
schemas.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
schemas["CreateWidget"]["properties"]["location"]["$ref"],
|
||||||
|
"#/components/schemas/GeoPoint"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
schemas["CreateWidget"]["properties"]["slug"]["$ref"],
|
||||||
|
"#/components/schemas/WireSlug"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
schemas["CreateWidget"]["properties"]["name"]["minLength"],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
schemas["CreateWidget"]["properties"]["name"]["maxLength"],
|
||||||
|
64
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
schemas["CreateWidget"]["properties"]["count"]["maximum"],
|
||||||
|
1_000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
schemas["CreateWidget"]["properties"]["bounded_balance"]["minimum"],
|
||||||
|
-100
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
schemas["CreateWidget"]["properties"]["bounded_balance"]["maximum"],
|
||||||
|
100
|
||||||
|
);
|
||||||
|
assert_eq!(schemas["CreateWidget"]["properties"]["tags"]["minItems"], 1);
|
||||||
|
assert_eq!(schemas["CreateWidget"]["properties"]["tags"]["maxItems"], 8);
|
||||||
|
assert!(schemas["PublicError"]["oneOf"].is_array());
|
||||||
|
|
||||||
|
let required = schemas["CreateWidget"]["required"]
|
||||||
|
.as_array()
|
||||||
|
.expect("required fields");
|
||||||
|
assert!(!required.contains(&json!("optional_label")));
|
||||||
|
assert!(required.contains(&json!("required_nullable_label")));
|
||||||
|
for field in ["optional_label", "required_nullable_label"] {
|
||||||
|
assert!(
|
||||||
|
schemas["CreateWidget"]["properties"][field]["type"]
|
||||||
|
.as_array()
|
||||||
|
.expect("nullable type union")
|
||||||
|
.contains(&json!("null"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let delete = &value["paths"]["/widgets/{widget_id}"]["delete"];
|
||||||
|
assert_eq!(delete["operationId"], "widgets.delete");
|
||||||
|
assert!(delete.get("security").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
delete["responses"]["204"],
|
||||||
|
json!({ "description": "Successful response" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, JsonSchema)]
|
||||||
|
pub struct FirstCollision {
|
||||||
|
first: String,
|
||||||
|
}
|
||||||
|
impl OpenApiSchema for FirstCollision {
|
||||||
|
fn openapi_schema_name() -> std::borrow::Cow<'static, str> {
|
||||||
|
"SharedWireName".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, JsonSchema)]
|
||||||
|
pub struct SecondCollision {
|
||||||
|
second: bool,
|
||||||
|
}
|
||||||
|
impl OpenApiSchema for SecondCollision {
|
||||||
|
fn openapi_schema_name() -> std::borrow::Cow<'static, str> {
|
||||||
|
"SharedWireName".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, JsonSchema)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum AmbiguousWireEnum {
|
||||||
|
Text(String),
|
||||||
|
Flag(bool),
|
||||||
|
}
|
||||||
|
impl OpenApiSchema for AmbiguousWireEnum {}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DanglingReference;
|
||||||
|
|
||||||
|
impl JsonSchema for DanglingReference {
|
||||||
|
fn schema_name() -> std::borrow::Cow<'static, str> {
|
||||||
|
"DanglingReference".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||||
|
schemars::json_schema!({ "$ref": "#/$defs/Missing" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl OpenApiSchema for DanglingReference {}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_name_collisions_and_ambiguous_wire_shapes_fail_closed() {
|
||||||
|
use api_macros::openapi::{OpenApiBuilder, OpenApiError};
|
||||||
|
|
||||||
|
let info = OpenApiInfo {
|
||||||
|
title: "Rejected fixture",
|
||||||
|
version: "1",
|
||||||
|
source_digest: "git:bad",
|
||||||
|
};
|
||||||
|
let mut collision = OpenApiBuilder::new(info).expect("builder");
|
||||||
|
{
|
||||||
|
let mut operation = collision
|
||||||
|
.operation("GET", "/first", "first")
|
||||||
|
.expect("operation");
|
||||||
|
operation
|
||||||
|
.response::<FirstCollision>(200, "application/json", "ok")
|
||||||
|
.expect("first schema");
|
||||||
|
operation.finish().expect("first operation");
|
||||||
|
}
|
||||||
|
let error = collision
|
||||||
|
.operation("GET", "/second", "second")
|
||||||
|
.expect("operation")
|
||||||
|
.response::<SecondCollision>(200, "application/json", "ok")
|
||||||
|
.expect_err("different schemas must not share a public name");
|
||||||
|
assert!(matches!(error, OpenApiError::InvalidContract(_)));
|
||||||
|
assert!(error.to_string().contains("component name collision"));
|
||||||
|
|
||||||
|
let mut ambiguous = OpenApiBuilder::new(info).expect("builder");
|
||||||
|
let error = ambiguous
|
||||||
|
.operation("GET", "/ambiguous", "ambiguous")
|
||||||
|
.expect("operation")
|
||||||
|
.response::<AmbiguousWireEnum>(200, "application/json", "ok")
|
||||||
|
.expect_err("untagged/ambiguous unions must fail closed");
|
||||||
|
assert!(error.to_string().contains("untagged"));
|
||||||
|
|
||||||
|
let mut dangling = OpenApiBuilder::new(info).expect("builder");
|
||||||
|
{
|
||||||
|
let mut operation = dangling
|
||||||
|
.operation("GET", "/dangling", "dangling")
|
||||||
|
.expect("operation");
|
||||||
|
operation
|
||||||
|
.response::<DanglingReference>(200, "application/json", "ok")
|
||||||
|
.expect("schema registration succeeds before graph validation");
|
||||||
|
operation.finish().expect("operation");
|
||||||
|
}
|
||||||
|
let error = dangling
|
||||||
|
.finish()
|
||||||
|
.expect_err("dangling component references must fail closed");
|
||||||
|
assert!(error.to_string().contains("unresolved"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_export_writes_the_canonical_document() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"yoi-openapi-{}-{}.json",
|
||||||
|
std::process::id(),
|
||||||
|
std::thread::current().name().unwrap_or("test")
|
||||||
|
));
|
||||||
|
let document = document();
|
||||||
|
document.write_json(&path).expect("export OpenAPI file");
|
||||||
|
let written = std::fs::read_to_string(&path).expect("read export");
|
||||||
|
std::fs::remove_file(path).expect("remove export");
|
||||||
|
assert_eq!(written, document.to_json().expect("canonical JSON"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user