Wymcp.Tool.ValueGate (Wymcp v0.9.1)

View Source

The value gate: the last dispatch gate, which checks every value in a call's data against the action's schema. Where it sits in the chain, and what its refusal carries, is the rule stated in Wymcp.Tool, "Dispatch errors and self-correction"; this module builds what the gate validates against and reads data through it.

One root per action, built at registration

Each action's schema is compiled into a JSV.Root once, at the registration moment, by build_roots!/2: Wymcp.Router.init/1 stores the roots, and Wymcp.Methods.ToolsCall hands the called tool's to dispatch on the Wymcp.Context. A call validates against a built root and never compiles a schema. A tool called with no stored root — a direct run/2 call with a context Wymcp.Testing.build_context/1 built — has its action's root built on the spot by build_root!/3, so it meets the same gate and the same answer as a call over the wire.

The root is the action's :properties with the closure the unknown-key gate enforces (Wymcp.Tool.KeySet.close_properties/1), under an object type: the schema Wymcp.Help and the input_schema digest publish, so what is validated is what a caller reads. :required stays out of it — the required gate answers a missing property ahead of this one — and a root without it is one a :defaults map, which never carries a required key, validates against whole. A "required" inside a property's own schema — a nested object's, an array item's — is part of the value it constrains and stays in the root, so a bulk call one of whose items lacks such a key is refused whole, as a call with one wrong-typed item is.

format is asserted: a "format": "date" property refuses "tomorrow", and a format name JSV does not know refuses the schema at registration. An output root (Wymcp.Tool.build_output_schema_root/1) leaves format an annotation, because there the server checks its own data rather than a caller's.

A schema JSV cannot build — an unknown format, a term JSON cannot carry, such as a keyword list where a property schema belongs, and some keywords of the wrong type — raises Wymcp.ConfigurationError on Wymcp.Tool.actions/0, naming the tool, the action and what the build stopped on. So does a :defaults value its property schema refuses, or one JSV raises on while checking it, and that is what lets the gate read data as the caller sent it, before defaults: every value a default adds was proven here. The build checks no schema against the JSON Schema metaschema, so a keyword of the wrong type JSV builds anyway — "enum" => 5 — is met only when a value is checked against it, and "required" => "a" is not met at all.

The data run_action/3 receives is the data as this gate read it: JSV's cast, which turns a whole-number float sent where the schema says "integer" — 2.0 — into the integer the schema promises.

What a refusal says

check/2 reports every offending value in one answer: one entry per data path, spelled by Wymcp.Tool.KeySet.render_path/1 from JSV's JSON pointer — the data is walked to tell an array index from an object key that is all digits — carrying that path's problems deduplicated and sorted. The entries are bounded as the unknown-key gate's are: the first ten in sort order, chosen by Wymcp.Bound.first/1, and a count of them all. A path's problems are bounded the same way — the first ten, and an "and N more" text after them when there are more — since a schema can put many texts at one path, an anyOf whose branches each require a different property among them.

The problems are JSV's own texts at its cause level, read through Wymcp.JsonRpc, except where JSV's text quotes what the caller sent — a number has no length bound, and a 301-digit integer comes back whole; a key's length is the body's; and uniqueItems names every duplicate pair, so its text grows with the array. Those keywords get a text of wymcp's that names no sent value:

  • minimum, maximum, exclusiveMinimum, exclusiveMaximum and multipleOf name the bound alone ("value is higher than maximum 100"), and a number too large for multipleOf's division, which JSV reports as an arithmetic error, reads "value could not be checked against multipleOf";
  • uniqueItems reads "value must contain unique items";
  • format names the schema's format and drops JSV's parenthesised reason, which for uri-template quotes the sent string ("value does not respect the 'uri-template' format");
  • additionalProperties, propertyNames and patternProperties — reached where the unknown-key gate does not follow, such as an anyOf branch — say which keyword the object fails and name no key.

The data path already says which value it was. Every other keyword's text passes through, and every problem, rewritten or not, goes through Wymcp.Bound.echo/1, so a text no probe has met is cut at the bound rather than sent whole. So the answer quotes the request only through its data paths, under the rule Wymcp.Bound states in "What a refusal quotes".

What a refusal costs

The ten paths are chosen before the data is read: JSV's errors are grouped by JSON pointer, the pointers are ordered by their tokens alone, and only the ten Wymcp.Bound.first/1 keeps are walked against the data and given their problems. So the work past JSV is linear in the number of errors JSV reports, and a call with a hundred thousand wrong-typed array items costs what its ten listed paths do to read. The order reads an all-digit token as an index, which it is under an array; under an object an all-digit key is spelled as a key, but chosen among the ten as if it were an index. JSV's own work — collecting and normalizing every error — is linear in the data, which the body limit (Wymcp.Plugs.Pipeline) bounds.

Summary

Functions

Builds one action's root from its properties. Raises Wymcp.ConfigurationError on Wymcp.Tool.actions/0, naming the tool, the action and what JSV stopped on: where in the schema it failed and why, or, where JSV raises instead of answering, JSV's own message.

Builds the root of every action in actions — the map module's Wymcp.Tool.actions/0 returned, already through action-schema validation — and proves each action's :defaults against it. Returns the roots keyed by action.

Checks data against an action's root. Returns {:ok, cast_data} — data as JSV cast it, a whole-number float under "integer" an integer — or {:error, invalid, count}: count is the number of data paths holding an offending value, and invalid lists the first ten of them in sort order, each as %{at: data_path, problems: problems}.

One line naming every entry invalid lists — <data path>: <problems>, joined by ; — and how many more count holds beyond them, so the prose never claims a complete list it is not.

Functions

build_root!(module, action, properties)

Builds one action's root from its properties. Raises Wymcp.ConfigurationError on Wymcp.Tool.actions/0, naming the tool, the action and what JSV stopped on: where in the schema it failed and why, or, where JSV raises instead of answering, JSV's own message.

build_roots!(module, actions)

Builds the root of every action in actions — the map module's Wymcp.Tool.actions/0 returned, already through action-schema validation — and proves each action's :defaults against it. Returns the roots keyed by action.

Raises Wymcp.ConfigurationError on Wymcp.Tool.actions/0 for a schema build_root!/3 refuses, or for a default its property schema refuses, naming the tool, the action, and each offending default's data path with its problems.

check(data, root)

Checks data against an action's root. Returns {:ok, cast_data} — data as JSV cast it, a whole-number float under "integer" an integer — or {:error, invalid, count}: count is the number of data paths holding an offending value, and invalid lists the first ten of them in sort order, each as %{at: data_path, problems: problems}.

Examples

iex> properties = %{"limit" => %{"type" => "integer", "maximum" => 100}}
iex> root = Wymcp.Tool.ValueGate.build_root!(Tasks, :list, properties)
iex> Wymcp.Tool.ValueGate.check(%{"limit" => 50}, root)
{:ok, %{"limit" => 50}}
iex> Wymcp.Tool.ValueGate.check(%{"limit" => 50.0}, root)
{:ok, %{"limit" => 50}}
iex> Wymcp.Tool.ValueGate.check(%{"limit" => 500}, root)
{:error, [%{at: "limit", problems: ["value is higher than maximum 100"]}], 1}

summarize(invalid, count)

One line naming every entry invalid lists — <data path>: <problems>, joined by ; — and how many more count holds beyond them, so the prose never claims a complete list it is not.

Examples

iex> invalid = [%{at: "limit", problems: ["value is higher than maximum 100"]}]
iex> Wymcp.Tool.ValueGate.summarize(invalid, 3)
"limit: value is higher than maximum 100 and 2 more"