# 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](fields.md), [permissions](permissions.md), [actions](actions.md), and [search and filters](search-and-filters.md) 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): ```jsonnet 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): ```jsonnet { 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): ```jsonnet 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: ```jsonnet 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): ```typescript import { request } from "../client.gen"; /** Create Task (POST /v1/tracker/tasks/) */ export function createTask(options: CreateTaskOptions): Promise { return request({ method: "POST", url: "/v1/tracker/tasks/", body: options.body, }); } /** List Tasks (POST /v1/tracker/tasks/search) */ export function listTasks(options: ListTasksOptions): Promise { return request({ 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): ```typescript // 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](table-configuration.md).