diff --git a/Cargo.lock b/Cargo.lock index 21139f8db..b8c2aefda 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", @@ -1086,6 +1088,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -2806,6 +2809,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..79b46eaa7 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,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 { 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 +1439,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..f6372171a 100644 --- a/crates/api-macros/src/lib.rs +++ b/crates/api-macros/src/lib.rs @@ -59,6 +59,24 @@ //! 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. 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 //! //! `#[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")] 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..6b6795003 --- /dev/null +++ b/crates/api-macros/src/openapi.rs @@ -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` 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 OpenApiSchema for Option { + const PARAMETER_REQUIRED: bool = false; + const BEARER_CREDENTIAL: bool = T::BEARER_CREDENTIAL; +} +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, + uses_bearer_auth: bool, +} + +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(), + uses_bearer_auth: false, + }) + } + + 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(), + bearer_security_required: None, + }) + } + + pub fn finish(self) -> Result { + 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::>(); + 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(&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, + bearer_security_required: Option, +} + +impl OpenApiOperation<'_> { + pub fn parameter( + &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::()?; + 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); + } + 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::>() + .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..1a79d6d30 --- /dev/null +++ b/crates/api-macros/tests/openapi.rs @@ -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::>() +} + +#[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(&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, + #[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)] +#[serde(tag = "code", content = "details")] +pub enum PublicError { + Invalid { message: String }, +} +impl OpenApiSchema for PublicError {} + +type OptionalRequestId = Option; + +#[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; + + #[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::>() + ); + } + 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::(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")); + + 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] +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")); +}