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, .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.

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.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.

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 - equals OA_ABI_VERSION in 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". method and path are given with explicit lengths, and path is 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 fills caps with the raw captured path segments (name => borrowed value SV). On a 405 - the path exists but not for this method - it returns NULL and fills allow with the upper-case method names, an AV of SVs. On a 404 it returns NULL and leaves allow empty. caps and allow are 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". raw is 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 typed with 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 - into errors and returns 0. Both containers are caller-owned and should be empty on entry; either may be NULL to discard it, and a NULL typed validates 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:

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)