NAME
Open::API - OpenAPI 3.1 server and client
VERSION
Version 0.02
SYNOPSIS
use Open::API;
# a spec: hashref, JSON text, YAML text, or a filename
my $api = Open::API->new(spec => 'openapi.json');
# a PSGI app: operationId => handler (a coderef, or a fully qualified
# sub name); requests are routed and validated in C before a handler runs
my $app = $api->to_app(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'] ];
},
});
# the same spec drives a client (see Open::API::Client)
my $client = Open::API::Client->new(
api => $api,
base_url => 'http://127.0.0.1:5000'
);
my $res = $client->getPet(petId => 42)->get;
DESCRIPTION
Open::API loads an OpenAPI 3.1 document once and compiles every parameter, header, cookie and body schema through JSON::Schema::Fast at startup. Each request is then routed and validated on a C hot path.
It runs on any PSGI server. On Hyperman a handler may return a Future and the worker keeps serving other connections while it resolves. The same compiled object also drives Open::API::Client, a spec-driven HTTP client on Fetch's C ABI, so one document defines both sides of the wire.
OpenAPI 3.1 only: 3.1 schemas are native JSON Schema 2020-12, which is what JSON::Schema::Fast validates. A document with any other openapi version croaks at load.
CONSTRUCTOR
new
my $api = Open::API->new(spec => $spec);
spec is required and may be:
a hashref (an already-decoded document),
a string of JSON text,
a string of YAML text (decoded with YAML::XS, loaded lazily - only YAML specs need it), or
a filename (
.json,.yamlor.yml; anything else is sniffed by content).
Compilation walks paths once: every operation needs a unique operationId (it is the dispatch key), path templates are pre-split, path-item and operation parameters are merged (operation wins), and every schema is compiled through the JSF ABI - parameters with coercion enabled (string sources satisfy typed schemas, exactly what OpenAPI parameters need) and default-filling on. $refs to #/components/schemas/... are resolved at compile time. A malformed document, a missing or duplicate operationId, or an unresolvable reference croaks here, at startup.
THE PSGI APP
to_app
my $app = $api->to_app(
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,
);
Returns a PSGI coderef whose request path runs in C: route the method and path, validate and assemble every declared input, then call
$handlers->{$operationId}->(\%params, $env);
A handler is a coderef, or a fully qualified sub name as a string ('MyApp::listPets') - names are resolved once, at to_app time, so a typo croaks at startup rather than surfacing per request:
my $app = $api->to_app(handlers => {
listPets => 'MyApp::Pets::list',
getPet => \&MyApp::Pets::get,
});
%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 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
to_app takes optional before and after hooks - each a coderef or a fully qualified sub name, resolved at to_app time like handlers. The canonical use is auth:
my $app = $api->to_app(
handlers => \%handlers,
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
},
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: give to_app a security map of scheme name to a checker, 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.
my $app = $api->to_app(
handlers => \%handlers,
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 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
to_app takes a csrf option that 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:
my $app = $api->to_app(
handlers => \%handlers,
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 $submited && $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:
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:
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"). 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.
METHODS
spec
The decoded OpenAPI document.
operations
my $ops = $api->operations; # [ { operationId, method, path }, ... ]
operation
my $info = $api->operation('getPet');
A description of one operation: params by location (name + required), body (required flag + content types) and responses (statuses with compiled schemas). undef for an unknown id.
match
my ($opId, $captures) = $api->match($method => $path);
The router alone: ($operationId, \%raw_path_captures) on a match; an empty list for a 404; (undef, \@allow) when the path exists but the method does not (a 405 and its Allow list). For framework adapters.
validate_request
my ($ok, $result) = $api->validate_request($opId, {
path => \%raw_captures,
query => $query_string_or_hashref,
header => \%lowercased_headers,
body => $raw_body_or_decoded_ref,
});
The validator alone: (1, \%params) or (0, \@errors). Header names must be lowercased by the caller; cookies are parsed from the cookie header when the operation declares cookie parameters. For framework adapters - together with "match" this is the complete integration surface, everything heavy stays in C.
ERRORS
Validation errors are hashrefs: the JSON::Schema::Fast output fields (instanceLocation, keyword, schemaLocation, message) augmented with in (path / query / header / cookie / body / response) and name (the parameter name). Missing required inputs use keyword => 'required'; an undecodable JSON body uses keyword => 'json'.
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..
ARCHITECTURE
Open::API is a consumer of two runtime-resolved C ABIs, the same DBI-style versioned function-pointer tables used across the Semantic stack:
JSON::Schema::Fast (required) - schemas are compiled once through
jsf_abi.hand every validation on the request path is a direct C call. Resolved and version-checked at load; absence is a hard error.Fetch (optional) - Open::API::Client fires requests through
fetch_abi.h. Resolved lazily on first client construction; the server side never loads it.
There is no link-time coupling: each distribution builds and upgrades independently, and a version skew fails cleanly at boot (t/12-abi-guard.t).
SEE ALSO
Open::API::Client, JSON::Schema::Fast, Fetch, 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
You can also look for information at:
RT: CPAN's request tracker (report bugs here)
Search CPAN
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)