Resources & operations¶
Layer: Backend · data & CRUD (the foundation every other guide builds on)
A resource binds one SQLAlchemy model to a router of CRUD operations — the foundation fields, permissions, actions, and search and filters all build on.
How it works¶
The scope tree is fixed — project → app → resource → operation. A resource
lives in an app (a Python module); apps mount under a URL prefix in the
project. Codegen emits one router per resource into
_generated/{module}/routes/{resource}.py, plus a pytest file when
generate_tests: true.
The config you write¶
You write — be/config/resources/task.jsonnet (the resource object):
local list = import "be/operations/list.libsonnet";
local resource = import "be/resources/presets.libsonnet";
{
model: "fsh.models.Task", // SQLAlchemy class — the only required key besides `operations`
pk: { name: "id", type: "uuid" }, // default { id, uuid }
route_prefix: "/tasks", // default /{model_slug}s
generate_tests: true, // emit a pytest file (default false)
search: {}, // opt list `q` + `_values` into trigram search — see search-and-filters.md
representations: [ /* named dump shapes — see fields.md */ ],
representation_roles: { default: "default", opa: "authz" }, // which rep plays each role
// Order-preserving op entries keyed by `name`. Reads take a `representation`,
// writes take `fields`. The list route is POST /search (payload rides in the body).
operations: [
// GET /{pk} — point read; `representation` picks the dump shape.
{ name: "get", representation: "resource" },
// POST /search — built via the list.searchable preset (detail in search-and-filters.md).
list.searchable(
representation="list_item",
filters=[
{ name: "id", values: "self" },
{ name: "completed", values: "bool" },
{ name: "project_id", values: "ref", ref_resource: "project" },
],
order=["title"], // sortable fields — independent of filterability
default_order="title",
),
// POST / — body from `fields`. 201 with empty body unless `representation`
// is set to echo the written row (FE drops it into cache, no follow-up GET).
{
name: "create",
rate_limit: "10/minute", // per-op override; requires project.rate_limit
pre: "tracker.hooks.normalise_task_title_on_create", // async (body, *, db) -> body; create/update only
representation: "default",
fields: [
{ name: "title", type: "str" },
{ name: "description", type: "str", nullable: true },
{ name: "project_id", type: "uuid" },
],
},
// PATCH /{pk} — partial. 200 empty body unless `representation` set.
// (`post` is the write-side hook: async (row, *, db, session) -> None on create/update/delete.)
{
name: "update",
pre: "tracker.hooks.normalise_task_title_on_update",
fields: [
{ name: "title", type: "str" },
{ name: "description", type: "str", nullable: true },
],
},
// DELETE /{pk} — 204, no body.
{ name: "delete" },
// A verb beyond CRUD, dispatched to a consumer fn. `can` is an async
// (resource, session) -> bool: 403 on False AND the visibility gate that
// drops the action from a row's `actions`. See actions.md.
resource.action(
name="complete",
fn="tracker.actions.complete_task",
can="tracker.guards.can_complete_task",
),
],
}
ResourceConfig is extra="forbid" — a typo’d key errors at load time rather
than being silently dropped.
You write — be/config/tracker.jsonnet (the app: a Python module bundling resources):
{
version: "1",
module: "tracker", // Python package the code lands in: _generated/tracker/routes/task.py
resources: [
import "resources/project.jsonnet",
import "resources/task.jsonnet",
import "resources/user.jsonnet",
// ...
],
}
You write — be/config/project.jsonnet (mount apps under a prefix):
apps: [
// task.create ends up at POST /v1/tracker/tasks/ =
// /v1 (project mount) + /tracker (app prefix) + /tasks (route_prefix).
{ config: import "tracker.jsonnet", prefix: "/tracker" },
{ config: import "inventory.jsonnet", prefix: "/inventory" },
// fsh.app(...) bundles cross-cutting platform resources into one entry.
// `owner_field` emits a Rego `view` row filter keyed off the column — it
// REQUIRES an `opa` rep that includes that field (enforced at load time).
fsh.app({
saved_views: { serializer: "fsh.serializers.dump_view",
owner_guard: "fsh.guards.is_view_owner", // in-process write gate (`can`)
owner_field: "owner_id",
reorder_fn: "fsh.actions.reorder_views" },
notification_preferences: {},
report_definitions: { owner_guard: "fsh.guards.is_report_owner", owner_field: "owner_id" },
report_runs: { owner_guard: "fsh.guards.is_run_owner", owner_field: "owner_id" }, // read-only
}),
],
Reusable shorthands from be/resources/presets.libsonnet collapse whole resources:
local resource = import "be/resources/presets.libsonnet";
// id+name catalogue (roles, departments, locations): three id+name reps + five
// CRUD ops, a `self` filter, `name` search. `extra_fields` append a column;
// `can` gates the writes; `searchable=false` drops the `search` block.
resource.nameDimension("fsh.models.Role", "/roles")
// S3 upload flow: a get of the FileMixin columns + four file-flow actions —
// POST /upload (presigned PUT), /{pk}/complete (204), /{pk}/download
// (presigned GET), /{pk}/delete-file (cascade, 204).
resource.files()
// Owner-scoped platform presets, wired through fsh.app(...) above:
// saved_views(...) = 5 CRUD + optional reorder action
// report_definitions(...) = CRUD
// report_runs(...) = read-only (get + list), runs are platform-produced
// List read-scoping is the OPA row filter, not `can` (owner_guard is
// object-based). See reports.md and permissions.md.
The frontend codegen emits¶
Resources have no bespoke frontend — each surfaces through the generated TypeScript client: types, an SDK, and TanStack Query hooks keyed off the resource’s cache key. Regenerated every run.
Generated — fe/src/_generated/sdk/task.gen.ts (do not edit):
import { request } from "../client.gen";
/** Create Task (POST /v1/tracker/tasks/) */
export function createTask(options: CreateTaskOptions): Promise<DefaultTaskResource> {
return request<DefaultTaskResource>({
method: "POST", url: "/v1/tracker/tasks/", body: options.body,
});
}
/** List Tasks (POST /v1/tracker/tasks/search) */
export function listTasks(options: ListTasksOptions): Promise<TaskPage> {
return request<TaskPage>({
method: "POST", url: "/v1/tracker/tasks/search", body: options.body,
});
}
// ...plus getTask, updateTask, deleteTask, taskCompleteAction, getTaskValues.
Generated — fe/src/_generated/queries/task.gen.ts (do not edit):
// TanStack Query hooks; the queryKey root ["tasks"] is the resource cache key.
export function useListTasks(options: ListTasksOptions) { /* useQuery(listTasksQueryOptions(...)) */ }
export function useGetTaskSuspense(options: GetTaskOptions) { /* useSuspenseQuery(...) */ }
A resource’s list op renders through table configuration.