Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 34 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 2 additions & 9 deletions crates/core/ras-auth-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -140,14 +139,8 @@ mod tests {
}
}

struct NoopWaker;

impl Wake for NoopWaker {
fn wake(self: Arc<Self>) {}
}

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) {
Expand Down
45 changes: 45 additions & 0 deletions crates/core/ras-transport-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
[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 }
getrandom = "0.2"
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 }
# `js` lets `getrandom` source entropy from the browser crypto API on wasm.
getrandom = { version = "0.2", features = ["js"] }

[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"]
30 changes: 30 additions & 0 deletions crates/core/ras-transport-core/README.md
Original file line number Diff line number Diff line change
@@ -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<dyn _>` 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.
102 changes: 102 additions & 0 deletions crates/core/ras-transport-core/src/axum_test_transport.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//! In-process test transport wrapping an `axum_test::TestServer`.
//!
//! 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"))]

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<TestServer>,
}

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<TestServer>) -> 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<TransportResponse, TransportError> {
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::Bytes, TransportError>(bytes)
}));

Ok(TransportResponse::new(status, headers, body_stream))
}
}
Loading
Loading