NAME
Punk::Validate - collecting request validation
SYNOPSIS
# imperative, in any handler
post '/books' => sub {
my ($c) = @_;
my $v = $c->validate({
type => 'object',
required => ['title'],
properties => {
title => { type => 'string', minLength => 1 },
year => { type => 'integer', minimum => 1900 },
},
});
if ($v->has_errors) {
$c->flash(errors => { map { $_->{name} => $_->{message} }
@{ $v->errors } });
return $c->redirect('/books/new');
}
my $params = $v->valid; # typed: year is an integer
...
};
# or declared on the route
get '/books' => 'Web::Book#list', {
validate => {
type => 'object',
properties => { page => { type => 'integer', minimum => 1 } },
},
};
post '/books' => 'Web::Book#create', {
validate => { schema => \%schema,
source => 'params',
on_invalid => 'Web::Book#form' },
};
# MyApp::Controller::Web::Book - create only runs on valid input,
# and a bare validate reads the Result the route guard stashed
sub create {
my ($c) = @_;
my $params = $c->validate->valid; # typed: year is an integer
my $book = $c->model('book')->create($params);
return $c->redirect("/books/$book->{id}");
}
# ...and form is the on_invalid target: re-render with the errors
sub form {
my ($c) = @_;
my $v = $c->validate;
return $c->render('book/form', {
errors => { map { $_->{name} => $_->{message} } @{ $v->errors } },
values => $v->valid,
}, status => 400);
}
DESCRIPTION
Validation that collects: invalid input never dies, it produces a "THE RESULT" the handler (or the generated route guard) turns into a 400 or a form re-render. This is the deliberate opposite of Punk::Model's field checks, which croak at write time because a handler reaching the model with bad data is a bug; here the bad data is the expected case. Only a malformed schema croaks - a boot-shaped error.
The whole tier is C (punk_validate.h), running on the JSON::Schema::Fast C ABI: schemas are JSON Schema, compiled once through the table with coercion on (a form's "1984" satisfies integer) - a plain hashref is compiled and cached by its canonical JSON, and a prebuilt JSON::Schema::Fast::Compiled passes straight through. The route guard is a C closure appended to the compiled route record at boot; per request nothing is Perl but the calls that fetch the data. Errors are in exactly Open::API's shape - the JSON::Schema::Fast fields (instanceLocation, keyword, schemaLocation, message) plus name - so an API answers one shape whether the route was validated by the OpenAPI mount or by validate.
THE CONTEXT METHOD
validate($schema, $data?)
Run a validation now. $data defaults per request: the decoded JSON body when the request's content type is JSON, the merged $c->params (captures, query, form) otherwise. Returns the Result, which is also stashed for the no-argument form below.
validate
With no arguments, the reader: the last Result this request produced - a route-level validate ran before the handler, so this is how the handler collects its outcome. Undef when nothing validated.
THE ROUTE OPTION
get '/books' => $target, { validate => \%schema };
post '/books' => $target, {
validate => { schema => \%schema, source => 'params',
on_invalid => 'Web::Book#form' },
};
The bare form is just a schema. The hashref-with-schema form adds source (params or json; default auto per request) and on_invalid (a coderef or 'Controller#method' resolved at boot) called with the context when validation fails - it sees the Result via a bare $c->validate, typically flashes the errors and redirects, or re-renders the form. Without on_invalid the failure answer is 400 { errors => [...] }, byte-shaped like the OpenAPI mount's.
The schema compiles once, at to_app; a malformed one croaks naming the route. The generated check runs after the route's scope guards, so an under auth guard that answers 401 still costs no body parse. On success the handler runs with the Result already stashed; valid hands it the typed params.
THE RESULT
A Punk::Validate::Result, built and read in C.
has_errors
errors
The error count, and the arrayref of error hashrefs described above.
error($name)
The first message for one field - what a form template wants beside an input. A root-level required failure is expanded to one error per missing property, so the message lands beside the right field.
valid / valid($name)
The typed, filtered parameters: only declared properties, numeric strings numified for integer/number, booleans canonical 1/0 - the shapes $c->openapi delivers on an API mount. With a precompiled schema there are no properties to filter by and the data comes back as-is.
TO_JSON
{ errors => [...] } - the Result JSON-encodes as the 400 body.
SEE ALSO
Punk, JSON::Schema::Fast, Open::API, Punk::Model.
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)