NAME

App::Actions::Schema - check that a document matches a small JSON schema

SYNOPSIS

Get the list of problems:

use App::Actions::Schema qw(validate);

my $schema = {
    type       => 'object',
    required   => ['name'],
    properties => { name => { type => 'string' }, count => { type => 'integer' } },
};

my @errors = validate({ name => 'x', count => 'many' }, $schema);
# ("$.count: expected integer, got 'many'")

Stop at the first problem:

use App::Actions::Schema qw(assert_valid);

assert_valid($document, $schema, 'the check --json document does not match its schema');

Show the problems in one line, and exit with the right status:

use App::Actions::Schema qw(validate describe_errors);

my @errors = validate($document, $schema);
if (@errors) {
    print STDERR describe_errors(\@errors), "\n";
    exit App::Actions::Schema::invalid_exit();    # 70
}

DESCRIPTION

Some commands print a document for other programs to read, for example action-run check --json. Each such document has a schema: a description of which keys it has and what type each value is. You can print the schemas with action-run schema.

This module checks a document against its schema. The commands use it on their own output before they print it. So if a programmer adds, removes or changes a key and forgets to change the schema, the command fails at once, and the broken document is never printed.

It understands only a small part of JSON Schema - the keywords the distribution's own schemas use:

type - one type name, or a list of them: null, boolean, integer, number, string, array or object.
const - the value must be exactly this.
enum - the value must be one of these.
properties and required - for an object: the schema of each key, and the keys that must be there. A key that is not listed in properties is an error.
items - for an array: the schema every element must match.
oneOf - the value must match at least one of these schemas.

Any other keyword is ignored.

FUNCTIONS

All functions can be imported by name; none is imported by default.

validate

my @errors = validate($value, $schema);

Compares a value with a schema and lists every difference.

ARGUMENTS

  • $value - the document, as Perl data (hashes, arrays, scalars, and JSON::PP booleans).

  • $schema - the schema, as a hash reference.

  • $path - optional. The name to use for the top of the document in messages. Default $.

RETURNS

A list of messages, one for each problem, in document order. Each message starts with the path to the problem, for example $.jobs[2].name: missing. An empty list means the value matches.

SIDE EFFECTS

None.

EXAMPLE

my @errors = validate([1, 'two'], { type => 'array', items => { type => 'integer' } });
# ("$[1]: expected integer, got 'two'")

API SPECIFICATION

INPUT

{
    value  => { type => 'any', optional => 1 },
    schema => { type => 'hashref' },
    path   => { type => 'string', optional => 1, default => '$' },
}

OUTPUT

{ type => 'array', element_type => 'string' }

MESSAGES

These are returned, not thrown.

+---------------------------------------------+-------------------------------+------------------------------+
| Message                                     | Meaning                       | What to do                   |
+---------------------------------------------+-------------------------------+------------------------------+
| PATH: expected TYPE, got VALUE              | wrong type                    | fix the value or the schema  |
| PATH: expected X, got Y                     | const did not match           | fix the value or the schema  |
| PATH: V is not one of A, B                  | enum did not match            | use one of the listed values |
| PATH.KEY: unexpected key                    | key not in properties         | add it to the schema, or     |
|                                             |                               | stop producing it            |
| PATH.KEY: missing                           | required key absent           | add the key                  |
| PATH: matches none of the alternative       | no oneOf branch matched; the  | read the first branch's      |
| shapes (first said: ...)                    | first branch's error is shown | error                        |
+---------------------------------------------+-------------------------------+------------------------------+

assert_valid

assert_valid($value, $schema, $what);

Like "validate", but throws an exception if there is any problem.

ARGUMENTS

  • $value - the document.

  • $schema - its schema.

  • $what - the start of the error sentence, for example the action-run check --json document does not match its schema.

RETURNS

1 if the value matches. Otherwise it does not return.

SIDE EFFECTS

Throws an App::Actions::Schema::Invalid exception if there is a problem. Its message is $what, a colon, the first problem, the number of other problems if there are any, and a newline. It has no file name or line number, because the problem is in a document, not in the code.

EXAMPLE

assert_valid({}, { type => 'object', required => ['a'] }, 'the test document is wrong');
# dies: "the test document is wrong: $.a: missing\n"

API SPECIFICATION

INPUT

{
    value  => { type => 'any', optional => 1 },
    schema => { type => 'hashref' },
    what   => { type => 'string', min => 1 },
}

OUTPUT

{ type => 'integer', memberof => [ 1 ] }

MESSAGES

+--------------------------------------------+-------------------------+--------------------------------+
| Exception                                  | Meaning                 | What to do                     |
+--------------------------------------------+-------------------------+--------------------------------+
| App::Actions::Schema::Invalid:             | the document does not   | a bug in this distribution:    |
| "WHAT: FIRST (and N more)\n"               | match its schema        | report it; exit status 70      |
+--------------------------------------------+-------------------------+--------------------------------+

describe_errors

my $text = describe_errors(\@errors);

Makes one short line from a list of problems.

ARGUMENTS

  • \@errors - a reference to the list "validate" returned.

RETURNS

The first problem, followed by (and N more) if there are others. The empty string if the list is empty.

SIDE EFFECTS

None.

EXAMPLE

print describe_errors(['$.a: missing', '$.b: missing', '$.c: missing']);
# $.a: missing (and 2 more)

API SPECIFICATION

INPUT

{ errors => { type => 'arrayref', element_type => 'string' } }

OUTPUT

{ type => 'string' }

MESSAGES

None.

invalid_exit

exit App::Actions::Schema::invalid_exit();

Returns the exit status a command uses when its own document fails the check.

ARGUMENTS

None.

RETURNS

70. This is EX_SOFTWARE from sysexits.h, which means "a bug in the program". It is not 2, because the commands use 2 for "fix your computer first", and a CI job should be able to tell the two apart.

SIDE EFFECTS

None.

EXAMPLE

exit invalid_exit() if @errors;

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'integer', memberof => [ 70 ] }

MESSAGES

None.

EXCEPTIONS

"assert_valid" throws an App::Actions::Schema::Invalid. It is an App::Actions::Error with the code schema.invalid and no location. See that module.

COMMON PITFALLS

  • Extra keys are errors. Unlike full JSON Schema, an object whose schema has properties may not have any other key. There is no additionalProperties keyword to allow them.

  • undef is null. An undef value matches only the type null. If a key may be missing or undef, give it the type ['string', 'null'], and leave it out of required.

  • Booleans must be JSON booleans. The type boolean matches only JSON::PP::true and JSON::PP::false, not 1 or 0. And 1 and 0 match integer, not boolean.

  • Numbers and strings are compared as text. For const and enum, 1 and '1' are the same, but 1.0 and 1 are not.

  • A string type matches numbers. Perl does not separate them, so 42 matches string.

  • Unknown keywords are ignored, not reported. A misspelt keyword (require for required) silently checks nothing.

  • In list context, call validate in list context. my $n = validate(...) gives the number of problems, which is what you want for a test; my ($first) = validate(...) gives the first.

ENCODING

Strings in the document and the schema can be any Perl character string, including non-ASCII and emoji. The check compares characters, so decode JSON text with JSON::PP (which returns characters) before you check it. Messages quote the values as they are, so encode them when you print.

AUTHOR

Nigel Horne, njh@nigelhorne.com

FORMAL SPECIFICATION

This section describes each routine in the Z notation, a precise mathematical language for describing programs. You do not need to read it to use the module: everything here is also said in plain words in the sections above.

How to read it: a box names a routine. The lines above the middle rule declare the values it works with. A name ending in ? is an input, and a name ending in ! is an output. The lines below the rule are always true when the routine returns. None of these routines has a state.

[VALUE, SCHEMA, PATH]
matches : VALUE × SCHEMA → 𝔹
problems : VALUE × SCHEMA × PATH → seq MESSAGE

matches(v, s) ⇔ problems(v, s, "$") = ⟨⟩

validate

┌─ Validate ────────────────────────────────────────
│ value?  : VALUE
│ schema? : SCHEMA
│ path?   : optional PATH
│ errors! : seq MESSAGE
├───────────────────────────────────────────────────
│ errors! = problems(value?, schema?, if path? = ⊥ then "$" else path?)
│ oneOf ∈ dom schema? ⇒
│     (∃ a ∈ ran schema?.oneOf • problems(value?, a, path?) = ⟨⟩)
│         ⇔ errors! = ⟨⟩
│ value? ∈ OBJECT ∧ properties ∈ dom schema? ⇒
│     ∀ k ∈ dom value? \ dom schema?.properties • "path?.k: unexpected key" in errors!
│     ∀ k ∈ ran schema?.required \ dom value? • "path?.k: missing" in errors!
│ value? ∈ ARRAY ∧ items ∈ dom schema? ⇒
│     ∀ i ∈ dom value? • problems(value?(i), schema?.items, path?[i]) ⊆ errors!
└───────────────────────────────────────────────────

assert_valid

┌─ AssertValid ─────────────────────────────────────
│ value?  : VALUE
│ schema? : SCHEMA
│ what?   : seq CHAR
│ r!      : ℕ
├───────────────────────────────────────────────────
│ problems(value?, schema?, "$") = ⟨⟩ ⇒ r! = 1
│ problems(value?, schema?, "$") = e ≠ ⟨⟩ ⇒
│     raises Invalid(what? ⁀ ": " ⁀ describe(e) ⁀ "\n")
└───────────────────────────────────────────────────

describe_errors

┌─ DescribeErrors ──────────────────────────────────
│ errors? : seq MESSAGE
│ text!   : seq CHAR
├───────────────────────────────────────────────────
│ errors? = ⟨⟩ ⇒ text! = ⟨⟩
│ #errors? = 1 ⇒ text! = head errors?
│ #errors? > 1 ⇒ text! = head errors? ⁀ " (and " ⁀ show(#errors? − 1) ⁀ " more)"
└───────────────────────────────────────────────────

invalid_exit

┌─ InvalidExit ─────────────────────────────────────
│ status! : ℕ
├───────────────────────────────────────────────────
│ status! = 70
└───────────────────────────────────────────────────

STATE DIAGRAM

The module keeps no state between calls. A document goes through these steps when a command checks it:

  build document
        |
        v
+---------------+  validate / assert_valid   +-----------+
|   UNCHECKED   |--------------------------->| CHECKING  |
+---------------+                            +-----------+
                                              |         |
                         no problems          |         | one or more problems
                      (validate: ();          |         | (validate: list of messages;
                       assert_valid: 1)       |         |  assert_valid: throws
                                              v         v  App::Actions::Schema::Invalid)
                                        +-------+   +---------+
                                        | VALID |   | INVALID |
                                        +-------+   +---------+
                                            |            |
                         command prints it  |            | describe_errors; command
                                            v            v exits with invalid_exit() = 70
                                        printed       not printed

LICENSE AND COPYRIGHT

Copyright 2026 Nigel Horne.

This program is released under the GNU General Public License, version 2. See the LICENSE file that comes with this distribution. If you use it, please let me know.