feat(api): project bearer authorization into OpenAPI

This commit is contained in:
2026-09-18 05:54:41 +09:00
parent f32b354f9b
commit 648421e454
3 changed files with 62 additions and 3 deletions
+3 -1
View File
@@ -73,7 +73,9 @@
//! public component-name contract. Different schemas claiming one name, unsafe-width integer //! public component-name contract. Different schemas claiming one name, unsafe-width integer
//! schemas, and ambiguous non-null `anyOf`/untagged representations fail closed. Use bounded //! 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 //! 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 //! # Optional HTTP adapters
//! //!
+52 -2
View File
@@ -31,6 +31,8 @@ pub trait OpenApiSchema: JsonSchema {
/// This semantic hook intentionally lives on the resolved type, so aliases /// This semantic hook intentionally lives on the resolved type, so aliases
/// such as `type Header = Option<String>` retain optionality. /// such as `type Header = Option<String>` retain optionality.
const PARAMETER_REQUIRED: bool = true; 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> { fn openapi_schema_name() -> Cow<'static, str> {
Self::schema_name() 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<T: OpenApiSchema> OpenApiSchema for Option<T> { impl<T: OpenApiSchema> OpenApiSchema for Option<T> {
const PARAMETER_REQUIRED: bool = false; const PARAMETER_REQUIRED: bool = false;
const BEARER_CREDENTIAL: bool = T::BEARER_CREDENTIAL;
} }
impl<T: OpenApiSchema> OpenApiSchema for Vec<T> {} impl<T: OpenApiSchema> OpenApiSchema for Vec<T> {}
impl<T: OpenApiSchema> OpenApiSchema for Box<T> {} impl<T: OpenApiSchema> OpenApiSchema for Box<T> {}
@@ -123,6 +130,7 @@ pub struct OpenApiBuilder {
paths: BTreeMap<String, BTreeMap<String, Value>>, paths: BTreeMap<String, BTreeMap<String, Value>>,
schemas: BTreeMap<String, Value>, schemas: BTreeMap<String, Value>,
operation_ids: BTreeSet<String>, operation_ids: BTreeSet<String>,
uses_bearer_auth: bool,
} }
impl OpenApiBuilder { impl OpenApiBuilder {
@@ -148,6 +156,7 @@ impl OpenApiBuilder {
paths: BTreeMap::new(), paths: BTreeMap::new(),
schemas: BTreeMap::new(), schemas: BTreeMap::new(),
operation_ids: BTreeSet::new(), operation_ids: BTreeSet::new(),
uses_bearer_auth: false,
}) })
} }
@@ -189,6 +198,7 @@ impl OpenApiBuilder {
parameters: Vec::new(), parameters: Vec::new(),
request_body: None, request_body: None,
responses: BTreeMap::new(), responses: BTreeMap::new(),
bearer_security_required: None,
}) })
} }
@@ -202,12 +212,25 @@ impl OpenApiBuilder {
}) })
.collect::<Map<_, _>>(); .collect::<Map<_, _>>();
let schemas = self.schemas.into_iter().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!({ let document = canonicalize(json!({
"openapi": "3.1.0", "openapi": "3.1.0",
"info": self.info, "info": self.info,
"paths": paths, "paths": paths,
"components": { "schemas": schemas }, "components": components,
})); }));
validate_component_references(&document)?; validate_component_references(&document)?;
Ok(OpenApiDocument(document)) Ok(OpenApiDocument(document))
@@ -271,6 +294,7 @@ pub struct OpenApiOperation<'a> {
parameters: Vec<Value>, parameters: Vec<Value>,
request_body: Option<Value>, request_body: Option<Value>,
responses: BTreeMap<String, Value>, responses: BTreeMap<String, Value>,
bearer_security_required: Option<bool>,
} }
impl OpenApiOperation<'_> { impl OpenApiOperation<'_> {
@@ -286,6 +310,22 @@ impl OpenApiOperation<'_> {
))); )));
} }
let required = location == "path" || T::PARAMETER_REQUIRED; 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>()?; let schema = self.parent.schema_ref::<T>()?;
self.parameters.push(json!({ self.parameters.push(json!({
"name": name, "name": name,
@@ -378,6 +418,16 @@ impl OpenApiOperation<'_> {
if let Some(request_body) = self.request_body { if let Some(request_body) = self.request_body {
operation.insert("requestBody".to_owned(), 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 self.parent
.paths .paths
.entry(self.path) .entry(self.path)
+7
View File
@@ -93,6 +93,7 @@ pub trait FixtureApi {
&self, &self,
#[path] widget_id: u32, #[path] widget_id: u32,
#[query] lookup: Lookup, #[query] lookup: Lookup,
#[header("authorization")] authorization: String,
#[header("x-request-id")] request_id: OptionalRequestId, #[header("x-request-id")] request_id: OptionalRequestId,
#[body] request: CreateWidget, #[body] request: CreateWidget,
) -> Result<Widget, PublicError>; ) -> Result<Widget, PublicError>;
@@ -176,6 +177,7 @@ fn operations_and_components_preserve_the_wire_contract() {
let value = document().as_value().clone(); let value = document().as_value().clone();
let create = &value["paths"]["/widgets/{widget_id}"]["post"]; let create = &value["paths"]["/widgets/{widget_id}"]["post"];
assert_eq!(create["operationId"], "widgets.create"); assert_eq!(create["operationId"], "widgets.create");
assert_eq!(create["security"], json!([{ "bearerAuth": [] }]));
assert_eq!( assert_eq!(
create["responses"]["201"]["content"]["application/json"]["schema"], create["responses"]["201"]["content"]["application/json"]["schema"],
json!({ json!({
@@ -214,6 +216,10 @@ fn operations_and_components_preserve_the_wire_contract() {
let schemas = value["components"]["schemas"] let schemas = value["components"]["schemas"]
.as_object() .as_object()
.expect("components"); .expect("components");
assert_eq!(
value["components"]["securitySchemes"]["bearerAuth"],
json!({ "type": "http", "scheme": "bearer" })
);
for name in [ for name in [
"CreateWidget", "CreateWidget",
"GeoPoint", "GeoPoint",
@@ -278,6 +284,7 @@ fn operations_and_components_preserve_the_wire_contract() {
let delete = &value["paths"]["/widgets/{widget_id}"]["delete"]; let delete = &value["paths"]["/widgets/{widget_id}"]["delete"];
assert_eq!(delete["operationId"], "widgets.delete"); assert_eq!(delete["operationId"], "widgets.delete");
assert!(delete.get("security").is_none());
assert_eq!( assert_eq!(
delete["responses"]["204"], delete["responses"]["204"],
json!({ "description": "Successful response" }) json!({ "description": "Successful response" })