DocsCustom Routes

Custom Routes

Add Rust handlers of your own beside Postrust's API, for webhooks, jobs and business logic, running as the caller under row-level security.

How custom routes work

Custom routes allow you to add your own endpoints alongside the auto-generated REST and GraphQL APIs. This is useful for:

  • Webhook handlers (Stripe, GitHub, etc.)
  • Custom authentication flows
  • Background job triggers
  • Custom business logic

A crate of your own

You don't fork Postrust. Write a small binary crate that depends on postrust-server, build the application with postrust_server::Builder, and nest your ownaxum routers into it. Everything the postrust binary serves is still served, configured from the samePGRST_* environment variables. It needs Rust 1.94 or later.

Cargo.toml
[dependencies]
postrust-server = "3"
postrust-core = "3"
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.9", default-features = false, features = ["postgres"] }
serde_json = "1"
anyhow = "1"

Adding routes

A nested router takes the same state as the API's own routes: the pool, the schema cache and the configuration.

Rust
use axum::{extract::State, routing::{get, post}, Json, Router};
use postrust_server::{AppState, Builder, Host};
use std::sync::Arc;

fn hooks() -> Router<Arc<AppState>> {
    Router::new()
        .route("/stripe", post(stripe))
        .route("/stats", get(stats))
}

async fn stripe(Json(event): Json<serde_json::Value>) -> Json<serde_json::Value> {
    // Verify and handle the webhook...
    Json(serde_json::json!({ "received": true }))
}

async fn stats(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
    let tables = state.schema_cache().await.tables.len();
    Json(serde_json::json!({ "tables": tables }))
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config = postrust_core::AppConfig::try_from_env()?;
    let addr = format!("{}:{}", config.server_host, config.server_port);

    let app = Builder::new(config, Host::SERVER)
        .nest("/hooks", hooks())
        .build()
        .await?;

    let listener = tokio::net::TcpListener::bind(&addr).await?;
    axum::serve(listener, app.router).await?;
    Ok(())
}

Builder::layer wraps the whole application, the API's own routes included, in a tower layer of your own: rate limiting, a header, a log line.

Querying as the caller, under RLS

state.pool connects as the authenticator role. A query sent on it directly has no row-level security and every grant the authenticator has. For a query that should see what the caller may see, take a User and begin the transaction withbegin_as:

Rust
use postrust_server::{AppState, User};

async fn my_todos(
    user: User,
    State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, postrust_server::Error> {
    let mut tx = state.begin_as(&user).await?;
    let (total,): (i64,) = sqlx::query_as("SELECT count(*) FROM todos")
        .fetch_one(&mut *tx)
        .await?;
    tx.commit().await?;
    Ok(Json(serde_json::json!({ "role": user.role(), "todos": total })))
}
  • User authenticates the request as REST does: the same token, the same anonymous role, the same 401 for a token that does not verify. Use Option<User> for a route that also serves requests without a token.
  • begin_as sets the role, request.jwt.claims, request.headers,app.settings.* and the rest exactly as a REST request's transaction is set up, and calls PGRST_DB_PRE_REQUEST. begin_as_with takesTxOptions for a read-only transaction or another isolation level.
  • postrust_server::Error answers a failure with PostgREST's status and body, so a permission the database refuses is the same 401 or 403 REST would give.

Where routes may go

build() returns an error, before connecting to anything, for a prefix that is/, contains a parameter or wildcard, or overlaps a path the server serves itself:/api, /healthz, /v1/version, /v1/graphql,/v1alpha1/graphql, /admin, /_/health, /_/ready or/_ itself. Two prefixes you nest may not overlap either. Beneath /_ is yours: .nest("/_/hooks", router) serves next to the health checks.

In compatibility mode REST is also served at /, and a nested route comes first: a prefix of /orders answers in place of the orders table, which is still at/api/orders. Postrust logs a warning at start-up when that happens.

Running it

Shell
PGRST_DB_URI=postgres://... PGRST_JWT_SECRET=... cargo run --release
curl -X POST localhost:3000/hooks/stripe -d '{"type":"charge.succeeded"}'

A complete, compiling example with a row-level security policy is inexamples/custom-routes.