PhoenixKit.API.Validator (phoenix_kit v2.63.0)

Copy Markdown View Source

A small JSON Schema validator for the input of API tools.

Core is installed in every PhoenixKit application, so it does not take a JSON Schema dependency for this. The validator implements exactly the subset the tools use. Any other validating keyword (const, exclusiveMinimum, multipleOf, uniqueItems, oneOf, anyOf, allOf, not, $ref, patternProperties, minProperties and so on) would be ignored by validate/2, so check_schema/2 refuses a schema that uses one; PhoenixKit.API.Tool runs it on every tool, so a schema mistake fails at registration, not as a 500 on one request.

Annotations constrain nothing and are accepted and ignored: description, title, default, examples and format, and also $schema, $id, $comment, example, deprecated, readOnly, writeOnly and any key that starts with a lowercase x-. format is not enforced: a tool that needs a shape checked declares a pattern.

KeywordApplies toError limit / allowed
type (one name or a list)any
enumanyallowed
required, properties, additionalPropertiesobjects
minimum, maximum (inclusive)numberslimit
minLength, maxLength (characters)stringslimit
pattern (unanchored, ECMA-262 style)stringslimit
minItems, maxItems, itemsarrayslimit

type is one of "object", "array", "string", "integer", "number", "boolean", "null". A schema with an unsupported type, or a pattern that is not a valid regular expression, is a bug in the tool and raises ArgumentError (validate/2 compiles every pattern of the schema once, up front). A pattern follows JSON Schema (ECMA-262), not PCRE defaults: $ does not match before a trailing newline, and \d, \w and [[:alpha:]] are ASCII only, while . still matches one code point. Booleans are valid sub-schemas (true accepts anything, false accepts nothing).

Input and schema

Input is what Jason decodes: maps with string keys, lists, strings, numbers, booleans and nil. A schema is a JSON-shaped map. Its keys may be strings or atoms (they are normalised to strings first), so %{type: "object"} works. Atom values of enum are compared as strings. A number member matches a number that is equal to it (1 and 1.0); a list or an object member matches strictly, so [1.0] does not match [1].

A float with no fractional part is accepted for "integer" and comes back as an integer (JSON has one number type, and 2.0 is an integer in JSON Schema). The validated value is otherwise the input unchanged. A tool that must not accept an absurdly large integer declares maximum (1.0e300 is an integral float).

Errors

Every problem is reported, not just the first. Each error names the offending value by path:

"name"              a property of the input
"items[2].quantity" a property of the third element of `items`
"[0]"               the first element when the input itself is an array
""                  the input as a whole

message is for people, limit carries the bound that was broken and allowed the permitted values, so a client can show them without parsing text.

At most 100 errors are returned. The input is untrusted and can be megabytes large (20 000 unknown keys against a closed schema, a list of 100 000 wrong elements). String lengths are counted without building a list, patterns are compiled once per call, and error lists are built by prepending (never errors ++ more per key). The walk stops as soon as it has collected 100 errors, and the answer is {:error, first_100}. A result of exactly 100 errors may therefore be incomplete. Error paths shorten a key longer than 64 bytes, so an error response is not as large as the body that caused it. A string that is not valid UTF-8 never matches a pattern.

Summary

Functions

Whether keyword only annotates a schema: check_schema/2 accepts it and validate/2 ignores it. True for the annotations listed in the moduledoc and for any key that starts with a lowercase x-.

Checks that a tool's schema only uses what validate/2 enforces.

Adds executor control fields (dry_run, confirm_token, idempotency_key) to a tool's input schema.

Validates input against schema.

Types

error()

@type error() :: %{
  path: String.t(),
  message: String.t(),
  limit: term() | nil,
  allowed: [term()] | nil
}

Functions

annotation?(keyword)

@spec annotation?(String.t()) :: boolean()

Whether keyword only annotates a schema: check_schema/2 accepts it and validate/2 ignores it. True for the annotations listed in the moduledoc and for any key that starts with a lowercase x-.

check_schema(schema, root \\ "schema")

@spec check_schema(term(), String.t()) :: :ok | {:error, [String.t()]}

Checks that a tool's schema only uses what validate/2 enforces.

Returns :ok or {:error, messages}. A message names the offending keyword ("input_schema.properties.sku.format is not supported ..."). It refuses a validating keyword validate/2 would silently ignore, an unsupported type, a bound that is not a number (a length or item count that is not a non-negative integer), a pattern that is not a valid regular expression, and a sub-schema that is neither an object nor a boolean. Annotations (description, title, default, examples, format ...) and x- extensions pass. root is the label used at the start of every message.

close_schema(schema, extra_properties)

@spec close_schema(map(), map()) :: map()

Adds executor control fields (dry_run, confirm_token, idempotency_key) to a tool's input schema.

extra_properties maps field names to their schemas. They are merged into the schema's properties, replacing a property of the same name (a module never declares them), and the schema is closed with additionalProperties: false, so every other unknown field is still refused. The result has string keys.

validate(schema, input)

@spec validate(map(), term()) :: {:ok, term()} | {:error, [error()]}

Validates input against schema.

Returns {:ok, value} (the input, with integral floats turned into integers where the schema says "integer") or {:error, errors} with the violations, at most 100 of them.