From 42d0e7b934acad065286f56881e77d384c5d7b02 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Fri, 5 Jun 2026 14:17:42 +0200 Subject: [PATCH 1/6] feat(transport): add HttpTransport abstraction + in-process e2e tests Generated REST/JSON-RPC/File clients hard-coded reqwest::Client and were never exercised against a server in tests. Introduce ras-transport-core, an object-safe HttpTransport trait (dyn dispatch, conditional Send mirroring WebSocketTransport) with two impls: ReqwestTransport (production) and AxumTestTransport (in-process, wraps axum_test::TestServer). - Typed TransportError replaces Box in generated client returns. - Streaming RequestBody/TransportResponse; hand-rolled RFC 7578 MultipartBuilder replaces reqwest::multipart::Form (uploads stream from disk on native, downloads stream the response body). - Generated clients store Arc and gain build_with_transport; query serialization moves to serde_urlencoded (reqwest .query() parity). - REST/JSON-RPC/File migrated; bidirectional left unchanged (WebSocket, already has WebSocketTransport). Breaking (pre-1.0): file download methods now return TransportResponse instead of reqwest::Response; generated methods return Result. Adds client->server e2e tests over AxumTestTransport for every macro (incl. the from-disk streaming upload under --features fs) plus transport-core unit tests (multipart framing, query parity, request/response/transport at ~100% lines). Closes #17 Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 37 +- Cargo.toml | 1 + crates/core/ras-transport-core/Cargo.toml | 42 ++ crates/core/ras-transport-core/README.md | 30 ++ .../src/axum_test_transport.rs | 101 ++++ crates/core/ras-transport-core/src/error.rs | 73 +++ crates/core/ras-transport-core/src/lib.rs | 431 ++++++++++++++++++ .../core/ras-transport-core/src/multipart.rs | 260 +++++++++++ crates/core/ras-transport-core/src/request.rs | 116 +++++ .../src/reqwest_transport.rs | 99 ++++ .../core/ras-transport-core/src/response.rs | 93 ++++ .../tests/multipart_framing.rs | 93 ++++ .../tests/multipart_streaming.rs | 93 ++++ .../tests/query_serialization.rs | 86 ++++ .../tests/request_response_units.rs | 292 ++++++++++++ .../tests/transport_in_process.rs | 158 +++++++ crates/rest/ras-file-macro/Cargo.toml | 14 +- crates/rest/ras-file-macro/src/client.rs | 232 ++++++---- crates/rest/ras-file-macro/tests/e2e.rs | 117 ++++- .../rest/ras-file-macro/tests/support/mod.rs | 11 + crates/rest/ras-rest-macro/Cargo.toml | 6 +- crates/rest/ras-rest-macro/src/client.rs | 141 +++--- crates/rest/ras-rest-macro/tests/e2e.rs | 137 +++--- .../ras-rest-macro/tests/http_integration.rs | 81 +++- .../rest/ras-rest-macro/tests/support/mod.rs | 15 + crates/rpc/ras-jsonrpc-macro/Cargo.toml | 6 +- crates/rpc/ras-jsonrpc-macro/src/client.rs | 97 ++-- crates/rpc/ras-jsonrpc-macro/tests/e2e.rs | 84 ++++ .../ras-jsonrpc-macro/tests/support/mod.rs | 12 + examples/basic-jsonrpc/api/Cargo.toml | 4 +- examples/bidirectional-chat/api/Cargo.toml | 2 + examples/file-service-example/Cargo.toml | 4 +- .../file-service-api/Cargo.toml | 6 +- examples/oauth2-demo/api/Cargo.toml | 4 +- .../rest-wasm-example/rest-api/Cargo.toml | 3 +- .../fixtures/jsonrpc-fixture/Cargo.toml | 3 +- .../fixtures/rest-fixture/Cargo.toml | 3 +- 37 files changed, 2672 insertions(+), 315 deletions(-) create mode 100644 crates/core/ras-transport-core/Cargo.toml create mode 100644 crates/core/ras-transport-core/README.md create mode 100644 crates/core/ras-transport-core/src/axum_test_transport.rs create mode 100644 crates/core/ras-transport-core/src/error.rs create mode 100644 crates/core/ras-transport-core/src/lib.rs create mode 100644 crates/core/ras-transport-core/src/multipart.rs create mode 100644 crates/core/ras-transport-core/src/request.rs create mode 100644 crates/core/ras-transport-core/src/reqwest_transport.rs create mode 100644 crates/core/ras-transport-core/src/response.rs create mode 100644 crates/core/ras-transport-core/tests/multipart_framing.rs create mode 100644 crates/core/ras-transport-core/tests/multipart_streaming.rs create mode 100644 crates/core/ras-transport-core/tests/query_serialization.rs create mode 100644 crates/core/ras-transport-core/tests/request_response_units.rs create mode 100644 crates/core/ras-transport-core/tests/transport_in_process.rs diff --git a/Cargo.lock b/Cargo.lock index a9c822d..84ae5e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,7 +234,7 @@ dependencies = [ "ras-jsonrpc-macro", "ras-jsonrpc-types", "ras-permission-manifest", - "reqwest", + "ras-transport-core", "schemars", "serde", "serde_json", @@ -275,6 +275,7 @@ dependencies = [ "ras-permission-manifest", "ras-rest-core", "ras-rest-macro", + "ras-transport-core", "reqwest", "schemars", "serde", @@ -1081,6 +1082,7 @@ dependencies = [ "ras-file-core", "ras-file-macro", "ras-permission-manifest", + "ras-transport-core", "reqwest", "schemars", "serde", @@ -1126,6 +1128,7 @@ dependencies = [ "ras-file-core", "ras-file-macro", "ras-permission-manifest", + "ras-transport-core", "reqwest", "serde", "serde_json", @@ -2068,7 +2071,7 @@ dependencies = [ "ras-jsonrpc-macro", "ras-jsonrpc-types", "ras-permission-manifest", - "reqwest", + "ras-transport-core", "schemars", "serde", "serde_json", @@ -2347,6 +2350,7 @@ dependencies = [ "ras-jsonrpc-macro", "ras-jsonrpc-types", "ras-permission-manifest", + "ras-transport-core", "reqwest", "schemars", "serde", @@ -2367,6 +2371,7 @@ dependencies = [ "ras-permission-manifest", "ras-rest-core", "ras-rest-macro", + "ras-transport-core", "reqwest", "schemars", "serde", @@ -2663,12 +2668,13 @@ dependencies = [ "axum", "axum-test", "criterion", + "futures-util", "proc-macro2", "quote", "ras-auth-core", "ras-file-core", "ras-permission-manifest", - "reqwest", + "ras-transport-core", "schemars", "serde", "serde_json", @@ -2873,7 +2879,7 @@ dependencies = [ "ras-jsonrpc-core", "ras-jsonrpc-types", "ras-permission-manifest", - "reqwest", + "ras-transport-core", "schemars", "serde", "serde_json", @@ -2969,7 +2975,7 @@ dependencies = [ "ras-jsonrpc-core", "ras-permission-manifest", "ras-rest-core", - "reqwest", + "ras-transport-core", "schemars", "serde", "serde_json", @@ -2979,6 +2985,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "ras-transport-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "axum-test", + "bytes", + "futures-core", + "futures-util", + "http", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + [[package]] name = "ras-version-core" version = "0.1.0" @@ -3147,6 +3173,7 @@ dependencies = [ "ras-permission-manifest", "ras-rest-core", "ras-rest-macro", + "ras-transport-core", "reqwest", "schemars", "serde", diff --git a/Cargo.toml b/Cargo.toml index 945c112..8180207 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ rand = "0.8" ratatui = "0.29" schemars = "1.0.0-alpha.20" serde_json = "1.0" +serde_urlencoded = "0.7" sha2 = "0.10" tempfile = "3.13" thiserror = "2.0" diff --git a/crates/core/ras-transport-core/Cargo.toml b/crates/core/ras-transport-core/Cargo.toml new file mode 100644 index 0000000..e979534 --- /dev/null +++ b/crates/core/ras-transport-core/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "ras-transport-core" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "HTTP transport abstraction for generated Rust Agent Stack clients (production reqwest + in-process axum-test transports)" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +homepage = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" + +[dependencies] +async-trait = { workspace = true } +bytes = { workspace = true } +futures-core = { workspace = true } +futures-util = { workspace = true } +http = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_urlencoded = { workspace = true } +thiserror = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +reqwest = { workspace = true, optional = true } +axum-test = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +tokio-util = { workspace = true, optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["json"], optional = true } + +[dev-dependencies] +tokio = { workspace = true } +futures-util = { workspace = true } +axum = { workspace = true } +serde = { workspace = true } + +[features] +default = [] +reqwest = ["dep:reqwest"] +fs = ["dep:tokio", "dep:tokio-util"] +axum-test = ["dep:axum-test"] diff --git a/crates/core/ras-transport-core/README.md b/crates/core/ras-transport-core/README.md new file mode 100644 index 0000000..cc85e52 --- /dev/null +++ b/crates/core/ras-transport-core/README.md @@ -0,0 +1,30 @@ +# ras-transport-core + +HTTP transport abstraction for generated Rust Agent Stack clients. + +Generated REST / JSON-RPC / File clients dispatch through the `HttpTransport` +trait instead of hard-coding `reqwest::Client`. Two implementations ship here: + +- `ReqwestTransport` (production, `reqwest` feature) — a dumb pipe over + `reqwest::Client` that streams request and response bodies on native targets. +- `AxumTestTransport` (`axum-test` feature, native only) — wraps an + `axum_test::TestServer` so generated clients can be exercised end-to-end + against a server with no sockets. + +This trait is the HTTP sibling of the `WebSocketTransport` abstraction in +`ras-jsonrpc-bidirectional-client`, following the same dyn-dispatch + +conditional-`Send` (`async_trait(?Send)` on wasm + `TransportThreadBounds`) +pattern so a single `Arc` works on both native and wasm. + +## Features + +- `reqwest` — production transport (declared for both native and wasm targets). +- `fs` — native file-part streaming from disk (`MultipartBuilder::file_path` / + `stream_part`), pulls in `tokio` + `tokio-util`. +- `axum-test` — in-process test transport (native only). + +## WASM + +WASM is a hard target. The fetch API cannot stream request bodies, so on wasm +`RequestBody::Stream` is collected before sending and `MultipartBuilder` +file/stream parts are `fs`-gated (native only). Response bodies still work. diff --git a/crates/core/ras-transport-core/src/axum_test_transport.rs b/crates/core/ras-transport-core/src/axum_test_transport.rs new file mode 100644 index 0000000..dfb33da --- /dev/null +++ b/crates/core/ras-transport-core/src/axum_test_transport.rs @@ -0,0 +1,101 @@ +//! In-process test transport wrapping an `axum_test::TestServer`. +//! +//! This is the only transport that buffers: `axum-test` drives the router +//! directly and has no streaming request/response API. It exists so generated +//! clients can be exercised end-to-end against a server with no sockets. + +#![cfg(all(not(target_arch = "wasm32"), feature = "axum-test"))] + +use std::sync::Arc; + +use axum_test::TestServer; +use bytes::BytesMut; +use futures_util::StreamExt; +use futures_util::stream; + +use crate::error::TransportError; +use crate::request::{RequestBody, TransportRequest}; +use crate::response::TransportResponse; +use crate::{HttpTransport, byte_stream_from}; + +/// A [`HttpTransport`] that dispatches into an `axum_test::TestServer`. +/// +/// `TestServer` is **not** `Clone`, so it is held behind an `Arc`. +#[derive(Clone)] +pub struct AxumTestTransport { + server: Arc, +} + +impl AxumTestTransport { + /// Construct from an owned `TestServer`. + pub fn new(server: TestServer) -> Self { + AxumTestTransport { + server: Arc::new(server), + } + } + + /// Construct from a shared `TestServer`. + pub fn from_arc(server: Arc) -> Self { + AxumTestTransport { server } + } +} + +/// Strip scheme + authority from an absolute URL, leaving `path[?query]`. +/// +/// `axum-test` routes against a path, not a full URL. Falls back to returning +/// the input unchanged when it does not look like an absolute URL. +fn strip_origin(url: &str) -> String { + // Find "scheme://", then the first '/' after the authority. + if let Some(scheme_end) = url.find("://") { + let after = &url[scheme_end + 3..]; + match after.find('/') { + Some(slash) => after[slash..].to_string(), + None => "/".to_string(), + } + } else { + url.to_string() + } +} + +#[async_trait::async_trait] +impl HttpTransport for AxumTestTransport { + async fn execute( + &self, + request: TransportRequest, + ) -> Result { + let path = strip_origin(&request.url); + let mut req = self.server.method(request.method, &path); + + for (name, value) in request.headers.iter() { + req = req.add_header(name.clone(), value.clone()); + } + + // Collect the (possibly streaming) request body — axum-test buffers. + let body_bytes = match request.body { + RequestBody::Empty => bytes::Bytes::new(), + RequestBody::Bytes(b) => b, + RequestBody::Stream(mut s) => { + let mut buf = BytesMut::new(); + while let Some(chunk) = s.next().await { + buf.extend_from_slice(&chunk?); + } + buf.freeze() + } + }; + if !body_bytes.is_empty() { + req = req.bytes(body_bytes); + } + + let resp = req.await; + let status = resp.status_code(); + let headers = resp.headers().clone(); + let bytes = resp.into_bytes(); + + // Single-chunk response stream. + let body_stream = byte_stream_from(stream::once(async move { + Ok::(bytes) + })); + + Ok(TransportResponse::new(status, headers, body_stream)) + } +} diff --git a/crates/core/ras-transport-core/src/error.rs b/crates/core/ras-transport-core/src/error.rs new file mode 100644 index 0000000..42c83dc --- /dev/null +++ b/crates/core/ras-transport-core/src/error.rs @@ -0,0 +1,73 @@ +//! Typed transport error. +//! +//! Generated clients return `Result` instead of the old +//! `Box`, so callers can match on the +//! failure mode (connection vs. HTTP status vs. (de)serialization vs. a +//! JSON-RPC application error). + +use thiserror::Error; + +/// Errors produced by an [`crate::HttpTransport`] or by the helpers that build +/// requests / decode responses. +#[derive(Debug, Error)] +pub enum TransportError { + /// The request never produced an HTTP response (DNS, TCP, TLS, connect, etc). + #[error("connection error: {0}")] + Connection(String), + + /// The server returned a non-success HTTP status. + /// + /// Produced by [`crate::TransportResponse::error_for_status`]; transports + /// themselves never inspect status (they are dumb pipes). + #[error("http status {status}: {body}")] + Status { + /// The HTTP status code. + status: http::StatusCode, + /// The (best-effort, lossy-UTF8) response body captured for diagnostics. + body: String, + }, + + /// Serializing a request body / query value failed. + #[error("serialize error: {0}")] + Serialize(#[source] serde_json::Error), + + /// Deserializing a response body failed. + #[error("deserialize error: {0}")] + Deserialize(#[source] serde_json::Error), + + /// Reading or writing a streaming body failed mid-flight. + #[error("body error: {0}")] + Body(String), + + /// A JSON-RPC 2.0 error object was returned in the response envelope. + #[error("json-rpc error {code}: {message}")] + JsonRpc { + /// The JSON-RPC error code. + code: i64, + /// The JSON-RPC error message. + message: String, + }, +} + +impl TransportError { + /// Construct a [`TransportError::Status`] from a status code and a body. + pub fn http_status(status: http::StatusCode, body: impl Into) -> Self { + TransportError::Status { + status, + body: body.into(), + } + } +} + +#[cfg(feature = "reqwest")] +impl From for TransportError { + fn from(err: reqwest::Error) -> Self { + // Decode failures (including body-read failures) map to `Body`; every + // other reqwest failure is treated as a connection-level problem. + if err.is_decode() { + TransportError::Body(err.to_string()) + } else { + TransportError::Connection(err.to_string()) + } + } +} diff --git a/crates/core/ras-transport-core/src/lib.rs b/crates/core/ras-transport-core/src/lib.rs new file mode 100644 index 0000000..42a203c --- /dev/null +++ b/crates/core/ras-transport-core/src/lib.rs @@ -0,0 +1,431 @@ +//! HTTP transport abstraction for generated Rust Agent Stack clients. +//! +//! Generated REST / JSON-RPC / File clients dispatch through the +//! [`HttpTransport`] trait instead of hard-coding `reqwest::Client`. Two impls +//! ship here: [`ReqwestTransport`] (production, `reqwest` feature) and +//! [`AxumTestTransport`] (in-process, wraps `axum_test::TestServer`, native + +//! `axum-test` feature) so clients can be exercised end-to-end against a server +//! with no sockets. +//! +//! # Relationship to `WebSocketTransport` +//! +//! This trait is the HTTP sibling of the `WebSocketTransport` abstraction in +//! `ras-jsonrpc-bidirectional-client` +//! (`crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/lib.rs`). +//! Both follow the same dyn-dispatch + conditional-`Send` pattern: +//! `#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]` together with the +//! [`TransportThreadBounds`] marker, so a single `Arc` works on both +//! native and wasm targets. They are intentionally separate: bidirectional RPC +//! is WebSocket (full-duplex frames), not request/response HTTP, and must not +//! be routed through `HttpTransport`. +//! +//! On wasm, request bodies cannot be streamed (the fetch API has no streaming +//! request body), so [`RequestBody::Stream`] is collected before sending; +//! response bodies still stream. + +use std::pin::Pin; + +use bytes::Bytes; +use futures_core::Stream; +use serde::Serialize; +use serde::de::DeserializeOwned; + +pub mod error; +pub mod multipart; +pub mod request; +pub mod response; + +#[cfg(feature = "reqwest")] +pub mod reqwest_transport; + +#[cfg(all(not(target_arch = "wasm32"), feature = "axum-test"))] +pub mod axum_test_transport; + +pub use error::TransportError; +pub use multipart::MultipartBuilder; +pub use request::{RequestBody, TransportRequest}; +pub use response::TransportResponse; + +#[cfg(feature = "reqwest")] +pub use reqwest_transport::ReqwestTransport; + +#[cfg(all(not(target_arch = "wasm32"), feature = "axum-test"))] +pub use axum_test_transport::AxumTestTransport; + +/// Re-export of the `http` crate so generated code can refer to +/// `::ras_transport_core::http::Method` etc. without a direct dependency. +pub use http; + +// --- Thread-bound marker, mirroring the bidirectional-client precedent. --- + +/// Marker for the thread bounds a transport (and its streams) must satisfy. +/// +/// `Send + Sync` on native; unconstrained on wasm (single-threaded). +#[cfg(not(target_arch = "wasm32"))] +pub trait TransportThreadBounds: Send + Sync {} + +#[cfg(not(target_arch = "wasm32"))] +impl TransportThreadBounds for T {} + +/// Marker for the thread bounds a transport (and its streams) must satisfy. +#[cfg(target_arch = "wasm32")] +pub trait TransportThreadBounds {} + +#[cfg(target_arch = "wasm32")] +impl TransportThreadBounds for T {} + +// --- Byte stream alias, conditionally `Send`. --- + +/// A streaming sequence of body chunks. `Send` on native, not on wasm. +#[cfg(not(target_arch = "wasm32"))] +pub type ByteStream = Pin> + Send>>; + +/// A streaming sequence of body chunks. +#[cfg(target_arch = "wasm32")] +pub type ByteStream = Pin>>>; + +/// Box a stream into a [`ByteStream`], applying the conditional `Send` bound. +#[cfg(not(target_arch = "wasm32"))] +pub fn byte_stream_from(stream: S) -> ByteStream +where + S: Stream> + Send + 'static, +{ + Box::pin(stream) +} + +/// Box a stream into a [`ByteStream`]. +#[cfg(target_arch = "wasm32")] +pub fn byte_stream_from(stream: S) -> ByteStream +where + S: Stream> + 'static, +{ + Box::pin(stream) +} + +// --- The transport trait. --- + +/// Abstraction over the wire transport used by a generated HTTP client. +/// +/// See the [crate-level docs](crate) for the relationship with +/// `WebSocketTransport`. +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +pub trait HttpTransport: TransportThreadBounds { + /// Execute a request and return the (streaming) response. + /// + /// Implementations are dumb pipes: they MUST NOT inspect the status code. + /// Callers map non-success statuses via + /// [`TransportResponse::error_for_status`]. + async fn execute( + &self, + request: TransportRequest, + ) -> Result; +} + +// --- Query / JSON helpers. --- + +/// Serialize a single query value to its `application/x-www-form-urlencoded` +/// form for one `key`, returning zero or more `(key, value)` pairs. +/// +/// Serializing one key/value at a time (rather than a whole struct) means a +/// `Vec` produces repeated keys and `#[serde(rename = ...)]` enum variants +/// encode by their rename — matching reqwest's old `.query()` behavior exactly. +/// `Option::None` produces no pairs. +pub fn serialize_query_value( + key: &str, + value: &T, +) -> Result, TransportError> { + // We collect each scalar value into its own `(key, encoded_value)` entry so + // that scalars produce one pair, sequences produce repeated keys, and + // `None` produces nothing. Each scalar is rendered through + // `serde_urlencoded` (one key=value pair) so enum `#[serde(rename)]`s and + // numeric/bool formatting match reqwest's old `.query()` byte-for-byte. + let mut collector = QueryValueCollector { values: Vec::new() }; + value + .serialize(&mut collector) + .map_err(|e| TransportError::Serialize(serde::ser::Error::custom(e.to_string())))?; + Ok(collector + .values + .into_iter() + .map(|v| (key.to_string(), v)) + .collect()) +} + +/// Serialize several `(key, value)` query parameters and join them into a +/// single query string (without a leading `?`). Empty result if no pairs. +/// +/// The final byte-level encoding is delegated to `serde_urlencoded` (the same +/// crate reqwest's `.query()` uses internally), so the produced wire query +/// string matches the pre-transport reqwest client exactly — including its +/// `application/x-www-form-urlencoded` unreserved set (`*` stays raw, `~` +/// becomes `%7E`, space becomes `+`). Keys are emitted in order, so repeated +/// keys (from `Vec`) preserve their sequence. +pub fn serialize_query_pairs(pairs: &[(String, String)]) -> String { + serde_urlencoded::to_string(pairs).unwrap_or_default() +} + +/// Deserialize JSON bytes into `T`, mapping failures to +/// [`TransportError::Deserialize`]. +pub fn deserialize_json(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(TransportError::Deserialize) +} + +// --- internal helpers --- + +/// Render one scalar value through `serde_urlencoded` and return the decoded +/// value string (the part after `=` of a single `k=v` pair), so enum renames +/// and scalar formatting match reqwest's old `.query()` exactly. +fn encode_scalar(value: &T) -> Result { + use serde::ser::Error as _; + // serde_urlencoded serializes a sequence of (key, value) tuples. + let encoded = serde_urlencoded::to_string([("v", value)]) + .map_err(|e| serde_json::Error::custom(e.to_string()))?; + // `encoded` looks like "v="; strip the "v=" prefix and decode. + let raw = encoded.strip_prefix("v=").unwrap_or(&encoded); + Ok(percent_decode(raw)) +} + +/// A serde `Serializer` that collects scalar query values, expanding sequences +/// into multiple values and treating `Option::None`/unit as empty. +struct QueryValueCollector { + values: Vec, +} + +type QueryResult = Result<(), serde_json::Error>; + +macro_rules! collect_scalar { + ($method:ident, $ty:ty) => { + fn $method(self, v: $ty) -> QueryResult { + self.values.push(encode_scalar(&v)?); + Ok(()) + } + }; +} + +impl<'a> serde::Serializer for &'a mut QueryValueCollector { + type Ok = (); + type Error = serde_json::Error; + type SerializeSeq = Self; + type SerializeTuple = Self; + type SerializeTupleStruct = Self; + type SerializeTupleVariant = serde::ser::Impossible<(), serde_json::Error>; + type SerializeMap = serde::ser::Impossible<(), serde_json::Error>; + type SerializeStruct = serde::ser::Impossible<(), serde_json::Error>; + type SerializeStructVariant = serde::ser::Impossible<(), serde_json::Error>; + + collect_scalar!(serialize_bool, bool); + collect_scalar!(serialize_i8, i8); + collect_scalar!(serialize_i16, i16); + collect_scalar!(serialize_i32, i32); + collect_scalar!(serialize_i64, i64); + collect_scalar!(serialize_u8, u8); + collect_scalar!(serialize_u16, u16); + collect_scalar!(serialize_u32, u32); + collect_scalar!(serialize_u64, u64); + collect_scalar!(serialize_f32, f32); + collect_scalar!(serialize_f64, f64); + collect_scalar!(serialize_char, char); + + fn serialize_str(self, v: &str) -> QueryResult { + self.values.push(v.to_string()); + Ok(()) + } + + fn serialize_bytes(self, _v: &[u8]) -> QueryResult { + use serde::ser::Error as _; + Err(serde_json::Error::custom("bytes are not a valid query value")) + } + + fn serialize_none(self) -> QueryResult { + Ok(()) + } + + fn serialize_some(self, value: &T) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(self) + } + + fn serialize_unit(self) -> QueryResult { + Ok(()) + } + + fn serialize_unit_struct(self, _name: &'static str) -> QueryResult { + Ok(()) + } + + fn serialize_unit_variant( + self, + _name: &'static str, + _variant_index: u32, + variant: &'static str, + ) -> QueryResult { + // Honor `#[serde(rename = ...)]`: `variant` is already the renamed form. + self.values.push(variant.to_string()); + Ok(()) + } + + fn serialize_newtype_struct(self, _name: &'static str, value: &T) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(self) + } + + fn serialize_newtype_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + value: &T, + ) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(self) + } + + fn serialize_seq(self, _len: Option) -> Result { + Ok(self) + } + + fn serialize_tuple(self, _len: usize) -> Result { + Ok(self) + } + + fn serialize_tuple_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + Ok(self) + } + + fn serialize_tuple_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + use serde::ser::Error as _; + Err(serde_json::Error::custom( + "tuple variants are not valid query values", + )) + } + + fn serialize_map(self, _len: Option) -> Result { + use serde::ser::Error as _; + Err(serde_json::Error::custom("maps are not valid query values")) + } + + fn serialize_struct( + self, + _name: &'static str, + _len: usize, + ) -> Result { + use serde::ser::Error as _; + Err(serde_json::Error::custom( + "structs are not valid query values", + )) + } + + fn serialize_struct_variant( + self, + _name: &'static str, + _variant_index: u32, + _variant: &'static str, + _len: usize, + ) -> Result { + use serde::ser::Error as _; + Err(serde_json::Error::custom( + "struct variants are not valid query values", + )) + } +} + +impl serde::ser::SerializeSeq for &mut QueryValueCollector { + type Ok = (); + type Error = serde_json::Error; + fn serialize_element(&mut self, value: &T) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(&mut **self) + } + fn end(self) -> QueryResult { + Ok(()) + } +} + +impl serde::ser::SerializeTuple for &mut QueryValueCollector { + type Ok = (); + type Error = serde_json::Error; + fn serialize_element(&mut self, value: &T) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(&mut **self) + } + fn end(self) -> QueryResult { + Ok(()) + } +} + +impl serde::ser::SerializeTupleStruct for &mut QueryValueCollector { + type Ok = (); + type Error = serde_json::Error; + fn serialize_field(&mut self, value: &T) -> QueryResult + where + T: ?Sized + Serialize, + { + value.serialize(&mut **self) + } + fn end(self) -> QueryResult { + Ok(()) + } +} + +/// Decode `application/x-www-form-urlencoded` text (`+` -> space, `%XX`). +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < bytes.len() => { + let hi = hex_val(bytes[i + 1]); + let lo = hex_val(bytes[i + 2]); + match (hi, lo) { + (Some(h), Some(l)) => { + out.push((h << 4) | l); + i += 3; + } + _ => { + out.push(bytes[i]); + i += 1; + } + } + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} diff --git a/crates/core/ras-transport-core/src/multipart.rs b/crates/core/ras-transport-core/src/multipart.rs new file mode 100644 index 0000000..06d36e7 --- /dev/null +++ b/crates/core/ras-transport-core/src/multipart.rs @@ -0,0 +1,260 @@ +//! Hand-rolled streaming `multipart/form-data` builder (RFC 7578). +//! +//! Replaces `reqwest::multipart::Form`. The body is assembled as a flattened +//! stream of segments so that file/stream parts never have to be buffered in +//! memory on native targets: each part contributes a header segment, the value +//! (which may itself be a stream), and a trailing CRLF; the whole thing is +//! `futures_util::stream::iter(segments).flatten()`. + +use bytes::Bytes; +use futures_util::StreamExt; +use futures_util::stream::{self}; +#[cfg(not(target_arch = "wasm32"))] +use futures_util::stream::BoxStream; +#[cfg(target_arch = "wasm32")] +use futures_util::stream::LocalBoxStream; + +use crate::error::TransportError; +use crate::request::RequestBody; +use crate::{ByteStream, byte_stream_from}; + +/// A single multipart part, captured before framing. +struct Part { + /// Field name; escaped for the `Content-Disposition` quoted-string at framing + /// time by [`escape_disposition_param`]. + name: String, + filename: Option, + content_type: Option, + value: PartValue, +} + +enum PartValue { + Bytes(Bytes), + // Only constructed via the `fs`-gated stream/file part methods; the `build` + // match arm always references it. + #[allow(dead_code)] + Stream(ByteStream), +} + +/// Builder for a streaming `multipart/form-data` body. +/// +/// Call [`MultipartBuilder::build`] to obtain the streaming body and the +/// `Content-Type` header value (including the generated boundary). +pub struct MultipartBuilder { + boundary: String, + parts: Vec, +} + +impl MultipartBuilder { + /// Create a builder with an auto-generated boundary. + pub fn new() -> Self { + MultipartBuilder { + boundary: generate_boundary(), + parts: Vec::new(), + } + } + + /// Create a builder with an explicit boundary (used by tests for + /// deterministic wire output). + pub fn with_boundary(boundary: impl Into) -> Self { + MultipartBuilder { + boundary: boundary.into(), + parts: Vec::new(), + } + } + + /// The full `Content-Type` header value, including the boundary. + pub fn content_type(&self) -> String { + format!("multipart/form-data; boundary={}", self.boundary) + } + + /// Add a plain text field. + pub fn text(mut self, name: impl Into, value: impl Into) -> Self { + self.parts.push(Part { + name: name.into(), + filename: None, + content_type: None, + value: PartValue::Bytes(Bytes::from(value.into().into_bytes())), + }); + self + } + + /// Add a JSON field (serialized, `Content-Type: application/json`). + pub fn json( + mut self, + name: impl Into, + value: &T, + ) -> Result { + let bytes = serde_json::to_vec(value).map_err(TransportError::Serialize)?; + self.parts.push(Part { + name: name.into(), + filename: None, + content_type: Some("application/json".to_string()), + value: PartValue::Bytes(Bytes::from(bytes)), + }); + Ok(self) + } + + /// Add a binary part with an explicit filename and content type. + pub fn bytes_part( + mut self, + name: impl Into, + filename: impl Into, + content_type: impl Into, + bytes: impl Into, + ) -> Self { + self.parts.push(Part { + name: name.into(), + filename: Some(filename.into()), + content_type: Some(content_type.into()), + value: PartValue::Bytes(bytes.into()), + }); + self + } + + /// Add a streaming part directly from a [`ByteStream`]. + #[cfg(all(feature = "fs", not(target_arch = "wasm32")))] + pub fn stream_part( + mut self, + name: impl Into, + filename: impl Into, + content_type: impl Into, + stream: ByteStream, + ) -> Self { + self.parts.push(Part { + name: name.into(), + filename: Some(filename.into()), + content_type: Some(content_type.into()), + value: PartValue::Stream(stream), + }); + self + } + + /// Add a part streamed from a file on disk. + /// + /// The `tokio::fs::File` -> `ReaderStream` conversion lives here (under the + /// `fs` feature) so consumers need not depend on `tokio-util` themselves. + #[cfg(all(feature = "fs", not(target_arch = "wasm32")))] + pub async fn file_path( + self, + name: impl Into, + content_type: impl Into, + path: impl AsRef, + ) -> Result { + let path = path.as_ref(); + let filename = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file".to_string()); + let file = tokio::fs::File::open(path) + .await + .map_err(|e| TransportError::Body(e.to_string()))?; + let reader = tokio_util::io::ReaderStream::new(file); + let stream: ByteStream = byte_stream_from( + reader.map(|res| res.map_err(|e| TransportError::Body(e.to_string()))), + ); + Ok(self.stream_part(name, filename, content_type, stream)) + } + + /// Build the streaming body and its `Content-Type` value. + pub fn build(self) -> (RequestBody, String) { + let content_type = self.content_type(); + let boundary = self.boundary; + + // Each part is flattened into: [header segment, value stream, CRLF segment]. + // A final closing-boundary segment is appended after all parts. + let mut segments: Vec = Vec::new(); + for part in self.parts { + let header = part_header(&boundary, &part); + segments.push(byte_stream_from(stream::once(async move { + Ok::(Bytes::from(header.into_bytes())) + }))); + match part.value { + PartValue::Bytes(b) => { + segments.push(byte_stream_from(stream::once(async move { Ok(b) }))); + } + PartValue::Stream(s) => { + segments.push(s); + } + } + segments.push(byte_stream_from(stream::once(async move { + Ok(Bytes::from_static(b"\r\n")) + }))); + } + let trailer = format!("--{boundary}--\r\n"); + segments.push(byte_stream_from(stream::once(async move { + Ok(Bytes::from(trailer.into_bytes())) + }))); + + let body_stream = flatten_segments(segments); + (RequestBody::Stream(body_stream), content_type) + } +} + +impl Default for MultipartBuilder { + fn default() -> Self { + Self::new() + } +} + +/// Escape a `Content-Disposition` quoted-string parameter so that a `"`, CR, or +/// LF in a (potentially user-supplied) field name or filename cannot terminate +/// the quoted string or inject extra header lines / corrupt the multipart frame. +/// Mirrors the percent-escaping browsers and `reqwest::multipart` apply. +fn escape_disposition_param(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "%22") + .replace('\r', "%0D") + .replace('\n', "%0A") +} + +/// Build the RFC 7578 header block for a part (boundary line + disposition + +/// optional content type + the blank line that ends the header block). +fn part_header(boundary: &str, part: &Part) -> String { + let mut s = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"{}\"", + escape_disposition_param(&part.name) + ); + if let Some(filename) = &part.filename { + s.push_str(&format!( + "; filename=\"{}\"", + escape_disposition_param(filename) + )); + } + s.push_str("\r\n"); + if let Some(ct) = &part.content_type { + s.push_str(&format!("Content-Type: {ct}\r\n")); + } + s.push_str("\r\n"); + s +} + +/// Flatten the per-part segment streams into one body stream, respecting the +/// conditional `Send` bound on [`ByteStream`]. +#[cfg(not(target_arch = "wasm32"))] +fn flatten_segments(segments: Vec) -> ByteStream { + let flat: BoxStream<'static, Result> = + stream::iter(segments).flatten().boxed(); + Box::pin(flat) +} + +#[cfg(target_arch = "wasm32")] +fn flatten_segments(segments: Vec) -> ByteStream { + let flat: LocalBoxStream<'static, Result> = + stream::iter(segments).flatten().boxed_local(); + Box::pin(flat) +} + +/// Generate a random-ish boundary. Uses the address of a stack allocation and +/// a monotonic counter to avoid pulling in `rand`. +fn generate_boundary() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("ras-boundary-{nanos:x}-{n:x}") +} diff --git a/crates/core/ras-transport-core/src/request.rs b/crates/core/ras-transport-core/src/request.rs new file mode 100644 index 0000000..9f37848 --- /dev/null +++ b/crates/core/ras-transport-core/src/request.rs @@ -0,0 +1,116 @@ +//! Request types: the body enum, the `TransportRequest` value, and its builders. + +use std::time::Duration; + +use bytes::Bytes; +use http::{HeaderMap, HeaderName, HeaderValue, Method}; +use serde::Serialize; + +use crate::ByteStream; +use crate::error::TransportError; + +/// The body of an outgoing request. +/// +/// `Stream` carries a real streaming body on native; on wasm the +/// [`crate::HttpTransport`] implementations collect it before sending because +/// the fetch API cannot stream request bodies. +pub enum RequestBody { + /// No body. + Empty, + /// A fully-buffered body. + Bytes(Bytes), + /// A streaming body (multipart uploads, file uploads). + Stream(ByteStream), +} + +impl RequestBody { + /// An empty body. + pub fn empty() -> Self { + RequestBody::Empty + } + + /// Serialize a value as a JSON body. + /// + /// The caller is responsible for setting `Content-Type: application/json` + /// (see [`TransportRequest::json`], which does both). + pub fn from_json(value: &T) -> Result { + let bytes = serde_json::to_vec(value).map_err(TransportError::Serialize)?; + Ok(RequestBody::Bytes(Bytes::from(bytes))) + } +} + +impl std::fmt::Debug for RequestBody { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RequestBody::Empty => f.write_str("RequestBody::Empty"), + RequestBody::Bytes(b) => f.debug_tuple("RequestBody::Bytes").field(&b.len()).finish(), + RequestBody::Stream(_) => f.write_str("RequestBody::Stream(..)"), + } + } +} + +/// A fully-resolved HTTP request handed to a transport. +/// +/// `url` is always the full absolute URL the client builds today; +/// [`crate::AxumTestTransport`] strips the scheme+authority down to a +/// path+query. +#[derive(Debug)] +pub struct TransportRequest { + /// HTTP method. + pub method: Method, + /// Absolute request URL (scheme + authority + path + query). + pub url: String, + /// Request headers. + pub headers: HeaderMap, + /// Request body. + pub body: RequestBody, + /// Optional per-request timeout (ignored on wasm). + pub timeout: Option, +} + +impl TransportRequest { + /// Construct a new request with an empty body and no headers. + pub fn new(method: Method, url: impl Into) -> Self { + TransportRequest { + method, + url: url.into(), + headers: HeaderMap::new(), + body: RequestBody::Empty, + timeout: None, + } + } + + /// Add a header. Invalid header names/values are silently dropped. + pub fn header(mut self, name: impl AsRef, value: impl AsRef) -> Self { + if let (Ok(name), Ok(value)) = ( + HeaderName::try_from(name.as_ref()), + HeaderValue::try_from(value.as_ref()), + ) { + self.headers.insert(name, value); + } + self + } + + /// Set the `Authorization: Bearer ` header. + pub fn bearer(self, token: impl AsRef) -> Self { + self.header("authorization", format!("Bearer {}", token.as_ref())) + } + + /// Serialize `value` as a JSON body and set `Content-Type: application/json`. + pub fn json(mut self, value: &T) -> Result { + self.body = RequestBody::from_json(value)?; + Ok(self.header("content-type", "application/json")) + } + + /// Set the body directly. + pub fn body(mut self, body: RequestBody) -> Self { + self.body = body; + self + } + + /// Set the per-request timeout. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } +} diff --git a/crates/core/ras-transport-core/src/reqwest_transport.rs b/crates/core/ras-transport-core/src/reqwest_transport.rs new file mode 100644 index 0000000..8be18ed --- /dev/null +++ b/crates/core/ras-transport-core/src/reqwest_transport.rs @@ -0,0 +1,99 @@ +//! Production transport backed by `reqwest`. +//! +//! reqwest re-exports the `http` crate's `Method`/`StatusCode`/`HeaderMap`, so +//! these cross the boundary with no conversion. The transport is a **dumb +//! pipe**: it never inspects status — the generated client calls +//! [`crate::TransportResponse::error_for_status`]. + +#![cfg(feature = "reqwest")] + +use futures_util::StreamExt; + +use crate::error::TransportError; +use crate::request::{RequestBody, TransportRequest}; +use crate::response::TransportResponse; +use crate::{HttpTransport, byte_stream_from}; + +/// A [`HttpTransport`] backed by a `reqwest::Client`. +#[derive(Clone)] +pub struct ReqwestTransport { + client: reqwest::Client, +} + +impl ReqwestTransport { + /// Construct with a default `reqwest::Client`. + pub fn new() -> Self { + ReqwestTransport { + client: reqwest::Client::new(), + } + } + + /// Construct from an existing `reqwest::Client`. + pub fn from_client(client: reqwest::Client) -> Self { + ReqwestTransport { client } + } +} + +impl Default for ReqwestTransport { + fn default() -> Self { + Self::new() + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +impl HttpTransport for ReqwestTransport { + async fn execute( + &self, + request: TransportRequest, + ) -> Result { + let mut builder = self + .client + .request(request.method, &request.url) + .headers(request.headers); + + #[cfg(not(target_arch = "wasm32"))] + if let Some(timeout) = request.timeout { + builder = builder.timeout(timeout); + } + + builder = match request.body { + RequestBody::Empty => builder, + RequestBody::Bytes(bytes) => builder.body(bytes), + #[cfg(not(target_arch = "wasm32"))] + RequestBody::Stream(stream) => { + builder.body(reqwest::Body::wrap_stream(stream)) + } + #[cfg(target_arch = "wasm32")] + RequestBody::Stream(mut stream) => { + // wasm fetch cannot stream request bodies; collect first. + let mut buf = bytes::BytesMut::new(); + while let Some(chunk) = stream.next().await { + buf.extend_from_slice(&chunk?); + } + builder.body(buf.freeze()) + } + }; + + let resp = builder.send().await?; + let status = resp.status(); + let headers = resp.headers().clone(); + + // Native streams the response body; wasm reqwest lacks the `stream` + // feature, so collect into a single chunk (response streaming on wasm + // is bounded by the fetch implementation regardless). + #[cfg(not(target_arch = "wasm32"))] + let body_stream = + byte_stream_from(resp.bytes_stream().map(|res| res.map_err(TransportError::from))); + + #[cfg(target_arch = "wasm32")] + let body_stream = { + let bytes = resp.bytes().await?; + byte_stream_from(futures_util::stream::once(async move { + Ok::(bytes) + })) + }; + + Ok(TransportResponse::new(status, headers, body_stream)) + } +} diff --git a/crates/core/ras-transport-core/src/response.rs b/crates/core/ras-transport-core/src/response.rs new file mode 100644 index 0000000..cbbe1cc --- /dev/null +++ b/crates/core/ras-transport-core/src/response.rs @@ -0,0 +1,93 @@ +//! Response type wrapping a single-consumption streaming body. + +use bytes::{Bytes, BytesMut}; +use futures_util::StreamExt; +use http::{HeaderMap, StatusCode}; +use serde::de::DeserializeOwned; + +use crate::ByteStream; +use crate::error::TransportError; + +/// An HTTP response with a lazily-consumed streaming body. +/// +/// Not `Clone`: the body is a single-consumption [`ByteStream`]. +pub struct TransportResponse { + status: StatusCode, + headers: HeaderMap, + body: ByteStream, +} + +impl TransportResponse { + /// Construct a response from its parts. + pub fn new(status: StatusCode, headers: HeaderMap, body: ByteStream) -> Self { + TransportResponse { + status, + headers, + body, + } + } + + /// The HTTP status code. + pub fn status(&self) -> StatusCode { + self.status + } + + /// The response headers. + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + /// Whether the status is in the 2xx range. + pub fn is_success(&self) -> bool { + self.status.is_success() + } + + /// Consume the response and collect the full body into [`Bytes`]. + pub async fn bytes(self) -> Result { + let mut stream = self.body; + let mut buf = BytesMut::new(); + while let Some(chunk) = stream.next().await { + buf.extend_from_slice(&chunk?); + } + Ok(buf.freeze()) + } + + /// Consume the response and deserialize the body as JSON. + pub async fn json(self) -> Result { + let bytes = self.bytes().await?; + crate::deserialize_json(&bytes) + } + + /// Consume the response and decode the body as UTF-8 text (lossy). + pub async fn text(self) -> Result { + let bytes = self.bytes().await?; + Ok(String::from_utf8_lossy(&bytes).into_owned()) + } + + /// Take the raw streaming body, leaving status/headers behind. + pub fn into_body_stream(self) -> ByteStream { + self.body + } + + /// Return `self` if the status is success, otherwise collect the body and + /// produce a [`TransportError::Status`]. + pub async fn error_for_status(self) -> Result { + if self.is_success() { + Ok(self) + } else { + let status = self.status; + let body = self.text().await.unwrap_or_default(); + Err(TransportError::http_status(status, body)) + } + } +} + +impl std::fmt::Debug for TransportResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TransportResponse") + .field("status", &self.status) + .field("headers", &self.headers) + .field("body", &"ByteStream(..)") + .finish() + } +} diff --git a/crates/core/ras-transport-core/tests/multipart_framing.rs b/crates/core/ras-transport-core/tests/multipart_framing.rs new file mode 100644 index 0000000..e2066b0 --- /dev/null +++ b/crates/core/ras-transport-core/tests/multipart_framing.rs @@ -0,0 +1,93 @@ +//! Multipart framing must produce exact RFC 7578 wire bytes. + +use futures_util::StreamExt; +use ras_transport_core::request::RequestBody; +use ras_transport_core::multipart::MultipartBuilder; + +async fn collect_body(body: RequestBody) -> Vec { + match body { + RequestBody::Stream(mut s) => { + let mut out = Vec::new(); + while let Some(chunk) = s.next().await { + out.extend_from_slice(&chunk.expect("chunk")); + } + out + } + RequestBody::Bytes(b) => b.to_vec(), + RequestBody::Empty => Vec::new(), + } +} + +#[tokio::test] +async fn new_generates_a_boundary_and_default_matches() { + // Covers new() / Default / generate_boundary (other tests use with_boundary). + let builder = MultipartBuilder::default().text("field", "value"); + let ct = builder.content_type(); + assert!( + ct.starts_with("multipart/form-data; boundary=ras-boundary-"), + "unexpected content-type: {ct}" + ); + let boundary = ct + .strip_prefix("multipart/form-data; boundary=") + .unwrap() + .to_string(); + let (body, ct2) = builder.build(); + assert_eq!(ct, ct2); + let text = String::from_utf8(collect_body(body).await).unwrap(); + assert!(text.starts_with(&format!("--{boundary}\r\n"))); + assert!(text.ends_with(&format!("--{boundary}--\r\n"))); +} + +#[tokio::test] +async fn text_json_bytes_combo_produces_exact_wire_bytes() { + #[derive(serde::Serialize)] + struct Meta { + id: u32, + } + + let builder = MultipartBuilder::with_boundary("BOUND") + .text("field1", "hello") + .json("meta", &Meta { id: 7 }) + .expect("json part") + .bytes_part("file", "a.bin", "application/octet-stream", b"\x00\x01\x02".to_vec()); + + let content_type = builder.content_type(); + assert_eq!(content_type, "multipart/form-data; boundary=BOUND"); + + let (body, ct) = builder.build(); + assert_eq!(ct, "multipart/form-data; boundary=BOUND"); + + let bytes = collect_body(body).await; + + let mut expected: Vec = Vec::new(); + expected.extend_from_slice(b"--BOUND\r\n"); + expected.extend_from_slice(b"Content-Disposition: form-data; name=\"field1\"\r\n"); + expected.extend_from_slice(b"\r\n"); + expected.extend_from_slice(b"hello"); + expected.extend_from_slice(b"\r\n"); + + expected.extend_from_slice(b"--BOUND\r\n"); + expected.extend_from_slice(b"Content-Disposition: form-data; name=\"meta\"\r\n"); + expected.extend_from_slice(b"Content-Type: application/json\r\n"); + expected.extend_from_slice(b"\r\n"); + expected.extend_from_slice(b"{\"id\":7}"); + expected.extend_from_slice(b"\r\n"); + + expected.extend_from_slice(b"--BOUND\r\n"); + expected.extend_from_slice( + b"Content-Disposition: form-data; name=\"file\"; filename=\"a.bin\"\r\n", + ); + expected.extend_from_slice(b"Content-Type: application/octet-stream\r\n"); + expected.extend_from_slice(b"\r\n"); + expected.extend_from_slice(b"\x00\x01\x02"); + expected.extend_from_slice(b"\r\n"); + + expected.extend_from_slice(b"--BOUND--\r\n"); + + assert_eq!( + bytes, + expected, + "got:\n{}", + String::from_utf8_lossy(&bytes) + ); +} diff --git a/crates/core/ras-transport-core/tests/multipart_streaming.rs b/crates/core/ras-transport-core/tests/multipart_streaming.rs new file mode 100644 index 0000000..b4c5310 --- /dev/null +++ b/crates/core/ras-transport-core/tests/multipart_streaming.rs @@ -0,0 +1,93 @@ +//! Coverage for the streaming multipart paths (`stream_part`, `file_path`) and +//! `Content-Disposition` escaping. Native + `fs` feature only. Uses a temp file +//! on disk (no network/sockets). + +#![cfg(all(not(target_arch = "wasm32"), feature = "fs"))] + +use bytes::Bytes; +use futures_util::{StreamExt, stream}; +use ras_transport_core::multipart::MultipartBuilder; +use ras_transport_core::request::RequestBody; +use ras_transport_core::{ByteStream, TransportError, byte_stream_from}; + +async fn collect_body(body: RequestBody) -> Vec { + match body { + RequestBody::Stream(mut s) => { + let mut out = Vec::new(); + while let Some(chunk) = s.next().await { + out.extend_from_slice(&chunk.expect("chunk")); + } + out + } + RequestBody::Bytes(b) => b.to_vec(), + RequestBody::Empty => Vec::new(), + } +} + +#[tokio::test] +async fn stream_part_produces_exact_framing_from_multiple_chunks() { + // A multi-chunk stream must be framed as one part with all chunks contiguous. + let chunks: Vec<&'static [u8]> = vec![b"abc", b"def", b"ghi"]; + let stream: ByteStream = byte_stream_from(stream::iter( + chunks + .into_iter() + .map(|c| Ok::(Bytes::from_static(c))), + )); + + let (body, content_type) = MultipartBuilder::with_boundary("BOUND") + .stream_part("upload", "data.bin", "application/octet-stream", stream) + .build(); + + assert_eq!(content_type, "multipart/form-data; boundary=BOUND"); + let bytes = collect_body(body).await; + let expected = concat!( + "--BOUND\r\n", + "Content-Disposition: form-data; name=\"upload\"; filename=\"data.bin\"\r\n", + "Content-Type: application/octet-stream\r\n", + "\r\n", + "abcdefghi\r\n", + "--BOUND--\r\n", + ); + assert_eq!(String::from_utf8(bytes).unwrap(), expected); +} + +#[tokio::test] +async fn file_path_streams_disk_contents_into_a_part() { + // Write a temp file, then stream it through file_path (tokio::fs -> ReaderStream). + let dir = std::env::temp_dir(); + let path = dir.join(format!("ras-transport-fp-{}.txt", std::process::id())); + tokio::fs::write(&path, b"file-contents-here") + .await + .expect("write temp file"); + + let (body, _ct) = MultipartBuilder::with_boundary("B") + .file_path("doc", "text/plain", &path) + .await + .expect("file_path") + .build(); + + let bytes = collect_body(body).await; + let text = String::from_utf8(bytes).unwrap(); + // filename is derived from the path's file name. + let fname = path.file_name().unwrap().to_string_lossy(); + assert!(text.contains(&format!( + "Content-Disposition: form-data; name=\"doc\"; filename=\"{fname}\"" + ))); + assert!(text.contains("Content-Type: text/plain\r\n")); + assert!(text.contains("file-contents-here")); + assert!(text.ends_with("--B--\r\n")); + + tokio::fs::remove_file(&path).await.ok(); +} + +#[tokio::test] +async fn disposition_params_with_quotes_and_newlines_are_escaped() { + // A hostile filename must not break the frame: " -> %22, CR/LF -> %0D/%0A. + let (body, _ct) = MultipartBuilder::with_boundary("B") + .bytes_part("field", "ev\"il\r\n.txt", "application/octet-stream", Bytes::from_static(b"x")) + .build(); + let text = String::from_utf8(collect_body(body).await).unwrap(); + assert!(text.contains("filename=\"ev%22il%0D%0A.txt\"")); + // The header line still terminates cleanly and the single part frames correctly. + assert!(text.ends_with("x\r\n--B--\r\n")); +} diff --git a/crates/core/ras-transport-core/tests/query_serialization.rs b/crates/core/ras-transport-core/tests/query_serialization.rs new file mode 100644 index 0000000..8d07624 --- /dev/null +++ b/crates/core/ras-transport-core/tests/query_serialization.rs @@ -0,0 +1,86 @@ +//! Query serialization must reproduce reqwest's `.query()` behavior: +//! Option-skipping, Vec repeated-keys, and enum serde-renames. + +use ras_transport_core::{serialize_query_pairs, serialize_query_value}; +use serde::Serialize; + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +enum Sort { + #[serde(rename = "created_at")] + CreatedAt, + Name, +} + +#[test] +fn option_none_skips_and_some_emits() { + let none: Option = None; + let pairs = serialize_query_value("limit", &none).unwrap(); + assert!(pairs.is_empty(), "None must produce no pairs: {pairs:?}"); + + let some: Option = Some(10); + let pairs = serialize_query_value("limit", &some).unwrap(); + assert_eq!(pairs, vec![("limit".to_string(), "10".to_string())]); +} + +#[test] +fn vec_produces_repeated_keys() { + let tags = vec!["a", "b", "c"]; + let pairs = serialize_query_value("tag", &tags).unwrap(); + assert_eq!( + pairs, + vec![ + ("tag".to_string(), "a".to_string()), + ("tag".to_string(), "b".to_string()), + ("tag".to_string(), "c".to_string()), + ] + ); + assert_eq!(serialize_query_pairs(&pairs), "tag=a&tag=b&tag=c"); +} + +#[test] +fn enum_uses_serde_rename() { + let pairs = serialize_query_value("sort", &Sort::CreatedAt).unwrap(); + assert_eq!(pairs, vec![("sort".to_string(), "created_at".to_string())]); + + let pairs = serialize_query_value("sort", &Sort::Name).unwrap(); + assert_eq!(pairs, vec![("sort".to_string(), "name".to_string())]); +} + +#[test] +fn pairs_are_percent_encoded() { + let pairs = serialize_query_value("q", &"hello world & co").unwrap(); + assert_eq!( + serialize_query_pairs(&pairs), + "q=hello+world+%26+co" + ); +} + +#[test] +fn encoding_matches_reqwests_urlencoded_unreserved_set() { + // reqwest's `.query()` delegates to serde_urlencoded, whose unreserved set + // is `[A-Za-z0-9*-._]` (space -> `+`). Regression coverage for the two + // characters where the previous hand-rolled encoder diverged: + // `~` must be percent-encoded (`%7E`), and `*` must stay raw (`*`). + let pairs = serialize_query_value("q", &"~").unwrap(); + assert_eq!(serialize_query_pairs(&pairs), "q=%7E"); + + let pairs = serialize_query_value("q", &"*").unwrap(); + assert_eq!(serialize_query_pairs(&pairs), "q=*"); + + // And the round-trip through `serialize_query_value` (which decodes the + // scalar) followed by `serialize_query_pairs` (which re-encodes) is stable. + let pairs = serialize_query_value("q", &"a~b*c").unwrap(); + assert_eq!(pairs, vec![("q".to_string(), "a~b*c".to_string())]); + assert_eq!(serialize_query_pairs(&pairs), "q=a%7Eb*c"); +} + +#[test] +fn full_query_string_joins_multiple_params() { + let mut all = Vec::new(); + all.extend(serialize_query_value("limit", &Some(5u32)).unwrap()); + let none: Option = None; + all.extend(serialize_query_value("offset", &none).unwrap()); + all.extend(serialize_query_value("tag", &vec!["x", "y"]).unwrap()); + assert_eq!(serialize_query_pairs(&all), "limit=5&tag=x&tag=y"); +} diff --git a/crates/core/ras-transport-core/tests/request_response_units.rs b/crates/core/ras-transport-core/tests/request_response_units.rs new file mode 100644 index 0000000..f91193a --- /dev/null +++ b/crates/core/ras-transport-core/tests/request_response_units.rs @@ -0,0 +1,292 @@ +//! In-process unit coverage for the request/response value types, the typed +//! error, and the query-serialization helpers. No transport, no sockets. + +use bytes::Bytes; +use futures_util::stream; +use http::{Method, StatusCode}; +use ras_transport_core::request::RequestBody; +use ras_transport_core::{ + ByteStream, TransportError, TransportRequest, TransportResponse, byte_stream_from, + deserialize_json, serialize_query_pairs, serialize_query_value, +}; +use serde::Serialize; + +fn single_chunk(bytes: &'static [u8]) -> ByteStream { + byte_stream_from(stream::once(async move { + Ok::(Bytes::from_static(bytes)) + })) +} + +fn response(status: u16, body: &'static [u8]) -> TransportResponse { + TransportResponse::new( + StatusCode::from_u16(status).unwrap(), + http::HeaderMap::new(), + single_chunk(body), + ) +} + +// --- TransportRequest builders --- + +#[test] +fn request_builders_set_method_url_headers_body_timeout() { + #[derive(Serialize)] + struct Payload { + a: u32, + } + + let req = TransportRequest::new(Method::POST, "http://h/x") + .header("x-custom", "v") + .bearer("tok") + .json(&Payload { a: 1 }) + .expect("json body") + .timeout(std::time::Duration::from_millis(50)); + + assert_eq!(req.method, Method::POST); + assert_eq!(req.url, "http://h/x"); + assert_eq!(req.headers.get("x-custom").unwrap(), "v"); + assert_eq!(req.headers.get("authorization").unwrap(), "Bearer tok"); + assert_eq!(req.headers.get("content-type").unwrap(), "application/json"); + assert_eq!(req.timeout, Some(std::time::Duration::from_millis(50))); + assert!(matches!(req.body, RequestBody::Bytes(_))); +} + +#[test] +fn request_header_drops_invalid_name_or_value() { + // Invalid header name (contains a space) and invalid value (newline) are + // silently dropped rather than panicking. + let req = TransportRequest::new(Method::GET, "/x") + .header("bad name", "ok") + .header("ok-name", "bad\nvalue"); + assert!(req.headers.is_empty()); +} + +#[test] +fn request_body_direct_and_empty() { + let req = TransportRequest::new(Method::PUT, "/x").body(RequestBody::Bytes(Bytes::from_static(b"hi"))); + assert!(matches!(req.body, RequestBody::Bytes(_))); + + assert!(matches!(RequestBody::empty(), RequestBody::Empty)); +} + +#[test] +fn request_body_debug_variants() { + assert_eq!(format!("{:?}", RequestBody::Empty), "RequestBody::Empty"); + assert_eq!( + format!("{:?}", RequestBody::Bytes(Bytes::from_static(b"abc"))), + "RequestBody::Bytes(3)" + ); + let s = RequestBody::Stream(single_chunk(b"x")); + assert_eq!(format!("{s:?}"), "RequestBody::Stream(..)"); +} + +// --- TransportResponse --- + +#[tokio::test] +async fn response_bytes_text_json_and_accessors() { + let resp = response(200, b"{\"v\":7}"); + assert_eq!(resp.status(), StatusCode::OK); + assert!(resp.is_success()); + assert!(resp.headers().is_empty()); + assert!(format!("{resp:?}").contains("ByteStream(..)")); + + #[derive(serde::Deserialize)] + struct V { + v: u32, + } + let parsed: V = response(200, b"{\"v\":7}").json().await.unwrap(); + assert_eq!(parsed.v, 7); + + assert_eq!(response(200, b"hello").text().await.unwrap(), "hello"); + assert_eq!(&response(200, b"raw").bytes().await.unwrap()[..], b"raw"); +} + +#[tokio::test] +async fn response_into_body_stream_yields_chunks() { + use futures_util::StreamExt; + let chunks: Vec<&'static [u8]> = vec![b"foo", b"bar", b"baz"]; + let body = byte_stream_from(stream::iter( + chunks + .into_iter() + .map(|c| Ok::(Bytes::from_static(c))), + )); + let resp = TransportResponse::new(StatusCode::OK, http::HeaderMap::new(), body); + + let mut stream = resp.into_body_stream(); + let mut collected = Vec::new(); + while let Some(chunk) = stream.next().await { + collected.extend_from_slice(&chunk.unwrap()); + } + assert_eq!(collected, b"foobarbaz"); +} + +#[tokio::test] +async fn error_for_status_passes_success_and_maps_failure() { + // success: returns self unchanged + let ok = response(204, b"").error_for_status().await.expect("2xx ok"); + assert_eq!(ok.status(), StatusCode::NO_CONTENT); + + // failure: drains body into TransportError::Status + let err = response(503, b"down") + .error_for_status() + .await + .expect_err("5xx errors"); + match err { + TransportError::Status { status, body } => { + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body, "down"); + } + other => panic!("expected Status, got {other:?}"), + } +} + +// --- TransportError --- + +#[test] +fn error_display_and_constructor_cover_all_variants() { + let status = TransportError::http_status(StatusCode::NOT_FOUND, "missing"); + assert_eq!(status.to_string(), "http status 404 Not Found: missing"); + + assert_eq!( + TransportError::Connection("refused".into()).to_string(), + "connection error: refused" + ); + assert_eq!( + TransportError::Body("truncated".into()).to_string(), + "body error: truncated" + ); + assert_eq!( + TransportError::JsonRpc { + code: -32601, + message: "Method not found".into() + } + .to_string(), + "json-rpc error -32601: Method not found" + ); + + // Deserialize variant: invalid JSON bytes. + let de_err: Result = deserialize_json(b"not json"); + assert!(matches!(de_err, Err(TransportError::Deserialize(_)))); + + // Serialize variant: serde_json rejects a map with non-string keys. + let mut bad = std::collections::BTreeMap::new(); + bad.insert((1u8, 2u8), 3u32); + let ser_err = RequestBody::from_json(&bad).map(|_| ()); + assert!(matches!(ser_err, Err(TransportError::Serialize(_)))); +} + +// --- query helpers (lib.rs) --- + +#[test] +fn query_value_covers_scalar_kinds_and_percent_decode() { + // bool / int / float go through encode_scalar + percent_decode. + assert_eq!(serialize_query_value("b", &true).unwrap(), vec![("b".into(), "true".into())]); + assert_eq!(serialize_query_value("n", &-42i64).unwrap(), vec![("n".into(), "-42".into())]); + assert_eq!(serialize_query_value("f", &1.5f64).unwrap(), vec![("f".into(), "1.5".into())]); + + // char '/' encodes to %2F then is percent-decoded back to '/'. + assert_eq!(serialize_query_value("c", &'/').unwrap(), vec![("c".into(), "/".into())]); + // a string value is taken verbatim (serialize_str path, no encode_scalar). + assert_eq!( + serialize_query_value("s", &"a b/c").unwrap(), + vec![("s".into(), "a b/c".into())] + ); + + // newtype struct delegates to inner. + #[derive(Serialize)] + struct Wrap(u8); + assert_eq!(serialize_query_value("w", &Wrap(9)).unwrap(), vec![("w".into(), "9".into())]); +} + +#[test] +fn query_value_covers_every_scalar_width() { + // Each integer/float width is a distinct generated Serializer method. + assert_eq!(serialize_query_value("v", &7i8).unwrap()[0].1, "7"); + assert_eq!(serialize_query_value("v", &7i16).unwrap()[0].1, "7"); + assert_eq!(serialize_query_value("v", &7i32).unwrap()[0].1, "7"); + assert_eq!(serialize_query_value("v", &7i64).unwrap()[0].1, "7"); + assert_eq!(serialize_query_value("v", &7u8).unwrap()[0].1, "7"); + assert_eq!(serialize_query_value("v", &7u16).unwrap()[0].1, "7"); + assert_eq!(serialize_query_value("v", &7u32).unwrap()[0].1, "7"); + assert_eq!(serialize_query_value("v", &7u64).unwrap()[0].1, "7"); + assert_eq!(serialize_query_value("v", &2.5f32).unwrap()[0].1, "2.5"); + assert_eq!(serialize_query_value("v", &2.5f64).unwrap()[0].1, "2.5"); +} + +#[test] +fn query_value_seq_option_and_unit_variant() { + // Vec -> repeated values for one key. + assert_eq!( + serialize_query_value("t", &vec!["x", "y"]).unwrap(), + vec![("t".into(), "x".into()), ("t".into(), "y".into())] + ); + // Option::None -> no pairs; Some -> one. + assert_eq!(serialize_query_value("o", &Option::::None).unwrap(), vec![]); + assert_eq!(serialize_query_value("o", &Some(3u32)).unwrap(), vec![("o".into(), "3".into())]); + + #[derive(Serialize)] + enum Kind { + #[serde(rename = "the_one")] + One, + } + assert_eq!(serialize_query_value("k", &Kind::One).unwrap(), vec![("k".into(), "the_one".into())]); +} + +#[test] +fn query_value_tuple_newtype_variant_and_unit_shapes() { + // Tuple -> repeated values (serialize_tuple + SerializeTuple). + assert_eq!( + serialize_query_value("t", &(1u32, 2u32)).unwrap(), + vec![("t".into(), "1".into()), ("t".into(), "2".into())] + ); + + // Tuple struct -> repeated values (serialize_tuple_struct + SerializeTupleStruct). + #[derive(Serialize)] + struct Pair(u32, u32); + assert_eq!( + serialize_query_value("p", &Pair(3, 4)).unwrap(), + vec![("p".into(), "3".into()), ("p".into(), "4".into())] + ); + + // Newtype variant -> delegates to inner value. + #[derive(Serialize)] + enum E { + N(u32), + } + assert_eq!(serialize_query_value("e", &E::N(5)).unwrap(), vec![("e".into(), "5".into())]); + + // Unit and unit struct -> no pairs. + #[derive(Serialize)] + struct U; + assert_eq!(serialize_query_value("u", &()).unwrap(), vec![]); + assert_eq!(serialize_query_value("u", &U).unwrap(), vec![]); +} + +#[test] +fn query_value_rejects_unsupported_shapes() { + use std::collections::BTreeMap; + + #[derive(Serialize)] + struct S { + a: u32, + } + assert!(serialize_query_value("s", &S { a: 1 }).is_err()); // struct + + let mut m = BTreeMap::new(); + m.insert("k", 1); + assert!(serialize_query_value("m", &m).is_err()); // map + + #[derive(Serialize)] + enum E { + Tup(u8, u8), + Strukt { x: u8 }, + } + assert!(serialize_query_value("e", &E::Tup(1, 2)).is_err()); // tuple variant + assert!(serialize_query_value("e", &E::Strukt { x: 1 }).is_err()); // struct variant +} + +#[test] +fn query_pairs_join_and_empty() { + assert_eq!(serialize_query_pairs(&[]), ""); + let pairs = vec![("a".to_string(), "1".to_string()), ("b".to_string(), "two".to_string())]; + assert_eq!(serialize_query_pairs(&pairs), "a=1&b=two"); +} diff --git a/crates/core/ras-transport-core/tests/transport_in_process.rs b/crates/core/ras-transport-core/tests/transport_in_process.rs new file mode 100644 index 0000000..aba23fd --- /dev/null +++ b/crates/core/ras-transport-core/tests/transport_in_process.rs @@ -0,0 +1,158 @@ +//! End-to-end coverage of `AxumTestTransport` against a real `axum::Router` +//! driven through `axum-test`'s in-process mock transport — no sockets. + +#![cfg(all(not(target_arch = "wasm32"), feature = "axum-test"))] + +use axum::Router; +use axum::body::Bytes as AxumBytes; +use axum::extract::Path; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, post}; +use bytes::Bytes; +use futures_util::stream; +use ras_transport_core::request::RequestBody; +use ras_transport_core::{ + AxumTestTransport, HttpTransport, TransportError, TransportRequest, byte_stream_from, +}; + +async fn echo_body(headers: HeaderMap, body: AxumBytes) -> (StatusCode, String) { + // Echo the body, and surface a header so header forwarding is observable. + let seen_auth = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("none") + .to_string(); + ( + StatusCode::OK, + format!("{}|auth={}", String::from_utf8_lossy(&body), seen_auth), + ) +} + +fn router() -> Router { + Router::new() + .route("/ping", get(|| async { "pong" })) + .route("/echo", post(echo_body)) + .route( + "/boom", + get(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "kaboom") }), + ) + .route( + "/items/{id}", + get(|Path(id): Path| async move { format!("item:{id}") }), + ) +} + +fn transport() -> AxumTestTransport { + let server = axum_test::TestServer::builder() + .mock_transport() + .build(router()) + .expect("build mock-transport TestServer"); + AxumTestTransport::new(server) +} + +#[tokio::test] +async fn get_with_absolute_url_strips_origin_and_returns_body() { + let t = transport(); + let resp = t + .execute(TransportRequest::new(http::Method::GET, "http://api.example/ping")) + .await + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK); + assert_eq!(resp.text().await.unwrap(), "pong"); +} + +#[tokio::test] +async fn get_with_bare_path_url_also_routes() { + // strip_origin fallback branch: no "scheme://", url used as-is. + let t = transport(); + let resp = t + .execute(TransportRequest::new(http::Method::GET, "/ping")) + .await + .unwrap(); + assert_eq!(resp.text().await.unwrap(), "pong"); +} + +#[tokio::test] +async fn absolute_url_without_path_falls_back_to_root() { + // strip_origin: "scheme://authority" with no trailing slash -> "/". + // Root isn't routed, so we just assert it dispatches and yields a status. + let t = transport(); + let resp = t + .execute(TransportRequest::new(http::Method::GET, "http://api.example")) + .await + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn post_bytes_body_and_headers_are_forwarded() { + let t = transport(); + let req = TransportRequest::new(http::Method::POST, "http://api.example/echo") + .bearer("sekret") + .body(RequestBody::Bytes(Bytes::from_static(b"payload"))); + let resp = t.execute(req).await.unwrap(); + assert_eq!(resp.text().await.unwrap(), "payload|auth=Bearer sekret"); +} + +#[tokio::test] +async fn post_streaming_body_is_collected_before_dispatch() { + // Exercises the RequestBody::Stream collection branch in AxumTestTransport. + let t = transport(); + let chunks: Vec<&'static [u8]> = vec![b"strea", b"ming", b"-body"]; + let body = byte_stream_from(stream::iter( + chunks + .into_iter() + .map(|c| Ok::(Bytes::from_static(c))), + )); + let req = TransportRequest::new(http::Method::POST, "http://api.example/echo") + .body(RequestBody::Stream(body)); + let resp = t.execute(req).await.unwrap(); + assert_eq!(resp.text().await.unwrap(), "streaming-body|auth=none"); +} + +#[tokio::test] +async fn empty_body_get_does_not_set_a_body() { + // path param route + empty body branch. + let t = transport(); + let resp = t + .execute(TransportRequest::new( + http::Method::GET, + "http://api.example/items/42", + )) + .await + .unwrap(); + assert_eq!(resp.text().await.unwrap(), "item:42"); +} + +#[tokio::test] +async fn non_success_maps_through_error_for_status() { + let t = transport(); + let resp = t + .execute(TransportRequest::new(http::Method::GET, "http://api.example/boom")) + .await + .unwrap(); + let err = resp.error_for_status().await.unwrap_err(); + match err { + TransportError::Status { status, body } => { + assert_eq!(status, http::StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body, "kaboom"); + } + other => panic!("expected Status, got {other:?}"), + } +} + +#[tokio::test] +async fn from_arc_constructor_shares_the_server() { + let server = std::sync::Arc::new( + axum_test::TestServer::builder() + .mock_transport() + .build(router()) + .unwrap(), + ); + let t = AxumTestTransport::from_arc(server); + let resp = t + .execute(TransportRequest::new(http::Method::GET, "/ping")) + .await + .unwrap(); + assert_eq!(resp.text().await.unwrap(), "pong"); +} diff --git a/crates/rest/ras-file-macro/Cargo.toml b/crates/rest/ras-file-macro/Cargo.toml index 3be5cfb..b7b73c8 100644 --- a/crates/rest/ras-file-macro/Cargo.toml +++ b/crates/rest/ras-file-macro/Cargo.toml @@ -15,7 +15,10 @@ proc-macro = true [features] default = ["server", "client"] server = [] -client = [] +client = ["ras-transport-core/reqwest"] +# Enables generated streaming file-upload helpers (native only). Consumers that +# turn this on must also depend on `tokio`, `tokio-util`, and `futures-util`. +fs = ["ras-transport-core/fs"] permissions = [] [dependencies] @@ -24,17 +27,24 @@ quote = { workspace = true } proc-macro2 = { workspace = true } schemars = { workspace = true } serde_json = { workspace = true } +# Proc-macro crates can't re-export runtime deps, but the `client` feature must +# activate `ras-transport-core/reqwest` so the default `ReqwestTransport` is +# available to consumers. Consumers must depend on `ras-transport-core` too. +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", default-features = false } [dev-dependencies] tokio = { workspace = true, features = ["full"] } tokio-util = { workspace = true } +futures-util = { workspace = true } axum = { workspace = true } -reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } ras-auth-core = { path = "../../core/ras-auth-core", version = "0.1.0" } ras-file-core = { path = "../ras-file-core", version = "0.1.0" } ras-permission-manifest = { path = "../../specs/ras-permission-manifest", version = "0.1.0" } +# Generated-client tests drive the client over the in-process AxumTestTransport, +# and the default `build()` path needs the reqwest transport to compile. +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", features = ["reqwest", "axum-test", "fs"] } thiserror = { workspace = true } async-trait = { workspace = true } axum-test = { workspace = true } diff --git a/crates/rest/ras-file-macro/src/client.rs b/crates/rest/ras-file-macro/src/client.rs index 2a2dbb6..4918195 100644 --- a/crates/rest/ras-file-macro/src/client.rs +++ b/crates/rest/ras-file-macro/src/client.rs @@ -19,10 +19,13 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { .map(|endpoint| generate_client_method(definition, endpoint, &base_path)); quote! { + #[derive(Clone)] pub struct #client_name { - client: ::reqwest::Client, + transport: ::std::sync::Arc, base_url: String, - bearer_token: ::std::sync::RwLock>, + bearer_token: ::std::sync::Arc<::std::sync::RwLock>>, + #[cfg(not(target_arch = "wasm32"))] + default_timeout: Option<::std::time::Duration>, } impl #client_name { @@ -34,16 +37,24 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { *self.bearer_token.write().unwrap() = token.map(|token| token.into()); } - fn build_request(&self, method: ::reqwest::Method, path: &str) -> ::reqwest::RequestBuilder { + fn build_request( + &self, + path: &str, + ) -> (String, ::ras_transport_core::http::HeaderMap) { let base = self.base_url.trim_end_matches('/'); let path = path.trim_start_matches('/'); - let mut request = self.client.request(method, format!("{}/{}", base, path)); + let url = format!("{}/{}", base, path); + let mut headers = ::ras_transport_core::http::HeaderMap::new(); if let Some(token) = self.bearer_token.read().unwrap().as_ref() { - request = request.header("Authorization", format!("Bearer {}", token)); + if let Ok(value) = ::ras_transport_core::http::HeaderValue::from_str( + &format!("Bearer {}", token), + ) { + headers.insert(::ras_transport_core::http::header::AUTHORIZATION, value); + } } - request + (url, headers) } #(#client_methods)* @@ -51,52 +62,64 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { pub struct #builder_name { base_url: String, - client: Option<::reqwest::Client>, + transport: Option<::std::sync::Arc>, #[cfg(not(target_arch = "wasm32"))] - timeout: Option, + timeout: Option<::std::time::Duration>, } impl #builder_name { pub fn new(base_url: impl Into) -> Self { Self { base_url: base_url.into(), - client: None, + transport: None, #[cfg(not(target_arch = "wasm32"))] timeout: None, } } - pub fn with_client(mut self, client: ::reqwest::Client) -> Self { - self.client = Some(client); + pub fn with_transport( + mut self, + transport: ::std::sync::Arc, + ) -> Self { + self.transport = Some(transport); self } #[cfg(not(target_arch = "wasm32"))] - pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self { + pub fn with_timeout(mut self, timeout: ::std::time::Duration) -> Self { self.timeout = Some(timeout); self } - pub fn build(self) -> Result<#client_name, Box> { - let client = match self.client { - Some(client) => client, - None => { - let mut builder = ::reqwest::Client::builder(); - - #[cfg(not(target_arch = "wasm32"))] - if let Some(timeout) = self.timeout { - builder = builder.timeout(timeout); - } - - builder.build()? - } - }; + pub fn build(self) -> #client_name { + let transport: ::std::sync::Arc = + match self.transport { + Some(transport) => transport, + None => ::std::sync::Arc::new( + ::ras_transport_core::ReqwestTransport::new(), + ), + }; + + #client_name { + transport, + base_url: self.base_url, + bearer_token: ::std::sync::Arc::new(::std::sync::RwLock::new(None)), + #[cfg(not(target_arch = "wasm32"))] + default_timeout: self.timeout, + } + } - Ok(#client_name { - client, + pub fn build_with_transport( + self, + transport: ::std::sync::Arc, + ) -> #client_name { + #client_name { + transport, base_url: self.base_url, - bearer_token: ::std::sync::RwLock::new(None), - }) + bearer_token: ::std::sync::Arc::new(::std::sync::RwLock::new(None)), + #[cfg(not(target_arch = "wasm32"))] + default_timeout: self.timeout, + } } } @@ -131,21 +154,29 @@ fn generate_client_method( &self, #(#path_params,)* form: #form_builder, - ) -> Result<#response_type, Box> { + ) -> Result<#response_type, ::ras_transport_core::TransportError> { let path = #full_path.to_string()#(#path_replace)*; - let response = self - .build_request(::reqwest::Method::POST, &path) - .multipart(form.into_form()) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let text = response.text().await?; - return Err(format!("Upload failed with status {}: {}", status, text).into()); + let (url, mut headers) = self.build_request(&path); + + let (body, content_type) = form.into_body(); + if let Ok(value) = ::ras_transport_core::http::HeaderValue::from_str(&content_type) { + headers.insert(::ras_transport_core::http::header::CONTENT_TYPE, value); } - Ok(response.json().await?) + let mut request = ::ras_transport_core::TransportRequest::new( + ::ras_transport_core::http::Method::POST, + url, + ) + .body(body); + request.headers = headers; + + #[cfg(not(target_arch = "wasm32"))] + if let Some(timeout) = self.default_timeout { + request = request.timeout(timeout); + } + + let response = self.transport.execute(request).await?.error_for_status().await?; + response.json().await } } } @@ -153,19 +184,22 @@ fn generate_client_method( pub async fn #method_name( &self, #(#path_params,)* - ) -> Result<::reqwest::Response, Box> { + ) -> Result<::ras_transport_core::TransportResponse, ::ras_transport_core::TransportError> { let path = #full_path.to_string()#(#path_replace)*; - let response = self - .build_request(::reqwest::Method::GET, &path) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let text = response.text().await?; - return Err(format!("Download failed with status {}: {}", status, text).into()); + let (url, headers) = self.build_request(&path); + + let mut request = ::ras_transport_core::TransportRequest::new( + ::ras_transport_core::http::Method::GET, + url, + ); + request.headers = headers; + + #[cfg(not(target_arch = "wasm32"))] + if let Some(timeout) = self.default_timeout { + request = request.timeout(timeout); } + let response = self.transport.execute(request).await?.error_for_status().await?; Ok(response) } }, @@ -188,41 +222,44 @@ fn generate_multipart_builder( match part.kind { UploadPartKind::File => quote! { - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "fs"))] pub async fn #method_name( mut self, file_path: impl AsRef, file_name: Option<&str>, content_type: Option<&str>, - ) -> Result> { - let file = ::tokio::fs::File::open(file_path.as_ref()).await?; - let length = file.metadata().await.ok().map(|metadata| metadata.len()); - let stream = ::tokio_util::io::ReaderStream::new(file); - let body = ::reqwest::Body::wrap_stream(stream); - - let file_name = file_name - .map(ToString::to_string) - .or_else(|| { - file_path - .as_ref() - .file_name() - .and_then(|name| name.to_str()) - .map(ToString::to_string) - }) - .unwrap_or_else(|| "file".to_string()); - - let mut part = if let Some(length) = length { - ::reqwest::multipart::Part::stream_with_length(body, length) - } else { - ::reqwest::multipart::Part::stream(body) - } - .file_name(file_name); - - if let Some(content_type) = content_type { - part = part.mime_str(content_type)?; - } + ) -> Result { + use ::futures_util::StreamExt as _; + + let content_type = content_type.unwrap_or("application/octet-stream"); + let path_ref = file_path.as_ref(); + + let builder = match file_name { + Some(name) => { + let file = ::tokio::fs::File::open(path_ref).await.map_err(|e| { + ::ras_transport_core::TransportError::Body(e.to_string()) + })?; + let stream = ::ras_transport_core::byte_stream_from( + ::tokio_util::io::ReaderStream::new(file).map(|chunk| { + chunk.map_err(|e| { + ::ras_transport_core::TransportError::Body(e.to_string()) + }) + }), + ); + self.builder.stream_part( + #field_name, + name.to_string(), + content_type.to_string(), + stream, + ) + } + None => self + .builder + .file_path(#field_name, content_type.to_string(), path_ref) + .await?, + }; - self.form = self.form.part(#field_name, part); + self.builder = builder; Ok(self) } @@ -231,13 +268,15 @@ fn generate_multipart_builder( bytes: impl Into>, file_name: impl Into, content_type: Option<&str>, - ) -> Result> { - let mut part = ::reqwest::multipart::Part::bytes(bytes.into()) - .file_name(file_name.into()); - if let Some(content_type) = content_type { - part = part.mime_str(content_type)?; - } - self.form = self.form.part(#field_name, part); + ) -> Result { + let content_type = content_type.unwrap_or("application/octet-stream"); + let bytes: Vec = bytes.into(); + self.builder = self.builder.bytes_part( + #field_name, + file_name.into(), + content_type.to_string(), + bytes, + ); Ok(self) } }, @@ -247,18 +286,15 @@ fn generate_multipart_builder( pub fn #method_name( mut self, value: &#ty, - ) -> Result> { - let json = ::serde_json::to_string(value)?; - let part = ::reqwest::multipart::Part::text(json) - .mime_str("application/json")?; - self.form = self.form.part(#field_name, part); + ) -> Result { + self.builder = self.builder.json(#field_name, value)?; Ok(self) } } } UploadPartKind::Text => quote! { pub fn #method_name(mut self, value: impl Into) -> Self { - self.form = self.form.part(#field_name, ::reqwest::multipart::Part::text(value.into())); + self.builder = self.builder.text(#field_name, value.into()); self } }, @@ -267,20 +303,20 @@ fn generate_multipart_builder( Some(quote! { pub struct #builder_name { - form: ::reqwest::multipart::Form, + builder: ::ras_transport_core::MultipartBuilder, } impl #builder_name { pub fn new() -> Self { Self { - form: ::reqwest::multipart::Form::new(), + builder: ::ras_transport_core::MultipartBuilder::new(), } } #(#methods)* - pub fn into_form(self) -> ::reqwest::multipart::Form { - self.form + pub fn into_body(self) -> (::ras_transport_core::RequestBody, String) { + self.builder.build() } } diff --git a/crates/rest/ras-file-macro/tests/e2e.rs b/crates/rest/ras-file-macro/tests/e2e.rs index 03f7616..9531648 100644 --- a/crates/rest/ras-file-macro/tests/e2e.rs +++ b/crates/rest/ras-file-macro/tests/e2e.rs @@ -13,7 +13,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; mod support; -use support::{MockAuthProvider, mock_http_server}; +use support::{MockAuthProvider, axum_transport, mock_http_server, mock_http_server_arc}; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct UploadMetadata { @@ -212,35 +212,54 @@ fn demo_server(service: DemoImpl) -> TestServer { ) } +fn demo_server_arc(service: DemoImpl) -> Arc { + mock_http_server_arc( + DemoBuilder::::new(service) + .auth_provider(MockAuthProvider::default()) + .build(), + ) +} + +fn demo_client(server: Arc) -> DemoClient { + DemoClient::builder("http://test.local").build_with_transport(axum_transport(server)) +} + #[tokio::test] async fn upload_and_download_round_trips_declared_multipart_fields() { let service = DemoImpl::new(); let storage = service.storage.clone(); - let server = mock_http_server( - DemoBuilder::::new(service) - .auth_provider(MockAuthProvider::default()) - .build(), - ); + let server = demo_server_arc(service); + let client = demo_client(server); + client.set_bearer_token(Some("user-token")); let payload = b"streamed file".to_vec(); - let response = server - .post("/files/upload") - .authorization_bearer("user-token") - .multipart(form(payload.clone())) - .await; + let metadata = UploadMetadata { + title: "demo".to_string(), + }; - response.assert_status(StatusCode::CREATED); - let uploaded: UploadResponse = response.json(); + let form = DemoUploadMultipart::new() + .file_bytes( + payload.clone(), + "blob.bin", + Some("application/octet-stream"), + ) + .expect("file part") + .metadata(&metadata) + .expect("json part") + .comment("hello"); + + let uploaded: UploadResponse = client.upload(form).await.expect("upload succeeds"); assert_eq!(uploaded.size, payload.len() as u64); assert_eq!(uploaded.title, "demo"); assert_eq!(uploaded.comment.as_deref(), Some("hello")); assert_eq!(storage.lock().unwrap().len(), 1); - let response = server - .get(&format!("/files/download/{}", uploaded.file_id)) - .await; - response.assert_status_ok(); + let response = client + .download_by_file_id(uploaded.file_id.clone()) + .await + .expect("download succeeds"); + assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()["content-type"], "application/octet-stream" @@ -249,7 +268,59 @@ async fn upload_and_download_round_trips_declared_multipart_fields() { response.headers()["content-disposition"], "attachment; filename=\"file-0.bin\"" ); - assert_eq!(response.into_bytes().as_ref(), payload.as_slice()); + let downloaded = response.bytes().await.expect("download body"); + assert_eq!(downloaded.as_ref(), payload.as_slice()); +} + +#[cfg(all(not(target_arch = "wasm32"), feature = "fs"))] +#[tokio::test] +async fn upload_streams_file_part_from_disk_round_trips() { + use std::io::Write as _; + + let service = DemoImpl::new(); + let storage = service.storage.clone(); + let server = demo_server_arc(service); + let client = demo_client(server); + client.set_bearer_token(Some("user-token")); + + // Write a temp file that the generated streaming `file(path, ...)` method + // (tokio::fs::File -> ReaderStream -> MultipartBuilder::stream_part) reads + // from disk. This is the only test that drives the from-disk streaming path. + let payload = b"streamed-from-disk file contents".to_vec(); + let mut temp = tempfile::NamedTempFile::new().expect("create temp file"); + temp.write_all(&payload).expect("write temp file"); + temp.flush().expect("flush temp file"); + let path = temp.path().to_path_buf(); + + let metadata = UploadMetadata { + title: "demo".to_string(), + }; + + let form = DemoUploadMultipart::new() + .file(&path, Some("blob.bin"), Some("application/octet-stream")) + .await + .expect("streaming file part from disk") + .metadata(&metadata) + .expect("json part") + .comment("hello"); + + let uploaded: UploadResponse = client.upload(form).await.expect("upload succeeds"); + assert_eq!(uploaded.size, payload.len() as u64); + assert_eq!(uploaded.title, "demo"); + assert_eq!(uploaded.comment.as_deref(), Some("hello")); + + assert_eq!(storage.lock().unwrap().len(), 1); + + // Verify the exact bytes survived the from-disk streaming multipart framing. + let response = client + .download_by_file_id(uploaded.file_id.clone()) + .await + .expect("download succeeds"); + assert_eq!(response.status(), StatusCode::OK); + let downloaded = response.bytes().await.expect("download body"); + assert_eq!(downloaded.as_ref(), payload.as_slice()); + + drop(temp); } #[tokio::test] @@ -267,7 +338,7 @@ fn generated_client_multipart_builder_covers_declared_parts() { title: "demo".to_string(), }; - let form = DemoUploadMultipart::new() + let (body, content_type) = DemoUploadMultipart::new() .file_bytes( b"body".to_vec(), "blob.bin", @@ -277,9 +348,13 @@ fn generated_client_multipart_builder_covers_declared_parts() { .metadata(&metadata) .expect("json part") .comment("hello") - .into_form(); + .into_body(); - let _ = form; + assert!(content_type.starts_with("multipart/form-data; boundary=")); + match body { + ras_transport_core::RequestBody::Stream(_) => {} + other => panic!("expected streaming multipart body, got {other:?}"), + } } #[tokio::test] diff --git a/crates/rest/ras-file-macro/tests/support/mod.rs b/crates/rest/ras-file-macro/tests/support/mod.rs index ab7e833..348289b 100644 --- a/crates/rest/ras-file-macro/tests/support/mod.rs +++ b/crates/rest/ras-file-macro/tests/support/mod.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use axum::Router; use axum_test::TestServer; @@ -50,3 +51,13 @@ pub fn mock_http_server(router: Router) -> TestServer { .build(router) .expect("failed to start axum-test TestServer with in-memory transport") } + +#[allow(dead_code)] +pub fn mock_http_server_arc(router: Router) -> Arc { + Arc::new(mock_http_server(router)) +} + +#[allow(dead_code)] +pub fn axum_transport(server: Arc) -> Arc { + Arc::new(ras_transport_core::AxumTestTransport::from_arc(server)) +} diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index 6bd9803..6db84fb 100644 --- a/crates/rest/ras-rest-macro/Cargo.toml +++ b/crates/rest/ras-rest-macro/Cargo.toml @@ -15,7 +15,7 @@ proc-macro = true [features] default = ["server", "client"] # Enable server by default for backward compatibility server = ["axum", "ras-auth-core", "ras-rest-core", "async-trait"] -client = ["reqwest"] +client = ["ras-transport-core/reqwest"] permissions = [] [dependencies] @@ -32,11 +32,11 @@ ras-rest-core = { path = "../ras-rest-core", version = "0.1.1", optional = true async-trait = { workspace = true, optional = true } # Client dependencies -reqwest = { workspace = true, optional = true } +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", optional = true } [dev-dependencies] tokio = { workspace = true } -reqwest = { workspace = true } +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", features = ["axum-test"] } tower = { workspace = true } rand = { workspace = true } ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.2.0" } diff --git a/crates/rest/ras-rest-macro/src/client.rs b/crates/rest/ras-rest-macro/src/client.rs index f0bb776..91c04f4 100644 --- a/crates/rest/ras-rest-macro/src/client.rs +++ b/crates/rest/ras-rest-macro/src/client.rs @@ -72,7 +72,7 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok /// Generated client for the REST service #[derive(Clone)] pub struct #client_name { - client: reqwest::Client, + transport: std::sync::Arc, server_url: String, base_path: String, bearer_token: Option, @@ -100,42 +100,29 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok self } - /// Build the client + /// Build the client using the default [`ReqwestTransport`]. /// /// # Errors /// - /// Returns an error if the underlying HTTP client fails to build + /// Returns an error if the underlying transport fails to construct. pub fn build(self) -> Result<#client_name, Box> { - let mut client_builder = reqwest::Client::builder(); - - // Timeout is not supported in WASM builds - #[cfg(not(target_arch = "wasm32"))] - if let Some(timeout) = self.timeout { - client_builder = client_builder.timeout(timeout); - } - - let client = client_builder.build()?; - - Ok(#client_name { - client, - server_url: self.server_url, - base_path: #base_path.to_string(), - bearer_token: None, - default_timeout: self.timeout, - }) + let transport = std::sync::Arc::new(::ras_transport_core::ReqwestTransport::new()); + self.build_with_transport(transport) } - pub fn build_with_client_builder(self, mut client_builder: ::reqwest::ClientBuilder) -> Result<#client_name, Box> { - // Timeout is not supported in WASM builds - #[cfg(not(target_arch = "wasm32"))] - if let Some(timeout) = self.timeout { - client_builder = client_builder.timeout(timeout); - } - - let client = client_builder.build()?; - + /// Build the client over an explicit transport (e.g. an in-process + /// test transport). This is the injection point used by tests. + /// + /// # Errors + /// + /// Currently infallible, but returns a `Result` for forward + /// compatibility and parity with [`build`]. + pub fn build_with_transport( + self, + transport: std::sync::Arc, + ) -> Result<#client_name, Box> { Ok(#client_name { - client, + transport, server_url: self.server_url, base_path: #base_path.to_string(), bearer_token: None, @@ -278,7 +265,7 @@ fn generate_client_method( quote! { /// Call the #method_name endpoint - pub async fn #method_name(&self, #(#params),*) -> Result<#response_type, Box> { + pub async fn #method_name(&self, #(#params),*) -> Result<#response_type, ::ras_transport_core::TransportError> { self.#method_name_with_timeout(#(#call_args,)* None).await } } @@ -296,11 +283,11 @@ fn generate_client_method_with_timeout( ) -> proc_macro2::TokenStream { let method_name_with_timeout = quote::format_ident!("{}_with_timeout", method_name); let http_method = match method { - HttpMethod::Get => quote! { reqwest::Method::GET }, - HttpMethod::Post => quote! { reqwest::Method::POST }, - HttpMethod::Put => quote! { reqwest::Method::PUT }, - HttpMethod::Delete => quote! { reqwest::Method::DELETE }, - HttpMethod::Patch => quote! { reqwest::Method::PATCH }, + HttpMethod::Get => quote! { ::ras_transport_core::http::Method::GET }, + HttpMethod::Post => quote! { ::ras_transport_core::http::Method::POST }, + HttpMethod::Put => quote! { ::ras_transport_core::http::Method::PUT }, + HttpMethod::Delete => quote! { ::ras_transport_core::http::Method::DELETE }, + HttpMethod::Patch => quote! { ::ras_transport_core::http::Method::PATCH }, }; // Build function parameters @@ -335,9 +322,11 @@ fn generate_client_method_with_timeout( } // Build query-string handling. Required params are always serialized; - // `Option` params are skipped when `None`. Values are serialized by - // reqwest's serde-backed `.query()` helper so enum serde renames and other - // query wire formats stay aligned with server-side extraction. + // `Option` params are skipped when `None`. Values are run through + // `ras_transport_core::serialize_query_value`, which mirrors reqwest's old + // serde-backed `.query()` behavior: `Vec` produces repeated keys and + // enum serde renames are honored. The collected (decoded) pairs are + // percent-encoded and appended to the URL via `serialize_query_pairs`. let query_handling = if query_params.is_empty() { quote! {} } else { @@ -348,30 +337,44 @@ fn generate_client_method_with_timeout( quote! { if let Some(__values) = &#param_name { for __item in __values { - request_builder = request_builder.query(&[(#param_str, __item)]); + __query_pairs.extend( + ::ras_transport_core::serialize_query_value(#param_str, __item)?, + ); } } } } else if vec_inner_type(&qp.param_type).is_some() { quote! { for __item in &#param_name { - request_builder = request_builder.query(&[(#param_str, __item)]); + __query_pairs.extend( + ::ras_transport_core::serialize_query_value(#param_str, __item)?, + ); } } } else if option_inner_type(&qp.param_type).is_some() { quote! { if let Some(__v) = &#param_name { - request_builder = request_builder.query(&[(#param_str, __v)]); + __query_pairs.extend( + ::ras_transport_core::serialize_query_value(#param_str, __v)?, + ); } } } else { quote! { - request_builder = request_builder.query(&[(#param_str, &#param_name)]); + __query_pairs.extend( + ::ras_transport_core::serialize_query_value(#param_str, &#param_name)?, + ); } } }); quote! { + let mut __query_pairs: Vec<(String, String)> = Vec::new(); #(#query_serializers)* + if !__query_pairs.is_empty() { + // Use '&' if the path template already carried a literal query. + url.push(if url.contains('?') { '&' } else { '?' }); + url.push_str(&::ras_transport_core::serialize_query_pairs(&__query_pairs)); + } } }; @@ -383,11 +386,12 @@ fn generate_client_method_with_timeout( params.push(quote! { #param_name: #param_type }); } - // Add request body parameter if present + // Add request body parameter if present. The body is serialized to JSON + // (which also sets `Content-Type: application/json`). let request_body_handling = if let Some(request_type) = request_type { params.push(quote! { body: #request_type }); quote! { - request_builder = request_builder.json(&body); + __request = __request.json(&body)?; } } else { quote! {} @@ -398,24 +402,17 @@ fn generate_client_method_with_timeout( let response_handling = if is_unit_type { quote! { - if response.status().is_success() { - Ok(()) - } else { - let status = response.status(); - let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); - Err(format!("HTTP error {}: {}", status, error_text).into()) - } + let __response = self.transport.execute(__request).await?; + __response.error_for_status().await?; + Ok(()) } } else { quote! { - if response.status().is_success() { - let result = response.json().await?; - Ok(result) - } else { - let status = response.status(); - let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); - Err(format!("HTTP error {}: {}", status, error_text).into()) - } + let __response = self.transport.execute(__request).await?; + let __response = __response.error_for_status().await?; + let __bytes = __response.bytes().await?; + let __result = ::ras_transport_core::deserialize_json(&__bytes)?; + Ok(__result) } }; @@ -425,29 +422,27 @@ fn generate_client_method_with_timeout( &self, #(#params,)* timeout: Option - ) -> Result<#response_type, Box> { - let url = #url_construction; + ) -> Result<#response_type, ::ras_transport_core::TransportError> { + let mut url = #url_construction; + + #query_handling - let mut request_builder = self.client - .request(#http_method, &url); + let mut __request = ::ras_transport_core::TransportRequest::new(#http_method, url); // Add bearer token if available if let Some(token) = &self.bearer_token { - request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); + __request = __request.bearer(token); } - #query_handling - #request_body_handling - // Override timeout if provided (not supported in WASM builds) - #[cfg(not(target_arch = "wasm32"))] - if let Some(timeout) = timeout { - request_builder = request_builder.timeout(timeout); + // Apply an explicit per-call timeout, falling back to the client + // default. Timeout is honored only on native (ignored on WASM by + // the transport). + if let Some(timeout) = timeout.or(self.default_timeout) { + __request = __request.timeout(timeout); } - let response = request_builder.send().await?; - #response_handling } } diff --git a/crates/rest/ras-rest-macro/tests/e2e.rs b/crates/rest/ras-rest-macro/tests/e2e.rs index 62b16fd..fae5d0e 100644 --- a/crates/rest/ras-rest-macro/tests/e2e.rs +++ b/crates/rest/ras-rest-macro/tests/e2e.rs @@ -9,7 +9,7 @@ use ras_rest_macro::rest_service; use serde::{Deserialize, Serialize}; mod support; -use support::{MockAuthProvider, mock_http_server}; +use support::{MockAuthProvider, axum_transport, mock_http_server, mock_http_server_arc}; #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] struct Item { @@ -269,6 +269,17 @@ fn server() -> axum_test::TestServer { mock_http_server(router()) } +/// A generated `DemoClient` wired over an in-process [`AxumTestTransport`]. +/// The `server_url` is a placeholder origin — the test transport strips the +/// scheme+authority and routes by path+query against the in-memory router. +fn client() -> DemoClient { + let server = mock_http_server_arc(router()); + let transport = axum_transport(server); + DemoClientBuilder::new("http://in-memory.test") + .build_with_transport(transport) + .expect("failed to build DemoClient over AxumTestTransport") +} + #[tokio::test] async fn unauth_get_round_trips() { let response = server().get("/api/items").await; @@ -368,102 +379,106 @@ async fn auth_post_with_admin_succeeds_and_user_id_propagates() { #[tokio::test] async fn query_params_required_and_optional_serialize_correctly() { - let server = server(); - - let response = server.get("/api/search?q=hi&limit=3&exact=true").await; - response.assert_status_ok(); - let resp: ItemsResponse = response.json(); + // Drive the generated client over the in-process transport so the + // serde_urlencoded query path is exercised live (required + Option-skip). + let client = client(); + + let resp = client + .get_search("hi".to_string(), Some(3), true) + .await + .expect("get_search with limit failed"); assert_eq!(resp.items.len(), 3); assert_eq!(resp.items[0].name, "exact:hi-0"); assert_eq!(resp.items[2].name, "exact:hi-2"); - let response = server.get("/api/search?q=zz&exact=false").await; - response.assert_status_ok(); - let resp: ItemsResponse = response.json(); + // `limit: None` must be skipped from the query string entirely. + let resp = client + .get_search("zz".to_string(), None, false) + .await + .expect("get_search without limit failed"); assert_eq!(resp.items.len(), 2); assert_eq!(resp.items[0].name, "fuzzy:zz-0"); } #[tokio::test] async fn vec_query_params_serialize_as_repeated_keys() { - let server = server(); - - let response = server - .get("/api/filter?tags=red&tags=blue&optional_tags=featured") - .await; - response.assert_status_ok(); - let resp: ItemsResponse = response.json(); + // `Vec` and `Option>` query params must serialize as repeated + // keys through the generated client. + let client = client(); + + let resp = client + .get_filter( + vec!["red".to_string(), "blue".to_string()], + Some(vec!["featured".to_string()]), + ) + .await + .expect("get_filter with tags failed"); let names: Vec<_> = resp.items.into_iter().map(|item| item.name).collect(); assert_eq!(names, vec!["tag:red", "tag:blue", "optional:featured"]); - let response = server.get("/api/filter?tags=solo").await; - response.assert_status_ok(); - let resp: ItemsResponse = response.json(); + let resp = client + .get_filter(vec!["solo".to_string()], None) + .await + .expect("get_filter solo failed"); let names: Vec<_> = resp.items.into_iter().map(|item| item.name).collect(); assert_eq!(names, vec!["tag:solo"]); } #[tokio::test] async fn enum_query_params_use_serde_renames_without_display() { - let server = server(); - - let response = server.get("/api/sorted?order=asc").await; - response.assert_status_ok(); - let resp: ItemsResponse = response.json(); + // Enum query values must honor `#[serde(rename)]` (asc/desc) rather than + // any Display/Debug formatting. + let client = client(); + + let resp = client + .get_sorted(SortOrder::Asc) + .await + .expect("get_sorted asc failed"); assert_eq!(resp.items[0].name, "order:asc"); - let response = server.get("/api/sorted?order=desc").await; - response.assert_status_ok(); - let resp: ItemsResponse = response.json(); + let resp = client + .get_sorted(SortOrder::Desc) + .await + .expect("get_sorted desc failed"); assert_eq!(resp.items[0].name, "order:desc"); } #[tokio::test] async fn query_params_with_body_and_auth() { - let server = server(); - - let response = server - .post("/api/items/batch?notify=true") - .authorization_bearer("admin-token") - .json(&CreateItem { - name: "alpha".into(), - }) - .await; - response.assert_status(StatusCode::CREATED); - let item: Item = response.json(); + // Combined: bool query param + JSON body + bearer auth, via the client. + let mut client = client(); + client.set_bearer_token(Some("admin-token")); + + let item = client + .post_items_batch(true, CreateItem { name: "alpha".into() }) + .await + .expect("post_items_batch notify=true failed"); assert_eq!(item.name, "alpha(notified)"); - let response = server - .post("/api/items/batch?notify=false") - .authorization_bearer("admin-token") - .json(&CreateItem { - name: "beta".into(), - }) - .await; - response.assert_status(StatusCode::CREATED); - let item: Item = response.json(); + let item = client + .post_items_batch(false, CreateItem { name: "beta".into() }) + .await + .expect("post_items_batch notify=false failed"); assert_eq!(item.name, "beta(silent)"); } #[tokio::test] async fn query_params_with_path_param() { - let server = server(); - - let response = server - .get("/api/items/42/related?tag=featured") - .authorization_bearer("user-token") - .await; - response.assert_status_ok(); - let resp: ItemsResponse = response.json(); + // Path param substitution + Option query param + bearer auth, via client. + let mut client = client(); + client.set_bearer_token(Some("user-token")); + + let resp = client + .get_items_by_id_related(42, Some("featured".to_string())) + .await + .expect("get_items_by_id_related with tag failed"); assert_eq!(resp.items[0].id, 42); assert_eq!(resp.items[0].name, "related/featured"); - let response = server - .get("/api/items/42/related") - .authorization_bearer("user-token") - .await; - response.assert_status_ok(); - let resp: ItemsResponse = response.json(); + let resp = client + .get_items_by_id_related(42, None) + .await + .expect("get_items_by_id_related without tag failed"); assert_eq!(resp.items[0].name, "related/none"); } diff --git a/crates/rest/ras-rest-macro/tests/http_integration.rs b/crates/rest/ras-rest-macro/tests/http_integration.rs index 65a2eb4..aa4348b 100644 --- a/crates/rest/ras-rest-macro/tests/http_integration.rs +++ b/crates/rest/ras-rest-macro/tests/http_integration.rs @@ -437,6 +437,22 @@ fn create_rest_test_server() -> TestServer { TestServer::builder().mock_transport().build(app).unwrap() } +/// `Arc`-wrapped twin of [`create_rest_test_server`] for sharing with an +/// in-process [`AxumTestTransport`]. +fn create_rest_test_server_arc() -> Arc { + Arc::new(create_rest_test_server()) +} + +/// Build a generated `TestRestServiceClient` over an in-process transport +/// backed by the shared `TestServer`. +fn create_rest_test_client(server: Arc) -> TestRestServiceClient { + let transport: Arc = + Arc::new(ras_transport_core::AxumTestTransport::from_arc(server)); + TestRestServiceClientBuilder::new("http://in-memory.test") + .build_with_transport(transport) + .expect("failed to build TestRestServiceClient over AxumTestTransport") +} + fn create_rest_cookie_test_server(csrf: bool) -> TestServer { let mut builder = TestRestServiceBuilder::new(TestRestServiceImpl) .auth_provider(TestRestAuthProvider::new()) @@ -1098,13 +1114,64 @@ async fn test_new_permission_logic() { #[tokio::test] async fn test_generated_rest_client() { - let mut client = TestRestServiceClientBuilder::new("http://example.invalid") - .with_timeout(std::time::Duration::from_millis(100)) - .build() - .unwrap(); - - client.set_bearer_token(Some("superuser-token")); - assert_eq!(client.bearer_token(), Some("superuser-token")); + // Real end-to-end test: drive the generated client over the in-process + // AxumTestTransport against the live router. Covers unauthenticated GET, + // query-param serialization, bearer auth, a unit-type response, and HTTP + // error -> TransportError::Status mapping. + let server = create_rest_test_server_arc(); + let mut client = create_rest_test_client(server); + + // Bearer-token accessors still behave as before. + assert_eq!(client.bearer_token(), None); + + // 1. Unauthenticated GET returning a deserialized body. + let users = client.get_users().await.expect("get_users failed"); + assert_eq!(users.total, 2); + assert_eq!(users.users[0].name, "John Doe"); + + // 2. Query params (required + optional) over the serde_urlencoded path. + let search = client + .get_search_users("john".to_string(), Some(5), Some(10)) + .await + .expect("get_search_users failed"); + assert!(search.users[0].name.contains("john")); + assert!(search.users[0].name.contains("offset 10")); + + // Optional query params omitted when None. + let search = client + .get_search_users("jane".to_string(), None, None) + .await + .expect("get_search_users without optionals failed"); + assert!(search.users[0].name.contains("jane")); + + // 3. Bearer auth: a permissioned GET succeeds once the token is set. + client.set_bearer_token(Some("user-token")); + assert_eq!(client.bearer_token(), Some("user-token")); + let user = client + .get_users_by_id(7) + .await + .expect("get_users_by_id with user token failed"); + assert_eq!(user.id, Some(7)); + + // 4. Unit-type response (DELETE -> ()) with admin auth. + let mut admin_client = create_rest_test_client(create_rest_test_server_arc()); + admin_client.set_bearer_token(Some("admin-token")); + admin_client + .delete_users_by_id(5) + .await + .expect("delete_users_by_id with admin token failed"); + + // 5. HTTP error mapping: 404 -> TransportError::Status. + let err = client + .get_users_by_id(404) + .await + .expect_err("get_users_by_id(404) should fail"); + match err { + ras_transport_core::TransportError::Status { status, .. } => { + assert_eq!(status, ras_transport_core::http::StatusCode::NOT_FOUND); + } + other => panic!("expected TransportError::Status, got {other:?}"), + } } #[tokio::test] diff --git a/crates/rest/ras-rest-macro/tests/support/mod.rs b/crates/rest/ras-rest-macro/tests/support/mod.rs index ab7e833..607549f 100644 --- a/crates/rest/ras-rest-macro/tests/support/mod.rs +++ b/crates/rest/ras-rest-macro/tests/support/mod.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use axum::Router; use axum_test::TestServer; @@ -50,3 +51,17 @@ pub fn mock_http_server(router: Router) -> TestServer { .build(router) .expect("failed to start axum-test TestServer with in-memory transport") } + +/// Build an in-memory `TestServer` wrapped in an `Arc` for sharing with an +/// [`AxumTestTransport`]. +#[allow(dead_code)] +pub fn mock_http_server_arc(router: Router) -> Arc { + Arc::new(mock_http_server(router)) +} + +/// Wrap a shared `TestServer` into an in-process [`HttpTransport`] suitable for +/// driving a generated client. +#[allow(dead_code)] +pub fn axum_transport(server: Arc) -> Arc { + Arc::new(ras_transport_core::AxumTestTransport::from_arc(server)) +} diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index 5910c21..f6a8d78 100644 --- a/crates/rpc/ras-jsonrpc-macro/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-macro/Cargo.toml @@ -15,7 +15,7 @@ proc-macro = true [features] default = ["server", "client"] # Enable server by default for backward compatibility server = ["axum", "ras-jsonrpc-core"] -client = ["reqwest"] +client = ["ras-transport-core/reqwest"] permissions = [] [dependencies] @@ -32,14 +32,14 @@ axum = { workspace = true, optional = true } ras-jsonrpc-core = { path = "../ras-jsonrpc-core", version = "0.1.2", optional = true } # Client dependencies -reqwest = { workspace = true, optional = true } +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0" } # Always needed for types ras-jsonrpc-types = { path = "../ras-jsonrpc-types", version = "0.1.1" } [dev-dependencies] tokio = { workspace = true } -reqwest = { workspace = true } +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", features = ["axum-test"] } tower = { workspace = true } rand = { workspace = true } ras-identity-session = { path = "../../identity/ras-identity-session", version = "0.2.0" } diff --git a/crates/rpc/ras-jsonrpc-macro/src/client.rs b/crates/rpc/ras-jsonrpc-macro/src/client.rs index 356317c..8d3f78d 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/client.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/client.rs @@ -22,7 +22,7 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok /// Generated client for the JSON-RPC service #[derive(Clone)] pub struct #client_name { - client: reqwest::Client, + transport: std::sync::Arc, server_url: String, bearer_token: Option, default_timeout: Option, @@ -55,21 +55,22 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok self } - /// Build the client + /// Build the client using the default [`ReqwestTransport`]. pub fn build(self) -> Result<#client_name, Box> { - let server_url = self.server_url.ok_or("Server URL is required")?; - - let mut client_builder = reqwest::Client::builder(); - - #[cfg(not(target_arch = "wasm32"))] - if let Some(timeout) = self.timeout { - client_builder = client_builder.timeout(timeout); - } + let transport = std::sync::Arc::new(::ras_transport_core::ReqwestTransport::new()); + self.build_with_transport(transport) + } - let client = client_builder.build()?; + /// Build the client over an explicit transport (e.g. an in-process + /// test transport). This is the injection point used by tests. + pub fn build_with_transport( + self, + transport: std::sync::Arc, + ) -> Result<#client_name, Box> { + let server_url = self.server_url.ok_or("Server URL is required")?; Ok(#client_name { - client, + transport, server_url, bearer_token: None, default_timeout: self.timeout, @@ -97,7 +98,7 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok method: &str, params: T, timeout: Option, - ) -> Result> + ) -> Result where T: serde::Serialize, R: serde::de::DeserializeOwned, @@ -109,35 +110,69 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok "id": 1 }); - let mut request_builder = self.client - .post(&self.server_url) - .header("Content-Type", "application/json") - .json(&request_body); + let mut request = ::ras_transport_core::TransportRequest::new( + ::ras_transport_core::http::Method::POST, + self.server_url.clone(), + ) + .json(&request_body)?; // Add bearer token if available if let Some(token) = &self.bearer_token { - request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); + request = request.bearer(token); } - // Override timeout if provided (not supported in WASM) - #[cfg(not(target_arch = "wasm32"))] - if let Some(timeout) = timeout { - request_builder = request_builder.timeout(timeout); + // Apply per-call timeout, falling back to the client default. + if let Some(timeout) = timeout.or(self.default_timeout) { + request = request.timeout(timeout); } - let response = request_builder.send().await?; - let json_response: serde_json::Value = response.json().await?; + // The transport is a dumb pipe and never inspects status. For + // JSON-RPC, error detail lives in the body even on non-2xx + // responses (auth/permission failures map to 401/403 but still + // carry a JSON-RPC error envelope), so parse the body first and + // only fall back to the HTTP status when the body is not a + // well-formed JSON-RPC response. + let response = self.transport.execute(request).await?; + let status = response.status(); + let body = response.bytes().await?; + + let json_response: serde_json::Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(err) => { + if status.is_success() { + return Err(::ras_transport_core::TransportError::Deserialize(err)); + } + let text = String::from_utf8_lossy(&body).into_owned(); + return Err(::ras_transport_core::TransportError::http_status(status, text)); + } + }; // Check for JSON-RPC error if let Some(error) = json_response.get("error") { - return Err(format!("JSON-RPC error: {}", error).into()); + let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(0); + let message = error + .get("message") + .and_then(|m| m.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| error.to_string()); + return Err(::ras_transport_core::TransportError::JsonRpc { code, message }); } - // Extract result - let result = json_response.get("result") - .ok_or("Missing result in JSON-RPC response")?; + // No JSON-RPC error: surface any non-success HTTP status. + if !status.is_success() { + let text = String::from_utf8_lossy(&body).into_owned(); + return Err(::ras_transport_core::TransportError::http_status(status, text)); + } - let deserialized_result: R = serde_json::from_value(result.clone())?; + // Extract result + let result = json_response.get("result").ok_or_else(|| { + ::ras_transport_core::TransportError::Body( + "Missing result in JSON-RPC response".to_string(), + ) + })?; + + let deserialized_result: R = serde_json::from_value(result.clone()) + .map_err(::ras_transport_core::TransportError::Deserialize)?; Ok(deserialized_result) } } @@ -237,7 +272,7 @@ fn generate_client_method( ) -> proc_macro2::TokenStream { quote! { /// Call the #method_name method - pub async fn #method_name(&self, params: #request_type) -> Result<#response_type, Box> { + pub async fn #method_name(&self, params: #request_type) -> Result<#response_type, ::ras_transport_core::TransportError> { self.make_request(#method_str, params, None).await } } @@ -258,7 +293,7 @@ fn generate_client_method_with_timeout( &self, params: #request_type, timeout: std::time::Duration - ) -> Result<#response_type, Box> { + ) -> Result<#response_type, ::ras_transport_core::TransportError> { self.make_request(#method_str, params, Some(timeout)).await } } diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs index 772e9b4..c7536f0 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs @@ -9,6 +9,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; mod support; +#[cfg(feature = "client")] +use support::{axum_transport, mock_http_server_arc}; use support::{MockAuthProvider, mock_http_server}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -164,6 +166,88 @@ fn versioned_client_method_names_sanitize_semver_labels() { let _method_with_timeout = DemoClient::rename_user_v1_0_0_with_timeout; } +/// Build the generated `DemoClient` wired to drive requests through the +/// in-process `AxumTestTransport`, exercising the full envelope-build + +/// transport-execute + error-extraction path of the migrated client. +#[cfg(feature = "client")] +fn demo_client() -> DemoClient { + let server = mock_http_server_arc(router()); + let transport = axum_transport(server); + DemoClientBuilder::new() + // The AxumTestTransport strips scheme+authority, so the host is + // irrelevant; only the path "/rpc" matters. + .server_url("http://in-memory.test/rpc") + .build_with_transport(transport) + .expect("build DemoClient over AxumTestTransport") +} + +#[cfg(feature = "client")] +#[tokio::test] +async fn generated_client_round_trips_over_axum_transport() { + let client = demo_client(); + + let resp = client + .ping(EchoRequest { + msg: "hello-from-client".to_string(), + }) + .await + .expect("ping over transport should succeed"); + + assert_eq!(resp.msg, "hello-from-client"); + assert_eq!(resp.user_id, None); +} + +#[cfg(feature = "client")] +#[tokio::test] +async fn generated_client_sends_bearer_and_succeeds_with_permission() { + let mut client = demo_client(); + client.set_bearer_token(Some("user-token")); + + let resp = client + .add(AddRequest { a: 7, b: 35 }) + .await + .expect("authenticated add should succeed"); + + assert_eq!(resp.sum, 42); +} + +#[cfg(feature = "client")] +#[tokio::test] +async fn generated_client_surfaces_jsonrpc_error_on_missing_permission() { + let client = demo_client(); + + let err = client + .add(AddRequest { a: 1, b: 2 }) + .await + .expect_err("anonymous add must be rejected as a JSON-RPC error"); + + match err { + ras_transport_core::TransportError::JsonRpc { message, .. } => { + let m = message.to_lowercase(); + assert!( + m.contains("auth") || m.contains("permission"), + "expected auth/permission error, got: {message}" + ); + } + other => panic!("expected JsonRpc error variant, got: {other:?}"), + } +} + +#[cfg(feature = "client")] +#[tokio::test] +async fn generated_client_round_trips_versioned_wire_method() { + let client = demo_client(); + + let resp = client + .rename_user_v1_0_0(RenameUserV1 { + name: "Ada".to_string(), + }) + .await + .expect("legacy versioned method should round-trip via client"); + + assert_eq!(resp, RenameUserResponseV1 { name: "Ada".to_string() }); +} + async fn call_rpc( server: &axum_test::TestServer, method: &str, diff --git a/crates/rpc/ras-jsonrpc-macro/tests/support/mod.rs b/crates/rpc/ras-jsonrpc-macro/tests/support/mod.rs index ab7e833..9a1f616 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/support/mod.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/support/mod.rs @@ -50,3 +50,15 @@ pub fn mock_http_server(router: Router) -> TestServer { .build(router) .expect("failed to start axum-test TestServer with in-memory transport") } + +#[allow(dead_code)] +pub fn mock_http_server_arc(router: Router) -> std::sync::Arc { + std::sync::Arc::new(mock_http_server(router)) +} + +#[allow(dead_code)] +pub fn axum_transport( + server: std::sync::Arc, +) -> std::sync::Arc { + std::sync::Arc::new(ras_transport_core::AxumTestTransport::from_arc(server)) +} diff --git a/examples/basic-jsonrpc/api/Cargo.toml b/examples/basic-jsonrpc/api/Cargo.toml index c33811f..d654640 100644 --- a/examples/basic-jsonrpc/api/Cargo.toml +++ b/examples/basic-jsonrpc/api/Cargo.toml @@ -13,15 +13,15 @@ readme = "README.md" [features] default = [] server = ["ras-jsonrpc-macro/server", "dep:axum", "dep:ras-jsonrpc-core"] -client = ["ras-jsonrpc-macro/client", "dep:reqwest"] +client = ["ras-jsonrpc-macro/client", "ras-transport-core/reqwest"] [dependencies] ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false, features = ["permissions"] } ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2", optional = true } +ras-transport-core = { path = "../../../crates/core/ras-transport-core", version = "0.1.0" } ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } serde = { workspace = true } serde_json = { workspace = true } schemars = { workspace = true } axum = { workspace = true, optional = true } -reqwest = { workspace = true, optional = true } diff --git a/examples/bidirectional-chat/api/Cargo.toml b/examples/bidirectional-chat/api/Cargo.toml index a873632..efed114 100644 --- a/examples/bidirectional-chat/api/Cargo.toml +++ b/examples/bidirectional-chat/api/Cargo.toml @@ -21,6 +21,7 @@ server = [ client = [ "ras-jsonrpc-bidirectional-macro/client", "ras-rest-macro/client", + "ras-transport-core/reqwest", "dep:ras-jsonrpc-bidirectional-client", ] @@ -37,6 +38,7 @@ ras-jsonrpc-bidirectional-client = { path = "../../../crates/rpc/bidirectional/r ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0" } ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1" } +ras-transport-core = { path = "../../../crates/core/ras-transport-core", version = "0.1.0" } ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false, features = ["permissions"] } reqwest = { workspace = true, features = ["json"] } diff --git a/examples/file-service-example/Cargo.toml b/examples/file-service-example/Cargo.toml index d94a3a5..4cc38a5 100644 --- a/examples/file-service-example/Cargo.toml +++ b/examples/file-service-example/Cargo.toml @@ -13,7 +13,8 @@ readme = "README.md" [features] default = ["server"] server = ["ras-file-macro/server"] -client = ["ras-file-macro/client"] +client = ["ras-file-macro/client", "ras-transport-core/reqwest"] +fs = ["ras-file-macro/fs", "ras-transport-core/fs"] [dependencies] axum = { workspace = true } @@ -22,6 +23,7 @@ serde = { workspace = true } serde_json = { workspace = true } ras-file-macro = { path = "../../crates/rest/ras-file-macro", version = "0.1.0", default-features = false } ras-file-core = { path = "../../crates/rest/ras-file-core", version = "0.1.0" } +ras-transport-core = { path = "../../crates/core/ras-transport-core", version = "0.1.0" } ras-auth-core = { path = "../../crates/core/ras-auth-core", version = "0.1.0" } ras-permission-manifest = { path = "../../crates/specs/ras-permission-manifest", version = "0.1.0" } thiserror = { workspace = true } diff --git a/examples/file-service-wasm/file-service-api/Cargo.toml b/examples/file-service-wasm/file-service-api/Cargo.toml index 72d9672..46253e5 100644 --- a/examples/file-service-wasm/file-service-api/Cargo.toml +++ b/examples/file-service-wasm/file-service-api/Cargo.toml @@ -16,6 +16,7 @@ crate-type = ["rlib"] [dependencies] ras-file-macro = { path = "../../../crates/rest/ras-file-macro", version = "0.1.0", default-features = false, features = ["permissions"] } ras-file-core = { path = "../../../crates/rest/ras-file-core", version = "0.1.0", optional = true } +ras-transport-core = { path = "../../../crates/core/ras-transport-core", version = "0.1.0" } ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0", optional = true } ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } serde = { workspace = true, features = ["derive"] } @@ -54,8 +55,7 @@ server = [ ] client = [ "ras-file-macro/client", - "dep:reqwest", - "dep:tokio", - "dep:tokio-util", + "ras-transport-core/reqwest", ] +fs = ["ras-file-macro/fs", "ras-transport-core/fs", "dep:tokio", "dep:tokio-util"] wasm-client = ["client", "wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys", "serde-wasm-bindgen"] diff --git a/examples/oauth2-demo/api/Cargo.toml b/examples/oauth2-demo/api/Cargo.toml index 92c9b51..f1820f2 100644 --- a/examples/oauth2-demo/api/Cargo.toml +++ b/examples/oauth2-demo/api/Cargo.toml @@ -13,12 +13,13 @@ readme = "README.md" [features] default = [] server = ["ras-jsonrpc-macro/server", "dep:axum", "dep:ras-jsonrpc-core"] -client = ["ras-jsonrpc-macro/client", "dep:reqwest"] +client = ["ras-jsonrpc-macro/client", "ras-transport-core/reqwest"] [dependencies] # JSON-RPC infrastructure ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false, features = ["permissions"] } ras-jsonrpc-core = { path = "../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2", optional = true } +ras-transport-core = { path = "../../../crates/core/ras-transport-core", version = "0.1.0" } ras-jsonrpc-types = { path = "../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } @@ -27,4 +28,3 @@ axum = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } schemars = { workspace = true } -reqwest = { workspace = true, optional = true } diff --git a/examples/rest-wasm-example/rest-api/Cargo.toml b/examples/rest-wasm-example/rest-api/Cargo.toml index 8713d2f..51c935d 100644 --- a/examples/rest-wasm-example/rest-api/Cargo.toml +++ b/examples/rest-wasm-example/rest-api/Cargo.toml @@ -18,6 +18,7 @@ ras-rest-macro = { path = "../../../crates/rest/ras-rest-macro", version = "0.2. ras-auth-core = { path = "../../../crates/core/ras-auth-core", version = "0.1.0", optional = true } ras-permission-manifest = { path = "../../../crates/specs/ras-permission-manifest", version = "0.1.0" } ras-rest-core = { path = "../../../crates/rest/ras-rest-core", version = "0.1.1", optional = true } +ras-transport-core = { path = "../../../crates/core/ras-transport-core", version = "0.1.0" } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } schemars = { workspace = true } @@ -45,4 +46,4 @@ server = [ "dep:axum-extra", "dep:tokio", ] -client = ["ras-rest-macro/client", "dep:reqwest"] +client = ["ras-rest-macro/client", "ras-transport-core/reqwest"] diff --git a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml index acf1e66..e58a34b 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml +++ b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml @@ -13,12 +13,13 @@ readme = "README.md" [features] default = ["server"] server = ["ras-jsonrpc-macro/server"] -client = ["ras-jsonrpc-macro/client"] +client = ["ras-jsonrpc-macro/client", "ras-transport-core/reqwest"] [dependencies] anyhow = { workspace = true } axum = { workspace = true } ras-auth-core = { path = "../../../../crates/core/ras-auth-core", version = "0.1.0" } +ras-transport-core = { path = "../../../../crates/core/ras-transport-core", version = "0.1.0" } ras-jsonrpc-core = { path = "../../../../crates/rpc/ras-jsonrpc-core", version = "0.1.2" } ras-jsonrpc-macro = { path = "../../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false } ras-jsonrpc-types = { path = "../../../../crates/rpc/ras-jsonrpc-types", version = "0.1.1" } diff --git a/tests/playwright/fixtures/rest-fixture/Cargo.toml b/tests/playwright/fixtures/rest-fixture/Cargo.toml index 59299e8..2efc631 100644 --- a/tests/playwright/fixtures/rest-fixture/Cargo.toml +++ b/tests/playwright/fixtures/rest-fixture/Cargo.toml @@ -13,7 +13,7 @@ readme = "README.md" [features] default = ["server"] server = ["ras-rest-macro/server"] -client = ["ras-rest-macro/client"] +client = ["ras-rest-macro/client", "ras-transport-core/reqwest"] [dependencies] anyhow = { workspace = true } @@ -21,6 +21,7 @@ async-trait = { workspace = true } axum = { workspace = true } axum-extra = { workspace = true } ras-auth-core = { path = "../../../../crates/core/ras-auth-core", version = "0.1.0" } +ras-transport-core = { path = "../../../../crates/core/ras-transport-core", version = "0.1.0" } ras-rest-core = { path = "../../../../crates/rest/ras-rest-core", version = "0.1.1" } ras-rest-macro = { path = "../../../../crates/rest/ras-rest-macro", version = "0.2.1", default-features = false } ras-permission-manifest = { path = "../../../../crates/specs/ras-permission-manifest", version = "0.1.0" } From 05c53c4c68567f9d321bc026f639570a2933a951 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Fri, 5 Jun 2026 14:26:53 +0200 Subject: [PATCH 2/6] fix(transport): satisfy fmt/clippy and keep file streaming consumer-dep-free CI runs `cargo clippy --workspace --all-targets --all-features --locked -D warnings` plus rustfmt; this fixes the gates: - rustfmt: apply `cargo fmt --all`. - clippy: elide the redundant lifetime on the QueryValueCollector Serializer impl. - Move the tokio::fs -> ReaderStream conversion fully into MultipartBuilder::file_path (now takes an optional filename override) so the generated file-upload client no longer emits futures_util/tokio/tokio-util references. This was breaking the file-service examples under --all-features (E0432 unresolved `futures_util`, E0599 ReaderStream is not an iterator), which only depend on ras-transport-core. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/ras-transport-core/src/lib.rs | 12 ++--- .../core/ras-transport-core/src/multipart.rs | 19 ++++--- .../src/reqwest_transport.rs | 10 ++-- .../tests/multipart_framing.rs | 16 +++--- .../tests/multipart_streaming.rs | 9 +++- .../tests/query_serialization.rs | 5 +- .../tests/request_response_units.rs | 53 +++++++++++++++---- .../tests/transport_in_process.rs | 15 ++++-- crates/rest/ras-file-macro/src/client.rs | 42 +++++---------- crates/rest/ras-rest-macro/tests/e2e.rs | 14 ++++- crates/rpc/ras-jsonrpc-macro/tests/e2e.rs | 9 +++- 11 files changed, 124 insertions(+), 80 deletions(-) diff --git a/crates/core/ras-transport-core/src/lib.rs b/crates/core/ras-transport-core/src/lib.rs index 42a203c..7da6774 100644 --- a/crates/core/ras-transport-core/src/lib.rs +++ b/crates/core/ras-transport-core/src/lib.rs @@ -116,10 +116,8 @@ pub trait HttpTransport: TransportThreadBounds { /// Implementations are dumb pipes: they MUST NOT inspect the status code. /// Callers map non-success statuses via /// [`TransportResponse::error_for_status`]. - async fn execute( - &self, - request: TransportRequest, - ) -> Result; + async fn execute(&self, request: TransportRequest) + -> Result; } // --- Query / JSON helpers. --- @@ -202,7 +200,7 @@ macro_rules! collect_scalar { }; } -impl<'a> serde::Serializer for &'a mut QueryValueCollector { +impl serde::Serializer for &mut QueryValueCollector { type Ok = (); type Error = serde_json::Error; type SerializeSeq = Self; @@ -233,7 +231,9 @@ impl<'a> serde::Serializer for &'a mut QueryValueCollector { fn serialize_bytes(self, _v: &[u8]) -> QueryResult { use serde::ser::Error as _; - Err(serde_json::Error::custom("bytes are not a valid query value")) + Err(serde_json::Error::custom( + "bytes are not a valid query value", + )) } fn serialize_none(self) -> QueryResult { diff --git a/crates/core/ras-transport-core/src/multipart.rs b/crates/core/ras-transport-core/src/multipart.rs index 06d36e7..43e8e82 100644 --- a/crates/core/ras-transport-core/src/multipart.rs +++ b/crates/core/ras-transport-core/src/multipart.rs @@ -8,11 +8,11 @@ use bytes::Bytes; use futures_util::StreamExt; -use futures_util::stream::{self}; #[cfg(not(target_arch = "wasm32"))] use futures_util::stream::BoxStream; #[cfg(target_arch = "wasm32")] use futures_util::stream::LocalBoxStream; +use futures_util::stream::{self}; use crate::error::TransportError; use crate::request::RequestBody; @@ -132,20 +132,25 @@ impl MultipartBuilder { /// Add a part streamed from a file on disk. /// - /// The `tokio::fs::File` -> `ReaderStream` conversion lives here (under the - /// `fs` feature) so consumers need not depend on `tokio-util` themselves. + /// The `tokio::fs::File` -> `ReaderStream` conversion (and the `futures_util` + /// usage it needs) lives here, under the `fs` feature, so consumers — and + /// generated client code — need not depend on `tokio`/`tokio-util`/ + /// `futures_util` themselves. `filename` overrides the part filename; when + /// `None`, it is derived from the path's file name. #[cfg(all(feature = "fs", not(target_arch = "wasm32")))] pub async fn file_path( self, name: impl Into, + filename: Option, content_type: impl Into, path: impl AsRef, ) -> Result { let path = path.as_ref(); - let filename = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "file".to_string()); + let filename = filename.unwrap_or_else(|| { + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file".to_string()) + }); let file = tokio::fs::File::open(path) .await .map_err(|e| TransportError::Body(e.to_string()))?; diff --git a/crates/core/ras-transport-core/src/reqwest_transport.rs b/crates/core/ras-transport-core/src/reqwest_transport.rs index 8be18ed..7fc9925 100644 --- a/crates/core/ras-transport-core/src/reqwest_transport.rs +++ b/crates/core/ras-transport-core/src/reqwest_transport.rs @@ -61,9 +61,7 @@ impl HttpTransport for ReqwestTransport { RequestBody::Empty => builder, RequestBody::Bytes(bytes) => builder.body(bytes), #[cfg(not(target_arch = "wasm32"))] - RequestBody::Stream(stream) => { - builder.body(reqwest::Body::wrap_stream(stream)) - } + RequestBody::Stream(stream) => builder.body(reqwest::Body::wrap_stream(stream)), #[cfg(target_arch = "wasm32")] RequestBody::Stream(mut stream) => { // wasm fetch cannot stream request bodies; collect first. @@ -83,8 +81,10 @@ impl HttpTransport for ReqwestTransport { // feature, so collect into a single chunk (response streaming on wasm // is bounded by the fetch implementation regardless). #[cfg(not(target_arch = "wasm32"))] - let body_stream = - byte_stream_from(resp.bytes_stream().map(|res| res.map_err(TransportError::from))); + let body_stream = byte_stream_from( + resp.bytes_stream() + .map(|res| res.map_err(TransportError::from)), + ); #[cfg(target_arch = "wasm32")] let body_stream = { diff --git a/crates/core/ras-transport-core/tests/multipart_framing.rs b/crates/core/ras-transport-core/tests/multipart_framing.rs index e2066b0..65def04 100644 --- a/crates/core/ras-transport-core/tests/multipart_framing.rs +++ b/crates/core/ras-transport-core/tests/multipart_framing.rs @@ -1,8 +1,8 @@ //! Multipart framing must produce exact RFC 7578 wire bytes. use futures_util::StreamExt; -use ras_transport_core::request::RequestBody; use ras_transport_core::multipart::MultipartBuilder; +use ras_transport_core::request::RequestBody; async fn collect_body(body: RequestBody) -> Vec { match body { @@ -49,7 +49,12 @@ async fn text_json_bytes_combo_produces_exact_wire_bytes() { .text("field1", "hello") .json("meta", &Meta { id: 7 }) .expect("json part") - .bytes_part("file", "a.bin", "application/octet-stream", b"\x00\x01\x02".to_vec()); + .bytes_part( + "file", + "a.bin", + "application/octet-stream", + b"\x00\x01\x02".to_vec(), + ); let content_type = builder.content_type(); assert_eq!(content_type, "multipart/form-data; boundary=BOUND"); @@ -84,10 +89,5 @@ async fn text_json_bytes_combo_produces_exact_wire_bytes() { expected.extend_from_slice(b"--BOUND--\r\n"); - assert_eq!( - bytes, - expected, - "got:\n{}", - String::from_utf8_lossy(&bytes) - ); + assert_eq!(bytes, expected, "got:\n{}", String::from_utf8_lossy(&bytes)); } diff --git a/crates/core/ras-transport-core/tests/multipart_streaming.rs b/crates/core/ras-transport-core/tests/multipart_streaming.rs index b4c5310..b016a94 100644 --- a/crates/core/ras-transport-core/tests/multipart_streaming.rs +++ b/crates/core/ras-transport-core/tests/multipart_streaming.rs @@ -61,7 +61,7 @@ async fn file_path_streams_disk_contents_into_a_part() { .expect("write temp file"); let (body, _ct) = MultipartBuilder::with_boundary("B") - .file_path("doc", "text/plain", &path) + .file_path("doc", None, "text/plain", &path) .await .expect("file_path") .build(); @@ -84,7 +84,12 @@ async fn file_path_streams_disk_contents_into_a_part() { async fn disposition_params_with_quotes_and_newlines_are_escaped() { // A hostile filename must not break the frame: " -> %22, CR/LF -> %0D/%0A. let (body, _ct) = MultipartBuilder::with_boundary("B") - .bytes_part("field", "ev\"il\r\n.txt", "application/octet-stream", Bytes::from_static(b"x")) + .bytes_part( + "field", + "ev\"il\r\n.txt", + "application/octet-stream", + Bytes::from_static(b"x"), + ) .build(); let text = String::from_utf8(collect_body(body).await).unwrap(); assert!(text.contains("filename=\"ev%22il%0D%0A.txt\"")); diff --git a/crates/core/ras-transport-core/tests/query_serialization.rs b/crates/core/ras-transport-core/tests/query_serialization.rs index 8d07624..ea8ae78 100644 --- a/crates/core/ras-transport-core/tests/query_serialization.rs +++ b/crates/core/ras-transport-core/tests/query_serialization.rs @@ -50,10 +50,7 @@ fn enum_uses_serde_rename() { #[test] fn pairs_are_percent_encoded() { let pairs = serialize_query_value("q", &"hello world & co").unwrap(); - assert_eq!( - serialize_query_pairs(&pairs), - "q=hello+world+%26+co" - ); + assert_eq!(serialize_query_pairs(&pairs), "q=hello+world+%26+co"); } #[test] diff --git a/crates/core/ras-transport-core/tests/request_response_units.rs b/crates/core/ras-transport-core/tests/request_response_units.rs index f91193a..5c8af51 100644 --- a/crates/core/ras-transport-core/tests/request_response_units.rs +++ b/crates/core/ras-transport-core/tests/request_response_units.rs @@ -62,7 +62,8 @@ fn request_header_drops_invalid_name_or_value() { #[test] fn request_body_direct_and_empty() { - let req = TransportRequest::new(Method::PUT, "/x").body(RequestBody::Bytes(Bytes::from_static(b"hi"))); + let req = TransportRequest::new(Method::PUT, "/x") + .body(RequestBody::Bytes(Bytes::from_static(b"hi"))); assert!(matches!(req.body, RequestBody::Bytes(_))); assert!(matches!(RequestBody::empty(), RequestBody::Empty)); @@ -179,12 +180,24 @@ fn error_display_and_constructor_cover_all_variants() { #[test] fn query_value_covers_scalar_kinds_and_percent_decode() { // bool / int / float go through encode_scalar + percent_decode. - assert_eq!(serialize_query_value("b", &true).unwrap(), vec![("b".into(), "true".into())]); - assert_eq!(serialize_query_value("n", &-42i64).unwrap(), vec![("n".into(), "-42".into())]); - assert_eq!(serialize_query_value("f", &1.5f64).unwrap(), vec![("f".into(), "1.5".into())]); + assert_eq!( + serialize_query_value("b", &true).unwrap(), + vec![("b".into(), "true".into())] + ); + assert_eq!( + serialize_query_value("n", &-42i64).unwrap(), + vec![("n".into(), "-42".into())] + ); + assert_eq!( + serialize_query_value("f", &1.5f64).unwrap(), + vec![("f".into(), "1.5".into())] + ); // char '/' encodes to %2F then is percent-decoded back to '/'. - assert_eq!(serialize_query_value("c", &'/').unwrap(), vec![("c".into(), "/".into())]); + assert_eq!( + serialize_query_value("c", &'/').unwrap(), + vec![("c".into(), "/".into())] + ); // a string value is taken verbatim (serialize_str path, no encode_scalar). assert_eq!( serialize_query_value("s", &"a b/c").unwrap(), @@ -194,7 +207,10 @@ fn query_value_covers_scalar_kinds_and_percent_decode() { // newtype struct delegates to inner. #[derive(Serialize)] struct Wrap(u8); - assert_eq!(serialize_query_value("w", &Wrap(9)).unwrap(), vec![("w".into(), "9".into())]); + assert_eq!( + serialize_query_value("w", &Wrap(9)).unwrap(), + vec![("w".into(), "9".into())] + ); } #[test] @@ -220,15 +236,24 @@ fn query_value_seq_option_and_unit_variant() { vec![("t".into(), "x".into()), ("t".into(), "y".into())] ); // Option::None -> no pairs; Some -> one. - assert_eq!(serialize_query_value("o", &Option::::None).unwrap(), vec![]); - assert_eq!(serialize_query_value("o", &Some(3u32)).unwrap(), vec![("o".into(), "3".into())]); + assert_eq!( + serialize_query_value("o", &Option::::None).unwrap(), + vec![] + ); + assert_eq!( + serialize_query_value("o", &Some(3u32)).unwrap(), + vec![("o".into(), "3".into())] + ); #[derive(Serialize)] enum Kind { #[serde(rename = "the_one")] One, } - assert_eq!(serialize_query_value("k", &Kind::One).unwrap(), vec![("k".into(), "the_one".into())]); + assert_eq!( + serialize_query_value("k", &Kind::One).unwrap(), + vec![("k".into(), "the_one".into())] + ); } #[test] @@ -252,7 +277,10 @@ fn query_value_tuple_newtype_variant_and_unit_shapes() { enum E { N(u32), } - assert_eq!(serialize_query_value("e", &E::N(5)).unwrap(), vec![("e".into(), "5".into())]); + assert_eq!( + serialize_query_value("e", &E::N(5)).unwrap(), + vec![("e".into(), "5".into())] + ); // Unit and unit struct -> no pairs. #[derive(Serialize)] @@ -287,6 +315,9 @@ fn query_value_rejects_unsupported_shapes() { #[test] fn query_pairs_join_and_empty() { assert_eq!(serialize_query_pairs(&[]), ""); - let pairs = vec![("a".to_string(), "1".to_string()), ("b".to_string(), "two".to_string())]; + let pairs = vec![ + ("a".to_string(), "1".to_string()), + ("b".to_string(), "two".to_string()), + ]; assert_eq!(serialize_query_pairs(&pairs), "a=1&b=two"); } diff --git a/crates/core/ras-transport-core/tests/transport_in_process.rs b/crates/core/ras-transport-core/tests/transport_in_process.rs index aba23fd..432fd83 100644 --- a/crates/core/ras-transport-core/tests/transport_in_process.rs +++ b/crates/core/ras-transport-core/tests/transport_in_process.rs @@ -54,7 +54,10 @@ fn transport() -> AxumTestTransport { async fn get_with_absolute_url_strips_origin_and_returns_body() { let t = transport(); let resp = t - .execute(TransportRequest::new(http::Method::GET, "http://api.example/ping")) + .execute(TransportRequest::new( + http::Method::GET, + "http://api.example/ping", + )) .await .unwrap(); assert_eq!(resp.status(), http::StatusCode::OK); @@ -78,7 +81,10 @@ async fn absolute_url_without_path_falls_back_to_root() { // Root isn't routed, so we just assert it dispatches and yields a status. let t = transport(); let resp = t - .execute(TransportRequest::new(http::Method::GET, "http://api.example")) + .execute(TransportRequest::new( + http::Method::GET, + "http://api.example", + )) .await .unwrap(); assert_eq!(resp.status(), http::StatusCode::NOT_FOUND); @@ -128,7 +134,10 @@ async fn empty_body_get_does_not_set_a_body() { async fn non_success_maps_through_error_for_status() { let t = transport(); let resp = t - .execute(TransportRequest::new(http::Method::GET, "http://api.example/boom")) + .execute(TransportRequest::new( + http::Method::GET, + "http://api.example/boom", + )) .await .unwrap(); let err = resp.error_for_status().await.unwrap_err(); diff --git a/crates/rest/ras-file-macro/src/client.rs b/crates/rest/ras-file-macro/src/client.rs index 4918195..60d3b8b 100644 --- a/crates/rest/ras-file-macro/src/client.rs +++ b/crates/rest/ras-file-macro/src/client.rs @@ -229,37 +229,19 @@ fn generate_multipart_builder( file_name: Option<&str>, content_type: Option<&str>, ) -> Result { - use ::futures_util::StreamExt as _; - let content_type = content_type.unwrap_or("application/octet-stream"); - let path_ref = file_path.as_ref(); - - let builder = match file_name { - Some(name) => { - let file = ::tokio::fs::File::open(path_ref).await.map_err(|e| { - ::ras_transport_core::TransportError::Body(e.to_string()) - })?; - let stream = ::ras_transport_core::byte_stream_from( - ::tokio_util::io::ReaderStream::new(file).map(|chunk| { - chunk.map_err(|e| { - ::ras_transport_core::TransportError::Body(e.to_string()) - }) - }), - ); - self.builder.stream_part( - #field_name, - name.to_string(), - content_type.to_string(), - stream, - ) - } - None => self - .builder - .file_path(#field_name, content_type.to_string(), path_ref) - .await?, - }; - - self.builder = builder; + // The disk -> stream conversion (and its tokio/tokio-util/ + // futures-util usage) lives entirely in ras-transport-core, + // so consumers need not depend on those crates. + self.builder = self + .builder + .file_path( + #field_name, + file_name.map(|name| name.to_string()), + content_type.to_string(), + file_path.as_ref(), + ) + .await?; Ok(self) } diff --git a/crates/rest/ras-rest-macro/tests/e2e.rs b/crates/rest/ras-rest-macro/tests/e2e.rs index fae5d0e..ccd85eb 100644 --- a/crates/rest/ras-rest-macro/tests/e2e.rs +++ b/crates/rest/ras-rest-macro/tests/e2e.rs @@ -450,13 +450,23 @@ async fn query_params_with_body_and_auth() { client.set_bearer_token(Some("admin-token")); let item = client - .post_items_batch(true, CreateItem { name: "alpha".into() }) + .post_items_batch( + true, + CreateItem { + name: "alpha".into(), + }, + ) .await .expect("post_items_batch notify=true failed"); assert_eq!(item.name, "alpha(notified)"); let item = client - .post_items_batch(false, CreateItem { name: "beta".into() }) + .post_items_batch( + false, + CreateItem { + name: "beta".into(), + }, + ) .await .expect("post_items_batch notify=false failed"); assert_eq!(item.name, "beta(silent)"); diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs index c7536f0..8d03606 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs @@ -9,9 +9,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; mod support; +use support::{MockAuthProvider, mock_http_server}; #[cfg(feature = "client")] use support::{axum_transport, mock_http_server_arc}; -use support::{MockAuthProvider, mock_http_server}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] struct EchoRequest { @@ -245,7 +245,12 @@ async fn generated_client_round_trips_versioned_wire_method() { .await .expect("legacy versioned method should round-trip via client"); - assert_eq!(resp, RenameUserResponseV1 { name: "Ada".to_string() }); + assert_eq!( + resp, + RenameUserResponseV1 { + name: "Ada".to_string() + } + ); } async fn call_rpc( From 6098683200f9b48b4c9ae2aaeb137f493ee47170 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Fri, 5 Jun 2026 14:48:18 +0200 Subject: [PATCH 3/6] fix: restore CI on rustc/clippy 1.96.0 CI's `dtolnay/rust-toolchain@stable` advanced to rustc 1.96.0 (2026-05-25), which broke the workspace independently of the transport refactor: - E0446 "private type in public interface" is now a hard error. Many existing ras-jsonrpc-macro test/example/bench fixtures declared non-pub request/ response structs that the generated `pub` service trait exposes. Make those fixture types `pub` (bare visibility flips only). - New lint clippy::manual_noop_waker fires on ras-auth-core's test NoopWaker; replace it with `std::task::Waker::noop().clone()`. No library logic, public APIs, features, deps, or CI config changed. Verified on 1.96.0 (exact CI commands): - cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - cargo test --workspace --all-targets --all-features --locked - cargo test --doc --workspace --all-features --locked - cargo fmt --all -- --check Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/ras-auth-core/src/lib.rs | 11 ++--------- crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs | 4 ++-- .../examples/explorer_params_demo.rs | 14 +++++++------- .../examples/missing_handler_demo.rs | 4 ++-- crates/rpc/ras-jsonrpc-macro/examples/usage.rs | 4 ++-- crates/rpc/ras-jsonrpc-macro/tests/e2e.rs | 16 ++++++++-------- .../tests/error_sanitization_test.rs | 4 ++-- .../rpc/ras-jsonrpc-macro/tests/explorer_test.rs | 8 ++++---- .../ras-jsonrpc-macro/tests/http_integration.rs | 16 ++++++++-------- .../tests/http_status_codes_test.rs | 4 ++-- .../rpc/ras-jsonrpc-macro/tests/integration.rs | 8 ++++---- .../tests/missing_handler_test.rs | 4 ++-- .../tests/multiple_invocations.rs | 4 ++-- 13 files changed, 47 insertions(+), 54 deletions(-) diff --git a/crates/core/ras-auth-core/src/lib.rs b/crates/core/ras-auth-core/src/lib.rs index cbd7edf..54c3056 100644 --- a/crates/core/ras-auth-core/src/lib.rs +++ b/crates/core/ras-auth-core/src/lib.rs @@ -107,8 +107,7 @@ pub trait AuthProvider: Send + Sync + 'static { #[cfg(test)] mod tests { use std::collections::HashSet; - use std::sync::Arc; - use std::task::{Context, Poll, Wake, Waker}; + use std::task::{Context, Poll, Waker}; use serde_json::json; @@ -140,14 +139,8 @@ mod tests { } } - struct NoopWaker; - - impl Wake for NoopWaker { - fn wake(self: Arc) {} - } - fn poll_auth_future(mut future: AuthFuture<'_>) -> AuthResult { - let waker = Waker::from(Arc::new(NoopWaker)); + let waker = Waker::noop().clone(); let mut context = Context::from_waker(&waker); match future.as_mut().poll(&mut context) { diff --git a/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs b/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs index eafed17..14d6512 100644 --- a/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs +++ b/crates/rpc/ras-jsonrpc-macro/benches/dispatch.rs @@ -16,13 +16,13 @@ mod support; use support::{MockAuthProvider, mock_http_server}; #[derive(Debug, Clone, Serialize, Deserialize)] -struct AddRequest { +pub struct AddRequest { a: i64, b: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct AddResponse { +pub struct AddResponse { sum: i64, } diff --git a/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs b/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs index d6755c4..5bee8e2 100644 --- a/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs +++ b/crates/rpc/ras-jsonrpc-macro/examples/explorer_params_demo.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] /// Request to create a new user account -struct CreateUserRequest { +pub struct CreateUserRequest { /// The desired username for the new account username: String, /// Email address for the user @@ -19,20 +19,20 @@ struct CreateUserRequest { } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct CreateUserResponse { +pub struct CreateUserResponse { user_id: String, message: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] /// Request to get user details by ID -struct GetUserRequest { +pub struct GetUserRequest { /// The unique user identifier user_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct GetUserResponse { +pub struct GetUserResponse { user_id: String, username: String, email: String, @@ -42,7 +42,7 @@ struct GetUserResponse { #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] /// Search for users by various criteria -struct SearchUsersRequest { +pub struct SearchUsersRequest { /// Optional username pattern to search for #[serde(skip_serializing_if = "Option::is_none")] username_pattern: Option, @@ -62,13 +62,13 @@ fn default_limit() -> u32 { } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct SearchUsersResponse { +pub struct SearchUsersResponse { users: Vec, total_count: u32, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct UserSummary { +pub struct UserSummary { user_id: String, username: String, email: String, diff --git a/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs b/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs index d7b178f..ef7a6ae 100644 --- a/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs +++ b/crates/rpc/ras-jsonrpc-macro/examples/missing_handler_demo.rs @@ -6,13 +6,13 @@ use serde::{Deserialize, Serialize}; // Example types #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct CalculateRequest { +pub struct CalculateRequest { a: i32, b: i32, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct CalculateResponse { +pub struct CalculateResponse { result: i32, } diff --git a/crates/rpc/ras-jsonrpc-macro/examples/usage.rs b/crates/rpc/ras-jsonrpc-macro/examples/usage.rs index eebc0da..804c03e 100644 --- a/crates/rpc/ras-jsonrpc-macro/examples/usage.rs +++ b/crates/rpc/ras-jsonrpc-macro/examples/usage.rs @@ -3,13 +3,13 @@ use ras_jsonrpc_macro::jsonrpc_service; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] -struct SignInRequest { +pub struct SignInRequest { email: String, password: String, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct SignInResponse { +pub struct SignInResponse { jwt: String, user_id: String, } diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs index 8d03606..e699b23 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs @@ -14,45 +14,45 @@ use support::{MockAuthProvider, mock_http_server}; use support::{axum_transport, mock_http_server_arc}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -struct EchoRequest { +pub struct EchoRequest { msg: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -struct EchoResponse { +pub struct EchoResponse { msg: String, user_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -struct AddRequest { +pub struct AddRequest { a: i64, b: i64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -struct AddResponse { +pub struct AddResponse { sum: i64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -struct RenameUserV1 { +pub struct RenameUserV1 { name: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -struct RenameUserV2 { +pub struct RenameUserV2 { display_name: String, notify: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -struct RenameUserResponseV1 { +pub struct RenameUserResponseV1 { name: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -struct RenameUserResponseV2 { +pub struct RenameUserResponseV2 { display_name: String, notified: bool, } diff --git a/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs index a90e557..7216569 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/error_sanitization_test.rs @@ -5,12 +5,12 @@ mod tests { // Test types #[derive(Serialize, Deserialize, Debug)] - struct TestRequest { + pub struct TestRequest { value: String, } #[derive(Serialize, Deserialize, Debug)] - struct TestResponse { + pub struct TestResponse { result: String, } diff --git a/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs index ab5db24..9047e49 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/explorer_test.rs @@ -5,23 +5,23 @@ mod tests { use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] - struct CreateUserRequest { + pub struct CreateUserRequest { username: String, email: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] - struct CreateUserResponse { + pub struct CreateUserResponse { user_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] - struct GetUserRequest { + pub struct GetUserRequest { user_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] - struct GetUserResponse { + pub struct GetUserResponse { user_id: String, username: String, email: String, diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs index 8629037..8139573 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs @@ -9,26 +9,26 @@ use std::collections::HashSet; // Test data structures for various scenarios #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct SignInRequest { +pub struct SignInRequest { email: String, password: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct SignInResponse { +pub struct SignInResponse { jwt: String, user_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct CreateUserRequest { +pub struct CreateUserRequest { name: String, email: String, permissions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct User { +pub struct User { id: Option, name: String, email: String, @@ -36,26 +36,26 @@ struct User { } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct ComplexRequest { +pub struct ComplexRequest { data: Vec, metadata: Option, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct NestedData { +pub struct NestedData { id: i32, value: String, active: bool, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct MetadataInfo { +pub struct MetadataInfo { version: String, tags: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct ProcessingResult { +pub struct ProcessingResult { processed_count: usize, errors: Vec, success: bool, diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs index e1e6358..7db68f1 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_status_codes_test.rs @@ -7,12 +7,12 @@ use std::collections::HashSet; use tower::ServiceExt; #[derive(Debug, Clone, Serialize, Deserialize)] -struct TestRequest { +pub struct TestRequest { value: String, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct TestResponse { +pub struct TestResponse { result: String, } diff --git a/crates/rpc/ras-jsonrpc-macro/tests/integration.rs b/crates/rpc/ras-jsonrpc-macro/tests/integration.rs index 73609b6..808c539 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/integration.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/integration.rs @@ -3,25 +3,25 @@ use serde::{Deserialize, Serialize}; // Test types for requests and responses #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct SignInRequest { +pub struct SignInRequest { email: String, password: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct SignInResponse { +pub struct SignInResponse { jwt: String, user_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct CreateUserRequest { +pub struct CreateUserRequest { name: String, role: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct User { +pub struct User { id: String, name: String, role: String, diff --git a/crates/rpc/ras-jsonrpc-macro/tests/missing_handler_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/missing_handler_test.rs index 0b02f9c..1151571 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/missing_handler_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/missing_handler_test.rs @@ -3,12 +3,12 @@ use serde::{Deserialize, Serialize}; // Test types #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct TestRequest { +pub struct TestRequest { data: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct TestResponse { +pub struct TestResponse { result: String, } diff --git a/crates/rpc/ras-jsonrpc-macro/tests/multiple_invocations.rs b/crates/rpc/ras-jsonrpc-macro/tests/multiple_invocations.rs index c7b0c7b..4d649b2 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/multiple_invocations.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/multiple_invocations.rs @@ -2,12 +2,12 @@ use ras_jsonrpc_macro::jsonrpc_service; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct PingRequest { +pub struct PingRequest { value: String, } #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -struct PingResponse { +pub struct PingResponse { value: String, } From 49b70030424851bbe21df4f0406ff70fda7e89bf Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Fri, 5 Jun 2026 21:56:37 +0200 Subject: [PATCH 4/6] fix(transport): address review findings on transport abstraction - multipart: generate boundary from CSPRNG entropy (getrandom) instead of a predictable timestamp+counter, and correct the stale doc comment. The boundary is the only thing protecting multipart framing from injection when part bodies carry caller-forwarded bytes. - error: add TransportError::Timeout and route reqwest timeouts to it so a per-request timeout no longer masquerades as a connection error. - response: error_for_status surfaces a failed body read instead of silently blanking the diagnostic. - lib: serialize_query_pairs now returns Result (matching serialize_query_value) rather than swallowing failure into an empty string after the codegen has written a '?'/'&' separator; rest client codegen propagates it with '?'. - ras-jsonrpc-macro: make ras-transport-core an optional dep, matching the rest/file macro crates (it is only referenced by generated client code). - docs: fix broken intra-doc links in generated clients and the inaccurate "only transport that buffers" note on the axum-test transport. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/core/ras-transport-core/Cargo.toml | 3 ++ .../src/axum_test_transport.rs | 7 +++-- crates/core/ras-transport-core/src/error.rs | 13 +++++++-- crates/core/ras-transport-core/src/lib.rs | 10 +++++-- .../core/ras-transport-core/src/multipart.rs | 28 ++++++++++++------- .../core/ras-transport-core/src/response.rs | 8 +++++- .../tests/query_serialization.rs | 15 ++++++---- .../tests/request_response_units.rs | 4 +-- crates/rest/ras-rest-macro/src/client.rs | 6 ++-- crates/rpc/ras-jsonrpc-macro/Cargo.toml | 5 ++-- crates/rpc/ras-jsonrpc-macro/src/client.rs | 2 +- 12 files changed, 69 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84ae5e4..8cff78c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2995,6 +2995,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", + "getrandom 0.2.17", "http", "reqwest", "serde", diff --git a/crates/core/ras-transport-core/Cargo.toml b/crates/core/ras-transport-core/Cargo.toml index e979534..ab24d09 100644 --- a/crates/core/ras-transport-core/Cargo.toml +++ b/crates/core/ras-transport-core/Cargo.toml @@ -12,6 +12,7 @@ readme = "README.md" [dependencies] async-trait = { workspace = true } bytes = { workspace = true } +getrandom = "0.2" futures-core = { workspace = true } futures-util = { workspace = true } http = { workspace = true } @@ -28,6 +29,8 @@ tokio-util = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] reqwest = { version = "0.12", default-features = false, features = ["json"], optional = true } +# `js` lets `getrandom` source entropy from the browser crypto API on wasm. +getrandom = { version = "0.2", features = ["js"] } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/core/ras-transport-core/src/axum_test_transport.rs b/crates/core/ras-transport-core/src/axum_test_transport.rs index dfb33da..8dd4c0d 100644 --- a/crates/core/ras-transport-core/src/axum_test_transport.rs +++ b/crates/core/ras-transport-core/src/axum_test_transport.rs @@ -1,8 +1,9 @@ //! In-process test transport wrapping an `axum_test::TestServer`. //! -//! This is the only transport that buffers: `axum-test` drives the router -//! directly and has no streaming request/response API. It exists so generated -//! clients can be exercised end-to-end against a server with no sockets. +//! This transport fully buffers both request and response bodies: `axum-test` +//! drives the router directly and has no streaming request/response API. (The +//! native `ReqwestTransport` streams both; the wasm one also buffers.) It +//! exists so generated clients can be exercised end-to-end with no sockets. #![cfg(all(not(target_arch = "wasm32"), feature = "axum-test"))] diff --git a/crates/core/ras-transport-core/src/error.rs b/crates/core/ras-transport-core/src/error.rs index 42c83dc..9d09991 100644 --- a/crates/core/ras-transport-core/src/error.rs +++ b/crates/core/ras-transport-core/src/error.rs @@ -15,6 +15,10 @@ pub enum TransportError { #[error("connection error: {0}")] Connection(String), + /// The request exceeded its configured timeout before a response arrived. + #[error("timeout: {0}")] + Timeout(String), + /// The server returned a non-success HTTP status. /// /// Produced by [`crate::TransportResponse::error_for_status`]; transports @@ -62,9 +66,12 @@ impl TransportError { #[cfg(feature = "reqwest")] impl From for TransportError { fn from(err: reqwest::Error) -> Self { - // Decode failures (including body-read failures) map to `Body`; every - // other reqwest failure is treated as a connection-level problem. - if err.is_decode() { + // Timeouts get their own variant (the per-request timeout this crate + // plumbs through surfaces here); decode/body-read failures map to + // `Body`; everything else is treated as a connection-level problem. + if err.is_timeout() { + TransportError::Timeout(err.to_string()) + } else if err.is_decode() { TransportError::Body(err.to_string()) } else { TransportError::Connection(err.to_string()) diff --git a/crates/core/ras-transport-core/src/lib.rs b/crates/core/ras-transport-core/src/lib.rs index 7da6774..a74e8da 100644 --- a/crates/core/ras-transport-core/src/lib.rs +++ b/crates/core/ras-transport-core/src/lib.rs @@ -158,8 +158,14 @@ pub fn serialize_query_value( /// `application/x-www-form-urlencoded` unreserved set (`*` stays raw, `~` /// becomes `%7E`, space becomes `+`). Keys are emitted in order, so repeated /// keys (from `Vec`) preserve their sequence. -pub fn serialize_query_pairs(pairs: &[(String, String)]) -> String { - serde_urlencoded::to_string(pairs).unwrap_or_default() +/// +/// Returns [`TransportError::Serialize`] on encoding failure rather than +/// silently yielding an empty string — generated clients append the result +/// after a `?`/`&` separator, so a swallowed failure would send a different +/// (unfiltered) query than the caller asked for. +pub fn serialize_query_pairs(pairs: &[(String, String)]) -> Result { + serde_urlencoded::to_string(pairs) + .map_err(|e| TransportError::Serialize(serde::ser::Error::custom(e.to_string()))) } /// Deserialize JSON bytes into `T`, mapping failures to diff --git a/crates/core/ras-transport-core/src/multipart.rs b/crates/core/ras-transport-core/src/multipart.rs index 43e8e82..b1a140d 100644 --- a/crates/core/ras-transport-core/src/multipart.rs +++ b/crates/core/ras-transport-core/src/multipart.rs @@ -251,15 +251,23 @@ fn flatten_segments(segments: Vec) -> ByteStream { Box::pin(flat) } -/// Generate a random-ish boundary. Uses the address of a stack allocation and -/// a monotonic counter to avoid pulling in `rand`. +/// Generate an unpredictable multipart boundary from 128 bits of CSPRNG output +/// (via `getrandom`), hex-encoded. +/// +/// An unguessable boundary is the only thing protecting the multipart framing +/// from injection: part bodies are written verbatim and never scanned for the +/// delimiter, so a caller forwarding attacker-controlled bytes relies on the +/// attacker being unable to guess the boundary. This matches the entropy +/// `reqwest::multipart` uses; a predictable (timestamp/counter) boundary would +/// let an attacker forge or terminate parts. fn generate_boundary() -> String { - use std::sync::atomic::{AtomicU64, Ordering}; - static COUNTER: AtomicU64 = AtomicU64::new(0); - let n = COUNTER.fetch_add(1, Ordering::Relaxed); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - format!("ras-boundary-{nanos:x}-{n:x}") + use std::fmt::Write as _; + let mut bytes = [0u8; 16]; + getrandom::getrandom(&mut bytes).expect("failed to read OS entropy for multipart boundary"); + let mut s = String::with_capacity("ras-boundary-".len() + bytes.len() * 2); + s.push_str("ras-boundary-"); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s } diff --git a/crates/core/ras-transport-core/src/response.rs b/crates/core/ras-transport-core/src/response.rs index cbbe1cc..e78d947 100644 --- a/crates/core/ras-transport-core/src/response.rs +++ b/crates/core/ras-transport-core/src/response.rs @@ -76,7 +76,13 @@ impl TransportResponse { Ok(self) } else { let status = self.status; - let body = self.text().await.unwrap_or_default(); + // Surface a failed body read rather than silently blanking the + // diagnostic — an empty `http status 500:` hides why the body was + // unreadable. + let body = match self.text().await { + Ok(body) => body, + Err(e) => format!(""), + }; Err(TransportError::http_status(status, body)) } } diff --git a/crates/core/ras-transport-core/tests/query_serialization.rs b/crates/core/ras-transport-core/tests/query_serialization.rs index ea8ae78..953d4e8 100644 --- a/crates/core/ras-transport-core/tests/query_serialization.rs +++ b/crates/core/ras-transport-core/tests/query_serialization.rs @@ -35,7 +35,7 @@ fn vec_produces_repeated_keys() { ("tag".to_string(), "c".to_string()), ] ); - assert_eq!(serialize_query_pairs(&pairs), "tag=a&tag=b&tag=c"); + assert_eq!(serialize_query_pairs(&pairs).unwrap(), "tag=a&tag=b&tag=c"); } #[test] @@ -50,7 +50,10 @@ fn enum_uses_serde_rename() { #[test] fn pairs_are_percent_encoded() { let pairs = serialize_query_value("q", &"hello world & co").unwrap(); - assert_eq!(serialize_query_pairs(&pairs), "q=hello+world+%26+co"); + assert_eq!( + serialize_query_pairs(&pairs).unwrap(), + "q=hello+world+%26+co" + ); } #[test] @@ -60,16 +63,16 @@ fn encoding_matches_reqwests_urlencoded_unreserved_set() { // characters where the previous hand-rolled encoder diverged: // `~` must be percent-encoded (`%7E`), and `*` must stay raw (`*`). let pairs = serialize_query_value("q", &"~").unwrap(); - assert_eq!(serialize_query_pairs(&pairs), "q=%7E"); + assert_eq!(serialize_query_pairs(&pairs).unwrap(), "q=%7E"); let pairs = serialize_query_value("q", &"*").unwrap(); - assert_eq!(serialize_query_pairs(&pairs), "q=*"); + assert_eq!(serialize_query_pairs(&pairs).unwrap(), "q=*"); // And the round-trip through `serialize_query_value` (which decodes the // scalar) followed by `serialize_query_pairs` (which re-encodes) is stable. let pairs = serialize_query_value("q", &"a~b*c").unwrap(); assert_eq!(pairs, vec![("q".to_string(), "a~b*c".to_string())]); - assert_eq!(serialize_query_pairs(&pairs), "q=a%7Eb*c"); + assert_eq!(serialize_query_pairs(&pairs).unwrap(), "q=a%7Eb*c"); } #[test] @@ -79,5 +82,5 @@ fn full_query_string_joins_multiple_params() { let none: Option = None; all.extend(serialize_query_value("offset", &none).unwrap()); all.extend(serialize_query_value("tag", &vec!["x", "y"]).unwrap()); - assert_eq!(serialize_query_pairs(&all), "limit=5&tag=x&tag=y"); + assert_eq!(serialize_query_pairs(&all).unwrap(), "limit=5&tag=x&tag=y"); } diff --git a/crates/core/ras-transport-core/tests/request_response_units.rs b/crates/core/ras-transport-core/tests/request_response_units.rs index 5c8af51..f8ba747 100644 --- a/crates/core/ras-transport-core/tests/request_response_units.rs +++ b/crates/core/ras-transport-core/tests/request_response_units.rs @@ -314,10 +314,10 @@ fn query_value_rejects_unsupported_shapes() { #[test] fn query_pairs_join_and_empty() { - assert_eq!(serialize_query_pairs(&[]), ""); + assert_eq!(serialize_query_pairs(&[]).unwrap(), ""); let pairs = vec![ ("a".to_string(), "1".to_string()), ("b".to_string(), "two".to_string()), ]; - assert_eq!(serialize_query_pairs(&pairs), "a=1&b=two"); + assert_eq!(serialize_query_pairs(&pairs).unwrap(), "a=1&b=two"); } diff --git a/crates/rest/ras-rest-macro/src/client.rs b/crates/rest/ras-rest-macro/src/client.rs index 91c04f4..13f71b9 100644 --- a/crates/rest/ras-rest-macro/src/client.rs +++ b/crates/rest/ras-rest-macro/src/client.rs @@ -100,7 +100,7 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok self } - /// Build the client using the default [`ReqwestTransport`]. + /// Build the client using the default `ReqwestTransport`. /// /// # Errors /// @@ -116,7 +116,7 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok /// # Errors /// /// Currently infallible, but returns a `Result` for forward - /// compatibility and parity with [`build`]. + /// compatibility and parity with [`Self::build`]. pub fn build_with_transport( self, transport: std::sync::Arc, @@ -373,7 +373,7 @@ fn generate_client_method_with_timeout( if !__query_pairs.is_empty() { // Use '&' if the path template already carried a literal query. url.push(if url.contains('?') { '&' } else { '?' }); - url.push_str(&::ras_transport_core::serialize_query_pairs(&__query_pairs)); + url.push_str(&::ras_transport_core::serialize_query_pairs(&__query_pairs)?); } } }; diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index f6a8d78..8f0d18e 100644 --- a/crates/rpc/ras-jsonrpc-macro/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-macro/Cargo.toml @@ -31,8 +31,9 @@ schemars = { workspace = true } axum = { workspace = true, optional = true } ras-jsonrpc-core = { path = "../ras-jsonrpc-core", version = "0.1.2", optional = true } -# Client dependencies -ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0" } +# Client dependencies (only referenced by generated client code, gated by the +# `client` feature — see `ras-rest-macro`/`ras-file-macro` for the same wiring). +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", optional = true } # Always needed for types ras-jsonrpc-types = { path = "../ras-jsonrpc-types", version = "0.1.1" } diff --git a/crates/rpc/ras-jsonrpc-macro/src/client.rs b/crates/rpc/ras-jsonrpc-macro/src/client.rs index 8d3f78d..3bd9323 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/client.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/client.rs @@ -55,7 +55,7 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok self } - /// Build the client using the default [`ReqwestTransport`]. + /// Build the client using the default `ReqwestTransport`. pub fn build(self) -> Result<#client_name, Box> { let transport = std::sync::Arc::new(::ras_transport_core::ReqwestTransport::new()); self.build_with_transport(transport) From ee67941a6d8f193c7e04da3edce1f6985d8ec28a Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Fri, 5 Jun 2026 23:38:21 +0200 Subject: [PATCH 5/6] fix(transport): close three client-side injection/auth gaps in transport abstraction Harden the new reqwest-free request construction in ras-transport-core and the generated REST/JSON-RPC/file clients. Each fix ships with a red-then-green regression test. - Multipart Content-Type CRLF injection: the hand-rolled multipart builder wrote a part's Content-Type verbatim (unlike name/filename, which were escaped), letting a caller-supplied content type inject extra header lines or break the part header block. Sanitize it at the framing sink. - Bearer token fail-open: TransportRequest::bearer() silently dropped a token that could not be encoded as a header value, sending the request unauthenticated. It now fails closed (returns TransportError::InvalidHeader); header() stays best-effort. Propagated through the rest/jsonrpc/file generators (file build_request now returns Result). - Unencoded path parameters: client generators substituted path params raw, so '/', '?', '#', etc. could escape the URL segment. Add and wire encode_path_segment (RFC 3986 unreserved pass-through, percent-encode rest). Tests: multipart/bearer regressions in ras-transport-core; path-encoding unit test plus a capturing-transport integration test in ras-rest-macro. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + crates/core/ras-transport-core/src/error.rs | 9 ++ crates/core/ras-transport-core/src/lib.rs | 36 ++++++ .../core/ras-transport-core/src/multipart.rs | 13 ++- crates/core/ras-transport-core/src/request.rs | 16 ++- .../tests/request_response_units.rs | 22 +++- .../tests/security_regression.rs | 82 ++++++++++++++ .../tests/transport_in_process.rs | 1 + crates/rest/ras-file-macro/src/client.rs | 36 ++++-- crates/rest/ras-rest-macro/Cargo.toml | 1 + crates/rest/ras-rest-macro/src/client.rs | 12 +- .../tests/path_param_encoding.rs | 105 ++++++++++++++++++ crates/rpc/ras-jsonrpc-macro/src/client.rs | 2 +- 13 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 crates/core/ras-transport-core/tests/security_regression.rs create mode 100644 crates/rest/ras-rest-macro/tests/path_param_encoding.rs diff --git a/Cargo.lock b/Cargo.lock index 8cff78c..e5f66ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2964,6 +2964,7 @@ dependencies = [ "axum", "axum-extra", "axum-test", + "bytes", "chrono", "criterion", "futures", diff --git a/crates/core/ras-transport-core/src/error.rs b/crates/core/ras-transport-core/src/error.rs index 9d09991..5c340e9 100644 --- a/crates/core/ras-transport-core/src/error.rs +++ b/crates/core/ras-transport-core/src/error.rs @@ -43,6 +43,15 @@ pub enum TransportError { #[error("body error: {0}")] Body(String), + /// A header value could not be encoded (e.g. a bearer token containing a + /// control character). Surfaced instead of silently dropping the header, + /// so an auth-bearing request is never sent unauthenticated. + /// + /// The offending value is deliberately not included to avoid leaking + /// secrets (tokens) into error messages/logs. + #[error("invalid header value: {0}")] + InvalidHeader(String), + /// A JSON-RPC 2.0 error object was returned in the response envelope. #[error("json-rpc error {code}: {message}")] JsonRpc { diff --git a/crates/core/ras-transport-core/src/lib.rs b/crates/core/ras-transport-core/src/lib.rs index a74e8da..451a3fa 100644 --- a/crates/core/ras-transport-core/src/lib.rs +++ b/crates/core/ras-transport-core/src/lib.rs @@ -174,6 +174,42 @@ pub fn deserialize_json(bytes: &[u8]) -> Result `/items/`). Without encoding, a value +/// containing `/`, `?`, `#`, `%`, or control bytes could break out of its +/// segment and alter the request's path, query, or fragment (e.g. an `id` of +/// `../admin` or `x?role=admin`). Only RFC 3986 `unreserved` characters +/// (`ALPHA`/`DIGIT`/`-`/`.`/`_`/`~`) pass through unescaped; every other byte is +/// `%XX`-encoded. The result is what servers (e.g. axum's `Path` extractor) +/// percent-decode back to the original value. +pub fn encode_path_segment(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for &b in value.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push(hex_digit(b >> 4)); + out.push(hex_digit(b & 0x0f)); + } + } + } + out +} + +/// Upper-case hex digit for a nibble (0..=15). +fn hex_digit(nibble: u8) -> char { + match nibble { + 0..=9 => (b'0' + nibble) as char, + _ => (b'A' + (nibble - 10)) as char, + } +} + // --- internal helpers --- /// Render one scalar value through `serde_urlencoded` and return the decoded diff --git a/crates/core/ras-transport-core/src/multipart.rs b/crates/core/ras-transport-core/src/multipart.rs index b1a140d..97434f7 100644 --- a/crates/core/ras-transport-core/src/multipart.rs +++ b/crates/core/ras-transport-core/src/multipart.rs @@ -214,6 +214,17 @@ fn escape_disposition_param(value: &str) -> String { .replace('\n', "%0A") } +/// Strip CR/LF from a part's `Content-Type` before it is written as a header +/// line. The value is interpolated verbatim into the part header block, so a +/// CR or LF (which is never valid in a MIME type) would otherwise let a +/// caller-supplied content type inject extra header lines or prematurely +/// terminate the header block — the same multipart-framing injection that +/// [`escape_disposition_param`] guards `name`/`filename` against. `reqwest`'s +/// `Part::mime_str` got this for free by validating the value as a header. +fn sanitize_content_type(value: &str) -> String { + value.replace(['\r', '\n'], "") +} + /// Build the RFC 7578 header block for a part (boundary line + disposition + /// optional content type + the blank line that ends the header block). fn part_header(boundary: &str, part: &Part) -> String { @@ -229,7 +240,7 @@ fn part_header(boundary: &str, part: &Part) -> String { } s.push_str("\r\n"); if let Some(ct) = &part.content_type { - s.push_str(&format!("Content-Type: {ct}\r\n")); + s.push_str(&format!("Content-Type: {}\r\n", sanitize_content_type(ct))); } s.push_str("\r\n"); s diff --git a/crates/core/ras-transport-core/src/request.rs b/crates/core/ras-transport-core/src/request.rs index 9f37848..e65a369 100644 --- a/crates/core/ras-transport-core/src/request.rs +++ b/crates/core/ras-transport-core/src/request.rs @@ -81,6 +81,10 @@ impl TransportRequest { } /// Add a header. Invalid header names/values are silently dropped. + /// + /// This best-effort behavior is intentional for arbitrary caller headers; + /// for the security-sensitive auth header use [`Self::bearer`], which fails + /// closed instead. pub fn header(mut self, name: impl AsRef, value: impl AsRef) -> Self { if let (Ok(name), Ok(value)) = ( HeaderName::try_from(name.as_ref()), @@ -92,8 +96,16 @@ impl TransportRequest { } /// Set the `Authorization: Bearer ` header. - pub fn bearer(self, token: impl AsRef) -> Self { - self.header("authorization", format!("Bearer {}", token.as_ref())) + /// + /// Unlike [`Self::header`], this fails closed: if the token cannot be + /// encoded as a header value (e.g. it contains a control character) it + /// returns [`TransportError::InvalidHeader`] rather than silently dropping + /// the header and sending the request unauthenticated. + pub fn bearer(mut self, token: impl AsRef) -> Result { + let value = HeaderValue::try_from(format!("Bearer {}", token.as_ref())) + .map_err(|_| TransportError::InvalidHeader("bearer token".to_string()))?; + self.headers.insert(http::header::AUTHORIZATION, value); + Ok(self) } /// Serialize `value` as a JSON body and set `Content-Type: application/json`. diff --git a/crates/core/ras-transport-core/tests/request_response_units.rs b/crates/core/ras-transport-core/tests/request_response_units.rs index f8ba747..f8f0fc8 100644 --- a/crates/core/ras-transport-core/tests/request_response_units.rs +++ b/crates/core/ras-transport-core/tests/request_response_units.rs @@ -7,7 +7,7 @@ use http::{Method, StatusCode}; use ras_transport_core::request::RequestBody; use ras_transport_core::{ ByteStream, TransportError, TransportRequest, TransportResponse, byte_stream_from, - deserialize_json, serialize_query_pairs, serialize_query_value, + deserialize_json, encode_path_segment, serialize_query_pairs, serialize_query_value, }; use serde::Serialize; @@ -37,6 +37,7 @@ fn request_builders_set_method_url_headers_body_timeout() { let req = TransportRequest::new(Method::POST, "http://h/x") .header("x-custom", "v") .bearer("tok") + .expect("valid bearer token") .json(&Payload { a: 1 }) .expect("json body") .timeout(std::time::Duration::from_millis(50)); @@ -312,6 +313,25 @@ fn query_value_rejects_unsupported_shapes() { assert!(serialize_query_value("e", &E::Strukt { x: 1 }).is_err()); // struct variant } +#[test] +fn encode_path_segment_passes_unreserved_and_escapes_the_rest() { + // RFC 3986 unreserved set is passed through verbatim. + assert_eq!(encode_path_segment("abcXYZ-._~09"), "abcXYZ-._~09"); + + // Segment/path/query/fragment delimiters are escaped so a value cannot + // break out of its slot. + assert_eq!(encode_path_segment("a/b"), "a%2Fb"); + assert_eq!(encode_path_segment("../admin"), "..%2Fadmin"); + assert_eq!(encode_path_segment("x?role=admin"), "x%3Frole%3Dadmin"); + assert_eq!(encode_path_segment("a#frag"), "a%23frag"); + assert_eq!(encode_path_segment("100%"), "100%25"); + assert_eq!(encode_path_segment("a b"), "a%20b"); + + // Control bytes and multi-byte UTF-8 are percent-encoded per byte. + assert_eq!(encode_path_segment("a\r\nb"), "a%0D%0Ab"); + assert_eq!(encode_path_segment("é"), "%C3%A9"); +} + #[test] fn query_pairs_join_and_empty() { assert_eq!(serialize_query_pairs(&[]).unwrap(), ""); diff --git a/crates/core/ras-transport-core/tests/security_regression.rs b/crates/core/ras-transport-core/tests/security_regression.rs new file mode 100644 index 0000000..5f6a331 --- /dev/null +++ b/crates/core/ras-transport-core/tests/security_regression.rs @@ -0,0 +1,82 @@ +//! Security regression tests. +//! +//! Each test pins a concrete attack: it must fail before the corresponding fix +//! and pass after. See the security review of `feat/transport-trait-abstraction`. + +use futures_util::StreamExt; +use http::Method; +use ras_transport_core::TransportRequest; +use ras_transport_core::multipart::MultipartBuilder; +use ras_transport_core::request::RequestBody; + +async fn collect_body(body: RequestBody) -> Vec { + match body { + RequestBody::Stream(mut s) => { + let mut out = Vec::new(); + while let Some(chunk) = s.next().await { + out.extend_from_slice(&chunk.expect("chunk")); + } + out + } + RequestBody::Bytes(b) => b.to_vec(), + RequestBody::Empty => Vec::new(), + } +} + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + +/// A part `Content-Type` carrying CRLF must not be able to inject extra header +/// lines (or prematurely terminate the part header block) into the multipart +/// frame. `name`/`filename` are already escaped by `escape_disposition_param`; +/// `content_type` was written verbatim, so this is the gap. +#[tokio::test] +async fn content_type_crlf_cannot_inject_multipart_headers() { + let malicious = "image/png\r\nX-Injected: evil\r\nContent-Type: text/html"; + let (body, _ct) = MultipartBuilder::with_boundary("BOUND") + .bytes_part("file", "a.png", malicious, b"data".to_vec()) + .build(); + + let bytes = collect_body(body).await; + let wire = String::from_utf8_lossy(&bytes); + + // No injected header line may appear as its own CRLF-delimited header. + assert!( + !contains(&bytes, b"\r\nX-Injected: evil"), + "CRLF injection in Content-Type produced a forged header line:\n{wire}" + ); + // The header block must not be terminated early by an injected blank line + // before the legitimate one (a single part has exactly one blank line that + // ends its header block). + assert_eq!( + wire.matches("\r\n\r\n").count(), + 1, + "Content-Type injection altered the header/body boundary:\n{wire}" + ); +} + +/// A bearer token that cannot be encoded as a header value must NOT result in a +/// request that is silently sent without an `Authorization` header (fail-open). +/// `bearer()` is the dedicated auth helper, so it must fail closed. +#[test] +fn bearer_rejects_unencodable_token_instead_of_sending_unauthenticated() { + // A control character cannot live in an HTTP header value. + let result = TransportRequest::new(Method::GET, "https://api/x").bearer("tok\r\ninjected"); + assert!( + matches!( + result, + Err(ras_transport_core::TransportError::InvalidHeader(_)) + ), + "bearer() must fail closed on an unencodable token, got: {result:?}" + ); + + // A well-formed token still works and sets the header. + let req = TransportRequest::new(Method::GET, "https://api/x") + .bearer("good-token") + .expect("valid token"); + assert_eq!( + req.headers.get("authorization").unwrap(), + "Bearer good-token" + ); +} diff --git a/crates/core/ras-transport-core/tests/transport_in_process.rs b/crates/core/ras-transport-core/tests/transport_in_process.rs index 432fd83..936829d 100644 --- a/crates/core/ras-transport-core/tests/transport_in_process.rs +++ b/crates/core/ras-transport-core/tests/transport_in_process.rs @@ -95,6 +95,7 @@ async fn post_bytes_body_and_headers_are_forwarded() { let t = transport(); let req = TransportRequest::new(http::Method::POST, "http://api.example/echo") .bearer("sekret") + .expect("valid bearer token") .body(RequestBody::Bytes(Bytes::from_static(b"payload"))); let resp = t.execute(req).await.unwrap(); assert_eq!(resp.text().await.unwrap(), "payload|auth=Bearer sekret"); diff --git a/crates/rest/ras-file-macro/src/client.rs b/crates/rest/ras-file-macro/src/client.rs index 60d3b8b..0d1d5ce 100644 --- a/crates/rest/ras-file-macro/src/client.rs +++ b/crates/rest/ras-file-macro/src/client.rs @@ -40,21 +40,31 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { fn build_request( &self, path: &str, - ) -> (String, ::ras_transport_core::http::HeaderMap) { + ) -> Result< + (String, ::ras_transport_core::http::HeaderMap), + ::ras_transport_core::TransportError, + > { let base = self.base_url.trim_end_matches('/'); let path = path.trim_start_matches('/'); let url = format!("{}/{}", base, path); let mut headers = ::ras_transport_core::http::HeaderMap::new(); if let Some(token) = self.bearer_token.read().unwrap().as_ref() { - if let Ok(value) = ::ras_transport_core::http::HeaderValue::from_str( + // Fail closed: a token that cannot be encoded must not be + // silently dropped, which would send the request + // unauthenticated. + let value = ::ras_transport_core::http::HeaderValue::from_str( &format!("Bearer {}", token), - ) { - headers.insert(::ras_transport_core::http::header::AUTHORIZATION, value); - } + ) + .map_err(|_| { + ::ras_transport_core::TransportError::InvalidHeader( + "bearer token".to_string(), + ) + })?; + headers.insert(::ras_transport_core::http::header::AUTHORIZATION, value); } - (url, headers) + Ok((url, headers)) } #(#client_methods)* @@ -140,10 +150,18 @@ fn generate_client_method( let ty = ¶m.ty; quote! { #name: #ty } }); + // Percent-encode each path parameter for its segment so a `/`, `?`, `#`, + // etc. in a caller-supplied value cannot escape its slot and alter the + // request's path or query. let path_replace = endpoint.path_params.iter().map(|param| { let name = ¶m.name; let placeholder = format!("{{{}}}", name); - quote! { .replace(#placeholder, &#name.to_string()) } + quote! { + .replace( + #placeholder, + &::ras_transport_core::encode_path_segment(&#name.to_string()), + ) + } }); match &endpoint.operation { @@ -156,7 +174,7 @@ fn generate_client_method( form: #form_builder, ) -> Result<#response_type, ::ras_transport_core::TransportError> { let path = #full_path.to_string()#(#path_replace)*; - let (url, mut headers) = self.build_request(&path); + let (url, mut headers) = self.build_request(&path)?; let (body, content_type) = form.into_body(); if let Ok(value) = ::ras_transport_core::http::HeaderValue::from_str(&content_type) { @@ -186,7 +204,7 @@ fn generate_client_method( #(#path_params,)* ) -> Result<::ras_transport_core::TransportResponse, ::ras_transport_core::TransportError> { let path = #full_path.to_string()#(#path_replace)*; - let (url, headers) = self.build_request(&path); + let (url, headers) = self.build_request(&path)?; let mut request = ::ras_transport_core::TransportRequest::new( ::ras_transport_core::http::Method::GET, diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index 6db84fb..1471a27 100644 --- a/crates/rest/ras-rest-macro/Cargo.toml +++ b/crates/rest/ras-rest-macro/Cargo.toml @@ -36,6 +36,7 @@ ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0" [dev-dependencies] tokio = { workspace = true } +bytes = { workspace = true } ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", features = ["axum-test"] } tower = { workspace = true } rand = { workspace = true } diff --git a/crates/rest/ras-rest-macro/src/client.rs b/crates/rest/ras-rest-macro/src/client.rs index 13f71b9..69b4f5a 100644 --- a/crates/rest/ras-rest-macro/src/client.rs +++ b/crates/rest/ras-rest-macro/src/client.rs @@ -306,10 +306,16 @@ fn generate_client_method_with_timeout( params.push(quote! { #param_name: #param_type }); param_names.push(param_name); - // Handle path parameter substitution + // Handle path parameter substitution. The value is percent-encoded for + // a single path segment so a `/`, `?`, `#`, etc. in a caller-supplied + // path parameter cannot escape its slot and alter the request's path or + // query. let placeholder = format!("{{{}}}", param_name); path_substitutions.push(quote! { - .replace(#placeholder, &#param_name.to_string()) + .replace( + #placeholder, + &::ras_transport_core::encode_path_segment(&#param_name.to_string()), + ) }); } @@ -431,7 +437,7 @@ fn generate_client_method_with_timeout( // Add bearer token if available if let Some(token) = &self.bearer_token { - __request = __request.bearer(token); + __request = __request.bearer(token)?; } #request_body_handling diff --git a/crates/rest/ras-rest-macro/tests/path_param_encoding.rs b/crates/rest/ras-rest-macro/tests/path_param_encoding.rs new file mode 100644 index 0000000..83ce77e --- /dev/null +++ b/crates/rest/ras-rest-macro/tests/path_param_encoding.rs @@ -0,0 +1,105 @@ +//! Security regression: path parameters substituted into the URL template must +//! be percent-encoded for their segment, so a `/`, `?`, `#`, etc. in a +//! caller-supplied value cannot break out of its slot and alter the request's +//! path or query. Drives the generated client through a capturing transport and +//! inspects the exact wire URL it produced. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use bytes::Bytes; +use ras_rest_macro::rest_service; +use ras_transport_core::{ + ByteStream, HttpTransport, TransportError, TransportRequest, TransportResponse, + byte_stream_from, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +struct Item { + id: u32, + name: String, +} + +rest_service!({ + service_name: PathDemo, + base_path: "/api", + openapi: false, + serve_docs: false, + endpoints: [ + GET UNAUTHORIZED files/{name: String}() -> Item, + ] +}); + +/// A transport that records the request URL and returns a canned 200 response. +#[derive(Clone, Default)] +struct CapturingTransport { + last_url: Arc>>, +} + +#[async_trait] +impl HttpTransport for CapturingTransport { + async fn execute( + &self, + request: TransportRequest, + ) -> Result { + *self.last_url.lock().unwrap() = Some(request.url.clone()); + + // A valid body so the client method returns Ok; the URL is what we + // assert on, but returning a parseable body keeps the test honest. + let body: ByteStream = byte_stream_from(futures::stream::once(async { + Ok::(Bytes::from_static(b"{\"id\":1,\"name\":\"x\"}")) + })); + Ok(TransportResponse::new( + ras_transport_core::http::StatusCode::OK, + ras_transport_core::http::HeaderMap::new(), + body, + )) + } +} + +fn client_capturing() -> (PathDemoClient, Arc>>) { + let transport = CapturingTransport::default(); + let captured = transport.last_url.clone(); + let client = PathDemoClientBuilder::new("http://in-memory.test") + .build_with_transport(Arc::new(transport)) + .expect("build PathDemoClient"); + (client, captured) +} + +#[tokio::test] +async fn path_param_with_slash_cannot_escape_its_segment() { + let (client, captured) = client_capturing(); + + // A value containing '/' must not introduce extra path segments. + let _ = client.get_files_by_name("a/b".to_string()).await; + let url = captured.lock().unwrap().clone().expect("url captured"); + + assert!( + url.ends_with("/api/files/a%2Fb"), + "path param '/' leaked into the URL path: {url}" + ); + assert!( + !url.contains("/api/files/a/b"), + "path param was not encoded: {url}" + ); +} + +#[tokio::test] +async fn path_param_with_query_and_fragment_chars_is_encoded() { + let (client, captured) = client_capturing(); + + // '?' would otherwise inject a query string; '#' a fragment. + let _ = client + .get_files_by_name("x?role=admin#frag".to_string()) + .await; + let url = captured.lock().unwrap().clone().expect("url captured"); + + assert!( + url.ends_with("/api/files/x%3Frole%3Dadmin%23frag"), + "query/fragment chars leaked unencoded into the URL: {url}" + ); + // The URL must carry no real query or fragment delimiter. + assert!(!url.contains('?'), "injected '?' present: {url}"); + assert!(!url.contains('#'), "injected '#' present: {url}"); +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/client.rs b/crates/rpc/ras-jsonrpc-macro/src/client.rs index 3bd9323..02e8a8d 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/client.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/client.rs @@ -118,7 +118,7 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok // Add bearer token if available if let Some(token) = &self.bearer_token { - request = request.bearer(token); + request = request.bearer(token)?; } // Apply per-call timeout, falling back to the client default. From 6c3c463089c462689d0de91931f604ff0ad71242 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 6 Jun 2026 09:59:28 +0200 Subject: [PATCH 6/6] fix(transport): consolidate generated client APIs --- CHANGELOG.md | 5 + crates/rest/ras-file-macro/Cargo.toml | 19 ++- crates/rest/ras-file-macro/src/client.rs | 156 +++++++++++------- crates/rest/ras-file-macro/tests/e2e.rs | 45 ++++- crates/rest/ras-rest-macro/Cargo.toml | 5 +- crates/rest/ras-rest-macro/src/client.rs | 47 ++++-- crates/rest/ras-rest-macro/tests/e2e.rs | 18 ++ crates/rpc/ras-jsonrpc-macro/Cargo.toml | 5 +- crates/rpc/ras-jsonrpc-macro/README.md | 12 +- crates/rpc/ras-jsonrpc-macro/src/client.rs | 41 +++-- crates/rpc/ras-jsonrpc-macro/tests/e2e.rs | 22 ++- .../tests/http_integration.rs | 5 +- .../src/generated-specs-and-clients.md | 13 +- documentation/src/macros/file-service.md | 26 +-- documentation/src/macros/jsonrpc-service.md | 17 +- documentation/src/macros/rest-service.md | 10 +- documentation/src/permission-manifests.md | 2 +- documentation/src/tutorial/build-clients.md | 11 +- .../src/tutorial/create-the-api-crate.md | 17 +- documentation/src/tutorial/index.md | 3 + examples/basic-jsonrpc/api/Cargo.toml | 2 +- examples/basic-jsonrpc/api/README.md | 2 +- examples/bidirectional-chat/api/Cargo.toml | 2 +- examples/bidirectional-chat/api/README.md | 2 +- examples/file-service-example/Cargo.toml | 2 +- .../file-service-api/Cargo.toml | 2 +- .../file-service-api/README.md | 3 +- examples/oauth2-demo/api/Cargo.toml | 2 +- examples/oauth2-demo/api/README.md | 2 +- .../rest-wasm-example/rest-api/Cargo.toml | 2 +- examples/rest-wasm-example/rest-api/README.md | 2 +- examples/wasm-ui-demo/src/lib.rs | 3 +- .../fixtures/jsonrpc-fixture/Cargo.toml | 2 +- .../fixtures/rest-fixture/Cargo.toml | 2 +- 34 files changed, 344 insertions(+), 165 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea3827e..29a752f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed - 2026-06-06 +- REST, JSON-RPC, and file generated-client APIs are now consistent: builders take the URL at construction, auth state is cloned, `build_with_transport(...)` is always available for generated clients, public timeout variants take `Duration`, and default reqwest-backed `build()` is emitted only when the macro crate's `reqwest` feature is enabled. +- Macro client features now distinguish transport-injected clients from default reqwest clients: `client` emits generated clients using `ras-transport-core`, while `reqwest` enables the default `ReqwestTransport` constructor. +- Documentation now describes the `client`/`reqwest` split, direct `ras-transport-core` dependency requirements for generated client consumers, and native file-client `fs` helpers. + ### Changed - 2026-05-24 - Specification types crate now uses the `ras-openrpc-types` package name and `ras_openrpc_types` import path. - Package metadata, clone instructions, and documentation links now point to the moved `rust-api-stack` repository. diff --git a/crates/rest/ras-file-macro/Cargo.toml b/crates/rest/ras-file-macro/Cargo.toml index b7b73c8..223cf35 100644 --- a/crates/rest/ras-file-macro/Cargo.toml +++ b/crates/rest/ras-file-macro/Cargo.toml @@ -13,12 +13,14 @@ readme = "README.md" proc-macro = true [features] -default = ["server", "client"] +default = ["server", "reqwest"] server = [] -client = ["ras-transport-core/reqwest"] -# Enables generated streaming file-upload helpers (native only). Consumers that -# turn this on must also depend on `tokio`, `tokio-util`, and `futures-util`. -fs = ["ras-transport-core/fs"] +client = ["dep:ras-transport-core"] +reqwest = ["client", "ras-transport-core/reqwest"] +# Enables generated streaming file-upload helpers (native only). Disk streaming +# is implemented by `ras-transport-core`, so generated-client consumers do not +# need direct Tokio stream dependencies for this helper. +fs = ["client", "ras-transport-core/fs"] permissions = [] [dependencies] @@ -27,10 +29,9 @@ quote = { workspace = true } proc-macro2 = { workspace = true } schemars = { workspace = true } serde_json = { workspace = true } -# Proc-macro crates can't re-export runtime deps, but the `client` feature must -# activate `ras-transport-core/reqwest` so the default `ReqwestTransport` is -# available to consumers. Consumers must depend on `ras-transport-core` too. -ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", default-features = false } +# Proc-macro crates can't re-export runtime deps. Consumers of generated client +# code must depend on `ras-transport-core` directly too. +ras-transport-core = { path = "../../core/ras-transport-core", version = "0.1.0", default-features = false, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/crates/rest/ras-file-macro/src/client.rs b/crates/rest/ras-file-macro/src/client.rs index 0d1d5ce..d4d6ff5 100644 --- a/crates/rest/ras-file-macro/src/client.rs +++ b/crates/rest/ras-file-macro/src/client.rs @@ -17,14 +17,27 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { .endpoints .iter() .map(|endpoint| generate_client_method(definition, endpoint, &base_path)); + let build_method = if cfg!(feature = "reqwest") { + quote! { + pub fn build( + self, + ) -> Result<#client_name, Box> { + let transport = ::std::sync::Arc::new( + ::ras_transport_core::ReqwestTransport::new(), + ); + self.build_with_transport(transport) + } + } + } else { + quote! {} + }; quote! { #[derive(Clone)] pub struct #client_name { transport: ::std::sync::Arc, base_url: String, - bearer_token: ::std::sync::Arc<::std::sync::RwLock>>, - #[cfg(not(target_arch = "wasm32"))] + bearer_token: Option, default_timeout: Option<::std::time::Duration>, } @@ -33,8 +46,12 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { #builder_name::new(base_url) } - pub fn set_bearer_token(&self, token: Option>) { - *self.bearer_token.write().unwrap() = token.map(|token| token.into()); + pub fn set_bearer_token(&mut self, token: Option>) { + self.bearer_token = token.map(|token| token.into()); + } + + pub fn bearer_token(&self) -> Option<&str> { + self.bearer_token.as_deref() } fn build_request( @@ -49,7 +66,7 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { let url = format!("{}/{}", base, path); let mut headers = ::ras_transport_core::http::HeaderMap::new(); - if let Some(token) = self.bearer_token.read().unwrap().as_ref() { + if let Some(token) = self.bearer_token.as_ref() { // Fail closed: a token that cannot be encoded must not be // silently dropped, which would send the request // unauthenticated. @@ -72,8 +89,6 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { pub struct #builder_name { base_url: String, - transport: Option<::std::sync::Arc>, - #[cfg(not(target_arch = "wasm32"))] timeout: Option<::std::time::Duration>, } @@ -81,55 +96,27 @@ pub fn generate_client(definition: &FileServiceDefinition) -> TokenStream { pub fn new(base_url: impl Into) -> Self { Self { base_url: base_url.into(), - transport: None, - #[cfg(not(target_arch = "wasm32"))] timeout: None, } } - pub fn with_transport( - mut self, - transport: ::std::sync::Arc, - ) -> Self { - self.transport = Some(transport); - self - } - - #[cfg(not(target_arch = "wasm32"))] pub fn with_timeout(mut self, timeout: ::std::time::Duration) -> Self { self.timeout = Some(timeout); self } - pub fn build(self) -> #client_name { - let transport: ::std::sync::Arc = - match self.transport { - Some(transport) => transport, - None => ::std::sync::Arc::new( - ::ras_transport_core::ReqwestTransport::new(), - ), - }; - - #client_name { - transport, - base_url: self.base_url, - bearer_token: ::std::sync::Arc::new(::std::sync::RwLock::new(None)), - #[cfg(not(target_arch = "wasm32"))] - default_timeout: self.timeout, - } - } + #build_method pub fn build_with_transport( self, transport: ::std::sync::Arc, - ) -> #client_name { - #client_name { + ) -> Result<#client_name, Box> { + Ok(#client_name { transport, base_url: self.base_url, - bearer_token: ::std::sync::Arc::new(::std::sync::RwLock::new(None)), - #[cfg(not(target_arch = "wasm32"))] + bearer_token: None, default_timeout: self.timeout, - } + }) } } @@ -143,26 +130,45 @@ fn generate_client_method( base_path: &str, ) -> TokenStream { let method_name = &endpoint.name; + let method_name_with_timeout = format_ident!("{}_with_timeout", method_name); + let method_name_with_optional_timeout = + format_ident!("__ras_{}_with_optional_timeout", method_name); let path = endpoint.path.value(); let full_path = format!("{}{}", base_path.trim_end_matches('/'), path); - let path_params = endpoint.path_params.iter().map(|param| { - let name = ¶m.name; - let ty = ¶m.ty; - quote! { #name: #ty } - }); + let path_params: Vec<_> = endpoint + .path_params + .iter() + .map(|param| { + let name = ¶m.name; + let ty = ¶m.ty; + quote! { #name: #ty } + }) + .collect(); + let path_args: Vec<_> = endpoint + .path_params + .iter() + .map(|param| { + let name = ¶m.name; + quote! { #name } + }) + .collect(); // Percent-encode each path parameter for its segment so a `/`, `?`, `#`, // etc. in a caller-supplied value cannot escape its slot and alter the // request's path or query. - let path_replace = endpoint.path_params.iter().map(|param| { - let name = ¶m.name; - let placeholder = format!("{{{}}}", name); - quote! { - .replace( - #placeholder, - &::ras_transport_core::encode_path_segment(&#name.to_string()), - ) - } - }); + let path_replace: Vec<_> = endpoint + .path_params + .iter() + .map(|param| { + let name = ¶m.name; + let placeholder = format!("{{{}}}", name); + quote! { + .replace( + #placeholder, + &::ras_transport_core::encode_path_segment(&#name.to_string()), + ) + } + }) + .collect(); match &endpoint.operation { Operation::Upload { response_type, .. } => { @@ -172,6 +178,24 @@ fn generate_client_method( &self, #(#path_params,)* form: #form_builder, + ) -> Result<#response_type, ::ras_transport_core::TransportError> { + self.#method_name_with_optional_timeout(#(#path_args,)* form, None).await + } + + pub async fn #method_name_with_timeout( + &self, + #(#path_params,)* + form: #form_builder, + timeout: ::std::time::Duration, + ) -> Result<#response_type, ::ras_transport_core::TransportError> { + self.#method_name_with_optional_timeout(#(#path_args,)* form, Some(timeout)).await + } + + async fn #method_name_with_optional_timeout( + &self, + #(#path_params,)* + form: #form_builder, + timeout: Option<::std::time::Duration>, ) -> Result<#response_type, ::ras_transport_core::TransportError> { let path = #full_path.to_string()#(#path_replace)*; let (url, mut headers) = self.build_request(&path)?; @@ -188,8 +212,7 @@ fn generate_client_method( .body(body); request.headers = headers; - #[cfg(not(target_arch = "wasm32"))] - if let Some(timeout) = self.default_timeout { + if let Some(timeout) = timeout.or(self.default_timeout) { request = request.timeout(timeout); } @@ -202,6 +225,22 @@ fn generate_client_method( pub async fn #method_name( &self, #(#path_params,)* + ) -> Result<::ras_transport_core::TransportResponse, ::ras_transport_core::TransportError> { + self.#method_name_with_optional_timeout(#(#path_args,)* None).await + } + + pub async fn #method_name_with_timeout( + &self, + #(#path_params,)* + timeout: ::std::time::Duration, + ) -> Result<::ras_transport_core::TransportResponse, ::ras_transport_core::TransportError> { + self.#method_name_with_optional_timeout(#(#path_args,)* Some(timeout)).await + } + + async fn #method_name_with_optional_timeout( + &self, + #(#path_params,)* + timeout: Option<::std::time::Duration>, ) -> Result<::ras_transport_core::TransportResponse, ::ras_transport_core::TransportError> { let path = #full_path.to_string()#(#path_replace)*; let (url, headers) = self.build_request(&path)?; @@ -212,8 +251,7 @@ fn generate_client_method( ); request.headers = headers; - #[cfg(not(target_arch = "wasm32"))] - if let Some(timeout) = self.default_timeout { + if let Some(timeout) = timeout.or(self.default_timeout) { request = request.timeout(timeout); } diff --git a/crates/rest/ras-file-macro/tests/e2e.rs b/crates/rest/ras-file-macro/tests/e2e.rs index 9531648..a7570d6 100644 --- a/crates/rest/ras-file-macro/tests/e2e.rs +++ b/crates/rest/ras-file-macro/tests/e2e.rs @@ -221,7 +221,9 @@ fn demo_server_arc(service: DemoImpl) -> Arc { } fn demo_client(server: Arc) -> DemoClient { - DemoClient::builder("http://test.local").build_with_transport(axum_transport(server)) + DemoClient::builder("http://test.local") + .build_with_transport(axum_transport(server)) + .expect("build DemoClient over AxumTestTransport") } #[tokio::test] @@ -229,7 +231,7 @@ async fn upload_and_download_round_trips_declared_multipart_fields() { let service = DemoImpl::new(); let storage = service.storage.clone(); let server = demo_server_arc(service); - let client = demo_client(server); + let mut client = demo_client(server); client.set_bearer_token(Some("user-token")); let payload = b"streamed file".to_vec(); @@ -280,7 +282,7 @@ async fn upload_streams_file_part_from_disk_round_trips() { let service = DemoImpl::new(); let storage = service.storage.clone(); let server = demo_server_arc(service); - let client = demo_client(server); + let mut client = demo_client(server); client.set_bearer_token(Some("user-token")); // Write a temp file that the generated streaming `file(path, ...)` method @@ -323,6 +325,43 @@ async fn upload_streams_file_part_from_disk_round_trips() { drop(temp); } +#[tokio::test] +async fn generated_file_client_timeout_variants_round_trip() { + let service = DemoImpl::new(); + let storage = service.storage.clone(); + let server = demo_server_arc(service); + let mut client = demo_client(server); + client.set_bearer_token(Some("user-token")); + + let payload = b"timeout upload".to_vec(); + let metadata = UploadMetadata { + title: "timeout".to_string(), + }; + let form = DemoUploadMultipart::new() + .file_bytes( + payload.clone(), + "timeout.bin", + Some("application/octet-stream"), + ) + .expect("file part") + .metadata(&metadata) + .expect("json part"); + + let uploaded = client + .upload_with_timeout(form, std::time::Duration::from_secs(1)) + .await + .expect("upload_with_timeout succeeds"); + assert_eq!(uploaded.size, payload.len() as u64); + assert_eq!(storage.lock().unwrap().len(), 1); + + let response = client + .download_by_file_id_with_timeout(uploaded.file_id, std::time::Duration::from_secs(1)) + .await + .expect("download_by_file_id_with_timeout succeeds"); + let downloaded = response.bytes().await.expect("download body"); + assert_eq!(downloaded.as_ref(), payload.as_slice()); +} + #[tokio::test] async fn download_returns_not_found_for_missing_file() { let server = demo_server(DemoImpl::new()); diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index 1471a27..8d6ab4e 100644 --- a/crates/rest/ras-rest-macro/Cargo.toml +++ b/crates/rest/ras-rest-macro/Cargo.toml @@ -13,9 +13,10 @@ readme = "README.md" proc-macro = true [features] -default = ["server", "client"] # Enable server by default for backward compatibility +default = ["server", "reqwest"] server = ["axum", "ras-auth-core", "ras-rest-core", "async-trait"] -client = ["ras-transport-core/reqwest"] +client = ["dep:ras-transport-core"] +reqwest = ["client", "ras-transport-core/reqwest"] permissions = [] [dependencies] diff --git a/crates/rest/ras-rest-macro/src/client.rs b/crates/rest/ras-rest-macro/src/client.rs index 69b4f5a..5fa9803 100644 --- a/crates/rest/ras-rest-macro/src/client.rs +++ b/crates/rest/ras-rest-macro/src/client.rs @@ -57,6 +57,22 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok .iter() .flat_map(generate_client_methods_with_timeout_for_endpoint); + let build_method = if cfg!(feature = "reqwest") { + quote! { + /// Build the client using the default `ReqwestTransport`. + /// + /// # Errors + /// + /// Returns an error if the underlying transport fails to construct. + pub fn build(self) -> Result<#client_name, Box> { + let transport = std::sync::Arc::new(::ras_transport_core::ReqwestTransport::new()); + self.build_with_transport(transport) + } + } + } else { + quote! {} + }; + let output = quote! { /// Helper function to join URL segments properly fn join_url_segments(base: &str, path: &str) -> String { @@ -100,15 +116,7 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok self } - /// Build the client using the default `ReqwestTransport`. - /// - /// # Errors - /// - /// Returns an error if the underlying transport fails to construct. - pub fn build(self) -> Result<#client_name, Box> { - let transport = std::sync::Arc::new(::ras_transport_core::ReqwestTransport::new()); - self.build_with_transport(transport) - } + #build_method /// Build the client over an explicit transport (e.g. an in-process /// test transport). This is the injection point used by tests. @@ -261,12 +269,13 @@ fn generate_client_method( call_args.push(quote! { body }); } - let method_name_with_timeout = quote::format_ident!("{}_with_timeout", method_name); + let method_name_with_optional_timeout = + quote::format_ident!("__ras_{}_with_optional_timeout", method_name); quote! { /// Call the #method_name endpoint pub async fn #method_name(&self, #(#params),*) -> Result<#response_type, ::ras_transport_core::TransportError> { - self.#method_name_with_timeout(#(#call_args,)* None).await + self.#method_name_with_optional_timeout(#(#call_args,)* None).await } } } @@ -282,6 +291,8 @@ fn generate_client_method_with_timeout( response_type: &Type, ) -> proc_macro2::TokenStream { let method_name_with_timeout = quote::format_ident!("{}_with_timeout", method_name); + let method_name_with_optional_timeout = + quote::format_ident!("__ras_{}_with_optional_timeout", method_name); let http_method = match method { HttpMethod::Get => quote! { ::ras_transport_core::http::Method::GET }, HttpMethod::Post => quote! { ::ras_transport_core::http::Method::POST }, @@ -293,7 +304,7 @@ fn generate_client_method_with_timeout( // Build function parameters let mut params = Vec::new(); let mut path_substitutions = Vec::new(); - let mut param_names = Vec::new(); + let mut call_args = Vec::new(); // Build URL construction with proper joining let mut url_construction = quote! { join_url_segments(&join_url_segments(&self.server_url, &self.base_path), #path) @@ -304,7 +315,7 @@ fn generate_client_method_with_timeout( let param_name = &path_param.name; let param_type = &path_param.param_type; params.push(quote! { #param_name: #param_type }); - param_names.push(param_name); + call_args.push(quote! { #param_name }); // Handle path parameter substitution. The value is percent-encoded for // a single path segment so a `/`, `?`, `#`, etc. in a caller-supplied @@ -390,12 +401,14 @@ fn generate_client_method_with_timeout( let param_name = &query_param.name; let param_type = &query_param.param_type; params.push(quote! { #param_name: #param_type }); + call_args.push(quote! { #param_name }); } // Add request body parameter if present. The body is serialized to JSON // (which also sets `Content-Type: application/json`). let request_body_handling = if let Some(request_type) = request_type { params.push(quote! { body: #request_type }); + call_args.push(quote! { body }); quote! { __request = __request.json(&body)?; } @@ -425,6 +438,14 @@ fn generate_client_method_with_timeout( quote! { /// Call the #method_name endpoint with a custom timeout pub async fn #method_name_with_timeout( + &self, + #(#params,)* + timeout: std::time::Duration + ) -> Result<#response_type, ::ras_transport_core::TransportError> { + self.#method_name_with_optional_timeout(#(#call_args,)* Some(timeout)).await + } + + async fn #method_name_with_optional_timeout( &self, #(#params,)* timeout: Option diff --git a/crates/rest/ras-rest-macro/tests/e2e.rs b/crates/rest/ras-rest-macro/tests/e2e.rs index ccd85eb..fa45107 100644 --- a/crates/rest/ras-rest-macro/tests/e2e.rs +++ b/crates/rest/ras-rest-macro/tests/e2e.rs @@ -400,6 +400,24 @@ async fn query_params_required_and_optional_serialize_correctly() { assert_eq!(resp.items[0].name, "fuzzy:zz-0"); } +#[tokio::test] +async fn generated_client_timeout_variant_accepts_duration() { + let client = client(); + + let resp = client + .get_search_with_timeout( + "timeout".to_string(), + Some(1), + false, + std::time::Duration::from_secs(1), + ) + .await + .expect("get_search_with_timeout failed"); + + assert_eq!(resp.items.len(), 1); + assert_eq!(resp.items[0].name, "fuzzy:timeout-0"); +} + #[tokio::test] async fn vec_query_params_serialize_as_repeated_keys() { // `Vec` and `Option>` query params must serialize as repeated diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index 8f0d18e..afe46c5 100644 --- a/crates/rpc/ras-jsonrpc-macro/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-macro/Cargo.toml @@ -13,9 +13,10 @@ readme = "README.md" proc-macro = true [features] -default = ["server", "client"] # Enable server by default for backward compatibility +default = ["server", "reqwest"] server = ["axum", "ras-jsonrpc-core"] -client = ["ras-transport-core/reqwest"] +client = ["dep:ras-transport-core"] +reqwest = ["client", "ras-transport-core/reqwest"] permissions = [] [dependencies] diff --git a/crates/rpc/ras-jsonrpc-macro/README.md b/crates/rpc/ras-jsonrpc-macro/README.md index c2550f3..3c58a3b 100644 --- a/crates/rpc/ras-jsonrpc-macro/README.md +++ b/crates/rpc/ras-jsonrpc-macro/README.md @@ -34,28 +34,30 @@ ras-jsonrpc-types = "0.1.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" schemars = "1.0.0-alpha.20" +ras-transport-core = { version = "0.1.0", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] axum = { version = "0.8", optional = true } tokio = { version = "1.0", features = ["full"], optional = true } -reqwest = { version = "0.12", features = ["json"], optional = true } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json"], optional = true } [features] default = [] server = [ + "ras-jsonrpc-macro/server", "dep:ras-jsonrpc-core", "dep:axum", "dep:tokio", ] -client = ["dep:reqwest"] +client = ["ras-jsonrpc-macro/reqwest", "ras-transport-core/reqwest"] ``` Define `server` and `client` on the API crate that invokes the macro. Downstream server and client crates enable the API crate feature they need. +The macro crate's `client` feature emits generated clients with +`build_with_transport(...)`; its `reqwest` feature also emits the default +reqwest-backed `build()`. + ## Quick Start ### 1. Define Your Service diff --git a/crates/rpc/ras-jsonrpc-macro/src/client.rs b/crates/rpc/ras-jsonrpc-macro/src/client.rs index 02e8a8d..623be52 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/client.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/client.rs @@ -18,6 +18,18 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok .iter() .flat_map(generate_client_methods_with_timeout_for_method); + let build_method = if cfg!(feature = "reqwest") { + quote! { + /// Build the client using the default `ReqwestTransport`. + pub fn build(self) -> Result<#client_name, Box> { + let transport = std::sync::Arc::new(::ras_transport_core::ReqwestTransport::new()); + self.build_with_transport(transport) + } + } + } else { + quote! {} + }; + let output = quote! { /// Generated client for the JSON-RPC service #[derive(Clone)] @@ -30,36 +42,26 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok /// Builder for the JSON-RPC client pub struct #client_builder_name { - server_url: Option, + server_url: String, timeout: Option, } impl #client_builder_name { - /// Create a new client builder - pub fn new() -> Self { + /// Create a new client builder with the required server URL + pub fn new(server_url: impl Into) -> Self { Self { - server_url: None, + server_url: server_url.into(), timeout: None, } } - /// Set the server URL - pub fn server_url(mut self, url: impl Into) -> Self { - self.server_url = Some(url.into()); - self - } - /// Set the default timeout for requests pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self { self.timeout = Some(timeout); self } - /// Build the client using the default `ReqwestTransport`. - pub fn build(self) -> Result<#client_name, Box> { - let transport = std::sync::Arc::new(::ras_transport_core::ReqwestTransport::new()); - self.build_with_transport(transport) - } + #build_method /// Build the client over an explicit transport (e.g. an in-process /// test transport). This is the injection point used by tests. @@ -67,11 +69,9 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok self, transport: std::sync::Arc, ) -> Result<#client_name, Box> { - let server_url = self.server_url.ok_or("Server URL is required")?; - Ok(#client_name { transport, - server_url, + server_url: self.server_url, bearer_token: None, default_timeout: self.timeout, }) @@ -84,6 +84,11 @@ pub fn generate_client_code(service_def: &ServiceDefinition) -> proc_macro2::Tok self.bearer_token = token.map(|t| t.into()); } + /// Create a new client builder + pub fn builder(server_url: impl Into) -> #client_builder_name { + #client_builder_name::new(server_url) + } + /// Get a reference to the bearer token pub fn bearer_token(&self) -> Option<&str> { self.bearer_token.as_deref() diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs index e699b23..1bf5f05 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs @@ -173,10 +173,9 @@ fn versioned_client_method_names_sanitize_semver_labels() { fn demo_client() -> DemoClient { let server = mock_http_server_arc(router()); let transport = axum_transport(server); - DemoClientBuilder::new() + DemoClientBuilder::new("http://in-memory.test/rpc") // The AxumTestTransport strips scheme+authority, so the host is // irrelevant; only the path "/rpc" matters. - .server_url("http://in-memory.test/rpc") .build_with_transport(transport) .expect("build DemoClient over AxumTestTransport") } @@ -197,6 +196,25 @@ async fn generated_client_round_trips_over_axum_transport() { assert_eq!(resp.user_id, None); } +#[cfg(feature = "client")] +#[tokio::test] +async fn generated_client_timeout_variant_accepts_duration() { + let client = demo_client(); + + let resp = client + .ping_with_timeout( + EchoRequest { + msg: "timeout-client".to_string(), + }, + std::time::Duration::from_secs(1), + ) + .await + .expect("ping_with_timeout over transport should succeed"); + + assert_eq!(resp.msg, "timeout-client"); + assert_eq!(resp.user_id, None); +} + #[cfg(feature = "client")] #[tokio::test] async fn generated_client_sends_bearer_and_succeeds_with_permission() { diff --git a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs index 8139573..3872439 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/http_integration.rs @@ -710,12 +710,11 @@ async fn test_openrpc_generation() { assert!(permissions.contains(&json!("moderator"))); } -#[cfg(feature = "client")] +#[cfg(feature = "reqwest")] #[test] fn test_client_generation() { // Test that client generation compiles and produces valid API - let client_result = TestServiceClientBuilder::new() - .server_url("http://example.invalid/rpc") + let client_result = TestServiceClientBuilder::new("http://example.invalid/rpc") .with_timeout(std::time::Duration::from_millis(1000)) .build(); diff --git a/documentation/src/generated-specs-and-clients.md b/documentation/src/generated-specs-and-clients.md index 559ccfd..699a317 100644 --- a/documentation/src/generated-specs-and-clients.md +++ b/documentation/src/generated-specs-and-clients.md @@ -35,11 +35,16 @@ upload limits, part policies, content types, and range support. ## Rust Clients -Enabling a service macro crate's `client` feature emits typed Rust clients. +Enabling a service macro crate's `client` feature emits typed Rust clients that +can be constructed with `build_with_transport(...)`. Enabling the macro crate's +`reqwest` feature also emits the default reqwest-backed `build()`. + The examples keep API definitions in separate API crates and expose API-crate -`client` features that forward to the macro crates, so server and browser -crates can depend on the same contract while selecting different generated -surfaces. +`client` features that forward to the macro crate's `reqwest` feature, so +server and browser crates can depend on the same contract while selecting +different generated surfaces. Test-only or in-process clients can instead +forward only the macro crate's `client` feature and depend directly on +`ras-transport-core`. For browser targets, compile client crates with `--target wasm32-unknown-unknown` and enable only the API crate's client-side feature set. See: diff --git a/documentation/src/macros/file-service.md b/documentation/src/macros/file-service.md index c8eecd3..21c683d 100644 --- a/documentation/src/macros/file-service.md +++ b/documentation/src/macros/file-service.md @@ -18,18 +18,14 @@ ras-file-core = { version = "0.1.0", optional = true } ras-auth-core = { version = "0.1.0", optional = true } serde = { version = "1.0", features = ["derive"] } async-trait = { version = "0.1", optional = true } +ras-transport-core = { version = "0.1.0", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] axum = { version = "0.8", optional = true } -reqwest = { version = "0.12", optional = true } tokio = { version = "1.0", optional = true } -tokio-util = { version = "0.7", optional = true } schemars = { version = "1.0.0-alpha.20", optional = true } serde_json = { version = "1.0", optional = true } -[target.'cfg(target_arch = "wasm32")'.dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart"], optional = true } - [features] default = [] server = [ @@ -41,13 +37,23 @@ server = [ "dep:schemars", "dep:serde_json", ] -client = ["ras-file-macro/client", "dep:reqwest", "dep:tokio", "dep:tokio-util"] +client = ["ras-file-macro/reqwest", "ras-transport-core/reqwest"] +fs = ["ras-file-macro/fs", "ras-transport-core/fs"] ``` Server crates depend on the API crate with `features = ["server"]`. Native and browser clients depend on the same API crate with `features = ["client"]`. -Those API-crate features forward to `ras-file-macro/server` and -`ras-file-macro/client`; the macro emits only the selected generated surfaces. +Those API-crate features forward to the relevant macro crate features; the +macro emits only the selected generated surfaces. + +Enable `fs` as well for native generated-client helpers that stream file parts +from disk. + +The macro crate's `client` feature emits the generated client types and +`build_with_transport(...)`. Its `reqwest` feature also emits the default +reqwest-backed `build()`. If a crate only injects a custom transport, forward +`ras-file-macro/client` plus `dep:ras-transport-core` instead of +`ras-file-macro/reqwest`. ## Define The Service @@ -202,11 +208,11 @@ Enable it through the API crate dependency: ```toml [dependencies] -document-api = { path = "../file-service-api", default-features = false, features = ["client"] } +document-api = { path = "../file-service-api", default-features = false, features = ["client", "fs"] } ``` ```rust,ignore -let client = DocumentServiceClient::builder("http://localhost:3000") +let mut client = DocumentServiceClient::builder("http://localhost:3000") .with_timeout(std::time::Duration::from_secs(30)) .build()?; diff --git a/documentation/src/macros/jsonrpc-service.md b/documentation/src/macros/jsonrpc-service.md index 9a6fd26..d5b187a 100644 --- a/documentation/src/macros/jsonrpc-service.md +++ b/documentation/src/macros/jsonrpc-service.md @@ -18,17 +18,17 @@ ras-jsonrpc-types = "0.1.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" schemars = "1.0.0-alpha.20" +ras-transport-core = { version = "0.1.0", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ras-jsonrpc-core = { version = "0.1.2", optional = true } axum = { version = "0.8", optional = true } tokio = { version = "1.0", features = ["full"], optional = true } -reqwest = { version = "0.12", features = ["json"], optional = true } [features] default = [] server = ["ras-jsonrpc-macro/server", "dep:ras-jsonrpc-core", "dep:axum", "dep:tokio"] -client = ["ras-jsonrpc-macro/client", "dep:reqwest"] +client = ["ras-jsonrpc-macro/reqwest", "ras-transport-core/reqwest"] ``` Server binaries then depend on `my-api` with `features = ["server"]`; clients @@ -36,6 +36,12 @@ depend on the same API crate with `features = ["client"]`. The generated code itself is selected by the `ras-jsonrpc-macro` features, not by generated consumer-crate cfg attributes. +The macro crate's `client` feature emits the generated client types and +`build_with_transport(...)`. Its `reqwest` feature also emits the default +reqwest-backed `build()`. If a crate only injects a custom transport, forward +`ras-jsonrpc-macro/client` plus `dep:ras-transport-core` instead of +`ras-jsonrpc-macro/reqwest`. + ## Define The Service ```rust,ignore @@ -130,8 +136,7 @@ The generated client calls methods by their Rust names and sends the correct JSON-RPC wire method internally. ```rust,ignore -let mut client = UserServiceClientBuilder::new() - .server_url("http://localhost:3000/rpc") +let mut client = UserServiceClientBuilder::new("http://localhost:3000/rpc") .with_timeout(std::time::Duration::from_secs(10)) .build()?; @@ -155,9 +160,7 @@ For browser/WASM clients, use the same generated client with a browser URL and set the bearer token on a cloned client before protected calls: ```rust,ignore -let client = UserServiceClientBuilder::new() - .server_url("/rpc") - .build()?; +let client = UserServiceClientBuilder::new("/rpc").build()?; let mut authed = client.clone(); authed.set_bearer_token(Some(token)); diff --git a/documentation/src/macros/rest-service.md b/documentation/src/macros/rest-service.md index 2df3495..ba3bf50 100644 --- a/documentation/src/macros/rest-service.md +++ b/documentation/src/macros/rest-service.md @@ -13,6 +13,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" schemars = "1.0.0-alpha.20" async-trait = { version = "0.1", optional = true } +ras-transport-core = { version = "0.1.0", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ras-rest-core = { version = "0.1.1", optional = true } @@ -20,7 +21,6 @@ ras-auth-core = { version = "0.1.0", optional = true } axum = { version = "0.8", optional = true } axum-extra = { version = "0.10", features = ["query"], optional = true } tokio = { version = "1.0", features = ["full"], optional = true } -reqwest = { version = "0.12", features = ["json"], optional = true } [features] default = [] @@ -33,7 +33,7 @@ server = [ "dep:axum-extra", "dep:tokio", ] -client = ["ras-rest-macro/client", "dep:reqwest"] +client = ["ras-rest-macro/reqwest", "ras-transport-core/reqwest"] ``` These API-crate features are forwarding gates. They enable the relevant macro @@ -47,6 +47,12 @@ or WASM crate depends on the same crate with `features = ["client"]`. If one crate should always expose both surfaces, enable `server` and `client` directly on the `ras-rest-macro` dependency and make the runtime dependencies non-optional. +The macro crate's `client` feature emits the generated client types and +`build_with_transport(...)`. Its `reqwest` feature also emits the default +reqwest-backed `build()`. If a crate only injects a custom transport, forward +`ras-rest-macro/client` plus `dep:ras-transport-core` instead of +`ras-rest-macro/reqwest`. + ## Define The Service ```rust,ignore diff --git a/documentation/src/permission-manifests.md b/documentation/src/permission-manifests.md index a255ac5..9db49e4 100644 --- a/documentation/src/permission-manifests.md +++ b/documentation/src/permission-manifests.md @@ -33,7 +33,7 @@ forward to the macro crate: ```toml [features] server = ["ras-rest-macro/server", "dep:axum", "dep:ras-rest-core"] -client = ["ras-rest-macro/client", "dep:reqwest"] +client = ["ras-rest-macro/reqwest", "ras-transport-core/reqwest"] ``` Server build scripts then depend on the API crate feature that makes the diff --git a/documentation/src/tutorial/build-clients.md b/documentation/src/tutorial/build-clients.md index 5a69224..03e1ed9 100644 --- a/documentation/src/tutorial/build-clients.md +++ b/documentation/src/tutorial/build-clients.md @@ -1,6 +1,8 @@ # 4. Build Clients Client crates depend on the same API crate with `features = ["client"]`. +Enable the API crate's `fs` feature too when using generated file-upload helpers +that read parts from disk. ```toml [dependencies] @@ -69,6 +71,14 @@ let bytes = response.bytes().await?; For tests or browser-like buffered content, generated multipart builders also provide `*_bytes` helpers where file parts are declared. +If you use the disk-streaming `file(...)` helper above, depend on the API crate +with both `client` and `fs` enabled: + +```toml +[dependencies] +workspace-api = { path = "../workspace-api", default-features = false, features = ["client", "fs"] } +``` + ## TypeScript Clients From OpenAPI If your browser app is TypeScript, generate a fetch client from the OpenAPI @@ -132,4 +142,3 @@ activity.subscribe_project("project-123".to_string()).await?; Use generated clients directly at application edges, then wrap them in small domain-specific adapters if the UI needs a simpler interface. - diff --git a/documentation/src/tutorial/create-the-api-crate.md b/documentation/src/tutorial/create-the-api-crate.md index afb1269..c617728 100644 --- a/documentation/src/tutorial/create-the-api-crate.md +++ b/documentation/src/tutorial/create-the-api-crate.md @@ -21,6 +21,7 @@ serde = { version = "1.0", features = ["derive"] } schemars = { version = "1.0.0-alpha.20", optional = true } serde_json = { version = "1.0", optional = true } async-trait = { version = "0.1", optional = true } +ras-transport-core = { version = "0.1.0", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ras-auth-core = { version = "0.1.0", optional = true } @@ -30,11 +31,6 @@ ras-jsonrpc-bidirectional-server = { version = "0.1.0", optional = true } axum = { version = "0.8", optional = true } axum-extra = { version = "0.10", optional = true } tokio = { version = "1.0", optional = true } -tokio-util = { version = "0.7", optional = true } -reqwest = { version = "0.12", optional = true } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart"], optional = true } [features] default = [] @@ -54,13 +50,13 @@ server = [ "dep:tokio", ] client = [ - "ras-rest-macro/client", - "ras-file-macro/client", + "ras-rest-macro/reqwest", + "ras-file-macro/reqwest", "ras-jsonrpc-bidirectional-macro/client", - "dep:reqwest", + "ras-transport-core/reqwest", "dep:tokio", - "dep:tokio-util", ] +fs = ["ras-file-macro/fs", "ras-transport-core/fs"] ``` Server crates enable `workspace-api/server`. Rust or WASM clients enable @@ -68,6 +64,9 @@ Server crates enable `workspace-api/server`. Rust or WASM clients enable code is emitted; the API-crate features are just a convenient way to select those macro features from downstream crates. +Enable `workspace-api/fs` for native file-client helpers that stream upload +parts from disk. + ## Source Layout Split by service boundary: diff --git a/documentation/src/tutorial/index.md b/documentation/src/tutorial/index.md index 23bd93b..80e8d11 100644 --- a/documentation/src/tutorial/index.md +++ b/documentation/src/tutorial/index.md @@ -46,6 +46,9 @@ workspace-api = { path = "../workspace-api", default-features = false, features This keeps generated transport code out of crates that do not need it, while keeping request and response types shared. The proc macro features decide what code is emitted; the API-crate features are only the downstream selection point. +For HTTP service macros, the macro crate's `client` feature emits +transport-injected clients; the macro crate's `reqwest` feature additionally +emits the default `build()` constructor. ## What You Will Build diff --git a/examples/basic-jsonrpc/api/Cargo.toml b/examples/basic-jsonrpc/api/Cargo.toml index d654640..4023369 100644 --- a/examples/basic-jsonrpc/api/Cargo.toml +++ b/examples/basic-jsonrpc/api/Cargo.toml @@ -13,7 +13,7 @@ readme = "README.md" [features] default = [] server = ["ras-jsonrpc-macro/server", "dep:axum", "dep:ras-jsonrpc-core"] -client = ["ras-jsonrpc-macro/client", "ras-transport-core/reqwest"] +client = ["ras-jsonrpc-macro/reqwest", "ras-transport-core/reqwest"] [dependencies] ras-jsonrpc-macro = { path = "../../../crates/rpc/ras-jsonrpc-macro", version = "0.2.0", default-features = false, features = ["permissions"] } diff --git a/examples/basic-jsonrpc/api/README.md b/examples/basic-jsonrpc/api/README.md index d4e3562..fb18a41 100644 --- a/examples/basic-jsonrpc/api/README.md +++ b/examples/basic-jsonrpc/api/README.md @@ -16,7 +16,7 @@ The service route is selected by the server crate when it builds the generated r ## Features - `server` - enables generated server-side types and Axum integration. -- `client` - enables the generated HTTP client. +- `client` - enables the generated HTTP client with the default reqwest transport. - default: no generated transport code. ## Checks diff --git a/examples/bidirectional-chat/api/Cargo.toml b/examples/bidirectional-chat/api/Cargo.toml index efed114..bfbcc0a 100644 --- a/examples/bidirectional-chat/api/Cargo.toml +++ b/examples/bidirectional-chat/api/Cargo.toml @@ -20,7 +20,7 @@ server = [ ] client = [ "ras-jsonrpc-bidirectional-macro/client", - "ras-rest-macro/client", + "ras-rest-macro/reqwest", "ras-transport-core/reqwest", "dep:ras-jsonrpc-bidirectional-client", ] diff --git a/examples/bidirectional-chat/api/README.md b/examples/bidirectional-chat/api/README.md index e3be055..e0cfccc 100644 --- a/examples/bidirectional-chat/api/README.md +++ b/examples/bidirectional-chat/api/README.md @@ -23,7 +23,7 @@ The runnable server is documented in [../server/README.md](../server/README.md), ## Features - `server` - enables generated server integration and Axum support. -- `client` - enables the generated bidirectional client. +- `client` - enables the generated bidirectional client and REST auth client with the default reqwest transport. - default: no generated transport code. ## Checks diff --git a/examples/file-service-example/Cargo.toml b/examples/file-service-example/Cargo.toml index 4cc38a5..ebe0534 100644 --- a/examples/file-service-example/Cargo.toml +++ b/examples/file-service-example/Cargo.toml @@ -13,7 +13,7 @@ readme = "README.md" [features] default = ["server"] server = ["ras-file-macro/server"] -client = ["ras-file-macro/client", "ras-transport-core/reqwest"] +client = ["ras-file-macro/reqwest", "ras-transport-core/reqwest"] fs = ["ras-file-macro/fs", "ras-transport-core/fs"] [dependencies] diff --git a/examples/file-service-wasm/file-service-api/Cargo.toml b/examples/file-service-wasm/file-service-api/Cargo.toml index 46253e5..36d0387 100644 --- a/examples/file-service-wasm/file-service-api/Cargo.toml +++ b/examples/file-service-wasm/file-service-api/Cargo.toml @@ -54,7 +54,7 @@ server = [ "dep:serde_json", ] client = [ - "ras-file-macro/client", + "ras-file-macro/reqwest", "ras-transport-core/reqwest", ] fs = ["ras-file-macro/fs", "ras-transport-core/fs", "dep:tokio", "dep:tokio-util"] diff --git a/examples/file-service-wasm/file-service-api/README.md b/examples/file-service-wasm/file-service-api/README.md index f641cc8..fb83e58 100644 --- a/examples/file-service-wasm/file-service-api/README.md +++ b/examples/file-service-wasm/file-service-api/README.md @@ -16,7 +16,8 @@ The backend implementation is documented in [../file-service-backend/README.md]( ## Features - `server` - marker feature used by the backend package when depending on this shared API crate. -- `client` - enables the macro-generated upload/download client for native or `wasm32` callers. +- `client` - enables the macro-generated upload/download client with the default reqwest transport. +- `fs` - enables native generated-client helpers that stream upload parts from disk. - `wasm-client` - compatibility alias that also enables the extra WASM helper dependencies. ## Checks diff --git a/examples/oauth2-demo/api/Cargo.toml b/examples/oauth2-demo/api/Cargo.toml index f1820f2..8b485cf 100644 --- a/examples/oauth2-demo/api/Cargo.toml +++ b/examples/oauth2-demo/api/Cargo.toml @@ -13,7 +13,7 @@ readme = "README.md" [features] default = [] server = ["ras-jsonrpc-macro/server", "dep:axum", "dep:ras-jsonrpc-core"] -client = ["ras-jsonrpc-macro/client", "ras-transport-core/reqwest"] +client = ["ras-jsonrpc-macro/reqwest", "ras-transport-core/reqwest"] [dependencies] # JSON-RPC infrastructure diff --git a/examples/oauth2-demo/api/README.md b/examples/oauth2-demo/api/README.md index f78292f..2812851 100644 --- a/examples/oauth2-demo/api/README.md +++ b/examples/oauth2-demo/api/README.md @@ -1,6 +1,6 @@ # OAuth2 Demo API -Shared JSON-RPC API contract for the [OAuth2 demo](../README.md). This crate defines the service payloads and uses `ras-jsonrpc-macro` to generate the `GoogleOAuth2Service` server trait and OpenRPC document consumed by the demo server. +Shared JSON-RPC API contract for the [OAuth2 demo](../README.md). This crate defines the service payloads and uses `ras-jsonrpc-macro` to generate the `GoogleOAuth2Service` server trait, optional reqwest-backed Rust client, and OpenRPC document consumed by the demo server. ## Generated Service diff --git a/examples/rest-wasm-example/rest-api/Cargo.toml b/examples/rest-wasm-example/rest-api/Cargo.toml index 51c935d..61ac26d 100644 --- a/examples/rest-wasm-example/rest-api/Cargo.toml +++ b/examples/rest-wasm-example/rest-api/Cargo.toml @@ -46,4 +46,4 @@ server = [ "dep:axum-extra", "dep:tokio", ] -client = ["ras-rest-macro/client", "ras-transport-core/reqwest"] +client = ["ras-rest-macro/reqwest", "ras-transport-core/reqwest"] diff --git a/examples/rest-wasm-example/rest-api/README.md b/examples/rest-wasm-example/rest-api/README.md index afd6e7f..ebe4f63 100644 --- a/examples/rest-wasm-example/rest-api/README.md +++ b/examples/rest-wasm-example/rest-api/README.md @@ -19,7 +19,7 @@ The service declaration in `src/lib.rs` generates: ## Features - `server`: enables generated server-side types and router integration -- `client`: enables generated Rust client helpers +- `client`: enables generated Rust client helpers with the default reqwest transport - default: no generated server or client transport code ## Checks diff --git a/examples/wasm-ui-demo/src/lib.rs b/examples/wasm-ui-demo/src/lib.rs index 836f832..5362342 100644 --- a/examples/wasm-ui-demo/src/lib.rs +++ b/examples/wasm-ui-demo/src/lib.rs @@ -132,8 +132,7 @@ impl App { let api_url = rpc_endpoint_url(&protocol, &host); // Initialize the RPC client - let client = MyServiceClientBuilder::new() - .server_url(&api_url) + let client = MyServiceClientBuilder::new(&api_url) .build() .expect("Failed to build client"); diff --git a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml index e58a34b..ac675d3 100644 --- a/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml +++ b/tests/playwright/fixtures/jsonrpc-fixture/Cargo.toml @@ -13,7 +13,7 @@ readme = "README.md" [features] default = ["server"] server = ["ras-jsonrpc-macro/server"] -client = ["ras-jsonrpc-macro/client", "ras-transport-core/reqwest"] +client = ["ras-jsonrpc-macro/reqwest", "ras-transport-core/reqwest"] [dependencies] anyhow = { workspace = true } diff --git a/tests/playwright/fixtures/rest-fixture/Cargo.toml b/tests/playwright/fixtures/rest-fixture/Cargo.toml index 2efc631..daf7a2e 100644 --- a/tests/playwright/fixtures/rest-fixture/Cargo.toml +++ b/tests/playwright/fixtures/rest-fixture/Cargo.toml @@ -13,7 +13,7 @@ readme = "README.md" [features] default = ["server"] server = ["ras-rest-macro/server"] -client = ["ras-rest-macro/client", "ras-transport-core/reqwest"] +client = ["ras-rest-macro/reqwest", "ras-transport-core/reqwest"] [dependencies] anyhow = { workspace = true }