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,
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:

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.

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