From cfb9c4a26ad59531ac2527c746ba067b05136a66 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 11:08:01 -0700 Subject: [PATCH 01/10] feat(skills): seed canonical SKILL.md files and wire [skills] extra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task #142 — Add five SKILL.md files co-located with their providers: - fastapi/skills/fastapi-routing/ HTTP route definition with Router - fastapi/skills/fastapi-resource-controllers/ RESTful CRUD via router.resource() - masoniteorm/skills/orm-models/ Model class, QueryBuilder, relationships - masoniteorm/skills/orm-migrations/ Blueprint, migration lifecycle, artisan cmds - masoniteorm/commands/skills/console-commands/ Cleo command authoring and registration Each file follows the YAML-frontmatter convention expected by ClaudeAdapter (name + description keys, then markdown body with usage examples drawn from the real source). Task #143 — Add [skills] optional extra to fastapi_startkit/pyproject.toml. SkillsServiceProvider requires no additional pip dependencies; the extra signals intent and documents how to register SkillsServiceProvider so artisan discovers skills:sync / skills:list. Co-Authored-By: Claude Sonnet 4.6 --- fastapi_startkit/pyproject.toml | 12 ++ .../fastapi-resource-controllers/SKILL.md | 82 ++++++++++++ .../fastapi/skills/fastapi-routing/SKILL.md | 72 +++++++++++ .../commands/skills/console-commands/SKILL.md | 101 +++++++++++++++ .../skills/orm-migrations/SKILL.md | 108 ++++++++++++++++ .../masoniteorm/skills/orm-models/SKILL.md | 118 ++++++++++++++++++ 6 files changed, 493 insertions(+) create mode 100644 fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-resource-controllers/SKILL.md create mode 100644 fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-routing/SKILL.md create mode 100644 fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/skills/console-commands/SKILL.md create mode 100644 fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-migrations/SKILL.md create mode 100644 fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-models/SKILL.md diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 4cc4d042..511539ab 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -53,6 +53,18 @@ ai = [ "google-generativeai>=0.8.0", ] +# Skills extra — no additional pip dependencies required. +# All functionality (SkillRegistry, ClaudeAdapter, GeminiAdapter) uses only +# Python stdlib (pathlib, inspect, dataclasses). PyYAML is detected at +# runtime and used when present, but falls back to a built-in parser. +# Install this extra to make SkillsServiceProvider and its artisan commands +# (skills:sync, skills:list) available. Register SkillsServiceProvider in +# your application's providers list to activate command discovery: +# +# from fastapi_startkit.skills.provider import SkillsServiceProvider +# app = Application(providers=[..., SkillsServiceProvider]) +skills = [] + [dependency-groups] dev = [ "dumpdie>=1.5.0", diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-resource-controllers/SKILL.md b/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-resource-controllers/SKILL.md new file mode 100644 index 00000000..82e70e3c --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-resource-controllers/SKILL.md @@ -0,0 +1,82 @@ +--- +name: fastapi-resource-controllers +description: Scaffold RESTful CRUD routes from a controller class using router.resource(). Use when you need the standard index/create/store/show/edit/update/destroy route set with optional filtering. +--- + +# FastAPI Resource Controllers + +`router.resource(name, controller)` registers the seven standard RESTful routes from a single controller object or class. This mirrors the resource-controller convention and keeps route registration DRY. + +## Basic usage + +```python +from fastapi_startkit.fastapi import Router + +router = Router() +router.resource("users", UsersController) +``` + +This registers: + +| Method | Path | Controller method | Route name | +|--------|------|-------------------|------------| +| GET | `/users` | `index` | `users` | +| GET | `/users/create` | `create` | `users.create` | +| POST | `/users` | `store` | `users.store` | +| GET | `/users/{user}` | `show` | `users.show` | +| GET | `/users/{user}/edit` | `edit` | `users.edit` | +| PUT | `/users/{user}` | `update` | `users.update` | +| DELETE | `/users/{user}` | `destroy` | `users.destroy` | + +Only methods that **exist** on the controller are registered — missing methods are silently skipped. + +## Controller pattern + +A resource controller is a plain class with async handler methods: + +```python +class UsersController: + async def index(self): + return await User.all() + + async def show(self, user: int): + return await User.find_or_fail(user) + + async def store(self, data: UserCreateSchema): + return await User.create(data.model_dump()) + + async def update(self, user: int, data: UserUpdateSchema): + instance = await User.find_or_fail(user) + await instance.update(data.model_dump(exclude_unset=True)) + return instance + + async def destroy(self, user: int): + instance = await User.find_or_fail(user) + await instance.delete() + return {"deleted": True} +``` + +## Filtering routes with `only` and `excepts` + +```python +# Only register read routes +router.resource("posts", PostsController, only={"index", "show"}) + +# Register everything except the create/edit form routes +router.resource("posts", PostsController, excepts={"create", "edit"}) +``` + +## Custom route names + +```python +router.resource("posts", PostsController, names={"index": "post.list", "show": "post.detail"}) +``` + +## Custom URL parameter name + +By default the parameter is derived from the resource name (e.g. `users` → `user`). Override it: + +```python +router.resource("categories", CategoriesController, parameters={"categories": "category_id"}) +# Routes become /categories/{category_id} +``` diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-routing/SKILL.md b/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-routing/SKILL.md new file mode 100644 index 00000000..41f4214b --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-routing/SKILL.md @@ -0,0 +1,72 @@ +--- +name: fastapi-routing +description: Define HTTP routes with the fastapi-startkit Router wrapper. Use when adding GET, POST, PUT, PATCH, DELETE endpoints or grouping routes by auth level with shared dependencies. +--- + +# FastAPI Routing + +The `Router` class wraps FastAPI's `APIRouter` and adds a fluent method API. Use it instead of bare `APIRouter` so your routes stay consistent with the rest of the framework. + +## Defining routes + +Import `Router` from `fastapi_startkit.fastapi` and call the HTTP-verb methods: + +```python +from fastapi_startkit.fastapi import Router + +router = Router() + +router.get("/users", users_controller.index) +router.post("/users", users_controller.store) +router.put("/users/{user_id}", users_controller.update) +router.patch("/users/{user_id}", users_controller.partial_update) +router.delete("/users/{user_id}", users_controller.destroy) +``` + +## Grouping routes by access level + +Create separate `Router` instances with shared `dependencies` to split guest and authenticated routes: + +```python +from fastapi import Depends +from fastapi_startkit.fastapi import Router + +guest = Router() +guest.get("/login", auth_controller.create) +guest.post("/login", auth_controller.store) + +auth = Router(dependencies=[Depends(require_auth)]) +auth.get("/dashboard", dashboard_controller.index) +auth.resource("users", users_controller) +``` + +## Including routers in routes/web.py + +Register your routers with the FastAPI application: + +```python +# routes/web.py +from fastapi_startkit import app + +app().fastapi.include_router(guest.router) +app().fastapi.include_router(auth.router) +``` + +## Route options + +Pass any standard FastAPI `add_api_route` keyword arguments as extra kwargs: + +```python +router.get( + "/users", + users_controller.index, + response_model=list[UserSchema], + status_code=200, + tags=["users"], + summary="List all users", +) +``` + +## Accessing the underlying APIRouter + +Use `router.router` to access the raw `APIRouter` instance, or rely on `__getattr__` passthrough for any attributes not defined on `Router` itself. diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/skills/console-commands/SKILL.md b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/skills/console-commands/SKILL.md new file mode 100644 index 00000000..9794e599 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/skills/console-commands/SKILL.md @@ -0,0 +1,101 @@ +--- +name: console-commands +description: Build artisan CLI commands with Cleo in fastapi-startkit. Use when adding new artisan commands, defining arguments/options, accessing the service container, or registering commands via a provider. +--- + +# Console Commands + +Artisan commands are built on [Cleo](https://github.com/python-poetry/cleo). Each command is a class with a `name`, `description`, optional `arguments`/`options`, and a `handle()` method. + +## Defining a command + +```python +from cleo.helpers import argument, option +from fastapi_startkit.console import Command + +class GreetCommand(Command): + name = "greet" + description = "Greet a user by name." + + arguments = [ + argument("username", description="The name to greet"), + ] + options = [ + option("shout", "s", description="Output in uppercase", flag=True), + ] + + def handle(self): + name = self.argument("username") + msg = f"Hello, {name}!" + if self.option("shout"): + msg = msg.upper() + self.line(msg) +``` + +Run it: + +```bash +uv run artisan greet Alice +uv run artisan greet Alice --shout +``` + +## Async commands + +For commands that call `async` framework code (e.g. ORM queries), wrap with `asyncio.run`: + +```python +import asyncio +from fastapi_startkit.console import Command + +class SyncUsersCommand(Command): + name = "users:sync" + description = "Synchronise users from the remote API." + + def handle(self): + asyncio.run(self.handle_async()) + + async def handle_async(self): + from app.models import User + users = await User.where("synced", False).get() + self.line(f"Syncing {len(users)} users…") + # ... do work +``` + +## Accessing the container + +`Command` carries a `container` property set by the framework before `handle()` is called. Resolve any bound service: + +```python +def handle(self): + config = self.container.make("config") + db = self.container.make("db") + self.line(config.get("app.name")) +``` + +## Output helpers + +| Method | Description | +|--------|-------------| +| `self.line(msg)` | Print a line | +| `self.info(msg)` | Print in green | +| `self.comment(msg)` | Print in yellow | +| `self.error(msg)` | Print in red | +| `self.question(msg)` | Print in cyan | +| `self.line_error(msg)` | Print to stderr | +| `self.ask(question)` | Prompt for text input | +| `self.confirm(question)` | Prompt for yes/no | + +## Registering commands in a provider + +Expose commands from a service provider's `boot()` method: + +```python +from fastapi_startkit.providers import Provider +from app.commands import GreetCommand, SyncUsersCommand + +class AppServiceProvider(Provider): + def boot(self) -> None: + self.commands([GreetCommand, SyncUsersCommand]) +``` + +The provider must be registered in `bootstrap/application.py` for the commands to appear in `uv run artisan list`. diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-migrations/SKILL.md b/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-migrations/SKILL.md new file mode 100644 index 00000000..9c8d2c38 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-migrations/SKILL.md @@ -0,0 +1,108 @@ +--- +name: orm-migrations +description: Create and run database schema migrations using the fastapi-startkit ORM. Use when creating tables, adding/dropping columns, or managing migration lifecycle with artisan commands. +--- + +# ORM Migrations + +Migrations describe schema changes as versioned Python files. The `Blueprint` builder maps to SQL DDL for SQLite, MySQL, and PostgreSQL. + +## Creating a migration + +```bash +uv run artisan make:migration create_users_table +``` + +This generates a file in `databases/migrations/` with `up()` and `down()` methods. + +## Migration class structure + +```python +from fastapi_startkit.masoniteorm.migrations import Migration + +class CreateUsersTable(Migration): + async def up(self): + async with await self.schema.create("users") as table: + table.increments("id") + table.string("name") + table.string("email").unique() + table.string("password") + table.enum("role", ["admin", "user"]).default("user") + table.boolean("active").default(True) + table.timestamps() + + async def down(self): + await self.schema.drop("users") +``` + +## Common Blueprint column types + +| Method | SQL type | +|--------|----------| +| `increments("id")` | auto-increment primary key | +| `string("col", length=255)` | VARCHAR | +| `text("col")` | TEXT | +| `integer("col")` | INT | +| `big_integer("col")` | BIGINT | +| `boolean("col")` | BOOLEAN / TINYINT(1) | +| `decimal("col", precision, scale)` | DECIMAL | +| `float_type("col")` | FLOAT | +| `date("col")` | DATE | +| `datetime("col")` | DATETIME | +| `timestamp("col")` | TIMESTAMP | +| `timestamps()` | `created_at` + `updated_at` | +| `soft_deletes()` | `deleted_at` nullable | +| `enum("col", ["a", "b"])` | ENUM | +| `json("col")` | JSON / TEXT | +| `foreign("col")` | foreign key column | +| `uuid("col")` | UUID / CHAR(36) | + +## Column modifiers + +Chain these after any column method: + +```python +table.string("email").unique() +table.string("bio").nullable() +table.string("status").default("active") +table.string("code").unsigned() +table.integer("views").after("title") # MySQL only +``` + +## Altering an existing table + +```python +async def up(self): + async with await self.schema.table("users") as table: + table.add_column("phone", "string", nullable=True) + table.drop_column("legacy_field") + table.rename_column("old_name", "new_name") +``` + +## Running migrations + +```bash +# Run all pending migrations +uv run artisan db:migrate + +# Check migration status +uv run artisan migrate:status + +# Roll back the last batch +uv run artisan migrate:rollback + +# Drop all tables and re-run from scratch +uv run artisan migrate:fresh +``` + +## Migration directory + +By default migrations are read from `databases/migrations/`. Configure a custom path in `config/database.py`: + +```python +@dataclass +class DatabaseConfig: + migrations: dict = field(default_factory=lambda: { + "directory": "databases/migrations" + }) +``` diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-models/SKILL.md b/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-models/SKILL.md new file mode 100644 index 00000000..35c1807c --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-models/SKILL.md @@ -0,0 +1,118 @@ +--- +name: orm-models +description: Define and query database models with the fastapi-startkit async ORM. Use when creating model classes, reading/writing records, filtering with the QueryBuilder, or working with relationships. +--- + +# ORM Models + +`Model` is the base class for all database models. It auto-pluralises the table name, tracks `created_at`/`updated_at` timestamps, and exposes a fluent async `QueryBuilder`. + +## Defining a model + +```python +from fastapi_startkit.masoniteorm import Model + +class User(Model): + name: str + email: str + role: str = "user" +``` + +- Class name is automatically pluralised to the table name (`User` → `users`). Override with `__table__ = "my_users"`. +- Annotated class-level attributes become `__fillable__` fields automatically. +- `created_at` and `updated_at` are managed as `Carbon` (pendulum) objects. + +## Creating records + +```python +user = await User.create({"name": "Alice", "email": "alice@example.com"}) +``` + +## Querying records + +```python +# All records +users = await User.all() + +# Single record by primary key +user = await User.find(1) + +# Raise ModelNotFoundException if not found +user = await User.find_or_fail(1) + +# First matching record +user = await User.where("role", "admin").first() +user = await User.where("role", "admin").first_or_fail() + +# Filtered collection +admins = await User.where("role", "admin").get() + +# Chained conditions +result = await User.where("active", True).where_not_null("email").order_by("name").limit(10).get() +``` + +## Updating and deleting + +```python +user = await User.find_or_fail(1) +await user.update({"name": "Bob"}) +await user.delete() +``` + +## Upserts + +```python +# Find or create +user = await User.first_or_create({"email": "bob@example.com"}, {"name": "Bob"}) + +# Update or create +user = await User.update_or_create({"email": "bob@example.com"}, {"name": "Bob", "role": "editor"}) +``` + +## Aggregates + +```python +count = await User.count() +exists = await User.where("email", "x@example.com").exists() +``` + +## Relationships + +Declare relationships as class-level descriptors: + +```python +from fastapi_startkit.masoniteorm.relationships import HasMany, BelongsTo + +class Post(Model): + title: str + user_id: int + + author = BelongsTo("User", foreign_key="user_id") + +class User(Model): + name: str + + posts = HasMany("Post", foreign_key="user_id") +``` + +Eager-load relationships to avoid N+1 queries: + +```python +users = await User.with_("posts").get() +for user in users: + print(user.posts) +``` + +## Connections + +Switch the database connection at query time: + +```python +users = await User.on("read_replica").where("active", True).get() +``` + +## Raw where clauses + +```python +users = await User.where_raw("lower(email) = ?", ("alice@example.com",)).get() +``` From f1cbf181c625fb73172bd60471e08fd5b6cca331 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 15:56:49 -0700 Subject: [PATCH 02/10] feat(skills): add broadcasting SKILL.md (#156) Co-locates broadcasting/skills/broadcasting/SKILL.md with ReverbProvider so SkillRegistry discovers it when the provider is registered. Documents the no-facade API: - BroadcastEvent subclass + broadcast_on() + payload dict - event.emit() and await broadcast(event) for dispatch - Channel / PrivateChannel / PresenceChannel with auto-prefix behaviour - @channel(pattern) decorator in routes/channels.py for auth callbacks - ReverbProvider registration Co-Authored-By: Claude Sonnet 4.6 --- .../broadcasting/skills/broadcasting/SKILL.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 fastapi_startkit/src/fastapi_startkit/broadcasting/skills/broadcasting/SKILL.md diff --git a/fastapi_startkit/src/fastapi_startkit/broadcasting/skills/broadcasting/SKILL.md b/fastapi_startkit/src/fastapi_startkit/broadcasting/skills/broadcasting/SKILL.md new file mode 100644 index 00000000..d17f5558 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/broadcasting/skills/broadcasting/SKILL.md @@ -0,0 +1,101 @@ +--- +name: broadcasting +description: WebSocket event broadcasting via Reverb — define events, emit to channels, authorize subscribers. +--- + +# Broadcasting + +The broadcasting module lets you push real-time events from the server to connected WebSocket clients using the Reverb driver (Pusher protocol). Use it whenever you need live updates — order status, notifications, presence indicators, chat messages — without polling. + +## When to use + +- Notify a user that their background job completed +- Push live order-status updates to a dashboard +- Show who else is viewing a page (presence channels) +- Fan-out notifications to a group of authenticated users + +## Defining an event + +Subclass `BroadcastEvent`, implement `broadcast_on()` to declare target channels, and set `payload` with the data to send: + +```python +from fastapi_startkit.broadcasting import BroadcastEvent, PrivateChannel + +class OrderShipped(BroadcastEvent): + def __init__(self, order_id: int) -> None: + self.payload = {"order_id": order_id, "status": "shipped"} + + def broadcast_on(self) -> list: + return [PrivateChannel(f"orders.{self.payload['order_id']}")] +``` + +- `broadcast_on()` must return a list of `Channel`, `PrivateChannel`, or `PresenceChannel` objects. +- `payload` (dict) is what subscribers receive. Defaults to `{}`. +- The event name on the wire is the class name (`"OrderShipped"`) unless you set `name = "custom.name"` on the class. + +## Emitting an event + +Call `.emit()` on an event instance — it dispatches to all channels in `broadcast_on()`: + +```python +OrderShipped(order_id=123).emit() +``` + +For an awaitable dispatch (e.g. inside an async route handler) use the `broadcast` helper directly: + +```python +from fastapi_startkit.broadcasting import broadcast + +await broadcast(OrderShipped(order_id=123)) +``` + +## Channel types + +| Class | Channel name on wire | Auth required | +|-------|----------------------|---------------| +| `Channel("chat")` | `chat` | No — public, open to all | +| `PrivateChannel("orders.1")` | `private-orders.1` | Yes — checked via `@channel` callback | +| `PresenceChannel("room.1")` | `presence-room.1` | Yes — checked + member tracking | + +`PrivateChannel` and `PresenceChannel` automatically prepend `private-` / `presence-` to the name you supply. + +## Channel authorization + +Private and presence channels require a server-side authorization callback. Register callbacks in `routes/channels.py` using the `@channel` decorator: + +```python +# routes/channels.py +from fastapi_startkit.broadcasting import channel + +@channel("orders.{order_id}") +async def authorize_orders(user, order_id: int) -> bool: + """Grant access only to the user who owns the order.""" + return user is not None and user.id == order_id + +@channel("private-notifications") +async def authorize_notifications(user) -> bool: + return user is not None +``` + +- The pattern supports `{wildcard}` placeholders. Wildcard values are cast to the declared parameter type (e.g. `order_id: int`). +- `user` is the authenticated user injected from the container's auth service. +- Return `True` to grant access, `False` to deny. +- Private/presence channels with **no registered callback are denied by default** (fail-safe). +- `routes/channels.py` is auto-loaded by `ReverbProvider` on boot. + +## Registering the provider + +Add `ReverbProvider` to your application's providers list: + +```python +from fastapi_startkit.broadcasting import ReverbProvider + +app = Application( + providers=[ + ..., + ReverbProvider, + ] +) +``` + +`ReverbProvider` binds `BroadcastManager` into the container, mounts the Reverb WebSocket endpoint, and registers the `/broadcasting/auth` HTTP route used by Laravel Echo for channel handshakes. From 965181ba6b9aa246af4641cb10741fc6807c7c78 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 16:15:14 -0700 Subject: [PATCH 03/10] feat(skills): async controllers, advanced ORM patterns, architecture rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fastapi-resource-controllers/SKILL.md: - Rewrite controller example as full async (async def + await on all ORM) - Show ResourceCollection return type and TaskResource.collection(paginator) - Add "Advanced ORM query patterns" section with: lambda-grouped WHERE clauses, paginate() with JSON:API meta, where_not_in, or_where_raw masoniteorm/skills/orm-models/SKILL.md: - Add "Advanced query patterns" section: lambda-grouped WHERE (SubGroup), where_not_in, OR conditions (or_where / or_where_null / or_where_raw), paginate() + LengthAwarePaginator, simple_paginate, joins - Raw-fragment guidance: use where_raw sparingly .ai/laravel/skill/laravel-best-practices/rules/architecture.md (task #157): - 10 architecture rules with do/don't Python examples: 1. Provider register() vs boot() separation 2. Container injection via resolve() over service locator 3. Single responsibility per provider 4. Resourceful controllers — async def, ResourceCollection return 5. ORM conventions — await discipline, relationship descriptors 6. Route organisation — guest/auth Router split, middleware at Router level 7. Config via dataclass + env(), no hardcoded values 8. Business logic in services, not controllers/routes 9. Broadcasting — BroadcastEvent, @channel auth, no facades 10. Async discipline — no blocking I/O in async context Co-Authored-By: Claude Sonnet 4.6 --- .../rules/architecture.md | 289 ++++++++++++++++++ .../fastapi-resource-controllers/SKILL.md | 131 ++++++-- .../masoniteorm/skills/orm-models/SKILL.md | 129 ++++++-- 3 files changed, 495 insertions(+), 54 deletions(-) create mode 100644 .ai/laravel/skill/laravel-best-practices/rules/architecture.md diff --git a/.ai/laravel/skill/laravel-best-practices/rules/architecture.md b/.ai/laravel/skill/laravel-best-practices/rules/architecture.md new file mode 100644 index 00000000..1403fa7f --- /dev/null +++ b/.ai/laravel/skill/laravel-best-practices/rules/architecture.md @@ -0,0 +1,289 @@ +# Architecture Rules — fastapi-startkit (Laravel-inspired) + +This framework follows Laravel conventions adapted to Python and FastAPI. The rules below govern how to structure providers, containers, controllers, models, routes, and config in a fastapi-startkit application. + +--- + +## Rule 1 — Provider pattern: `register()` for bindings, `boot()` for logic + +`register()` must only bind things into the container. Never resolve other bindings inside `register()` — other providers may not have run yet. All logic that depends on resolved services belongs in `boot()`. + +```python +# ✅ DO +class OrdersProvider(Provider): + def register(self) -> None: + self.app.bind("orders", OrderService()) # pure binding + + def boot(self) -> None: + service = self.app.make("orders") # safe to resolve here + service.configure(self.app.make("config").get("orders")) + +# ❌ DON'T +class OrdersProvider(Provider): + def register(self) -> None: + config = self.app.make("config") # resolving in register() — unsafe + self.app.bind("orders", OrderService(config)) +``` + +--- + +## Rule 2 — Service container: prefer constructor injection via `resolve()` + +Use `app.resolve(callable)` to auto-wire dependencies by type hint instead of calling `app.make()` manually (service locator anti-pattern). Reserve `make()` for entry points and bootstrap code. + +```python +# ✅ DO — container resolves OrderRepository automatically +class OrderService: + def __init__(self, repo: OrderRepository) -> None: + self.repo = repo + +instance = app().resolve(OrderService) + +# ❌ DON'T — service locator hides dependencies +class OrderService: + def __init__(self) -> None: + self.repo = app().make("orders.repo") # hidden dependency +``` + +--- + +## Rule 3 — Single responsibility per provider + +Each `Provider` subclass owns exactly one concern. Mixing unrelated bindings into one provider makes the app harder to understand and test. + +```python +# ✅ DO — separate providers +class AuthProvider(Provider): + def register(self) -> None: + self.app.bind("auth", AuthManager()) + +class MailProvider(Provider): + def register(self) -> None: + self.app.bind("mail", MailManager()) + +# ❌ DON'T — one provider for everything +class AppProvider(Provider): + def register(self) -> None: + self.app.bind("auth", AuthManager()) + self.app.bind("mail", MailManager()) + self.app.bind("cache", CacheManager()) + self.app.bind("queue", QueueManager()) +``` + +--- + +## Rule 4 — Resourceful controllers: one per resource, all `async def` + +One controller class per resource. Use the standard `index / show / store / update / destroy` method names. Every method must be `async def`. Collection endpoints return `ResourceCollection`; single-resource endpoints return a `Resource`. + +```python +# ✅ DO +from fastapi_startkit.jsonapi import JsonResource, ResourceCollection + +class TaskResource(JsonResource["Task"]): + hidden = ["deleted_at"] + +class TasksController: + async def index(self, project_id: int) -> ResourceCollection: + tasks = await Task.where("project_id", project_id).paginate() + return TaskResource.collection(tasks) + + async def show(self, task: int) -> TaskResource: + return TaskResource(await Task.find_or_fail(task)) + + async def store(self, data: TaskCreateSchema) -> TaskResource: + return TaskResource(await Task.create(data.model_dump())) + + async def destroy(self, task: int) -> dict: + await (await Task.find_or_fail(task)).delete() + return {"deleted": True} + +# ❌ DON'T — sync methods, raw dicts, no resource wrapping +class TasksController: + def index(self): # missing async + return Task.all() # missing await, returns raw collection + def get_task(self, id): # non-standard method name + return {"id": id, "title": "..."} # raw dict, no Resource +``` + +--- + +## Rule 5 — ORM conventions: always `await`, relationships as descriptors + +Every ORM query is async — always `await` it. Declare relationships as class-level descriptors, not inside methods. Avoid raw SQL unless no ORM equivalent exists; prefer `where_raw` / `or_where_raw` over embedding SQL strings elsewhere. + +```python +# ✅ DO +from fastapi_startkit.masoniteorm import Model +from fastapi_startkit.masoniteorm.relationships import HasMany, BelongsTo + +class Post(Model): + title: str + user_id: int + author = BelongsTo("User", foreign_key="user_id") # descriptor + +async def get_posts(user_id: int): + return await Post.where("user_id", user_id).get() # awaited + +# ❌ DON'T +class Post(Model): + async def get_author(self): # relationship inside a method — not a descriptor + return await User.find(self.user_id) + +def get_posts(user_id: int): # not async + return Post.where("user_id", user_id).get() # unawaited coroutine — silent bug +``` + +--- + +## Rule 6 — Route organisation: separate guest/auth routers, middleware at Router level + +Create distinct `Router` instances for guest and authenticated routes. Apply middleware (e.g. `Depends(require_auth)`) at the `Router` constructor level, not per-route. Use `router.resource()` for CRUD resources. + +```python +# ✅ DO +from fastapi import Depends +from fastapi_startkit.fastapi import Router + +guest = Router() +guest.get("/login", auth_controller.create) +guest.post("/login", auth_controller.store) + +auth = Router(dependencies=[Depends(require_auth)]) # middleware at Router level +auth.resource("tasks", tasks_controller) +auth.resource("projects", projects_controller) + +# ❌ DON'T — scatters middleware across individual routes +router = Router() +router.get("/tasks", tasks_controller.index, dependencies=[Depends(require_auth)]) +router.post("/tasks", tasks_controller.store, dependencies=[Depends(require_auth)]) +router.get("/tasks/{id}", tasks_controller.show, dependencies=[Depends(require_auth)]) +``` + +--- + +## Rule 7 — Configuration: dataclass + `env()`, never hardcoded values + +Define all config as a `@dataclass` with fields sourced via `env()`. Register the config object in a provider. Never hardcode hostnames, keys, or connection strings in application code. + +```python +# ✅ DO +from dataclasses import dataclass, field +from fastapi_startkit.environment import env + +@dataclass +class RedisConfig: + host: str = field(default_factory=lambda: env("REDIS_HOST", "127.0.0.1")) + port: int = field(default_factory=lambda: env("REDIS_PORT", 6379)) + password: str = field(default_factory=lambda: env("REDIS_PASSWORD", "")) + +class RedisProvider(Provider): + def register(self) -> None: + self.app.bind("redis.config", RedisConfig()) + +# ❌ DON'T — hardcoded values +REDIS_HOST = "127.0.0.1" +REDIS_PORT = 6379 + +class RedisClient: + def __init__(self): + self.host = "127.0.0.1" # hardcoded — breaks across environments +``` + +--- + +## Rule 8 — No business logic in routes or controllers + +Routes delegate to controllers; controllers delegate to service classes or models. Business logic (validation beyond Pydantic, workflow orchestration, external API calls) belongs in a dedicated service layer. + +```python +# ✅ DO — controller is thin, delegates to service +class OrdersController: + async def store(self, data: OrderCreateSchema) -> OrderResource: + order = await OrderService().place(data) + return OrderResource(order) + +class OrderService: + async def place(self, data: OrderCreateSchema) -> Order: + await self._validate_inventory(data.items) + order = await Order.create(data.model_dump()) + await self._charge_payment(order) + OrderShipped(order.id).emit() + return order + +# ❌ DON'T — business logic inline in a controller method +class OrdersController: + async def store(self, data: OrderCreateSchema) -> dict: + for item in data.items: + stock = await Inventory.find(item.id) + if stock.quantity < item.qty: + raise HTTPException(400, "Out of stock") + order = await Order.create(data.model_dump()) + await stripe.charge(order.total) # payment logic in controller + OrderShipped(order.id).emit() + return {"id": order.id} +``` + +--- + +## Rule 9 — Broadcasting: `BroadcastEvent` subclasses, auth in `routes/channels.py`, no facades + +Define events as `BroadcastEvent` subclasses. Dispatch with `.emit()` or `await broadcast(event)`. Authorize private/presence channels exclusively in `routes/channels.py` using the `@channel` decorator. Do not use the `Broadcast` facade. + +```python +# ✅ DO +from fastapi_startkit.broadcasting import BroadcastEvent, PrivateChannel, channel, broadcast + +class OrderShipped(BroadcastEvent): + def __init__(self, order_id: int) -> None: + self.payload = {"order_id": order_id} + def broadcast_on(self) -> list: + return [PrivateChannel(f"orders.{self.payload['order_id']}")] + +# Dispatch (fire-and-forget) +OrderShipped(order_id=123).emit() + +# routes/channels.py — auth callbacks +@channel("orders.{order_id}") +async def authorize_orders(user, order_id: int) -> bool: + return user is not None and user.id == order_id + +# ❌ DON'T +from fastapi_startkit.facades.Broadcast import Broadcast + +await Broadcast.event(event) # facade — avoid +await Broadcast.dispatch(event) # facade — avoid + +# auth callback inline in a route handler — not in routes/channels.py +@router.post("/some-route") +async def handler(): + if not await check_channel_auth(...): # auth logic leaking into route + ... +``` + +--- + +## Rule 10 — Async discipline: all I/O is `async/await`, no blocking calls in async context + +Every function that performs I/O (database, HTTP, file system, sleep) must be `async def` and `await`-ed at every call site. Never call synchronous blocking I/O (e.g. `requests.get`, `time.sleep`, sync file reads) from inside an async function — it blocks the entire event loop. + +```python +# ✅ DO +import asyncio +import httpx + +async def fetch_and_save(url: str) -> None: + async with httpx.AsyncClient() as client: + response = await client.get(url) # async HTTP + record = await ExternalData.create({"body": response.text}) # async ORM + await asyncio.sleep(1) # async sleep + +# ❌ DON'T +import requests +import time + +async def fetch_and_save(url: str) -> None: + response = requests.get(url) # BLOCKS the event loop + time.sleep(1) # BLOCKS the event loop + ExternalData.create({"body": response.text}) # unawaited coroutine — silent bug +``` diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-resource-controllers/SKILL.md b/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-resource-controllers/SKILL.md index 82e70e3c..b1f6fb4a 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-resource-controllers/SKILL.md +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-resource-controllers/SKILL.md @@ -13,68 +13,139 @@ description: Scaffold RESTful CRUD routes from a controller class using router.r from fastapi_startkit.fastapi import Router router = Router() -router.resource("users", UsersController) +router.resource("tasks", TasksController) ``` This registers: | Method | Path | Controller method | Route name | |--------|------|-------------------|------------| -| GET | `/users` | `index` | `users` | -| GET | `/users/create` | `create` | `users.create` | -| POST | `/users` | `store` | `users.store` | -| GET | `/users/{user}` | `show` | `users.show` | -| GET | `/users/{user}/edit` | `edit` | `users.edit` | -| PUT | `/users/{user}` | `update` | `users.update` | -| DELETE | `/users/{user}` | `destroy` | `users.destroy` | +| GET | `/tasks` | `index` | `tasks` | +| GET | `/tasks/create` | `create` | `tasks.create` | +| POST | `/tasks` | `store` | `tasks.store` | +| GET | `/tasks/{task}` | `show` | `tasks.show` | +| GET | `/tasks/{task}/edit` | `edit` | `tasks.edit` | +| PUT | `/tasks/{task}` | `update` | `tasks.update` | +| DELETE | `/tasks/{task}` | `destroy` | `tasks.destroy` | Only methods that **exist** on the controller are registered — missing methods are silently skipped. ## Controller pattern -A resource controller is a plain class with async handler methods: +All controller methods must be `async def`. ORM calls must be `await`-ed. Collection endpoints return `ResourceCollection`; single-resource endpoints return a `Resource` instance. ```python -class UsersController: - async def index(self): - return await User.all() - - async def show(self, user: int): - return await User.find_or_fail(user) - - async def store(self, data: UserCreateSchema): - return await User.create(data.model_dump()) - - async def update(self, user: int, data: UserUpdateSchema): - instance = await User.find_or_fail(user) +import datetime +from fastapi_startkit.jsonapi import JsonResource, ResourceCollection + +class TaskResource(JsonResource["Task"]): + hidden = ["deleted_at"] + +class TasksController: + async def index(self, project_id: int) -> ResourceCollection: + cutoff = ( + datetime.datetime.now() - datetime.timedelta(days=7) + ).isoformat() + + tasks = await ( + Task + .where("project_id", project_id) + .where(lambda q: ( + q.where_not_in("tasks.status", ["completed", "cancelled"]) + .or_where("tasks.completed_at", ">=", cutoff) + .or_where_raw("tasks.completed_at IS NULL") + )) + .paginate() + ) + + return TaskResource.collection(tasks) + + async def show(self, task: int) -> TaskResource: + return TaskResource(await Task.find_or_fail(task)) + + async def store(self, data: TaskCreateSchema) -> TaskResource: + instance = await Task.create(data.model_dump()) + return TaskResource(instance) + + async def update(self, task: int, data: TaskUpdateSchema) -> TaskResource: + instance = await Task.find_or_fail(task) await instance.update(data.model_dump(exclude_unset=True)) - return instance + return TaskResource(instance) - async def destroy(self, user: int): - instance = await User.find_or_fail(user) + async def destroy(self, task: int) -> dict: + instance = await Task.find_or_fail(task) await instance.delete() return {"deleted": True} ``` +## Advanced ORM query patterns + +### Lambda-grouped WHERE clauses + +Pass a lambda to `where()` to wrap a group of conditions in parentheses. The lambda receives a fresh `QueryBuilder` and must return it after chaining: + +```python +# SQL: WHERE project_id = ? AND (status NOT IN (?,?) OR completed_at >= ? OR completed_at IS NULL) +tasks = await ( + Task + .where("project_id", project_id) + .where(lambda q: ( + q.where_not_in("status", ["completed", "cancelled"]) + .or_where("completed_at", ">=", cutoff) + .or_where_raw("completed_at IS NULL") + )) + .get() +) +``` + +### Pagination + +`paginate()` returns a `LengthAwarePaginator` with total / per_page / current_page attributes. Pass it directly to `Resource.collection()` — pagination meta is included automatically in the JSON:API envelope. + +```python +# Default: 15 per page, page 1 +paginator = await Task.where("active", True).paginate() + +# Custom page size and page number (inject from query params) +paginator = await Task.where("active", True).paginate(per_page=25, page=page) + +return TaskResource.collection(paginator) +# → {"data": [...], "meta": {"total": 120, "per_page": 25, "current_page": 2, ...}} +``` + +### Excluding values + +```python +active = await Task.where_not_in("status", ["archived", "deleted"]).get() +``` + +### Raw fragments + +Use `where_raw` / `or_where_raw` only when the ORM cannot express the condition — not as a default: + +```python +tasks = await Task.where_raw("LOWER(title) LIKE ?", ("%search%",)).get() +``` + ## Filtering routes with `only` and `excepts` ```python -# Only register read routes -router.resource("posts", PostsController, only={"index", "show"}) +# Read-only resource +router.resource("reports", ReportsController, only={"index", "show"}) -# Register everything except the create/edit form routes -router.resource("posts", PostsController, excepts={"create", "edit"}) +# Skip HTML form routes (API-only) +router.resource("tasks", TasksController, excepts={"create", "edit"}) ``` ## Custom route names ```python -router.resource("posts", PostsController, names={"index": "post.list", "show": "post.detail"}) +router.resource("tasks", TasksController, names={"index": "task.list", "show": "task.detail"}) ``` ## Custom URL parameter name -By default the parameter is derived from the resource name (e.g. `users` → `user`). Override it: +By default the parameter is derived from the resource name (`tasks` → `task`). Override it: ```python router.resource("categories", CategoriesController, parameters={"categories": "category_id"}) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-models/SKILL.md b/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-models/SKILL.md index 35c1807c..85ecaeb3 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-models/SKILL.md +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-models/SKILL.md @@ -28,19 +28,17 @@ class User(Model): user = await User.create({"name": "Alice", "email": "alice@example.com"}) ``` -## Querying records +## Basic querying ```python # All records users = await User.all() -# Single record by primary key +# By primary key user = await User.find(1) +user = await User.find_or_fail(1) # raises ModelNotFoundException if missing -# Raise ModelNotFoundException if not found -user = await User.find_or_fail(1) - -# First matching record +# First match user = await User.where("role", "admin").first() user = await User.where("role", "admin").first_or_fail() @@ -48,7 +46,14 @@ user = await User.where("role", "admin").first_or_fail() admins = await User.where("role", "admin").get() # Chained conditions -result = await User.where("active", True).where_not_null("email").order_by("name").limit(10).get() +result = await ( + User + .where("active", True) + .where_not_null("email") + .order_by("name") + .limit(10) + .get() +) ``` ## Updating and deleting @@ -62,23 +67,109 @@ await user.delete() ## Upserts ```python -# Find or create user = await User.first_or_create({"email": "bob@example.com"}, {"name": "Bob"}) - -# Update or create user = await User.update_or_create({"email": "bob@example.com"}, {"name": "Bob", "role": "editor"}) ``` ## Aggregates ```python -count = await User.count() +count = await User.count() exists = await User.where("email", "x@example.com").exists() +total = await Order.sum("amount") ``` -## Relationships +## Advanced query patterns + +### Lambda-grouped WHERE clauses + +Pass a lambda to `where()` to group conditions in parentheses. The lambda receives a fresh `QueryBuilder` and must return it after chaining: + +```python +import datetime + +cutoff = (datetime.datetime.now() - datetime.timedelta(days=7)).isoformat() + +# SQL: +# WHERE project_id = ? +# AND ( +# status NOT IN (?,?) +# OR completed_at >= ? +# OR completed_at IS NULL +# ) +tasks = await ( + Task + .where("project_id", project_id) + .where(lambda q: ( + q.where_not_in("status", ["completed", "cancelled"]) + .or_where("completed_at", ">=", cutoff) + .or_where_raw("completed_at IS NULL") + )) + .get() +) +``` + +### Excluding values + +```python +active = await Task.where_not_in("status", ["archived", "deleted"]).get() +``` + +### OR conditions + +```python +results = await Task.where("priority", "high").or_where("due_today", True).get() +results = await Task.where_null("deleted_at").or_where_null("archived_at").get() +``` + +### Raw fragments + +Use `where_raw` / `or_where_raw` only when the ORM cannot express the condition: + +```python +tasks = await Task.where_raw("LOWER(title) LIKE ?", ("%search%",)).get() +tasks = await Task.where("active", True).or_where_raw("priority > 5").get() +``` + +### Pagination -Declare relationships as class-level descriptors: +`paginate()` returns a `LengthAwarePaginator`. Pass it to `JsonResource.collection()` and pagination meta is included automatically in the JSON:API envelope. + +```python +# 15 records per page (default) +paginator = await Task.where("project_id", project_id).paginate() + +# Custom page size and page (inject from request query params) +paginator = await Task.where("project_id", project_id).paginate(per_page=25, page=page) + +# paginator attributes: total, per_page, current_page, last_page +print(paginator.total, paginator.current_page) + +# Wrap for JSON:API response +from fastapi_startkit.jsonapi import JsonResource +return TaskResource.collection(paginator) +# → {"data": [...], "meta": {"total": 120, "per_page": 25, "current_page": 2, ...}} +``` + +For a lighter paginator that only detects whether a next page exists (no COUNT query): + +```python +paginator = await Task.paginate_simple(per_page=25, page=page) +``` + +### Joins + +```python +tasks = await ( + Task + .join("projects", "tasks.project_id", "=", "projects.id") + .where("projects.owner_id", user_id) + .select("tasks.*", "projects.name") + .get() +) +``` + +## Relationships ```python from fastapi_startkit.masoniteorm.relationships import HasMany, BelongsTo @@ -86,16 +177,14 @@ from fastapi_startkit.masoniteorm.relationships import HasMany, BelongsTo class Post(Model): title: str user_id: int - author = BelongsTo("User", foreign_key="user_id") class User(Model): name: str - posts = HasMany("Post", foreign_key="user_id") ``` -Eager-load relationships to avoid N+1 queries: +Eager-load to avoid N+1 queries: ```python users = await User.with_("posts").get() @@ -105,14 +194,6 @@ for user in users: ## Connections -Switch the database connection at query time: - ```python users = await User.on("read_replica").where("active", True).get() ``` - -## Raw where clauses - -```python -users = await User.where_raw("lower(email) = ?", ("alice@example.com",)).get() -``` From a05f3a19a3e29aa2c937d208c6931d73e76fed0b Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 16:23:30 -0700 Subject: [PATCH 04/10] refactor(skills): move all SKILL.md files to publishable stubs layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-requested rework: all skills must live in the centralized stubs tree so they are published via `artisan provider:publish` and scanned by SkillRegistry from the project root .ai/ directory. New canonical path for every skill: skills/stubs/.ai/fastapi-startkit/skill//SKILL.md Moves (6 renames + 1 rule file rename, all history-preserving): fastapi/skills/fastapi-routing/ → stubs/.ai/.../fastapi-routing/ fastapi/skills/fastapi-resource-controllers/ → stubs/.ai/.../fastapi-resource-controllers/ masoniteorm/skills/orm-models/ → stubs/.ai/.../orm-models/ masoniteorm/skills/orm-migrations/ → stubs/.ai/.../orm-migrations/ masoniteorm/commands/skills/console-commands/→ stubs/.ai/.../console-commands/ broadcasting/skills/broadcasting/ → stubs/.ai/.../broadcasting/ .ai/laravel/…/rules/architecture.md → stubs/.ai/.../laravel-best-practices/rules/architecture.md New file: stubs/.ai/.../laravel-best-practices/SKILL.md — parent skill for the rules/ directory (frontmatter + body listing rules/architecture.md) Co-Authored-By: Claude Sonnet 4.6 --- .../skill}/broadcasting/SKILL.md | 0 .../skill}/console-commands/SKILL.md | 0 .../fastapi-resource-controllers/SKILL.md | 0 .../skill}/fastapi-routing/SKILL.md | 0 .../skill/laravel-best-practices/SKILL.md | 30 +++++++++++++++++++ .../rules/architecture.md | 0 .../skill}/orm-migrations/SKILL.md | 0 .../skill}/orm-models/SKILL.md | 0 8 files changed, 30 insertions(+) rename fastapi_startkit/src/fastapi_startkit/{broadcasting/skills => skills/stubs/.ai/fastapi-startkit/skill}/broadcasting/SKILL.md (100%) rename fastapi_startkit/src/fastapi_startkit/{masoniteorm/commands/skills => skills/stubs/.ai/fastapi-startkit/skill}/console-commands/SKILL.md (100%) rename fastapi_startkit/src/fastapi_startkit/{fastapi/skills => skills/stubs/.ai/fastapi-startkit/skill}/fastapi-resource-controllers/SKILL.md (100%) rename fastapi_startkit/src/fastapi_startkit/{fastapi/skills => skills/stubs/.ai/fastapi-startkit/skill}/fastapi-routing/SKILL.md (100%) create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/SKILL.md rename {.ai/laravel => fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit}/skill/laravel-best-practices/rules/architecture.md (100%) rename fastapi_startkit/src/fastapi_startkit/{masoniteorm/skills => skills/stubs/.ai/fastapi-startkit/skill}/orm-migrations/SKILL.md (100%) rename fastapi_startkit/src/fastapi_startkit/{masoniteorm/skills => skills/stubs/.ai/fastapi-startkit/skill}/orm-models/SKILL.md (100%) diff --git a/fastapi_startkit/src/fastapi_startkit/broadcasting/skills/broadcasting/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md similarity index 100% rename from fastapi_startkit/src/fastapi_startkit/broadcasting/skills/broadcasting/SKILL.md rename to fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/skills/console-commands/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/console-commands/SKILL.md similarity index 100% rename from fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/skills/console-commands/SKILL.md rename to fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/console-commands/SKILL.md diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-resource-controllers/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-resource-controllers/SKILL.md similarity index 100% rename from fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-resource-controllers/SKILL.md rename to fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-resource-controllers/SKILL.md diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-routing/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-routing/SKILL.md similarity index 100% rename from fastapi_startkit/src/fastapi_startkit/fastapi/skills/fastapi-routing/SKILL.md rename to fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-routing/SKILL.md diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..b9ef101c --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/SKILL.md @@ -0,0 +1,30 @@ +--- +name: laravel-best-practices +description: Architectural and code conventions for fastapi-startkit applications — provider pattern, container, resourceful controllers, ORM, routing, config, and async discipline. +--- + +# Laravel Best Practices for fastapi-startkit + +This skill contains the canonical architecture rules for fastapi-startkit projects. The framework is heavily Laravel-inspired (provider pattern, IoC container, artisan commands, facades, ORM, resourceful routing), so conventions follow both Laravel idioms and Python/FastAPI specifics. + +## Rules files + +- [`rules/architecture.md`](rules/architecture.md) — 10 rules covering: + 1. Provider `register()` vs `boot()` separation + 2. Service container injection via `resolve()`, not service locator + 3. Single responsibility per `Provider` + 4. Resourceful controllers — `async def`, `ResourceCollection` return types + 5. ORM conventions — `await` discipline, relationship descriptors, minimal raw SQL + 6. Route organisation — guest/auth `Router` split, middleware at `Router` level + 7. Configuration via `@dataclass` + `env()`, no hardcoded values + 8. No business logic in routes or controllers — delegate to services + 9. Broadcasting — `BroadcastEvent` subclasses, `@channel` auth callbacks, no facades + 10. Async discipline — no blocking I/O in async context + +## When to use + +Apply these rules when: +- Starting a new fastapi-startkit application +- Reviewing a pull request for architecture compliance +- Deciding where to put new code (provider vs service vs controller vs model) +- Debugging async issues or unexpected blocking behaviour diff --git a/.ai/laravel/skill/laravel-best-practices/rules/architecture.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/rules/architecture.md similarity index 100% rename from .ai/laravel/skill/laravel-best-practices/rules/architecture.md rename to fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/rules/architecture.md diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-migrations/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-migrations/SKILL.md similarity index 100% rename from fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-migrations/SKILL.md rename to fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-migrations/SKILL.md diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-models/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-models/SKILL.md similarity index 100% rename from fastapi_startkit/src/fastapi_startkit/masoniteorm/skills/orm-models/SKILL.md rename to fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-models/SKILL.md From a87057d5920dbd9138a8aea54ad67fc8fc6009d9 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 17:55:47 -0700 Subject: [PATCH 05/10] fix(skills): await all emit() calls in broadcasting and architecture docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BroadcastEvent.emit() is async — every example must use await .emit(). Also update Rule 9 description in architecture.md to match. Co-Authored-By: Claude Sonnet 4.6 --- .../stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md | 6 +++--- .../skill/laravel-best-practices/rules/architecture.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md index d17f5558..afa47d4e 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md @@ -35,13 +35,13 @@ class OrderShipped(BroadcastEvent): ## Emitting an event -Call `.emit()` on an event instance — it dispatches to all channels in `broadcast_on()`: +Call `await .emit()` on an event instance — it dispatches to all channels in `broadcast_on()`: ```python -OrderShipped(order_id=123).emit() +await OrderShipped(order_id=123).emit() ``` -For an awaitable dispatch (e.g. inside an async route handler) use the `broadcast` helper directly: +Alternatively use the `broadcast` helper directly: ```python from fastapi_startkit.broadcasting import broadcast diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/rules/architecture.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/rules/architecture.md index 1403fa7f..b6e68a8f 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/rules/architecture.md +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/rules/architecture.md @@ -228,7 +228,7 @@ class OrdersController: ## Rule 9 — Broadcasting: `BroadcastEvent` subclasses, auth in `routes/channels.py`, no facades -Define events as `BroadcastEvent` subclasses. Dispatch with `.emit()` or `await broadcast(event)`. Authorize private/presence channels exclusively in `routes/channels.py` using the `@channel` decorator. Do not use the `Broadcast` facade. +Define events as `BroadcastEvent` subclasses. Dispatch with `await .emit()` or `await broadcast(event)`. Authorize private/presence channels exclusively in `routes/channels.py` using the `@channel` decorator. Do not use the `Broadcast` facade. ```python # ✅ DO @@ -240,8 +240,8 @@ class OrderShipped(BroadcastEvent): def broadcast_on(self) -> list: return [PrivateChannel(f"orders.{self.payload['order_id']}")] -# Dispatch (fire-and-forget) -OrderShipped(order_id=123).emit() +# Dispatch +await OrderShipped(order_id=123).emit() # routes/channels.py — auth callbacks @channel("orders.{order_id}") From 689209aa9a98adfa8ec869c088ed8821b727ece5 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 17:58:06 -0700 Subject: [PATCH 06/10] feat(skills): consolidate fastapi skills + add inertia-controllers - Merge fastapi-routing and fastapi-resource-controllers into a single fastapi/SKILL.md covering routing verbs, auth groups, resource() shortcut, and the canonical async JSON-API controller pattern - Canonical controller uses pendulum.now(), Response(204), find_or_fail, specific exception catches, Logger.error, and no orphaned child rows - Add inertia-controllers/SKILL.md with Inertia.render(), 303 redirects, Form Requests, DB transactions, and dispatched (non-awaited) long jobs - Remove superseded fastapi-routing/ and fastapi-resource-controllers/ dirs Co-Authored-By: Claude Sonnet 4.6 --- .../fastapi-resource-controllers/SKILL.md | 153 ------------- .../skill/fastapi-routing/SKILL.md | 72 ------ .../fastapi-startkit/skill/fastapi/SKILL.md | 208 ++++++++++++++++++ .../skill/inertia-controllers/SKILL.md | 206 +++++++++++++++++ 4 files changed, 414 insertions(+), 225 deletions(-) delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-resource-controllers/SKILL.md delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-routing/SKILL.md create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi/SKILL.md create mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/inertia-controllers/SKILL.md diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-resource-controllers/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-resource-controllers/SKILL.md deleted file mode 100644 index b1f6fb4a..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-resource-controllers/SKILL.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -name: fastapi-resource-controllers -description: Scaffold RESTful CRUD routes from a controller class using router.resource(). Use when you need the standard index/create/store/show/edit/update/destroy route set with optional filtering. ---- - -# FastAPI Resource Controllers - -`router.resource(name, controller)` registers the seven standard RESTful routes from a single controller object or class. This mirrors the resource-controller convention and keeps route registration DRY. - -## Basic usage - -```python -from fastapi_startkit.fastapi import Router - -router = Router() -router.resource("tasks", TasksController) -``` - -This registers: - -| Method | Path | Controller method | Route name | -|--------|------|-------------------|------------| -| GET | `/tasks` | `index` | `tasks` | -| GET | `/tasks/create` | `create` | `tasks.create` | -| POST | `/tasks` | `store` | `tasks.store` | -| GET | `/tasks/{task}` | `show` | `tasks.show` | -| GET | `/tasks/{task}/edit` | `edit` | `tasks.edit` | -| PUT | `/tasks/{task}` | `update` | `tasks.update` | -| DELETE | `/tasks/{task}` | `destroy` | `tasks.destroy` | - -Only methods that **exist** on the controller are registered — missing methods are silently skipped. - -## Controller pattern - -All controller methods must be `async def`. ORM calls must be `await`-ed. Collection endpoints return `ResourceCollection`; single-resource endpoints return a `Resource` instance. - -```python -import datetime -from fastapi_startkit.jsonapi import JsonResource, ResourceCollection - -class TaskResource(JsonResource["Task"]): - hidden = ["deleted_at"] - -class TasksController: - async def index(self, project_id: int) -> ResourceCollection: - cutoff = ( - datetime.datetime.now() - datetime.timedelta(days=7) - ).isoformat() - - tasks = await ( - Task - .where("project_id", project_id) - .where(lambda q: ( - q.where_not_in("tasks.status", ["completed", "cancelled"]) - .or_where("tasks.completed_at", ">=", cutoff) - .or_where_raw("tasks.completed_at IS NULL") - )) - .paginate() - ) - - return TaskResource.collection(tasks) - - async def show(self, task: int) -> TaskResource: - return TaskResource(await Task.find_or_fail(task)) - - async def store(self, data: TaskCreateSchema) -> TaskResource: - instance = await Task.create(data.model_dump()) - return TaskResource(instance) - - async def update(self, task: int, data: TaskUpdateSchema) -> TaskResource: - instance = await Task.find_or_fail(task) - await instance.update(data.model_dump(exclude_unset=True)) - return TaskResource(instance) - - async def destroy(self, task: int) -> dict: - instance = await Task.find_or_fail(task) - await instance.delete() - return {"deleted": True} -``` - -## Advanced ORM query patterns - -### Lambda-grouped WHERE clauses - -Pass a lambda to `where()` to wrap a group of conditions in parentheses. The lambda receives a fresh `QueryBuilder` and must return it after chaining: - -```python -# SQL: WHERE project_id = ? AND (status NOT IN (?,?) OR completed_at >= ? OR completed_at IS NULL) -tasks = await ( - Task - .where("project_id", project_id) - .where(lambda q: ( - q.where_not_in("status", ["completed", "cancelled"]) - .or_where("completed_at", ">=", cutoff) - .or_where_raw("completed_at IS NULL") - )) - .get() -) -``` - -### Pagination - -`paginate()` returns a `LengthAwarePaginator` with total / per_page / current_page attributes. Pass it directly to `Resource.collection()` — pagination meta is included automatically in the JSON:API envelope. - -```python -# Default: 15 per page, page 1 -paginator = await Task.where("active", True).paginate() - -# Custom page size and page number (inject from query params) -paginator = await Task.where("active", True).paginate(per_page=25, page=page) - -return TaskResource.collection(paginator) -# → {"data": [...], "meta": {"total": 120, "per_page": 25, "current_page": 2, ...}} -``` - -### Excluding values - -```python -active = await Task.where_not_in("status", ["archived", "deleted"]).get() -``` - -### Raw fragments - -Use `where_raw` / `or_where_raw` only when the ORM cannot express the condition — not as a default: - -```python -tasks = await Task.where_raw("LOWER(title) LIKE ?", ("%search%",)).get() -``` - -## Filtering routes with `only` and `excepts` - -```python -# Read-only resource -router.resource("reports", ReportsController, only={"index", "show"}) - -# Skip HTML form routes (API-only) -router.resource("tasks", TasksController, excepts={"create", "edit"}) -``` - -## Custom route names - -```python -router.resource("tasks", TasksController, names={"index": "task.list", "show": "task.detail"}) -``` - -## Custom URL parameter name - -By default the parameter is derived from the resource name (`tasks` → `task`). Override it: - -```python -router.resource("categories", CategoriesController, parameters={"categories": "category_id"}) -# Routes become /categories/{category_id} -``` diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-routing/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-routing/SKILL.md deleted file mode 100644 index 41f4214b..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi-routing/SKILL.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: fastapi-routing -description: Define HTTP routes with the fastapi-startkit Router wrapper. Use when adding GET, POST, PUT, PATCH, DELETE endpoints or grouping routes by auth level with shared dependencies. ---- - -# FastAPI Routing - -The `Router` class wraps FastAPI's `APIRouter` and adds a fluent method API. Use it instead of bare `APIRouter` so your routes stay consistent with the rest of the framework. - -## Defining routes - -Import `Router` from `fastapi_startkit.fastapi` and call the HTTP-verb methods: - -```python -from fastapi_startkit.fastapi import Router - -router = Router() - -router.get("/users", users_controller.index) -router.post("/users", users_controller.store) -router.put("/users/{user_id}", users_controller.update) -router.patch("/users/{user_id}", users_controller.partial_update) -router.delete("/users/{user_id}", users_controller.destroy) -``` - -## Grouping routes by access level - -Create separate `Router` instances with shared `dependencies` to split guest and authenticated routes: - -```python -from fastapi import Depends -from fastapi_startkit.fastapi import Router - -guest = Router() -guest.get("/login", auth_controller.create) -guest.post("/login", auth_controller.store) - -auth = Router(dependencies=[Depends(require_auth)]) -auth.get("/dashboard", dashboard_controller.index) -auth.resource("users", users_controller) -``` - -## Including routers in routes/web.py - -Register your routers with the FastAPI application: - -```python -# routes/web.py -from fastapi_startkit import app - -app().fastapi.include_router(guest.router) -app().fastapi.include_router(auth.router) -``` - -## Route options - -Pass any standard FastAPI `add_api_route` keyword arguments as extra kwargs: - -```python -router.get( - "/users", - users_controller.index, - response_model=list[UserSchema], - status_code=200, - tags=["users"], - summary="List all users", -) -``` - -## Accessing the underlying APIRouter - -Use `router.router` to access the raw `APIRouter` instance, or rely on `__getattr__` passthrough for any attributes not defined on `Router` itself. diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi/SKILL.md new file mode 100644 index 00000000..a34b3a2f --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi/SKILL.md @@ -0,0 +1,208 @@ +--- +name: fastapi +description: Define HTTP routes and RESTful resource controllers with the fastapi-startkit Router — covers verb methods, auth grouping, resource() shortcut, and the canonical async JSON-API controller pattern. +--- + +# FastAPI Routing & Resource Controllers + +The `Router` class wraps FastAPI's `APIRouter` and adds a fluent API and `resource()` shortcut. Use it instead of bare `APIRouter` so routes stay consistent with the rest of the framework. + +## Defining routes + +Import `Router` from `fastapi_startkit.fastapi` and call the HTTP-verb methods: + +```python +from fastapi_startkit.fastapi import Router + +router = Router() + +router.get("/users", users_controller.index) +router.post("/users", users_controller.store) +router.put("/users/{user_id}", users_controller.update) +router.patch("/users/{user_id}", users_controller.partial_update) +router.delete("/users/{user_id}", users_controller.destroy) +``` + +## Grouping routes by access level + +Create separate `Router` instances with shared `dependencies` to split guest and authenticated routes. Apply middleware at the `Router` constructor level — not per-route: + +```python +from fastapi import Depends +from fastapi_startkit.fastapi import Router + +guest = Router() +guest.get("/login", auth_controller.create) +guest.post("/login", auth_controller.store) + +auth = Router(dependencies=[Depends(require_auth)]) +auth.get("/dashboard", dashboard_controller.index) +auth.resource("tasks", tasks_controller) +auth.resource("projects", projects_controller) +``` + +Register in `routes/web.py`: + +```python +# routes/web.py +from fastapi_startkit import app + +app().fastapi.include_router(guest.router) +app().fastapi.include_router(auth.router) +``` + +## resource() — RESTful route registration + +`router.resource(name, controller)` registers the seven standard RESTful routes from a single controller. Only methods that **exist** on the controller are registered — missing methods are silently skipped. + +```python +router.resource("tasks", TasksController) +``` + +| Method | Path | Controller method | Route name | +|--------|------|-------------------|------------| +| GET | `/tasks` | `index` | `tasks` | +| GET | `/tasks/create` | `create` | `tasks.create` | +| POST | `/tasks` | `store` | `tasks.store` | +| GET | `/tasks/{task}` | `show` | `tasks.show` | +| GET | `/tasks/{task}/edit` | `edit` | `tasks.edit` | +| PUT | `/tasks/{task}` | `update` | `tasks.update` | +| DELETE | `/tasks/{task}` | `destroy` | `tasks.destroy` | + +### Filtering routes + +```python +# Read-only resource +router.resource("reports", ReportsController, only={"index", "show"}) + +# Skip HTML form routes (API-only) +router.resource("tasks", TasksController, excepts={"create", "edit"}) +``` + +### Custom route names + +```python +router.resource("tasks", TasksController, names={"index": "task.list", "show": "task.detail"}) +``` + +### Custom URL parameter name + +```python +router.resource("categories", CategoriesController, parameters={"categories": "category_id"}) +# Routes become /categories/{category_id} +``` + +## Canonical resource controller (JSON-API) + +All controller methods must be `async def`. ORM calls must be `await`-ed. Collection endpoints return `ResourceCollection`; single-resource endpoints return a `Resource` instance. Use `find_or_fail()` — never fetch and manually raise 404. Use `Response(204)` for empty responses, not `JSONResponse({}, 204)`. Handle ORM exceptions specifically — never catch bare `Exception` alongside specific ones. Log failures with `Logger.error`, not `.debug`. Use `pendulum.now()` instead of `datetime.datetime.now()`. + +```python +import pendulum +from fastapi import Response +from fastapi_startkit.jsonapi import JsonResource, ResourceCollection +from fastapi_startkit.masoniteorm.exceptions import ModelNotFound +from fastapi_startkit.logging import Logger + +class TaskResource(JsonResource["Task"]): + hidden = ["deleted_at"] + +class TasksController: + async def index(self, project_id: int) -> ResourceCollection: + cutoff = pendulum.now().subtract(days=7).isoformat() + + tasks = await ( + Task + .where("project_id", project_id) + .where(lambda q: ( + q.where_not_in("tasks.status", ["completed", "cancelled"]) + .or_where("tasks.completed_at", ">=", cutoff) + .or_where_raw("tasks.completed_at IS NULL") + )) + .paginate() + ) + + return TaskResource.collection(tasks) + + async def show(self, task: int) -> TaskResource: + return TaskResource(await Task.find_or_fail(task)) + + async def store(self, data: TaskCreateSchema) -> TaskResource: + try: + instance = await Task.create(data.model_dump()) + return TaskResource(instance) + except Exception as exc: + Logger.error(f"Failed to create task: {exc}") + raise + + async def update(self, task: int, data: TaskUpdateSchema) -> TaskResource: + instance = await Task.find_or_fail(task) + payload = data.model_dump(exclude_unset=True) + + if "status" in payload and payload["status"] in ("completed", "cancelled"): + payload.setdefault("completed_at", pendulum.now().isoformat()) + + await instance.update(payload) + return TaskResource(instance) + + async def destroy(self, task: int) -> Response: + instance = await Task.find_or_fail(task) + await instance.subtasks().delete() # no orphaned child rows + await instance.delete() + return Response(status_code=204) +``` + +## Advanced ORM query patterns + +### Lambda-grouped WHERE clauses + +Pass a lambda to `where()` to wrap conditions in parentheses. The lambda receives a fresh `QueryBuilder` and must return it: + +```python +# SQL: WHERE project_id = ? AND (status NOT IN (?,?) OR completed_at >= ? OR completed_at IS NULL) +tasks = await ( + Task + .where("project_id", project_id) + .where(lambda q: ( + q.where_not_in("status", ["completed", "cancelled"]) + .or_where("completed_at", ">=", cutoff) + .or_where_raw("completed_at IS NULL") + )) + .get() +) +``` + +### Pagination + +`paginate()` returns a `LengthAwarePaginator`. Pass it directly to `Resource.collection()` — pagination meta is included automatically in the JSON:API envelope. + +```python +# Default: 15 per page, page 1 +paginator = await Task.where("active", True).paginate() + +# Custom page size and page number (inject from query params) +paginator = await Task.where("active", True).paginate(per_page=25, page=page) + +return TaskResource.collection(paginator) +# → {"data": [...], "meta": {"total": 120, "per_page": 25, "current_page": 2, ...}} +``` + +### Excluding values + +```python +active = await Task.where_not_in("status", ["archived", "deleted"]).get() +``` + +### Raw fragments + +Use `where_raw` / `or_where_raw` only when the ORM cannot express the condition: + +```python +tasks = await Task.where_raw("LOWER(title) LIKE ?", ("%search%",)).get() +``` + +### or_where and or_where_null + +```python +results = await Task.where("user_id", user_id).or_where("shared", True).get() +overdue = await Task.where_null("completed_at").or_where_null("due_at").get() +``` diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/inertia-controllers/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/inertia-controllers/SKILL.md new file mode 100644 index 00000000..fdefdf65 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/inertia-controllers/SKILL.md @@ -0,0 +1,206 @@ +--- +name: inertia-controllers +description: Build server-side Inertia.js controllers with fastapi-startkit — Inertia.render(), 303 redirects, Form Request validation, database transactions, and resource serialization. +--- + +# Inertia Controllers + +Inertia controllers render Vue/React/Svelte pages server-side using `Inertia.render()`. They follow the same resource-controller conventions as JSON-API controllers but return Inertia responses instead of JSON:API envelopes. + +## Basic Inertia render + +Use `Inertia.render(component, props)` to render a page component. The component path maps to your frontend file (e.g. `"Tasks/Index"` → `resources/js/Pages/Tasks/Index.vue`): + +```python +from fastapi_startkit.inertia import Inertia + +class TasksController: + async def index(self) -> dict: + tasks = await Task.paginate() + return Inertia.render("Tasks/Index", { + "tasks": [t.serialize() for t in tasks.result], + "meta": { + "current_page": tasks.current_page, + "last_page": tasks.last_page, + "per_page": tasks.per_page, + "total": tasks.total, + }, + }) +``` + +## Resource serialization in Inertia props + +Use `JsonResource` to produce consistent, hidden-field-safe serialization, then pass `.serialize()` dicts into the props dict: + +```python +from fastapi_startkit.jsonapi import JsonResource + +class TaskResource(JsonResource["Task"]): + hidden = ["deleted_at", "internal_notes"] + +class TasksController: + async def show(self, task: int) -> dict: + instance = await Task.find_or_fail(task) + return Inertia.render("Tasks/Show", { + "task": TaskResource(instance).serialize(), + }) +``` + +## Mutations with 303 redirects + +After any write operation (store, update, destroy) redirect with `status_code=303` so the browser issues a GET request after the POST/PUT/DELETE. This prevents duplicate form submissions on page refresh: + +```python +from fastapi.responses import RedirectResponse + +class TasksController: + async def store(self, data: TaskStoreRequest) -> RedirectResponse: + await Task.create(data.model_dump()) + return RedirectResponse(url="/tasks", status_code=303) + + async def update(self, task: int, data: TaskUpdateRequest) -> RedirectResponse: + instance = await Task.find_or_fail(task) + payload = data.model_dump(exclude_unset=True) + + if "status" in payload and payload["status"] in ("completed", "cancelled"): + payload.setdefault("completed_at", pendulum.now().isoformat()) + + await instance.update(payload) + return RedirectResponse(url=f"/tasks/{task}", status_code=303) + + async def destroy(self, task: int) -> RedirectResponse: + instance = await Task.find_or_fail(task) + await instance.subtasks().delete() # delete children first — no orphaned rows + await instance.delete() + return RedirectResponse(url="/tasks", status_code=303) +``` + +## Form Requests (validation) + +Declare incoming data as a Pydantic model (Form Request). FastAPI validates the payload before the controller method is called: + +```python +from pydantic import BaseModel, Field +from typing import Optional + +class TaskStoreRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + project_id: int + due_at: Optional[str] = None + +class TaskUpdateRequest(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=255) + status: Optional[str] = None + due_at: Optional[str] = None +``` + +Use `model_dump(exclude_unset=True)` for partial updates so only provided fields reach the ORM: + +```python +await instance.update(data.model_dump(exclude_unset=True)) +``` + +## Database transactions + +Wrap multi-step writes in a transaction. Long-running jobs (email, webhooks, queued work) must be dispatched — not awaited inline — so the HTTP response is not blocked: + +```python +from fastapi_startkit.masoniteorm import DB +from app.jobs import SendWelcomeEmail + +class UsersController: + async def store(self, data: UserStoreRequest) -> RedirectResponse: + async with DB.transaction(): + user = await User.create(data.model_dump()) + await Profile.create({"user_id": user.id}) + + SendWelcomeEmail(user.id).dispatch() # queued — not awaited inline + + return RedirectResponse(url="/users", status_code=303) +``` + +## Error handling + +Use `find_or_fail()` — never fetch and raise a manual 404. Catch only specific exceptions; never catch bare `Exception` alongside specific ones. Log failures with `Logger.error`: + +```python +from fastapi_startkit.masoniteorm.exceptions import ModelNotFound +from fastapi_startkit.logging import Logger + +class TasksController: + async def edit(self, task: int) -> dict: + try: + instance = await Task.find_or_fail(task) + except ModelNotFound: + Logger.error(f"Task {task} not found") + raise + return Inertia.render("Tasks/Edit", { + "task": TaskResource(instance).serialize(), + }) +``` + +## Complete controller example + +```python +import pendulum +from fastapi.responses import RedirectResponse +from fastapi_startkit.inertia import Inertia +from fastapi_startkit.jsonapi import JsonResource +from fastapi_startkit.masoniteorm.exceptions import ModelNotFound +from fastapi_startkit.logging import Logger +from app.http.requests.task import TaskStoreRequest, TaskUpdateRequest +from app.models.Task import Task + + +class TaskResource(JsonResource["Task"]): + hidden = ["deleted_at"] + + +class TasksController: + async def index(self) -> dict: + tasks = await Task.paginate() + return Inertia.render("Tasks/Index", { + "tasks": [TaskResource(t).serialize() for t in tasks.result], + "meta": { + "current_page": tasks.current_page, + "last_page": tasks.last_page, + "per_page": tasks.per_page, + "total": tasks.total, + }, + }) + + async def create(self) -> dict: + return Inertia.render("Tasks/Create", {}) + + async def store(self, data: TaskStoreRequest) -> RedirectResponse: + await Task.create(data.model_dump()) + return RedirectResponse(url="/tasks", status_code=303) + + async def show(self, task: int) -> dict: + instance = await Task.find_or_fail(task) + return Inertia.render("Tasks/Show", { + "task": TaskResource(instance).serialize(), + }) + + async def edit(self, task: int) -> dict: + instance = await Task.find_or_fail(task) + return Inertia.render("Tasks/Edit", { + "task": TaskResource(instance).serialize(), + }) + + async def update(self, task: int, data: TaskUpdateRequest) -> RedirectResponse: + instance = await Task.find_or_fail(task) + payload = data.model_dump(exclude_unset=True) + + if "status" in payload and payload["status"] in ("completed", "cancelled"): + payload.setdefault("completed_at", pendulum.now().isoformat()) + + await instance.update(payload) + return RedirectResponse(url=f"/tasks/{task}", status_code=303) + + async def destroy(self, task: int) -> RedirectResponse: + instance = await Task.find_or_fail(task) + await instance.subtasks().delete() + await instance.delete() + return RedirectResponse(url="/tasks", status_code=303) +``` From 54005f1324230f667c4048cd773691355115ca30 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 18:18:06 -0700 Subject: [PATCH 07/10] refactor(skills): reduce skill set to broadcasting/console/orm-migrations only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete fastapi/, inertia-controllers/, laravel-best-practices/, orm-models/ (all replaced by the single verbatim fastapi-startkit skill on PR #113) - Revert fastapi_startkit/pyproject.toml to main (removes [skills] extra added in an earlier commit — not part of this PR's scope) - Strip filler from broadcasting/SKILL.md: remove "When to use" section, intro paragraph, and docstring comment in channel callback example - Remove placeholder `# ...do work` comment from console-commands/SKILL.md Co-Authored-By: Claude Sonnet 4.6 --- fastapi_startkit/pyproject.toml | 14 +- .../skill/broadcasting/SKILL.md | 10 - .../skill/console-commands/SKILL.md | 1 - .../fastapi-startkit/skill/fastapi/SKILL.md | 208 ------------- .../skill/inertia-controllers/SKILL.md | 206 ------------- .../skill/laravel-best-practices/SKILL.md | 30 -- .../rules/architecture.md | 289 ------------------ .../skill/orm-models/SKILL.md | 199 ------------ 8 files changed, 1 insertion(+), 956 deletions(-) delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi/SKILL.md delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/inertia-controllers/SKILL.md delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/SKILL.md delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/rules/architecture.md delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-models/SKILL.md diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 511539ab..2f70ef48 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fastapi-startkit" -version = "0.40.0" +version = "0.40.1" description = "Fastapi Starter kit components" authors = [ {name = "Bedram Tamang", email = "tmgbedu@gmail.com"} @@ -53,18 +53,6 @@ ai = [ "google-generativeai>=0.8.0", ] -# Skills extra — no additional pip dependencies required. -# All functionality (SkillRegistry, ClaudeAdapter, GeminiAdapter) uses only -# Python stdlib (pathlib, inspect, dataclasses). PyYAML is detected at -# runtime and used when present, but falls back to a built-in parser. -# Install this extra to make SkillsServiceProvider and its artisan commands -# (skills:sync, skills:list) available. Register SkillsServiceProvider in -# your application's providers list to activate command discovery: -# -# from fastapi_startkit.skills.provider import SkillsServiceProvider -# app = Application(providers=[..., SkillsServiceProvider]) -skills = [] - [dependency-groups] dev = [ "dumpdie>=1.5.0", diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md index afa47d4e..69a86485 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md @@ -5,15 +5,6 @@ description: WebSocket event broadcasting via Reverb — define events, emit to # Broadcasting -The broadcasting module lets you push real-time events from the server to connected WebSocket clients using the Reverb driver (Pusher protocol). Use it whenever you need live updates — order status, notifications, presence indicators, chat messages — without polling. - -## When to use - -- Notify a user that their background job completed -- Push live order-status updates to a dashboard -- Show who else is viewing a page (presence channels) -- Fan-out notifications to a group of authenticated users - ## Defining an event Subclass `BroadcastEvent`, implement `broadcast_on()` to declare target channels, and set `payload` with the data to send: @@ -69,7 +60,6 @@ from fastapi_startkit.broadcasting import channel @channel("orders.{order_id}") async def authorize_orders(user, order_id: int) -> bool: - """Grant access only to the user who owns the order.""" return user is not None and user.id == order_id @channel("private-notifications") diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/console-commands/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/console-commands/SKILL.md index 9794e599..3f714ee9 100644 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/console-commands/SKILL.md +++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/console-commands/SKILL.md @@ -58,7 +58,6 @@ class SyncUsersCommand(Command): from app.models import User users = await User.where("synced", False).get() self.line(f"Syncing {len(users)} users…") - # ... do work ``` ## Accessing the container diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi/SKILL.md deleted file mode 100644 index a34b3a2f..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/fastapi/SKILL.md +++ /dev/null @@ -1,208 +0,0 @@ ---- -name: fastapi -description: Define HTTP routes and RESTful resource controllers with the fastapi-startkit Router — covers verb methods, auth grouping, resource() shortcut, and the canonical async JSON-API controller pattern. ---- - -# FastAPI Routing & Resource Controllers - -The `Router` class wraps FastAPI's `APIRouter` and adds a fluent API and `resource()` shortcut. Use it instead of bare `APIRouter` so routes stay consistent with the rest of the framework. - -## Defining routes - -Import `Router` from `fastapi_startkit.fastapi` and call the HTTP-verb methods: - -```python -from fastapi_startkit.fastapi import Router - -router = Router() - -router.get("/users", users_controller.index) -router.post("/users", users_controller.store) -router.put("/users/{user_id}", users_controller.update) -router.patch("/users/{user_id}", users_controller.partial_update) -router.delete("/users/{user_id}", users_controller.destroy) -``` - -## Grouping routes by access level - -Create separate `Router` instances with shared `dependencies` to split guest and authenticated routes. Apply middleware at the `Router` constructor level — not per-route: - -```python -from fastapi import Depends -from fastapi_startkit.fastapi import Router - -guest = Router() -guest.get("/login", auth_controller.create) -guest.post("/login", auth_controller.store) - -auth = Router(dependencies=[Depends(require_auth)]) -auth.get("/dashboard", dashboard_controller.index) -auth.resource("tasks", tasks_controller) -auth.resource("projects", projects_controller) -``` - -Register in `routes/web.py`: - -```python -# routes/web.py -from fastapi_startkit import app - -app().fastapi.include_router(guest.router) -app().fastapi.include_router(auth.router) -``` - -## resource() — RESTful route registration - -`router.resource(name, controller)` registers the seven standard RESTful routes from a single controller. Only methods that **exist** on the controller are registered — missing methods are silently skipped. - -```python -router.resource("tasks", TasksController) -``` - -| Method | Path | Controller method | Route name | -|--------|------|-------------------|------------| -| GET | `/tasks` | `index` | `tasks` | -| GET | `/tasks/create` | `create` | `tasks.create` | -| POST | `/tasks` | `store` | `tasks.store` | -| GET | `/tasks/{task}` | `show` | `tasks.show` | -| GET | `/tasks/{task}/edit` | `edit` | `tasks.edit` | -| PUT | `/tasks/{task}` | `update` | `tasks.update` | -| DELETE | `/tasks/{task}` | `destroy` | `tasks.destroy` | - -### Filtering routes - -```python -# Read-only resource -router.resource("reports", ReportsController, only={"index", "show"}) - -# Skip HTML form routes (API-only) -router.resource("tasks", TasksController, excepts={"create", "edit"}) -``` - -### Custom route names - -```python -router.resource("tasks", TasksController, names={"index": "task.list", "show": "task.detail"}) -``` - -### Custom URL parameter name - -```python -router.resource("categories", CategoriesController, parameters={"categories": "category_id"}) -# Routes become /categories/{category_id} -``` - -## Canonical resource controller (JSON-API) - -All controller methods must be `async def`. ORM calls must be `await`-ed. Collection endpoints return `ResourceCollection`; single-resource endpoints return a `Resource` instance. Use `find_or_fail()` — never fetch and manually raise 404. Use `Response(204)` for empty responses, not `JSONResponse({}, 204)`. Handle ORM exceptions specifically — never catch bare `Exception` alongside specific ones. Log failures with `Logger.error`, not `.debug`. Use `pendulum.now()` instead of `datetime.datetime.now()`. - -```python -import pendulum -from fastapi import Response -from fastapi_startkit.jsonapi import JsonResource, ResourceCollection -from fastapi_startkit.masoniteorm.exceptions import ModelNotFound -from fastapi_startkit.logging import Logger - -class TaskResource(JsonResource["Task"]): - hidden = ["deleted_at"] - -class TasksController: - async def index(self, project_id: int) -> ResourceCollection: - cutoff = pendulum.now().subtract(days=7).isoformat() - - tasks = await ( - Task - .where("project_id", project_id) - .where(lambda q: ( - q.where_not_in("tasks.status", ["completed", "cancelled"]) - .or_where("tasks.completed_at", ">=", cutoff) - .or_where_raw("tasks.completed_at IS NULL") - )) - .paginate() - ) - - return TaskResource.collection(tasks) - - async def show(self, task: int) -> TaskResource: - return TaskResource(await Task.find_or_fail(task)) - - async def store(self, data: TaskCreateSchema) -> TaskResource: - try: - instance = await Task.create(data.model_dump()) - return TaskResource(instance) - except Exception as exc: - Logger.error(f"Failed to create task: {exc}") - raise - - async def update(self, task: int, data: TaskUpdateSchema) -> TaskResource: - instance = await Task.find_or_fail(task) - payload = data.model_dump(exclude_unset=True) - - if "status" in payload and payload["status"] in ("completed", "cancelled"): - payload.setdefault("completed_at", pendulum.now().isoformat()) - - await instance.update(payload) - return TaskResource(instance) - - async def destroy(self, task: int) -> Response: - instance = await Task.find_or_fail(task) - await instance.subtasks().delete() # no orphaned child rows - await instance.delete() - return Response(status_code=204) -``` - -## Advanced ORM query patterns - -### Lambda-grouped WHERE clauses - -Pass a lambda to `where()` to wrap conditions in parentheses. The lambda receives a fresh `QueryBuilder` and must return it: - -```python -# SQL: WHERE project_id = ? AND (status NOT IN (?,?) OR completed_at >= ? OR completed_at IS NULL) -tasks = await ( - Task - .where("project_id", project_id) - .where(lambda q: ( - q.where_not_in("status", ["completed", "cancelled"]) - .or_where("completed_at", ">=", cutoff) - .or_where_raw("completed_at IS NULL") - )) - .get() -) -``` - -### Pagination - -`paginate()` returns a `LengthAwarePaginator`. Pass it directly to `Resource.collection()` — pagination meta is included automatically in the JSON:API envelope. - -```python -# Default: 15 per page, page 1 -paginator = await Task.where("active", True).paginate() - -# Custom page size and page number (inject from query params) -paginator = await Task.where("active", True).paginate(per_page=25, page=page) - -return TaskResource.collection(paginator) -# → {"data": [...], "meta": {"total": 120, "per_page": 25, "current_page": 2, ...}} -``` - -### Excluding values - -```python -active = await Task.where_not_in("status", ["archived", "deleted"]).get() -``` - -### Raw fragments - -Use `where_raw` / `or_where_raw` only when the ORM cannot express the condition: - -```python -tasks = await Task.where_raw("LOWER(title) LIKE ?", ("%search%",)).get() -``` - -### or_where and or_where_null - -```python -results = await Task.where("user_id", user_id).or_where("shared", True).get() -overdue = await Task.where_null("completed_at").or_where_null("due_at").get() -``` diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/inertia-controllers/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/inertia-controllers/SKILL.md deleted file mode 100644 index fdefdf65..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/inertia-controllers/SKILL.md +++ /dev/null @@ -1,206 +0,0 @@ ---- -name: inertia-controllers -description: Build server-side Inertia.js controllers with fastapi-startkit — Inertia.render(), 303 redirects, Form Request validation, database transactions, and resource serialization. ---- - -# Inertia Controllers - -Inertia controllers render Vue/React/Svelte pages server-side using `Inertia.render()`. They follow the same resource-controller conventions as JSON-API controllers but return Inertia responses instead of JSON:API envelopes. - -## Basic Inertia render - -Use `Inertia.render(component, props)` to render a page component. The component path maps to your frontend file (e.g. `"Tasks/Index"` → `resources/js/Pages/Tasks/Index.vue`): - -```python -from fastapi_startkit.inertia import Inertia - -class TasksController: - async def index(self) -> dict: - tasks = await Task.paginate() - return Inertia.render("Tasks/Index", { - "tasks": [t.serialize() for t in tasks.result], - "meta": { - "current_page": tasks.current_page, - "last_page": tasks.last_page, - "per_page": tasks.per_page, - "total": tasks.total, - }, - }) -``` - -## Resource serialization in Inertia props - -Use `JsonResource` to produce consistent, hidden-field-safe serialization, then pass `.serialize()` dicts into the props dict: - -```python -from fastapi_startkit.jsonapi import JsonResource - -class TaskResource(JsonResource["Task"]): - hidden = ["deleted_at", "internal_notes"] - -class TasksController: - async def show(self, task: int) -> dict: - instance = await Task.find_or_fail(task) - return Inertia.render("Tasks/Show", { - "task": TaskResource(instance).serialize(), - }) -``` - -## Mutations with 303 redirects - -After any write operation (store, update, destroy) redirect with `status_code=303` so the browser issues a GET request after the POST/PUT/DELETE. This prevents duplicate form submissions on page refresh: - -```python -from fastapi.responses import RedirectResponse - -class TasksController: - async def store(self, data: TaskStoreRequest) -> RedirectResponse: - await Task.create(data.model_dump()) - return RedirectResponse(url="/tasks", status_code=303) - - async def update(self, task: int, data: TaskUpdateRequest) -> RedirectResponse: - instance = await Task.find_or_fail(task) - payload = data.model_dump(exclude_unset=True) - - if "status" in payload and payload["status"] in ("completed", "cancelled"): - payload.setdefault("completed_at", pendulum.now().isoformat()) - - await instance.update(payload) - return RedirectResponse(url=f"/tasks/{task}", status_code=303) - - async def destroy(self, task: int) -> RedirectResponse: - instance = await Task.find_or_fail(task) - await instance.subtasks().delete() # delete children first — no orphaned rows - await instance.delete() - return RedirectResponse(url="/tasks", status_code=303) -``` - -## Form Requests (validation) - -Declare incoming data as a Pydantic model (Form Request). FastAPI validates the payload before the controller method is called: - -```python -from pydantic import BaseModel, Field -from typing import Optional - -class TaskStoreRequest(BaseModel): - title: str = Field(..., min_length=1, max_length=255) - project_id: int - due_at: Optional[str] = None - -class TaskUpdateRequest(BaseModel): - title: Optional[str] = Field(None, min_length=1, max_length=255) - status: Optional[str] = None - due_at: Optional[str] = None -``` - -Use `model_dump(exclude_unset=True)` for partial updates so only provided fields reach the ORM: - -```python -await instance.update(data.model_dump(exclude_unset=True)) -``` - -## Database transactions - -Wrap multi-step writes in a transaction. Long-running jobs (email, webhooks, queued work) must be dispatched — not awaited inline — so the HTTP response is not blocked: - -```python -from fastapi_startkit.masoniteorm import DB -from app.jobs import SendWelcomeEmail - -class UsersController: - async def store(self, data: UserStoreRequest) -> RedirectResponse: - async with DB.transaction(): - user = await User.create(data.model_dump()) - await Profile.create({"user_id": user.id}) - - SendWelcomeEmail(user.id).dispatch() # queued — not awaited inline - - return RedirectResponse(url="/users", status_code=303) -``` - -## Error handling - -Use `find_or_fail()` — never fetch and raise a manual 404. Catch only specific exceptions; never catch bare `Exception` alongside specific ones. Log failures with `Logger.error`: - -```python -from fastapi_startkit.masoniteorm.exceptions import ModelNotFound -from fastapi_startkit.logging import Logger - -class TasksController: - async def edit(self, task: int) -> dict: - try: - instance = await Task.find_or_fail(task) - except ModelNotFound: - Logger.error(f"Task {task} not found") - raise - return Inertia.render("Tasks/Edit", { - "task": TaskResource(instance).serialize(), - }) -``` - -## Complete controller example - -```python -import pendulum -from fastapi.responses import RedirectResponse -from fastapi_startkit.inertia import Inertia -from fastapi_startkit.jsonapi import JsonResource -from fastapi_startkit.masoniteorm.exceptions import ModelNotFound -from fastapi_startkit.logging import Logger -from app.http.requests.task import TaskStoreRequest, TaskUpdateRequest -from app.models.Task import Task - - -class TaskResource(JsonResource["Task"]): - hidden = ["deleted_at"] - - -class TasksController: - async def index(self) -> dict: - tasks = await Task.paginate() - return Inertia.render("Tasks/Index", { - "tasks": [TaskResource(t).serialize() for t in tasks.result], - "meta": { - "current_page": tasks.current_page, - "last_page": tasks.last_page, - "per_page": tasks.per_page, - "total": tasks.total, - }, - }) - - async def create(self) -> dict: - return Inertia.render("Tasks/Create", {}) - - async def store(self, data: TaskStoreRequest) -> RedirectResponse: - await Task.create(data.model_dump()) - return RedirectResponse(url="/tasks", status_code=303) - - async def show(self, task: int) -> dict: - instance = await Task.find_or_fail(task) - return Inertia.render("Tasks/Show", { - "task": TaskResource(instance).serialize(), - }) - - async def edit(self, task: int) -> dict: - instance = await Task.find_or_fail(task) - return Inertia.render("Tasks/Edit", { - "task": TaskResource(instance).serialize(), - }) - - async def update(self, task: int, data: TaskUpdateRequest) -> RedirectResponse: - instance = await Task.find_or_fail(task) - payload = data.model_dump(exclude_unset=True) - - if "status" in payload and payload["status"] in ("completed", "cancelled"): - payload.setdefault("completed_at", pendulum.now().isoformat()) - - await instance.update(payload) - return RedirectResponse(url=f"/tasks/{task}", status_code=303) - - async def destroy(self, task: int) -> RedirectResponse: - instance = await Task.find_or_fail(task) - await instance.subtasks().delete() - await instance.delete() - return RedirectResponse(url="/tasks", status_code=303) -``` diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/SKILL.md deleted file mode 100644 index b9ef101c..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: laravel-best-practices -description: Architectural and code conventions for fastapi-startkit applications — provider pattern, container, resourceful controllers, ORM, routing, config, and async discipline. ---- - -# Laravel Best Practices for fastapi-startkit - -This skill contains the canonical architecture rules for fastapi-startkit projects. The framework is heavily Laravel-inspired (provider pattern, IoC container, artisan commands, facades, ORM, resourceful routing), so conventions follow both Laravel idioms and Python/FastAPI specifics. - -## Rules files - -- [`rules/architecture.md`](rules/architecture.md) — 10 rules covering: - 1. Provider `register()` vs `boot()` separation - 2. Service container injection via `resolve()`, not service locator - 3. Single responsibility per `Provider` - 4. Resourceful controllers — `async def`, `ResourceCollection` return types - 5. ORM conventions — `await` discipline, relationship descriptors, minimal raw SQL - 6. Route organisation — guest/auth `Router` split, middleware at `Router` level - 7. Configuration via `@dataclass` + `env()`, no hardcoded values - 8. No business logic in routes or controllers — delegate to services - 9. Broadcasting — `BroadcastEvent` subclasses, `@channel` auth callbacks, no facades - 10. Async discipline — no blocking I/O in async context - -## When to use - -Apply these rules when: -- Starting a new fastapi-startkit application -- Reviewing a pull request for architecture compliance -- Deciding where to put new code (provider vs service vs controller vs model) -- Debugging async issues or unexpected blocking behaviour diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/rules/architecture.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/rules/architecture.md deleted file mode 100644 index b6e68a8f..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/laravel-best-practices/rules/architecture.md +++ /dev/null @@ -1,289 +0,0 @@ -# Architecture Rules — fastapi-startkit (Laravel-inspired) - -This framework follows Laravel conventions adapted to Python and FastAPI. The rules below govern how to structure providers, containers, controllers, models, routes, and config in a fastapi-startkit application. - ---- - -## Rule 1 — Provider pattern: `register()` for bindings, `boot()` for logic - -`register()` must only bind things into the container. Never resolve other bindings inside `register()` — other providers may not have run yet. All logic that depends on resolved services belongs in `boot()`. - -```python -# ✅ DO -class OrdersProvider(Provider): - def register(self) -> None: - self.app.bind("orders", OrderService()) # pure binding - - def boot(self) -> None: - service = self.app.make("orders") # safe to resolve here - service.configure(self.app.make("config").get("orders")) - -# ❌ DON'T -class OrdersProvider(Provider): - def register(self) -> None: - config = self.app.make("config") # resolving in register() — unsafe - self.app.bind("orders", OrderService(config)) -``` - ---- - -## Rule 2 — Service container: prefer constructor injection via `resolve()` - -Use `app.resolve(callable)` to auto-wire dependencies by type hint instead of calling `app.make()` manually (service locator anti-pattern). Reserve `make()` for entry points and bootstrap code. - -```python -# ✅ DO — container resolves OrderRepository automatically -class OrderService: - def __init__(self, repo: OrderRepository) -> None: - self.repo = repo - -instance = app().resolve(OrderService) - -# ❌ DON'T — service locator hides dependencies -class OrderService: - def __init__(self) -> None: - self.repo = app().make("orders.repo") # hidden dependency -``` - ---- - -## Rule 3 — Single responsibility per provider - -Each `Provider` subclass owns exactly one concern. Mixing unrelated bindings into one provider makes the app harder to understand and test. - -```python -# ✅ DO — separate providers -class AuthProvider(Provider): - def register(self) -> None: - self.app.bind("auth", AuthManager()) - -class MailProvider(Provider): - def register(self) -> None: - self.app.bind("mail", MailManager()) - -# ❌ DON'T — one provider for everything -class AppProvider(Provider): - def register(self) -> None: - self.app.bind("auth", AuthManager()) - self.app.bind("mail", MailManager()) - self.app.bind("cache", CacheManager()) - self.app.bind("queue", QueueManager()) -``` - ---- - -## Rule 4 — Resourceful controllers: one per resource, all `async def` - -One controller class per resource. Use the standard `index / show / store / update / destroy` method names. Every method must be `async def`. Collection endpoints return `ResourceCollection`; single-resource endpoints return a `Resource`. - -```python -# ✅ DO -from fastapi_startkit.jsonapi import JsonResource, ResourceCollection - -class TaskResource(JsonResource["Task"]): - hidden = ["deleted_at"] - -class TasksController: - async def index(self, project_id: int) -> ResourceCollection: - tasks = await Task.where("project_id", project_id).paginate() - return TaskResource.collection(tasks) - - async def show(self, task: int) -> TaskResource: - return TaskResource(await Task.find_or_fail(task)) - - async def store(self, data: TaskCreateSchema) -> TaskResource: - return TaskResource(await Task.create(data.model_dump())) - - async def destroy(self, task: int) -> dict: - await (await Task.find_or_fail(task)).delete() - return {"deleted": True} - -# ❌ DON'T — sync methods, raw dicts, no resource wrapping -class TasksController: - def index(self): # missing async - return Task.all() # missing await, returns raw collection - def get_task(self, id): # non-standard method name - return {"id": id, "title": "..."} # raw dict, no Resource -``` - ---- - -## Rule 5 — ORM conventions: always `await`, relationships as descriptors - -Every ORM query is async — always `await` it. Declare relationships as class-level descriptors, not inside methods. Avoid raw SQL unless no ORM equivalent exists; prefer `where_raw` / `or_where_raw` over embedding SQL strings elsewhere. - -```python -# ✅ DO -from fastapi_startkit.masoniteorm import Model -from fastapi_startkit.masoniteorm.relationships import HasMany, BelongsTo - -class Post(Model): - title: str - user_id: int - author = BelongsTo("User", foreign_key="user_id") # descriptor - -async def get_posts(user_id: int): - return await Post.where("user_id", user_id).get() # awaited - -# ❌ DON'T -class Post(Model): - async def get_author(self): # relationship inside a method — not a descriptor - return await User.find(self.user_id) - -def get_posts(user_id: int): # not async - return Post.where("user_id", user_id).get() # unawaited coroutine — silent bug -``` - ---- - -## Rule 6 — Route organisation: separate guest/auth routers, middleware at Router level - -Create distinct `Router` instances for guest and authenticated routes. Apply middleware (e.g. `Depends(require_auth)`) at the `Router` constructor level, not per-route. Use `router.resource()` for CRUD resources. - -```python -# ✅ DO -from fastapi import Depends -from fastapi_startkit.fastapi import Router - -guest = Router() -guest.get("/login", auth_controller.create) -guest.post("/login", auth_controller.store) - -auth = Router(dependencies=[Depends(require_auth)]) # middleware at Router level -auth.resource("tasks", tasks_controller) -auth.resource("projects", projects_controller) - -# ❌ DON'T — scatters middleware across individual routes -router = Router() -router.get("/tasks", tasks_controller.index, dependencies=[Depends(require_auth)]) -router.post("/tasks", tasks_controller.store, dependencies=[Depends(require_auth)]) -router.get("/tasks/{id}", tasks_controller.show, dependencies=[Depends(require_auth)]) -``` - ---- - -## Rule 7 — Configuration: dataclass + `env()`, never hardcoded values - -Define all config as a `@dataclass` with fields sourced via `env()`. Register the config object in a provider. Never hardcode hostnames, keys, or connection strings in application code. - -```python -# ✅ DO -from dataclasses import dataclass, field -from fastapi_startkit.environment import env - -@dataclass -class RedisConfig: - host: str = field(default_factory=lambda: env("REDIS_HOST", "127.0.0.1")) - port: int = field(default_factory=lambda: env("REDIS_PORT", 6379)) - password: str = field(default_factory=lambda: env("REDIS_PASSWORD", "")) - -class RedisProvider(Provider): - def register(self) -> None: - self.app.bind("redis.config", RedisConfig()) - -# ❌ DON'T — hardcoded values -REDIS_HOST = "127.0.0.1" -REDIS_PORT = 6379 - -class RedisClient: - def __init__(self): - self.host = "127.0.0.1" # hardcoded — breaks across environments -``` - ---- - -## Rule 8 — No business logic in routes or controllers - -Routes delegate to controllers; controllers delegate to service classes or models. Business logic (validation beyond Pydantic, workflow orchestration, external API calls) belongs in a dedicated service layer. - -```python -# ✅ DO — controller is thin, delegates to service -class OrdersController: - async def store(self, data: OrderCreateSchema) -> OrderResource: - order = await OrderService().place(data) - return OrderResource(order) - -class OrderService: - async def place(self, data: OrderCreateSchema) -> Order: - await self._validate_inventory(data.items) - order = await Order.create(data.model_dump()) - await self._charge_payment(order) - OrderShipped(order.id).emit() - return order - -# ❌ DON'T — business logic inline in a controller method -class OrdersController: - async def store(self, data: OrderCreateSchema) -> dict: - for item in data.items: - stock = await Inventory.find(item.id) - if stock.quantity < item.qty: - raise HTTPException(400, "Out of stock") - order = await Order.create(data.model_dump()) - await stripe.charge(order.total) # payment logic in controller - OrderShipped(order.id).emit() - return {"id": order.id} -``` - ---- - -## Rule 9 — Broadcasting: `BroadcastEvent` subclasses, auth in `routes/channels.py`, no facades - -Define events as `BroadcastEvent` subclasses. Dispatch with `await .emit()` or `await broadcast(event)`. Authorize private/presence channels exclusively in `routes/channels.py` using the `@channel` decorator. Do not use the `Broadcast` facade. - -```python -# ✅ DO -from fastapi_startkit.broadcasting import BroadcastEvent, PrivateChannel, channel, broadcast - -class OrderShipped(BroadcastEvent): - def __init__(self, order_id: int) -> None: - self.payload = {"order_id": order_id} - def broadcast_on(self) -> list: - return [PrivateChannel(f"orders.{self.payload['order_id']}")] - -# Dispatch -await OrderShipped(order_id=123).emit() - -# routes/channels.py — auth callbacks -@channel("orders.{order_id}") -async def authorize_orders(user, order_id: int) -> bool: - return user is not None and user.id == order_id - -# ❌ DON'T -from fastapi_startkit.facades.Broadcast import Broadcast - -await Broadcast.event(event) # facade — avoid -await Broadcast.dispatch(event) # facade — avoid - -# auth callback inline in a route handler — not in routes/channels.py -@router.post("/some-route") -async def handler(): - if not await check_channel_auth(...): # auth logic leaking into route - ... -``` - ---- - -## Rule 10 — Async discipline: all I/O is `async/await`, no blocking calls in async context - -Every function that performs I/O (database, HTTP, file system, sleep) must be `async def` and `await`-ed at every call site. Never call synchronous blocking I/O (e.g. `requests.get`, `time.sleep`, sync file reads) from inside an async function — it blocks the entire event loop. - -```python -# ✅ DO -import asyncio -import httpx - -async def fetch_and_save(url: str) -> None: - async with httpx.AsyncClient() as client: - response = await client.get(url) # async HTTP - record = await ExternalData.create({"body": response.text}) # async ORM - await asyncio.sleep(1) # async sleep - -# ❌ DON'T -import requests -import time - -async def fetch_and_save(url: str) -> None: - response = requests.get(url) # BLOCKS the event loop - time.sleep(1) # BLOCKS the event loop - ExternalData.create({"body": response.text}) # unawaited coroutine — silent bug -``` diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-models/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-models/SKILL.md deleted file mode 100644 index 85ecaeb3..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-models/SKILL.md +++ /dev/null @@ -1,199 +0,0 @@ ---- -name: orm-models -description: Define and query database models with the fastapi-startkit async ORM. Use when creating model classes, reading/writing records, filtering with the QueryBuilder, or working with relationships. ---- - -# ORM Models - -`Model` is the base class for all database models. It auto-pluralises the table name, tracks `created_at`/`updated_at` timestamps, and exposes a fluent async `QueryBuilder`. - -## Defining a model - -```python -from fastapi_startkit.masoniteorm import Model - -class User(Model): - name: str - email: str - role: str = "user" -``` - -- Class name is automatically pluralised to the table name (`User` → `users`). Override with `__table__ = "my_users"`. -- Annotated class-level attributes become `__fillable__` fields automatically. -- `created_at` and `updated_at` are managed as `Carbon` (pendulum) objects. - -## Creating records - -```python -user = await User.create({"name": "Alice", "email": "alice@example.com"}) -``` - -## Basic querying - -```python -# All records -users = await User.all() - -# By primary key -user = await User.find(1) -user = await User.find_or_fail(1) # raises ModelNotFoundException if missing - -# First match -user = await User.where("role", "admin").first() -user = await User.where("role", "admin").first_or_fail() - -# Filtered collection -admins = await User.where("role", "admin").get() - -# Chained conditions -result = await ( - User - .where("active", True) - .where_not_null("email") - .order_by("name") - .limit(10) - .get() -) -``` - -## Updating and deleting - -```python -user = await User.find_or_fail(1) -await user.update({"name": "Bob"}) -await user.delete() -``` - -## Upserts - -```python -user = await User.first_or_create({"email": "bob@example.com"}, {"name": "Bob"}) -user = await User.update_or_create({"email": "bob@example.com"}, {"name": "Bob", "role": "editor"}) -``` - -## Aggregates - -```python -count = await User.count() -exists = await User.where("email", "x@example.com").exists() -total = await Order.sum("amount") -``` - -## Advanced query patterns - -### Lambda-grouped WHERE clauses - -Pass a lambda to `where()` to group conditions in parentheses. The lambda receives a fresh `QueryBuilder` and must return it after chaining: - -```python -import datetime - -cutoff = (datetime.datetime.now() - datetime.timedelta(days=7)).isoformat() - -# SQL: -# WHERE project_id = ? -# AND ( -# status NOT IN (?,?) -# OR completed_at >= ? -# OR completed_at IS NULL -# ) -tasks = await ( - Task - .where("project_id", project_id) - .where(lambda q: ( - q.where_not_in("status", ["completed", "cancelled"]) - .or_where("completed_at", ">=", cutoff) - .or_where_raw("completed_at IS NULL") - )) - .get() -) -``` - -### Excluding values - -```python -active = await Task.where_not_in("status", ["archived", "deleted"]).get() -``` - -### OR conditions - -```python -results = await Task.where("priority", "high").or_where("due_today", True).get() -results = await Task.where_null("deleted_at").or_where_null("archived_at").get() -``` - -### Raw fragments - -Use `where_raw` / `or_where_raw` only when the ORM cannot express the condition: - -```python -tasks = await Task.where_raw("LOWER(title) LIKE ?", ("%search%",)).get() -tasks = await Task.where("active", True).or_where_raw("priority > 5").get() -``` - -### Pagination - -`paginate()` returns a `LengthAwarePaginator`. Pass it to `JsonResource.collection()` and pagination meta is included automatically in the JSON:API envelope. - -```python -# 15 records per page (default) -paginator = await Task.where("project_id", project_id).paginate() - -# Custom page size and page (inject from request query params) -paginator = await Task.where("project_id", project_id).paginate(per_page=25, page=page) - -# paginator attributes: total, per_page, current_page, last_page -print(paginator.total, paginator.current_page) - -# Wrap for JSON:API response -from fastapi_startkit.jsonapi import JsonResource -return TaskResource.collection(paginator) -# → {"data": [...], "meta": {"total": 120, "per_page": 25, "current_page": 2, ...}} -``` - -For a lighter paginator that only detects whether a next page exists (no COUNT query): - -```python -paginator = await Task.paginate_simple(per_page=25, page=page) -``` - -### Joins - -```python -tasks = await ( - Task - .join("projects", "tasks.project_id", "=", "projects.id") - .where("projects.owner_id", user_id) - .select("tasks.*", "projects.name") - .get() -) -``` - -## Relationships - -```python -from fastapi_startkit.masoniteorm.relationships import HasMany, BelongsTo - -class Post(Model): - title: str - user_id: int - author = BelongsTo("User", foreign_key="user_id") - -class User(Model): - name: str - posts = HasMany("Post", foreign_key="user_id") -``` - -Eager-load to avoid N+1 queries: - -```python -users = await User.with_("posts").get() -for user in users: - print(user.posts) -``` - -## Connections - -```python -users = await User.on("read_replica").where("active", True).get() -``` From 9ab1b94b3ddd18f43a29d1ac15ba438e1e04e82f Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 19:16:03 -0700 Subject: [PATCH 08/10] chore(release): clean dist/ before building to avoid stale artifacts --- bin/release.sh | 1 + example/package-lock.json | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 example/package-lock.json diff --git a/bin/release.sh b/bin/release.sh index c963caa8..4618e188 100755 --- a/bin/release.sh +++ b/bin/release.sh @@ -22,6 +22,7 @@ if [ -d "$PACKAGE_DIR" ]; then # Build echo " Building..." + rm -rf dist/ uv build # Publish via twine diff --git a/example/package-lock.json b/example/package-lock.json deleted file mode 100644 index 6e4aa92b..00000000 --- a/example/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "example", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From 59258016d9818e2d1d39e30a8e2d64b4fa4fe444 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Fri, 12 Jun 2026 19:34:37 -0700 Subject: [PATCH 09/10] feat: remove the warning from test --- example/config-app/uv.lock | 9 +++++++-- example/database-app/uv.lock | 9 +++++++-- .../fastapi_startkit/masoniteorm/connections/manager.py | 1 + .../tests/masoniteorm/models/test_model_attributes.py | 6 ++++-- .../tests/masoniteorm/models/test_model_query.py | 6 ++++-- fastapi_startkit/uv.lock | 2 +- 6 files changed, 24 insertions(+), 9 deletions(-) diff --git a/example/config-app/uv.lock b/example/config-app/uv.lock index f1a6cb64..4939697b 100644 --- a/example/config-app/uv.lock +++ b/example/config-app/uv.lock @@ -162,7 +162,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.26.0" +version = "0.40.1" source = { editable = "../../fastapi_startkit" } dependencies = [ { name = "cleo" }, @@ -178,23 +178,26 @@ dependencies = [ requires-dist = [ { name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" }, { name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.22.1" }, + { name = "anthropic", marker = "extra == 'ai'", specifier = ">=0.49.0" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29.0" }, { name = "cleo", specifier = ">=2.1.0,<3.0.0" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "dotty-dict", specifier = ">=1.3.1" }, { name = "faker", marker = "extra == 'database'", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.124.4,<0.125.0" }, + { name = "google-generativeai", marker = "extra == 'ai'", specifier = ">=0.8.0" }, { name = "inflection", specifier = ">=0.5.1" }, { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, + { name = "openai", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai"] [package.metadata.requires-dev] dev = [ @@ -202,10 +205,12 @@ dev = [ { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "asyncpg", specifier = ">=0.29.0" }, { name = "dumpdie", specifier = ">=1.5.0" }, + { name = "faker", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.124.4" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.9.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38" }, { name = "twine", specifier = ">=6.2.0" }, diff --git a/example/database-app/uv.lock b/example/database-app/uv.lock index aa0ae8bb..3ae36732 100644 --- a/example/database-app/uv.lock +++ b/example/database-app/uv.lock @@ -498,7 +498,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.26.0" +version = "0.40.1" source = { editable = "../../fastapi_startkit" } dependencies = [ { name = "cleo" }, @@ -527,23 +527,26 @@ postgres = [ requires-dist = [ { name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" }, { name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.22.1" }, + { name = "anthropic", marker = "extra == 'ai'", specifier = ">=0.49.0" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29.0" }, { name = "cleo", specifier = ">=2.1.0,<3.0.0" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "dotty-dict", specifier = ">=1.3.1" }, { name = "faker", marker = "extra == 'database'", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.124.4,<0.125.0" }, + { name = "google-generativeai", marker = "extra == 'ai'", specifier = ">=0.8.0" }, { name = "inflection", specifier = ">=0.5.1" }, { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, + { name = "openai", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai"] [package.metadata.requires-dev] dev = [ @@ -551,10 +554,12 @@ dev = [ { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "asyncpg", specifier = ">=0.29.0" }, { name = "dumpdie", specifier = ">=1.5.0" }, + { name = "faker", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.124.4" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.9.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38" }, { name = "twine", specifier = ">=6.2.0" }, diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py index b988397d..b7c10262 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/connections/manager.py @@ -41,5 +41,6 @@ def get_schema_builder(self): async def clear(self): for conn in self.connections.values(): + await conn.close() await conn.engine.dispose() self.connections.clear() diff --git a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py index dc4d3d2e..40a4b5bf 100644 --- a/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py +++ b/fastapi_startkit/tests/masoniteorm/models/test_model_attributes.py @@ -25,8 +25,10 @@ @pytest.fixture -def db(): - return DatabaseManager(ConnectionFactory(), SQLITE_CONFIG) +async def db(): + manager = DatabaseManager(ConnectionFactory(), SQLITE_CONFIG) + yield manager + await manager.clear() @pytest.fixture diff --git a/fastapi_startkit/tests/masoniteorm/models/test_model_query.py b/fastapi_startkit/tests/masoniteorm/models/test_model_query.py index 6483a0b3..766dbe6b 100644 --- a/fastapi_startkit/tests/masoniteorm/models/test_model_query.py +++ b/fastapi_startkit/tests/masoniteorm/models/test_model_query.py @@ -30,8 +30,10 @@ @pytest.fixture -def db(): - return DatabaseManager(ConnectionFactory(), SQLITE_CONFIG) +async def db(): + manager = DatabaseManager(ConnectionFactory(), SQLITE_CONFIG) + yield manager + await manager.clear() @pytest.fixture diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index 3ded87ed..947f8d4b 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -593,7 +593,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.38.0" +version = "0.40.1" source = { editable = "." } dependencies = [ { name = "cleo" }, From e751ccb3163a41e93823d64ff38c39d01a0dfd05 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 13 Jun 2026 00:25:48 -0700 Subject: [PATCH 10/10] chore: remove skills stubs accidentally included in test-warning-fix --- .../skill/broadcasting/SKILL.md | 91 --------------- .../skill/console-commands/SKILL.md | 100 ---------------- .../skill/orm-migrations/SKILL.md | 108 ------------------ 3 files changed, 299 deletions(-) delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/console-commands/SKILL.md delete mode 100644 fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-migrations/SKILL.md diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md deleted file mode 100644 index 69a86485..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/broadcasting/SKILL.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -name: broadcasting -description: WebSocket event broadcasting via Reverb — define events, emit to channels, authorize subscribers. ---- - -# Broadcasting - -## Defining an event - -Subclass `BroadcastEvent`, implement `broadcast_on()` to declare target channels, and set `payload` with the data to send: - -```python -from fastapi_startkit.broadcasting import BroadcastEvent, PrivateChannel - -class OrderShipped(BroadcastEvent): - def __init__(self, order_id: int) -> None: - self.payload = {"order_id": order_id, "status": "shipped"} - - def broadcast_on(self) -> list: - return [PrivateChannel(f"orders.{self.payload['order_id']}")] -``` - -- `broadcast_on()` must return a list of `Channel`, `PrivateChannel`, or `PresenceChannel` objects. -- `payload` (dict) is what subscribers receive. Defaults to `{}`. -- The event name on the wire is the class name (`"OrderShipped"`) unless you set `name = "custom.name"` on the class. - -## Emitting an event - -Call `await .emit()` on an event instance — it dispatches to all channels in `broadcast_on()`: - -```python -await OrderShipped(order_id=123).emit() -``` - -Alternatively use the `broadcast` helper directly: - -```python -from fastapi_startkit.broadcasting import broadcast - -await broadcast(OrderShipped(order_id=123)) -``` - -## Channel types - -| Class | Channel name on wire | Auth required | -|-------|----------------------|---------------| -| `Channel("chat")` | `chat` | No — public, open to all | -| `PrivateChannel("orders.1")` | `private-orders.1` | Yes — checked via `@channel` callback | -| `PresenceChannel("room.1")` | `presence-room.1` | Yes — checked + member tracking | - -`PrivateChannel` and `PresenceChannel` automatically prepend `private-` / `presence-` to the name you supply. - -## Channel authorization - -Private and presence channels require a server-side authorization callback. Register callbacks in `routes/channels.py` using the `@channel` decorator: - -```python -# routes/channels.py -from fastapi_startkit.broadcasting import channel - -@channel("orders.{order_id}") -async def authorize_orders(user, order_id: int) -> bool: - return user is not None and user.id == order_id - -@channel("private-notifications") -async def authorize_notifications(user) -> bool: - return user is not None -``` - -- The pattern supports `{wildcard}` placeholders. Wildcard values are cast to the declared parameter type (e.g. `order_id: int`). -- `user` is the authenticated user injected from the container's auth service. -- Return `True` to grant access, `False` to deny. -- Private/presence channels with **no registered callback are denied by default** (fail-safe). -- `routes/channels.py` is auto-loaded by `ReverbProvider` on boot. - -## Registering the provider - -Add `ReverbProvider` to your application's providers list: - -```python -from fastapi_startkit.broadcasting import ReverbProvider - -app = Application( - providers=[ - ..., - ReverbProvider, - ] -) -``` - -`ReverbProvider` binds `BroadcastManager` into the container, mounts the Reverb WebSocket endpoint, and registers the `/broadcasting/auth` HTTP route used by Laravel Echo for channel handshakes. diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/console-commands/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/console-commands/SKILL.md deleted file mode 100644 index 3f714ee9..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/console-commands/SKILL.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -name: console-commands -description: Build artisan CLI commands with Cleo in fastapi-startkit. Use when adding new artisan commands, defining arguments/options, accessing the service container, or registering commands via a provider. ---- - -# Console Commands - -Artisan commands are built on [Cleo](https://github.com/python-poetry/cleo). Each command is a class with a `name`, `description`, optional `arguments`/`options`, and a `handle()` method. - -## Defining a command - -```python -from cleo.helpers import argument, option -from fastapi_startkit.console import Command - -class GreetCommand(Command): - name = "greet" - description = "Greet a user by name." - - arguments = [ - argument("username", description="The name to greet"), - ] - options = [ - option("shout", "s", description="Output in uppercase", flag=True), - ] - - def handle(self): - name = self.argument("username") - msg = f"Hello, {name}!" - if self.option("shout"): - msg = msg.upper() - self.line(msg) -``` - -Run it: - -```bash -uv run artisan greet Alice -uv run artisan greet Alice --shout -``` - -## Async commands - -For commands that call `async` framework code (e.g. ORM queries), wrap with `asyncio.run`: - -```python -import asyncio -from fastapi_startkit.console import Command - -class SyncUsersCommand(Command): - name = "users:sync" - description = "Synchronise users from the remote API." - - def handle(self): - asyncio.run(self.handle_async()) - - async def handle_async(self): - from app.models import User - users = await User.where("synced", False).get() - self.line(f"Syncing {len(users)} users…") -``` - -## Accessing the container - -`Command` carries a `container` property set by the framework before `handle()` is called. Resolve any bound service: - -```python -def handle(self): - config = self.container.make("config") - db = self.container.make("db") - self.line(config.get("app.name")) -``` - -## Output helpers - -| Method | Description | -|--------|-------------| -| `self.line(msg)` | Print a line | -| `self.info(msg)` | Print in green | -| `self.comment(msg)` | Print in yellow | -| `self.error(msg)` | Print in red | -| `self.question(msg)` | Print in cyan | -| `self.line_error(msg)` | Print to stderr | -| `self.ask(question)` | Prompt for text input | -| `self.confirm(question)` | Prompt for yes/no | - -## Registering commands in a provider - -Expose commands from a service provider's `boot()` method: - -```python -from fastapi_startkit.providers import Provider -from app.commands import GreetCommand, SyncUsersCommand - -class AppServiceProvider(Provider): - def boot(self) -> None: - self.commands([GreetCommand, SyncUsersCommand]) -``` - -The provider must be registered in `bootstrap/application.py` for the commands to appear in `uv run artisan list`. diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-migrations/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-migrations/SKILL.md deleted file mode 100644 index 9c8d2c38..00000000 --- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/skill/orm-migrations/SKILL.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -name: orm-migrations -description: Create and run database schema migrations using the fastapi-startkit ORM. Use when creating tables, adding/dropping columns, or managing migration lifecycle with artisan commands. ---- - -# ORM Migrations - -Migrations describe schema changes as versioned Python files. The `Blueprint` builder maps to SQL DDL for SQLite, MySQL, and PostgreSQL. - -## Creating a migration - -```bash -uv run artisan make:migration create_users_table -``` - -This generates a file in `databases/migrations/` with `up()` and `down()` methods. - -## Migration class structure - -```python -from fastapi_startkit.masoniteorm.migrations import Migration - -class CreateUsersTable(Migration): - async def up(self): - async with await self.schema.create("users") as table: - table.increments("id") - table.string("name") - table.string("email").unique() - table.string("password") - table.enum("role", ["admin", "user"]).default("user") - table.boolean("active").default(True) - table.timestamps() - - async def down(self): - await self.schema.drop("users") -``` - -## Common Blueprint column types - -| Method | SQL type | -|--------|----------| -| `increments("id")` | auto-increment primary key | -| `string("col", length=255)` | VARCHAR | -| `text("col")` | TEXT | -| `integer("col")` | INT | -| `big_integer("col")` | BIGINT | -| `boolean("col")` | BOOLEAN / TINYINT(1) | -| `decimal("col", precision, scale)` | DECIMAL | -| `float_type("col")` | FLOAT | -| `date("col")` | DATE | -| `datetime("col")` | DATETIME | -| `timestamp("col")` | TIMESTAMP | -| `timestamps()` | `created_at` + `updated_at` | -| `soft_deletes()` | `deleted_at` nullable | -| `enum("col", ["a", "b"])` | ENUM | -| `json("col")` | JSON / TEXT | -| `foreign("col")` | foreign key column | -| `uuid("col")` | UUID / CHAR(36) | - -## Column modifiers - -Chain these after any column method: - -```python -table.string("email").unique() -table.string("bio").nullable() -table.string("status").default("active") -table.string("code").unsigned() -table.integer("views").after("title") # MySQL only -``` - -## Altering an existing table - -```python -async def up(self): - async with await self.schema.table("users") as table: - table.add_column("phone", "string", nullable=True) - table.drop_column("legacy_field") - table.rename_column("old_name", "new_name") -``` - -## Running migrations - -```bash -# Run all pending migrations -uv run artisan db:migrate - -# Check migration status -uv run artisan migrate:status - -# Roll back the last batch -uv run artisan migrate:rollback - -# Drop all tables and re-run from scratch -uv run artisan migrate:fresh -``` - -## Migration directory - -By default migrations are read from `databases/migrations/`. Configure a custom path in `config/database.py`: - -```python -@dataclass -class DatabaseConfig: - migrations: dict = field(default_factory=lambda: { - "directory": "databases/migrations" - }) -```