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.
[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.
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:
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 })))
}Userauthenticates the request as REST does: the same token, the same anonymous role, the same 401 for a token that does not verify. UseOption<User>for a route that also serves requests without a token.begin_assets the role,request.jwt.claims,request.headers,app.settings.*and the rest exactly as a REST request's transaction is set up, and callsPGRST_DB_PRE_REQUEST.begin_as_withtakesTxOptionsfor a read-only transaction or another isolation level.postrust_server::Erroranswers 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
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.