Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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,
OPTIONAL_AUTH feed(()) -> Feed,
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.

OPTIONAL_AUTH means the operation is public, but opportunistically identified. The route is never rejected for auth reasons; instead the handler receives a ras_auth_core::Caller as its first argument:

pub enum Caller {
    Anonymous,
    Authenticated(AuthenticatedUser),
}

Resolution is fully lenient: a missing credential, an invalid or expired token, a missing auth provider, or a cookie credential that fails CSRF on an unsafe method (POST/PUT/PATCH/DELETE) all resolve to Caller::Anonymous — a forged or stale credential simply executes as the public path. A valid credential resolves to Caller::Authenticated(user). No permission check is performed. Reach for it on “public, but richer when signed in” endpoints (per-document ACLs, personalization, author previews). The file_service! macro surfaces the same optional caller through FileRequestContext rather than a Caller parameter.

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.

OPTIONAL_AUTH operations advertise an optional security requirement: REST and file services emit security: [{}, { "bearerAuth": [] }] (anonymous is acceptable, and a bearer token is honoured), and JSON-RPC methods emit x-authentication with "required": false.

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:

WorkflowMacroReason
list projects, read tasks, create tasksrest_service!conventional JSON resources, OpenAPI, browser clients
upload and download task attachmentsfile_service!streaming, multipart validation, early auth checks
live task activityjsonrpc_bidirectional_service!typed WebSocket notifications
command-heavy workflowsjsonrpc_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. UNAUTHORIZED is fully public, and OPTIONAL_AUTH is public but hands the handler a Caller so it can tailor the response when a valid credential is present (see Auth In The API Contract).

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.3.0", default-features = false }
ras-file-macro = { version = "0.2.0", default-features = false }
ras-jsonrpc-bidirectional-macro = { version = "0.2.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.2.0", optional = true }
ras-rest-core = { version = "0.2.0", optional = true }
ras-file-core = { version = "0.2.0", optional = true }
ras-jsonrpc-bidirectional-server = { version = "0.2.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.2.0"
ras-rest-core = "0.2.0"
ras-file-core = "0.2.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.3.0", default-features = false }
ras-jsonrpc-types = "0.2.0"
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.2.0", 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,
        OPTIONAL_AUTH feed(FeedRequest) -> FeedResponse,
        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. The auth requirement is one of UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS([...]); see Auth In The API Contract.

Service Options

Optional service-level fields (alongside service_name / methods):

OptionDefaultMeaning
require_json_content_type: <bool>trueReject a non-application/json request with 415 before parsing.
body_limit: <bytes>2 * 1024 * 1024Maximum request body size.
openrpc: true / explorer: trueoffEmit the OpenRPC document and host the API explorer.
docs_require_auth: <bool>falseGate the explorer page and openrpc.json behind authentication (any authenticated user).
feature_gated: <bool>falseWrap the server/client in the consumer crate’s own server/client features.

The JSON-RPC endpoint always uses application/json; the default require_json_content_type check rejects a cross-origin text/plain POST (a CORS-safelisted content type that skips preflight) before the body is parsed. Set it to false only for clients that cannot send the header.

Note: the explorer and openrpc.json are served without authentication by default when enabled, exposing your method names and schemas. Set docs_require_auth: true to gate them (the RPC endpoint itself is never gated by this option), or leave the explorer disabled in production.

Because a browser top-level navigation cannot send an Authorization header, the gated explorer is only reachable in a browser under cookie auth (.auth_cookie(...)); on a bearer-only transport it is reachable only by a programmatic client that sets the header.

A malformed JSON body and authentication/authorization rejections are logged server-side via tracing. A service that declares any WITH_PERMISSIONS method (or a gated explorer) but is built without .auth_provider(...) fails build() with a clear error instead of rejecting every such call at runtime.

Implement The Generated Trait

Protected (WITH_PERMISSIONS) methods receive &AuthenticatedUser before their request payload; OPTIONAL_AUTH methods receive a ras_jsonrpc_core::Caller instead (the request is never rejected for auth reasons):

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.3.0", 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.2.0", optional = true }
ras-auth-core = { version = "0.2.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 OPTIONAL_AUTH feed() -> 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. AUTH_REQUIREMENT is one of UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS([...]) — see Auth In The API Contract. An OPTIONAL_AUTH handler receives a ras_auth_core::Caller as its first argument: the route is public, but identifies the caller when a valid credential is present.

Request Bodies And Content-Type

Endpoints that declare a body read and JSON-decode it after the auth/CSRF/permission checks succeed, so unauthenticated callers cannot make the server buffer or parse payloads. By default a request whose Content-Type is not application/json (parameters such as ; charset=utf-8 are allowed) is rejected with 415 Unsupported Media Type before the body is read. Requiring application/json forces a CORS preflight for cross-origin requests, closing the simple-request CSRF shape (a cross-origin text/plain POST). Malformed JSON is logged (category + line/column, never the value) and answered with 400; a body over the limit is 413, distinct from an unreadable stream (400).

To accept any content type — for example a device client that cannot set the header — opt out at the service level with require_json_content_type: false.

The gate only applies to endpoints that declare a request body. A bodiless mutating endpoint (e.g. POST logout() -> ()) has no body to type-check and is not gated, so its CSRF protection comes from the auth transport: a bearer token is not ambient, and cookie auth carries a mandatory CSRF header (a non-safelisted header that itself forces a preflight).

Service And Endpoint Options

Service-level options (alongside service_name / base_path / endpoints):

OptionDefaultMeaning
body_limit: <bytes>2 * 1024 * 1024Maximum request body size.
require_json_content_type: <bool>trueEnforce application/json on bodied endpoints.
serve_docs: <bool> / docs_path: "..."false / /docsHost the API explorer and openapi.json.
docs_require_auth: <bool>falseGate the docs page and openapi.json behind authentication (any authenticated user).
feature_gated: <bool>falseWrap the server/client in the consumer crate’s own server/client features.

Note: when serve_docs is enabled the docs page and openapi.json are served without authentication by default, exposing your method names, schemas, and permission requirements. Set docs_require_auth: true to gate them, or disable serve_docs in production.

docs_require_auth gates the whole explorer (page + spec) with the same credential check as your endpoints. Because a browser top-level navigation cannot send an Authorization header, the gated docs are only reachable in a browser under cookie auth (.auth_cookie(...)); on a bearer-only transport they are reachable only by a programmatic client that sets the header. Use it when the docs live behind cookie auth or should be hidden from browsers entirely.

Per-endpoint options go in a trailing { ... } block after the response type:

// 16 KiB cap and access to request headers for just this endpoint.
POST WITH_PERMISSIONS(["admin"]) devices/{id: String}(Telemetry) -> Ack {
    body_limit: 16384,
    headers: true,
}
  • body_limit: <bytes> overrides the service body limit for this endpoint.
  • headers: true passes the request axum::http::HeaderMap to the handler as an extra argument, immediately after the caller/user and before the path parameters — the way to read a custom device header or the credential presence without a separate tower layer. The map is unredacted: it still contains the caller’s Authorization, Cookie, and CSRF headers, so do not log it or forward it upstream verbatim (use ras_auth_core::redact_sensitive_headers_for_auth_transport if you need to).

Versioning

An endpoint can serve older payload shapes at legacy paths and migrate them to the canonical types. Give the endpoint a version: label and one or more versions: entries, each with its own path, request, response, and a migration: type implementing ras_rest_core::VersionMigration for both the request (legacy → canonical) and response (canonical → legacy):

POST WITH_PERMISSIONS(["admin"]) items/{id: String}(RenameItemV2) -> RenamedItemV2 {
    version: "v2",
    versions: [
        "v1" {
            path: items/{id: String}/rename,
            request: RenameItemV1,
            response: RenamedItemV1,
            migration: RenameMigration,
        },
    ],
}

Each legacy path becomes its own route sharing the endpoint’s auth level.

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.2.0", default-features = false }
ras-file-core = { version = "0.2.0", optional = true }
ras-auth-core = { version = "0.2.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 — UNAUTHORIZED, OPTIONAL_AUTH, and WITH_PERMISSIONS([...]) (see Auth In The API Contract):

UNAUTHORIZED
OPTIONAL_AUTH
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 OPTIONAL_AUTH for a public download/upload that should still recognise a signed-in caller: the route is never rejected for auth reasons, and the (optional) caller is surfaced through FileRequestContextctx.user is Some(user) for a valid credential and None otherwise — rather than as a separate Caller parameter.

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.2.0"
ras-jsonrpc-types = "0.2.0"
ras-jsonrpc-bidirectional-types = "0.2.0"
ras-jsonrpc-bidirectional-macro = { version = "0.2.0", default-features = false }
ras-jsonrpc-bidirectional-server = { version = "0.2.0", optional = true }
ras-jsonrpc-bidirectional-client = { version = "0.2.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, OPTIONAL_AUTH, and WITH_PERMISSIONS(["a"] | ["b", "c"]) style as the HTTP JSON-RPC macro (see Auth In The API Contract). An OPTIONAL_AUTH method is built from the connection’s optional user, so the server must allow anonymous connections (require_auth(false)) for anonymous callers to reach it. OPTIONAL_AUTH is not allowed on server_to_client calls — those are outbound, so there is no inbound caller to identify.

Implement And Mount The Server

Server handlers receive the connection id and connection manager. WITH_PERMISSIONS methods also receive &AuthenticatedUser; OPTIONAL_AUTH methods receive a ras_auth_core::Caller (in the same position) instead.

#[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.

Connection Security Defaults

The generated builder produces a WebSocketService. Its defaults are tuned for production, and every one of them is overridable on ras_jsonrpc_bidirectional_server::WebSocketServiceBuilder or by implementing the corresponding WebSocketService method:

SettingDefaultWhat it does
max_message_size1 MiBEnforced at the transport, so an oversized frame is rejected before it is buffered.
subscription_limits64 topics per message, 256 per connection, 256-byte names, 100 000 globallyAn over-limit Subscribe gets an invalid-params error and the connection stays open.
keepaliveping every 30 s, close after 90 s idleReclaims half-open sockets. Browsers and tungstenite answer pings automatically.
auth_revalidation_interval30 sRe-runs the auth provider on the connection’s token. Failure closes the socket.
on_permission_changeDropSubscriptionsOn successful re-validation every held subscription is re-run through authorize_subscribe; topics no longer authorized are dropped, and any topic message already queued for the socket is discarded at send time. Close closes the socket instead so the client must reconnect.
max_connections10 000Enforced atomically with a semaphore permit held for the connection’s lifetime. None lifts the cap.

All of these are also settable on the generated <Service>Builder.

Topic subscriptions are default-deny: override authorize_subscribe on the handler to allow the topics a connection is entitled to. The limits above are enforced inside ConnectionContext::subscribe itself, the only way to add a subscription, so they hold from any handler callback and with any ConnectionManager. It returns an error on refusal; the default handle_subscribe logs and continues. WITH_PERMISSIONS checks go through your provider’s check_permissions, so wildcard or hierarchical permission schemes behave the same over WebSocket as over REST.

Authenticating From A Browser

A browser WebSocket cannot set headers. The WASM client therefore offers two subprotocols on upgrade, ras-jsonrpc and token.<jwt>; the server reads the token from the second and selects the first, so the token is never echoed in the response. Native clients send Authorization: Bearer <token>. Tokens are never placed in the URL query string, and the server does not read them from there.

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.3.0", 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.2.0", default-features = false, features = ["permissions"] }
ras-jsonrpc-macro = { version = "0.3.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-core defines AuthProvider, AuthenticatedUser, AuthError, bearer/cookie transport helpers, and CSRF configuration.
  • ras-identity-core defines identity-provider traits.
  • ras-identity-local provides username/password verification with Argon2.
  • ras-identity-oauth2 provides OAuth2 with PKCE support.
  • ras-identity-session issues and verifies JWT sessions and can attach permissions to authenticated identities.

Typical Flow

  1. A public endpoint such as sign_in or an OAuth2 callback verifies an identity.
  2. The application creates a JWT session through the session crate.
  3. Protected generated services receive bearer tokens or configured secure cookies.
  4. The generated service calls the configured AuthProvider.
  5. Handler methods receive &AuthenticatedUser only 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. Cookie auth is not two independent knobs: because the browser attaches cookies automatically, cookie credentials are always paired with CSRF protection. Calling .auth_cookie(...) installs a default double-submit CsrfConfig for you, and a transport that enables cookies without a CSRF config fails to build(). Override the default with .csrf_protection(...) if you need a session-bound token or a custom header, but there is deliberately no builder path to cookie auth without CSRF.

CsrfConfig also offers two weaker modes, dangerous_header_presence_only(...) and dangerous_static_value(...). They are named that way on purpose: neither binds the CSRF token to the session, and pairing either with cookie auth emits a tracing::warn! at startup. Prefer the default double-submit cookie.

CSRF is enforced only for cookie credentials on unsafe methods (POST, PUT, PATCH, DELETE). Bearer tokens and safe methods remain exempt.

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.