NAME
Open::API::Plack - the PSGI app for a compiled OpenAPI 3.1 spec
SYNOPSIS
use Open::API::Plack;
my $plack = Open::API::Plack->new(spec => 'openapi.json'); # or api => $api
# configuration accumulates - register handlers from anywhere at boot
$plack->handlers(
listPets => 'MyApp::Pets::list', # resolved once, at to_app time
getPet => sub {
my ($params, $env) = @_;
my $pet = find_pet($params->{path}{petId}); # already typed
$pet || [ 404, ['Content-Type' => 'text/plain'], ['gone'] ];
},
);
$plack->before(sub { ... });
$plack->after(sub { ... }); # setters chain
my $app = $plack->to_app; # finalise into the PSGI coderef
# or everything up front, chained straight into to_app:
my $app = Open::API::Plack->new(
spec => 'openapi.json',
handlers => { listPets => 'MyApp::Pets::list' },
ui => 1,
)->to_app;
DESCRIPTION
The server side of Open::API.
Configuration is two-phase. new and the accessors only accumulate options - handlers, hooks and security checkers can be registered dynamically, from multiple modules, in any order. to_app then finalises: handler and hook names resolve, the spec's security coverage is checked, and the app closure is built. Anything wrong with the configuration croaks there, at startup.
It runs on any PSGI server. On Hyperman a handler may return a Future and the worker keeps serving other connections while it resolves.
CONSTRUCTOR
new
my $plack = Open::API::Plack->new(
api => $open_api, # or spec => (see Open::API/new)
handlers => { $operationId => sub { ... }, ... },
before => sub { ... }, # optional (see HOOKS)
after => sub { ... }, # optional (see HOOKS)
security => { $scheme => sub { ... }, ... }, # see SECURITY
csrf => { ... }, # optional (see CSRF)
headers => { ... }, # response headers (see RESPONSE HEADERS)
cors => { ... }, # optional (see CORS)
max_body_size => 1_048_576, # bytes; 413 over this
negotiate => 0, # 415 / 406 (see NEGOTIATION)
error_format => 'json', # or 'problem' (RFC 7807)
validate_responses => 0,
ui => 0, # docs UI (see UI)
);
Every option is optional here - each can also be supplied (or extended) later through the accessor of the same name. api must be an Open::API; spec is anything "new" in Open::API accepts and is compiled into api immediately, so a malformed document croaks at construction. One of the two must have been given by the time "to_app" is called. Everything else is stored as-is and validated by to_app.
ACCESSORS
Setters return the object, so calls chain. Called with no argument, each returns the stored value (undef when unset).
handlers
$plack->handlers(listPets => sub { ... }, getPet => 'MyApp::get');
$plack->handlers({ deletePet => sub { ... } }); # a hashref merges too
my $map = $plack->handlers; # the live hashref
Registers operation handlers. Multiple calls merge: each call adds or overwrites the named operationIds and keeps the rest, so different modules can register their own handlers independently before the app is finalised. The getter returns the live map (created empty on first use) - delete from it to unregister.
security
$plack->security(ApiKey => sub { ... });
my $checkers = $plack->security;
The scheme => checker map (see "SECURITY"). Same merge semantics as "handlers".
before
after
Get/set the request hooks (see "HOOKS"). Each takes a coderef or a fully qualified sub name; names resolve at to_app time.
csrf
cors
headers
Get/set the corresponding option hashref (see "CSRF", "CORS" and "RESPONSE HEADERS"). Setting replaces the whole hashref.
max_body_size
negotiate
error_format
validate_responses
Get/set the scalar options (see "to_app" and the sections below).
ui
$plack->ui(1);
$plack->ui({ path => '/documentation', title => 'My API' });
Get/set the docs UI option (see "UI"). Setting replaces the value.
api
Get/set the compiled Open::API. The setter croaks unless given an Open::API (or subclass) instance.
spec
$plack->spec('openapi.yaml');
Compile a spec - anything "new" in Open::API accepts - into "api" right away. The getter returns the compiled Open::API (the same object api returns).
THE PSGI APP
to_app
my $app = $plack->to_app;
Takes no arguments: finalise the stored configuration and return the PSGI coderef. Handler, hook and checker names given as strings ('MyApp::listPets') are resolved here, so a typo croaks at startup rather than surfacing per request; every security scheme the spec's operations require must have a checker (see "SECURITY"); the csrf, cors and header options are normalised. The object can be reconfigured and to_app called again - each call builds an independent app from the configuration at that moment.
The request path runs in C: route the method and path, validate and assemble every declared input, then call
$handlers->{$operationId}->(\%params, $env);
%params has path, query, header and cookie hashes (validated, percent-decoded, defaults applied; query parameters declared as arrays become arrayrefs) and body - the JSON-decoded, schema-validated request body (raw bytes for declared non-JSON content types).
The handler may return:
a PSGI triplet - passed through untouched;
any other value - JSON-encoded as a
200 application/json;an object with
on_ready(a Future). On a non-blocking server (psgi.nonblocking, e.g. Hyperman) it is handed to the server to await - the worker serves other connections meanwhile - and must resolve to a PSGI triplet. On blocking servers it is awaited inline.
Requests that never reach a handler:
400 validation failed - { errors => [ ... ] } (see Open::API/ERRORS)
404 no matching path
405 path exists, method does not - with an Allow header
406 Accept admits none of the responses (negotiate; see NEGOTIATION)
413 request body over max_body_size
415 request Content-Type is not declared (negotiate)
500 the handler died - { errors => [ { message => $@ } ] }
501 matched operation has no handler
Every response - handler successes and all of the above alike - is passed through response finalization: the RFC 7807 rewrite (when error_format is 'problem'), CORS headers for an allowed cross-origin request, and the security header set. See "RESPONSE HEADERS", "CORS" and "NEGOTIATION".
validate_responses => 1 additionally checks each response body against the operation's response schema for that status and turns a mismatch into a 500 carrying the validation errors marked in => 'response' - a development-mode tool, off by default. It applies to Future returns too (the check runs when the future resolves).
HOOKS
The optional before and after hooks are each a coderef or a fully qualified sub name, resolved at to_app time like handlers. The canonical use is auth:
my $plack = Open::API::Plack->new(spec => 'openapi.json');
$plack->before(sub {
my ($env, $operationId) = @_;
my $user = check_token($env->{HTTP_AUTHORIZATION})
or return [ 401, ['Content-Type' => 'application/json'],
['{"errors":[{"message":"unauthorized"}]}'] ];
$env->{'openapi.user'} = $user; # visible to the handler
return; # continue
});
$plack->after(sub {
my ($resp, $env, $operationId) = @_;
push @{ $resp->[1] }, 'X-Request-Id' => $env->{'openapi.rid'} // '-';
return;
});
before($env, $operationId)-
Runs after routing (so 404s and 405s never reach it - the operation is known) and before validation (an unauthorized caller costs no validation work and gets its 401 rather than a 400). Return a reference to short-circuit the request: a PSGI triplet is sent as-is, any other reference is JSON-encoded as a 200. Non-reference returns (including undef) continue to validation. A die becomes a 500. Stash anything the handler needs into
$env. after($response, $env, $operationId)-
Runs on every response for a matched operation - handler successes and the 400/500/501 error responses alike (404/405 have no operation, so no hook).
$responseis the final PSGI triplet: mutate it in place (add headers, log$response->[0]), or return a new triplet to replace it outright; any other return value is ignored. A die becomes a 500.When a handler returns a Future on a non-blocking server, the after hook (and response validation) are chained onto the future and run when it resolves - the worker is never blocked.
SECURITY
The spec's securitySchemes and security requirements are enforced for you: fill the "security" map with a checker per scheme name, and each matched operation's requirements are checked in C before validation or the before hook run. This is the declarative complement to a hand-written before hook - the spec already says which operations need which schemes, so you supply only the verify step.
$plack->security(
# scheme name (from components.securitySchemes) => checker
ApiKey => sub {
my ($credential, $env, $operationId, $scopes) = @_;
my $user = lookup_api_key($credential) or return 0;
return $user; # truthy = authorized; stashed
},
BearerAuth => sub {
my ($token) = @_;
verify_jwt($token); # truthy user object, or 0/undef
},
);
The checker receives the extracted $credential, the $env, the $operationId, and the requirement's $scopes arrayref (the values from the security entry, undef when none). What is extracted depends on the scheme type:
apiKey- the raw value of the named header, query parameter or cookie (in: header|query|cookie).httpbearer(andoauth2/openIdConnect, treated as bearer) - the token afterAuthorization: Bearer.httpbasic- the base64-decoded"user:pass"string.
Return a true value to authorize: it is collected under the scheme name in $env->{'openapi.auth'} (a hashref) and the request proceeds to the handler. Return false (0, undef, empty string) to reject. A checker that dies becomes a 500.
Requirements follow the OpenAPI semantics: an operation's security is a list of alternatives (OR), and the schemes within one alternative are all required together (AND). The first alternative whose schemes all pass wins. When none pass the request gets a 401; no before hook or handler runs. An operation-level security overrides the document-level default, and an explicit empty security: [] disables auth for that operation.
Only http basic, http bearer, apiKey, oauth2 and openIdConnect scheme types are supported; a spec that references any other type croaks at Open::API->new. Every scheme any matched operation requires must have a checker in the "security" map, or to_app croaks at startup - a missing checker is a configuration error, never a silent open door. Unsupported flows (the OAuth2 token exchange itself) are out of scope: you verify an already-issued token.
Open::API::Client is the mirror image - give it the credentials and it attaches them automatically. See "SECURITY" in Open::API::Client.
CSRF
The csrf option guards every state-changing method (anything other than GET, HEAD, OPTIONS and TRACE) in C, before validation, the security check or the before hook run. It combines two defenses:
$plack->csrf({
origins => [ 'https://app.example.com' ], # Origin allowlist
# a server-side token, verified against your own session/DB store:
header => 'X-CSRF-Token', # where the token arrives
cookie => 'csrf', # name used when rotating
check => sub {
my ($submitted, $env) = @_;
my $session = load_session($env);
my $stored = delete $session->{csrf} || ""; # atomic take-and-remove
return 0 unless defined $submitted && $submitted =~ m/^$stored$/;
my $fresh = random_token(); # your own unguessable value
$session->{csrf} = $fresh;
save_session($env, $session);
return $fresh; # framework sets the cookie
},
});
Origin / Referer check
Always on when csrf is given. On an unsafe method the request's Origin (or Referer, when Origin is absent) must be acceptable, or the request gets a 403. With an origins list the request origin's authority (host:port) must match one of the listed origins; without a list the default is same-origin - the origin authority must equal the Host header. A missing Origin and Referer on an unsafe method is rejected. This is stateless, needs nothing on the client, and is the whole defense for a modern SameSite cookie setup - set your session cookie SameSite=Lax or Strict and the Origin check is belt-and-braces.
RESPONSE HEADERS
A secure default header set is stamped onto every response - handler successes and the 4xx/5xx errors alike - suitable for a JSON API:
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
X-Frame-Options: DENY
Referrer-Policy: no-referrer
They are applied set-if-absent, so a handler (or the after hook) that sets one of these on a particular response keeps the last word. The headers option overrides the defaults, adds headers, or removes a default by mapping it to undef:
$plack->headers({
'Content-Security-Policy' => "default-src 'self'", # override
'Strict-Transport-Security' => 'max-age=63072000', # add
'Referrer-Policy' => undef, # drop a default
});
Header names match case-insensitively, so 'x-frame-options' => undef removes the default X-Frame-Options. Strict-Transport-Security (HSTS) is not a default - it only makes sense over HTTPS and can lock a domain out of plain HTTP, so it is opt-in. To ship nothing but what you list, map each default to undef (or just override the ones you keep).
CORS
For a browser API served to a different origin, cors answers preflight OPTIONS requests and adds Access-Control-* headers to actual responses:
$plack->cors({
origins => [ 'https://app.example.com' ], # or '*' (the default)
credentials => 0, # allow cookies/auth
headers => [ 'Content-Type', 'X-CSRF-Token' ], # preflight allow
expose => [ 'X-Request-Id' ], # readable by JS
max_age => 600, # cache the preflight
});
An Origin is allowed when origins is '*' or lists it exactly (scheme, host and port). For an allowed request Access-Control-Allow-Origin is set (the concrete origin, plus Vary: Origin, unless origins is the wildcard), along with Access-Control-Allow-Credentials when credentials is on and Access-Control-Expose-Headers from expose. A preflight (an OPTIONS carrying Access-Control-Request-Method for a path that has no OPTIONS operation) gets a 204 whose Access-Control-Allow-Methods is the path's declared methods, with Allow-Headers echoed from the request or taken from headers, and Max-Age from max_age. credentials => 1 with a wildcard origin is refused at to_app - the browser forbids it and it would be a serious hole, so you must name the origins.
NEGOTIATION
With negotiate => 1 the request path enforces the spec's media types:
415 when a request carries a body whose
Content-Typematches none of the operation's declaredrequestBodycontent types.406 when the request's
Acceptadmits none of the operation's declared response content types (a*/*, an absentAccept, or an operation that declares no response types always passes - the check is lenient).
It is off by default because it rejects requests the validator would otherwise accept (a non-JSON body passes through raw without it). Both checks run in C before validation.
ERROR FORMAT
By default an error is { errors => [ ... ] } as application/json (see "ERRORS" in Open::API). error_format => 'problem' instead emits RFC 7807 application/problem+json for every error this layer generates:
{ "type": "about:blank", "title": "Bad Request", "status": 400,
"detail": "...", "errors": [ ... ] }
title is the status reason phrase, detail the first error's message, and the original errors array is carried along. Only this layer's own JSON error envelope is rewritten; a handler that returns its own error body is left untouched.
UI
my $app = Open::API::Plack->new(spec => 'openapi.json', ui => 1)->to_app;
With ui set, to_app wraps the finished app so the documentation UI - a self-contained Swagger UI clone, see Open::API::UI - answers GET and HEAD on its own paths before the router runs. The defaults serve the page at /docs (plus its two assets underneath) and the spec JSON at /openapi.json; every other path, and every other method on those paths, behaves exactly as without the option. A truthy scalar takes the defaults; a hashref is passed through as "new" in Open::API::UI options (path, spec_path, title, try_it, headers):
$plack->ui({ path => '/documentation', spec_path => '/spec.json' });
The UI is built at to_app time, so problems surface at startup: a missing Template::Stencil (a recommends, not a prerequisite - the rest of the distribution never needs it) and a ui path the spec already declares both croak there.
Two configurations cooperate automatically. The UI's try-it-out script speaks this app's csrf dialect - the token header and rotation cookie names are read from "csrf", and with an origins allowlist the docs origin must be listed or try-it-out requests get 403s. And the UI's responses carry their own header set (a CSP admitting the page's own script and style); the API's stricter default headers are untouched. See Open::API::UI for the page itself, its limits, and the routes contract other frameworks can mount.
PERFORMANCE
The point of compiling the spec: on the bench in bench/openapi.pl (two Hyperman workers, wrk, 64 connections, this machine) a fully routed and validated operation serves 200,909 req/s against a bare hand-rolled PSGI ceiling of 202,013 req/s - the whole OpenAPI layer costs about half a percent.
SEE ALSO
Open::API, Open::API::Client, Open::API::UI, Plack, Hyperman.
AUTHOR
LNATION <email@lnation.org>
BUGS
Please report any bugs or feature requests to bug-open-api at rt.cpan.org, or through the web interface at https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Open-API. I will be notified, and then you'll automatically be notified of progress on your bug as I make changes.
SUPPORT
You can find documentation for this module with the perldoc command.
perldoc Open::API::Plack
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)