The screen document: what a host declares, and why one is refused.
A document is the contract between a host that authors content and any
runtime that shows it. This module is the two halves of admitting one.
admit/1 turns decoded JSON into the struct the rest of the package
reads, or says the input is not a document at all. validate/1 takes that
struct and returns every reason the document is wrong, all of them, so an
author fixing what they wrote learns everything in one pass.
Neither raises on anything a host can produce, and neither consults a context: a document is wrong or right on its own, before a visitor exists, which is what lets an editor answer the author while the author is still looking at it.
What admit/1 takes and what it builds
It takes the decoded JSON map - string keys throughout, as
Jason.decode!/1 gives - and builds a struct whose fields are atoms. The
two are different things: what the host writes is JSON, and what this
package reads is the struct. A screen is %{key: ..., title: ..., nodes: [...]} and a node is an atom-keyed map carrying :type, its :key
and :condition where the document declared them, and the fields its type
names. metadata is the one map that keeps its string keys: it is an open
map by declaration, and a host may put what it likes beside name,
description and domain.
The envelope's kind names the content kind the document belongs to, and
a document that omits it is a screen document: the admitted struct carries
"screens" for it, so a document authored before kinds existed is
admitted unchanged. A kind this package has no runtime for is carried as
it was written and refused by validate/1.
admit/1 answers nil for an input that is not a screen document - not
a map, or a map whose spine is not a document's: no list of screens, a
screen that is not an object, a screen with no list of nodes, a node that
is not an object, a variant whose candidates are not a list. That is the
whole of what it refuses. Everything else - a missing field, a key of the
wrong shape, a level of 9, a condition that does not parse - is a document
that exists and is wrong, and saying which is validate/1's job.
A field this version does not know is not carried onto the admitted node.
metadata is the declared place for what a host wants to keep beside the
vocabulary, and the admitted node map is atom-keyed by declaration, so
there is nowhere on a node for an unrecognized key to live. Whether such a
key should also be a finding is not decided by the record this module
implements, and this version does not raise one.
The findings validate/1 raises
One code per check, stable, and a host switches on the code rather than on the wording:
document.unknown_kind- the envelope names a content kind this package has no runtime for. The finding carries the value and no node key, because the envelope is the document's and not any one node's.document.unknown_type- the registry has no such type.document.duplicate_key- a key used twice anywhere in the document.document.invalid_key- a key missing, or not matching[a-z][a-z0-9_]*.document.missing_field- a field the node's type requires.document.level_out_of_range- a heading level outside 1 to 6.document.invalid_condition- a condition that does not parse.document.invalid_template- a template field holding a construct outside the template subset. The finding carries the construct the template refused, and the node's key.document.invalid_writes- a write that does not address a response, or whose value is not the constant form.document.unknown_format- a format name this package does not know.document.empty_variant- a variant with no candidates.document.unreachable_variant_candidate- an unconditional candidate that is not last, which buries every candidate after it.
Examples
iex> doc = Riddler.Screens.Document.admit(%{
...> "schema_version" => 1,
...> "id" => "edoc_signup",
...> "screens" => [%{"key" => "account", "title" => "Create your account", "nodes" => [
...> %{"type" => "heading", "key" => "account_heading", "level" => 1, "text" => "Create your account"}
...> ]}]
...> })
iex> {:ok, ^doc} = Riddler.Screens.Document.validate(doc)
iex> hd(hd(doc.screens).nodes).key
"account_heading"
iex> Riddler.Screens.Document.admit("not a document")
nil
Summary
Functions
Turns a decoded JSON document into the struct, or answers nil.
Returns {:ok, document} or every finding against it.
Types
@type screen() :: %{ key: term(), title: term(), nodes: [Riddler.Screens.Type.node_t()] }
An admitted screen: its key, its title, and its nodes in order.
@type t() :: %Riddler.Screens.Document{ id: term(), kind: term(), metadata: %{optional(String.t()) => term()}, schema_version: term(), screens: [screen()] }
An admitted document.
Functions
Turns a decoded JSON document into the struct, or answers nil.
Total: it never raises, whatever it is handed. nil means the input is not
a screen document. A document that is wrong rather than absent is
admitted here and refused by validate/1.
iex> Riddler.Screens.Document.admit(%{"screens" => []})
%Riddler.Screens.Document{schema_version: nil, kind: "screens", id: nil, metadata: %{}, screens: []}
iex> Riddler.Screens.Document.admit(%{"screens" => "three of them"})
nil
@spec validate(t()) :: {:ok, t()} | {:error, [Riddler.Finding.t()]}
Returns {:ok, document} or every finding against it.
The checks are the document's own - keys, conditions, templates, duplicates, and the fields each type requires - together with the checks each type raises about itself. Findings come back in document order, node by node, with the duplicate keys first because they are about the document rather than about any one node.
iex> doc = Riddler.Screens.Document.admit(%{"screens" => [
...> %{"key" => "account", "title" => "Create your account", "nodes" => [
...> %{"type" => "carousel", "key" => "pictures"}
...> ]}
...> ]})
iex> {:error, [finding]} = Riddler.Screens.Document.validate(doc)
iex> {finding.code, finding.node_key}
{"document.unknown_type", "pictures"}