Specta LogoSpecta
1.0.0-rc.x (alpha)

Procedure

Define typed resolvers, context, and errors.

A procedure is one operation on your API. It has an input type, an output type, an error type, and a resolver. Attach it to a router with .procedure("name", procedure).

These examples use the experimental source revision from the quickstart.

Define an error type

Implement rspc::Error on your application's error type:

use serde::Serialize;
use specta::Type;

#[derive(Debug, Serialize, Type)]
pub enum ApiError {
    InvalidName,
    Internal,
}

impl rspc::Error for ApiError {
    fn into_procedure_error(self) -> rspc::ProcedureError {
        rspc::ResolverError::new(self, None::<std::io::Error>).into()
    }
}

ResolverError::new takes the serializable public value first and an optional source error second. Keep database errors, credentials, and internal diagnostics out of the public value.

The error type in the generated metadata does not guarantee that every transport sends it intact. The checked Axum adapter's legacy JSON-RPC path converts resolver failures to a code and message. The tuple client does not expose your error enum as a typed result. Test the error response through your actual adapter before depending on a particular shape.

To expose a string instead of an enum, implement Type for the error with a string override and serialize it as a string. Do not export an enum type while sending unrelated string values.

Queries and mutations

use rspc::{Procedure, Router};

pub fn router() -> Router<()> {
    Router::new()
        .procedure("version", {
            Procedure::builder::<ApiError>()
                .query(|_: (), _: ()| async { Ok(env!("CARGO_PKG_VERSION")) })
        })
        .procedure("greet", {
            Procedure::builder::<ApiError>()
                .mutation(|_: (), name: String| async move {
                    if name.trim().is_empty() {
                        return Err(ApiError::InvalidName);
                    }
                    Ok(format!("Hello, {name}!"))
                })
        })
}

Use () when there is no input. The resolver returns a future containing Result<T, ApiError>.

  • Ordinary inputs implement Deserialize, Type, and Send.
  • Ordinary outputs implement Serialize, Type, Send, and Sync.
  • ResolverInput and ResolverOutput provide extension points for custom input and output handling.

Derive Type on every custom type reachable from your inputs, outputs, or errors. This rspc revision uses Specta 2.0.0-rc.22, not the API documented by the current standalone Specta guides.

Request context

The integration supplies the first resolver argument. It can hold shared state and request data:

#[derive(Clone)]
pub struct Ctx {
    pub greeting: std::sync::Arc<String>,
}

let procedure = Procedure::builder::<ApiError>()
    .query(|ctx: Ctx, name: String| async move {
        Ok(format!("{}, {name}!", ctx.greeting))
    });

Prefer context over capturing a database connection in the resolver closure. The router can then be built and checked without connecting to external services.

Shared procedure builders

Put common middleware in a helper rather than adding it to every resolver:

use rspc::{ProcedureBuilder, ResolverInput, ResolverOutput};

fn base<TInput, TOutput>() -> ProcedureBuilder<
    ApiError, Ctx, Ctx, TInput, TInput, TOutput, TOutput,
>
where
    TInput: ResolverInput,
    TOutput: ResolverOutput<ApiError>,
{
    Procedure::builder::<ApiError>()
}

The context, input, and output type pairs allow middleware to change the types seen by the resolver while preserving the types exposed by the procedure. Most applications let Rust infer these parameters.

Attach middleware with .with(...) inside the helper. Create separate helpers for public and authenticated procedures. Middleware belongs to procedure builders, not routers, in this API.

Map internal errors

Log an internal error on the server, then return a safe application error. This also works for an anyhow::Error:

fn public_error(error: anyhow::Error) -> ApiError {
    eprintln!("request failed: {error:?}");
    ApiError::Internal
}

Use .map_err(public_error)? where the fallible operation runs. Add anyhow if you use this example. Do not send error.to_string() to the client unless its contents are safe to expose.

Streams

Use .subscription(...) for a resolver that returns a stream of results. Each item is a Result<T, ApiError>:

let procedure = Procedure::builder::<ApiError>()
    .subscription(|_: (), _: ()| async {
        Ok(futures::stream::iter([
            Ok::<_, ApiError>(1_u32), Ok(2_u32), Ok(3_u32),
        ]))
    });

Add futures to use this example. The transport must support subscriptions. The tuple client's FetchTransport does not; use a compatible WebSocket transport.

Edit on GitHub

Last updated on

On this page