Skip to content

Commit 162072a

Browse files
authored
Typed API client (openapi-fetch + generated types) and typed Inertia page props (#504 #505) (#576)
* docs: batch 4 design (typed API client, typed page props) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * Gate: reject unmarked 'as unknown as' in shipped TS; docs and skill stop teaching the cast (#505) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * Typed page props: usePageProps, SharedProps registered with Inertia, remove double casts (#505) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * feat(ui): typed API client — createApi, apiResult, apiFetch, useApiErrorToast (#504) createApi<paths>() wraps openapi-fetch with same-origin credentials, Accept: application/json and the csrfToken shared prop as X-CSRF-Token on unsafe methods. apiResult()/apiFetch() normalise to { ok, status, data, detail, fieldErrors, error }, network failures as status 0. openapi-typescript is pinned as a packages/ui devDependency so it can use TypeScript 5 without displacing the repo's TypeScript 7. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * feat(hosting): csrfToken shared prop for signed-in sessions (#504) Anonymous pages (and therefore any page a module may mark cacheable) never carry it, and nothing is minted into their session. API-token principals render as anonymous and get none either. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * feat(cli): smpy openapi gen-types + gen-api-types / ci-api-types-fresh (#504) Cuts each module's /api operations (and only the component schemas they reach) out of the in-memory schema and runs openapi-typescript into <pkg>/pages/api.generated.ts. Without --module it regenerates modules that already have the file; it never writes outside the project. Operations whose JSON success body has no schema are listed as untyped. ci-api-types-fresh hashes the generated files before/after like ci-i18n-fresh; it runs in make lint and the js-typecheck CI job. *.generated.ts is exempt from Biome and the 300-line cap. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * refactor(tenants, api_tokens): typed API client and typed page props (#504) tenants: 10 fetch calls move to api.* (8 typed, 2 through apiFetch: the settings module's route and the server-supplied multipart upload URL); useTenantErrors.describe takes an ApiResult and keeps each call's own offline copy. apiDetail.ts is gone. api_tokens: postJson becomes typed api.POST calls that still pin the page's csrf_token prop; the unused api_url prop is dropped. Pages take typed component args instead of the usePage() double cast. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix(ui): never send the CSRF token to another origin (#504) apiFetch and the createApi middleware attached X-CSRF-Token whatever the destination, so an absolute or protocol-relative URL (or a foreign baseUrl) would hand the session's token to that origin. The header is now set only when the request's origin equals location.origin. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * feat(cli): scaffolded modules ship the typed API client (#504) Module templates (base and framework_repo) add pages/api.ts, a sample pages/api.generated.ts matching what gen-types emits for the CRUD routes (pinned by test_cli_gen_types_scaffold), generated DTO types for page props, and a Browse delete action through api.DELETE. make new-module regenerates the types once the module is installed; smpy create-module prints the gen-types command (the module is not importable yet there). Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * docs: typed API client, csrfToken prop, untyped endpoint list (#504) Untyped /api endpoints (JSON success body without a schema), 15 in all: AuditLog: GET /api/audit_log/export.csv Branding: GET /api/branding/logo, /logo-dark, /favicon FileStorage: GET files/{id}/thumbnail, files/{id}/download, public/{id}/thumbnail, public/{id}/{filename}, public/{id} Users: DELETE auth/token; POST auth/forgot-password, auth/reset-password, auth/request-verify-token; GET auth/{provider}/login, /callback Details in docs/superpowers/specs/2026-10-22-untyped-endpoints.md. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * test(cli): keep the scaffold drift test's boot out of the repo (#504) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * test(hosting): strict=False pin follows the shared openapi app builder (#504) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix(ui): csrf token tracker no longer resurrects the embedded token after an anonymous navigation (#504) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * test(ui): csrf test resets use undefined (#504) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * perf(ui): resolve csrf token only for unsafe same-origin requests; cache embedded parse (#504) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix: address code review findings (round 1, pass 1) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * Fix main's new pages/tests for typed-client gates (typed args instead of double casts) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
1 parent 0f848c3 commit 162072a

120 files changed

Lines changed: 4108 additions & 693 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/pr.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ jobs:
5555
- run: make ci-doc-paths
5656
# Fixed sleeps in tests are timing bets; poll with simple_module_test.wait_for.
5757
- run: make ci-check-test-sleeps
58+
# `as unknown as` switches the type checker off; use typed page args / usePageProps.
59+
- run: make ci-check-no-double-cast
5860
# The checked-in typed-key files must match what the installed modules'
5961
# catalogs emit. Catches a catalog edit committed without regenerating, and
6062
# (with the namespace guard in gen_i18n.py) a regeneration that dropped keys.
@@ -170,6 +172,9 @@ jobs:
170172
cache-dependency-glob: ${{ env.UV_CACHE_GLOB }}
171173
- run: make install-py
172174
- run: make install-js
175+
# Committed pages/api.generated.ts must match the routes (#504); needs
176+
# both the Python app (schema) and node_modules (openapi-typescript).
177+
- run: make ci-api-types-fresh
173178
- run: make ci-js-typecheck
174179

175180
js-build:

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ All notable changes to this project are documented in this file. The format is b
1717
- `host/client_app` no longer re-declares `@simple-module-py/ui`'s runtime dependencies, nor `@base-ui/react`, `@radix-ui/react-slot`, `autoprefixer` or `postcss` (nothing loaded them) (#521).
1818

1919
### Added
20+
- Typed API client (#504). `@simple-module-py/ui` ships `createApi<paths>()` (an
21+
`openapi-fetch` client with same-origin credentials, `Accept: application/json` and
22+
the CSRF header), `apiResult()` / `apiFetch()` normalising every call to
23+
`{ ok, status, data, detail, fieldErrors, error }` (network failure: `status: 0`),
24+
and `useApiErrorToast(fallbackKey)`. `smpy openapi gen-types [--module X]` writes
25+
each module's `pages/api.generated.ts` from its routes; `make gen-api-types`
26+
regenerates them and `make ci-api-types-fresh` (in `make lint` and CI) fails on a
27+
stale one. `*.generated.ts` is exempt from Biome and the 300-line cap. New modules
28+
ship `pages/api.ts`, the generated types and a Browse page that deletes through
29+
the client. `TranslationKey` is exported from `@simple-module-py/i18n`.
30+
- `csrfToken` Inertia shared prop for signed-in sessions (#504), which the API client
31+
sends as `X-CSRF-Token` to the page's own origin. Anonymous pages never carry it.
2032
- Resource-scoped access (#526): `simple_module_core.resources` (`ResourcePolicy` with `filter`/`can`, `ResourcePrincipal`, `ResourceType`, `ResourceTypeRegistry`) and a new `register_resource_types` hook (read at `app.state.sm.resource_types`); `simple_module_db.resource_policy.OwnedResourcePolicy` (owner OR public-for-view OR an injected `share_exists`, with a keyword-required bypass permission) so lists and `paginate()` counts are filtered in SQL; `simple_module_hosting.resource_deps.RequiresResource(policy, action, loader, permission=None)` loads rows only through the policy: invisible → 404, visible but not allowed → 403, and admits API tokens only when `permission=` is set. See `docs/framework/resource-access.md`.
2133
- New optional `sharing` module (`simple_module_sharing`, #526): tenant-scoped `sharing_resource_share` table (migration `f733c7938fd1`, a new root and head labelled `sharing`), `share_exists_for(...)` for `OwnedResourcePolicy`, grant/revoke/list API under `/api/sharing/shares` (validates the resource type, the granter's `share` right on the row and the grantee via the user directory, which on a multi-tenant app must be a member of the active tenant: same error as an unknown id, and labels only for members; new core seam `simple_module_core.tenancy.tenant_member_ids` / `app.state.tenant_members`, published by `tenants`; expiry checked in SQL; revoke hard-deletes) and an admin page at `/admin/sharing/` (en + de). **Release note:** register a PyPI pending publisher for `simple_module_sharing` before the next release.
2234
- Server-rendered `<head>` for Inertia pages (#535 step 1): `inertia.render(..., head=Head(title, description, canonical, robots, og, twitter, json_ld))` puts the tags in the first response so crawlers and link unfurlers see them without JS; they are also sent as the `_head` prop and re-emitted on client visits via Inertia's `serverHead` (`@simple-module-py/ui/lib/server-head`). URL fields are http(s)-only, JSON-LD cannot close its `<script>`. New `public_base_url` host setting (DB-backed, read per request, no restart) builds canonical / `og:url`; the request `Host` is never used. Both `index.html` templates updated. Body SSR remains open in #535.
@@ -144,6 +156,15 @@ All notable changes to this project are documented in this file. The format is b
144156
it, which removes the `users` ↔ `audit_log` import cycle.
145157

146158
### Changed
159+
- tenants and api_tokens pages call their APIs through the typed client and take
160+
typed component props (#504). **Behaviour change:** the api_tokens pages no longer
161+
receive the unused `api_url` prop.
162+
- Typed page props (#505): `@simple-module-py/ui` registers `SharedProps` (now including `i18n`) with
163+
Inertia, so `usePage().props.auth` is typed, and exports `usePageProps<T>()` for components and
164+
layouts. Pages take typed component arguments. The `as unknown as` casts in module pages, `packages/ui`
165+
and `host/client_app` are gone, and `make lint` / CI reject new ones (`ci-check-no-double-cast`)
166+
unless marked `// cast-ok: <reason>`. `docs/frontend/shared-props.md` and the
167+
`simple-module-inertia-pages` skill no longer teach the cast.
147168
- **Behaviour change:** bulk and Core DML writes on audited models now produce
148169
audit entries (actions `bulk_*`), in requests and Celery workers. Statements
149170
that set only excluded columns (the outbox, `TaskExecution.heartbeat_at`) are

‎CLAUDE.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Everything goes through `make`. Full list: `docs/reference/make-commands.md`.
3737
| `make migrations-roundtrip-pg` | Postgres portability gate (upgrade → downgrade base → upgrade → `alembic check`); CI runs it |
3838
| `make gen-pages` | Regenerate `host/client_app/modules.{manifest.json,generated.ts,generated.css}` (gitignored; needed before `tsc`/Vite) |
3939
| `make gen-i18n` | Regenerate `packages/i18n/src/{keys.generated,generated-resources}.ts` (committed; CI diffs them) |
40+
| `make gen-api-types` | Regenerate each module's `pages/api.generated.ts` from its routes (committed; CI diffs via `ci-api-types-fresh`) |
4041
| `make new-module name=<name>` | Scaffold a module package (see § Adding a module for what it does *not* do) |
4142
| `make openapi` | Export `openapi.json` for typed clients |
4243
| `make worker` / `make beat` | Celery worker / beat for `background_tasks` |
@@ -85,6 +86,9 @@ Run what CI runs, in this order; a red push costs a review cycle.
8586
drop a namespace (an unsynced venv looks like a deleted module);
8687
`make gen-i18n ARGS=--allow-removals` is only for a module you really
8788
removed or a deliberate `SM_MODULES_ENABLED` subset.
89+
Changed an endpoint's response shape in a module that has `pages/api.generated.ts`?
90+
`make gen-api-types` and commit it. Don't write `as unknown as` in shipped
91+
TS (`ci-check-no-double-cast`); mark an unavoidable one `// cast-ok: <reason>`.
8892
5. Added a module, a table or a migration? Also run `make doctor`, and review
8993
the revision per `docs/module-authoring.md` § Reviewing a revision
9094
autogenerated on SQLite (boolean `server_default` as `sa.true()/sa.false()`,
@@ -97,9 +101,9 @@ Run what CI runs, in this order; a red push costs a review cycle.
97101
changes). Every feature/fix PR in recent history does this.
98102

99103
CI (`.github/workflows/pr.yml`, aggregate job `pr-checks`): python-lint (includes
100-
`make ci-check-metadata` and the `make ci-i18n-fresh` diff), python-typecheck,
104+
`make ci-check-metadata` and the `make ci-i18n-fresh` diff and `make ci-check-no-double-cast`), python-typecheck,
101105
python-tests (Redis service), js-tests, js-lint (`biome ci --error-on-warnings`,
102-
plus the untranslated check), js-typecheck, js-build (`make ci-css-build`),
106+
plus the untranslated check), js-typecheck (plus `make ci-api-types-fresh`), js-build (`make ci-css-build`),
103107
migrations-roundtrip-pg, doctor (`make ci-doctor`: migrate a throwaway SQLite DB,
104108
then `make doctor`), e2e-smoke, perf-guards, file-size-check, package-build. CI
105109
calls the same `make ci-*` targets as `make lint`, with `UV_SYNC_FLAGS=--locked`

‎Makefile‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history migrations-roundtrip-pg docker-up docker-down kill new-module gen-pages openapi gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated ci-check-metadata ci-check-locales ci-doc-paths ci-docs-build ci-i18n-fresh ci-css-build ci-doctor ci-build-packages worker beat worker-docker
1+
.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history migrations-roundtrip-pg docker-up docker-down kill new-module gen-pages openapi gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-test-sleeps ci-check-no-double-cast ci-check-hardcoded-strings ci-check-untranslated ci-check-metadata ci-check-locales ci-doc-paths ci-docs-build ci-i18n-fresh ci-api-types-fresh gen-api-types ci-css-build ci-doctor ci-build-packages worker beat worker-docker
22

33
# UV_SYNC_FLAGS is empty locally; CI passes --locked so a stale uv.lock fails the build.
44
UV_SYNC_FLAGS ?=
@@ -30,6 +30,11 @@ dev-ui:
3030
openapi:
3131
uv run --project host smpy openapi export --out openapi.json
3232

33+
# Regenerate <pkg>/pages/api.generated.ts for modules that have one (#504);
34+
# start a new module's with ARGS="--module <name>".
35+
gen-api-types:
36+
uv run --project host smpy openapi gen-types $(ARGS)
37+
3338
# Regenerate host/client_app/modules.{manifest.json,generated.ts,generated.css} from installed modules.
3439
gen-pages:
3540
uv run --project host smpy host gen-pages --host-dir=host/client_app
@@ -124,7 +129,7 @@ loadtest: ## Run locust against a server already on $(LOCUST_H
124129
loadtest-memray: ## Start uvicorn under memray, load-test, emit flamegraph
125130
scripts/loadtest_memray.sh $(LOCUST_ARGS)
126131

127-
lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-metadata ci-check-locales ci-doc-paths ci-i18n-fresh ci-check-file-size ci-check-test-sleeps ci-check-hardcoded-strings ci-check-untranslated
132+
lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-metadata ci-check-locales ci-doc-paths ci-i18n-fresh ci-api-types-fresh ci-check-file-size ci-check-test-sleeps ci-check-no-double-cast ci-check-hardcoded-strings ci-check-untranslated
128133

129134
# Kept granular so pr.yml can run them in parallel.
130135
ci-python-lint:
@@ -184,6 +189,17 @@ ci-i18n-fresh:
184189
exit 1; \
185190
fi
186191

192+
# Same idea for the typed API client (#504): every module that opted in with a
193+
# committed <pkg>/pages/api.generated.ts must match what its routes emit now.
194+
ci-api-types-fresh:
195+
@before=$$(find modules -path '*/pages/api.generated.ts' -type f | sort | xargs -r cat | sha256sum); \
196+
uv run --project host smpy openapi gen-types || exit 1; \
197+
after=$$(find modules -path '*/pages/api.generated.ts' -type f | sort | xargs -r cat | sha256sum); \
198+
if [ "$$before" != "$$after" ]; then \
199+
echo "pages/api.generated.ts was stale and has been regenerated: run 'make gen-api-types' and commit the result"; \
200+
exit 1; \
201+
fi
202+
187203
# Drives a real gen-pages + vite build to prove module CSS and cross-module npm
188204
# imports resolve. Needs node_modules; slow and rewrites generated files, so it
189205
# is not part of `lint`.
@@ -200,6 +216,11 @@ ci-check-file-size:
200216
ci-check-test-sleeps:
201217
uv run python scripts/check_test_sleeps.py
202218

219+
# Fail on `as unknown as` in shipped TS (pages, components, packages/ui, host client)
220+
# without `// cast-ok: <reason>`; use typed page args or usePageProps instead.
221+
ci-check-no-double-cast:
222+
uv run python scripts/check_no_double_cast.py
223+
203224
# Enforce that permissions, role names, Inertia page ids, and module dependency
204225
# names are declared as named constants rather than hardcoded string literals.
205226
ci-check-hardcoded-strings:
@@ -268,6 +289,8 @@ new-module: ## Scaffold a new module (usage: make new-module nam
268289
uv sync --all-packages
269290
npm install
270291
$(MAKE) gen-i18n
292+
@# Replace the scaffold's sample types with what the module's routes emit.
293+
-uv run --project host smpy openapi gen-types --module $(name)
271294

272295
# Kill dev servers
273296
kill:

‎biome.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"framework/hosting/tsconfig.json",
1212
"!.claude",
1313
"!host/client_app/modules.generated.ts",
14+
"!**/*.generated.ts",
1415
"!host/client_app/modules.manifest.json",
1516
"!host/client_app/modules.generated.css",
1617
"!host/client_app/modules.assets.json"

‎docs/frontend/inertia.md‎

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Inertia sends the request with the `X-Inertia` header and expects an Inertia res
7171
This is exactly what diagnostic `SM018` warns about: an `api.py` route that returns JSON, called from a page via `router.post/patch/put/delete`. Either:
7272

7373
- Change the endpoint to live in `views.py` and return `inertia.render(...)` or a redirect.
74-
- Or change the page code to use `fetch(...)` for a true JSON call.
74+
- Or change the page code to call the endpoint through the module's typed API client (see [JSON calls from pages](#json-calls-from-pages)).
7575

7676
## Redirect-after-post
7777

@@ -129,7 +129,7 @@ Every Inertia response includes:
129129
}
130130
```
131131

132-
Accessed from any page via `usePage().props.auth` etc. (the exported `SharedProps` type from `@simple-module-py/ui` covers `auth` + `menus`); translations come from `useT()` in `@simple-module-py/i18n`. See [Shared props & layout](/frontend/shared-props).
132+
Accessed from any page via `usePage().props.auth` etc. (typed globally from `SharedProps` in `@simple-module-py/ui`, which covers `auth`, `menus` and `i18n`); translations come from `useT()` in `@simple-module-py/i18n`. See [Shared props & layout](/frontend/shared-props).
133133

134134
## Frontend runtime setup
135135

@@ -243,10 +243,70 @@ landed owns its own `app.tsx` and must add the call itself.
243243

244244
## CSRF
245245

246-
There is no explicit CSRF token middleware. Protection comes from `SameSite=Lax` on the session cookie:
246+
The baseline is `SameSite=Lax` on the session cookie:
247247

248248
- Browsers don't attach the session cookie to **cross-site** POST/PUT/DELETE.
249249
- A forged form-submit from another origin arrives without authentication, so it's 401/403.
250250
- Same-site form submissions work normally.
251251

252-
This is sufficient because Inertia mutations go through `fetch`, which honors `SameSite`. Raw `fetch()` calls in page code don't need a CSRF header.
252+
Inertia forms need nothing more. A module that wants defence in depth puts
253+
`RequiresCsrf` from `simple_module_hosting.csrf` on its API router; the check
254+
then wants the session's token in an `X-CSRF-Token` header on
255+
POST/PUT/PATCH/DELETE (safe methods are never checked).
256+
257+
Every **signed-in** page carries that token as the `csrfToken` shared prop.
258+
Anonymous pages never do (nothing is minted into their session, so a public
259+
page stays cacheable and never hands one visitor's token to the next), and an
260+
API-token principal renders as anonymous. The typed API client below reads the
261+
prop and sends the header itself, only to the page's own origin, so page code
262+
never handles the token. A page may still receive its own token prop (the
263+
api_tokens pages do) and pin it per call with `headers`.
264+
265+
## JSON calls from pages
266+
267+
Each module calls its `/api/*` routes through a typed client (#504):
268+
269+
```ts
270+
// <pkg>/pages/api.ts
271+
import { createApi } from '@simple-module-py/ui/lib/api';
272+
import type { paths } from './api.generated';
273+
274+
export const api = createApi<paths>();
275+
```
276+
277+
```tsx
278+
import { apiResult } from '@simple-module-py/ui/lib/api';
279+
import { api } from './api';
280+
281+
const result = await apiResult(
282+
api.POST('/api/tenants/{tenant_id}/switch', { params: { path: { tenant_id: id } } }),
283+
);
284+
if (!result.ok) toast.error(result.detail ?? t(keys.tenants.errors.generic));
285+
```
286+
287+
- `createApi<paths>()` is an [`openapi-fetch`](https://openapi-ts.dev/openapi-fetch/)
288+
client: paths, parameters, bodies and responses are checked against the
289+
module's routes. It sends `credentials: 'same-origin'`, `Accept:
290+
application/json` and the `csrfToken` prop.
291+
- `apiResult(call)` normalises any call to `{ ok, status, data, detail,
292+
fieldErrors, error }`. `detail` is the server's string `detail` (a message or
293+
a stable error code), `fieldErrors` maps each 422 error's last `loc` element
294+
to its `msg`, `error` is the raw error body (for codes that carry more, like
295+
a limit), and a request that never reached the server is `status: 0` instead
296+
of a thrown exception. The wrapper holds no English text.
297+
- `useApiErrorToast(fallbackKey)` (`@simple-module-py/ui/hooks/use-api-error-toast`)
298+
returns a function that toasts `detail ?? t(fallbackKey)` for a failed result.
299+
- `apiFetch<T>(url, { method, json })` is the untyped twin with the same
300+
conventions and result shape, for routes outside the module's schema (another
301+
module's route, a server-supplied URL, `/setup/*`) and multipart uploads.
302+
303+
`pages/api.generated.ts` is generated, committed and never edited: run
304+
`smpy openapi gen-types --module <pkg>` (in the framework repo `make
305+
gen-api-types`, which regenerates every module that has the file) after
306+
changing a route or DTO. It needs Node: a local `openapi-typescript` is used
307+
when installed, otherwise `npx` fetches the pinned version. A route without
308+
`response_model` or a return annotation generates an `unknown` body, and the
309+
command lists it. CI fails when a committed file is stale
310+
(`make ci-api-types-fresh`). New modules from `smpy create-module` /
311+
`make new-module` ship the client, the generated file and a Browse page that
312+
deletes through it.

‎docs/frontend/pages.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ export default function Browse({ orders }: BrowseProps) {
9090
```
9191

9292
- `export default` is **required** — Inertia's resolver expects a default export.
93-
- Props are passed from the server; type them explicitly.
94-
- Use `useT()` from `@simple-module-py/i18n` for translations and `usePage()` (with the `SharedProps` type from `@simple-module-py/ui`) for `auth` / `menus`.
93+
- Props are passed from the server; type them as the component's argument (`function Browse({ items }: Props)`).
94+
- Use `useT()` from `@simple-module-py/i18n` for translations. `usePage().props.auth` / `.menus` are already typed (shared props); a component that also needs the page's own props calls `usePageProps<Props>()` from `@simple-module-py/ui`.
9595

9696
## Subdirectories
9797

0 commit comments

Comments
 (0)