Metadata-Version: 2.4
Name: fast-actions
Version: 0.3.0
Summary: Opinionated class-based action routers for FastAPI
Author: Florian Daude
Author-email: Florian Daude <floriandaude@hotmail.fr>
License-Expression: Apache-2.0
License-File: LICENSE
Requires-Dist: fastapi>=0.116.0
Requires-Dist: pydantic>=2.11.0
Requires-Python: >=3.12
Description-Content-Type: text/markdown

# Fast Actions

Fast Actions generates opinionated, class-based action routers for FastAPI.

It is intended for typed, action-oriented RPC APIs where every operation:

- uses `POST`;
- accepts exactly one Pydantic request object;
- returns exactly one Pydantic response object;
- has no path, query, header, or cookie parameters in its action signature; and
- receives shared FastAPI dependencies through controller fields.

Fast Actions only generates endpoints. It does not replace the FastAPI application, install
middleware, register global exception handlers, or change dependency injection and request
validation behavior.

## Installation

```console
pip install fast-actions
```

Python 3.12 or newer is required.

## Example

```python
import typing as t

from fastapi import FastAPI
from pydantic import BaseModel

from fast_actions import Controller, Depends, Error, errors


class CreateUserRequest(BaseModel):
    email: str


class CreateUserResponse(BaseModel):
    user_id: str


class EmailAlreadyExists(Error):
    pass


async def provide_user_service() -> 'UserService':
    ...


class Users(Controller):
    users: t.Annotated['UserService', Depends(provide_user_service)]

    @errors(EmailAlreadyExists)
    async def create(self, request: CreateUserRequest) -> CreateUserResponse:
        if await self.users.email_exists(request.email):
            message = 'A user already exists with this email.'
            raise EmailAlreadyExists(message)

        user = await self.users.create(request.email)
        return CreateUserResponse(user_id=user.id)


app = FastAPI()
Users.mount_on(app, '/users')
```

This defines `POST /users/create`.

`mount_on()` is a convenience for regular FastAPI router inclusion. The following forms are
equivalent:

```python
Users.mount_on(app, '/users')
app.include_router(Users.to_router('/users'))
```

Both `FastAPI` and `APIRouter` instances can be mounting targets.

## Actions

Every public asynchronous instance method declared on a controller is an action. Method names are
converted from snake case to kebab case:

```python
class Users(Controller):
    async def reset_password(
        self, request: ResetPasswordRequest
    ) -> ResetPasswordResponse:
        ...
```

This defines `POST /users/reset-password` when mounted on `/users`.

The method named exactly `_` defines an action at the controller mount path itself:

```python
class Users(Controller):
    async def _(self, request: ListUsersRequest) -> ListUsersResponse:
        ...
```

This defines `POST /users`. Other names beginning with `_` are private and are not registered, so
they can be used for controller helpers.

Action methods must:

- be asynchronous instance methods;
- accept exactly `self` and one required request argument; and
- annotate the request and response with concrete Pydantic `BaseModel` subclasses or `None`.

Invalid controller definitions raise `ControllerDefinitionError` when converted to a router.
Inherited controller actions are included. Action names that normalize to the same route are
rejected rather than being registered ambiguously.

## Dependencies

Controller dependencies use `Annotated` and `Depends` without a constructor:

```python
class Users(Controller):
    users: t.Annotated[UserService, Depends(provide_user_service)]
    actor: t.Annotated[Actor, Depends(authenticate)]
```

Fast Actions creates one controller instance per request and assigns the resolved dependencies to
its fields. Controllers cannot define `__init__` or `__new__`, and action methods cannot declare
additional dependencies.

All controller dependencies run for every action on that controller. Split a controller when its
actions need materially different dependencies or authentication policies.

`Depends` is re-exported by `fast_actions`; it is FastAPI's normal dependency marker, so dependency
overrides and dependency cleanup continue to work normally.

## Empty Objects

Annotate a request or response as `None` to represent an empty JSON object:

```python
class Cache(Controller):
    async def clear(self, request: None) -> None:
        ...
```

The client must send `{}`, the action receives `None`, the action returns `None`, and the HTTP
response is `{}`. Extra request properties are rejected by FastAPI validation.

## Errors

Raise `Error` from an action to return a JSON error response:

```python
class PermissionDenied(Error):
    @classmethod
    def get_status(cls) -> int:
        return 403


raise PermissionDenied('You cannot perform this action.')
```

The response is:

```json
{
  "code": "PERMISSION_DENIED",
  "message": "You cannot perform this action."
}
```

`Error.get_code()` derives the stable code from the class name. Renaming an error class is therefore
an API-breaking change. `Error.get_status()` defaults to `400` and may return any status from `400`
through `599` except `422`, which remains reserved for FastAPI request and dependency validation.

Declare errors for OpenAPI with the metadata-only `@errors` decorator:

```python
@errors(EmailAlreadyExists, PermissionDenied)
async def create(self, request: CreateUserRequest) -> CreateUserResponse:
    ...
```

An undeclared `Error` raised by the action is still returned using its code, message, and status; it
is simply absent from OpenAPI.

Fast Actions catches `Error` only around the controller method call. An `Error` raised by a
dependency is not converted. FastAPI `HTTPException`, request validation, dependency validation,
response validation, and unexpected exceptions keep their normal FastAPI behavior. In particular,
invalid requests remain `422` responses.

## Business Outcomes

Business outcomes that should all return `200` belong in the application's response model. Fast
Actions has no special result or detailed-error abstraction:

```python
class Created(BaseModel):
    user_id: str


class EmailUnavailable(BaseModel):
    code: t.Literal['EMAIL_UNAVAILABLE']
    message: str


class CreateUserResponse(BaseModel):
    result: Created | EmailUnavailable
```

Return these models normally instead of raising `Error`.

## OpenAPI Names

Fast Actions creates private request and response model copies for every action. The names are
derived from the path known when `to_router()` or `mount_on()` is called:

```text
/users                 -> UsersRequest, UsersResponse
/users/create          -> UsersCreateRequest, UsersCreateResponse
/api/v1/users/create   -> ApiV1UsersCreateRequest, ApiV1UsersCreateResponse
```

Reusing the same application model in multiple actions still produces distinct OpenAPI components.

Prefixes added later when a parent router is included are intentionally not part of these names:

```python
Users.mount_on(api_router, '/users')
app.include_router(api_router, prefix='/api/v1')
```

The final route is `/api/v1/users`, but its components are named `UsersRequest` and `UsersResponse`.

## Scope

Fast Actions is a good fit for internal APIs, backend-for-frontend services, and APIs consumed by
generated clients. POST-only reads do not have normal HTTP cache or safe-method semantics. File
uploads, streaming responses, and transport-oriented APIs should use FastAPI routes directly.
