feat: add typed conditional API responses
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
//! other application state remain explicit outer Axum layers. Each operation is also exposed as a
|
||||
//! separate router so route-specific layers can be applied before routers are merged.
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -28,6 +28,48 @@ where
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
pub fn parse_optional_header<T>(
|
||||
headers: &HeaderMap,
|
||||
name: &'static str,
|
||||
) -> Result<Option<T>, StatusCode>
|
||||
where
|
||||
T: FromStr,
|
||||
{
|
||||
headers
|
||||
.get(name)
|
||||
.map(|value| {
|
||||
value
|
||||
.to_str()
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?
|
||||
.parse()
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Opaque failure to encode one declared response header.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ResponseHeaderError;
|
||||
|
||||
impl fmt::Display for ResponseHeaderError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("declared response header encoding failed")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ResponseHeaderError {}
|
||||
|
||||
/// Insert one typed declared response header without retaining its value in an error.
|
||||
pub fn insert_response_header<T: fmt::Display>(
|
||||
headers: &mut HeaderMap,
|
||||
name: &'static str,
|
||||
value: &T,
|
||||
) -> Result<(), ResponseHeaderError> {
|
||||
let value = value.to_string().parse().map_err(|_| ResponseHeaderError)?;
|
||||
headers.insert(name, value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize a typed JSON response with the contract status.
|
||||
pub fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
|
||||
(status, Json(value)).into_response()
|
||||
@@ -52,7 +94,7 @@ pub fn status(code: u16) -> StatusCode {
|
||||
pub mod framework {
|
||||
pub use axum::{
|
||||
Json, Router,
|
||||
body::Bytes,
|
||||
body::{Body, Bytes},
|
||||
extract::{
|
||||
DefaultBodyLimit, Extension, Path, Query, State,
|
||||
rejection::{BytesRejection, JsonRejection},
|
||||
@@ -63,3 +105,21 @@ pub mod framework {
|
||||
serve,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn response_header_encoding_failure_is_bounded_and_value_safe() {
|
||||
let mut headers = HeaderMap::new();
|
||||
let error = insert_response_header(&mut headers, "set-cookie", &"secret\ninvalid")
|
||||
.expect_err("invalid header bytes must fail");
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"declared response header encoding failed"
|
||||
);
|
||||
assert!(!format!("{error:?}").contains("secret"));
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,11 @@
|
||||
//! method name but should be set explicitly for contracts which must remain stable while Rust
|
||||
//! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to
|
||||
//! `204`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP
|
||||
//! outcomes share one schema. A public error inferred from `Result<T, E>` defaults to status `400`;
|
||||
//! outcomes share one schema. Operations whose successful statuses have different body or header
|
||||
//! shapes use `responses = [(status = 200, body = Widget, headers = [("etag", String)]),
|
||||
//! (status = 304, headers = [("etag", String)])]`; the macro emits a typed result enum in the
|
||||
//! `<trait_name>_responses` module. Only explicit body-less `304` declarations extend success
|
||||
//! beyond 2xx. A public error inferred from `Result<T, E>` defaults to status `400`;
|
||||
//! `additional_error_statuses` publishes the same typed error schema for other declared outcomes.
|
||||
//! `bearer_auth = true` and `browser_auth = true` attach standard bearer and browser-session cookie
|
||||
//! security schemes. Body operations may opt into typed Axum rejection normalization with
|
||||
@@ -226,11 +230,21 @@ pub struct BodyMetadata {
|
||||
pub wire_kind: WireKind,
|
||||
}
|
||||
|
||||
/// Stable metadata for one explicitly declared response header.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ResponseHeaderMetadata {
|
||||
/// Canonical wire name declared in the API contract.
|
||||
pub wire_name: &'static str,
|
||||
/// Rust type spelling declared alongside the wire name.
|
||||
pub rust_type: &'static str,
|
||||
}
|
||||
|
||||
/// Stable metadata for one HTTP response.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ResponseMetadata {
|
||||
pub status: u16,
|
||||
pub body: BodyMetadata,
|
||||
pub headers: &'static [ResponseHeaderMetadata],
|
||||
}
|
||||
|
||||
/// Complete transport metadata for one operation.
|
||||
@@ -241,8 +255,10 @@ pub struct OperationMetadata {
|
||||
pub path: &'static str,
|
||||
pub parameters: &'static [ParameterMetadata],
|
||||
pub request_body: BodyMetadata,
|
||||
pub response: ResponseMetadata,
|
||||
pub error_response: Option<ResponseMetadata>,
|
||||
/// Every declared successful response, including status-specific body and header shape.
|
||||
pub success_responses: &'static [ResponseMetadata],
|
||||
/// Every declared public-error status. Error responses currently have no declared headers.
|
||||
pub error_responses: &'static [ResponseMetadata],
|
||||
}
|
||||
|
||||
/// Deterministic operation inventory emitted for an `#[api]` trait.
|
||||
@@ -256,7 +272,7 @@ pub trait Operation {
|
||||
type Parameters;
|
||||
/// Typed JSON or binary request body, or [`NoBody`].
|
||||
type RequestBody;
|
||||
/// JSON success response body type, or [`NoBody`].
|
||||
/// JSON success response body type, generated declared-response result, or [`NoBody`].
|
||||
type ResponseBody;
|
||||
/// JSON public error body type, or [`NoBody`].
|
||||
type ErrorBody;
|
||||
|
||||
@@ -427,6 +427,42 @@ impl OpenApiOperation<'_> {
|
||||
self.insert_response(status, json!({ "description": description }))
|
||||
}
|
||||
|
||||
pub fn response_header<T: OpenApiSchema>(
|
||||
&mut self,
|
||||
status: u16,
|
||||
name: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
let schema = self.parent.schema_ref::<T>()?;
|
||||
let response = self
|
||||
.responses
|
||||
.get_mut(&status.to_string())
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or_else(|| {
|
||||
OpenApiError::InvalidContract(format!(
|
||||
"response header `{name}` precedes status `{status}` for `{}`",
|
||||
self.operation_id
|
||||
))
|
||||
})?;
|
||||
let headers = response
|
||||
.entry("headers")
|
||||
.or_insert_with(|| Value::Object(Map::new()))
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| {
|
||||
OpenApiError::InvalidContract(format!(
|
||||
"response headers for status `{status}` are not an object for `{}`",
|
||||
self.operation_id
|
||||
))
|
||||
})?;
|
||||
if headers.contains_key(name) {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
"duplicate response header `{name}` for status `{status}` on `{}`",
|
||||
self.operation_id
|
||||
)));
|
||||
}
|
||||
headers.insert(name.to_owned(), json!({ "schema": schema }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_response(&mut self, status: u16, response: Value) -> Result<(), OpenApiError> {
|
||||
if !(100..=599).contains(&status) {
|
||||
return Err(OpenApiError::InvalidContract(format!(
|
||||
|
||||
@@ -399,6 +399,7 @@ impl<A: RequestAuthorizer> ClientCore<A> {
|
||||
}
|
||||
})?;
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
@@ -421,7 +422,11 @@ impl<A: RequestAuthorizer> ClientCore<A> {
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(ReceivedResponse { status, bytes })
|
||||
Ok(ReceivedResponse {
|
||||
status,
|
||||
headers,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,6 +458,18 @@ pub fn insert_header<T: fmt::Display>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert an optional string-form header value when present.
|
||||
pub fn insert_optional_header<T: fmt::Display>(
|
||||
headers: &mut HeaderMap,
|
||||
name: &str,
|
||||
value: &Option<T>,
|
||||
) -> Result<(), ClientFailure> {
|
||||
match value {
|
||||
Some(value) => insert_header(headers, name, value),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a JSON request body once. These exact bytes are authorized and transmitted.
|
||||
pub fn encode_json<T: Serialize + ?Sized>(value: &T) -> Result<EncodedBody, ClientFailure> {
|
||||
serde_json::to_vec(value)
|
||||
@@ -468,6 +485,7 @@ pub fn encode_binary(value: crate::BinaryBody) -> EncodedBody {
|
||||
/// Response received under the configured byte limit. Its content is always redacted in Debug.
|
||||
pub struct ReceivedResponse {
|
||||
status: StatusCode,
|
||||
headers: HeaderMap,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -476,6 +494,24 @@ impl ReceivedResponse {
|
||||
self.status
|
||||
}
|
||||
|
||||
pub fn parse_header<T>(&self, name: &'static str) -> Result<T, ClientFailure>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
{
|
||||
let mut values = self.headers.get_all(name).iter();
|
||||
let value = values
|
||||
.next()
|
||||
.ok_or(ClientFailure::MissingResponseHeader { name })?;
|
||||
if values.next().is_some() {
|
||||
return Err(ClientFailure::DuplicateResponseHeader { name });
|
||||
}
|
||||
value
|
||||
.to_str()
|
||||
.map_err(|_| ClientFailure::InvalidResponseHeader { name })?
|
||||
.parse()
|
||||
.map_err(|_| ClientFailure::InvalidResponseHeader { name })
|
||||
}
|
||||
|
||||
pub fn decode_json<T: DeserializeOwned>(&self, kind: DecodeKind) -> Result<T, ClientFailure> {
|
||||
serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind })
|
||||
}
|
||||
@@ -494,6 +530,7 @@ impl fmt::Debug for ReceivedResponse {
|
||||
formatter
|
||||
.debug_struct("ReceivedResponse")
|
||||
.field("status", &self.status)
|
||||
.field("headers", &"<redacted>")
|
||||
.field("bytes", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
@@ -517,6 +554,9 @@ pub enum ClientFailure {
|
||||
UnexpectedStatus { expected: u16, actual: u16 },
|
||||
ErrorResponseDecode { status: u16 },
|
||||
Decode { kind: DecodeKind },
|
||||
MissingResponseHeader { name: &'static str },
|
||||
DuplicateResponseHeader { name: &'static str },
|
||||
InvalidResponseHeader { name: &'static str },
|
||||
UnexpectedBody,
|
||||
}
|
||||
|
||||
@@ -552,6 +592,18 @@ impl fmt::Display for ClientFailure {
|
||||
Self::Decode {
|
||||
kind: DecodeKind::PublicError,
|
||||
} => formatter.write_str("public error response JSON was malformed"),
|
||||
Self::MissingResponseHeader { name } => {
|
||||
write!(formatter, "response header `{name}` was missing")
|
||||
}
|
||||
Self::DuplicateResponseHeader { name } => {
|
||||
write!(
|
||||
formatter,
|
||||
"response header `{name}` occurred more than once"
|
||||
)
|
||||
}
|
||||
Self::InvalidResponseHeader { name } => {
|
||||
write!(formatter, "response header `{name}` was invalid")
|
||||
}
|
||||
Self::UnexpectedBody => formatter.write_str("empty response contained a body"),
|
||||
}
|
||||
}
|
||||
@@ -616,3 +668,19 @@ impl<E> std::error::Error for ClientError<E> {}
|
||||
pub mod framework {
|
||||
pub use reqwest::{Client, Method, StatusCode, Url, header};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bodyless_response_validation_rejects_retained_bytes_without_exposing_them() {
|
||||
let response = ReceivedResponse {
|
||||
status: StatusCode::NOT_MODIFIED,
|
||||
headers: HeaderMap::new(),
|
||||
bytes: b"sensitive-body".to_vec(),
|
||||
};
|
||||
assert_eq!(response.require_empty(), Err(ClientFailure::UnexpectedBody));
|
||||
assert!(!format!("{response:?}").contains("sensitive-body"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>() {}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
|
|
||||
Reference in New Issue
Block a user