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,arrayorobject.const- the value must be exactly this.enum- the value must be one of these.propertiesandrequired- for an object: the schema of each key, and the keys that must be there. A key that is not listed inpropertiesis 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 examplethe 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
propertiesmay not have any other key. There is noadditionalPropertieskeyword to allow them.undef is
null. Anundefvalue matches only the typenull. If a key may be missing orundef, give it the type['string', 'null'], and leave it out ofrequired.Booleans must be JSON booleans. The type
booleanmatches onlyJSON::PP::trueandJSON::PP::false, not 1 or 0. And 1 and 0 matchinteger, notboolean.Numbers and strings are compared as text. For
constandenum,1and'1'are the same, but1.0and1are not.A string type matches numbers. Perl does not separate them, so
42matchesstring.Unknown keywords are ignored, not reported. A misspelt keyword (
requireforrequired) 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.