Custom Handlers

A custom endpoint declares handler: (and optionally method: / path:) instead of using one of the conventional CRUD actions (list / get / create / update / delete / bulk_create / bulk_delete). Custom handlers own their own request parsing and response generation — the framework gives you the route binding and authentication, but the rest is your code.

# resources/agents.yaml
endpoints:
  regenerate_secret:
    method: POST
    path: /agents/:id/regenerate_secret
    auth: [super_admin, admin]
    handler: regenerate_secret

handler: is rejected on convention action keys. list, get, create, update, delete are dispatched by the runtime CRUD path. Declaring handler: on one of these keys is a validation error caught by shaperail check, with a fix suggestion that renames the key to a non-convention action (e.g. post_<resource>) and pins method: / path: explicitly. To customize standard CRUD behavior without replacing the runtime path, use controller: { before: ... } / controller: { after: ... } instead.


What custom handlers do NOT inherit

Unlike CRUD endpoints, custom handlers do not get:

  • Automatic input validation against the resource’s schema: and the endpoint’s input:.
  • Automatic tenant isolation (WHERE tenant_key = $tenant).
  • Automatic event emission (events:).
  • The full before/after controller pipeline. Specifically:
    • controller: { before: <name> } IS supported (v0.11.1+; see below).
    • controller: { after: <name> } is a validation error — custom handlers own their response shape, so there is no data: envelope for the runtime to merge ctx.response_extras into. Put after-logic inside the handler itself.

You write that logic explicitly inside the handler.

Reading the request body

Custom handlers receive the request body as actix_web::web::Bytes stashed in the request extensions — not via req.take_payload(). The runtime extracts the body up front (so actix doesn’t drop it from ServiceRequest.payload) and inserts it under web::Bytes:

use actix_web::{HttpRequest, HttpResponse, web};

pub async fn create_journal_entry(
    req: HttpRequest,
    state: actix_web::web::Data<std::sync::Arc<shaperail_runtime::handlers::crud::AppState>>,
    _resource: std::sync::Arc<shaperail_core::ResourceDefinition>,
    _endpoint: std::sync::Arc<shaperail_core::EndpointSpec>,
) -> HttpResponse {
    // HttpRequest is !Send. Extract from req synchronously, move owned data
    // into the async block.
    let body = req
        .extensions()
        .get::<web::Bytes>()
        .cloned()
        .unwrap_or_default();
    if body.is_empty() {
        return HttpResponse::BadRequest().finish();
    }
    let payload: serde_json::Value = match serde_json::from_slice(&body) {
        Ok(v) => v,
        Err(e) => return HttpResponse::BadRequest().json(serde_json::json!({"error": e.to_string()})),
    };
    // ... use `state.pool` and `payload` ...
    HttpResponse::Created().finish()
}

req.take_payload() and req.payload() do not work in a Shaperail custom handler. actix-web only extracts the request payload when an extractor is declared in the dispatch closure’s argument list. The runtime declares body: web::Bytes there and stashes the result; manually re-reading the underlying stream returns Payload::None.

For bodies larger than 256 KB (actix’s default PayloadConfig limit) the request fails with 413 before the handler runs. Configure a larger limit at the app level if you need bigger payloads — that’s app-level configuration outside the framework’s scaffold.

Path parameters

Custom-endpoint paths use Express-style :name segments. Every named segment is converted to actix’s {name} syntax at registration time, so any number of params with any names will match and be captured under their declared names:

endpoints:
  webhook:
    method: POST
    path: /vendors/:vendor_id/webhook/:webhook_path_token
    auth: [public]
    handler: receive_webhook

Inside the handler, read params from req.match_info() using their declared names:

let vendor = req.match_info().get("vendor_id").unwrap_or("");
let token = req.match_info().get("webhook_path_token").unwrap_or("");

If the endpoint also declares controller: { before: ... }, the same params are mirrored into ctx.path_params: HashMap<String, String> for use during the controller phase.

Routes that worked before v0.14.1 still work. Earlier versions only converted the literal token :id; any other named param (:vendor_id, :slug, :account_number) was left as a literal segment and the route silently 404’d. The conversion is now general for any Rust-style identifier.

Authenticating and tenant-scoping queries

Use Subject from shaperail_runtime::auth. CRUD endpoints get tenant scoping for free; custom handlers must apply it explicitly because the framework cannot infer your data flow.

use actix_web::{HttpRequest, HttpResponse};
use shaperail_runtime::auth::Subject;
use sqlx::{Postgres, QueryBuilder};

pub async fn regenerate_secret(
    req: HttpRequest,
    state: actix_web::web::Data<std::sync::Arc<shaperail_runtime::handlers::crud::AppState>>,
    path: actix_web::web::Path<uuid::Uuid>,
) -> HttpResponse {
    // 1. Authenticate.
    let subject = match Subject::from_request(&req) {
        Ok(s) => s,
        Err(_) => return HttpResponse::Unauthorized().finish(),
    };

    // 2. Build a tenant-scoped UPDATE.
    let agent_id = path.into_inner();
    let new_hash: &str = ""; // computed earlier
    let mut q = QueryBuilder::<Postgres>::new("UPDATE agents SET mcp_secret_hash = ");
    q.push_bind(new_hash);
    q.push(" WHERE id = ");
    q.push_bind(agent_id);
    if subject.scope_to_tenant(&mut q, "org_id").is_err() {
        return HttpResponse::Unauthorized().finish();
    }

    // 3. Execute and respond.
    match q.build().execute(&state.pool).await {
        Ok(res) if res.rows_affected() == 1 => HttpResponse::Ok().finish(),
        Ok(_) => HttpResponse::NotFound().finish(),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

scope_to_tenant:

  • Is a no-op for super_admin (no filter applied — full visibility).
  • Appends " AND <column> = $N" with the bound tenant_id for any other role.
  • Returns Err(Unauthorized) for a non-super_admin subject whose JWT carries no tenant_id claim. That case is a config error and must fail loudly — never silently scope to “no filter.”

For post-fetch checks (read-then-validate flows), use assert_tenant_match(record_tenant_id) instead.

Auto-populating tenant context via controller: { before: ... }

If you declare a before: controller on a custom endpoint, the runtime runs the same hook pipeline as CRUD endpoints get and stashes the resulting Context into the request’s actix extensions. Your handler can read tenant_id, user, session, and response_extras from there — without manually calling Subject::from_request:

# resources/agents.yaml
endpoints:
  regenerate_secret:
    method: POST
    path: /agents/:id/regenerate_secret
    auth: [super_admin, admin]
    controller: { before: prepare_secret_rotation }
    handler: regenerate_secret
// resources/agents.controller.rs — runs before the handler
pub async fn prepare_secret_rotation(ctx: &mut Context) -> ControllerResult {
    // ctx.tenant_id is already populated; use it for cross-handler logic.
    // Stash anything you want the handler to see in ctx.session.
    ctx.session.insert("rotation_started_at".into(), json!(chrono::Utc::now()));
    Ok(())
}

// resources/agents.handlers.rs — runs after the before-controller
pub async fn regenerate_secret(
    req: HttpRequest,
    state: Arc<AppState>,
    _resource: Arc<ResourceDefinition>,
    _endpoint: Arc<EndpointSpec>,
) -> HttpResponse {
    use shaperail_runtime::handlers::controller::Context;
    let ctx = req.extensions().get::<Context>().cloned();
    let tenant = ctx.as_ref().and_then(|c| c.tenant_id.as_deref());
    // ... use tenant for SQL scoping ...
    HttpResponse::Ok().finish()
}

after: controllers are NOT supported on custom endpoints. The custom handler owns the response shape — there is no data: envelope for the runtime to merge response_extras into. If your logic needs an after-pass, factor it into a helper called from the handler.

Sharing logic across custom handlers

For endpoints without a before-controller, share logic the normal Rust way: extract a helper function in resources/<name>.handlers.rs and call it from each handler. The framework’s job is to give you Subject and the runtime’s AppState; your job is to use them.

What if I want CRUD-style hooks?

Use a CRUD endpoint and the controller: { before, after } declaration. The two-phase pipeline plus Context.session and Context.response_extras cover most “I need to mint a one-time value” cases without writing a custom handler at all. See Controllers.


Back to top

Shaperail documentation lives in the same repository as the framework so every release has versioned instructions. See the latest release for the most recent version.

This site uses Just the Docs, a documentation theme for Jekyll.