From 4cd5db80f6c2153b13c63d0f8906633320c567af Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 15 Jun 2026 13:01:22 -0700 Subject: [PATCH 1/2] feat(skills): add Inertia skill + resourceful controller in FastAPI skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the FastAPI skill stub to teach a full resourceful controller with the seven standard CRUD methods (index, create, store, show, edit, update, destroy) consistent with router.resource(). Add a new Inertia (Inertia.js + FastAPI) skill stub and register the "inertia" provider key in SkillRegistry.skills, mirroring the existing fastapi/database skills. Includes concrete render/props/resource examples, a resourceful Inertia controller, and shared data. Verified end-to-end: both stubs parse, discover() picks up inertia, and publish() exports to .ai/, .claude/skills/, and GEMINI.md. Full suite: 1493 passed, 7 skipped (Postgres-only tests excluded — no live DB). Co-Authored-By: Claude Opus 4.8 --- .../src/fastapi_startkit/skills/registry.py | 3 + .../.ai/fastapi-startkit/fastapi/SKILL.md | 76 ++++++++--- .../.ai/fastapi-startkit/inertia/SKILL.md | 128 ++++++++++++++++++ 3 files changed, 189 insertions(+), 18 deletions(-) create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/inertia/SKILL.md diff --git a/fastapi_startkit/src/fastapi_startkit/skills/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/registry.py index 6d60c5a1..124b4120 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/registry.py +++ b/fastapi_startkit/src/fastapi_startkit/skills/registry.py @@ -35,6 +35,9 @@ class SkillRegistry: "database": [ ".ai/fastapi-startkit/database/SKILL.md", ], + "inertia": [ + ".ai/fastapi-startkit/inertia/SKILL.md", + ], } def __init__(self, app: "Application") -> None: diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md index ace0a63b..db798a26 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md @@ -13,36 +13,76 @@ from fastapi_startkit.fastapi import Router router = Router() ``` -and use the crud resources, for example +and register routes explicitly, for example ```python +router.get("/users", users_controller.index) router.post("/users", users_controller.store) +router.get("/users/{user_id}", users_controller.show) router.put("/users/{user_id}", users_controller.update) -router.patch("/users/{user_id}", users_controller.patch) -router.delete("/users", users_controller.destroy) +router.patch("/users/{user_id}", users_controller.update) +router.delete("/users/{user_id}", users_controller.destroy) ``` -the controller will look like -```python -# app/http/controllers/users_controller.py -async def index(request: Request): - pass +### Resourceful controllers -async def show(user_id: int): - pass +Prefer `router.resource()` to register the seven standard CRUD routes in one +call. It maps to a **resourceful controller** with these methods: -async def store(data: UserSchema): - pass +| Method | Verb & URI | Purpose | +|-----------|-----------------------------|----------------------------------| +| `index` | GET `/users` | List the collection | +| `create` | GET `/users/create` | Show the "new" form | +| `store` | POST `/users` | Persist a new record | +| `show` | GET `/users/{user}` | Show a single record | +| `edit` | GET `/users/{user}/edit` | Show the "edit" form | +| `update` | PUT/PATCH `/users/{user}` | Persist changes to a record | +| `destroy` | DELETE `/users/{user}` | Delete a record | -async def update(user_id: int, data: UserSchema): - pass +```python +# routes/web.py +router.resource("users", users_controller) -async def destroy(user_id: int): - pass +# subset / exclusions +router.resource("users", users_controller, only=['index', 'show']) +router.resource("users", users_controller, excepts=['create', 'edit']) ``` -or use the resource function as: +A full resourceful controller mirrors those seven methods exactly: ```python -router.resource("users", users_controller, excepts=['create', 'edit']) +# app/http/controllers/users_controller.py +from fastapi_startkit.jsonapi import JsonResource + +from app.models import User +from app.http.requests.user_store_request import UserStoreRequest + +async def index(): + users = await User.all() + return JsonResource.collection(users) + +async def create(): + # render/return the "create" form payload + ... + +async def store(request: UserStoreRequest): + user = await User.create(request.model_dump()) + return JsonResource(user) + +async def show(user: int): + return JsonResource(await User.find_or_fail(user)) + +async def edit(user: int): + # render/return the "edit" form payload for the record + return JsonResource(await User.find_or_fail(user)) + +async def update(user: int, request: UserStoreRequest): + record = await User.find_or_fail(user) + await record.update(request.model_dump()) + return JsonResource(record) + +async def destroy(user: int): + record = await User.find_or_fail(user) + await record.delete() + return JsonResource(record) ``` ## ORM diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/inertia/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/inertia/SKILL.md new file mode 100644 index 00000000..b7b59f5f --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/inertia/SKILL.md @@ -0,0 +1,128 @@ +--- +name: fastapi-startkit-inertia +description: Building server-driven SPA pages with Inertia.js + FastAPI in fastapi-startkit (render, props, resources, forms, redirects, shared data). +--- + +# Inertia + FastAPI + +[Inertia.js](https://inertiajs.com/) lets you build a single-page app using +classic server-side routing and controllers. In fastapi-startkit you return an +`InertiaResponse` from a controller and Inertia renders the matching client +page component (Vue/React/Svelte) with the props you pass. + +## Setup + +Inertia ships as a provider. Register `InertiaProvider` and add +`InertiaMiddleware` to the app so `InertiaResponse` can negotiate full-page vs +XHR (partial) requests. + +```python +from fastapi_startkit.inertia import Inertia, InertiaMiddleware, InertiaProvider +``` + +## Rendering a page + +Use the `Inertia` facade. `Inertia.render(component, props)` returns an +`InertiaResponse`; `component` is the client-side page name (e.g. +`"Projects/Edit"`), `props` is a plain dict serialised to the page. + +```python +# app/http/controllers/projects_controller.py +from fastapi_startkit.inertia import Inertia, InertiaResponse + +from app.models import Project +from app.http.resources.project_resource import ProjectResource + + +async def edit(project: int) -> InertiaResponse: + p = await Project.find_or_fail(project) + return Inertia.render("Projects/Edit", { + "project": ProjectResource(p).serialize(), + }) +``` + +Pair resources with Inertia to keep the shape you send to the front-end +consistent. `ProjectResource` is a `JsonResource`; call `.serialize()` to get a +plain dict suitable for props. + +## A resourceful Inertia controller + +Inertia controllers follow the same resourceful method names as the rest of the +framework (`index`, `create`, `store`, `show`, `edit`, `update`, `destroy`). +GET methods render a page; write methods persist and then **redirect** (Inertia +expects a 303 redirect after `store`/`update`/`destroy`, not a JSON body). + +```python +# app/http/controllers/projects_controller.py +from fastapi import Request +from fastapi.responses import RedirectResponse +from fastapi_startkit.inertia import Inertia, InertiaResponse + +from app.models import Project +from app.http.resources.project_resource import ProjectResource + + +async def index() -> InertiaResponse: + projects = await Project.all() + return Inertia.render("Projects/Index", { + "projects": [ProjectResource(p).serialize() for p in projects], + }) + + +async def create() -> InertiaResponse: + return Inertia.render("Projects/Create") + + +async def store(request: Request): + form = await request.json() + await Project.create(form) + return RedirectResponse(url="/projects", status_code=303) + + +async def show(project: int) -> InertiaResponse: + p = await Project.find_or_fail(project) + return Inertia.render("Projects/Show", { + "project": ProjectResource(p).serialize(), + }) + + +async def edit(project: int) -> InertiaResponse: + p = await Project.find_or_fail(project) + return Inertia.render("Projects/Edit", { + "project": ProjectResource(p).serialize(), + }) + + +async def update(project: int, request: Request): + p = await Project.find_or_fail(project) + await p.update(await request.json()) + return RedirectResponse(url=f"/projects/{project}/edit", status_code=303) + + +async def destroy(project: int): + p = await Project.find_or_fail(project) + await p.delete() + return RedirectResponse(url="/projects", status_code=303) +``` + +Register the routes with `router.resource()`: + +```python +# routes/web.py +from fastapi_startkit.fastapi import Router + +from app.http.controllers import projects_controller + +router = Router() +router.resource("projects", projects_controller) +``` + +## Shared data + +Use `Inertia.share()` to expose props on every page (e.g. the authenticated +user or flash messages) without repeating them in each controller. + +```python +Inertia.share("auth", {"user": current_user}) +Inertia.share({"flash": {"success": "Project updated."}}) +``` From bbdccfaf61ed3ef682263952b64defee24aa1fe2 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 15 Jun 2026 13:12:02 -0700 Subject: [PATCH 2/2] docs(skills): add Inertia frontend (React) + request validation to skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the Inertia skill with the client side and server-side validation: - Pages & components: how createInertiaApp resolves Pages/ and props flow - Persistent layouts via `Component.layout = page => {page}` - Inertia form helper `useForm` (data/setData/verb methods/processing/errors), including a search-as-you-type example - Inertia HTTP helper `router` (get/post/delete/reload, visit options) - Request validation with `RequestModel` (Pydantic): EmailStr, constrained str, int, and optional/mixed fields via Field(...) Note: the requested `useHttp` is not a real @inertiajs/react export — the form helper is `useForm` (its data/setData/get/processing API matches the shared snippet) and the programmatic HTTP helper is `router`. Documented both real APIs. EmailStr/Field constraints and RequestModel verified at runtime; stub re-parsed and re-rendered to .claude/ successfully. Co-Authored-By: Claude Opus 4.8 --- .../.ai/fastapi-startkit/inertia/SKILL.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/inertia/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/inertia/SKILL.md index b7b59f5f..99190609 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/inertia/SKILL.md +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/inertia/SKILL.md @@ -117,6 +117,44 @@ router = Router() router.resource("projects", projects_controller) ``` +## Request validation (Pydantic) + +Type-hint a `RequestModel` parameter on a write method and FastAPI validates +the submitted form before your controller runs. `RequestModel` is a Pydantic +model, so use Pydantic types and `Field(...)` constraints — `EmailStr` for +emails, constrained `str`, `int`, optional/mixed fields, etc. Invalid input +raises a `ValidationError` (422) that Inertia surfaces back to the form's +`errors`. + +```python +# app/http/requests/project_store_request.py +from typing import Optional + +from pydantic import EmailStr, Field +from fastapi_startkit.fastapi import RequestModel + + +class ProjectStoreRequest(RequestModel): + name: str = Field(min_length=2, max_length=120) # required string, length-bounded + owner_email: EmailStr # validated email + budget: int = Field(ge=0) # integer, non-negative + priority: int = Field(default=1, ge=1, le=5) # mixed: optional with bounds + description: Optional[str] = Field(default=None, max_length=500) +``` + +```python +# app/http/controllers/projects_controller.py +from fastapi.responses import RedirectResponse + +from app.models import Project +from app.http.requests.project_store_request import ProjectStoreRequest + + +async def store(request: ProjectStoreRequest): + await Project.create(request.model_dump()) + return RedirectResponse(url="/projects", status_code=303) +``` + ## Shared data Use `Inertia.share()` to expose props on every page (e.g. the authenticated @@ -126,3 +164,170 @@ user or flash messages) without repeating them in each controller. Inertia.share("auth", {"user": current_user}) Inertia.share({"flash": {"success": "Project updated."}}) ``` + +# Frontend (React) + +Pages live under `resources/js/Pages/` and are resolved by name in +`resources/js/app.tsx` via `createInertiaApp`. The `component` you pass to +`Inertia.render("Projects/Edit", ...)` maps to `Pages/Projects/Edit.tsx`, and +the controller's `props` arrive as the component's props. + +```tsx +// resources/js/app.tsx +import { createInertiaApp } from '@inertiajs/react' +import { createRoot } from 'react-dom/client' + +createInertiaApp({ + resolve: name => { + const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true }) + return pages[`./Pages/${name}.tsx`] + }, + setup({ el, App, props }) { + createRoot(el).render() + }, +}) +``` + +## Components & props + +A page is a plain component that receives the controller's props. The shape +matches what the resource serialised on the server. + +```tsx +// resources/js/Pages/Projects/Show.tsx +interface ProjectShowProps { + project: { id: number; name: string; owner_email: string } +} + +export default function Show({ project }: ProjectShowProps) { + return ( + <> +

{project.name}

+

Owner: {project.owner_email}

+ + ) +} +``` + +## Persistent layouts + +Assign a `layout` function on the page component so the layout instance is +**kept mounted** across Inertia visits (state, scroll position, etc. persist). + +```tsx +// resources/js/Pages/Welcome.tsx +import Layout from './Layout' + +const Welcome = ({ user }: { user: { name: string } }) => { + return ( + <> +

Welcome

+

Hello {user.name}, welcome to your first Inertia app!

+ + ) +} + +Welcome.layout = (page: React.ReactNode) => {page} + +export default Welcome +``` + +## Inertia form helper — `useForm` + +`useForm` is Inertia's form helper. It tracks `data`, exposes `setData`, the +verb methods (`get`/`post`/`put`/`patch`/`delete`), a `processing` flag, and +server-side `errors` (populated from a 422 `ValidationError`). + +```tsx +// resources/js/Pages/Projects/Create.tsx +import { useForm } from '@inertiajs/react' +import Layout from './Layout' + +const Create = () => { + const { data, setData, post, processing, errors } = useForm({ + name: '', + owner_email: '', + }) + + function submit(e: React.FormEvent) { + e.preventDefault() + post('/projects', { + onSuccess: () => console.log('created'), + }) + } + + return ( +
+ setData('name', e.target.value)} + /> + {errors.name &&
{errors.name}
} + + setData('owner_email', e.target.value)} + /> + {errors.owner_email &&
{errors.owner_email}
} + + +
+ ) +} + +Create.layout = (page: React.ReactNode) => {page} + +export default Create +``` + +`useForm` also drives reactive requests like search-as-you-type — call a verb +method on change and read `processing` for the in-flight state: + +```tsx +import { useForm } from '@inertiajs/react' + +export default function Search() { + const { data, setData, get, processing } = useForm({ query: '' }) + + function search(e: React.ChangeEvent) { + setData('query', e.target.value) + get('/search', { + preserveState: true, + onSuccess: () => console.log('done'), + }) + } + + return ( + <> + + {processing &&
Searching…
} + + ) +} +``` + +## Inertia HTTP helper — `router` + +For programmatic visits outside a form, use the `router` helper. It performs +Inertia visits (`get`/`post`/`put`/`patch`/`delete`/`visit`/`reload`) and +accepts the same options (`preserveState`, `preserveScroll`, `only`, +`onSuccess`, …). + +```tsx +import { router } from '@inertiajs/react' + +// navigate +router.get('/projects') + +// submit data, then reload only specific props +router.post('/projects', { name: 'New' }, { + preserveScroll: true, + onSuccess: () => router.reload({ only: ['projects'] }), +}) + +// delete with a confirmation +function destroy(id: number) { + router.delete(`/projects/${id}`) +} +```