A pipe transforms or validates a single value on its way into a handler. Pydantic models already cover request bodies; pipes are for everything else — path parameters, query strings, headers.
A pipe#
from bustan import Injectable, Pipe, BadRequestError
@Injectable()
class ParsePositiveInt(Pipe):
async def transform(self, value: str) -> int:
try:
parsed = int(value)
except ValueError:
raise BadRequestError(f"{value!r} is not an integer")
if parsed < 1:
raise BadRequestError("must be positive")
return parsed
Apply it#
from bustan import UsePipes, Param
@Controller("/tasks")
class TaskController:
@Get("/{id}")
async def get_one(
self, id: int = Param("id", pipes=[ParsePositiveInt])
) -> dict[str, str]: ...
Or to every parameter on a route:
@UsePipes(ParsePositiveInt)
@Get("/{id}")
async def get_one(self, id: int) -> dict[str, str]: ...
Order of operations#
Guards run first — there is no point validating input for a request that is not allowed. Pipes run per parameter, in the order listed.
Built-in pipes#
| Pipe | Behaviour |
|---|---|
ParseInt |
String to int, 400 on failure |
ParseUUID |
String to UUID, 400 on failure |
ParseBool |
Accepts true/false/1/0 |
DefaultValue(v) |
Substitutes v when the value is absent |
@Get("/")
async def list_tasks(
self, limit: int = Query("limit", pipes=[DefaultValue(20), ParseInt])
) -> list[str]: ...
Pipes or Pydantic?#
Use a Pydantic model for a request body — it validates the whole shape and reports every error at once. Use a pipe for a single scalar coming from the path, query or headers, where a model would be ceremony around one value.