Getting started¶
End-to-end walkthrough: from an empty directory to a running app, then from the
bootstrapped template toward (a subset of) the
example app. Assumes
setup is done — fsh, uv, node/yarn, just, and just-pm
on your PATH.
The stack has four generated sides driven by jsonnet: a be FastAPI backend, a fe React/TanStack frontend, an opa Rego permissions service, and a render Hono microservice. You edit jsonnet (and a few safe-to-edit FE scaffolds); codegen emits the rest.
Scaffold a new app¶
fsh template pulls the codegen template into a directory and starts a fresh
git repo (the template’s history is dropped, so origin doesn’t point at it):
fsh template sample
cd sample
uv sync
uv sync puts the codegen CLI on PATH via the harness pyproject.toml.
Note
The template’s deps point at sibling codegen* checkouts (editable). fsh template auto-detects whether those siblings are present. Force it either way:
--editable builds from adjacent checkouts; --no-editable writes a
git-ignored .env with UV_NO_SOURCES=1 so uv pulls the published packages
from CodeArtifact instead.
To run multiple stacks in parallel, bake a port/DB offset in at pull time:
fsh template sample --offset 100
--offset N writes .env so every dev port shifts by N and the app targets
the app_N database. It’s frozen at pull time — changing it later means editing
.env and re-running DB setup.
Bootstrap the four sides¶
The freshly-pulled directory holds only the four bootstrap.jsonnet files (one
per side) plus the harness. just bootstrap scaffolds each side into be/,
fe/, opa/, render/:
just bootstrap
It runs the four *_root generators (be_root, fe_root, opa,
render_root), then just-pm sync to vendor the shared just modules, then a
per-side setup + lint-fix pass:
// be/bootstrap.jsonnet — the be_root toggles that decide what gets scaffolded.
{
name: "codegen-template-app",
module: "app",
opentelemetry: true, // telemetry block + init_telemetry(app)
files: true, // boto3 + fsh_lib.files upload helpers
auth: true, // auth.py stubs + auth.jwt(...) block
psycopg: true,
codegen_database: true,
pgqueuer: true, // background worker recipes
rate_limit: true, // rate_limit.slowapi(...) block
comms: true, // comms platform (requires pgqueuer)
fsh: true, // /fsh platform app: saved views, opt-ins
opa: true, // OPA gate + per-resource Rego schemas
samples: true, // a runnable User + Category CRUD out of the box
links: true, // /l/<code> short-link redirector
reports: true, // saved + system reports
oauth: true, // "Sign in with Google" (OIDC)
}
The four sides:
be — FastAPI + SQLAlchemy. Walks
be/config/project.jsonnet→ each app’s config and emits the API tree underbe/_generated/.fe — React + TanStack. Parses the BE’s
openapi.jsonand emits the typed client + hooks underfe/src/_generated/.opa — Rego permissions service; rules and per-resource JSON schemas sync from the backend’s emitted contract (see permissions).
render — Hono microservice consuming the BE’s render contract (drives PDFs and templated comms output).
Every emitted file is if_exists="skip", so re-running just bootstrap is a
no-op until you pass --force to the underlying CLI. To see what a re-run
would change (templates drift silently under skip):
just bootstrap-diff # or: just bootstrap-diff --check (CI gate)
Generate the contract surface¶
First run — bootstrap then regenerate every contract surface in one shot:
just fresh # = bootstrap + spec
After any *.jsonnet config edit, regenerate the BE + FE generated code:
just generate # aliases: just g / just gen -> be gen + fe gen
just validate # check the be + fe configs against their schemas
just generate covers in-repo generated code. The downstream contract
surfaces (OpenAPI, TS client, OPA sync, render regen) have their own recipes:
just spec # BE codegen -> specs/openapi.json -> FE TS client
just opa-spec # BE codegen -> opa sync + verify
just render-spec # BE codegen -> render regen
Warning
The FE yarn openapi-ts step (which produces the actual TypeScript client)
reads be/specs/openapi.json, which only exists once the BE has emitted it.
just spec handles the ordering; if you run the FE client step by hand, make
sure the BE spec is fresh first.
Run it¶
just dev # alias: just d
Runs the BE on :8000, the FE on :5173, and the pgqueuer worker under one
process group (Ctrl-C stops all). For a parallel stack, pass a port offset:
just dev 100 # ports +100, uses the app_100 database
Sign in at http://localhost:5173/login with alice / wonderland. First run
needs the DB: cd be && just db init.
From template to example (a walkthrough)¶
A freshly bootstrapped app has empty app config — its tracker app lists
resources: [], so the BE emits only project-scope plumbing (db wiring, auth
routes, the app skeleton). Below we build it toward the
example app, each step linking
its deep-dive guide.
After every config edit, re-run just generate (and just spec when the FE
needs the refreshed client).
1. Declare project plumbing in project.jsonnet¶
be/config/project.jsonnet holds cross-cutting platform features: databases,
auth, telemetry, rate-limiting, links, OPA, comms, reports, and the apps[]
list. Each is a helper import. Databases and apps[] are the minimum:
// be/config/project.jsonnet
local auth = import "be/auth/jwt.libsonnet";
local db = import "be/db/databases.libsonnet";
local opa = import "be/opa/opa.libsonnet";
local reports = import "be/reports/reports.libsonnet";
local telemetry = import "be/telemetry/telemetry.libsonnet";
{
databases: [
db.postgres("primary", { default: true }),
],
// ...
apps: [
{ config: import "tracker.jsonnet", prefix: "/tracker" },
],
}
Layer platform features on — each one helper block, reach for the guide when you turn it on:
auth: auth.config({...})withauth.password(...)/auth.oauth(...)methods — auth.opa: opa.config({ roles_loader, bindings_loader })— permissions.comms: comms.platform({...})— typed messages, user templates, recipient groups — emails and templates.reports: reports.platform({...})withreport_fns— reports.telemetry.otel(...),rate_limit.slowapi(...),links.shortener(...)— each a single block; see infra for the deployment side.
The OPA block is just its two loaders:
// be/config/project.jsonnet — permissions wiring.
opa: opa.config({
roles_loader: "permissions.load_roles",
bindings_loader: "permissions.load_bindings",
}),
2. Add an app under a prefix¶
An app is a versioned module with a resources[] list, mounted under a URL
prefix in apps[]. The template’s app starts empty; the example’s tracker
collects the core resources:
// be/config/tracker.jsonnet
{
version: "1",
module: "tracker",
resources: [
import "resources/project.jsonnet",
import "resources/task.jsonnet",
import "resources/user.jsonnet",
import "resources/role.jsonnet",
// ...
],
}
Split large apps into more { config, prefix } entries in project.jsonnet
(the example adds inventory, purchasing, vendors). See
resources for how apps and resources compose.
3. Add your first resource¶
A resource declares its model, primary key, route prefix, one or more representations (field projections) and operations (CRUD + more). The simplest is the shared name-dimension preset — one line yields full CRUD:
// be/config/resources/role.jsonnet — id + name catalogue, full CRUD.
(import "be/resources/presets.libsonnet").nameDimension("fsh.models.Role", "/roles")
A hand-rolled resource spells out its representations and operations. The example’s project resource shows the shape — representations built from fields helpers, an enum field, and a searchable list op with filters:
// be/config/resources/project.jsonnet (trimmed)
local fields = import "be/fields.libsonnet";
local list = import "be/operations/list.libsonnet";
{
model: "fsh.models.Project",
pk: { name: "id", type: "uuid" },
route_prefix: "/projects",
search: {},
representations: [
{
name: "list_item",
fields: [
fields.id(),
{ name: "name", type: "str" },
{ name: "slug", type: "str" },
{ name: "project_type", type: "enum", enum: "fsh.models.ProjectType" },
],
},
// ...
],
representation_roles: { default: "default" },
operations: [
{ name: "get", representation: "resource" },
list.searchable(
representation="list_item",
filters=[
{ name: "id", values: "self" },
{ name: "project_type", values: "enum", enum: "fsh.models.ProjectType" },
],
order=["name"],
default_order="name",
),
{ name: "create", fields: [/* ... */] },
{ name: "update", fields: [/* ... */] },
{ name: "delete" },
],
}
The enum field’s choices come from a Python StrEnum
(fsh.models.ProjectType) — see enums. The filter kinds
(self, ref, enum, bool) are covered in
search and filters.
4. Add an action and per-op guards¶
Beyond CRUD, resources declare actions — named operations backed by a dotted
Python callable, optionally gated by a can guard. The example’s task resource
adds a complete action plus per-op controls:
// be/config/resources/task.jsonnet (operations, trimmed)
local resource = import "be/resources/presets.libsonnet";
operations: [
{
name: "create",
rate_limit: "10/minute", // per-op override
pre: "tracker.hooks.normalise_task_title_on_create", // pre-write hook
fields: [/* ... */],
},
{ name: "delete" },
resource.action(
name="complete",
fn="tracker.actions.complete_task",
// Server-side visibility gate: available_actions omits `complete`
// from a row when this returns False — the FE drops it automatically.
can="tracker.guards.can_complete_task",
),
],
Actions, hooks, and guards are the subject of actions.
5. Layer features onto resources¶
With the platform wired in step 1, opt individual resources into each feature:
Permissions — set
representation_roles: { opa: "authz" }so a chosen representation is the projection sent to OPA on each point check (permissions):// be/config/resources/task.jsonnet representation_roles: { default: "default", opa: "authz", },
Comms — a
comms_targetmakes the resource a user-template target, its representation the variable surface (emails and templates):// be/config/resources/task.jsonnet comms_target: comms.target({ representation: "comms_summary", recipient_resolvers: { watchers: "tracker.comms_resolvers.task_watchers", project_lead: "tracker.comms_resolvers.task_project_lead", }, }),
Reports —
reports.opt_in(...)withreport_type+ areport_fnsource surfaces system/saved reports on the resource (reports):// be/config/resources/project.jsonnet reports: reports.opt_in({ types: ["csv", "json"], system_reports: [ reports.system({ slug: "all_projects", name: "All projects", report_type: "csv", source: reports.list_op_source({ base_fn: "project.list" }), }), ], }),
Auditing and commenting layer on the same way (both are not yet merged).
6. Wire the frontend¶
Once the BE shape is set, regenerate and fill the safe-to-edit FE scaffolds.
The generated client + hooks land under fe/src/_generated/ (never edited);
the scaffolds under fe/src/{fields,enums,actions} and fe/src/routes.ts are
scaffolded once (if_exists="skip") and are yours to fill:
just generate && just spec # emit types + client, then the FE scaffolds
Field catalogs (fields) — one file per resource; give each field a
cell. It won’t type-check until you do:// fe/src/fields/tracker/task.tsx import { defineFields } from "@fsh/components-library"; import type { ListItemTaskResource } from "../../_generated/types/tracker/task.gen"; export const taskFields = defineFields<ListItemTaskResource>()((field) => [ field("title", { header: "Title", cell: (r) => r.title, table: { sortable: true } }), field("completed", { header: "Completed", cell: (r) => r.completed }), ]);
Enum displays (enums) — labels + badge variants, typed against the generated enum so a new backend value fails to compile until you label it:
// fe/src/enums/projectType.ts import { defineEnumDisplay } from "@fsh/components-library"; import type { ProjectType } from "../_generated/types/tracker/project.gen"; export const projectType = defineEnumDisplay<ProjectType>({ product: { label: "Product", variant: "neutral" }, internal: { label: "Internal", variant: "neutral" }, });
Action drawers (actions) — every entry defaults to
null(opted out); replace anullwith{ label, icon, onAction }(or aviewoverlay) to surface it:// fe/src/actions/tracker/task.tsx export const taskActions = defineResourceActions(taskActionCatalog, { resource: "task", object: { view: null, update: null, delete: null, complete: null }, collection: { view: null, create: null }, bulk: {}, });
Routes — register pages in
fe/src/routes.tswithdefineArea, then link them from the sidebar inShell.tsx:// fe/src/routes.ts const assets = defineArea({ list: { path: "/assets", lazy: () => import("./pages/assets/AssetListPage") }, detail: { path: "/assets/$id", lazy: () => import("./pages/assets/AssetDetailPage") }, }); const areas = mergeAreas({ assets, categories, comms, settings });
Tables — column ordering, sorting, and defaults are tuned in table configuration.
Next steps¶
resources — apps, models, representations, operations.
fields — the field helpers and representation shapes.
enums — backend
StrEnum→ FE displays and filters.search and filters —
list.searchable, filter kinds, keyset paging.actions — actions, hooks,
canguards, FE drawers.permissions — the OPA gate, roles, bindings, per-resource projections.
auth — JWT sessions, password + OAuth/OIDC login.
emails and templates — the comms platform and user templates.
reports — saved + system reports,
report_fnsources, PDF/CSV/JSON.table configuration — FE table columns, sorting, defaults.
auditing — change history (not yet merged).
commenting — per-record comments (not yet merged).
infra — deployment, environments, and operational concerns.