NAME

Open::API - OpenAPI 3.1 server and client

VERSION

Version 0.01

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, .yaml or .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)
    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
500  the handler died   - { errors => [ { message => $@ } ] }
501  matched operation has no handler

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). $response is 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.

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. The validated client makes 41,358 sync calls/s where raw Fetch GETs of the same URL make 47,926.

Compiled objects hold everything they need: 500 full compile/validate/serve/destroy cycles grow RSS by ~64 kB (t/13-leaks.t).

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.h and 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:

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)