From f513cfca109050eb74d67b916f92b8706f909ec0 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 05:29:13 +0900 Subject: [PATCH 1/3] feat(api): generate deterministic OpenAPI 3.1 contracts --- Cargo.lock | 21 ++ crates/api-macros-impl/Cargo.toml | 4 + crates/api-macros-impl/src/lib.rs | 104 ++++++- crates/api-macros/Cargo.toml | 3 + crates/api-macros/src/lib.rs | 18 ++ crates/api-macros/src/openapi.rs | 477 +++++++++++++++++++++++++++++ crates/api-macros/tests/openapi.rs | 271 ++++++++++++++++ 7 files changed, 896 insertions(+), 2 deletions(-) create mode 100644 crates/api-macros/src/openapi.rs create mode 100644 crates/api-macros/tests/openapi.rs diff --git a/Cargo.lock b/Cargo.lock index 5156ef43c..c260073b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,7 +175,9 @@ dependencies = [ "api-macros-impl", "axum", "futures", + "oas3", "reqwest", + "schemars", "serde", "serde_json", "serde_urlencoded", @@ -1084,6 +1086,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -2804,6 +2807,24 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "oid-registry" version = "0.7.1" diff --git a/crates/api-macros-impl/Cargo.toml b/crates/api-macros-impl/Cargo.toml index 52c228754..bdfd51553 100644 --- a/crates/api-macros-impl/Cargo.toml +++ b/crates/api-macros-impl/Cargo.toml @@ -8,6 +8,10 @@ publish = false [lib] proc-macro = true +[features] +default = [] +openapi = [] + [dependencies] proc-macro-crate = "3" proc-macro2 = "1" diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index 061c690a8..fa6b49782 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -41,6 +41,7 @@ enum Method { struct AdapterConfig { reqwest: bool, axum: bool, + openapi: bool, } impl Parse for AdapterConfig { @@ -51,13 +52,14 @@ impl Parse for AdapterConfig { match adapter.to_string().as_str() { "reqwest" if !config.reqwest => config.reqwest = 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(), - "unsupported API adapter; expected `reqwest` or `axum`", + "unsupported API adapter; expected `reqwest`, `axum`, or `openapi`", )); } } @@ -1270,10 +1272,107 @@ 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!(), + }; + let required = matches!(parameter.location, Location::Path) || !is_option_type(ty); + Some(quote! { + operation.parameter::<#ty>(#name, #location, #required)?; + }) + }); + 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)* + ::core::result::Result::Ok(builder.finish()) + } + } +} + +fn is_option_type(ty: &Type) -> bool { + let Type::Path(path) = ty else { + return false; + }; + path.qself.is_none() + && path + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "Option") +} + fn expand_api(api: ApiDefinition) -> syn::Result { let api_crate = api_crate_path()?; let reqwest_adapter = reqwest_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 metadata_ident = api.metadata_ident; let module_ident = api.operations_module_ident; @@ -1353,6 +1452,7 @@ fn expand_api(api: ApiDefinition) -> syn::Result { #reqwest_adapter #axum_adapter + #openapi_adapter }) } diff --git a/crates/api-macros/Cargo.toml b/crates/api-macros/Cargo.toml index 6839cbcb6..0bc19b435 100644 --- a/crates/api-macros/Cargo.toml +++ b/crates/api-macros/Cargo.toml @@ -8,6 +8,7 @@ publish = false [features] default = [] axum = ["dep:axum", "dep:serde"] +openapi = ["dep:schemars", "dep:serde_json", "api-macros-impl/openapi"] reqwest = [ "dep:futures", "dep:reqwest", @@ -22,12 +23,14 @@ api-macros-impl = { path = "../api-macros-impl" } axum = { workspace = true, optional = true } futures = { workspace = true, optional = true } reqwest = { version = "0.13", default-features = false, optional = true, features = ["stream", "rustls"] } +schemars = { version = "1.2", optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } serde_urlencoded = { version = "0.7", optional = true } url = { version = "2", optional = true } [dev-dependencies] +oas3 = { version = "0.22", default-features = false } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["net"] } diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index 79ef65b92..da7405bcb 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -59,6 +59,22 @@ //! explicit variants for future transport work; accepting one requires a deliberate macro and //! adapter change rather than silently treating it as JSON. //! +//! # OpenAPI 3.1 export +//! +//! With the `openapi` feature, `#[api(openapi)]` emits a `_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. +//! //! # Optional HTTP adapters //! //! `#[api(reqwest)]` generates `TraitNameClient` and enables a typed Reqwest client when this @@ -84,6 +100,8 @@ pub use api_macros_impl::api; #[cfg(feature = "axum")] pub mod axum; +#[cfg(feature = "openapi")] +pub mod openapi; #[cfg(feature = "reqwest")] pub mod reqwest; diff --git a/crates/api-macros/src/openapi.rs b/crates/api-macros/src/openapi.rs new file mode 100644 index 000000000..0908a83f4 --- /dev/null +++ b/crates/api-macros/src/openapi.rs @@ -0,0 +1,477 @@ +//! 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 { + 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, String, char, i8, i16, i32, u8, u16, u32, f32, f64); + +impl OpenApiSchema for Option {} +impl OpenApiSchema for Vec {} +impl OpenApiSchema for Box {} +impl OpenApiSchema for std::sync::Arc {} +impl OpenApiSchema for BTreeMap {} + +/// 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 { + 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) -> 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 for OpenApiError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From 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>, + schemas: BTreeMap, + operation_ids: BTreeSet, +} + +impl OpenApiBuilder { + pub fn new(info: OpenApiInfo<'_>) -> Result { + 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(), + }) + } + + pub fn operation<'a>( + &'a mut self, + method: &'static str, + path: &'static str, + operation_id: &'static str, + ) -> Result, 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(), + }) + } + + pub fn finish(self) -> OpenApiDocument { + let paths = self + .paths + .into_iter() + .map(|(path, operations)| { + let operations = operations.into_iter().collect::>(); + (path, Value::Object(operations)) + }) + .collect::>(); + let schemas = self.schemas.into_iter().collect::>(); + + OpenApiDocument(canonicalize(json!({ + "openapi": "3.1.0", + "info": self.info, + "paths": paths, + "components": { "schemas": schemas }, + }))) + } + + fn schema_ref(&mut self) -> Result { + 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, + request_body: Option, + responses: BTreeMap, +} + +impl OpenApiOperation<'_> { + pub fn parameter( + &mut self, + name: &'static str, + location: &'static str, + required: bool, + ) -> Result<(), OpenApiError> { + if !matches!(location, "path" | "query" | "header") { + return Err(OpenApiError::InvalidContract(format!( + "unsupported parameter location `{location}` for `{}`", + self.operation_id + ))); + } + if location == "path" && !required { + return Err(OpenApiError::InvalidContract(format!( + "path parameter `{name}` for `{}` must be required", + self.operation_id + ))); + } + let schema = self.parent.schema_ref::()?; + 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( + &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::()?; + self.request_body = Some(json!({ + "required": true, + "content": { content_type: { "schema": schema } }, + })); + Ok(()) + } + + pub fn response( + &mut self, + status: u16, + content_type: &'static str, + description: &'static str, + ) -> Result<(), OpenApiError> { + let schema = self.parent.schema_ref::()?; + 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::>(); + 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); + } + 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 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_i64); + let maximum = object.get("maximum").and_then(Value::as_u64); + if !safe_format + && (minimum.is_none_or(|value| value < -MAX_SAFE_INTEGER) + || maximum.is_none_or(|value| value > MAX_SAFE_INTEGER as u64)) + { + 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::>() + .into_iter() + .collect(); + Value::Object(sorted) + } + Value::Array(values) => Value::Array(values.into_iter().map(canonicalize).collect()), + scalar => scalar, + } +} diff --git a/crates/api-macros/tests/openapi.rs b/crates/api-macros/tests/openapi.rs new file mode 100644 index 000000000..874ee000f --- /dev/null +++ b/crates/api-macros/tests/openapi.rs @@ -0,0 +1,271 @@ +#![cfg(feature = "openapi")] +#![allow(async_fn_in_trait, dead_code)] + +use api_macros::{ + api, + openapi::{OpenApiInfo, OpenApiSchema}, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +fn nullable_string_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + generator.subschema_for::>() +} + +#[derive(Debug, Deserialize, JsonSchema, Serialize)] +pub struct GeoPoint { + latitude: f64, + longitude: f64, +} +impl OpenApiSchema for GeoPoint {} + +#[derive(Debug, Deserialize, JsonSchema, Serialize)] +pub struct CreateWidget { + name: String, + count: u32, + /// Missing and explicit null are separate wire states. + #[schemars(default, schema_with = "nullable_string_schema")] + optional_label: Option, + #[schemars(required, schema_with = "nullable_string_schema")] + required_nullable_label: Option, + 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)] +pub enum PublicError { + Invalid { message: String }, +} +impl OpenApiSchema for PublicError {} + +#[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("x-request-id")] request_id: Option, + #[body] request: CreateWidget, + ) -> Result; + + #[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/")); +} + +#[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["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"); + for name in [ + "CreateWidget", + "GeoPoint", + "Lookup", + "PublicError", + "Widget", + "uint32", + "Nullable_string", + ] { + assert!( + schemas.contains_key(name), + "missing stable schema component {name}; found {:?}", + schemas.keys().collect::>() + ); + } + assert_eq!( + schemas["CreateWidget"]["properties"]["location"]["$ref"], + "#/components/schemas/GeoPoint" + ); + + 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_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 {} + +#[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::(200, "application/json", "ok") + .expect("first schema"); + operation.finish().expect("first operation"); + } + let error = collision + .operation("GET", "/second", "second") + .expect("operation") + .response::(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::(200, "application/json", "ok") + .expect_err("untagged/ambiguous unions must fail closed"); + assert!(error.to_string().contains("untagged")); +} + +#[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")); +} From f32b354f9bd300e8a9a7ad9b6ea2316c2b17e8ed Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 05:43:43 +0900 Subject: [PATCH 2/3] fix(api): close OpenAPI schema fidelity gaps --- crates/api-macros-impl/src/lib.rs | 17 +--- crates/api-macros/src/openapi.rs | 74 +++++++++++++---- crates/api-macros/tests/openapi.rs | 125 ++++++++++++++++++++++++++++- 3 files changed, 184 insertions(+), 32 deletions(-) diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs index fa6b49782..79b46eaa7 100644 --- a/crates/api-macros-impl/src/lib.rs +++ b/crates/api-macros-impl/src/lib.rs @@ -1301,9 +1301,8 @@ fn openapi_adapter_tokens( Location::Header => "header", Location::Body => unreachable!(), }; - let required = matches!(parameter.location, Location::Path) || !is_option_type(ty); Some(quote! { - operation.parameter::<#ty>(#name, #location, #required)?; + operation.parameter::<#ty>(#name, #location)?; }) }); let request = operation.request_body.as_ref().map(|ty| { @@ -1351,23 +1350,11 @@ fn openapi_adapter_tokens( > { let mut builder = #api_crate::openapi::OpenApiBuilder::new(info)?; #(#operations)* - ::core::result::Result::Ok(builder.finish()) + builder.finish() } } } -fn is_option_type(ty: &Type) -> bool { - let Type::Path(path) = ty else { - return false; - }; - path.qself.is_none() - && path - .path - .segments - .last() - .is_some_and(|segment| segment.ident == "Option") -} - fn expand_api(api: ApiDefinition) -> syn::Result { let api_crate = api_crate_path()?; let reqwest_adapter = reqwest_adapter_tokens(&api, &api_crate); diff --git a/crates/api-macros/src/openapi.rs b/crates/api-macros/src/openapi.rs index 0908a83f4..19ff1f5d0 100644 --- a/crates/api-macros/src/openapi.rs +++ b/crates/api-macros/src/openapi.rs @@ -26,6 +26,12 @@ const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; /// 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` retain optionality. + const PARAMETER_REQUIRED: bool = true; + fn openapi_schema_name() -> Cow<'static, str> { Self::schema_name() } @@ -39,7 +45,9 @@ macro_rules! impl_openapi_schema { impl_openapi_schema!((), bool, String, char, i8, i16, i32, u8, u16, u32, f32, f64); -impl OpenApiSchema for Option {} +impl OpenApiSchema for Option { + const PARAMETER_REQUIRED: bool = false; +} impl OpenApiSchema for Vec {} impl OpenApiSchema for Box {} impl OpenApiSchema for std::sync::Arc {} @@ -184,7 +192,7 @@ impl OpenApiBuilder { }) } - pub fn finish(self) -> OpenApiDocument { + pub fn finish(self) -> Result { let paths = self .paths .into_iter() @@ -195,12 +203,14 @@ impl OpenApiBuilder { .collect::>(); let schemas = self.schemas.into_iter().collect::>(); - OpenApiDocument(canonicalize(json!({ + let document = canonicalize(json!({ "openapi": "3.1.0", "info": self.info, "paths": paths, "components": { "schemas": schemas }, - }))) + })); + validate_component_references(&document)?; + Ok(OpenApiDocument(document)) } fn schema_ref(&mut self) -> Result { @@ -268,7 +278,6 @@ impl OpenApiOperation<'_> { &mut self, name: &'static str, location: &'static str, - required: bool, ) -> Result<(), OpenApiError> { if !matches!(location, "path" | "query" | "header") { return Err(OpenApiError::InvalidContract(format!( @@ -276,12 +285,7 @@ impl OpenApiOperation<'_> { self.operation_id ))); } - if location == "path" && !required { - return Err(OpenApiError::InvalidContract(format!( - "path parameter `{name}` for `{}` must be required", - self.operation_id - ))); - } + let required = location == "path" || T::PARAMETER_REQUIRED; let schema = self.parent.schema_ref::()?; self.parameters.push(json!({ "name": name, @@ -396,6 +400,46 @@ fn validate_component_name(name: &str) -> Result<(), OpenApiError> { 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) => { @@ -421,11 +465,11 @@ fn validate_schema(value: &Value, component: &str) -> Result<(), OpenApiError> { object.get("format").and_then(Value::as_str), Some("int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32") ); - let minimum = object.get("minimum").and_then(Value::as_i64); - let maximum = object.get("maximum").and_then(Value::as_u64); + 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) - || maximum.is_none_or(|value| value > MAX_SAFE_INTEGER as u64)) + && (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" diff --git a/crates/api-macros/tests/openapi.rs b/crates/api-macros/tests/openapi.rs index 874ee000f..0e659fbc3 100644 --- a/crates/api-macros/tests/openapi.rs +++ b/crates/api-macros/tests/openapi.rs @@ -6,7 +6,7 @@ use api_macros::{ openapi::{OpenApiInfo, OpenApiSchema}, }; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Value, json}; fn nullable_string_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { @@ -20,10 +20,36 @@ pub struct GeoPoint { } 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(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for WireSlug { + fn deserialize>(deserializer: D) -> Result { + 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, + slug: WireSlug, /// Missing and explicit null are separate wire states. #[schemars(default, schema_with = "nullable_string_schema")] optional_label: Option, @@ -47,11 +73,14 @@ pub struct Widget { 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; + #[api(openapi)] pub trait FixtureApi { #[post( @@ -64,7 +93,7 @@ pub trait FixtureApi { &self, #[path] widget_id: u32, #[query] lookup: Lookup, - #[header("x-request-id")] request_id: Option, + #[header("x-request-id")] request_id: OptionalRequestId, #[body] request: CreateWidget, ) -> Result; @@ -105,6 +134,41 @@ fn output_is_deterministic_and_accepted_by_an_independent_parser() { ); 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] @@ -156,6 +220,7 @@ fn operations_and_components_preserve_the_wire_contract() { "Lookup", "PublicError", "Widget", + "WireSlug", "uint32", "Nullable_string", ] { @@ -169,6 +234,33 @@ fn operations_and_components_preserve_the_wire_contract() { 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() @@ -220,6 +312,20 @@ pub enum AmbiguousWireEnum { } 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}; @@ -254,6 +360,21 @@ fn schema_name_collisions_and_ambiguous_wire_shapes_fail_closed() { .response::(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::(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] From 648421e454d2928fcc6c1f7c96fe434fdba9bf80 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 05:54:41 +0900 Subject: [PATCH 3/3] feat(api): project bearer authorization into OpenAPI --- crates/api-macros/src/lib.rs | 4 ++- crates/api-macros/src/openapi.rs | 54 ++++++++++++++++++++++++++++-- crates/api-macros/tests/openapi.rs | 7 ++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs index da7405bcb..f6372171a 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -73,7 +73,9 @@ //! 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. +//! 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 //! diff --git a/crates/api-macros/src/openapi.rs b/crates/api-macros/src/openapi.rs index 19ff1f5d0..6b6795003 100644 --- a/crates/api-macros/src/openapi.rs +++ b/crates/api-macros/src/openapi.rs @@ -31,6 +31,8 @@ pub trait OpenApiSchema: JsonSchema { /// This semantic hook intentionally lives on the resolved type, so aliases /// such as `type Header = Option` 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() @@ -43,10 +45,15 @@ macro_rules! impl_openapi_schema { }; } -impl_openapi_schema!((), bool, String, char, i8, i16, i32, u8, u16, u32, f32, f64); +impl_openapi_schema!((), bool, char, i8, i16, i32, u8, u16, u32, f32, f64); + +impl OpenApiSchema for String { + const BEARER_CREDENTIAL: bool = true; +} impl OpenApiSchema for Option { const PARAMETER_REQUIRED: bool = false; + const BEARER_CREDENTIAL: bool = T::BEARER_CREDENTIAL; } impl OpenApiSchema for Vec {} impl OpenApiSchema for Box {} @@ -123,6 +130,7 @@ pub struct OpenApiBuilder { paths: BTreeMap>, schemas: BTreeMap, operation_ids: BTreeSet, + uses_bearer_auth: bool, } impl OpenApiBuilder { @@ -148,6 +156,7 @@ impl OpenApiBuilder { paths: BTreeMap::new(), schemas: BTreeMap::new(), operation_ids: BTreeSet::new(), + uses_bearer_auth: false, }) } @@ -189,6 +198,7 @@ impl OpenApiBuilder { parameters: Vec::new(), request_body: None, responses: BTreeMap::new(), + bearer_security_required: None, }) } @@ -202,12 +212,25 @@ impl OpenApiBuilder { }) .collect::>(); let schemas = self.schemas.into_iter().collect::>(); + 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": { "schemas": schemas }, + "components": components, })); validate_component_references(&document)?; Ok(OpenApiDocument(document)) @@ -271,6 +294,7 @@ pub struct OpenApiOperation<'a> { parameters: Vec, request_body: Option, responses: BTreeMap, + bearer_security_required: Option, } impl OpenApiOperation<'_> { @@ -286,6 +310,22 @@ impl OpenApiOperation<'_> { ))); } 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::()?; self.parameters.push(json!({ "name": name, @@ -378,6 +418,16 @@ impl OpenApiOperation<'_> { 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) diff --git a/crates/api-macros/tests/openapi.rs b/crates/api-macros/tests/openapi.rs index 0e659fbc3..1a79d6d30 100644 --- a/crates/api-macros/tests/openapi.rs +++ b/crates/api-macros/tests/openapi.rs @@ -93,6 +93,7 @@ pub trait FixtureApi { &self, #[path] widget_id: u32, #[query] lookup: Lookup, + #[header("authorization")] authorization: String, #[header("x-request-id")] request_id: OptionalRequestId, #[body] request: CreateWidget, ) -> Result; @@ -176,6 +177,7 @@ 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!({ @@ -214,6 +216,10 @@ fn operations_and_components_preserve_the_wire_contract() { 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", @@ -278,6 +284,7 @@ fn operations_and_components_preserve_the_wire_contract() { 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" })