WARNING: These docs are a work in progress! Some sections are incomplete or incorrect.

Procedure

A procedure represents a single operation which can be executed on your server. You define procedures which are collected up into a router.

Procedure setup

The following code can be copied as the base setup for defining procedures. Although this may look like a lot of boilerplate, any non-trivial application will end up with all of these components.

use serde::Serialize;
use specta::Type;
use thiserror::Error;
use rspc::{Error2, ResolverError};
 
#[derive(Clone)]
pub struct Ctx {}
 
#[derive(Debug, Error, Serialize, Type)]
pub enum Error {}
 
impl Error2 for Error {
    fn into_resolver_error(self) -> rspc::ResolverError {
        ResolverError::new(500, self.to_string(), None::<std::io::Error>) // TODO: Shuffle error into last param?
    }
}
 
pub struct BaseProcedure<TErr = Error>(PhantomData<TErr>);
impl<TErr> BaseProcedure<TErr> {
    pub fn builder<TInput, TResult>() -> ProcedureBuilder<TErr, Ctx, Ctx, TInput, TResult>
    where
        TErr: Error2,
        TInput: ResolverInput,
        TResult: ResolverOutput<TErr>,
    {
        Procedure2::builder() // You add default middleware here
    }
}

Defining a procedure

The following code shows how to define a procedure and attach it to a router. rspc allows many procedures to be attached to a single router which allows logical grouping of procedures such as by feature or domain model.

use rspc::Router2;
 
pub fn mount() -> Router2<Ctx> {
    Router2::new()
        .procedure("version", {
            <BaseProcedure>::builder().query(|_, _: ()| async move { Ok(env!("CARGO_PKG_VERSION")) })
        })
}

Using custom types

For rspc to be able to convert your types into Typescript they must implement the specta::Type trait. Specta is a crate that was created so that rspc can introspect Rust types. The Type trait allows the Typescript exporter to understand the fields, generics and dependant types of a Rust type.

The easiest way to implement the specta::Type trait is by using the specta::Type derive macro. We have already implemented most in-built types if you can find a missing one open a GitHub Issue.

use specta::Type;
 
#[derive(Type)]
pub struct MyStruct {
    pub name: String,
    pub age: i32,
}
 
#[derive(Type)]
pub enum MyEnum {
    SomeVariant,
    // It is import MyStruct also implements `Type` or this will not work
    AnotherVariant(MyStruct),
}

Request Context

When calling execute on a operation you must provide a request context. The type of the request context must match the TCtx generic parameter defined on the rspc::Router.

Using request context is important because it means you can construct the router without a dependency on anything (such a database) which allows you to validate the router in a unit test. The routes are stringly typed so we can't just rely on Rust's compiler to validate the router. This tradeoff was made for the superior developer experience as we believe using request context and a unit test for validating the router is able to mitigate the risk.

A request context is created on every request and can hold any data the user wants. The request context also abstracts the underlying transport layer such as HTTP, Websocket or Tauri so that the router can be agonistic to which one is being used.

struct MyCtx {
    db: Arc<Database>,
    some_value: &'static str
}
 
// Axum shown here as an example. This could be any transport.
fn main() {
    let db = Arc::new(Database::new());
 
    // Setup your rspc router to take your custom context type
    let router = Router::<MyCtx>::new()
        .query("myQuery", |t| t(|ctx, input: ()| {
            assert_eq!(ctx.some_value, "Hello World");
        }))
        .build();
 
    axum::Router::new()
        // Attach the rspc router to your axum router
        // The closure you provide is used to create a new request context for each request
        .route("/rspc/:id",
            router
                .endpoint(move || MyCtx {
                    db: db.clone(),
                    some_value: "Hello World",
                })
                .axum()
        )
}

Capturing variables

rspc allows for capturing variables in the closure of a procedure. This is generally fround upon as it put a requirement on that value when creating the router which could limit your ability to unit test the router. More of the logic behind this is explained in request context section below. This is a general rule and you will likely find exceptions.

// NOT-RECOMMEND - Capturing variables
// You should avoid providing having arguments to your mount function
pub(crate) fn mount(db: DatabaseConn) -> Router {
    // The `move` on the next line is the best indication that you are capturing variables.
    <Router>::new().query("getUsers", move |t| {
        t(move |_, _: ()| async move { db.users().find_all().exec().await })
    });
}
 
 
// RECOMMEND - Using Request Context
struct MyCtx { db: DatabaseConn }
 
pub(crate) fn mount() -> Router {
    Router::<MyCtx>::new().query("getUsers", |t| {
        t(|ctx: MyCtx, _: ()| async move { ctx.db.users().find_all().exec().await })
    });
}

Error handling

rspc procedures have to return the type Result<T, rspc::Error> where T can be any type which can be returned from a normal procedure.

The fact that Rust as a language currently requires the error type to be concrete makes error handling slightly annoying. All of the error handling done by rspc relys on the question mark operator (?) in Rust to make a reasonable developer experience. The question mark operator will expand into something along the lines of return Err(From::from(err)) under the hood. This means for any type T if you implement From<T> for rspc::Error you will be able to rely on the question mark operator to convert it into an rspc::Error type.

use rspc::{Error, Router};
 
let router = <Router>::new()
    .query("ok", |t| {
        t(|_, args: ()| {
            // Rust infers the return type is `Result<String, rspc::Error>`
            Ok("Hello World".into())
        })
    })
    .query("err", |t| {
        t(|_, args: ()| {
            // Rust is unable to infer the `Ok` variant of the result.
            // We use the `as` keyword to tell Rust the type of the result.
            // This situation is rare in real world code.
            Err(Error::new(
                ErrorCode::BadRequest,
                "This is a custom error!".into(),
            )) as Result<String, _ /* Rust can infer the error type */>
        })
    })
    .query("errWithCause", |t| {
        t(|_, args: ()| {
            some_function_returning_error().map_err(|err| {
                Error::with_cause(
                    ErrorCode::BadRequest,
                    "This is a custom error!".into(),
                    // This error type implements `std::error::Error`
                    err,
                )
            })
        })
    })
    .build();

TODO

  • Put recommended file names on the code snippets???
  • Go through and breakdown the generics/parts. What traits the input/return types need, etc.
  • Example using anyhow
  • Example exposing strings to the frontend
  • Show examples extending BaseProcedure
  • Why not middleware on router?
  • Error handling
Edit on GitHub

Last updated on

On this page