rorpc-macros 0.2.5

Procedural macros for rorpc — thin bridge over rorpc-parse
Documentation

rorpc-macros

Crates.io Documentation License

Procedural macro bridge for rorpc — thin wrappers over rorpc-parse.

Overview

This crate contains only proc-macro entry points. All parsing, validation, and code generation logic lives in rorpc-parse where it can be tested with normal #[test] functions.

The entire implementation is a single lib.rs file with five proc macros that delegate to rorpc-parse.

Macros

#[contract]

Automatically generate TypeScript contract before fn main() runs (debug builds only). Replaces manual generate_contract() boilerplate.

Configure the output path in Cargo.toml:

[package.metadata.rorpc]
client_path = "../client/src/rpc/bindings.ts"
#[rorpc::contract]
#[tokio::main]
async fn main() {
    let app = rorpc::router!(state);
    axum::serve(listener, app).await.unwrap();
}

Supported syntaxes:

  • #[contract] — reads [package.metadata.rorpc] client_path from Cargo.toml
  • #[contract("../client/bindings.ts")] — string literal path
  • #[contract(CLIENT_PATH)] — constant
  • #[contract(concat!(...))] — concat expression

See docs/metadata-bridge.md for setup options.

Method-Specific Shorthands

Concise syntax for common HTTP methods:

use axum::{extract::State, Json};

#[rorpc::post("/planet/list")]
async fn list_planets(State(db): State<Db>) -> Json<Vec<Planet>> {
    Json(db.list().await)
}

#[rorpc::get("/planet/{id}")]
async fn find_planet(
    State(db): State<Db>,
    Path(id): Path<i32>,
    Query(q): Query<FindQuery>,
) -> Result<Json<Planet>, AppError> {
    db.find(id, q).await.map(Json).ok_or(AppError::NotFound)
}

Available methods: get, post, put, patch, delete

Optional attributes:

  • data — SSE data payload type for streaming handlers (e.g. data = "StreamEvent")

#[rorpc::namespace]

Group related handlers under a common path prefix using an inline module:

// planet.rs

#[rorpc::namespace("/planet")]
pub mod routes {
    use super::*;

    #[rorpc::get("/list")]     // Becomes /planet/list
    pub async fn list(State(db): State<Db>) -> Json<Vec<Planet>> {
        Json(db.list().await)
    }

    #[rorpc::get("/{id}")]     // Becomes /planet/{id}
    pub async fn find(State(db): State<Db>, Path(id): Path<i32>) -> Result<Json<Planet>, AppError> {
        db.find(id).await.map(Json).ok_or(AppError::NotFound)
    }
}

Rules:

  • Prefix must start with /
  • Prefix cannot contain .. path traversal
  • Namespace concatenates with handler path: /api + /status = /api/status
  • Requires inline module — file modules and inner attributes don't work on stable Rust

Supports nested namespaces:

#[rorpc::namespace("/api")]
pub mod api {
    #[rorpc::namespace("/v1")]
    pub mod v1 {
        use super::*;
        
        #[rorpc::get("/status")]  // Becomes /api/v1/status
        pub async fn status() -> Json<&'static str> {
            Json("ok")
        }
    }
}

#[rorpc::route]

Explicit method + path syntax:

#[rorpc::route(method = "POST", path = "/planet/list")]
async fn list_planets(State(db): State<Db>) -> Json<Vec<Planet>> {
    Json(db.list().await)
}

Required attributes:

  • method — HTTP method ("GET", "POST", etc.)
  • path — Route path (e.g. "/planet/list")

Optional attributes:

  • data — SSE data payload type for streaming handlers

router!

Auto-discovery macro that builds an Axum Router from all annotated handlers using the inventory crate.

use rorpc::router;

// All handlers, no state
let app = router!();

// With state
let app = router!(db);

// Module filtering
let app = router!("handlers::planet");
let app = router!(["handlers::planet", "api::v1"]);
let app = router!("handlers::{planet,user}");  // brace expansion
let app = router!("handlers::*");              // wildcard

// Filtering + state (any order)
let app = router!("handlers::planet", db);
let app = router!(db, "handlers::planet");

#[derive(ZodTs)]

Generate a fn zod_ts() -> String method that returns TypeScript Zod schemas. The generated schema is registered via inventory for contract generation.

use rorpc::ZodTs;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, ZodTs)]
pub struct Planet {
    pub id: i32,
    #[zod(min_length(1), max_length(100))]
    pub name: String,
    pub description: Option<String>,
}

Supported #[zod(...)] attributes:

  • Strings: min_length(n), max_length(n), length(n), email, url, regex("pattern"), starts_with("s"), ends_with("s"), includes("s")
  • Numbers: min(n), max(n), int, positive, negative, nonnegative, nonpositive, finite
  • Arrays: min_length(n), max_length(n), length(n)

#[derive(OrpcErrors)]

Register error enum variants for TypeScript contract generation. Variant names are converted to SCREAMING_SNAKE_CASE.

use rorpc::OrpcErrors;

#[derive(OrpcErrors)]
pub enum AppError {
    NotFound,                           // → NOT_FOUND: {}
    Conflict { reason: String },        // → CONFLICT: { data: z.object({...}) }
    DatabaseError(String),              // → DATABASE_ERROR: { data: z.string() }
}

Installation

This crate is typically used via the rorpc facade crate:

[dependencies]
rorpc = "0.1"

Or add it directly (not recommended):

[dependencies]
rorpc-macros = "0.1"

Architecture

rorpc-macros (proc-macro bridge, lib.rs only)
     └── rorpc-parse (all implementation, fully testable)
           └── syn 3.0, quote, proc-macro2, inventory

Why the split?

  • Proc-macro crates can't have normal #[test] functions
  • All logic in rorpc-parse can be unit-tested
  • rorpc-macros is just thin TokenStream conversion wrappers