feat: add typed conditional API responses

This commit is contained in:
2026-09-23 19:32:05 +09:00
parent 97822cf691
commit 237ab0e592
25 changed files with 1871 additions and 276 deletions
+46 -6
View File
@@ -43,6 +43,19 @@ pub trait BinaryApi {
async fn upload(&self, #[binary] payload: BinaryBody) -> Widget;
}
#[api]
pub trait ConditionalApi {
#[get(
"/conditional",
operation_id = "conditional.get",
responses = [
(status = 200, body = Widget, headers = [("etag", String), ("cache-control", String)]),
(status = 304, headers = [("etag", String), ("cache-control", String)])
]
)]
async fn fetch(&self) -> conditional_api_responses::Fetch;
}
#[api]
pub trait RawIdentifierApi {
#[get("/type", operation_id = "raw.type")]
@@ -94,9 +107,9 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() {
assert_eq!(create.method, HttpMethod::Post);
assert_eq!(create.path, "/widgets");
assert_eq!(create.request_body.wire_kind, WireKind::Json);
assert_eq!(create.response.status, 201);
assert_eq!(create.response.body.wire_kind, WireKind::Json);
assert_eq!(create.error_response.unwrap().status, 422);
assert_eq!(create.success_responses[0].status, 201);
assert_eq!(create.success_responses[0].body.wire_kind, WireKind::Json);
assert_eq!(create.error_responses[0].status, 422);
assert_eq!(create.parameters[0].location, ParameterLocation::Body);
assert_eq!(create.parameters[1].location, ParameterLocation::Header);
assert_eq!(create.parameters[1].wire_name, "x-request-id");
@@ -111,10 +124,37 @@ fn expansion_exposes_deterministic_metadata_and_type_connections() {
fn assert_binary_request<O: Operation<RequestBody = BinaryBody>>() {}
assert_binary_request::<binary_api_operations::Upload>();
let conditional = <conditional_api_operations::Fetch as Operation>::METADATA;
assert_eq!(conditional.success_responses.len(), 2);
assert_eq!(conditional.success_responses[0].status, 200);
assert_eq!(
conditional.success_responses[0].body.wire_kind,
WireKind::Json
);
assert_eq!(conditional.success_responses[0].headers.len(), 2);
assert_eq!(
conditional.success_responses[0].headers[0].wire_name,
"etag"
);
assert_eq!(
conditional.success_responses[0].headers[0].rust_type,
"String"
);
assert_eq!(conditional.success_responses[1].status, 304);
assert_eq!(
conditional.success_responses[1].body.wire_kind,
WireKind::Empty
);
fn assert_conditional_response<
O: Operation<ResponseBody = conditional_api_responses::Fetch>,
>() {
}
assert_conditional_response::<conditional_api_operations::Fetch>();
let delete = <widget_api_operations::Delete as Operation>::METADATA;
assert_eq!(delete.response.status, 204);
assert_eq!(delete.response.body.wire_kind, WireKind::Empty);
assert_eq!(delete.error_response, None);
assert_eq!(delete.success_responses[0].status, 204);
assert_eq!(delete.success_responses[0].body.wire_kind, WireKind::Empty);
assert!(delete.error_responses.is_empty());
fn assert_empty_request<O: Operation<RequestBody = NoBody>>() {}
fn assert_empty_response<O: Operation<ResponseBody = NoBody, ErrorBody = NoBody>>() {}
+212
View File
@@ -131,6 +131,84 @@ impl BinaryApi for BinaryService {
}
}
#[api(reqwest, axum)]
pub trait ResponseApi {
#[get(
"/conditional",
operation_id = "responses.conditional",
responses = [
(status = 200, body = Widget, headers = [("etag", String), ("cache-control", String)]),
(status = 304, headers = [("etag", String), ("cache-control", String)])
]
)]
async fn conditional(&self, #[query] lookup: Lookup) -> response_api_responses::Conditional;
#[post(
"/cookie",
operation_id = "responses.cookie",
responses = [
(status = 200, body = Widget, headers = [("set-cookie", String)])
]
)]
async fn cookie(&self) -> response_api_responses::Cookie;
}
#[derive(Clone)]
pub struct ResponseService;
impl ResponseApi for ResponseService {
async fn conditional(&self, lookup: Lookup) -> response_api_responses::Conditional {
if lookup.mode == "cached" {
response_api_responses::Conditional::Status304 {
header_etag: "\"widget-v1\"".to_owned(),
header_cache_control: "no-cache".to_owned(),
}
} else {
response_api_responses::Conditional::Status200 {
body: Widget {
id: 42,
name: "conditional".to_owned(),
mode: lookup.mode,
},
header_etag: "\"widget-v1\"".to_owned(),
header_cache_control: "no-cache".to_owned(),
}
}
}
async fn cookie(&self) -> response_api_responses::Cookie {
response_api_responses::Cookie::Status200 {
body: Widget {
id: 7,
name: "cookie".to_owned(),
mode: "session".to_owned(),
},
header_set_cookie: "session=top-secret; HttpOnly; SameSite=Lax".to_owned(),
}
}
}
#[api(reqwest)]
pub trait ResponseFailureApi {
#[get(
"/missing-header",
responses = [(status = 200, headers = [("x-count", u32)])]
)]
async fn missing_header(&self) -> response_failure_api_responses::MissingHeader;
#[get(
"/invalid-header",
responses = [(status = 200, headers = [("x-count", u32)])]
)]
async fn invalid_header(&self) -> response_failure_api_responses::InvalidHeader;
#[get(
"/duplicate-header",
responses = [(status = 200, headers = [("x-count", u32)])]
)]
async fn duplicate_header(&self) -> response_failure_api_responses::DuplicateHeader;
}
#[api(reqwest)]
pub trait FailureApi {
#[get("/malformed", operation_id = "failure.malformed", status = 200)]
@@ -175,6 +253,27 @@ impl api_reqwest::RequestAuthorizer for RecordingAuthorizer {
}
}
async fn missing_header() -> api_macros::axum::framework::StatusCode {
api_macros::axum::framework::StatusCode::OK
}
async fn invalid_header() -> api_macros::axum::framework::Response {
api_macros::axum::framework::Response::builder()
.status(api_macros::axum::framework::StatusCode::OK)
.header("x-count", "not-a-number")
.body(api_macros::axum::framework::Body::empty())
.expect("valid fixture response")
}
async fn duplicate_header() -> api_macros::axum::framework::Response {
api_macros::axum::framework::Response::builder()
.status(api_macros::axum::framework::StatusCode::OK)
.header("x-count", "1")
.header("x-count", "2")
.body(api_macros::axum::framework::Body::empty())
.expect("valid fixture response")
}
async fn malformed() -> &'static str {
"this is not json"
}
@@ -321,6 +420,119 @@ async fn generated_client_and_router_follow_the_normalized_contract() {
server.abort();
}
#[tokio::test]
async fn declared_response_status_bodies_and_headers_round_trip_without_secret_diagnostics() {
let app = ResponseApiAxum::router(ResponseService).merge(
api_macros::axum::framework::Router::new()
.route(
"/missing-header",
api_macros::axum::framework::get(missing_header),
)
.route(
"/invalid-header",
api_macros::axum::framework::get(invalid_header),
)
.route(
"/duplicate-header",
api_macros::axum::framework::get(duplicate_header),
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind declared-response fixture server");
let address = listener.local_addr().expect("fixture address");
let server = tokio::spawn(async move {
api_macros::axum::framework::serve(listener, app)
.await
.expect("serve declared-response fixture API");
});
let base_url = format!("http://{address}/");
let client = ResponseApiClient::try_new(&base_url).expect("response client");
let fresh = client
.conditional(Lookup {
mode: "fresh".to_owned(),
})
.await
.expect("200 declared response");
match fresh {
response_api_responses::Conditional::Status200 {
body,
header_etag,
header_cache_control,
} => {
assert_eq!(body.id, 42);
assert_eq!(header_etag, "\"widget-v1\"");
assert_eq!(header_cache_control, "no-cache");
}
other => panic!("unexpected fresh variant: {other:?}"),
}
let cached = client
.conditional(Lookup {
mode: "cached".to_owned(),
})
.await
.expect("304 declared response");
assert!(matches!(
cached,
response_api_responses::Conditional::Status304 {
ref header_etag,
ref header_cache_control,
} if header_etag == "\"widget-v1\"" && header_cache_control == "no-cache"
));
let cookie = client.cookie().await.expect("cookie response");
let cookie_debug = format!("{cookie:?}");
assert!(!cookie_debug.contains("top-secret"));
match cookie {
response_api_responses::Cookie::Status200 {
body,
header_set_cookie,
} => {
assert_eq!(body.name, "cookie");
assert_eq!(
header_set_cookie,
"session=top-secret; HttpOnly; SameSite=Lax"
);
}
}
let failures = ResponseFailureApiClient::try_new(&base_url).expect("failure client");
let missing = failures
.missing_header()
.await
.expect_err("missing header must fail");
assert!(matches!(
missing,
api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::MissingResponseHeader {
name: "x-count"
})
));
let invalid = failures
.invalid_header()
.await
.expect_err("invalid header must fail");
assert!(matches!(
invalid,
api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::InvalidResponseHeader {
name: "x-count"
})
));
let duplicate = failures
.duplicate_header()
.await
.expect_err("duplicate header must fail");
assert!(matches!(
duplicate,
api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::DuplicateResponseHeader {
name: "x-count"
})
));
server.abort();
}
#[tokio::test]
async fn binary_adapter_preserves_exact_bytes_content_type_and_route_limit() {
let app = binary_api_axum::upload(Arc::new(BinaryService))
+30
View File
@@ -103,6 +103,16 @@ pub trait FixtureApi {
#[put("/uploads", operation_id = "uploads.put", status = 200)]
async fn upload(&self, #[binary] body: BinaryBody) -> Widget;
#[get(
"/conditional",
operation_id = "conditional.get",
responses = [
(status = 200, body = Widget, headers = [("etag", String), ("cache-control", String)]),
(status = 304, headers = [("etag", String), ("cache-control", String)])
]
)]
async fn conditional(&self) -> fixture_api_responses::Conditional;
}
fn document() -> api_macros::openapi::OpenApiDocument {
@@ -303,6 +313,26 @@ fn operations_and_components_preserve_the_wire_contract() {
"binary payloads are not JSON Schema components"
);
let conditional = &value["paths"]["/conditional"]["get"];
assert_eq!(
conditional["responses"]["200"]["content"]["application/json"]["schema"],
json!({ "$ref": "#/components/schemas/Widget" })
);
assert_eq!(
conditional["responses"]["200"]["headers"]["etag"]["schema"],
json!({ "$ref": "#/components/schemas/string" })
);
assert_eq!(
conditional["responses"]["304"],
json!({
"description": "Alternate successful response",
"headers": {
"cache-control": { "schema": { "$ref": "#/components/schemas/string" } },
"etag": { "schema": { "$ref": "#/components/schemas/string" } }
}
})
);
let delete = &value["paths"]["/widgets/{widget_id}"]["delete"];
assert_eq!(delete["operationId"], "widgets.delete");
assert!(delete.get("security").is_none());
@@ -0,0 +1,14 @@
use api_macros::api;
pub struct Body;
#[api]
pub trait DuplicateResponseHeaderApi {
#[get(
"/duplicate",
responses = [(status = 200, body = Body, headers = [("etag", String), ("ETag", String)])]
)]
async fn duplicate(&self) -> duplicate_response_header_api_responses::Duplicate;
}
fn main() {}
@@ -0,0 +1,5 @@
error: duplicate response header name within one response
--> tests/ui/duplicate_response_header.rs:9:80
|
9 | responses = [(status = 200, body = Body, headers = [("etag", String), ("ETag", String)])]
| ^^^^^^
@@ -0,0 +1,14 @@
use api_macros::api;
pub struct Body;
#[api]
pub trait InvalidResponseHeaderApi {
#[get(
"/invalid",
responses = [(status = 200, body = Body, headers = [("bad header", String)])]
)]
async fn invalid(&self) -> invalid_response_header_api_responses::Invalid;
}
fn main() {}
@@ -0,0 +1,5 @@
error: response header name must be a valid HTTP field name
--> tests/ui/invalid_response_header.rs:9:62
|
9 | responses = [(status = 200, body = Body, headers = [("bad header", String)])]
| ^^^^^^^^^^^^
@@ -0,0 +1,14 @@
use api_macros::api;
pub struct Body;
#[api]
pub trait NotModifiedBodyApi {
#[get(
"/not-modified",
responses = [(status = 304, body = Body)]
)]
async fn not_modified(&self) -> not_modified_body_api_responses::NotModified;
}
fn main() {}
@@ -0,0 +1,5 @@
error: status 304 cannot have a response body
--> tests/ui/not_modified_body.rs:9:32
|
9 | responses = [(status = 304, body = Body)]
| ^^^
@@ -0,0 +1,18 @@
use api_macros::api;
pub struct Body;
#[api]
pub trait UndeclaredStatusApi {
#[get(
"/status",
responses = [(status = 200, body = Body)]
)]
async fn status(&self) -> undeclared_status_api_responses::Status;
}
fn invalid() -> undeclared_status_api_responses::Status {
undeclared_status_api_responses::Status::Status201 { body: Body }
}
fn main() {}
@@ -0,0 +1,14 @@
error[E0599]: no variant named `Status201` found for enum `undeclared_status_api_responses::Status`
--> tests/ui/undeclared_response_status.rs:15:46
|
5 | #[api]
| ------ variant `Status201` not found here
...
15 | undeclared_status_api_responses::Status::Status201 { body: Body }
| ^^^^^^^^^
|
help: there is a variant with a similar name
|
15 - undeclared_status_api_responses::Status::Status201 { body: Body }
15 + undeclared_status_api_responses::Status::Status200 { body: Body }
|