From 648421e454d2928fcc6c1f7c96fe434fdba9bf80 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 05:54:41 +0900 Subject: [PATCH] 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" })