Skip to content
GitHub

Typed wires

A message with the wrong shape is the failure I least want on a live line. A temperature that arrives as the string "hot", a missing id, a number where a list was supposed to be: nothing crashes, the flow keeps going, and something downstream quietly does the wrong thing. Flow takes JSON Schema to that problem in three steps.

Give the json node a schema in its settings and every value it handles is checked against it. A value that matches goes on. A value that doesn’t goes to a Catch node, doesn’t continue down the wire, and the error names every bad field:

does not match the schema: payload.temp: got string, want number; payload.id: missing property "id"

msg.schemaError carries the same thing as a list of {path, keyword, message} objects, for a Catch node that wants to route on it. Paths read the way a Change node writes them: payload.sensors[2].temp.

If the node has no schema in its settings and a message carries msg.schema, the node validates against that instead, the way Node-RED’s json node does. A message that passes has msg.schema removed. One that fails keeps it. The schema in the settings wins when both are there.

  • The schema is JSON Schema draft 2020-12 unless it names another draft with $schema. Drafts 4, 6, 7 and 2019-09 are accepted.
  • An invalid schema in the settings fails the deploy with the validator’s reason. An invalid msg.schema is an error on that message.
  • A schema is self-contained. A $ref to another URL or a file is refused, not fetched, so an edge box with no route out behaves like any other.
  • format is checked for the formats the validator knows and ignored for the rest, the same as most validators.
  • A flow with no schema anywhere behaves exactly as it did before.

The validator is internal/schema, a thin wrapper over santhosh-tekuri/jsonschema v6 (permissively licensed, pure Go). Anything else in Flow that needs to check a value against a schema uses the same package.

Any node can declare what it accepts and what each output port promises. Two optional keys on the node, both in the flow file, both ignored by every other tool that reads one, so a flow that carries them still round-trips byte for byte:

{"id":"sensor","type":"function","outputs":2,"wires":[["limit"],[]],
"ew_outSchemas":[{"type":"object","properties":{"payload":{"type":"object",
"required":["temp"],"properties":{"temp":{"type":"number"}}}}}, null]},
{"id":"limit","type":"switch","wires":[[],[]],
"ew_inSchema":{"type":"object","required":["topic"]}}

ew_inSchema is what the node takes. ew_outSchemas is a list with one schema or null for each output port. A schema describes the whole message, so the payload is properties.payload. Keys that start with an underscore, _msgid and the runtime’s other bookkeeping, are left out of what it sees, so a closed schema doesn’t have to name them. They’re JSON Schema like the json node’s, and the same package checks them.

A mismatch the editor can see is refused at deploy. When both ends of a wire declare a schema and no message could satisfy both, the deploy is refused before a byte is written, the same as the test gate: a 422, nothing restarted, nothing saved, and a record in the audit trail as deploy.refused with the reason typed wires.

the deploy was refused because of 1 typed wire problem, and the flows already running are untouched; junction "sensor" (sensor) output 1 -> "limit" (limit): payload.temp: the sender sends string and the receiver takes number

It only refuses what it can prove. Different types on the same field, a value outside the other end’s enum or range, a field the receiver requires that the sender’s closed schema rules out, a field the sender always sends that the receiver’s closed schema refuses. A sender that just doesn’t mention a field the receiver requires is not refused, because the message might well carry it, and a schema that uses $ref, allOf, anyOf, oneOf, not or if isn’t second-guessed in that part. A false refusal stops a deploy for nothing, and anything it can’t prove is still caught at run time. A schema that doesn’t compile, or an ew_outSchemas longer than the node has outputs, is refused the same way. Rollbacks aren’t checked, for the reason they aren’t gated by tests.

An Inject node’s literals count as a type. An Inject has no schema of its own, but what it sets for certain is as good as one. When an Inject is wired straight to a node with an ew_inSchema, the literal payload, and any other literal property it sets, is checked against that schema at deploy, and a literal the receiver can never take is refused the same way, with the same 422 and the same deploy.refused in the audit trail:

inject "reading" (i1) output 1 -> "limit" (l1): payload: the inject sets "hot", which the receiver does not take: got string, want number

Only a literal is checked: a string, a number, a boolean or JSON. A timestamp, an environment variable, flow or global context, another message property, JSONata, a buffer or a regular expression isn’t known until the node fires, so it is skipped, never guessed. A required property the Inject doesn’t set isn’t a clash either, because a value that isn’t static may set it. Only the wire from the Inject to the node is judged: a literal that goes through another node first is left to the run-time check. The editor draws the same clash on that wire.

One it can’t see goes to a Catch node and to a metric. A message arriving at a node that breaks its ew_inSchema never reaches the node. A message leaving a port that breaks its schema is stopped at the sender and never delivered. Either way it’s raised as an error from the node that held the schema, and the Catch node gets it with the path of every bad field in msg.error.message and as a list in msg.error.violations, {path, keyword, message} each:

the message does not match the schema of output 1: payload.temp: got string, want number

hotloop_flow_node_schema_mismatches_total counts them per node, and counts the json node’s mismatches too. A node with no schemas costs one nil check, and a flow with none behaves exactly as it did before.

A wire with a type at either end carries it. The editor writes the type on the wire, at the middle of the curve: the sender’s, as a few characters (payload {temp: number}), and the whole story when you hover the wire.

  • Both ends declare a type and they agree: green outline, the sender’s type.
  • Only one end declares one: dashed and muted, so you can see where a wire is still untyped.
  • Both declare one and they can never agree: the wire and its label go red and say which field, ≠ payload.temp, and the tooltip has the runtime’s reason. The deploy would refuse it, and now you can see it before you press Deploy.

The red isn’t the editor guessing. It asks the runtime, POST /flows/types, which runs the same check the deploy refuses with, so the canvas and the deploy can’t disagree. The editor only asks when something on the canvas has a schema, a little after the last edit. A flow with no schemas makes no call and draws nothing new.

Set the types in a node’s dialog, under Types: an input schema, and one for each output port. Empty means no type, and a node you never typed doesn’t grow the keys. Writing something that isn’t JSON, or isn’t an object, is refused in the dialog.