Metadata-Version: 2.4
Name: fastapi-component
Version: 0.3.0
Summary: FastAPI integration for python-components: run a component System inside the application lifespan
Project-URL: Homepage, https://github.com/lucassant95/fastapi-component
Project-URL: Documentation, https://github.com/lucassant95/fastapi-component#readme
Project-URL: Repository, https://github.com/lucassant95/fastapi-component
Project-URL: Issues, https://github.com/lucassant95/fastapi-component/issues
Project-URL: Changelog, https://github.com/lucassant95/fastapi-component/releases
Author-email: Lucas Sant'Anna <lucassant95@gmail.com>
Maintainer-email: Lucas Sant'Anna <lucassant95@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: component,dependency-injection,fastapi,lifecycle,lifespan,system
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: fastapi>=0.115
Requires-Dist: python-components<0.5,>=0.4.0
Provides-Extra: auth
Requires-Dist: pwdlib[argon2]>=0.2; extra == 'auth'
Requires-Dist: pyjwt>=2.8; extra == 'auth'
Description-Content-Type: text/markdown

# fastapi-component

FastAPI integration for [python-components](https://github.com/lucassant95/python-components):
run a component `System` inside the application lifespan.

Declaring that a component needs async initialization is just defining
`async def start()` — the `System` awaits it inside the server's event loop
when the application starts, and shuts everything down in reverse dependency
order when the server stops. This library wires that lifecycle into FastAPI
without hiding any native FastAPI feature.

```bash
pip install fastapi-component
```

Requires Python >= 3.11, `fastapi >= 0.115` and `python-components >= 0.4, < 0.5`.

## Usage

### 1. Native-first: plug the lifespan into your own app

If you build your `FastAPI` app yourself (custom middleware, sub-apps,
anything), use `system_lifespan` — it is a standard lifespan callable:

```python
from fastapi import FastAPI
from python_components import System
from fastapi_component import system_lifespan

system = System({
    "config": Config(),
    "database": Database().using(["config"]),      # async def start() → awaited in the loop
    "consumer": QueueConsumer().using(["config"]),  # async def start() / async def shutdown()
})

app = FastAPI(title="My API", lifespan=system_lifespan(system))
```

On startup the system is exposed as `app.state.system` (configurable via
`state_key=`) and started with `astart()`; on shutdown it is stopped with
`ashutdown()`.

Have startup/teardown logic of your own? Compose it with `wrap=` — it runs
strictly *inside* the system's lifecycle (components are up before it enters,
still up when it exits), and whatever it yields becomes regular
[lifespan state](https://www.starlette.io/lifespan/#lifespan-state):

```python
from contextlib import asynccontextmanager

@asynccontextmanager
async def app_lifespan(app):
    ml_model = await load_model()
    yield {"ml_model": ml_model}
    await ml_model.close()

app = FastAPI(lifespan=system_lifespan(system, wrap=app_lifespan))
```

### 2. Batteries: `create_app`

A thin factory when you don't need to construct `FastAPI` yourself. Every
native constructor argument passes through untouched — the one exception being
the legacy `on_startup`/`on_shutdown` event kwargs: because `create_app` always
installs a lifespan (which makes FastAPI ignore them), passing either raises
`TypeError` instead of silently dropping the handlers. Move that logic into
`lifespan=` (composed inside the system's lifecycle):

```python
from fastapi_component import create_app

app = create_app(
    system,
    routers=[users_router, orders_router],
    configure=lambda app: app.add_middleware(CORSMiddleware, allow_origins=["*"]),
    lifespan=app_lifespan,          # composed inside the system's lifecycle
    title="My API",                 # ← any FastAPI(...) kwarg:
    dependencies=[Depends(auth)],   #   app-level dependencies,
    exception_handlers=handlers,    #   exception handlers, docs_url, ...
)
```

`configure` is a build-time hook called once with the app — middleware,
instrumentation (e.g. Prometheus), extra endpoints, anything. It runs after all
routers (explicit and component-provided) are included. Component route
discovery can be disabled with `component_routes=False`.

### 3. Components that own their routes: `RouteProvider`

A component can ship its endpoints alongside its lifecycle by implementing the
`RouteProvider` protocol — a single method, `def routes(self) -> APIRouter`:

```python
from fastapi import APIRouter, Depends
from python_components import Component
from fastapi_component import component


class UsersComponent(Component):
    def routes(self) -> APIRouter:
        router = APIRouter(prefix="/users", tags=["users"])

        @router.get("")
        async def list_users(db=Depends(component("database"))):
            return await db.fetch_all("SELECT * FROM users")

        return router

    async def start(self): ...
    async def shutdown(self): ...
```

`create_app` includes these routers automatically. With a self-built app, call
`include_component_routes` after constructing it:

```python
from fastapi_component import include_component_routes, system_lifespan

app = FastAPI(lifespan=system_lifespan(system))
include_component_routes(app, system)
```

Discovery walks the system depth-first in `system_map` insertion order,
recursing into nested `System`s, and includes each component instance at most
once. Explicit `routers=` are included first, so on a path collision they win
(Starlette matches first-registered first).

One rule to remember: the app is built *before* the system starts, so
`routes()` runs on un-started components — declare paths there, but never
capture state produced by `start()`. Handlers run after startup; resolve live
components with `Depends(component("name"))` as above. A prefix belongs on the
router itself (`APIRouter(prefix=...)`).

### 4. Idiomatic DI in handlers

```python
from fastapi import Depends
from fastapi_component import component, get_system

@app.get("/status")
def status(system=Depends(get_system)):
    return {"state": system.state.value}

@app.get("/users")
async def list_users(db=Depends(component("database"))):
    return await db.fetch_all("SELECT * FROM users")
```

Handlers can also reach the system directly via `request.app.state.system`.

### 5. JWT authentication plugin (`fastapi-component[auth]`)

`fastapi_component.auth` ships a complete JWT auth flow as a component,
behind the optional `auth` extra:

```bash
pip install 'fastapi-component[auth]'   # adds PyJWT + pwdlib[argon2]
```

The application implements two persistence protocols (`UserStore`,
`RefreshTokenStore`) on components of its own, and wires the plugin in:

```python
from fastapi_component.auth import JWTAuth, require_scopes

system = System({
    "config": Config(),                      # exposes JWT_SECRET_KEY (required, ≥32 chars),
                                             # JWT_ACCESS_TOKEN_TTL_MINUTES (15),
                                             # JWT_REFRESH_TOKEN_TTL_DAYS (30)
    "auth_store": PostgresAuthStore().using(["database"]),   # implements both protocols
    "auth": JWTAuth().using(
        {"user_store": "auth_store", "token_store": "auth_store", "config": "config"}
    ),
})
app = create_app(system)   # RouteProvider discovery adds POST /auth/{login,refresh,logout}
```

- **Scopes are per-user data.** The `AuthUser` your store returns carries a
  `scopes` sequence; the plugin embeds it verbatim in the token and imposes
  no role or grouping concept — how scopes get assigned is entirely the
  application's policy.
- **Login** returns a short-lived HS256 access token (`sub`, `scope` claims)
  in JSON and a 30-day rotating refresh token in an httpOnly `Secure`
  cookie scoped to the auth prefix. Refresh tokens are stored sha256-hashed;
  replaying an already-rotated token revokes its whole session as compromised.
- **Guarding routes:** `dependencies=[Depends(require_scopes("catalog:write"))]`
  at router level, or `user: AuthenticatedUser = Depends(require_scopes())` to
  capture identity. Missing/invalid token → 401 with a `WWW-Authenticate:
  Bearer` challenge; insufficient scope → 403. Verification is stateless — no
  store access per request.
- **Testing consumers:** `require_scopes(...)` is cached per argument tuple,
  so `app.dependency_overrides[require_scopes("catalog:write")] = fake` in a
  test suite targets the very callable baked into the router.
- **External IdPs later:** `JWTAuth.issue_tokens(user)` is public — a future
  e.g. Google login route verifies the provider credential, resolves the user,
  and mints tokens through the exact path password login uses.

## Failure semantics

- **Startup failure (fail-fast):** if a component's `start()` raises, the
  components already started are rolled back (shut down in reverse order,
  automatic in python-components 0.4), `ComponentStartError` propagates and
  the server aborts startup. Nothing is left half-running.
- **Runtime failure:** this is a lifecycle manager, not a supervisor — there
  is no in-process restart of individual components. Clients that recover by
  design (e.g. lazy connection pools) self-heal on the next use; for anything
  irrecoverable, expose component health on an endpoint (the system is at
  `app.state.system`) and let your orchestrator's liveness probe restart the
  process.
- **Shutdown failure:** a component failing to shut down does not prevent the
  others from shutting down; the failures are aggregated in an
  `ExceptionGroup`, logged and re-raised so a non-clean exit is visible. If the
  app body (or `wrap` teardown) already raised, that exception stays primary and
  the shutdown `ExceptionGroup` is logged and attached to it via `add_note`
  rather than masking it (mirroring python-components' `System.__aexit__`).
- **Double start:** the lifespan refuses (with `RuntimeError`) a system that
  is already started — it must own the system's lifecycle. A system left in the
  STOPPED state is restarted, so each component's `start()` runs again;
  components must tolerate that if the app lifecycle can cycle.

## Development

```bash
uv sync
uv run pytest
uv run ruff format --check . && uv run ruff check .
```

## License

MIT
