feat(api): generate reqwest and axum adapters

This commit is contained in:
2026-09-18 00:01:41 +09:00
parent 01bddf2177
commit 01f5c560c7
7 changed files with 1465 additions and 13 deletions
+22
View File
@@ -5,8 +5,30 @@ edition.workspace = true
license.workspace = true
publish = false
[features]
default = []
axum = ["dep:axum", "dep:serde"]
reqwest = [
"dep:futures",
"dep:reqwest",
"dep:serde",
"dep:serde_json",
"dep:serde_urlencoded",
"dep:url",
]
[dependencies]
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"] }
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]
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["net"] }
trybuild = "1"
+61
View File
@@ -0,0 +1,61 @@
//! Axum support used by routers generated with `#[api(axum)]`.
//!
//! Generated operation routers contain only transport extraction, typed service invocation, and
//! response mapping. Authentication/authorization context, body-limit policy, timeout policy, and
//! 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 axum::{
Json,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use serde::Serialize;
/// Parse one contract header into its declared Rust type.
pub fn parse_header<T>(headers: &HeaderMap, name: &'static str) -> Result<T, StatusCode>
where
T: FromStr,
{
headers
.get(name)
.ok_or(StatusCode::BAD_REQUEST)?
.to_str()
.map_err(|_| StatusCode::BAD_REQUEST)?
.parse()
.map_err(|_| StatusCode::BAD_REQUEST)
}
/// 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()
}
/// Emit an empty response with the contract status.
pub fn empty_response(status: StatusCode) -> Response {
status.into_response()
}
/// Emit a transport-level rejection without a response body.
pub fn rejection(status: StatusCode) -> Response {
status.into_response()
}
/// Convert a status constant from generated metadata.
pub fn status(code: u16) -> StatusCode {
StatusCode::from_u16(code).expect("#[api] validates HTTP status constants")
}
/// Reexports used by generated router code.
pub mod framework {
pub use axum::{
Json, Router,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::Response,
routing::{delete, get, head, options, patch, post, put},
serve,
};
}
+24
View File
@@ -58,11 +58,35 @@
//! Only empty and JSON bodies are accepted by this first contract. [`WireKind`] reserves
//! explicit variants for future transport work; accepting one requires a deliberate macro and
//! adapter change rather than silently treating it as JSON.
//!
//! # Optional HTTP adapters
//!
//! `#[api(reqwest)]` generates `TraitNameClient` and enables a typed Reqwest client when this
//! crate's `reqwest` feature is enabled. `#[api(axum)]` generates `TraitNameAxum` plus one public
//! router per operation when the `axum` feature is enabled. `#[api(reqwest, axum)]` emits both
//! from the same normalized definition. A plain `#[api]` remains framework-independent and pulls
//! neither framework into DTO/contract-only builds.
//!
//! Generated clients use a fallible `try_new` constructor and builder. The builder validates an
//! absolute credential-free HTTP(S) base URL and configures request timeout and response byte
//! limit. A prevalidated [`reqwest::BaseUrl`] and policy-configured `reqwest::framework::Client`
//! may be injected instead. Request authorizers receive the final method, encoded path/query, and
//! exact serialized body bytes; credential headers and all body content are redacted from normal
//! diagnostics.
//!
//! Generated Axum adapters keep authentication, authorization, application state, body limits,
//! and timeout layers outside the adapter. Callers may layer the complete router or an individual
//! generated operation router before merging it into their application.
extern crate self as api_macros;
pub use api_macros_impl::api;
#[cfg(feature = "axum")]
pub mod axum;
#[cfg(feature = "reqwest")]
pub mod reqwest;
/// A transport-neutral HTTP method.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum HttpMethod {
+563
View File
@@ -0,0 +1,563 @@
//! Reqwest transport support used by clients generated with `#[api(reqwest)]`.
//!
//! The generated builder validates the base URL before it can be stored. Callers that enforce
//! stricter egress policy (custom DNS resolution, proxy policy, TLS roots, or connection pinning)
//! can construct their own [`reqwest::Client`] and inject it with the validated [`BaseUrl`].
use std::{fmt, time::Duration};
use futures::StreamExt as _;
use reqwest::{
Client, Method, StatusCode, Url,
header::{HeaderMap, HeaderName, HeaderValue},
};
use serde::{Serialize, de::DeserializeOwned};
/// Default timeout applied to the complete request, including response-body reads.
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Default maximum response body size (1 MiB).
pub const DEFAULT_RESPONSE_BODY_LIMIT: usize = 1024 * 1024;
/// A parsed HTTP(S) base URL without credentials, query, or fragment.
#[derive(Clone, Eq, PartialEq)]
pub struct BaseUrl(Url);
impl BaseUrl {
/// Access the validated URL.
pub fn as_url(&self) -> &Url {
&self.0
}
}
impl fmt::Debug for BaseUrl {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.debug_tuple("BaseUrl").field(&self.0).finish()
}
}
impl TryFrom<&str> for BaseUrl {
type Error = ClientBuildError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Url::parse(value)
.map_err(|_| ClientBuildError::InvalidBaseUrl)
.and_then(Self::try_from)
}
}
impl TryFrom<String> for BaseUrl {
type Error = ClientBuildError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<Url> for BaseUrl {
type Error = ClientBuildError;
fn try_from(mut value: Url) -> Result<Self, Self::Error> {
if !matches!(value.scheme(), "http" | "https")
|| value.cannot_be_a_base()
|| value.host_str().is_none()
|| !value.username().is_empty()
|| value.password().is_some()
|| value.query().is_some()
|| value.fragment().is_some()
{
return Err(ClientBuildError::InvalidBaseUrl);
}
if !value.path().ends_with('/') {
let mut path = value.path().to_owned();
path.push('/');
value.set_path(&path);
}
Ok(Self(value))
}
}
/// Failure while constructing a generated client.
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum ClientBuildError {
/// The base URL was not an absolute credential-free HTTP(S) URL without query or fragment.
InvalidBaseUrl,
/// Reqwest could not construct its default client.
ClientConstruction,
}
impl fmt::Debug for ClientBuildError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, formatter)
}
}
impl fmt::Display for ClientBuildError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::InvalidBaseUrl => "invalid HTTP client base URL",
Self::ClientConstruction => "failed to construct HTTP client",
})
}
}
impl std::error::Error for ClientBuildError {}
/// Exact canonical request material supplied to a [`RequestAuthorizer`].
///
/// `body` is the same byte slice subsequently passed to Reqwest. `path_and_query` is taken from
/// the final URL after path-segment and query encoding.
pub struct AuthorizerRequest<'a> {
pub method: &'a Method,
pub path_and_query: &'a str,
pub body: &'a [u8],
}
impl fmt::Debug for AuthorizerRequest<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AuthorizerRequest")
.field("method", self.method)
.field("path_and_query", &"<redacted>")
.field("body", &"<redacted>")
.finish()
}
}
/// Opaque authorizer failure. Secret-bearing authorizer errors are intentionally not retained.
#[derive(Clone, Copy, Default, Eq, PartialEq)]
pub struct AuthorizationError(());
impl AuthorizationError {
pub const fn new() -> Self {
Self(())
}
}
impl fmt::Debug for AuthorizationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("AuthorizationError")
}
}
impl fmt::Display for AuthorizationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("request authorization failed")
}
}
impl std::error::Error for AuthorizationError {}
/// Supplies credential headers for an exact encoded request.
pub trait RequestAuthorizer: Send + Sync {
fn authorize(&self, request: AuthorizerRequest<'_>) -> Result<HeaderMap, AuthorizationError>;
}
/// Authorizer used by default; it adds no headers.
#[derive(Clone, Copy, Default)]
pub struct NoAuthorizer;
impl RequestAuthorizer for NoAuthorizer {
fn authorize(&self, _request: AuthorizerRequest<'_>) -> Result<HeaderMap, AuthorizationError> {
Ok(HeaderMap::new())
}
}
impl fmt::Debug for NoAuthorizer {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("NoAuthorizer")
}
}
/// Builder shared by generated API-specific client builders.
pub struct ClientBuilder<A = NoAuthorizer> {
base_url: BaseUrl,
client: Option<Client>,
authorizer: A,
request_timeout: Duration,
response_body_limit: usize,
}
impl ClientBuilder<NoAuthorizer> {
pub fn try_new(base_url: impl AsRef<str>) -> Result<Self, ClientBuildError> {
Ok(Self::from_base_url(BaseUrl::try_from(base_url.as_ref())?))
}
pub fn from_base_url(base_url: BaseUrl) -> Self {
Self {
base_url,
client: None,
authorizer: NoAuthorizer,
request_timeout: DEFAULT_REQUEST_TIMEOUT,
response_body_limit: DEFAULT_RESPONSE_BODY_LIMIT,
}
}
}
impl<A> ClientBuilder<A> {
pub fn client(mut self, client: Client) -> Self {
self.client = Some(client);
self
}
pub fn authorizer<B>(self, authorizer: B) -> ClientBuilder<B> {
ClientBuilder {
base_url: self.base_url,
client: self.client,
authorizer,
request_timeout: self.request_timeout,
response_body_limit: self.response_body_limit,
}
}
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
pub fn response_body_limit(mut self, limit: usize) -> Self {
self.response_body_limit = limit;
self
}
pub fn build(self) -> Result<ClientCore<A>, ClientBuildError> {
let client = match self.client {
Some(client) => client,
None => Client::builder()
.build()
.map_err(|_| ClientBuildError::ClientConstruction)?,
};
Ok(ClientCore {
base_url: self.base_url,
client,
authorizer: self.authorizer,
request_timeout: self.request_timeout,
response_body_limit: self.response_body_limit,
})
}
}
impl<A> fmt::Debug for ClientBuilder<A> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientBuilder")
.field("base_url", &self.base_url)
.field("client", &"<opaque>")
.field("authorizer", &"<redacted>")
.field("request_timeout", &self.request_timeout)
.field("response_body_limit", &self.response_body_limit)
.finish()
}
}
/// Framework support held by each generated API client.
pub struct ClientCore<A = NoAuthorizer> {
base_url: BaseUrl,
client: Client,
authorizer: A,
request_timeout: Duration,
response_body_limit: usize,
}
impl<A: Clone> Clone for ClientCore<A> {
fn clone(&self) -> Self {
Self {
base_url: self.base_url.clone(),
client: self.client.clone(),
authorizer: self.authorizer.clone(),
request_timeout: self.request_timeout,
response_body_limit: self.response_body_limit,
}
}
}
impl<A> fmt::Debug for ClientCore<A> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ClientCore")
.field("base_url", &self.base_url)
.field("client", &"<opaque>")
.field("authorizer", &"<redacted>")
.field("request_timeout", &self.request_timeout)
.field("response_body_limit", &self.response_body_limit)
.finish()
}
}
impl<A> ClientCore<A> {
pub fn from_parts(
base_url: BaseUrl,
client: Client,
authorizer: A,
request_timeout: Duration,
response_body_limit: usize,
) -> Self {
Self {
base_url,
client,
authorizer,
request_timeout,
response_body_limit,
}
}
/// Build an endpoint by appending already-separated, unencoded path segments.
pub fn endpoint(&self, segments: &[String]) -> Result<Url, ClientFailure> {
let mut url = self.base_url.0.clone();
let mut path = url
.path_segments_mut()
.map_err(|_| ClientFailure::RequestEncoding)?;
path.pop_if_empty();
for segment in segments {
path.push(segment);
}
drop(path);
Ok(url)
}
pub fn response_body_limit(&self) -> usize {
self.response_body_limit
}
}
impl<A: RequestAuthorizer> ClientCore<A> {
pub async fn send(
&self,
method: Method,
url: Url,
mut headers: HeaderMap,
body: Option<Vec<u8>>,
) -> Result<ReceivedResponse, ClientFailure> {
let body_bytes = body.as_deref().unwrap_or_default();
let path_and_query = &url[url::Position::BeforePath..url::Position::AfterQuery];
let authorization = self
.authorizer
.authorize(AuthorizerRequest {
method: &method,
path_and_query,
body: body_bytes,
})
.map_err(|_| ClientFailure::Authorization)?;
headers.extend(authorization);
let mut request = self
.client
.request(method, url)
.headers(headers)
.timeout(self.request_timeout);
if let Some(body) = body {
request = request
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body);
}
let response = request.send().await.map_err(|error| {
if error.is_timeout() {
ClientFailure::Timeout
} else {
ClientFailure::Transport
}
})?;
let status = response.status();
let mut stream = response.bytes_stream();
let mut bytes = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|error| {
if error.is_timeout() {
ClientFailure::Timeout
} else {
ClientFailure::Transport
}
})?;
let Some(size) = bytes.len().checked_add(chunk.len()) else {
return Err(ClientFailure::ResponseTooLarge {
limit: self.response_body_limit,
});
};
if size > self.response_body_limit {
return Err(ClientFailure::ResponseTooLarge {
limit: self.response_body_limit,
});
}
bytes.extend_from_slice(&chunk);
}
Ok(ReceivedResponse { status, bytes })
}
}
/// Encode a serializable query DTO and append its pairs without changing existing pairs.
pub fn append_query<T: Serialize + ?Sized>(url: &mut Url, value: &T) -> Result<(), ClientFailure> {
let encoded = serde_urlencoded::to_string(value).map_err(|_| ClientFailure::RequestEncoding)?;
if encoded.is_empty() {
return Ok(());
}
let combined = match url.query() {
Some(existing) if !existing.is_empty() => format!("{existing}&{encoded}"),
_ => encoded,
};
url.set_query(Some(&combined));
Ok(())
}
/// Insert a string-form header value.
pub fn insert_header<T: fmt::Display>(
headers: &mut HeaderMap,
name: &str,
value: &T,
) -> Result<(), ClientFailure> {
let name =
HeaderName::from_bytes(name.as_bytes()).map_err(|_| ClientFailure::RequestEncoding)?;
let value =
HeaderValue::from_str(&value.to_string()).map_err(|_| ClientFailure::RequestEncoding)?;
headers.insert(name, value);
Ok(())
}
/// Serialize the request body once. These exact bytes are authorized and transmitted.
pub fn encode_json<T: Serialize + ?Sized>(value: &T) -> Result<Vec<u8>, ClientFailure> {
serde_json::to_vec(value).map_err(|_| ClientFailure::RequestEncoding)
}
/// Response received under the configured byte limit. Its content is always redacted in Debug.
pub struct ReceivedResponse {
status: StatusCode,
bytes: Vec<u8>,
}
impl ReceivedResponse {
pub fn status(&self) -> StatusCode {
self.status
}
pub fn decode_json<T: DeserializeOwned>(&self, kind: DecodeKind) -> Result<T, ClientFailure> {
serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind })
}
pub fn require_empty(&self) -> Result<(), ClientFailure> {
if self.bytes.is_empty() {
Ok(())
} else {
Err(ClientFailure::UnexpectedBody)
}
}
}
impl fmt::Debug for ReceivedResponse {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ReceivedResponse")
.field("status", &self.status)
.field("bytes", &"<redacted>")
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DecodeKind {
Success,
PublicError,
}
/// Non-public-error failure from a generated client.
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum ClientFailure {
Authorization,
RequestEncoding,
Transport,
Timeout,
ResponseTooLarge { limit: usize },
UnexpectedStatus { expected: u16, actual: u16 },
Decode { kind: DecodeKind },
UnexpectedBody,
}
impl fmt::Debug for ClientFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, formatter)
}
}
impl fmt::Display for ClientFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Authorization => formatter.write_str("request authorization failed"),
Self::RequestEncoding => formatter.write_str("request encoding failed"),
Self::Transport => formatter.write_str("HTTP transport failed"),
Self::Timeout => formatter.write_str("HTTP request timed out"),
Self::ResponseTooLarge { limit } => {
write!(formatter, "response body exceeded {limit} bytes")
}
Self::UnexpectedStatus { expected, actual } => {
write!(
formatter,
"expected HTTP status {expected}, received {actual}"
)
}
Self::Decode {
kind: DecodeKind::Success,
} => formatter.write_str("success response JSON was malformed"),
Self::Decode {
kind: DecodeKind::PublicError,
} => formatter.write_str("public error response JSON was malformed"),
Self::UnexpectedBody => formatter.write_str("empty response contained a body"),
}
}
}
impl std::error::Error for ClientFailure {}
/// Typed error returned by generated client methods.
pub enum ClientError<E> {
Failure(ClientFailure),
Public { status: StatusCode, error: E },
}
impl<E> ClientError<E> {
pub fn failure(failure: ClientFailure) -> Self {
Self::Failure(failure)
}
pub fn public(status: StatusCode, error: E) -> Self {
Self::Public { status, error }
}
pub fn into_public_error(self) -> Option<E> {
match self {
Self::Public { error, .. } => Some(error),
Self::Failure(_) => None,
}
}
}
impl<E> From<ClientFailure> for ClientError<E> {
fn from(value: ClientFailure) -> Self {
Self::Failure(value)
}
}
impl<E> fmt::Debug for ClientError<E> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Failure(failure) => formatter.debug_tuple("Failure").field(failure).finish(),
Self::Public { status, .. } => formatter
.debug_struct("Public")
.field("status", status)
.field("error", &"<redacted>")
.finish(),
}
}
}
impl<E> fmt::Display for ClientError<E> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Failure(failure) => fmt::Display::fmt(failure, formatter),
Self::Public { status, .. } => write!(formatter, "public HTTP error {status}"),
}
}
}
impl<E> std::error::Error for ClientError<E> {}
/// Reexports used by generated code and advanced client construction.
pub mod framework {
pub use reqwest::{Client, Method, StatusCode, Url, header};
}
+277
View File
@@ -0,0 +1,277 @@
#![cfg(all(feature = "reqwest", feature = "axum"))]
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use api_macros::{api, reqwest as api_reqwest};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CreateWidget {
name: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Lookup {
mode: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Widget {
id: u64,
name: String,
mode: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct PublicError {
message: String,
}
#[api(reqwest, axum)]
pub trait WidgetApi {
#[post(
"/widgets/{widget_id}",
operation_id = "widgets.create",
status = 201,
error_status = 422
)]
async fn create(
&self,
#[path] widget_id: u64,
#[query] lookup: Lookup,
#[header("authorization")] authorization: String,
#[body] request: CreateWidget,
) -> Result<Widget, PublicError>;
#[delete("/widgets/{widget_id}", operation_id = "widgets.delete", status = 204)]
async fn delete(&self, #[path] widget_id: u64) -> ();
}
#[derive(Clone)]
pub struct WidgetService;
impl WidgetApi for WidgetService {
async fn create(
&self,
widget_id: u64,
lookup: Lookup,
authorization: String,
request: CreateWidget,
) -> Result<Widget, PublicError> {
assert_eq!(authorization, "Bearer top-secret");
if request.name == "bad" {
return Err(PublicError {
message: "sensitive-public-detail".to_owned(),
});
}
Ok(Widget {
id: widget_id,
name: request.name,
mode: lookup.mode,
})
}
async fn delete(&self, _widget_id: u64) {}
}
#[api(reqwest)]
pub trait FailureApi {
#[get("/malformed", operation_id = "failure.malformed", status = 200)]
async fn malformed(&self) -> Widget;
#[get("/oversized", operation_id = "failure.oversized", status = 200)]
async fn oversized(&self) -> Widget;
#[get("/slow", operation_id = "failure.slow", status = 200)]
async fn slow(&self) -> Widget;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuthorizedRequest {
method: String,
path_and_query: String,
body: Vec<u8>,
}
#[derive(Clone)]
pub struct RecordingAuthorizer {
request: Arc<Mutex<Option<AuthorizedRequest>>>,
credential: String,
}
impl api_reqwest::RequestAuthorizer for RecordingAuthorizer {
fn authorize(
&self,
request: api_reqwest::AuthorizerRequest<'_>,
) -> Result<api_reqwest::framework::header::HeaderMap, api_reqwest::AuthorizationError> {
*self.request.lock().expect("recording lock") = Some(AuthorizedRequest {
method: request.method.to_string(),
path_and_query: request.path_and_query.to_owned(),
body: request.body.to_vec(),
});
let mut headers = api_reqwest::framework::header::HeaderMap::new();
headers.insert(
api_reqwest::framework::header::AUTHORIZATION,
self.credential.parse().expect("valid fixture credential"),
);
Ok(headers)
}
}
async fn malformed() -> &'static str {
"this is not json"
}
async fn oversized() -> String {
"response-content-that-must-never-appear-in-debug-output".to_owned()
}
async fn slow() -> &'static str {
tokio::time::sleep(Duration::from_millis(100)).await;
"{}"
}
#[tokio::test]
async fn generated_client_and_router_follow_the_normalized_contract() {
let app = WidgetApiAxum::router(WidgetService).merge(
api_macros::axum::framework::Router::new()
.route("/malformed", api_macros::axum::framework::get(malformed))
.route("/oversized", api_macros::axum::framework::get(oversized))
.route("/slow", api_macros::axum::framework::get(slow)),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind 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 fixture API");
});
let base_url = format!("http://{address}/");
let recorded = Arc::new(Mutex::new(None));
let client = WidgetApiClient::builder(&base_url)
.expect("validated base URL")
.authorizer(RecordingAuthorizer {
request: recorded.clone(),
credential: "Bearer top-secret".to_owned(),
})
.build()
.expect("client build");
let widget = client
.create(
7,
Lookup {
mode: "full".to_owned(),
},
"placeholder-overwritten-by-authorizer".to_owned(),
CreateWidget {
name: "alpha".to_owned(),
},
)
.await
.expect("typed success response");
assert_eq!(
widget,
Widget {
id: 7,
name: "alpha".to_owned(),
mode: "full".to_owned(),
}
);
assert_eq!(
recorded.lock().expect("recording lock").clone(),
Some(AuthorizedRequest {
method: "POST".to_owned(),
path_and_query: "/widgets/7?mode=full".to_owned(),
body: br#"{"name":"alpha"}"#.to_vec(),
})
);
assert!(!format!("{client:?}").contains("top-secret"));
client.delete(7).await.expect("typed empty response");
let public_error = client
.create(
8,
Lookup {
mode: "full".to_owned(),
},
"placeholder".to_owned(),
CreateWidget {
name: "bad".to_owned(),
},
)
.await
.expect_err("typed public error");
assert!(!format!("{public_error:?}").contains("sensitive-public-detail"));
assert_eq!(
public_error.into_public_error(),
Some(PublicError {
message: "sensitive-public-detail".to_owned(),
})
);
let malformed_error = FailureApiClient::try_new(&base_url)
.expect("failure client")
.malformed()
.await
.expect_err("malformed JSON must fail");
assert_eq!(
format!("{malformed_error:?}"),
"Failure(success response JSON was malformed)"
);
assert!(!format!("{malformed_error:?}").contains("this is not json"));
let oversized_error = FailureApiClient::builder(&base_url)
.expect("failure client builder")
.response_body_limit(8)
.build()
.expect("failure client")
.oversized()
.await
.expect_err("oversized body must fail");
assert!(matches!(
oversized_error,
api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::ResponseTooLarge {
limit: 8
})
));
assert!(!format!("{oversized_error:?}").contains("response-content"));
let timeout_error = FailureApiClient::builder(&base_url)
.expect("failure client builder")
.request_timeout(Duration::from_millis(10))
.build()
.expect("failure client")
.slow()
.await
.expect_err("slow response must time out");
assert!(matches!(
timeout_error,
api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::Timeout)
));
let _route_specific = widget_api_axum::create(Arc::new(WidgetService));
server.abort();
}
#[test]
fn base_urls_are_strict_and_named_adapter_types_are_public() {
assert!(api_reqwest::BaseUrl::try_from("ftp://example.test/").is_err());
assert!(api_reqwest::BaseUrl::try_from("https://user@example.test/").is_err());
assert!(api_reqwest::BaseUrl::try_from("https://example.test/?token=secret").is_err());
assert!(api_reqwest::BaseUrl::try_from("https://example.test/#fragment").is_err());
fn assert_client(_: &WidgetApiClient) {}
fn assert_builder(_: &WidgetApiClientBuilder) {}
let client = WidgetApiClient::try_new("https://example.test/").expect("client");
let builder = WidgetApiClient::builder("https://example.test/").expect("builder");
assert_client(&client);
assert_builder(&builder);
}