NAME

JSON::Schema::Fast - a fast JSON Schema (draft 2020-12) validator

VERSION

Version 0.04

SYNOPSIS

use JSON::Schema::Fast;

# compile a schema once (a hashref, a boolean, or JSON text)
my $v = JSON::Schema::Fast->compile({
    type       => 'object',
    required   => ['name'],
    properties => {
        name => { type => 'string', minLength => 1 },
        age  => { type => 'integer', minimum => 0 },
    },
});

# then validate live Perl data as often as you like
if ($v->is_valid($data)) { ... }                 # fast boolean

my ($ok, $errors) = $v->validate($data);         # collect all errors
for my $e (@$errors) {
    warn "$e->{instanceLocation}: $e->{keyword}\n";
}

DESCRIPTION

JSON::Schema::Fast compiles a JSON Schema once into a compact arena intermediate representation and validates live Perl data through a tight, threaded C interpreter. A compiled schema walks a packed IR with bitmask type checks and pre-hashed property lookups, and allocates nothing on the valid path.

CONSTRUCTOR

compile

my $v = JSON::Schema::Fast->compile($schema, %options);

Compiles $schema into a JSON::Schema::Fast::Compiled object. $schema may be:

  • a Perl hashref (the usual form),

  • a boolean schema - the JSON booleans true/false, 1/0, or an empty hashref {} (equivalent to true), or

  • a string of JSON text, which is decoded with File::Raw::JSON.

A schema with an unresolvable $ref, or that is malformed, throws an error.

resolver => sub { my $uri = shift; ... }

A coderef that returns the schema document (a hashref, or undef) for a URI. Called on demand to resolve remote or cross-document $ref/$dynamicRef and to load a custom $schema metaschema; the returned document is parsed into the same compiled object, and any references it introduces are resolved the same way.

Default: when you do not pass resolver, remote http/https documents are fetched through Fetch's C ABI (in XS - no per-request Perl) and decoded as JSON. Fetch and its ABI are resolved lazily, only the first time a remote reference is actually followed, so purely local schemas never touch the network. Because a schema is sometimes untrusted, note that this can issue outbound requests to URLs the schema names; pass resolver => undef to disable remote resolution entirely (same-document references only), or supply your own coderef to add a host allowlist, timeouts, or a different client.

coerce => 0 | 1 (default 0)

When a type check would fail because the value is a string, accept it if it can stand in for a permitted type: a numeric string satisfies number (and integer when integral), and "true"/"false" satisfy boolean. The numeric keywords (minimum, multipleOf, ...) then apply to the numeric value. Coercion never changes the caller's value. This is what lets OpenAPI string parameters validate against a typed schema.

apply_defaults => 0 | 1 (default 0)

Before validating an object, fill any missing property that declares a default into the callers data.

ERRORS

validate and errors return errors as an arrayref of plain hashes, each with the trimmed draft 2020-12 output fields:

instanceLocation

An RFC 6901 JSON Pointer into the data ("/items/3/age", or "" for the root). ~ and / in property names are escaped as ~0 and ~1.

keyword

The failing keyword (type, required, minimum, ...).

schemaLocation

A JSON Pointer into the schema ("/properties/age/minimum").

message

A short human-readable string.

All independent failures are collected, in a deterministic depth-first order. The boolean is_valid short-circuits on the first failure and does none of this work.

KEYWORD COVERAGE

The complete draft 2020-12 dialect:

  • Core / references: $ref, $id (base-URI scopes), $anchor, $defs, $dynamicRef/$dynamicAnchor, and remote / cross-document references (via resolver). URI resolution follows RFC 3986; fragments may be percent- and ~-escaped.

  • Type: type (string or array), enum, const.

  • Number: minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf.

  • String: minLength, maxLength (counted in codepoints), pattern (compiled once, cached), format (annotation).

  • Array: items, prefixItems, minItems, maxItems, uniqueItems, contains (with minContains/maxContains), unevaluatedItems.

  • Object: properties, patternProperties, additionalProperties, required, minProperties, maxProperties, propertyNames, dependentRequired, dependentSchemas, unevaluatedProperties.

  • Applicators: allOf, anyOf, oneOf, not, if/then/else.

  • Vocabulary: a custom $schema metaschema whose $vocabulary omits the validation vocabulary turns the validation keywords into annotations.

JSON Schema types follow the JSON value's type, not Perl's DWIM: a numeric string is a string unless coerce is set.

CONFORMANCE

Passes 100% of the mandatory JSON-Schema-Test-Suite for draft 2020-12 (the suite, including the remote documents and the 2020-12 meta-schemas, is vendored under t/ and run by t/70-conformance.t).

PERFORMANCE

A compiled schema is validated by walking a packed arena with no re-reading of the schema document and no allocation on the valid path (errors, pattern compilation and uniqueItems are the only things that allocate, and only when present).

C ABI

JSON::Schema::Fast exposes a small C ABI so that other XS modules can compile a schema once and validate against it entirely in C, with no Perl method dispatch per call. The motivating consumer is an OpenAPI layer: it compiles each parameter, header and body schema once at startup, then validates every request and response through the ABI on the hot path.

This is the same pattern JSON::Schema::Fast itself uses to consume Fetch's C ABI for remote references: a versioned table of function pointers, resolved at runtime, with no link-time coupling between the two distributions. Each builds and upgrades independently; a consumer vendors the header and checks the version at boot, so a mismatch means "fall back", never a crash.

This is an integration surface for XS authors, not part of the Perl API. Perl callers should use "compile" and the JSON::Schema::Fast::Compiled methods.

The table

The contract lives in include/jsf_abi.h (shipped in the distribution; a consumer vendors a copy at a pinned version):

#define JSF_ABI_VERSION 1

typedef struct jsf_abi {
    int abi_version;                 /* == JSF_ABI_VERSION */

    SV *(*compile)(pTHX_ SV *schema, SV *resolver,
                   int coerce, int apply_defaults);
    int (*is_valid)(pTHX_ SV *compiled, SV *data);
    int (*validate)(pTHX_ SV *compiled, SV *data, AV *errors);
} jsf_abi;

JSON::Schema::Fast::_abi_ptr

my $iv = JSON::Schema::Fast::_abi_ptr;

Returns the address of the process-wide jsf_abi table as an integer (an IV). A consumer calls this once at BOOT, INT2PTRs it to a const jsf_abi *, and checks ->abi_version == JSF_ABI_VERSION before using it. It is not intended to be called from Perl for any other purpose.

Functions

SV *compile(pTHX_ SV *schema, SV *resolver, int coerce, int apply_defaults)

Compile a schema into a reusable validator. schema is a Perl hashref, arrayref or boolean, or a JSON-text scalar (decoded here, as "compile" does). resolver is an optional coderef for remote $ref - pass NULL or an undef SV to get the default auto-fetch through Fetch's ABI, exactly like the Perl constructor. coerce and apply_defaults are the options documented under "compile".

Returns a blessed JSON::Schema::Fast::Compiled SV with a reference count of one, owned by the caller; it croaks on a malformed or unresolvable schema. Keep the handle and reuse it across many validations; SvREFCNT_dec it when done (the underlying compiled state is freed by the object's DESTROY).

int is_valid(pTHX_ SV *compiled, SV *data)

Fast boolean validation: returns 1 if data is valid against the compiled schema, 0 otherwise. compiled is a handle from compile. No errors are collected, so this is the cheapest path and allocates nothing on the valid path.

int validate(pTHX_ SV *compiled, SV *data, AV *errors)

Validate and collect errors: returns 1 (valid) or 0 (invalid), and when invalid pushes error hashrefs - the same structure documented under "ERRORS" - onto errors, an AV the caller owns (typically a mortal). Pass NULL for errors to skip collection, in which case this is exactly is_valid.

Example: an XS consumer

Vendor jsf_abi.h, resolve the table at boot, then compile once and validate per call:

#include "jsf_abi.h"   /* vendored from JSON::Schema::Fast */

static const jsf_abi *JSF = NULL;

MODULE = My::Fast::Consumer   PACKAGE = My::Fast::Consumer

BOOT:
{
    IV p = 0; dSP;
    eval_pv("require JSON::Schema::Fast;", FALSE);
    PUSHMARK(SP); PUTBACK;
    if (call_pv("JSON::Schema::Fast::_abi_ptr", G_SCALAR|G_EVAL) > 0) {
        SPAGAIN; p = POPi; PUTBACK;
    }
    if (p) {
        const jsf_abi *a = INT2PTR(const jsf_abi *, p);
        if (a && a->abi_version == JSF_ABI_VERSION) JSF = a;
    }
    /* JSF == NULL here means the installed JSON::Schema::Fast is too old
     * or absent; fall back to your Perl path. */
}

# compile $schema once (e.g. store the returned SV on your object), then:
#   int ok = JSF->is_valid(aTHX_ compiled_sv, data_sv);
# or with errors:
#   AV *errs = (AV *)sv_2mortal((SV *)newAV());
#   int ok = JSF->validate(aTHX_ compiled_sv, data_sv, errs);
# and SvREFCNT_dec(compiled_sv) when the validator is no longer needed.

The compiled SV is an ordinary refcounted Perl value: hold a reference for as long as you validate, and it cleans itself up when the last reference goes away.

SEE ALSO

JSON::Schema::Fast::Compiled (the compiled-object methods), File::Raw::JSON (JSON parsing), JSON::Schema::Modern.

AUTHOR

LNATION <email@lnation.org>

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