Install
$ agentstack add skill-ocbunknown-fastapi-claude-template-endpoint ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Writing HTTP endpoints (src/presentation/http/v1/endpoints//)
Endpoints are thin adapters. They have no business logic, no DB access, no validation beyond schema parsing. Their job is contract → Request → request_bus.send() → OkResponse(contract.from(result)).
Pick the audience folder first
Endpoints live under presentation/http/v1/endpoints// where `` decides auth:
| Folder | Router guard | Use for | |---|---|---| | public/ | none | healthcheck, register, login, refresh, public forms | | user/ | Authorization() (any authenticated user) | /users/me, logout, anything user-owned | | admin/ | Authorization("Admin") | admin user management, moderation, cross-user operations | | internal/ | service-to-service stub, include_in_schema=False | webhooks, internal APIs |
You never attach Authorization(...) at the endpoint function level when the router already enforces it. FastAPI caches the dependency result per request, so declaring user: Annotated[UserResult, Require(Authorization())] as a parameter inside a user/-audience endpoint is free — it returns the same cached UserResult that the router-level guard already validated.
Endpoint file skeleton
# src/presentation/http/v1/endpoints/admin/widget.py
from typing import Annotated
import uuid_utils.compat as uuid
from dishka.integrations.fastapi import DishkaRoute
from fastapi import APIRouter, Query, status
from fastapi import Depends as Require
from src.application.common.interfaces.request_bus import RequestBus
from src.application.common.pagination import OffsetPagination
from src.application.v1.results import OffsetResult, WidgetResult
from src.application.v1.usecases.widget import (
CreateWidgetRequest,
SelectManyWidgetRequest,
SelectWidgetRequest,
UpdateWidgetRequest,
)
from src.common.di import Depends
from src.database.psql.types.widget import WidgetLoads
from src.presentation.http.common.responses import OkResponse
from src.presentation.http.v1 import contracts
admin_widget_router = APIRouter(
prefix="/widgets", tags=["Admin | Widget"], route_class=DishkaRoute
)
Fixed imports
from fastapi import Depends as Require— the project always aliasesDepends→Requireto make the intent obvious.from src.common.di import Depends— this is Dishka'sDepends[T]marker used for DI, different from FastAPI'sDepends. UseDepends[RequestBus]etc. as parameter annotations.from dishka.integrations.fastapi import DishkaRoute— everyAPIRouterpassesroute_class=DishkaRouteso Dishka can inject into endpoints.tags=[" | "]— two-word tag with a pipe, e.g."Admin | User","Admin | Widget","User","Public".
Result → Contract mapping
Endpoints always hand the client a Contract, never a raw Result. There are two shapes:
- Single item:
contracts.User.model_validate(result)— pass the Result instance directly. No.model_dump()round-trip needed;Contractinheritsfrom_attributes=True, so Pydantic reads attributes off the Result object in place. - Paginated list:
result.map(contracts.User.model_validate)—OffsetResult.map(fn)appliesfnto every item in.dataand returns a newOffsetResult[R]with the sameoffset/limit/total. No manual unpacking, no re-building the envelope.
The OffsetResult[T] envelope is layer-agnostic — defined once in application/v1/results/base.py and reused as both the use case return type and the HTTP response type. You use the application class for response_model=OffsetResult[contracts.User]. There is no contracts.OffsetResult.
Parameter naming — non-negotiable
| HTTP method | Input source | Parameter name | Type annotation | |---|---|---|---| | GET | query string filters | query | Annotated[contracts.SelectWidgets, Require(contracts.SelectWidgets)] | | POST/PATCH/PUT/DELETE | JSON body | data | contracts.CreateWidget (direct, FastAPI parses body automatically) | | path | path params | widget_uuid: uuid.UUID (literal FastAPI path) | — | | extras for GET | standalone primitives | named (loads, order_by) | tuple[WidgetLoads, ...] = Query(default=(), title="...") |
Never use body, payload, params, queries, input, args. The project is consistent — query for GET filters, data for mutation bodies.
The five-parameter list shape (GET /widgets)
Every list endpoint uses exactly this shape — do not deviate, do not reorder:
@admin_widget_router.get(
"",
response_model=OffsetResult[contracts.Widget],
status_code=status.HTTP_200_OK,
)
async def select_widgets_endpoint(
request_bus: Depends[RequestBus],
query: Annotated[contracts.SelectWidgets, Require(contracts.SelectWidgets)],
pagination: Annotated[
contracts.OffsetPagination, Require(contracts.OffsetPagination)
],
loads: tuple[WidgetLoads, ...] = Query(
default=(), title="Additional relations"
),
) -> OkResponse[OffsetResult[contracts.Widget]]:
result: OffsetResult[WidgetResult] = await request_bus.send(
SelectManyWidgetRequest(
loads=loads,
**query.model_dump(),
pagination=OffsetPagination(**pagination.model_dump()),
)
)
return OkResponse(result.map(contracts.Widget.model_validate))
Five things to notice:
query: Annotated[ContractCls, Require(ContractCls)]—Require(ContractCls)tells FastAPI to treat the Pydantic model's fields as separate query string parameters. This works for flat primitives (str | None,UUID | None,int,Literal[...]). Nested/complex types break — keep filter contracts flat.pagination: Annotated[contracts.OffsetPagination, Require(contracts.OffsetPagination)]— same trick for pagination params.contracts.OffsetPaginationis the strict version (10 ≤ limit ≤ 200,offset ≥ 0). It is mapped to the lenient application version on the request_bus.send line:OffsetPagination(**pagination.model_dump()). In the endpoint module the unqualifiedOffsetPaginationrefers to the application class (imported fromsrc.application.common.pagination), andcontracts.OffsetPaginationrefers to the strict HTTP contract class — no name collision.loads: tuple[WidgetLoads, ...] = Query(default=(), title="...")— a separate standalone query parameter, not nested inside the filter contract. Lets the frontend opt in to relations per request. Forward as-is to the Request.await request_bus.send(SelectManyWidgetRequest(loads=loads, **query.model_dump(), pagination=OffsetPagination(**pagination.model_dump())))— flat kwargs from the query contract, pagination mapped, loads forwarded.result.map(contracts.Widget.model_validate)— the generic envelope.map()retargets the item type without re-building offset/limit/total manually.contracts.Widget.model_validate(widget_result)works becauseContractusesfrom_attributes=True.
Single GET / PATCH / POST shapes
GET one (path param)
@admin_widget_router.get(
"/{widget_uuid}",
response_model=contracts.Widget,
status_code=status.HTTP_200_OK,
)
async def select_widget_endpoint(
widget_uuid: uuid.UUID,
request_bus: Depends[RequestBus],
loads: tuple[WidgetLoads, ...] = Query(
default=(), title="Additional relations"
),
) -> OkResponse[contracts.Widget]:
result: WidgetResult = await request_bus.send(
SelectWidgetRequest(widget_uuid=widget_uuid, loads=loads)
)
return OkResponse(contracts.Widget.model_validate(result))
POST body
@admin_widget_router.post(
"",
response_model=contracts.Widget,
status_code=status.HTTP_201_CREATED,
)
async def create_widget_endpoint(
data: contracts.CreateWidget,
request_bus: Depends[RequestBus],
) -> OkResponse[contracts.Widget]:
result: WidgetResult = await request_bus.send(
CreateWidgetRequest(**data.model_dump())
)
return OkResponse(contracts.Widget.model_validate(result))
PATCH with path + body
@admin_widget_router.patch(
"/{widget_uuid}",
response_model=contracts.Widget,
status_code=status.HTTP_200_OK,
)
async def update_widget_endpoint(
widget_uuid: uuid.UUID,
data: contracts.AdminUpdateWidget,
request_bus: Depends[RequestBus],
) -> OkResponse[contracts.Widget]:
result: WidgetResult = await request_bus.send(
UpdateWidgetRequest(
widget_uuid=widget_uuid,
**data.model_dump(exclude_unset=True),
)
)
return OkResponse(contracts.Widget.model_validate(result))
Note exclude_unset=True — PATCH bodies should forward only the fields the client actually sent, so the use case can distinguish "not provided" from "set to null".
User-scoped endpoint (/me) — identity comes from the guard, never the client
When the endpoint lives under user/ audience and operates on the caller's own data, the target identifier must come from the authenticated user parameter, never from the path or body:
@user_me_router.patch(
"",
response_model=contracts.User,
status_code=status.HTTP_200_OK,
)
async def update_self_endpoint(
data: contracts.UpdateSelf, # ← NO role_uuid, NO active, NO user_uuid
request_bus: Depends[RequestBus],
user: Annotated[UserResult, Require(Authorization())], # ← identity from JWT
) -> OkResponse[contracts.User]:
result: UserResult = await request_bus.send(
UpdateUserRequest(
user_uuid=user.uuid, # ← from guard, never from client
**data.model_dump(exclude_unset=True),
)
)
return OkResponse(contracts.User.model_validate(result))
This is a hard rule — the architecture reviewer will fail any user endpoint that accepts an identifier in the path/body when the guard already provides it.
Contract rules — the split between user and admin
For every mutation with a user-facing version and an admin-facing version, you need two separate contracts:
# presentation/http/v1/contracts/widget.py
class UpdateSelfWidget(Contract):
"""Fields a user may change about their own widget."""
name: str | None = None
class AdminUpdateWidget(Contract):
"""Fields an admin may change about any widget."""
name: str | None = None
owner_uuid: uuid.UUID | None = None # privileged: reassign ownership
active: bool | None = None # privileged: moderation
Privileged fields that must NEVER appear in a user contract:
role_uuid,role,is_admin,permissions,scopesactive,banned,email_verifieduser_uuid,target_uuid,id(identity is from the guard, not the payload)login,email(identity fields)quota,tier,plan(billing-controlled)
Fields that must NEVER appear in a response contract (any audience):
password,password_hash,hashed_password,saltrefresh_token,access_token(unless you're returning a freshly issued pair in login/refresh)secret,api_key,private_keystripe_customer_id,internal_note- Any column from a DB model that you'd be uncomfortable seeing in a server log
If the Result type includes such a field, that's fine (application layer can have rich data) — but the Contract is the filter. List only the fields the client is allowed to see.
Router wiring (the __init__.py)
Each audience folder has a __init__.py that composes its endpoint files into a router with audience-level auth:
# src/presentation/http/v1/endpoints/admin/__init__.py
from fastapi import APIRouter
from fastapi import Depends as Require
from src.presentation.http.v1.guards import Authorization
from .user import admin_user_router
from .widget import admin_widget_router
def setup_admin_router() -> APIRouter:
router = APIRouter(
prefix="/admin",
dependencies=[Require(Authorization("Admin"))],
)
router.include_router(admin_user_router)
router.include_router(admin_widget_router)
return router
Then register the setup function in src/presentation/http/v1/endpoints/__init__.py::setup_v1_routers().
Anti-patterns (hard no)
- ❌ Business logic inside an endpoint (
if user.role.name == "Admin": ..., DB calls, conditional mediation). Push it into a use case. - ❌ Using
body,payload,params,queriesas parameter names. It'sdatafor bodies,queryfor GET filters. - ❌
Authorization("Admin")inside an endpoint parameter when the router already declares it. It double-validates the JWT. - ❌
Authorization()(no role) in anadmin/endpoint when the user must beAdmin. The router should enforce"Admin". - ❌ Declaring a path parameter like
/users/{user_uuid}underuser/audience — users shouldn't target themselves via path, use/users/meor/me. - ❌ Accepting
role_uuid,active,is_adminin anUpdateSelf*contract. - ❌ Returning a raw
Resultfrom the use case — always wrap inOkResponse(contracts.X.model_validate(result))(the intermediate.model_dump()is unnecessary becauseContractsetsfrom_attributes=True), or useresult.map(contracts.X.model_validate)forOffsetResult. - ❌ Forgetting
route_class=DishkaRouteonAPIRouter(...). Without it, Dishka can't inject into endpoint parameters. - ❌ Hard-coding
limit=10or any pagination behavior in the endpoint — pagination lives incontracts.OffsetPagination. - ❌ Calling the contract's
OffsetPaginationaspagination: OffsetPagination = OffsetPagination()— that's the application version. The endpoint must usecontracts.OffsetPaginationviaRequire.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: ocbunknown
- Source: ocbunknown/fastapi-claude-template
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.