feat(api): generate deterministic OpenAPI 3.1 contracts

This commit is contained in:
2026-09-18 05:29:13 +09:00
parent ae7f0f0446
commit f513cfca10
7 changed files with 896 additions and 2 deletions
+271
View File
@@ -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::<Option<String>>()
}
#[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<String>,
#[schemars(required, schema_with = "nullable_string_schema")]
required_nullable_label: Option<String>,
location: GeoPoint,
}
impl OpenApiSchema for CreateWidget {}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct Lookup {
detailed: bool,
}
impl OpenApiSchema for Lookup {}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
pub struct Widget {
id: u32,
request: CreateWidget,
}
impl OpenApiSchema for Widget {}
#[derive(Debug, Deserialize, JsonSchema, Serialize)]
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<String>,
#[body] request: CreateWidget,
) -> Result<Widget, PublicError>;
#[delete("/widgets/{widget_id}", operation_id = "widgets.delete", status = 204)]
async fn delete(&self, #[path] widget_id: u32) -> ();
}
fn document() -> api_macros::openapi::OpenApiDocument {
fixture_api_openapi(OpenApiInfo {
title: "Fixture API",
version: "1.2.3",
source_digest: "git:0123456789abcdef",
})
.expect("valid OpenAPI projection")
}
#[test]
fn output_is_deterministic_and_accepted_by_an_independent_parser() {
let first = document().to_json().expect("render document");
let second = document().to_json().expect("render document again");
assert_eq!(first.as_bytes(), second.as_bytes());
let parsed = oas3::from_json(&first).expect("oas3 independently parses generated document");
assert_eq!(
parsed
.validate_version()
.expect("valid OpenAPI version")
.major,
3
);
let value: Value = serde_json::from_str(&first).expect("valid JSON");
assert_eq!(value["openapi"], "3.1.0");
assert_eq!(value["info"]["x-yoi-source-digest"], "git:0123456789abcdef");
assert!(
value.get("servers").is_none(),
"deployment topology is omitted"
);
assert!(!first.contains("generatedAt"));
assert!(!first.contains("#/$defs/"));
}
#[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::<Vec<_>>()
);
}
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::<FirstCollision>(200, "application/json", "ok")
.expect("first schema");
operation.finish().expect("first operation");
}
let error = collision
.operation("GET", "/second", "second")
.expect("operation")
.response::<SecondCollision>(200, "application/json", "ok")
.expect_err("different schemas must not share a public name");
assert!(matches!(error, OpenApiError::InvalidContract(_)));
assert!(error.to_string().contains("component name collision"));
let mut ambiguous = OpenApiBuilder::new(info).expect("builder");
let error = ambiguous
.operation("GET", "/ambiguous", "ambiguous")
.expect("operation")
.response::<AmbiguousWireEnum>(200, "application/json", "ok")
.expect_err("untagged/ambiguous unions must fail closed");
assert!(error.to_string().contains("untagged"));
}
#[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"));
}