Transform and validate with pipes

Coerce or reject a parameter before the handler sees it.

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#

requestguardspipeshandlerinterceptorsresponserequestguardspipeshandlerinterceptorsresponse

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.