NAME
Open::API - OpenAPI 3.1 server and client
VERSION
Version 0.05
SYNOPSIS
use Open::API;
# a spec: hashref, JSON text, YAML text, or a filename
my $api = Open::API->new(spec => 'openapi.json');
# the compiled spec drives a PSGI app (see Open::API::Plack) ...
my $app = Open::API::Plack->new(
api => $api,
handlers => {
listPets => 'MyApp::Pets::list',
getPet => sub { ... },
},
)->to_app;
# ... and an HTTP 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.
The compiled object is the shared core of two consumers: Open::API::Plack serves it as a PSGI app (routing, validation, security, CSRF, CORS - all in C before a handler runs), and Open::API::Client is a spec-driven HTTP client on Fetch's C ABI, so one document defines both sides of the wire. The "match" and "validate_request" methods below expose the router and validator directly for any other framework adapter.
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.
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'.
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.
It is also a provider of one: include/oa_abi.h exposes the router and validator to another XS module's own C dispatcher, so an operation is routed and validated with no Perl frame between the socket and the controller. See "C ABI" below.
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 on the consumer side, t/25-abi.t for the provider table).
C ABI
include/oa_abi.h is the C form of "match" and "validate_request": a versioned function-pointer table resolved at runtime, in the DBI style used across the Semantic stack. There is no link-time symbol coupling, so provider and consumer build and upgrade independently. Punk is the first consumer - its C dispatcher routes, validates and hands the typed parameters to a controller without unwinding into Perl.
Getting the header
Open::API installs oa_abi.h through ExtUtils::Depends, so a consumer's Makefile.PL picks up the include path with no vendoring:
use ExtUtils::Depends;
my $pkg = ExtUtils::Depends->new('My::Consumer', 'Open::API');
WriteMakefile(
...
$pkg->get_makefile_vars, # INC reaches oa_abi.h
PREREQ_PM => { 'Open::API' => '0.04' },
);
The alternative is to vendor a copy of the header pinned at a known OA_ABI_VERSION. Either way the header only declares the table; nothing is linked.
Perl's own headers (EXTERN.h, perl.h, XSUB.h) must be included before oa_abi.h, which needs SV, AV, HV, STRLEN and pTHX.
Resolving the table
Open::API::_abi_ptr returns the address of the process-wide table as an IV. It is not a Perl-level API - it is the ABI entry point, and the only supported thing to do with it is INT2PTR it back to a const oa_abi * and check abi_version before the first call:
#include "oa_abi.h"
static const oa_abi *OA = NULL;
static const oa_abi *my_oa(pTHX) {
if (!OA) {
IV p = 0;
dSP;
eval_pv("require Open::API;", FALSE);
SPAGAIN; /* the require may have moved the stack */
if (!SvTRUE(ERRSV)) {
ENTER; SAVETMPS; PUSHMARK(SP); PUTBACK;
if (call_pv("Open::API::_abi_ptr", G_SCALAR | G_EVAL) > 0) {
SPAGAIN;
p = POPi;
}
PUTBACK; FREETMPS; LEAVE;
}
if (p) {
const oa_abi *a = INT2PTR(const oa_abi *, p);
if (a && a->abi_version == OA_ABI_VERSION) OA = a;
}
}
return OA; /* NULL means: use the Perl-visible methods instead */
}
The address is static for the life of the process, so resolve it once at boot and cache it. The IV may legitimately be negative on platforms that map shared objects high in the address space; INT2PTR round-trips the bits either way, so test for non-zero, not for positive.
A NULL result means Open::API is absent, too old, or built without the table. That is not an error in itself - the documented answer is to fall back to "match" and "validate_request", which do the same work with Perl frames. A consumer that hard-requires the ABI should croak at boot, not per request.
The table
typedef struct oa_abi {
int abi_version;
void *(*api_of) (pTHX_ SV *api_sv);
void *(*route) (pTHX_ void *api, const char *method, STRLEN mlen,
const char *path, STRLEN plen, HV *caps, AV *allow);
void *(*op_by_id) (pTHX_ void *api, SV *op_id);
int (*validate) (pTHX_ void *api, void *op, HV *raw, HV *typed,
AV *errors);
SV *(*op_id) (pTHX_ void *op);
} oa_abi;
abi_version- equalsOA_ABI_VERSIONin the header the provider was built with. Compare it before calling anything else.api_of- the opaque API handle behind a blessed Open::API SV (what "new" returns). Returns NULL when the SV is not a reference; it never croaks, so it is safe on the fall-back path. The handle lives as long as the SV does and is not freed by the caller.route- the C form of "match".methodandpathare given with explicit lengths, andpathis the request path only, already prefix-stripped by the caller (a mount strips its own prefix). On a match it returns the opaque operation handle and fillscapswith the raw captured path segments (name => borrowed value SV). On a 405 - the path exists but not for this method - it returns NULL and fillsallowwith the upper-case method names, an AV of SVs. On a 404 it returns NULL and leavesallowempty.capsandalloware caller-owned and must be empty on entry; either may be NULL to discard that output.op_by_id- the opaque operation handle for an operationId, for a consumer that dispatches by name rather than by path. NULL when the id is unknown.validate- the C form of "validate_request".rawis the same hash that method documents:{ path => \%captures, # e.g. the caps from route() query => $query_string | \%hash, header => \%lowercased_headers, # cookies ride the "cookie" header body => $raw_bytes | $decoded_ref }On success it fills
typedwith the validated, coerced, percent-decoded parameters (the path/query/header/cookie hashes and the body, exactly the shape "validate_request" returns) and returns 1. On failure it pushes error hashrefs - the "ERRORS" shape - intoerrorsand returns 0. Both containers are caller-owned and should be empty on entry; either may be NULL to discard it, and a NULLtypedvalidates without materialising the parameters.op_id- the operationId SV of a routed operation. It is borrowed: do not free it, and copy it if it must outlive the API object.
Every call takes pTHX_ and must be made on the interpreter that owns the API SV. The table itself is const and shared; the handles it hands back are owned by the Open::API object, so keep that object alive for as long as the dispatcher uses them.
Versioning
The table only ever grows at the end. OA_ABI_VERSION bumps on any append, and a consumer requires abi_version to equal the version it was written against, or to be greater than it if it treats a later table as a superset and uses only the prefix it knows. Fields are never reordered, retyped or removed within a major line, so a consumer built against version N keeps working against any provider that still reports N.
The provider table is exercised end to end by t/25-abi.t, which drives api_of, route, op_id and validate through the pointers and asserts the results match the Perl-visible "match" and "validate_request" - the ABI is the same behaviour with the Perl frames removed.
SEE ALSO
Open::API::Plack, Open::API::Client, JSON::Schema::Fast, Fetch, Hyperman, Punk.
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)