feat(api): generate reqwest and axum adapters
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user