diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7b07d7e82..e1adb666d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -55,6 +55,8 @@ jobs: - run: make ci-doc-paths # Fixed sleeps in tests are timing bets; poll with simple_module_test.wait_for. - run: make ci-check-test-sleeps + # `as unknown as` switches the type checker off; use typed page args / usePageProps. + - run: make ci-check-no-double-cast # The checked-in typed-key files must match what the installed modules' # catalogs emit. Catches a catalog edit committed without regenerating, and # (with the namespace guard in gen_i18n.py) a regeneration that dropped keys. @@ -170,6 +172,9 @@ jobs: cache-dependency-glob: ${{ env.UV_CACHE_GLOB }} - run: make install-py - run: make install-js + # Committed pages/api.generated.ts must match the routes (#504); needs + # both the Python app (schema) and node_modules (openapi-typescript). + - run: make ci-api-types-fresh - run: make ci-js-typecheck js-build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7318ce004..2099738a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,18 @@ All notable changes to this project are documented in this file. The format is b - `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). ### Added +- Typed API client (#504). `@simple-module-py/ui` ships `createApi()` (an + `openapi-fetch` client with same-origin credentials, `Accept: application/json` and + the CSRF header), `apiResult()` / `apiFetch()` normalising every call to + `{ ok, status, data, detail, fieldErrors, error }` (network failure: `status: 0`), + and `useApiErrorToast(fallbackKey)`. `smpy openapi gen-types [--module X]` writes + each module's `pages/api.generated.ts` from its routes; `make gen-api-types` + regenerates them and `make ci-api-types-fresh` (in `make lint` and CI) fails on a + stale one. `*.generated.ts` is exempt from Biome and the 300-line cap. New modules + ship `pages/api.ts`, the generated types and a Browse page that deletes through + the client. `TranslationKey` is exported from `@simple-module-py/i18n`. +- `csrfToken` Inertia shared prop for signed-in sessions (#504), which the API client + sends as `X-CSRF-Token` to the page's own origin. Anonymous pages never carry it. - 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`. - 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. - Server-rendered `` 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 ``; + expect(currentCsrfToken()).toBe('from-dom'); + }); +}); diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts new file mode 100644 index 000000000..4d27bd7f5 --- /dev/null +++ b/packages/ui/src/lib/api.ts @@ -0,0 +1,88 @@ +/** + * Typed JSON calls to a module's `/api` routes (#504). + * + * `createApi()` wraps `openapi-fetch` with the session conventions: + * same-origin credentials, `Accept: application/json`, and the `csrfToken` + * shared prop echoed as `X-CSRF-Token` on POST/PUT/PATCH/DELETE. Wrap a call + * in `apiResult()` for the `{ ok, status, data, detail, fieldErrors }` shape. + * `apiFetch()` is the untyped twin for routes outside the OpenAPI document + * (`/setup/*`) and multipart uploads. + */ +import createClient, { type Client, type ClientOptions, type Middleware } from 'openapi-fetch'; +import { type ApiResult, failure, NETWORK_FAILURE, readBody, success } from './api-result'; +import { currentCsrfToken } from './csrf-token'; + +export { type ApiResult, apiResult, type FieldErrors, NETWORK_FAILURE } from './api-result'; +export { currentCsrfToken, setCsrfToken } from './csrf-token'; + +const CSRF_HEADER = 'X-CSRF-Token'; +const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** The token is the session's: never hand it to another origin. */ +function sameOrigin(url: string): boolean { + const origin = globalThis.location?.origin; + try { + return !!origin && new URL(url).origin === origin; + } catch { + return false; + } +} + +function prepare(request: Request, token: () => string | null | undefined): Request { + if (!request.headers.has('Accept')) request.headers.set('Accept', 'application/json'); + if (!UNSAFE.has(request.method) || request.headers.has(CSRF_HEADER)) return request; + const value = sameOrigin(request.url) ? token() : null; + if (value) request.headers.set(CSRF_HEADER, value); + return request; +} + +export interface ApiOptions extends Omit { + /** Token source for this client; defaults to the `csrfToken` shared prop. */ + csrfToken?: () => string | null | undefined; +} + +/** An `openapi-fetch` client for a module's generated `paths`. */ +export function createApi(options: ApiOptions = {}): Client { + const { csrfToken, ...rest } = options; + const client = createClient({ + credentials: 'same-origin', + baseUrl: globalThis.location?.origin ?? '', + ...rest, + // Late-bound so a stubbed `globalThis.fetch` (tests) is honoured. + fetch: (input) => globalThis.fetch(input), + }); + const middleware: Middleware = { + onRequest: ({ request }) => prepare(request, csrfToken ?? currentCsrfToken), + }; + client.use(middleware); + return client; +} + +export interface ApiFetchInit extends RequestInit { + /** Serialised as the JSON body (sets `Content-Type`). */ + json?: unknown; +} + +/** Untyped call with the same conventions and result shape as `createApi`. */ +export async function apiFetch( + url: string, + init: ApiFetchInit = {}, +): Promise> { + const { json, ...rest } = init; + const headers = new Headers(rest.headers); + if (json !== undefined) headers.set('Content-Type', 'application/json'); + let response: Response; + try { + const request = new Request(new URL(url, globalThis.location?.href), { + credentials: 'same-origin', + ...rest, + headers, + body: json !== undefined ? JSON.stringify(json) : rest.body, + }); + response = await globalThis.fetch(prepare(request, currentCsrfToken)); + } catch { + return failure(NETWORK_FAILURE, null); + } + const body = await readBody(response); + return response.ok ? success(response.status, body as T) : failure(response.status, body); +} diff --git a/packages/ui/src/lib/csrf-token.test.ts b/packages/ui/src/lib/csrf-token.test.ts new file mode 100644 index 000000000..4c168854d --- /dev/null +++ b/packages/ui/src/lib/csrf-token.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const handlers: Record void> = {}; +vi.mock('@inertiajs/react', () => ({ + router: { + on: (name: string, fn: (e: { detail: { page: unknown } }) => void) => { + handlers[name] = fn; + }, + }, +})); + +describe('currentCsrfToken', () => { + beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = + ''; + }); + + it('reads the embedded page before any page event', async () => { + const { currentCsrfToken } = await import('./csrf-token'); + expect(currentCsrfToken()).toBe('tok'); + }); + + it('does not resurrect the embedded token after navigating to an anonymous page', async () => { + const { currentCsrfToken } = await import('./csrf-token'); + expect(currentCsrfToken()).toBe('tok'); + handlers.navigate({ detail: { page: { props: {} } } }); + expect(currentCsrfToken()).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/csrf-token.ts b/packages/ui/src/lib/csrf-token.ts new file mode 100644 index 000000000..4ff89186d --- /dev/null +++ b/packages/ui/src/lib/csrf-token.ts @@ -0,0 +1,50 @@ +/** + * The session's CSRF token as the client sees it: the `csrfToken` shared prop + * the server sends to signed-in sessions (#504). Tracked from Inertia's + * `navigate`/`success` events, with the page embedded in the document as the + * fallback before the first event fires. + */ +import { router } from '@inertiajs/react'; + +// undefined = no page event seen yet (use the embedded page); null = page has no token. +let current: string | null | undefined; +// The document's embedded page never changes: parse it once per distinct text. +let embedded: { text: string; token: string | null } | null = null; + +function tokenOf(page: unknown): string | null { + const value = (page as { props?: { csrfToken?: unknown } } | null)?.props?.csrfToken; + return typeof value === 'string' && value ? value : null; +} + +/** Override the tracked token (tests; a page that receives its own token prop). `undefined` re-enables the embedded-page fallback. */ +export function setCsrfToken(token: string | null | undefined): void { + current = token; +} + +/** The current page's `csrfToken`, or null when the server sent none (anonymous). */ +export function currentCsrfToken(): string | null { + if (current !== undefined) return current; + if (typeof document === 'undefined') return null; + const text = document.querySelector('script[data-page][type="application/json"]')?.textContent; + if (!text) return null; + if (embedded?.text !== text) { + try { + embedded = { text, token: tokenOf(JSON.parse(text)) }; + } catch { + embedded = { text, token: null }; + } + } + return embedded.token; +} + +try { + if (typeof window !== 'undefined' && typeof router?.on === 'function') { + const track = (event: { detail: { page: unknown } }) => { + current = tokenOf(event.detail.page); + }; + router.on('navigate', track); + router.on('success', track); + } +} catch { + // A test double of @inertiajs/react without `router`; the DOM fallback still works. +} diff --git a/packages/ui/src/lib/home-url.ts b/packages/ui/src/lib/home-url.ts index 8cd3895aa..a252d9885 100644 --- a/packages/ui/src/lib/home-url.ts +++ b/packages/ui/src/lib/home-url.ts @@ -1,12 +1,11 @@ -import { usePage } from '@inertiajs/react'; -import type { SharedProps } from '../types'; +import { usePageProps } from '../hooks/usePageProps'; /** * The app's home URL from the `homeUrl` shared prop (the users module's * `login_redirect_url`), or `/` when no module supplies one. */ export function useHomeUrl(): string { - const { homeUrl } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const { homeUrl } = usePageProps(); // Same-origin paths only: the value is admin-editable and now feeds hrefs. return homeUrl && /^\/(?![/\\])/.test(homeUrl) ? homeUrl : '/'; } diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 5088bbe40..ed0197392 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -46,6 +46,14 @@ export interface BrandingShared { footerLinks: { label: string; href: string }[] | null; } +export interface I18nSharedProps { + locale: string; + supportedLocales: string[]; + // ``null`` on Inertia XHR visits where the backend skipped the messages + // payload because the client already has them from the initial page load. + messages: Record | null; +} + export interface SharedProps { auth: { user: { name: string; email: string; roles: string[] } | null; @@ -58,6 +66,9 @@ export interface SharedProps { navbar: MenuItem[]; userDropdown: MenuItem[]; }; + // The session's CSRF token, sent to signed-in sessions only (#504). The API + // client (`createApi`/`apiFetch`) echoes it as `X-CSRF-Token`. + csrfToken?: string; // Injected by the users module's shared-props provider. Absent when no local // auth provider is installed (e.g. a Keycloak-only deployment), which reads // the same as "closed" — the host isn't the one taking signups. @@ -74,4 +85,16 @@ export interface SharedProps { // Injected by the branding module's shared-props provider (optional: the // module may not be installed). branding?: BrandingShared; + // Set by `InertiaLayoutDataMiddleware` on every response; optional so a + // hand-built props object (tests, error pages) need not carry it. + i18n?: I18nSharedProps; +} + +// Types `usePage().props` for the whole app: `usePage().props.auth` is a +// `SharedProps['auth']` without a cast. A mapped copy (not `SharedProps` +// itself) because Inertia requires an index signature on page props. +declare module '@inertiajs/core' { + interface InertiaConfig { + sharedPageProps: { [K in keyof SharedProps]: SharedProps[K] }; + } } diff --git a/scripts/check_file_size.py b/scripts/check_file_size.py index c5db93917..97d805b49 100644 --- a/scripts/check_file_size.py +++ b/scripts/check_file_size.py @@ -32,6 +32,9 @@ # and is rewritten on every dev-mode boot. Hand-shrinking is futile. "packages/i18n/src/*.generated.ts", "packages/i18n/src/generated-resources.ts", + # Typed API client definitions emitted by `smpy openapi gen-types` (#504), + # and any other generator output; regenerated, never edited by hand. + "**/*.generated.ts", # Alembic migrations are autogenerated from module metadata; an initial # migration covering every installed module's tables routinely exceeds # the cap and there's nothing meaningful to split out. diff --git a/scripts/check_no_double_cast.py b/scripts/check_no_double_cast.py new file mode 100644 index 000000000..0d53b8df4 --- /dev/null +++ b/scripts/check_no_double_cast.py @@ -0,0 +1,94 @@ +"""Flag ``as unknown as`` double casts in shipped TypeScript. + +A double cast turns the compiler off for that value: the type it names is a +claim, not a check. Pages take typed component arguments and components read +props through ``usePageProps`` (``packages/ui/src/hooks/usePageProps.ts``) +instead. A cast that is genuinely the only way (a third-party type with no +better seam) stays, marked on the same line or the line above: + + // cast-ok: + +Covers ``.ts``/``.tsx`` under ``modules///pages``, ``components``, ``hooks``, ``lib``, +``packages/ui/src`` and ``host/client_app``. Test files are exempt. + +Usage: + uv run python scripts/check_no_double_cast.py +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from collections.abc import Iterable +from pathlib import Path + +MARKER = "// cast-ok:" +_CAST = re.compile(r"\bas\s+unknown\s+as\b") +_TEST_NAME = re.compile(r"\.(test|spec)\.tsx?$") +_SOURCE_ROOTS = (("packages", "ui", "src"), ("host", "client_app")) + + +def is_checked(path: Path) -> bool: + """Return True for a non-test ``.ts``/``.tsx`` file in a checked directory.""" + if path.suffix not in {".ts", ".tsx"} or _TEST_NAME.search(path.name): + return False + parts = path.parts + if "tests-js" in parts or "node_modules" in parts: + return False + if any(parts[: len(root)] == root for root in _SOURCE_ROOTS): + return True + # modules////... (pages, components, hooks, lib, ...) + return len(parts) > 4 and parts[0] == "modules" and "tests" not in parts + + +def find_double_casts(text: str) -> list[tuple[int, str]]: + """Return ``(line_number, stripped_line)`` for each unmarked double cast.""" + lines = text.splitlines() + found: list[tuple[int, str]] = [] + for index, line in enumerate(lines): + if not _CAST.search(line): + continue + above = lines[index - 1] if index else "" + if MARKER in line or MARKER in above: + continue + found.append((index + 1, line.strip())) + return found + + +def _list_git_files(root: Path) -> list[Path]: + result = subprocess.run( + ["git", "ls-files", "--cached", "--others", "--exclude-standard"], + cwd=root, + capture_output=True, + text=True, + check=True, + ) + return [Path(line) for line in result.stdout.splitlines() if line] + + +def check(root: Path, paths: Iterable[Path]) -> list[tuple[Path, int, str]]: + violations: list[tuple[Path, int, str]] = [] + for rel in paths: + if not is_checked(rel) or not (root / rel).is_file(): + continue + text = (root / rel).read_text(encoding="utf-8", errors="replace") + violations.extend((rel, n, line) for n, line in find_double_casts(text)) + return violations + + +def main() -> int: + root = Path.cwd() + violations = check(root, _list_git_files(root)) + if not violations: + print("OK: no unmarked `as unknown as` casts.") + return 0 + print(f"FAIL: {len(violations)} `as unknown as` cast(s) without `{MARKER} `.") + print("Use typed page args or usePageProps, or mark a deliberate one.") + for path, number, line in violations: + print(f" {path.as_posix()}:{number}: {line}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_check_file_size.py b/scripts/tests/test_check_file_size.py index bddabb09f..6eae837a7 100644 --- a/scripts/tests/test_check_file_size.py +++ b/scripts/tests/test_check_file_size.py @@ -76,6 +76,11 @@ def test_non_shadcn_ui_is_not_exempt(self) -> None: p = Path("packages/ui/src/layouts/SidebarLayout.tsx") assert not is_exempt(p, DEFAULT_EXEMPT_GLOBS) + def test_generated_api_types_are_exempt(self) -> None: + p = Path("modules/tenants/tenants/pages/api.generated.ts") + assert is_exempt(p, DEFAULT_EXEMPT_GLOBS) + assert not is_exempt(Path("modules/tenants/tenants/pages/api.ts"), DEFAULT_EXEMPT_GLOBS) + def test_other_project_path_is_not_exempt(self) -> None: p = Path("modules/products/products/pages/Browse.tsx") assert not is_exempt(p, DEFAULT_EXEMPT_GLOBS) diff --git a/scripts/tests/test_check_no_double_cast.py b/scripts/tests/test_check_no_double_cast.py new file mode 100644 index 000000000..b656b775d --- /dev/null +++ b/scripts/tests/test_check_no_double_cast.py @@ -0,0 +1,57 @@ +"""Tests for the ``as unknown as`` double-cast lint.""" + +from __future__ import annotations + +from pathlib import Path + +from check_no_double_cast import check, find_double_casts, is_checked + + +def test_flags_unmarked_cast(): + src = "const a = 1;\nconst p = x as unknown as Props;\nconst q = y as Foo;\n" + assert [n for n, _ in find_double_casts(src)] == [2] + + +def test_flags_cast_split_with_extra_whitespace(): + assert find_double_casts("const p = x as unknown as Props;\n") + + +def test_marker_on_same_line_or_line_above_allows(): + same = "const p = x as unknown as Props; // cast-ok: third-party type\n" + above = "// cast-ok: third-party type\nconst p = x as unknown as Props;\n" + assert find_double_casts(same) == [] + assert find_double_casts(above) == [] + + +def test_marker_two_lines_above_does_not_count(): + src = "// cast-ok: stale\nconst a = 1;\nconst p = x as unknown as Props;\n" + assert [n for n, _ in find_double_casts(src)] == [3] + + +def test_is_checked_directories(): + assert is_checked(Path("modules/users/users/pages/Login.tsx")) + assert is_checked(Path("modules/users/users/pages/Users/Edit.tsx")) + assert is_checked(Path("modules/branding/branding/components/dirty.ts")) + assert is_checked(Path("modules/users/users/hooks/useThing.ts")) + assert is_checked(Path("modules/users/users/auth_local/components/AuthIntro.tsx")) + assert is_checked(Path("modules/users/users/lib/format.ts")) + assert is_checked(Path("packages/ui/src/layouts/SidebarLayout.tsx")) + assert is_checked(Path("host/client_app/i18n.ts")) + + +def test_is_checked_excludes_tests_and_other_dirs(): + assert not is_checked(Path("packages/ui/src/layouts/useDrawerA11y.test.tsx")) + assert not is_checked(Path("modules/users/tests-js/Login.test.tsx")) + assert not is_checked(Path("modules/users/users/service.py")) + assert not is_checked(Path("packages/i18n/src/index.ts")) + + +def test_check_reports_path_and_line(tmp_path: Path): + page = tmp_path / "modules/m/m/pages/A.tsx" + page.parent.mkdir(parents=True) + page.write_text("const p = x as unknown as Props;\n") + test = tmp_path / "packages/ui/src/a.test.tsx" + test.parent.mkdir(parents=True) + test.write_text("const p = x as unknown as Props;\n") + found = check(tmp_path, [page.relative_to(tmp_path), test.relative_to(tmp_path)]) + assert found == [(Path("modules/m/m/pages/A.tsx"), 1, "const p = x as unknown as Props;")] diff --git a/skills/simple-module-conventions/SKILL.md b/skills/simple-module-conventions/SKILL.md index 6eca3b89b..1c4323ac2 100644 --- a/skills/simple-module-conventions/SKILL.md +++ b/skills/simple-module-conventions/SKILL.md @@ -89,9 +89,10 @@ Starlette's default `SameSite=Lax` session cookie isn't attached to cross-site P from simple_module_hosting.csrf import RequiresCsrf, get_csrf_token router = APIRouter(dependencies=[Depends(RequiresCsrf())]) -# expose get_csrf_token(request) as a view prop; fetch() callers echo it as X-CSRF-Token ``` +Signed-in sessions receive the token as the `csrfToken` shared prop, and the module's typed client (`pages/api.ts`: `createApi()` from `@simple-module-py/ui/lib/api`) echoes it as `X-CSRF-Token` on unsafe same-origin calls — page code adds nothing. Call `/api/*` from pages through that client (types from `smpy openapi gen-types`), not raw `fetch()`; `apiFetch` covers routes outside the module's schema. + Safe methods are never checked, and bare test apps without `SessionMiddleware` are exempt. ### 7. Don't call `session.commit()` in service code diff --git a/skills/simple-module-creating/SKILL.md b/skills/simple-module-creating/SKILL.md index 7c1dcc805..44899b38e 100644 --- a/skills/simple-module-creating/SKILL.md +++ b/skills/simple-module-creating/SKILL.md @@ -52,6 +52,8 @@ modules/orders/ ├── deps.py # FastAPI dependencies ├── endpoints/{api,views}.py ├── pages/{Browse,Create,Edit}.tsx # *.tsx, auto-discovered by Vite + ├── pages/api.ts # typed /api client: createApi() + ├── pages/api.generated.ts # from `smpy openapi gen-types`; never edit └── locales/en.json ``` diff --git a/skills/simple-module-inertia-pages/SKILL.md b/skills/simple-module-inertia-pages/SKILL.md index b34a694b3..ee10a5f5e 100644 --- a/skills/simple-module-inertia-pages/SKILL.md +++ b/skills/simple-module-inertia-pages/SKILL.md @@ -37,6 +37,8 @@ Boot regenerates it too; mid-session adds need the manual call before HMR sees t | `menus` | `{ sidebar, adminSidebar, navbar, userDropdown }` | All modules' `register_menu_items()` output, auth/role-filtered | | `i18n` | `{ locale, supportedLocales, messages }` | Active locale (`request.state.locale`) + flattened message bundle for that locale (`messages` is `null` on Inertia XHR partials when the locale is unchanged) | +**Typing them in TSX.** A page receives its own props as typed component arguments (`export default function Browse({ items }: Props)`). Shared props are typed globally: `usePage().props.auth` is already a `SharedProps['auth']`. Components and layouts that need page props too use `usePageProps()` from `@simple-module-py/ui` (it returns `Props & SharedProps & { errors }`). Never write `usePage<{ props: X }>().props as unknown as X`; `make lint` rejects `as unknown as` outside tests unless the line carries `// cast-ok: `. + **`auth.user` is `None` when no `principal_serializer` is registered**, even if the user is authenticated. The framework can't know the shape of your user object. The `auth` module registers the callable during `register_settings(app)` (`modules/auth/auth/module.py`): ```python @@ -75,19 +77,26 @@ Inertia's `router.post(...)` / `.patch(...)` / `.put(...)` / `.delete(...)` expe Two fixes, in preference order: 1. **Stay Inertia-native** — point the call at a *view* endpoint (under `view_prefix`, e.g. `/orders/...`) that returns `RedirectResponse(..., status_code=303)`. Inertia follows the redirect and re-renders the page. -2. **Need the JSON payload back** — use plain `fetch()` against the `/api/*` endpoint instead of Inertia's `router`: +2. **Need the JSON payload back** — call the `/api/*` endpoint through the module's typed client, `pages/api.ts`, instead of Inertia's `router` or a raw `fetch()`: ```tsx -await fetch("/api/orders", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), -}); +import { apiResult } from "@simple-module-py/ui/lib/api"; +import { api } from "./api"; // export const api = createApi(); + +const result = await apiResult(api.POST("/api/orders/", { body: payload })); +if (!result.ok) return setError(result.detail ?? t(keys.orders.errors.generic)); +result.data; // typed from the route's response_model ``` `SM018` warns when it spots Inertia `router.{post,patch,put,delete}()` targeting `/api/*` paths. -Two things to know about that `fetch()` path: errors from `/api/*` come back as JSON (`{"detail": ...}`) rather than the HTML error page, so read `resp.json().detail` for the real reason; and if the module's router opted into `RequiresCsrf`, include the token from the view's props as an `X-CSRF-Token` header or the mutation is rejected with 403. +The typed client (#504): + +- **Types are generated.** `pages/api.generated.ts` comes from the module's routes — run `smpy openapi gen-types --module ` (`make gen-api-types` in the framework repo) after changing an endpoint or DTO, and commit it. Never edit it. A route without `response_model` (or a return annotation) generates an `unknown` body. +- **One result shape.** `apiResult(...)` resolves to `{ ok, status, data, detail, fieldErrors, error }`: `detail` is the server's string `detail` (a message or a stable code), `fieldErrors` maps a 422's last `loc` element to its `msg`, and a network failure is `status: 0` — it never throws. `useApiErrorToast(fallbackKey)` toasts `detail ?? t(fallbackKey)`. +- **CSRF is automatic.** Signed-in sessions get a `csrfToken` shared prop; the client sends it as `X-CSRF-Token` on POST/PUT/PATCH/DELETE to the same origin, so `RequiresCsrf` routes just work. Anonymous pages get no token. +- **Off-schema calls** (another module's route, a server-supplied URL, multipart uploads, `/setup/*`) use `apiFetch(url, { method, json })` — same conventions and result shape, untyped. +- **Reuse generated DTOs** for prop types: `type Item = components["schemas"]["OrderOut"]` (see *Typing them in TSX* above for how props are typed). ## Other gotchas