Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/bin/sh
cd fastapi_startkit && uvx ruff check --fix src/
186 changes: 186 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

This is a **monorepo** for the FastAPI Startkit ecosystem — a Laravel/Masonite-inspired framework for building Python applications with FastAPI. It contains four main components:

| Directory | Purpose | Published as |
|---|---|---|
| `fastapi_startkit/` | Core framework package | [`fastapi-startkit`](https://pypi.org/project/fastapi-startkit/) on PyPI |
| `fastapi_startkit.github.io.git/` | Documentation site | GitHub Pages (VitePress) |
| `example/` | Standalone example apps | Not published — reference only |
| `application/` | Starter application template | Not published — clone/scaffold target |

### `fastapi_startkit/` — Core Package

The PyPI package (`fastapi-startkit`, currently v0.13.6). Source lives under `src/fastapi_startkit/`. This is the foundational framework all other components depend on.

**Do not modify framework code unless explicitly necessary.** Changes to core abstractions (Container, Application, Model, Provider, Facades) can have broad breaking effects on downstream applications.

Optional extras are installed with pip/uv extras:

```
fastapi-startkit[fastapi] # FastAPI + Starlette
fastapi-startkit[database] # SQLAlchemy async ORM
fastapi-startkit[postgres] # asyncpg driver
fastapi-startkit[sqlite] # aiosqlite driver
fastapi-startkit[mysql] # aiomysql driver
fastapi-startkit[vite] # Jinja2 for Vite integration
```

### `fastapi_startkit.github.io.git/` — Documentation

VitePress site. Docs cover getting started, configuration, console, database, logging, FastAPI integration, frontend, and exception handling. Edit `.md` files under `docs/` and the home page at `index.md`.

### `example/` — Example Applications

Self-contained apps demonstrating specific features. Each subdirectory is an independent uv workspace member:

| App | What it shows |
|---|---|
| `config-app/` | Configuration system |
| `console-app/` | CLI / Cleo commands |
| `database-app/` | ORM, migrations, seeders |
| `fastapi-app/` | Minimal FastAPI setup |
| `inertia-pingcrm-app/` | Full Inertia.js + PingCRM clone |
| `onefile-app/` | Single-file application |
| `vite-app/` | Vite + Jinja2 frontend |

### `application/` — Starter Application

The template users clone when starting a new project. Contains the minimal scaffolding: `artisan` entrypoint, `bootstrap/`, `config/`, `providers/`, `routes/`, and `storage/`. It mirrors a typical project layout and is a uv workspace member of this monorepo.

## Commands

```bash
# Install all workspace dependencies
uv sync

# Build the core package
cd fastapi_startkit && uv build

# Run framework tests
uv run pytest fastapi_startkit/src/fastapi_startkit/tests/ -v

# Run a single test file
uv run pytest fastapi_startkit/src/fastapi_startkit/tests/configurations/test_config_merge.py -v

# Serve the docs locally
cd fastapi_startkit.github.io.git && npm run dev
```

Tests run with `asyncio_mode = "auto"` (configured in `pyproject.toml`), so all tests are async-capable by default.

## Architecture (Core Package)

### Application Lifecycle

1. `Application(base_path)` initializes the service container and singleton
2. `.load_environment()` loads `.env` + `.env.{APP_ENV}` (auto-detects `.env.testing` under pytest)
3. `.configure_paths()` sets config/storage paths
4. `.register_providers()` → `.load_providers()` (two-phase boot)
5. `app.fastapi` is lazy-loaded; HTTP routes delegate to the FastAPI instance

### Service Container (`container/container.py`)

Central IoC container. Core API:
- `bind(key, value)` — register a binding
- `make(key)` — resolve a binding
- `resolve(obj)` — auto-wire a callable by inspecting its type-hinted parameters

Hooks (`on_bind`, `on_make`, `on_resolve`) allow intercepting container operations. `collect('Auth*')` returns all bindings matching a wildcard.

### Configuration (`configuration/`)

Define config as a dataclass with fields sourced from environment variables via `env()`:

```python
from dataclasses import dataclass, field
from fastapi_startkit.environment import env

@dataclass
class RedisConfig:
host: str = field(default_factory=lambda: env('REDIS_HOST'))
port: int = field(default_factory=lambda: env('REDIS_PORT'))
```

`app.load_environment()` applies a two-step merge: `.env` as base, then `.env.{APP_ENV}` on top.

Register in the container for dotted-key access:

```python
config = app.make('config')
config.set('redis', RedisConfig())

Config.get('redis.host') # via facade
```

### Provider Pattern (`providers/`)

Providers are the standard way to register services. Each provider has two phases:
- `register()` — bind things into the container
- `boot()` — run after all providers are registered (safe to resolve dependencies here)

### FastAPI Routing (`fastapi/routers/router.py`)

`Router` wraps FastAPI's `APIRouter` and adds a `resource()` shortcut.

```python
from fastapi_startkit.fastapi import Router

router = Router()
router.get("/path", endpoint)
router.post("/path", endpoint)
router.put("/path", endpoint)
router.patch("/path", endpoint)
router.delete("/path", endpoint)
```

`router.resource(name, controller)` registers standard CRUD routes (index, create, store, show, edit, update, destroy). Use `only=`, `excepts=`, `names=`, `parameters=` to customise.

Group routes by access level using separate `Router` instances:

```python
# routes/web.py
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(auth_middleware)])
auth.get("/", dashboard_controller.index)
auth.resource("users", users_controller)
```

### ORM (`masoniteorm/`)

Async-first fork of Masonite ORM built on SQLAlchemy async:
- All DB operations are `async`/`await`
- `Model` auto-pluralizes table names via `inflection`
- `created_at`/`updated_at` managed as `pendulum` Carbon objects
- Relationships: `HasOne`, `HasMany`, `BelongsTo`, `BelongsToMany`, `HasOneThrough`
- `AsyncQueryBuilder` provides the chainable query interface

### Facades (`facades/`)

Static-like access to container-resolved services (`Config.get()`, `Auth.user()`, etc.). Each facade has a `.pyi` stub for IDE type support. Requires a booted Application singleton.

### Console (`commands/`, `masoniteorm/commands/`)

CLI built on [Cleo](https://github.com/python-poetry/cleo). Database commands (migrate, seed, make:model, etc.) live in `masoniteorm/commands/`. Run via `uv run artisan`.

## Key Dependencies

| Package | Purpose |
|---|---|
| `fastapi[standard]` | HTTP framework (lazily imported) |
| `sqlalchemy[asyncio]` | Async ORM backend |
| `pendulum` | Datetime/timezone (used as Carbon) |
| `cleo` | CLI commands |
| `dotty-dict` | Nested dict access via dotted keys |
| `inflection` | Table name pluralization |
| `asyncpg` / `aiomysql` / `aiosqlite` | DB drivers |
25 changes: 8 additions & 17 deletions example/database-app/bootstrap/application.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,23 @@
from pathlib import Path

from config.database import DatabaseConfig
from config.logging import LoggingConfig
from providers.console_provider import ConsoleProvider
from providers.fastapi_provider import FastAPIServiceProvider

from config.app import AppConfig

print("Loading Application class...")
from fastapi_startkit.application import Application
from fastapi_startkit.exceptions import ExceptionHandler
from fastapi_startkit.logging.providers import LogProvider
from fastapi_startkit.masoniteorm.providers import DatabaseProvider


class _FallbackHandler:
async def render(self, request, exc):
from fastapi.responses import JSONResponse
return JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
from config.app import AppConfig
from config.database import DatabaseConfig
from config.logging import LoggingConfig
from providers.console_provider import ConsoleProvider
from providers.fastapi_provider import FastAPIServiceProvider


class AppExceptionHandler(ExceptionHandler):
def register(self):
self.register_handler(Exception, _FallbackHandler())

pass

app: Application[AppConfig] = Application(
base_path=str(Path().cwd()),
base_path=Path(__file__).parent.parent,
config=AppConfig,
providers=[
(LogProvider, LoggingConfig),
Expand All @@ -35,4 +26,4 @@ def register(self):
FastAPIServiceProvider,
],
exception_handler=AppExceptionHandler,
)
)
2 changes: 1 addition & 1 deletion example/database-app/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
public = Router()

public.post("/register/student", student_auth.register)
public.post("/register/teacher", AuthController.register_teacher)
public.post("/register/teacher", AuthController.register_teacher)
10 changes: 10 additions & 0 deletions fastapi_startkit/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ dev = [
]


[tool.ruff]
line-length = 120

[tool.ruff.lint]
select = ["F401"]
fixable = ["F401"]

[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]

[tool.pytest.ini_options]
asyncio_mode = "auto"

Expand Down
5 changes: 4 additions & 1 deletion fastapi_startkit/src/fastapi_startkit/application.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import os
from fastapi_startkit.providers.app_provider import AppProvider
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from typing import Type, Callable, Any, List, TypeVar, Generic

from fastapi_startkit.providers.app_provider import AppProvider
from .config import AppConfig
from .configuration.providers import ConfigurationProvider
from .container import Container
Expand Down Expand Up @@ -83,6 +83,9 @@ def register_providers(self):
config = {}
if isinstance(provider_data, tuple):
provider_class, config = provider_data

if callable(config):
config = config()
else:
provider_class = provider_data

Expand Down
1 change: 0 additions & 1 deletion fastapi_startkit/src/fastapi_startkit/config/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
from .app import AppConfig
from .facades import Config
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi_startkit.loader import Loader
from ..utils.structures import data
from ..exceptions import InvalidConfigurationLocation, InvalidConfigurationSetup
from ..exceptions import InvalidConfigurationSetup


class Configuration:
Expand Down
6 changes: 4 additions & 2 deletions fastapi_startkit/src/fastapi_startkit/exceptions/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import atexit
from typing import Any, Callable, Dict, List, Optional, Type

from dumpdie import dd


class ExceptionHandler:
Expand Down Expand Up @@ -62,10 +61,13 @@ def report(self, exception: Exception):
self.report_exception(exception)

def report_exception(self, exception: Exception):
context = self._build_context(exception)
if self.app and self.app.has("logger"):
from fastapi_startkit.logging.logger import Logger

Logger.error(self._build_context(exception))
Logger.error(context)
else:
print(context, file=sys.stderr)

def _build_context(self, exception: Exception) -> str:
import traceback
Expand Down
28 changes: 28 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/fastapi/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class HTTPExceptionHandler:
"""
The base exception handler for FastAPI applications.
"""

async def render(self, request, exc):
import traceback
from fastapi.responses import JSONResponse
from fastapi_startkit.container import Container

app = Container.instance()
if app.is_debug():
tb = exc.__traceback__
frames = traceback.extract_tb(tb)
content = {
"message": str(exc),
"exception": f"{type(exc).__module__}.{type(exc).__qualname__}",
"file": frames[-1].filename if frames else None,
"line": frames[-1].lineno if frames else None,
"trace": [
{"file": f.filename, "line": f.lineno, "function": f.name}
for f in frames
],
}
else:
content = {"message": "Server Error"}

return JSONResponse(status_code=500, content=content)
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from fastapi_startkit.fastapi.exceptions import HTTPExceptionHandler
from fastapi import FastAPI

from fastapi_startkit.fastapi.commands import ServeCommand
Expand All @@ -20,10 +21,8 @@ def boot(self):

def _register_exception_handlers(self):
"""Wire exception_manager as a catch-all handler for all exceptions."""
if not self.app.exception_manager:
return

exception_manager = self.app.exception_manager
exception_manager.register_handler(Exception, HTTPExceptionHandler())

async def handler(request, exc):
return await exception_manager.handle(exc, {"request": request})
Expand Down
20 changes: 20 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/helpers/string.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ def trim(self, suffix: str) -> "Stringable":
def slugify(self) -> "Stringable":
return Stringable(Str.slugify(self.text))

def camel_case(self) -> "Stringable":
return Stringable(Str.camel_case(self.text))

def snake_case(self) -> "Stringable":
return Stringable(Str.snake_case(self.text))


class Str:
@classmethod
Expand All @@ -29,3 +35,17 @@ def slugify(cls, text: str) -> str:
def trim(cls, text: str, word: str) -> str:
"""Remove all occurrences of a word from the string (case-insensitive)."""
return re.sub(re.escape(word), "", text, flags=re.IGNORECASE).strip("_").strip()

@classmethod
def camel_case(cls, text: str) -> str:
"""Convert a string to camelCase."""
words = re.split(r"[-_\s]+", text)
return words[0].lower() + "".join(word.capitalize() for word in words[1:])

@classmethod
def snake_case(cls, text: str) -> str:
"""Convert a string to snake_case."""
text = re.sub(r"[-\s]+", "_", text)
text = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", text)
text = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", text)
return text.lower()
Loading
Loading