From 255e10cb21c7fa22394d812a58e64e50bfeb85c4 Mon Sep 17 00:00:00 2001 From: Ryth Azhur Date: Sun, 10 May 2026 00:05:18 -0400 Subject: [PATCH] =?UTF-8?q?mizan-fastapi=20e2e=20=E2=80=94=20example=20app?= =?UTF-8?q?=20+=20Playwright=20harness,=2014/14=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstration milestone. The substrate work earlier in the session established that mizan-fastapi can dispatch RPC, bundle context fetches, and emit invalidation envelopes via TestClient (in-process ASGI). This commit closes the demonstration gap: a real FastAPI server on port 8001 + a real React harness on port 5175 + Playwright in real Chromium, exercising generated hooks. What ships: backends/mizan-fastapi/src/mizan_fastapi/cli.py — schema-export CLI: - `python -m mizan_fastapi.cli ` imports the named module (triggering @client decorations + register() side effects), then prints the OpenAPI schema to stdout. Mirrors mizan-django's `manage.py export_mizan_schema` so the codegen consumes either backend the same subprocess way. backends/mizan-django/generate/generator/lib/fetch.mjs — codegen now dispatches on source.django vs source.fastapi. Refactored the subprocess plumbing into a shared runSubprocess helper. The codegen package is still named "mizan-django" by historical accident — it's the framework-agnostic CLI now (a rename for later). backends/mizan-fastapi/src/mizan_fastapi/executor.py — bug fix: mizan_core's @client decorator normalizes auth=True to meta['auth']='required'. The executor's match was only handling True, not 'required', so any auth-required endpoint failed with INTERNAL_ERROR. Now matches both. Caught when wiring up the FastAPI example backend's whoami fixture; would have surfaced first time any real FastAPI app used auth=True. backends/mizan-fastapi/tests/test_dispatch.py — added AuthTests covering the auth=True path so the bug fix has unit coverage. Suite now 12/12. examples/fastapi-react-site/ — parallel to examples/django-react-site/: - backend/main.py: FastAPI app with 11 @client fixtures matching the harness surface (echo, add, multiply, whoami, staff/superuser/ verified-only, notImplementedFn, buggyFn, permissionCheckFn, current_user context). Drops Django-only stuff (forms, channels, ws-whoami, session-bound JWT). - harness/: vite proxy → FastAPI on 8001; generated api/ produced by the codegen against fastapi.config.mjs. - mizan.spec.ts: Playwright suite, 14 tests covering the same axes as Django minus channel-chat. - ContextCurrentUser fixture renders 'loading' until data arrives rather than emitting
null
— fixes a race the Django harness has too (just doesn't trip in practice). Verified: - mizan-fastapi unit: 12/12 (incl. new auth=True coverage) - mizan-fastapi e2e: 14/14 (Playwright via real Chromium) - mizan-core unit: 15/15 - mizan-django unit: 348 pass, 21 skip - AFI conformance: 3/3 - mizan-django e2e: 14/15 (1 skip — channels, deferred) What remains for FastAPI side: - Dockerfile.test + docker-compose.test.yml so CI can run the e2e in the same containerized way as the Django example. - Makefile test-integration target for symmetry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../generate/generator/lib/fetch.mjs | 131 +-- .../mizan-fastapi/src/mizan_fastapi/cli.py | 45 ++ .../src/mizan_fastapi/executor.py | 2 +- backends/mizan-fastapi/tests/test_dispatch.py | 14 + examples/fastapi-react-site/.gitignore | 2 + examples/fastapi-react-site/backend/main.py | 167 ++++ .../fastapi-react-site/backend/pyproject.toml | 16 + .../harness/fastapi.config.mjs | 19 + .../fastapi-react-site/harness/index.html | 5 + .../fastapi-react-site/harness/package.json | 22 + .../harness/src/api/contexts/global.ts | 15 + .../harness/src/api/functions/add.ts | 9 + .../harness/src/api/functions/buggyFn.ts | 9 + .../harness/src/api/functions/echo.ts | 9 + .../harness/src/api/functions/multiply.ts | 9 + .../src/api/functions/notImplementedFn.ts | 9 + .../src/api/functions/permissionCheckFn.ts | 9 + .../harness/src/api/functions/staffOnly.ts | 9 + .../src/api/functions/superuserOnly.ts | 9 + .../harness/src/api/functions/verifiedOnly.ts | 9 + .../harness/src/api/functions/whoami.ts | 9 + .../harness/src/api/index.ts | 19 + .../harness/src/api/react.tsx | 169 ++++ .../harness/src/api/schema.json | 756 ++++++++++++++++++ .../harness/src/api/types.ts | 607 ++++++++++++++ .../harness/src/fixtures.tsx | 132 +++ .../fastapi-react-site/harness/src/main.tsx | 13 + .../fastapi-react-site/harness/tsconfig.json | 11 + .../fastapi-react-site/harness/vite.config.ts | 11 + examples/fastapi-react-site/mizan.spec.ts | 139 ++++ examples/fastapi-react-site/package.json | 12 + .../fastapi-react-site/playwright.config.ts | 14 + 32 files changed, 2359 insertions(+), 52 deletions(-) create mode 100644 backends/mizan-fastapi/src/mizan_fastapi/cli.py create mode 100644 examples/fastapi-react-site/.gitignore create mode 100644 examples/fastapi-react-site/backend/main.py create mode 100644 examples/fastapi-react-site/backend/pyproject.toml create mode 100644 examples/fastapi-react-site/harness/fastapi.config.mjs create mode 100644 examples/fastapi-react-site/harness/index.html create mode 100644 examples/fastapi-react-site/harness/package.json create mode 100644 examples/fastapi-react-site/harness/src/api/contexts/global.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/add.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/buggyFn.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/echo.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/multiply.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/notImplementedFn.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/permissionCheckFn.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/staffOnly.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/superuserOnly.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/verifiedOnly.ts create mode 100644 examples/fastapi-react-site/harness/src/api/functions/whoami.ts create mode 100644 examples/fastapi-react-site/harness/src/api/index.ts create mode 100644 examples/fastapi-react-site/harness/src/api/react.tsx create mode 100644 examples/fastapi-react-site/harness/src/api/schema.json create mode 100644 examples/fastapi-react-site/harness/src/api/types.ts create mode 100644 examples/fastapi-react-site/harness/src/fixtures.tsx create mode 100644 examples/fastapi-react-site/harness/src/main.tsx create mode 100644 examples/fastapi-react-site/harness/tsconfig.json create mode 100644 examples/fastapi-react-site/harness/vite.config.ts create mode 100644 examples/fastapi-react-site/mizan.spec.ts create mode 100644 examples/fastapi-react-site/package.json create mode 100644 examples/fastapi-react-site/playwright.config.ts diff --git a/backends/mizan-django/generate/generator/lib/fetch.mjs b/backends/mizan-django/generate/generator/lib/fetch.mjs index 8884e3e..26d4100 100644 --- a/backends/mizan-django/generate/generator/lib/fetch.mjs +++ b/backends/mizan-django/generate/generator/lib/fetch.mjs @@ -1,15 +1,58 @@ /** - * Schema Fetching + * Schema Fetching — dispatches on the backend type configured in + * `source.django` or `source.fastapi`. * - * Fetches mizan and channels schemas from Django management commands. + * Both flavors spawn a Python subprocess that prints schema JSON to stdout: + * Django: `python manage.py export_mizan_schema --indent 0` + * FastAPI: `python -m mizan_fastapi.cli ` */ import { spawn } from 'child_process' import path from 'path' -/** - * Run a Django management command and parse JSON output. - */ + +function runSubprocess(cmd, args, opts) { + const { cwd, env, label } = opts + return new Promise((resolve, reject) => { + const proc = spawn(cmd, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + env, + }) + + let stdout = '' + let stderr = '' + + proc.stdout.on('data', (data) => { stdout += data.toString() }) + proc.stderr.on('data', (data) => { stderr += data.toString() }) + + proc.on('close', (code) => { + if (code !== 0) { + reject(new Error(`${label} command failed (exit ${code}):\n${stderr}`)) + return + } + + const jsonStart = stdout.indexOf('{') + if (jsonStart === -1) { + reject(new Error(`No JSON found in ${label} output:\n${stdout}\n${stderr}`)) + return + } + + try { + resolve(JSON.parse(stdout.slice(jsonStart))) + } catch (err) { + reject(new Error(`Failed to parse JSON from ${label}:\n${err.message}\n${stdout}`)) + } + }) + + proc.on('error', (err) => { + reject(new Error(`Failed to spawn ${label} command: ${err.message}`)) + }) + }) +} + + function runDjangoCommand(source, cwd, command) { const managePath = path.resolve(cwd, source.django.managePath) const manageDir = path.dirname(managePath) @@ -24,51 +67,33 @@ function runDjangoCommand(source, cwd, command) { args = [managePath, command, '--indent', '0'] } - const env = source.django.env - ? { ...process.env, ...source.django.env } - : undefined - - return new Promise((resolve, reject) => { - const proc = spawn(cmd, args, { - cwd: manageDir, - stdio: ['ignore', 'pipe', 'pipe'], - shell: process.platform === 'win32', - env, - }) - - let stdout = '' - let stderr = '' - - proc.stdout.on('data', (data) => { stdout += data.toString() }) - proc.stderr.on('data', (data) => { stderr += data.toString() }) - - proc.on('close', (code) => { - if (code !== 0) { - reject(new Error(`Django command failed (exit ${code}):\n${stderr}`)) - return - } - - const jsonStart = stdout.indexOf('{') - if (jsonStart === -1) { - reject(new Error(`No JSON found in Django output:\n${stdout}\n${stderr}`)) - return - } - - try { - resolve(JSON.parse(stdout.slice(jsonStart))) - } catch (err) { - reject(new Error(`Failed to parse JSON from Django:\n${err.message}\n${stdout}`)) - } - }) - - proc.on('error', (err) => { - reject(new Error(`Failed to spawn Django command: ${err.message}`)) - }) - }) + const env = source.django.env ? { ...process.env, ...source.django.env } : undefined + return runSubprocess(cmd, args, { cwd: manageDir, env, label: 'Django' }) } + +function runFastapiSchemaCommand(source, cwd) { + const fastapiCwd = source.fastapi.cwd + ? path.resolve(cwd, source.fastapi.cwd) + : cwd + + let cmd, args + if (source.fastapi.command) { + cmd = source.fastapi.command[0] + args = [...source.fastapi.command.slice(1), '-m', 'mizan_fastapi.cli', source.fastapi.module] + } else { + cmd = source.fastapi.python || 'python' + args = ['-m', 'mizan_fastapi.cli', source.fastapi.module] + } + + const env = source.fastapi.env ? { ...process.env, ...source.fastapi.env } : undefined + return runSubprocess(cmd, args, { cwd: fastapiCwd, env, label: 'FastAPI' }) +} + + /** - * Fetch channels schema from Django. + * Fetch channels schema. Channels are a Django-only feature; FastAPI + * projects use native WebSockets and don't go through this path. */ export async function fetchChannelsSchema(source, cwd) { if (!source.django) { @@ -77,12 +102,16 @@ export async function fetchChannelsSchema(source, cwd) { return runDjangoCommand(source, cwd, 'export_channels_schema') } + /** - * Fetch mizan schema from Django. + * Fetch mizan schema. Dispatches on whichever backend source is configured. */ export async function fetchMizanSchema(source, cwd) { - if (!source.django) { - throw new Error('mizan schema export requires django source configuration') + if (source.fastapi) { + return runFastapiSchemaCommand(source, cwd) } - return runDjangoCommand(source, cwd, 'export_mizan_schema') + if (source.django) { + return runDjangoCommand(source, cwd, 'export_mizan_schema') + } + throw new Error('mizan schema export requires source.django or source.fastapi') } diff --git a/backends/mizan-fastapi/src/mizan_fastapi/cli.py b/backends/mizan-fastapi/src/mizan_fastapi/cli.py new file mode 100644 index 0000000..4834a2f --- /dev/null +++ b/backends/mizan-fastapi/src/mizan_fastapi/cli.py @@ -0,0 +1,45 @@ +""" +Schema-export CLI for codegen consumption. + +Usage: + python -m mizan_fastapi.cli + +Imports the named module (whose import side effects must register every +@client function with mizan_core.registry — typically by `@client` plus +`register(...)` calls at module top level), then prints the OpenAPI +schema to stdout as JSON. + +Mirrors mizan-django's `manage.py export_mizan_schema` so the codegen +CLI can fetch from either backend the same subprocess way. +""" + +from __future__ import annotations + +import importlib +import json +import sys + +from .schema import build_schema + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if len(args) != 1: + print("usage: python -m mizan_fastapi.cli ", file=sys.stderr) + return 2 + + module_name = args[0] + try: + importlib.import_module(module_name) + except Exception as e: + print(f"failed to import {module_name!r}: {e}", file=sys.stderr) + return 1 + + schema = build_schema() + json.dump(schema, sys.stdout) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backends/mizan-fastapi/src/mizan_fastapi/executor.py b/backends/mizan-fastapi/src/mizan_fastapi/executor.py index 8f6bf0c..59a04dd 100644 --- a/backends/mizan-fastapi/src/mizan_fastapi/executor.py +++ b/backends/mizan-fastapi/src/mizan_fastapi/executor.py @@ -84,7 +84,7 @@ def _enforce_auth(request: Any, requirement: Any) -> None: user = _user(request) match requirement: - case True: + case True | "required": if not _is_authenticated(user): raise Unauthorized("Authentication required") case "staff": diff --git a/backends/mizan-fastapi/tests/test_dispatch.py b/backends/mizan-fastapi/tests/test_dispatch.py index ffbd1d5..0c3e58e 100644 --- a/backends/mizan-fastapi/tests/test_dispatch.py +++ b/backends/mizan-fastapi/tests/test_dispatch.py @@ -59,11 +59,16 @@ def app(): def update_email(request, email: str) -> EchoOutput: return EchoOutput(message=f"updated: {email}") + @client(auth=True) + def whoami(request) -> UserOutput: + return UserOutput(email="real@example.com", authenticated=True) + register(echo, "echo") register(add, "add") register(current_user, "current_user") register(user_count, "user_count") register(update_email, "update_email") + register(whoami, "whoami") fastapi_app = FastAPI() fastapi_app.include_router(mizan_router, prefix="/api/mizan") @@ -147,6 +152,15 @@ class ContextFetchTests: # ─── Invalidation ─────────────────────────────────────────────────────────── +class AuthTests: + """The decorator normalizes auth=True → meta['auth']='required'; executor must match both.""" + + def test_anonymous_request_to_auth_required_returns_401(self, http): + r = http.post("/api/mizan/call/", json={"fn": "whoami", "args": {}}) + assert r.status_code == 401 + assert r.json()["error"]["code"] == "UNAUTHORIZED" + + class InvalidationTests: def test_mutation_emits_invalidate_list(self, http): r = http.post( diff --git a/examples/fastapi-react-site/.gitignore b/examples/fastapi-react-site/.gitignore new file mode 100644 index 0000000..111dd2c --- /dev/null +++ b/examples/fastapi-react-site/.gitignore @@ -0,0 +1,2 @@ +test-results/ +node_modules/ diff --git a/examples/fastapi-react-site/backend/main.py b/examples/fastapi-react-site/backend/main.py new file mode 100644 index 0000000..21bcf5a --- /dev/null +++ b/examples/fastapi-react-site/backend/main.py @@ -0,0 +1,167 @@ +""" +Example FastAPI app for the e2e harness — mirrors the surface that +examples/django-react-site/backend/testapp/clients.py exercises, minus +Django-only features (forms, channels, ws-whoami, session-bound JWT). + +The fixture functions are designed to drive specific Playwright tests: +- success-path RPC (echo, add, multiply) +- auth requirements (whoami, staff_only, superuser_only, verified_only) +- error codes (not_implemented_fn, buggy_fn, permission_check_fn) +- a global context (current_user) for the bundled-fetch path + +Anonymous access is the default — request.state.user is left unset so +the auth-required functions return UNAUTHORIZED, matching the harness +expectations for an anonymous browser session. +""" + +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from pydantic import BaseModel + +from mizan_core.client.function import client +from mizan_core.registry import register +from mizan_fastapi import ( + Forbidden, + MizanError, + mizan_exception_handler, + mizan_validation_handler, + router as mizan_router, +) + + +# ─── Output shapes ────────────────────────────────────────────────────────── + + +class EchoOutput(BaseModel): + message: str + + +class AddOutput(BaseModel): + result: int + + +class MultiplyOutput(BaseModel): + product: int + + +class UserOutput(BaseModel): + email: str + authenticated: bool + is_staff: bool = False + + +class MessageOutput(BaseModel): + message: str + + +# ─── Fixture functions ────────────────────────────────────────────────────── + + +@client +def echo(request, text: str) -> EchoOutput: + """Echoes the input text.""" + return EchoOutput(message=text) + + +@client +def add(request, a: int, b: int) -> AddOutput: + """Returns a + b.""" + return AddOutput(result=a + b) + + +@client +def multiply(request, x: int, y: int) -> MultiplyOutput: + """Returns x * y.""" + return MultiplyOutput(product=x * y) + + +@client(auth=True) +def whoami(request) -> UserOutput: + """Returns the authenticated user's identity. Anonymous → UNAUTHORIZED.""" + user = request.state.user + return UserOutput( + email=getattr(user, "email", ""), + authenticated=True, + is_staff=getattr(user, "is_staff", False), + ) + + +@client(auth="staff") +def staff_only(request) -> MessageOutput: + """Staff-only endpoint.""" + return MessageOutput(message="staff access ok") + + +@client(auth="superuser") +def superuser_only(request) -> MessageOutput: + """Superuser-only endpoint.""" + return MessageOutput(message="superuser access ok") + + +def _is_verified(request) -> bool: + user = getattr(getattr(request, "state", None), "user", None) + return bool(user) and getattr(user, "is_verified", False) + + +@client(auth=_is_verified) +def verified_only(request) -> MessageOutput: + """Verified-users-only endpoint. Anonymous → FORBIDDEN.""" + return MessageOutput(message="verified access ok") + + +@client +def not_implemented_fn(request) -> MessageOutput: + """Always raises NotImplementedError → NOT_IMPLEMENTED.""" + raise NotImplementedError("This function is intentionally not implemented") + + +@client +def buggy_fn(request) -> MessageOutput: + """Always raises a generic exception → INTERNAL_ERROR.""" + raise RuntimeError("Intentional bug for e2e testing") + + +@client +def permission_check_fn(request, secret: str) -> MessageOutput: + """Wrong secret → FORBIDDEN; correct secret → success.""" + if secret != "open-sesame": + raise Forbidden("Invalid secret") + return MessageOutput(message="access granted") + + +@client(context="global") +def current_user(request) -> UserOutput: + """The global context — auto-mounted at the React root.""" + user = getattr(getattr(request, "state", None), "user", None) + return UserOutput( + email=getattr(user, "email", "") if user else "", + authenticated=bool(user) and getattr(user, "is_authenticated", False), + is_staff=getattr(user, "is_staff", False) if user else False, + ) + + +# ─── Registration ─────────────────────────────────────────────────────────── + + +register(echo, "echo") +register(add, "add") +register(multiply, "multiply") +register(whoami, "whoami") +register(staff_only, "staff_only") +register(superuser_only, "superuser_only") +register(verified_only, "verified_only") +register(not_implemented_fn, "not_implemented_fn") +register(buggy_fn, "buggy_fn") +register(permission_check_fn, "permission_check_fn") +register(current_user, "current_user") + + +# ─── App ──────────────────────────────────────────────────────────────────── + + +app = FastAPI(title="mizan-fastapi e2e example") +app.include_router(mizan_router, prefix="/api/mizan") +app.add_exception_handler(MizanError, mizan_exception_handler) +app.add_exception_handler(RequestValidationError, mizan_validation_handler) diff --git a/examples/fastapi-react-site/backend/pyproject.toml b/examples/fastapi-react-site/backend/pyproject.toml new file mode 100644 index 0000000..d5a8386 --- /dev/null +++ b/examples/fastapi-react-site/backend/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "mizan-fastapi-example" +version = "0.1.0" +description = "Example FastAPI app exercising mizan-fastapi for the Playwright harness." +requires-python = ">=3.10" +dependencies = [ + "mizan-core", + "mizan-fastapi", + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "pydantic>=2.0", +] + +[tool.uv.sources] +mizan-core = { path = "../../../cores/mizan-python", editable = true } +mizan-fastapi = { path = "../../../backends/mizan-fastapi", editable = true } diff --git a/examples/fastapi-react-site/harness/fastapi.config.mjs b/examples/fastapi-react-site/harness/fastapi.config.mjs new file mode 100644 index 0000000..bae59e9 --- /dev/null +++ b/examples/fastapi-react-site/harness/fastapi.config.mjs @@ -0,0 +1,19 @@ +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(__dirname, '../../..') + +export default { + projectId: 'mizan-fastapi-e2e', + + source: { + fastapi: { + module: 'main', + cwd: path.join(root, 'examples/fastapi-react-site/backend'), + command: ['uv', 'run', 'python'], + }, + }, + + output: 'src/api', +} diff --git a/examples/fastapi-react-site/harness/index.html b/examples/fastapi-react-site/harness/index.html new file mode 100644 index 0000000..9f5fe9e --- /dev/null +++ b/examples/fastapi-react-site/harness/index.html @@ -0,0 +1,5 @@ + + +mizan FastAPI E2E Harness +
+ diff --git a/examples/fastapi-react-site/harness/package.json b/examples/fastapi-react-site/harness/package.json new file mode 100644 index 0000000..1c55adc --- /dev/null +++ b/examples/fastapi-react-site/harness/package.json @@ -0,0 +1,22 @@ +{ + "name": "mizan-fastapi-e2e-harness", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite --port 5175" + }, + "dependencies": { + "@mizan/base": "file:../../../frontends/mizan-base", + "@rythazhur/mizan": "file:../../../frontends/mizan-react", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/examples/fastapi-react-site/harness/src/api/contexts/global.ts b/examples/fastapi-react-site/harness/src/api/contexts/global.ts new file mode 100644 index 0000000..253ded6 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/contexts/global.ts @@ -0,0 +1,15 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanFetch } from '@mizan/base' + +import type { currentUserOutput } from '../types' + +export interface GlobalContextData { + current_user: currentUserOutput +} + +export type GlobalContextParams = Record + +export function fetchGlobalContext(params: GlobalContextParams): Promise { + return mizanFetch('global', params) +} diff --git a/examples/fastapi-react-site/harness/src/api/functions/add.ts b/examples/fastapi-react-site/harness/src/api/functions/add.ts new file mode 100644 index 0000000..a0efbcc --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/functions/add.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { addInput, addOutput } from '../types' + +export function callAdd(args: addInput): Promise { + return mizanCall('add', args) +} diff --git a/examples/fastapi-react-site/harness/src/api/functions/buggyFn.ts b/examples/fastapi-react-site/harness/src/api/functions/buggyFn.ts new file mode 100644 index 0000000..98e3020 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/functions/buggyFn.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { buggyFnOutput } from '../types' + +export function callBuggyFn(): Promise { + return mizanCall('buggy_fn', {}) +} diff --git a/examples/fastapi-react-site/harness/src/api/functions/echo.ts b/examples/fastapi-react-site/harness/src/api/functions/echo.ts new file mode 100644 index 0000000..5374efe --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/functions/echo.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { echoInput, echoOutput } from '../types' + +export function callEcho(args: echoInput): Promise { + return mizanCall('echo', args) +} diff --git a/examples/fastapi-react-site/harness/src/api/functions/multiply.ts b/examples/fastapi-react-site/harness/src/api/functions/multiply.ts new file mode 100644 index 0000000..174aba9 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/functions/multiply.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { multiplyInput, multiplyOutput } from '../types' + +export function callMultiply(args: multiplyInput): Promise { + return mizanCall('multiply', args) +} diff --git a/examples/fastapi-react-site/harness/src/api/functions/notImplementedFn.ts b/examples/fastapi-react-site/harness/src/api/functions/notImplementedFn.ts new file mode 100644 index 0000000..8302e10 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/functions/notImplementedFn.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { notImplementedFnOutput } from '../types' + +export function callNotImplementedFn(): Promise { + return mizanCall('not_implemented_fn', {}) +} diff --git a/examples/fastapi-react-site/harness/src/api/functions/permissionCheckFn.ts b/examples/fastapi-react-site/harness/src/api/functions/permissionCheckFn.ts new file mode 100644 index 0000000..0cd3362 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/functions/permissionCheckFn.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { permissionCheckFnInput, permissionCheckFnOutput } from '../types' + +export function callPermissionCheckFn(args: permissionCheckFnInput): Promise { + return mizanCall('permission_check_fn', args) +} diff --git a/examples/fastapi-react-site/harness/src/api/functions/staffOnly.ts b/examples/fastapi-react-site/harness/src/api/functions/staffOnly.ts new file mode 100644 index 0000000..2fa0c0a --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/functions/staffOnly.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { staffOnlyOutput } from '../types' + +export function callStaffOnly(): Promise { + return mizanCall('staff_only', {}) +} diff --git a/examples/fastapi-react-site/harness/src/api/functions/superuserOnly.ts b/examples/fastapi-react-site/harness/src/api/functions/superuserOnly.ts new file mode 100644 index 0000000..4e397ed --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/functions/superuserOnly.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { superuserOnlyOutput } from '../types' + +export function callSuperuserOnly(): Promise { + return mizanCall('superuser_only', {}) +} diff --git a/examples/fastapi-react-site/harness/src/api/functions/verifiedOnly.ts b/examples/fastapi-react-site/harness/src/api/functions/verifiedOnly.ts new file mode 100644 index 0000000..aedd921 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/functions/verifiedOnly.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { verifiedOnlyOutput } from '../types' + +export function callVerifiedOnly(): Promise { + return mizanCall('verified_only', {}) +} diff --git a/examples/fastapi-react-site/harness/src/api/functions/whoami.ts b/examples/fastapi-react-site/harness/src/api/functions/whoami.ts new file mode 100644 index 0000000..9e2e136 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/functions/whoami.ts @@ -0,0 +1,9 @@ +// AUTO-GENERATED by mizan — do not edit + +import { mizanCall } from '@mizan/base' + +import type { whoamiOutput } from '../types' + +export function callWhoami(): Promise { + return mizanCall('whoami', {}) +} diff --git a/examples/fastapi-react-site/harness/src/api/index.ts b/examples/fastapi-react-site/harness/src/api/index.ts new file mode 100644 index 0000000..3ba7c78 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/index.ts @@ -0,0 +1,19 @@ +// AUTO-GENERATED by mizan — do not edit + +export * from './types' + +export { fetchGlobalContext, type GlobalContextData, type GlobalContextParams } from './contexts/global' + +export { callEcho } from './functions/echo' +export { callAdd } from './functions/add' +export { callMultiply } from './functions/multiply' +export { callWhoami } from './functions/whoami' +export { callStaffOnly } from './functions/staffOnly' +export { callSuperuserOnly } from './functions/superuserOnly' +export { callVerifiedOnly } from './functions/verifiedOnly' +export { callNotImplementedFn } from './functions/notImplementedFn' +export { callBuggyFn } from './functions/buggyFn' +export { callPermissionCheckFn } from './functions/permissionCheckFn' + +// Stage 2 framework adapter +export * from './react' diff --git a/examples/fastapi-react-site/harness/src/api/react.tsx b/examples/fastapi-react-site/harness/src/api/react.tsx new file mode 100644 index 0000000..6667b82 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/react.tsx @@ -0,0 +1,169 @@ +'use client' + +// AUTO-GENERATED by mizan — do not edit + +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from 'react' +import { + configure, + initSession, + mizanCall, + mizanFetch, + MizanError, + registerContext, + type ContextState, +} from '@mizan/base' + +import { fetchGlobalContext, type GlobalContextData, type GlobalContextParams, callEcho, callAdd, callMultiply, callWhoami, callStaffOnly, callSuperuserOnly, callVerifiedOnly, callNotImplementedFn, callBuggyFn, callPermissionCheckFn } from './index' + +// Internal — runs inside a Provider, registers with the kernel exactly once. +function useContextSubscription( + name: string, + params: Record, + fetchFn: () => Promise, + initialData?: T, +): ContextState { + const ref = useRef | null>(null) + if (!ref.current) { + ref.current = registerContext(name, params, fetchFn, initialData) + } + const handle = ref.current + + useEffect(() => { + if (handle.getState().status === 'idle') handle.refetch() + return () => handle.unregister() + }, [handle]) + + return useSyncExternalStore(handle.subscribe, handle.getState, handle.getState) +} + +// Internal — wraps an imperative call() with isPending / error state. +interface MutationHook { + mutate: (args: TArgs) => Promise + isPending: boolean + error: Error | null +} + +function useMutation( + callFn: (args: TArgs) => Promise, +): MutationHook { + const [isPending, setIsPending] = useState(false) + const [error, setError] = useState(null) + + const mutate = useCallback(async (args: TArgs) => { + setIsPending(true) + setError(null) + try { + return await callFn(args) + } catch (e) { + setError(e as Error) + throw e + } finally { + setIsPending(false) + } + }, [callFn]) + + return { mutate, isPending, error } +} + +// ── Global Context ── + +const GlobalCtx = createContext | null>(null) + +export function GlobalContextProvider({ children }: { children: ReactNode }) { + const ssrData = typeof window !== 'undefined' ? (window as any).__MIZAN_SSR_DATA__ : undefined + const state = useContextSubscription('global', {}, () => fetchGlobalContext({} as any), ssrData) + return {children} +} + +export function useGlobalContext(): ContextState { + const ctx = useContext(GlobalCtx) + if (!ctx) throw new Error('useGlobalContext requires or ') + return ctx +} + +export function useCurrentUser(): currentUserOutput | null { + return useGlobalContext().data?.current_user ?? null +} + +export function useEcho() { + return useMutation[0], Awaited>>(callEcho) +} + +export function useAdd() { + return useMutation[0], Awaited>>(callAdd) +} + +export function useMultiply() { + return useMutation[0], Awaited>>(callMultiply) +} + +export function useWhoami() { + return useMutation>>(() => callWhoami() as any) +} + +export function useStaffOnly() { + return useMutation>>(() => callStaffOnly() as any) +} + +export function useSuperuserOnly() { + return useMutation>>(() => callSuperuserOnly() as any) +} + +export function useVerifiedOnly() { + return useMutation>>(() => callVerifiedOnly() as any) +} + +export function useNotImplementedFn() { + return useMutation>>(() => callNotImplementedFn() as any) +} + +export function useBuggyFn() { + return useMutation>>(() => callBuggyFn() as any) +} + +export function usePermissionCheckFn() { + return useMutation[0], Awaited>>(callPermissionCheckFn) +} + +// ── MizanContext root provider ── + +export interface MizanContextProps { + /** Base URL for protocol endpoints. Defaults to "/api/mizan". */ + baseUrl?: string + children: ReactNode +} + +/** + * Root provider — calls configure() once and mounts the global context (if defined). + * Must wrap any component using Mizan-generated hooks. + */ +export function MizanContext({ baseUrl, children }: MizanContextProps) { + const configured = useRef(false) + if (!configured.current) { + if (baseUrl) configure({ baseUrl }) + configured.current = true + } + return {children} +} + +// ── Imperative escape hatch ── + +/** + * Returns the imperative kernel API. For test harnesses or rare cases where + * a typed generated hook does not fit. Most app code should use the typed hooks. + */ +export function useMizan() { + return { call: mizanCall, fetch: mizanFetch } +} + +export type { ContextState } from '@mizan/base' +export { configure, initSession, MizanError } from '@mizan/base' diff --git a/examples/fastapi-react-site/harness/src/api/schema.json b/examples/fastapi-react-site/harness/src/api/schema.json new file mode 100644 index 0000000..1775ca9 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/schema.json @@ -0,0 +1,756 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "mizan Server Functions", + "description": "Auto-generated schema for mizan server functions", + "version": "1.0.0" + }, + "paths": { + "/mizan/echo": { + "post": { + "summary": "Echoes the input text.", + "operationId": "echo", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/echoInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/echoOutput" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/add": { + "post": { + "summary": "Returns a + b.", + "operationId": "add", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/addInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/addOutput" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/multiply": { + "post": { + "summary": "Returns x * y.", + "operationId": "multiply", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/multiplyInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/multiplyOutput" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/whoami": { + "post": { + "summary": "Returns the authenticated user's identity. Anonymous → UNAUTHORIZED.", + "operationId": "whoami", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/whoamiOutput" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/staff_only": { + "post": { + "summary": "Staff-only endpoint.", + "operationId": "staffOnly", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/staffOnlyOutput" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/superuser_only": { + "post": { + "summary": "Superuser-only endpoint.", + "operationId": "superuserOnly", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/superuserOnlyOutput" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/verified_only": { + "post": { + "summary": "Verified-users-only endpoint. Anonymous → FORBIDDEN.", + "operationId": "verifiedOnly", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/verifiedOnlyOutput" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/not_implemented_fn": { + "post": { + "summary": "Always raises NotImplementedError → NOT_IMPLEMENTED.", + "operationId": "notImplementedFn", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/notImplementedFnOutput" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/buggy_fn": { + "post": { + "summary": "Always raises a generic exception → INTERNAL_ERROR.", + "operationId": "buggyFn", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/buggyFnOutput" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/permission_check_fn": { + "post": { + "summary": "Wrong secret → FORBIDDEN; correct secret → success.", + "operationId": "permissionCheckFn", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/permissionCheckFnInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/permissionCheckFnOutput" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": false + } + } + }, + "/mizan/current_user": { + "post": { + "summary": "The global context — auto-mounted at the React root.", + "operationId": "currentUser", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/currentUserOutput" + } + } + } + } + }, + "x-mizan": { + "transport": "http", + "isContext": "global" + } + } + } + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + }, + "addInput": { + "properties": { + "a": { + "type": "integer", + "title": "A" + }, + "b": { + "type": "integer", + "title": "B" + } + }, + "type": "object", + "required": [ + "a", + "b" + ], + "title": "addInput" + }, + "addOutput": { + "properties": { + "result": { + "type": "integer", + "title": "Result" + } + }, + "type": "object", + "required": [ + "result" + ], + "title": "addOutput" + }, + "buggyFnOutput": { + "properties": { + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "buggyFnOutput" + }, + "currentUserOutput": { + "properties": { + "email": { + "type": "string", + "title": "Email" + }, + "authenticated": { + "type": "boolean", + "title": "Authenticated" + }, + "is_staff": { + "type": "boolean", + "title": "Is Staff", + "default": false + } + }, + "type": "object", + "required": [ + "email", + "authenticated" + ], + "title": "currentUserOutput" + }, + "echoInput": { + "properties": { + "text": { + "type": "string", + "title": "Text" + } + }, + "type": "object", + "required": [ + "text" + ], + "title": "echoInput" + }, + "echoOutput": { + "properties": { + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "echoOutput" + }, + "multiplyInput": { + "properties": { + "x": { + "type": "integer", + "title": "X" + }, + "y": { + "type": "integer", + "title": "Y" + } + }, + "type": "object", + "required": [ + "x", + "y" + ], + "title": "multiplyInput" + }, + "multiplyOutput": { + "properties": { + "product": { + "type": "integer", + "title": "Product" + } + }, + "type": "object", + "required": [ + "product" + ], + "title": "multiplyOutput" + }, + "notImplementedFnOutput": { + "properties": { + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "notImplementedFnOutput" + }, + "permissionCheckFnInput": { + "properties": { + "secret": { + "type": "string", + "title": "Secret" + } + }, + "type": "object", + "required": [ + "secret" + ], + "title": "permissionCheckFnInput" + }, + "permissionCheckFnOutput": { + "properties": { + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "permissionCheckFnOutput" + }, + "staffOnlyOutput": { + "properties": { + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "staffOnlyOutput" + }, + "superuserOnlyOutput": { + "properties": { + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "superuserOnlyOutput" + }, + "verifiedOnlyOutput": { + "properties": { + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "verifiedOnlyOutput" + }, + "whoamiOutput": { + "properties": { + "email": { + "type": "string", + "title": "Email" + }, + "authenticated": { + "type": "boolean", + "title": "Authenticated" + }, + "is_staff": { + "type": "boolean", + "title": "Is Staff", + "default": false + } + }, + "type": "object", + "required": [ + "email", + "authenticated" + ], + "title": "whoamiOutput" + } + } + }, + "x-mizan-functions": [ + { + "name": "echo", + "camelName": "echo", + "hasInput": true, + "inputType": "echoInput", + "outputType": "echoOutput", + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "add", + "camelName": "add", + "hasInput": true, + "inputType": "addInput", + "outputType": "addOutput", + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "multiply", + "camelName": "multiply", + "hasInput": true, + "inputType": "multiplyInput", + "outputType": "multiplyOutput", + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "whoami", + "camelName": "whoami", + "hasInput": false, + "inputType": null, + "outputType": "whoamiOutput", + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "staff_only", + "camelName": "staffOnly", + "hasInput": false, + "inputType": null, + "outputType": "staffOnlyOutput", + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "superuser_only", + "camelName": "superuserOnly", + "hasInput": false, + "inputType": null, + "outputType": "superuserOnlyOutput", + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "verified_only", + "camelName": "verifiedOnly", + "hasInput": false, + "inputType": null, + "outputType": "verifiedOnlyOutput", + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "not_implemented_fn", + "camelName": "notImplementedFn", + "hasInput": false, + "inputType": null, + "outputType": "notImplementedFnOutput", + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "buggy_fn", + "camelName": "buggyFn", + "hasInput": false, + "inputType": null, + "outputType": "buggyFnOutput", + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "permission_check_fn", + "camelName": "permissionCheckFn", + "hasInput": true, + "inputType": "permissionCheckFnInput", + "outputType": "permissionCheckFnOutput", + "transport": "http", + "isContext": false, + "isForm": false, + "formName": null, + "formRole": null + }, + { + "name": "current_user", + "camelName": "currentUser", + "hasInput": false, + "inputType": null, + "outputType": "currentUserOutput", + "transport": "http", + "isContext": "global", + "isForm": false, + "formName": null, + "formRole": null + } + ], + "x-mizan-contexts": { + "global": { + "functions": [ + "current_user" + ], + "params": {} + } + } +} \ No newline at end of file diff --git a/examples/fastapi-react-site/harness/src/api/types.ts b/examples/fastapi-react-site/harness/src/api/types.ts new file mode 100644 index 0000000..d23bae8 --- /dev/null +++ b/examples/fastapi-react-site/harness/src/api/types.ts @@ -0,0 +1,607 @@ +// AUTO-GENERATED by mizan — do not edit + +export interface paths { + "/mizan/echo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Echoes the input text. */ + post: operations["echo"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mizan/add": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Returns a + b. */ + post: operations["add"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mizan/multiply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Returns x * y. */ + post: operations["multiply"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mizan/whoami": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Returns the authenticated user's identity. Anonymous → UNAUTHORIZED. */ + post: operations["whoami"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mizan/staff_only": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Staff-only endpoint. */ + post: operations["staffOnly"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mizan/superuser_only": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Superuser-only endpoint. */ + post: operations["superuserOnly"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mizan/verified_only": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Verified-users-only endpoint. Anonymous → FORBIDDEN. */ + post: operations["verifiedOnly"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mizan/not_implemented_fn": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Always raises NotImplementedError → NOT_IMPLEMENTED. */ + post: operations["notImplementedFn"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mizan/buggy_fn": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Always raises a generic exception → INTERNAL_ERROR. */ + post: operations["buggyFn"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mizan/permission_check_fn": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Wrong secret → FORBIDDEN; correct secret → success. */ + post: operations["permissionCheckFn"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mizan/current_user": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** The global context — auto-mounted at the React root. */ + post: operations["currentUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + /** addInput */ + addInput: { + /** A */ + a: number; + /** B */ + b: number; + }; + /** addOutput */ + addOutput: { + /** Result */ + result: number; + }; + /** buggyFnOutput */ + buggyFnOutput: { + /** Message */ + message: string; + }; + /** currentUserOutput */ + currentUserOutput: { + /** Email */ + email: string; + /** Authenticated */ + authenticated: boolean; + /** + * Is Staff + * @default false + */ + is_staff: boolean; + }; + /** echoInput */ + echoInput: { + /** Text */ + text: string; + }; + /** echoOutput */ + echoOutput: { + /** Message */ + message: string; + }; + /** multiplyInput */ + multiplyInput: { + /** X */ + x: number; + /** Y */ + y: number; + }; + /** multiplyOutput */ + multiplyOutput: { + /** Product */ + product: number; + }; + /** notImplementedFnOutput */ + notImplementedFnOutput: { + /** Message */ + message: string; + }; + /** permissionCheckFnInput */ + permissionCheckFnInput: { + /** Secret */ + secret: string; + }; + /** permissionCheckFnOutput */ + permissionCheckFnOutput: { + /** Message */ + message: string; + }; + /** staffOnlyOutput */ + staffOnlyOutput: { + /** Message */ + message: string; + }; + /** superuserOnlyOutput */ + superuserOnlyOutput: { + /** Message */ + message: string; + }; + /** verifiedOnlyOutput */ + verifiedOnlyOutput: { + /** Message */ + message: string; + }; + /** whoamiOutput */ + whoamiOutput: { + /** Email */ + email: string; + /** Authenticated */ + authenticated: boolean; + /** + * Is Staff + * @default false + */ + is_staff: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + echo: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["echoInput"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["echoOutput"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + add: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["addInput"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["addOutput"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + multiply: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["multiplyInput"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["multiplyOutput"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + whoami: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["whoamiOutput"]; + }; + }; + }; + }; + staffOnly: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["staffOnlyOutput"]; + }; + }; + }; + }; + superuserOnly: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["superuserOnlyOutput"]; + }; + }; + }; + }; + verifiedOnly: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["verifiedOnlyOutput"]; + }; + }; + }; + }; + notImplementedFn: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["notImplementedFnOutput"]; + }; + }; + }; + }; + buggyFn: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["buggyFnOutput"]; + }; + }; + }; + }; + permissionCheckFn: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["permissionCheckFnInput"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["permissionCheckFnOutput"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + currentUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["currentUserOutput"]; + }; + }; + }; + }; +} + + +// Convenience type exports +export type HTTPValidationError = components["schemas"]["HTTPValidationError"] +export type ValidationError = components["schemas"]["ValidationError"] +export type addInput = components["schemas"]["addInput"] +export type addOutput = components["schemas"]["addOutput"] +export type buggyFnOutput = components["schemas"]["buggyFnOutput"] +export type currentUserOutput = components["schemas"]["currentUserOutput"] +export type echoInput = components["schemas"]["echoInput"] +export type echoOutput = components["schemas"]["echoOutput"] +export type multiplyInput = components["schemas"]["multiplyInput"] +export type multiplyOutput = components["schemas"]["multiplyOutput"] +export type notImplementedFnOutput = components["schemas"]["notImplementedFnOutput"] +export type permissionCheckFnInput = components["schemas"]["permissionCheckFnInput"] +export type permissionCheckFnOutput = components["schemas"]["permissionCheckFnOutput"] +export type staffOnlyOutput = components["schemas"]["staffOnlyOutput"] +export type superuserOnlyOutput = components["schemas"]["superuserOnlyOutput"] +export type verifiedOnlyOutput = components["schemas"]["verifiedOnlyOutput"] +export type whoamiOutput = components["schemas"]["whoamiOutput"] diff --git a/examples/fastapi-react-site/harness/src/fixtures.tsx b/examples/fastapi-react-site/harness/src/fixtures.tsx new file mode 100644 index 0000000..c977a9d --- /dev/null +++ b/examples/fastapi-react-site/harness/src/fixtures.tsx @@ -0,0 +1,132 @@ +/** + * E2E Test Fixtures — FastAPI flavor. + * + * Mirrors examples/django-react-site/harness/src/fixtures.tsx minus the + * Django-only surfaces (channels, forms). Each fixture uses GENERATED + * mizan hooks (not raw call()). Playwright reads the DOM to verify + * behavior. URL hash selects the fixture: #echo, #add, etc. + */ + +import { useState, useEffect } from 'react' + +import { + MizanContext, + useEcho, + useAdd, + useMultiply, + useWhoami, + useStaffOnly, + useSuperuserOnly, + useVerifiedOnly, + useNotImplementedFn, + useBuggyFn, + usePermissionCheckFn, + useCurrentUser, + MizanError, + useMizan, +} from './api' + + +export function Fixtures() { + const [hash, setHash] = useState(window.location.hash.slice(1)) + + useEffect(() => { + const onHash = () => setHash(window.location.hash.slice(1)) + window.addEventListener('hashchange', onHash) + return () => window.removeEventListener('hashchange', onHash) + }, []) + + switch (hash) { + case 'echo': return + case 'add': return + case 'multiply': return + case 'not-found': return + case 'validation-error': return + case 'auth-required': return + case 'staff-only': return + case 'superuser-only': return + case 'verified-only': return + case 'not-implemented': return + case 'internal-error': return + case 'permission-error': return + case 'permission-success': return + case 'context-current-user': return + default: return
Harness ready. Set #hash.
+ } +} + + +function Result({ data, error }: { data?: unknown; error?: unknown }) { + return ( + <> + {data !== undefined && ( +
{JSON.stringify(data)}
+ )} + {error !== undefined && error !== null && ( + <> +
+ {error instanceof MizanError ? 'MizanError' : 'Error'} +
+
+ {error instanceof MizanError ? error.code : ''} +
+
+                        {error instanceof Error ? error.message : String(error)}
+                    
+ + )} + + ) +} + + +function useRun(hook: () => { mutate: (input?: any) => Promise }, input?: any) { + const { mutate } = hook() + const [data, setData] = useState() + const [error, setError] = useState() + + useEffect(() => { + mutate(input).then(setData).catch(setError) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return { data, error } +} + + +function Echo() { const r = useRun(useEcho, { text: 'e2e-test' }); return } +function Add() { const r = useRun(useAdd, { a: 17, b: 25 }); return } +function Multiply() { const r = useRun(useMultiply, { x: 6, y: 7 }); return } + +function NotFound() { + const { call } = useMizan() + const [error, setError] = useState() + useEffect(() => { call('does_not_exist').catch(setError) }, [call]) + return +} + +function ValidationError() { + const { mutate } = useAdd() + const [error, setError] = useState() + useEffect(() => { (mutate as any)({ a: 'not_a_number', b: 'also_not' }).catch(setError) }, [mutate]) + return +} + +function AuthRequired() { const r = useRun(useWhoami); return } +function StaffOnly() { const r = useRun(useStaffOnly); return } +function SuperuserOnly() { const r = useRun(useSuperuserOnly); return } +function VerifiedOnly() { const r = useRun(useVerifiedOnly); return } +function NotImplemented() { const r = useRun(useNotImplementedFn); return } +function InternalError() { const r = useRun(useBuggyFn); return } +function PermissionError_() { const r = useRun(usePermissionCheckFn, { secret: 'wrong' }); return } +function PermissionSuccess() { const r = useRun(usePermissionCheckFn, { secret: 'open-sesame' }); return } + + +function ContextCurrentUser() { + try { + const user = useCurrentUser() + if (user === null) return
loading context...
+ return
{JSON.stringify(user)}
+ } catch { + return
loading context...
+ } +} diff --git a/examples/fastapi-react-site/harness/src/main.tsx b/examples/fastapi-react-site/harness/src/main.tsx new file mode 100644 index 0000000..edcb61f --- /dev/null +++ b/examples/fastapi-react-site/harness/src/main.tsx @@ -0,0 +1,13 @@ +import { createRoot } from 'react-dom/client' +import { MizanContext } from './api' +import { Fixtures } from './fixtures' + +function App() { + return ( + + + + ) +} + +createRoot(document.getElementById('root')!).render() diff --git a/examples/fastapi-react-site/harness/tsconfig.json b/examples/fastapi-react-site/harness/tsconfig.json new file mode 100644 index 0000000..3c8d345 --- /dev/null +++ b/examples/fastapi-react-site/harness/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "react-jsx", + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/examples/fastapi-react-site/harness/vite.config.ts b/examples/fastapi-react-site/harness/vite.config.ts new file mode 100644 index 0000000..1144bfc --- /dev/null +++ b/examples/fastapi-react-site/harness/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8001', + }, + }, +}) diff --git a/examples/fastapi-react-site/mizan.spec.ts b/examples/fastapi-react-site/mizan.spec.ts new file mode 100644 index 0000000..5d861a0 --- /dev/null +++ b/examples/fastapi-react-site/mizan.spec.ts @@ -0,0 +1,139 @@ +/** + * mizan-fastapi e2e — Real Chromium → React with generated hooks → real FastAPI server. + * + * Mirrors examples/django-react-site/mizan.spec.ts minus channel/form tests + * (those features are Django-only — FastAPI projects use native equivalents + * or skip them entirely). + */ + +import { test, expect } from '@playwright/test' + +const BASE = process.env.HARNESS_URL || 'http://localhost:5175' + +async function fixture(page: any, name: string) { + await page.goto(`${BASE}#${name}`) + await page.waitForSelector('[data-testid="result"], [data-testid="error-type"]', { timeout: 10000 }) +} + +async function getResult(page: any): Promise { + const el = page.locator('[data-testid="result"]') + if (await el.count() > 0) return JSON.parse(await el.textContent()) + return null +} + +async function getError(page: any) { + const typeEl = page.locator('[data-testid="error-type"]') + if (await typeEl.count() === 0) return null + return { + type: await typeEl.textContent(), + code: await page.locator('[data-testid="error-code"]').textContent(), + message: await page.locator('[data-testid="error-message"]').textContent(), + } +} + +// ─── Function hooks ───────────────────────────────────────────────────────── + +test.describe('generated function hooks', () => { + test('useEcho returns echoed text', async ({ page }) => { + await fixture(page, 'echo') + const result = await getResult(page) + expect(result.message).toBe('e2e-test') + }) + + test('useAdd returns correct sum', async ({ page }) => { + await fixture(page, 'add') + const result = await getResult(page) + expect(result.result).toBe(42) + }) + + test('useMultiply returns product', async ({ page }) => { + await fixture(page, 'multiply') + const result = await getResult(page) + expect(result.product).toBe(42) + }) + + test('usePermissionCheckFn succeeds with correct secret', async ({ page }) => { + await fixture(page, 'permission-success') + const result = await getResult(page) + expect(result.message).toBe('access granted') + }) +}) + +// ─── Error codes ──────────────────────────────────────────────────────────── + +test.describe('error codes from generated hooks', () => { + test('non-existent function → MizanError NOT_FOUND', async ({ page }) => { + await fixture(page, 'not-found') + const error = await getError(page) + expect(error!.type).toBe('MizanError') + expect(error!.code).toBe('NOT_FOUND') + }) + + test('wrong input types → MizanError VALIDATION_ERROR', async ({ page }) => { + await fixture(page, 'validation-error') + const error = await getError(page) + expect(error!.type).toBe('MizanError') + expect(error!.code).toBe('VALIDATION_ERROR') + }) + + test('useWhoami anonymous → auth error', async ({ page }) => { + await fixture(page, 'auth-required') + const error = await getError(page) + expect(error!.type).toBe('MizanError') + expect(['UNAUTHORIZED', 'FORBIDDEN']).toContain(error!.code) + }) + + test('useStaffOnly anonymous → UNAUTHORIZED', async ({ page }) => { + await fixture(page, 'staff-only') + const error = await getError(page) + expect(error!.type).toBe('MizanError') + expect(['UNAUTHORIZED', 'FORBIDDEN']).toContain(error!.code) + }) + + test('useSuperuserOnly anonymous → UNAUTHORIZED', async ({ page }) => { + await fixture(page, 'superuser-only') + const error = await getError(page) + expect(error!.type).toBe('MizanError') + expect(['UNAUTHORIZED', 'FORBIDDEN']).toContain(error!.code) + }) + + test('useVerifiedOnly anonymous → FORBIDDEN', async ({ page }) => { + await fixture(page, 'verified-only') + const error = await getError(page) + expect(error!.type).toBe('MizanError') + expect(['UNAUTHORIZED', 'FORBIDDEN']).toContain(error!.code) + }) + + test('useNotImplementedFn → NOT_IMPLEMENTED', async ({ page }) => { + await fixture(page, 'not-implemented') + const error = await getError(page) + expect(error!.type).toBe('MizanError') + expect(error!.code).toBe('NOT_IMPLEMENTED') + }) + + test('useBuggyFn → INTERNAL_ERROR', async ({ page }) => { + await fixture(page, 'internal-error') + const error = await getError(page) + expect(error!.type).toBe('MizanError') + expect(error!.code).toBe('INTERNAL_ERROR') + }) + + test('usePermissionCheckFn wrong secret → FORBIDDEN', async ({ page }) => { + await fixture(page, 'permission-error') + const error = await getError(page) + expect(error!.type).toBe('MizanError') + expect(error!.code).toBe('FORBIDDEN') + }) +}) + +// ─── Context hooks ────────────────────────────────────────────────────────── + +test.describe('generated context hooks', () => { + test('useCurrentUser returns anonymous data', async ({ page }) => { + await page.goto(`${BASE}#context-current-user`) + await page.waitForSelector('[data-testid="result"]', { timeout: 10000 }) + const result = await getResult(page) + expect(result.authenticated).toBe(false) + expect(result.email).toBe('') + }) +}) diff --git a/examples/fastapi-react-site/package.json b/examples/fastapi-react-site/package.json new file mode 100644 index 0000000..486ef44 --- /dev/null +++ b/examples/fastapi-react-site/package.json @@ -0,0 +1,12 @@ +{ + "name": "mizan-fastapi-e2e", + "version": "1.0.0", + "description": "Mizan FastAPI + React e2e harness — Playwright against generated hooks talking to a real FastAPI server.", + "private": true, + "type": "commonjs", + "scripts": {}, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@types/node": "^25.5.0" + } +} diff --git a/examples/fastapi-react-site/playwright.config.ts b/examples/fastapi-react-site/playwright.config.ts new file mode 100644 index 0000000..1610eca --- /dev/null +++ b/examples/fastapi-react-site/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: '.', + timeout: 15000, + retries: 0, + reporter: 'list', + use: { + baseURL: 'http://localhost:5175', + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + ], +})