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 writebe/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 writebe/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 writebe/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.

Generatedfe/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.

Generatedfe/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.