Introduction
Rust Agent Stack (RAS) is a set of Rust crates for building type-safe, authenticated service APIs. The central idea is that the API contract should be declared once, in Rust, and then used to generate the server boundary, handler trait, clients, API documents, and authentication checks.
The main service macros are:
jsonrpc_service!for HTTP JSON-RPC services.rest_service!for conventional JSON REST APIs.file_service!for streaming upload and download APIs.jsonrpc_bidirectional_service!for typed bidirectional JSON-RPC over WebSockets.
Each macro follows the same shape: define the wire contract, implement the generated trait, configure an auth provider when protected calls exist, then mount the generated server or use the generated client.
If you want a guided application design flow, start with the application tutorial. It walks through crate boundaries, auth, server implementation, generated clients, tests, and evolution.
The repository contains runnable examples, including JSON-RPC, REST, file services, OAuth2, bidirectional chat, and browser/WASM clients.
Why Typed Service Definitions
RAS service macros are intentionally strict. They ask you to describe endpoints with concrete Rust request and response types, then generate the repetitive boundary code from that description.
This matters for API builders because the API boundary is where drift usually appears:
- server handlers accept one shape while clients send another;
- documentation falls behind the real implementation;
- auth requirements live in middleware or comments instead of the operation definition;
- file uploads buffer too much data or validate too late;
- renamed fields break older clients without an explicit migration path.
With RAS, the service definition is the source of truth. The generated trait
forces every declared endpoint to be implemented. Request and response types are
serialized through serde, documented through schemars where specs are
generated, and reused by generated clients. When an endpoint is protected, the
generated trait signature receives an AuthenticatedUser, so handler code can
depend on authenticated identity without repeating token parsing.
The macros do not remove runtime validation. They move the easy-to-forget plumbing to generated code and leave the service implementation focused on domain behavior: read typed input, apply business rules, return typed output.
What The Macros Generate
Depending on the macro and enabled features, a service definition can generate:
- a trait that lists every handler method with typed parameters;
- an Axum router, WebSocket service, or runtime adapter;
- authentication and permission checks before handlers run;
- native Rust clients, including WASM-compatible clients for browser use;
- OpenRPC or OpenAPI documents with schemas and auth metadata;
- explorer UIs for supported HTTP services;
- version compatibility routes or methods where the macro supports migrations.
The result is a narrower contract between API design, server implementation, client usage, and published documentation.
Auth In The API Contract
RAS puts auth requirements next to the endpoint or method declaration:
UNAUTHORIZED health(()) -> HealthStatus,
WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile,
WITH_PERMISSIONS(["admin"] | ["owner", "editor"]) update_project(UpdateProject) -> Project,
This is deliberate. When auth is part of the API definition, generated code can enforce it consistently and generated API documents can expose it to clients.
Shared Runtime Model
All service macros integrate with ras-auth-core:
use ras_auth_core::{AuthFuture, AuthProvider, AuthenticatedUser};
struct MyAuthProvider;
impl AuthProvider for MyAuthProvider {
fn authenticate(&self, token: String) -> AuthFuture<'_> {
Box::pin(async move {
// Validate a JWT, API key, session token, or other credential.
todo!("return an AuthenticatedUser or AuthError")
})
}
}
AuthProvider::authenticate turns a credential into an
AuthenticatedUser. The default check_permissions implementation requires
that the user has every permission listed in the group, and providers may
override that method for custom policy.
Auth Syntax
UNAUTHORIZED means the generated server does not require a credential for the
operation.
WITH_PERMISSIONS(["a", "b"]) means the generated server requires a valid
credential and a permission group containing both a and b.
Groups use OR logic between groups and AND logic within a group:
WITH_PERMISSIONS(["admin"] | ["moderator", "editor"])
That allows either admin, or both moderator and editor.
An empty group is the authenticated-only form:
WITH_PERMISSIONS([])
It requires a valid user but no specific permission.
The same syntax is accepted by JSON-RPC, REST, file, and bidirectional JSON-RPC service macros.
What Gets Documented
When OpenRPC or OpenAPI generation is enabled, protected operations include
authentication metadata. REST and file services expose bearer auth security
requirements in OpenAPI, and JSON-RPC methods expose x-authentication.
Permission names are also emitted as extension metadata so explorer UIs and
client-generation workflows can show what a call requires. x-permissions
contains a flattened compatibility list, while x-permission-groups preserves
the real OR/AND grouping.
Build A Typed Workspace App
This tutorial walks through designing an application with RAS from the first API boundary decision to clients, tests, and deployment wiring.
The example application is a small team workspace:
- users list projects and tasks;
- users create and update tasks;
- users upload and download task attachments;
- clients can receive live activity notifications;
- admins can perform wider maintenance operations.
The important part is not the domain. The important part is the shape: the API
contract lives in a shared Rust crate, API-crate server and client features
forward to the service macro features, and auth requirements are declared
beside the operation definitions.
Target Architecture
Use a workspace with clear crate boundaries:
team-workspace/
Cargo.toml
crates/
workspace-api/ # DTOs and service macro declarations
workspace-server/ # Axum server, storage, auth provider, service impls
workspace-web/ # optional Rust/WASM client
web/ # optional TypeScript app generated from OpenAPI
The workspace-api crate is the center. Server and client crates depend on it
with different features:
[dependencies]
workspace-api = { path = "../workspace-api", default-features = false, features = ["server"] }
[dependencies]
workspace-api = { path = "../workspace-api", default-features = false, features = ["client"] }
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
By the end of the tutorial you will have:
- a typed API crate with REST, file, and optional WebSocket contracts;
- explicit permission requirements in the API definitions;
- an Axum server that implements generated traits;
- generated OpenAPI and Rust client usage;
- checks that prove no-default, server-only, client-only, and WASM builds keep working;
- a practical strategy for evolving the API without silently breaking clients.
The tutorial uses REST for project/task workflows, file services for
attachments, and bidirectional JSON-RPC for live notifications. If your
application is more command-oriented, the same structure works with
jsonrpc_service! instead of
rest_service!.
1. Design The Contract
Start with workflows, not with Axum routes or database tables. Write down what clients need to do and which operations need identity.
For the team workspace, the first pass looks like this:
| Workflow | Macro | Reason |
|---|---|---|
| list projects, read tasks, create tasks | rest_service! | conventional JSON resources, OpenAPI, browser clients |
| upload and download task attachments | file_service! | streaming, multipart validation, early auth checks |
| live task activity | jsonrpc_bidirectional_service! | typed WebSocket notifications |
| command-heavy workflows | jsonrpc_service! | optional alternative for RPC-style APIs |
Name Permissions Early
Permissions should be stable application concepts, not incidental handler details. Good permission names usually describe the capability:
project:read
project:write
task:write
attachment:read
attachment:write
admin
Each protected operation declares those requirements in the API definition:
GET WITH_PERMISSIONS(["project:read"]) projects() -> ProjectsResponse,
POST WITH_PERMISSIONS(["task:write"]) projects/{project_id: String}/tasks(CreateTaskRequest) -> Task,
DELETE WITH_PERMISSIONS(["admin"] | ["project:owner"]) projects/{project_id: String}() -> (),
WITH_PERMISSIONS(["a", "b"]) means the authenticated user needs both
permissions. WITH_PERMISSIONS(["a"] | ["b", "c"]) means either the first group
or the second group is enough. WITH_PERMISSIONS([]) means authenticated, with
no extra permission requirement.
Keep DTOs Boring
DTOs should be explicit, serializable, and independent of storage models. Avoid exposing database-specific fields just because they exist.
#[cfg(feature = "server")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "server", derive(JsonSchema))]
pub struct Task {
pub id: String,
pub project_id: String,
pub title: String,
pub status: TaskStatus,
pub assignee_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "server", derive(JsonSchema))]
pub enum TaskStatus {
Open,
InProgress,
Done,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "server", derive(JsonSchema))]
pub struct CreateTaskRequest {
pub title: String,
pub assignee_id: Option<String>,
}
The JsonSchema derive is gated because only server/spec generation needs it.
Shared serialization stays available with no transport feature enabled.
Sketch The Service
A REST task service definition can stay close to the client workflow:
rest_service!({
service_name: TaskService,
base_path: "/api/v1",
openapi: true,
serve_docs: true,
docs_path: "/docs",
endpoints: [
GET WITH_PERMISSIONS(["project:read"]) projects() -> ProjectsResponse,
GET WITH_PERMISSIONS(["project:read"]) projects/{project_id: String}/tasks() -> TasksResponse,
POST WITH_PERMISSIONS(["task:write"]) projects/{project_id: String}/tasks(CreateTaskRequest) -> Task,
PATCH WITH_PERMISSIONS(["task:write"]) tasks/{task_id: String}(UpdateTaskRequest) -> Task,
]
});
At this point you have made the most important design decisions: operation names, path parameters, request/response types, and auth requirements. The server implementation can change later without changing this contract.
2. Create The API Crate
The API crate owns DTOs and service declarations. It should not own database connections, runtime configuration, or concrete auth logic.
Cargo Features
Use API-crate features to forward macro-crate features and enable the runtime dependencies referenced by generated transport code:
[package]
name = "workspace-api"
edition = "2024"
[dependencies]
ras-rest-macro = { version = "0.2.1", default-features = false }
ras-file-macro = { version = "0.1.0", default-features = false }
ras-jsonrpc-bidirectional-macro = { version = "0.1.0", default-features = false }
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 }
ras-rest-core = { version = "0.1.1", optional = true }
ras-file-core = { version = "0.1.0", optional = true }
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 }
[features]
default = []
server = [
"ras-rest-macro/server",
"ras-file-macro/server",
"ras-jsonrpc-bidirectional-macro/server",
"dep:schemars",
"dep:serde_json",
"dep:async-trait",
"dep:ras-auth-core",
"dep:ras-rest-core",
"dep:ras-file-core",
"dep:ras-jsonrpc-bidirectional-server",
"dep:axum",
"dep:axum-extra",
"dep:tokio",
]
client = [
"ras-rest-macro/reqwest",
"ras-file-macro/reqwest",
"ras-jsonrpc-bidirectional-macro/client",
"ras-transport-core/reqwest",
"dep:tokio",
]
fs = ["ras-file-macro/fs", "ras-transport-core/fs"]
Server crates enable workspace-api/server. Rust or WASM clients enable
workspace-api/client. The proc macro crate features decide which generated
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:
src/
lib.rs
tasks.rs
attachments.rs
activity.rs
lib.rs re-exports the generated surface:
pub mod activity;
pub mod attachments;
pub mod tasks;
pub use activity::*;
pub use attachments::*;
pub use tasks::*;
Task Service
tasks.rs contains DTOs and the REST declaration:
use ras_rest_macro::rest_service;
#[cfg(feature = "server")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "server", derive(JsonSchema))]
pub struct TasksResponse {
pub tasks: Vec<Task>,
}
rest_service!({
service_name: TaskService,
base_path: "/api/v1",
openapi: true,
endpoints: [
GET WITH_PERMISSIONS(["project:read"]) projects/{project_id: String}/tasks() -> TasksResponse,
POST WITH_PERMISSIONS(["task:write"]) projects/{project_id: String}/tasks(CreateTaskRequest) -> Task,
]
});
Attachment Service
attachments.rs uses the file macro because attachments should be streamed and
validated before service code sees them:
use ras_file_macro::file_service;
#[cfg(feature = "server")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "server", derive(JsonSchema))]
pub struct AttachmentUploadResponse {
pub attachment_id: String,
pub file_name: String,
pub size: u64,
}
file_service!({
service_name: AttachmentService,
base_path: "/api/v1/attachments",
openapi: true,
endpoints: [
UPLOAD WITH_PERMISSIONS(["attachment:write"]) tasks/{task_id: String}/upload multipart {
max_total_bytes: 52428800,
reject_unknown_fields: true,
parts: [
file file {
required: true,
max_count: 1,
max_bytes: 52428800,
filename: required,
},
],
} -> AttachmentUploadResponse,
DOWNLOAD WITH_PERMISSIONS(["attachment:read"]) download/{attachment_id: String} {
content_types: ["application/octet-stream"],
ranges: true,
},
]
});
Activity Notifications
activity.rs defines live notifications. The server sends typed events and the
client registers typed handlers:
use ras_jsonrpc_bidirectional_macro::jsonrpc_bidirectional_service;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskChanged {
pub task_id: String,
pub project_id: String,
}
jsonrpc_bidirectional_service!({
service_name: ActivityService,
client_to_server: [
WITH_PERMISSIONS(["project:read"]) subscribe_project(String) -> (),
],
server_to_client: [
task_changed(TaskChanged),
],
server_to_client_calls: [
]
});
The API crate now describes the externally visible application boundary. The server crate can focus on persistence, auth, and business rules.
3. Implement The Server
The server crate depends on the API crate with features = ["server"] and
implements the generated traits.
[dependencies]
workspace-api = { path = "../workspace-api", default-features = false, features = ["server"] }
ras-auth-core = "0.1.0"
ras-rest-core = "0.1.1"
ras-file-core = "0.1.0"
axum = "0.8"
tokio = { version = "1.0", features = ["full"] }
async-trait = "0.1"
Auth Provider
RAS auth providers turn credentials into an AuthenticatedUser. Permission
checks declared in the API definition run after authentication and before the
handler.
use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser};
use std::collections::HashSet;
#[derive(Clone)]
pub struct AppAuthProvider {
sessions: SessionStore,
}
impl AuthProvider for AppAuthProvider {
fn authenticate(&self, token: String) -> AuthFuture<'_> {
Box::pin(async move {
let session = self
.sessions
.lookup(&token)
.await
.map_err(|_| AuthError::InvalidToken)?;
Ok(AuthenticatedUser {
user_id: session.user_id,
permissions: session.permissions.into_iter().collect::<HashSet<_>>(),
metadata: None,
})
})
}
}
Handlers do not parse tokens. Protected generated methods receive the authenticated user as a typed argument.
REST Handler Implementation
Generated REST traits return RestResult<T>.
use ras_auth_core::AuthenticatedUser;
use ras_rest_core::{RestResponse, RestResult};
use workspace_api::{
CreateTaskRequest, Task, TaskServiceTrait, TasksResponse,
};
#[derive(Clone)]
pub struct TaskHandlers {
tasks: TaskRepository,
}
#[async_trait::async_trait]
impl TaskServiceTrait for TaskHandlers {
async fn get_projects_by_project_id_tasks(
&self,
user: &AuthenticatedUser,
project_id: String,
) -> RestResult<TasksResponse> {
let tasks = self.tasks.visible_to(user, &project_id).await?;
Ok(RestResponse::ok(TasksResponse { tasks }))
}
async fn post_projects_by_project_id_tasks(
&self,
user: &AuthenticatedUser,
project_id: String,
request: CreateTaskRequest,
) -> RestResult<Task> {
let task = self.tasks.create(user, project_id, request).await?;
Ok(RestResponse::created(task))
}
}
The generated signature reflects the API declaration: protected endpoints get
&AuthenticatedUser, path parameters are typed arguments, and request bodies
are typed structs.
File Handler Implementation
Uploads run in phases. This lets generated code authenticate first, enforce size limits, reject unknown fields, and ensure file streams are consumed.
use ras_file_core::{FileRequestContext, FileResult, JsonResponse};
use workspace_api::{
AttachmentServiceTrait, AttachmentServiceTasksByTaskIdUploadPart,
AttachmentServiceTasksByTaskIdUploadPath, AttachmentUploadResponse,
};
pub struct UploadState {
attachment_id: Option<String>,
file_name: Option<String>,
size: u64,
}
#[async_trait::async_trait]
impl AttachmentServiceTrait for AttachmentHandlers {
type TasksByTaskIdUploadState = UploadState;
async fn tasks_by_task_id_upload_begin(
&self,
ctx: &FileRequestContext<'_>,
path: &AttachmentServiceTasksByTaskIdUploadPath,
) -> FileResult<Self::TasksByTaskIdUploadState> {
self.attachments.reserve(ctx.user, &path.task_id).await
}
async fn tasks_by_task_id_upload_part(
&self,
_ctx: &FileRequestContext<'_>,
_path: &AttachmentServiceTasksByTaskIdUploadPath,
state: &mut Self::TasksByTaskIdUploadState,
part: &mut AttachmentServiceTasksByTaskIdUploadPart<'_>,
) -> FileResult<()> {
match part {
AttachmentServiceTasksByTaskIdUploadPart::File(file) => {
while let Some(chunk) = file.next_chunk().await? {
state.size += chunk.len() as u64;
self.attachments.write_chunk(state, &chunk).await?;
}
state.file_name = file.file_name().map(str::to_owned);
}
}
Ok(())
}
async fn tasks_by_task_id_upload_finish(
&self,
_ctx: &FileRequestContext<'_>,
_path: &AttachmentServiceTasksByTaskIdUploadPath,
state: Self::TasksByTaskIdUploadState,
_summary: ras_file_core::UploadSummary,
) -> FileResult<JsonResponse<AttachmentUploadResponse>> {
Ok(JsonResponse::ok(state.into_response()?))
}
}
Generated names include path segments so multiple uploads can coexist in one service.
Mount The App
Build generated routers and merge them into one Axum app:
let auth = AppAuthProvider::new(session_store);
let task_routes = workspace_api::TaskServiceBuilder::new(TaskHandlers { tasks })
.auth_provider(auth.clone())
.build();
let attachment_routes =
workspace_api::AttachmentServiceBuilder::new(AttachmentHandlers { attachments })
.auth_provider(auth.clone())
.build();
let app = axum::Router::new()
.merge(task_routes)
.merge(attachment_routes);
For WebSocket services, mount the generated service state on an Axum route as shown in the bidirectional macro guide.
Generate Specs During Build
For REST and file services, a server build.rs can write OpenAPI documents for
frontends:
fn main() {
workspace_api::generate_taskservice_openapi_to_file()
.expect("generate task OpenAPI");
workspace_api::generate_attachmentservice_openapi_to_file()
.expect("generate attachment OpenAPI");
}
That keeps generated client input tied to the exact Rust API contract the server compiled against.
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.
[dependencies]
workspace-api = { path = "../workspace-api", default-features = false, features = ["client"] }
Rust REST Client
The generated REST client turns paths, query values, and request bodies into typed method arguments.
use workspace_api::{CreateTaskRequest, TaskServiceClient};
let mut client = TaskServiceClient::builder("https://workspace.example.com")
.with_timeout(std::time::Duration::from_secs(10))
.build()?;
client.set_bearer_token(Some(token));
let tasks = client
.get_projects_by_project_id_tasks("project-123".to_string())
.await?;
let created = client
.post_projects_by_project_id_tasks(
"project-123".to_string(),
CreateTaskRequest {
title: "Write release notes".to_string(),
assignee_id: None,
},
)
.await?;
The client method names mirror the generated handler names, so compiler errors surface contract changes immediately.
Rust File Client
The generated file client builds multipart requests and download requests.
use workspace_api::{AttachmentServiceClient, AttachmentServiceTasksByTaskIdUploadMultipart};
let mut client = AttachmentServiceClient::builder("https://workspace.example.com")
.with_timeout(std::time::Duration::from_secs(30))
.build()?;
client.set_bearer_token(Some(token));
let form = AttachmentServiceTasksByTaskIdUploadMultipart::new()
.file("notes.pdf", Some("notes.pdf"), Some("application/pdf"))
.await?;
let uploaded = client
.tasks_by_task_id_upload("task-123".to_string(), form)
.await?;
let response = client
.download_by_attachment_id(uploaded.attachment_id)
.await?;
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:
[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 files emitted by the server build. Generated clients usually accept one config object per call:
import {
getProjectsProjectIdTasks,
postProjectsProjectIdTasks,
} from './generated/task-client';
const baseUrl = 'https://workspace.example.com/api/v1';
const tasks = await getProjectsProjectIdTasks({
baseUrl,
headers: { Authorization: `Bearer ${token}` },
path: { project_id: 'project-123' },
});
const created = await postProjectsProjectIdTasks({
baseUrl,
headers: { Authorization: `Bearer ${token}` },
path: { project_id: 'project-123' },
body: {
title: 'Write release notes',
assignee_id: null,
},
});
File uploads use FormData or the generator’s multipart object shape:
await postTasksTaskIdUpload({
baseUrl: 'https://workspace.example.com/api/v1/attachments',
headers: { Authorization: `Bearer ${token}` },
path: { task_id: 'task-123' },
body: { file },
});
WebSocket Notifications
The bidirectional client registers typed notification handlers before connecting:
let mut activity = ActivityServiceClientBuilder::new("wss://workspace.example.com/ws")
.with_jwt_token(token)
.build()
.await?;
activity.on_task_changed(|event| {
println!("task changed: {}", event.task_id);
});
activity.connect().await?;
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.
5. Test, Ship, And Evolve
Strict API definitions are most useful when CI checks the important feature combinations and when tests assert that auth metadata stays visible in the generated specs.
Contract Tests
Test DTO serialization for wire stability:
#[test]
fn create_task_request_serializes_expected_shape() {
let value = serde_json::to_value(CreateTaskRequest {
title: "Write release notes".to_string(),
assignee_id: None,
})
.unwrap();
assert_eq!(
value,
serde_json::json!({
"title": "Write release notes",
"assignee_id": null
})
);
}
Test generated OpenAPI or OpenRPC output for route shape and permission metadata:
#[test]
fn openapi_documents_task_permissions() {
let doc = workspace_api::generate_taskservice_openapi();
let create = &doc["paths"]["/projects/{project_id}/tasks"]["post"];
assert_eq!(create["security"][0]["bearerAuth"], serde_json::json!([]));
assert_eq!(create["x-permissions"], serde_json::json!(["task:write"]));
}
These tests catch accidental auth changes before a client discovers them.
Server Tests
Use an in-memory Axum test server for generated routes:
#[tokio::test]
async fn create_task_requires_write_permission() {
let app = build_app_with_test_auth();
let server = axum_test::TestServer::new(app).unwrap();
let response = server
.post("/api/v1/projects/project-123/tasks")
.authorization_bearer("read-only-token")
.json(&CreateTaskRequest {
title: "Write release notes".to_string(),
assignee_id: None,
})
.await;
response.assert_status_forbidden();
}
File-service tests should include oversized requests, missing required parts, wrong content types, and auth rejection before upload handling begins.
Feature Matrix
Add CI checks for the API crate itself:
cargo check -p workspace-api --no-default-features --locked
cargo check -p workspace-api --no-default-features --features server --locked
cargo check -p workspace-api --no-default-features --features client --locked
cargo check -p workspace-api --target wasm32-unknown-unknown --no-default-features --features client --locked
This proves DTO-only, server-only, native client, and browser client builds stay separate.
Deployment Shape
A typical release pipeline does three things:
- build and test the Rust workspace;
- generate OpenAPI/OpenRPC documents from the API crate;
- publish generated docs and client inputs alongside the server artifact.
The mdBook in this repository is built in CI and published to GitHub Pages from
the master branch. Application repositories can use the same pattern for
project-specific API documentation.
Evolving The API
Prefer additive changes when possible:
- add optional request fields instead of requiring new fields immediately;
- add response fields that old clients can ignore;
- add new operations before removing old operations;
- preserve permission names unless the capability truly changed.
When a wire shape must change, use versioning support where the macro provides it. Keep the service implementation canonical and let the API boundary migrate legacy request and response shapes. That makes compatibility an explicit part of the contract instead of an untested handler branch.
Before removing a legacy operation, check generated specs, client usage, and server logs. The contract should tell you what still exists; telemetry should tell you what is still used.
jsonrpc_service!
Use jsonrpc_service! when you want an HTTP JSON-RPC API with typed request
and response payloads, generated server dispatch, generated Rust clients, and
optional OpenRPC output.
Dependencies And Features
Put the macro in the shared API definition crate. If you want server and client outputs to stay optional, expose API-crate features that forward to the macro crate features and enable the runtime dependencies those generated surfaces refer to.
[dependencies]
ras-jsonrpc-macro = { version = "0.2.0", default-features = false }
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 }
[features]
default = []
server = ["ras-jsonrpc-macro/server", "dep:ras-jsonrpc-core", "dep:axum", "dep:tokio"]
client = ["ras-jsonrpc-macro/reqwest", "ras-transport-core/reqwest"]
Server binaries then depend on my-api with features = ["server"]; clients
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
use ras_jsonrpc_macro::jsonrpc_service;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct SignInRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct SignInResponse {
pub token: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct UserProfile {
pub user_id: String,
}
jsonrpc_service!({
service_name: UserService,
openrpc: true,
methods: [
UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse,
WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile,
WITH_PERMISSIONS(["admin"] | ["support", "users:write"]) disable_user(String) -> (),
]
});
The Rust method name is the JSON-RPC wire method unless a versioned method block
sets an explicit wire name.
Implement The Generated Trait
Protected methods receive &AuthenticatedUser before their request payload:
struct UserServiceImpl;
impl UserServiceTrait for UserServiceImpl {
async fn sign_in(
&self,
request: SignInRequest,
) -> Result<SignInResponse, Box<dyn std::error::Error + Send + Sync>> {
todo!("verify credentials and issue token")
}
async fn get_profile(
&self,
user: &ras_jsonrpc_core::AuthenticatedUser,
_request: (),
) -> Result<UserProfile, Box<dyn std::error::Error + Send + Sync>> {
Ok(UserProfile {
user_id: user.user_id.clone(),
})
}
}
Build The Router
let rpc = UserServiceBuilder::new(UserServiceImpl)
.base_url("/rpc")
.auth_provider(my_auth_provider)
.build()?;
let app = axum::Router::new().merge(rpc);
The generated server extracts bearer credentials from Authorization, can be
configured for secure session cookies, routes by JSON-RPC method name, parses
typed params, checks auth, and converts handler errors into JSON-RPC error
responses.
Use The Generated Rust Client
Enable the shared API crate’s client feature in the crate that makes outbound
calls:
[dependencies]
my-api = { path = "../api", default-features = false, features = ["client"] }
The generated client calls methods by their Rust names and sends the correct JSON-RPC wire method internally.
let mut client = UserServiceClientBuilder::new("http://localhost:3000/rpc")
.with_timeout(std::time::Duration::from_secs(10))
.build()?;
let signed_in = client
.sign_in(SignInRequest {
email: "alice@example.com".to_string(),
password: "correct horse battery staple".to_string(),
})
.await?;
client.set_bearer_token(Some(signed_in.token));
let profile = client.get_profile(()).await?;
client
.disable_user("user-123".to_string())
.await?;
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:
let client = UserServiceClientBuilder::new("/rpc").build()?;
let mut authed = client.clone();
authed.set_bearer_token(Some(token));
let profile = authed.get_profile(()).await?;
OpenRPC And Clients
With openrpc: true, the macro generates:
pub fn generate_userservice_openrpc() -> serde_json::Value;
pub fn generate_userservice_openrpc_to_file() -> Result<(), std::io::Error>;
Request and response types must implement schemars::JsonSchema for OpenRPC
generation. The generated document includes schemas, method names, auth
metadata, permission metadata, and version metadata.
The API crate’s client feature emits typed Rust methods for the current
operation names and, when a method declares versioned compatibility, for the
legacy Rust method aliases too. Each generated method still sends the configured
wire method name, so old and new clients can coexist while the server migrates
requests at the API boundary.
Browser clients can compile to WASM when the API crate dependency is enabled
with features = ["client"] for wasm32.
See the runnable service in examples/basic-jsonrpc and the WASM client usage in examples/wasm-ui-demo.
rest_service!
Use rest_service! for JSON REST APIs that should generate Axum routes, typed
handler traits, native Rust clients, OpenAPI documents, and an optional API
explorer.
Dependencies And Features
[dependencies]
ras-rest-macro = { version = "0.2.1", default-features = false }
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 }
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 }
[features]
default = []
server = [
"ras-rest-macro/server",
"dep:ras-rest-core",
"dep:ras-auth-core",
"dep:async-trait",
"dep:axum",
"dep:axum-extra",
"dep:tokio",
]
client = ["ras-rest-macro/reqwest", "ras-transport-core/reqwest"]
These API-crate features are forwarding gates. They enable the relevant macro
crate feature and the runtime dependencies that generated code refers to. The
macro emits server or client code only when the corresponding
ras-rest-macro feature is enabled; the generated code does not depend on a
consumer-crate #[cfg(feature = "...")] branch.
A backend depends on the API crate with features = ["server"]; a Rust client
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
use ras_rest_macro::rest_service;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct User {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CreateUserRequest {
pub name: String,
}
rest_service!({
service_name: UserService,
base_path: "/api/v1",
openapi: true,
serve_docs: true,
docs_path: "/docs",
endpoints: [
GET UNAUTHORIZED users() -> Vec<User>,
GET WITH_PERMISSIONS(["user"]) users/{id: String}() -> User,
POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> User,
DELETE WITH_PERMISSIONS(["admin"] | ["support", "users:delete"]) users/{id: String}() -> (),
]
});
Endpoint syntax is:
METHOD AUTH_REQUIREMENT path/{param: Type}/segments(RequestType) -> ResponseType
Supported methods are GET, POST, PUT, DELETE, and PATCH.
Implement The Generated Trait
REST handlers return RestResult<T>, usually through RestResponse helpers:
use ras_auth_core::AuthenticatedUser;
use ras_rest_core::{RestError, RestResponse, RestResult};
struct UserServiceImpl;
#[async_trait::async_trait]
impl UserServiceTrait for UserServiceImpl {
async fn get_users(&self) -> RestResult<Vec<User>> {
Ok(RestResponse::ok(vec![]))
}
async fn get_users_by_id(
&self,
user: &AuthenticatedUser,
id: String,
) -> RestResult<User> {
todo!("load a user visible to user.user_id")
}
async fn post_users(
&self,
user: &AuthenticatedUser,
request: CreateUserRequest,
) -> RestResult<User> {
todo!("create user as admin")
}
}
Path parameters become ordinary typed arguments. Protected endpoints receive
&AuthenticatedUser before path and body arguments.
Build The Router
let app = UserServiceBuilder::new(UserServiceImpl)
.auth_provider(my_auth_provider)
.build();
The builder can also be configured for secure cookie auth and CSRF protection
without changing the AuthProvider.
Use The Generated Rust Client
Enable the shared API crate’s client feature in the crate that makes outbound
calls:
[dependencies]
my-rest-api = { path = "../rest-api", default-features = false, features = ["client"] }
Pass the server origin to the generated client; the macro’s base_path is
joined automatically.
let mut client = UserServiceClient::builder("http://localhost:3000")
.with_timeout(std::time::Duration::from_secs(10))
.build()?;
let users = client.get_users().await?;
let alice = client.get_users_by_id("alice".to_string()).await?;
client.set_bearer_token(Some(admin_token));
let created = client
.post_users(CreateUserRequest {
name: "Alice".to_string(),
})
.await?;
client.delete_users_by_id(created.id).await?;
Path parameters, query parameters, and request bodies become ordinary method arguments in that order.
Use An OpenAPI TypeScript Client
The REST examples also show the browser-oriented path: generate a fetch client
from the OpenAPI document, then call named functions with baseUrl, optional
headers, path parameters, query parameters, and body values.
import { getUsers, getUsersId, postUsers } from './generated';
import type { CreateUserRequest } from './generated';
const baseUrl = 'http://localhost:3000/api/v1';
const users = await getUsers({ baseUrl });
const alice = await getUsersId({
baseUrl,
path: { id: 'alice' },
});
const request: CreateUserRequest = { name: 'Alice' };
const created = await postUsers({
baseUrl,
headers: { Authorization: `Bearer ${adminToken}` },
body: request,
});
OpenAPI, Explorer, And Clients
With openapi: true, the macro generates:
pub fn generate_userservice_openapi() -> serde_json::Value;
pub fn generate_userservice_openapi_to_file() -> std::io::Result<()>;
With serve_docs: true, the generated router serves the built-in API explorer
under docs_path relative to base_path.
The OpenAPI document includes JSON schemas, routes, HTTP methods, bearer auth
requirements, and x-permissions metadata. It can be checked into build output
or consumed by TypeScript client generators.
See examples/rest-wasm-example for a REST API with OpenAPI output and browser client usage.
file_service!
Use file_service! when the API handles uploads or downloads. It is separate
from the JSON REST macro because file traffic has different constraints:
authenticate before reading the body, reject oversized requests early, validate
multipart fields, and stream bytes instead of buffering entire files.
Dependencies And Features
Put the file service definition in a shared API crate. If you want generated transport code to stay optional, expose API-crate features that forward to the macro crate features:
[dependencies]
ras-file-macro = { version = "0.1.0", default-features = false }
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 }
tokio = { version = "1.0", optional = true }
schemars = { version = "1.0.0-alpha.20", optional = true }
serde_json = { version = "1.0", optional = true }
[features]
default = []
server = [
"ras-file-macro/server",
"dep:ras-file-core",
"dep:ras-auth-core",
"dep:async-trait",
"dep:axum",
"dep:schemars",
"dep:serde_json",
]
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 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
use ras_file_macro::file_service;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct UploadResponse {
pub file_id: String,
pub size: u64,
}
file_service!({
service_name: DocumentService,
base_path: "/api/documents",
openapi: true,
endpoints: [
UPLOAD WITH_PERMISSIONS(["files:write"]) upload multipart {
max_total_bytes: 52428800,
reject_unknown_fields: true,
parts: [
file file {
required: true,
max_count: 1,
max_bytes: 52428800,
content_types: ["application/pdf", "text/plain"],
filename: optional,
},
json metadata: UploadMetadata {
required: false,
max_bytes: 4096,
content_types: ["application/json"],
},
],
} -> UploadResponse,
DOWNLOAD WITH_PERMISSIONS(["files:read"]) download/{file_id: String} {
content_types: ["application/octet-stream"],
ranges: true,
},
]
});
Every upload declares max_total_bytes, each part declares max_bytes, and
reject_unknown_fields defaults to true. File parts can require, forbid, or
allow filenames.
Implement The Upload Lifecycle
Uploads are processed in phases. The generated server authenticates and checks permissions before consuming the body, then calls service code as accepted parts arrive.
use ras_file_core::{FileRequestContext, FileResult, JsonResponse};
#[async_trait::async_trait]
impl DocumentServiceTrait for MyService {
type UploadState = UploadState;
async fn upload_begin(
&self,
ctx: &FileRequestContext<'_>,
path: &DocumentServiceUploadPath,
) -> FileResult<Self::UploadState> {
Ok(UploadState::default())
}
async fn upload_part(
&self,
ctx: &FileRequestContext<'_>,
path: &DocumentServiceUploadPath,
state: &mut Self::UploadState,
part: &mut DocumentServiceUploadPart<'_>,
) -> FileResult<()> {
match part {
DocumentServiceUploadPart::File(file) => {
while let Some(chunk) = file.next_chunk().await? {
state.write(&chunk).await?;
}
}
DocumentServiceUploadPart::Metadata(metadata) => {
state.metadata = Some(metadata.clone());
}
}
Ok(())
}
async fn upload_finish(
&self,
ctx: &FileRequestContext<'_>,
path: &DocumentServiceUploadPath,
state: Self::UploadState,
summary: ras_file_core::UploadSummary,
) -> FileResult<JsonResponse<UploadResponse>> {
Ok(JsonResponse::ok(state.into_response()))
}
}
If a file part is not fully consumed, the generated handler rejects the request.
Override the generated *_abort hook when temporary files or external
reservations need cleanup after an upload error.
Downloads
Download handlers return DownloadResponse:
use ras_file_core::{DownloadResponse, FileRequestContext, FileResult};
async fn download_by_file_id(
&self,
ctx: &FileRequestContext<'_>,
path: DocumentServiceDownloadByFileIdPath,
) -> FileResult<DownloadResponse> {
let file = self.storage.open(&path.file_id).await?;
DownloadResponse::stream(file.stream)
.content_type(file.content_type)?
.content_length(file.size)?
.attachment(file.original_name)
}
Path parameters become by_* method name segments. For example,
download/{file_id: String} generates download_by_file_id.
Auth Syntax
File services use the same auth syntax as the other service macros:
WITH_PERMISSIONS(["files:write"])
WITH_PERMISSIONS(["files:write", "tenant:active"])
WITH_PERMISSIONS(["admin"] | ["files:write", "tenant:active"])
WITH_PERMISSIONS([])
Use WITH_PERMISSIONS([]) for authenticated-only file operations.
Use The Generated Rust Client
The generated native client handles bearer auth, multipart construction, upload methods, and download requests.
Enable it through the API crate dependency:
[dependencies]
document-api = { path = "../file-service-api", default-features = false, features = ["client", "fs"] }
let mut client = DocumentServiceClient::builder("http://localhost:3000")
.with_timeout(std::time::Duration::from_secs(30))
.build()?;
client.set_bearer_token(Some(user_token));
let metadata = UploadMetadata {
title: "Quarterly report".to_string(),
};
let form = DocumentServiceUploadMultipart::new()
.file("report.pdf", Some("report.pdf"), Some("application/pdf"))
.await?
.metadata(&metadata)?;
let uploaded = client.upload(form).await?;
let response = client.download_by_file_id(uploaded.file_id).await?;
let bytes = response.bytes().await?;
For tests, browser-like flows, or already-buffered content, use the generated
*_bytes helper for file parts:
let form = DocumentServiceUploadMultipart::new()
.file_bytes(
b"hello".to_vec(),
"hello.txt",
Some("text/plain"),
)?;
let uploaded = client.upload(form).await?;
Use An OpenAPI TypeScript Client
OpenAPI-generated browser clients usually model multipart uploads as an object whose fields match the declared parts:
import {
downloadDownloadFileId,
uploadUpload,
uploadUploadProfilePicture,
} from './generated';
const baseUrl = 'http://localhost:3000/api/documents';
const uploaded = await uploadUpload({
baseUrl,
body: { file },
});
const secureUpload = await uploadUploadProfilePicture({
baseUrl,
headers: { Authorization: `Bearer ${token}` },
body: { file },
});
const downloaded = await downloadDownloadFileId({
baseUrl,
path: { file_id: uploaded.data.file_id },
});
OpenAPI And Clients
With openapi: true, the macro emits:
pub fn generate_documentservice_openapi() -> serde_json::Value;
pub fn generate_documentservice_openapi_to_file() -> std::io::Result<()>;
Upload operations include multipart/form-data schemas and an x-ras-file
extension for limits and part policies. Download operations document binary
responses, content types, and range support.
The native client feature generates multipart builders, including in-memory
*_bytes helpers for tests.
See examples/file-service-example and examples/file-service-wasm.
jsonrpc_bidirectional_service!
Use jsonrpc_bidirectional_service! for typed JSON-RPC traffic over
WebSockets. It generates server-side dispatch for client calls, client-side
method helpers, typed notification handling, and optional server-to-client
request support.
Dependencies And Features
[dependencies]
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ras-auth-core = "0.1.0"
ras-jsonrpc-types = "0.1.1"
ras-jsonrpc-bidirectional-types = "0.1.0"
ras-jsonrpc-bidirectional-macro = { version = "0.1.0", default-features = false }
ras-jsonrpc-bidirectional-server = { version = "0.1.0", optional = true }
ras-jsonrpc-bidirectional-client = { version = "0.1.0", optional = true }
[features]
default = []
server = [
"ras-jsonrpc-bidirectional-macro/server",
"dep:ras-jsonrpc-bidirectional-server",
]
client = [
"ras-jsonrpc-bidirectional-macro/client",
"dep:ras-jsonrpc-bidirectional-client",
]
These API-crate features forward to the macro crate and enable the runtime
dependencies used by the generated surface. The WebSocket server depends on the
API crate with features = ["server"]; TUI, native, or browser clients depend
on it with features = ["client"].
If server_to_client_calls is used, the server feature also needs optional
tokio and uuid dependencies because generated server-side client handles
track pending responses and timeouts.
Define The Service
use ras_jsonrpc_bidirectional_macro::jsonrpc_bidirectional_service;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendMessageRequest {
pub channel: String,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendMessageResponse {
pub message_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageReceived {
pub channel: String,
pub body: String,
}
jsonrpc_bidirectional_service!({
service_name: ChatService,
client_to_server: [
WITH_PERMISSIONS(["user"]) send_message(SendMessageRequest) -> SendMessageResponse,
],
server_to_client: [
message_received(MessageReceived),
],
server_to_client_calls: [
]
});
client_to_server methods support the same UNAUTHORIZED and
WITH_PERMISSIONS(["a"] | ["b", "c"]) style as the HTTP JSON-RPC macro.
Implement And Mount The Server
Server handlers receive the connection id and connection manager. Protected
methods also receive &AuthenticatedUser.
#[async_trait::async_trait]
impl ChatServiceService for ChatServiceImpl {
async fn send_message(
&self,
client_id: ras_jsonrpc_bidirectional_types::ConnectionId,
connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager,
user: &ras_auth_core::AuthenticatedUser,
request: SendMessageRequest,
) -> Result<SendMessageResponse, Box<dyn std::error::Error + Send + Sync>> {
todo!("persist and broadcast the message")
}
async fn notify_message_received(
&self,
connection_id: ras_jsonrpc_bidirectional_types::ConnectionId,
params: MessageReceived,
) -> ras_jsonrpc_bidirectional_types::Result<()> {
Ok(())
}
}
let websocket_service = ChatServiceBuilder::new(ChatServiceImpl, my_auth_provider)
.require_auth(false)
.build();
let app = axum::Router::new()
.route("/ws", axum::routing::get(ras_jsonrpc_bidirectional_server::websocket_handler::<_>))
.with_state(websocket_service);
require_auth(true) requires credentials for the connection as a whole.
Method-level permissions are still enforced for protected calls.
Client Usage
The client feature generates a typed client builder, method calls, connection helpers, and notification registration:
let mut client = ChatServiceClientBuilder::new("ws://localhost:3000/ws")
.with_jwt_token(token)
.build()
.await?;
client.on_message_received(|message| {
println!("{}: {}", message.channel, message.body);
});
client.connect().await?;
let sent = client.send_message(SendMessageRequest {
channel: "general".to_string(),
body: "hello".to_string(),
}).await?;
In application code, it is usually useful to register all notification handlers
before connect, then wrap common calls behind a small app-level client:
client.on_message_received(|message| {
println!("{}: {}", message.channel, message.body);
});
client.on_user_joined(|event| {
println!("{} joined", event.username);
});
client.connect().await?;
let rooms = client.list_rooms(ListRoomsRequest {}).await?;
client
.send_message(SendMessageRequest {
channel: rooms.default_channel,
body: "hello".to_string(),
})
.await?;
This macro does not currently generate OpenRPC. Use HTTP
jsonrpc_service! when an OpenRPC document is required.
See examples/bidirectional-chat.
Generated Specs And Clients
The service macros use the Rust API definition to generate machine-readable contracts and client helpers.
OpenRPC
jsonrpc_service! can generate OpenRPC when the service enables
openrpc: true or openrpc: { output: "path/to/file.json" }.
pub fn generate_userservice_openrpc() -> serde_json::Value;
pub fn generate_userservice_openrpc_to_file() -> Result<(), std::io::Error>;
The document includes method names, request and response schemas, auth
extensions, flattened x-permissions, grouped x-permission-groups, and
version metadata for versioned methods.
OpenAPI
rest_service! and file_service! can generate OpenAPI with openapi: true
or a custom output path.
pub fn generate_userservice_openapi() -> serde_json::Value;
pub fn generate_userservice_openapi_to_file() -> std::io::Result<()>;
REST operations include routes, HTTP methods, JSON schemas, bearer auth
requirements, flattened x-permissions, and grouped x-permission-groups.
File-service operations also include
multipart schemas, binary download responses, and x-ras-file metadata for
upload limits, part policies, content types, and range support.
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 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:
Build-Time Spec Generation
Backend crates can emit OpenRPC or OpenAPI during compilation from build.rs.
That keeps generated client input tied to the same Rust API contract used by
the server.
fn main() {
rest_api::generate_userservice_openapi_to_file()
.expect("generate OpenAPI");
}
The REST and file-service examples write specs under target/openapi. A
frontend build can then point its OpenAPI generator at that file.
TypeScript Call Shape
The generated TypeScript fetch clients used in the examples accept one config object per call:
await postUsers({
baseUrl: 'http://localhost:3000/api/v1',
headers: { Authorization: `Bearer ${token}` },
path: { id: 'user-123' },
query: { include_archived: false },
body: { name: 'Alice' },
});
Only include the fields the operation needs. Public GET calls often need only
baseUrl; protected uploads usually include headers and body.
Versioned Methods And Endpoints
JSON-RPC and REST macros support opt-in compatibility definitions. A canonical operation can declare legacy wire names, legacy request/response types, and a migration type. The generated server accepts both shapes while the service implementation only handles the canonical Rust type.
Use versioning when a deployed client still depends on an old wire contract and the server can safely migrate requests and responses at the API boundary.
Permission Manifests
The service macros can emit a typed permission manifest from the same auth declarations used by the generated server. This is useful for audits, admin UI tooling, test assertions, and token issuing code that should not repeat permission strings by hand.
Enable The Feature
Enable manifest generation on the macro crate. The generated API refers to
ras-permission-manifest, so add that crate beside the macro dependency:
[dependencies]
ras-rest-macro = { version = "0.2.1", default-features = false, features = ["permissions"] }
ras-permission-manifest = "0.1.0"
For file services and JSON-RPC services, use the equivalent macro crate:
ras-file-macro = { version = "0.1.0", default-features = false, features = ["permissions"] }
ras-jsonrpc-macro = { version = "0.2.0", default-features = false, features = ["permissions"] }
The permissions switch belongs to the macro crate. The macro emits the
manifest functions and constants only when that macro feature is enabled; the
generated code does not branch on a permissions feature in your API crate.
If your API crate exposes optional server/client outputs, those features can forward to the macro crate:
[features]
server = ["ras-rest-macro/server", "dep:axum", "dep:ras-rest-core"]
client = ["ras-rest-macro/reqwest", "ras-transport-core/reqwest"]
Server build scripts then depend on the API crate feature that makes the generated service/spec functions available:
[build-dependencies]
workspace-api = { path = "../workspace-api", features = ["server"] }
ras-permission-manifest = "0.1.0"
Generated API
For a service named UserService, the macro emits:
pub fn generate_userservice_permission_manifest()
-> ras_permission_manifest::ServicePermissions;
pub mod userservice_permissions {
pub const ADMIN: ras_permission_manifest::PermissionRef;
pub const TASK_WRITE: ras_permission_manifest::PermissionRef;
pub mod operations {
pub const POST_USERS: ras_permission_manifest::StaticPermissionRequirement;
}
}
Permission constants are generated from every permission string used by the service. Operation constants are generated for protected operations and preserve the same OR/AND grouping used by runtime checks.
Build-Time Artifact
Aggregate service manifests explicitly from build.rs:
fn main() {
let manifest = ras_permission_manifest::PermissionManifest::from_services([
workspace_api::generate_userservice_permission_manifest(),
workspace_api::generate_documentservice_permission_manifest(),
]);
ras_permission_manifest::write_manifest(
"target/ras-permissions/workspace.json",
&manifest,
)
.expect("write permission manifest");
}
The JSON distinguishes public operations, authenticated-only operations, and permission groups. For versioned compatibility endpoints, every callable wire method or path appears in the manifest.
Token Issuing
Use generated constants when constructing permission claims:
use ras_permission_manifest::PermissionSet;
use workspace_api::userservice_permissions;
let permissions = PermissionSet::new()
.with(userservice_permissions::TASK_WRITE)
.with(userservice_permissions::ADMIN)
.into_hash_set();
session_service.begin_session(user_id, permissions).await?;
You can also test whether a candidate token satisfies a generated operation requirement:
assert!(
userservice_permissions::operations::POST_USERS
.is_satisfied_by(&permissions)
);
The manifest does not replace the runtime auth model. JWT/session claims still carry strings, but token issuing code can now import compile-checked constants from the API contract instead of spelling those strings repeatedly.
Identity And Sessions
RAS separates service-level authorization from identity-provider concerns. The
service macros ask an AuthProvider to authenticate credentials and check
permissions. The identity crates help build those credentials and permission
sets.
Core Pieces
ras-auth-coredefinesAuthProvider,AuthenticatedUser,AuthError, bearer/cookie transport helpers, and CSRF configuration.ras-identity-coredefines identity-provider traits.ras-identity-localprovides username/password verification with Argon2.ras-identity-oauth2provides OAuth2 with PKCE support.ras-identity-sessionissues and verifies JWT sessions and can attach permissions to authenticated identities.
Typical Flow
- A public endpoint such as
sign_inor an OAuth2 callback verifies an identity. - The application creates a JWT session through the session crate.
- Protected generated services receive bearer tokens or configured secure cookies.
- The generated service calls the configured
AuthProvider. - Handler methods receive
&AuthenticatedUseronly after auth succeeds.
let jwt_auth = JwtAuthProvider::new(Arc::new(session_service));
let app = UserServiceBuilder::new(UserServiceImpl)
.auth_provider(jwt_auth)
.build();
Permissions
Permissions are ordinary strings stored on AuthenticatedUser. The default
AuthProvider::check_permissions requires all permissions in a group. Override
it when permissions are tenant-aware, role-derived, time-bound, or backed by an
external policy service.
Use WITH_PERMISSIONS([]) when an operation only needs a logged-in user and no
specific permission.
Secure Browser Sessions
Browser-facing services can use secure HttpOnly cookies instead of manually
placing bearer tokens in JavaScript. The same generated builders support cookie
auth transport and double-submit CSRF protection for unsafe cookie-authenticated
requests.
See the OAuth2 example in examples/oauth2-demo.
Observability
RAS exposes observability hooks so generated services can report request usage and method durations without baking one metrics backend into every macro.
The OpenTelemetry implementation lives in ras-observability-otel, and the
core traits live in ras-observability-core.
Standard Setup
use ras_observability_otel::standard_setup;
let otel = standard_setup("my-service")?;
let usage_tracker = otel.usage_tracker();
let duration_tracker = otel.method_duration_tracker();
let metrics_router = otel.metrics_router();
Generated service builders expose hooks such as with_usage_tracker and
with_method_duration_tracker where supported:
let service = UserServiceBuilder::new(UserServiceImpl)
.auth_provider(my_auth_provider)
.with_usage_tracker(usage_tracker)
.with_method_duration_tracker(duration_tracker)
.build();
let app = axum::Router::new()
.merge(service)
.merge(metrics_router);
Request Contexts
Use standard context constructors when recording custom metrics outside a generated service:
use ras_observability_core::RequestContext;
let rest = RequestContext::rest("POST", "/api/orders");
let rpc = RequestContext::jsonrpc("create_order");
let ws = RequestContext::websocket("send_message");
Consistent context names keep metrics comparable across REST, JSON-RPC, file, and WebSocket APIs.
See crates/observability/ras-observability-otel for crate-level details and examples.