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
metadata that is neither an object nor null, 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. It answers
nil too for a value of the wrong JSON type in a field the document schema
types: an id, a kind, a screen's key or title, or a node's key,
type or condition that is there and is not a string, null included; a
schema_version that is there and is not an integer; and a nodes on a
node whose type reads none that is not a list of nodes. A list of nodes
there is admitted and dropped, as any field the type does not name is. That
is the whole of what it refuses, and the document schema refuses each of
them too, so a value the schema calls a document is admitted. 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.invalid_schema_version- the envelope declares aschema_versionother than the one this package implements. No node key, for the same reason.document.unknown_type- the registry has no such type.document.duplicate_key- a key used twice anywhere in the document. Only the keys that are strings are compared. A key that is not a string is alreadydocument.invalid_key, and two nodes carrying the same non-string key are never reported as a duplicate: there is no key there to have been used twice, and saying there is would tell the author to rename one of them when what each of them needs is a key.document.invalid_key- a key missing, a key that is there and is not a string, or a key not matching[a-z][a-z0-9_]*. One code, three messages, because all three are the same mistake from the document's side: the node has no name anything else can use. The first two carry no node key -node_keyis how a host looks the node up, and neither an absent key nor a key of the wrong form is a name to look one up by - and the key the document did write is named in the message.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.invalid_style- a button'sstyleis there and is not a string. Which string it is stays the renderer's business; that it is a string is this package's.document.invalid_validates- a button'svalidatesis there and is not a boolean.document.invalid_required- a question'srequiredis there and is not a boolean.document.unknown_format- a format name this package does not know.document.invalid_pattern- a question that asks for thepatternformat declares apatternthat format cannot compile. Nothing a visitor could type would satisfy it, so it is the document that is wrong and not a response. Only where the question declares that format: apatternon a question asking for another format, or for none, is a field nothing consults and raises nothing.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: String.t() | nil, title: String.t() | nil, nodes: [Riddler.Screens.Type.node_t()] }
An admitted screen: its key, its title, and its nodes in order.
@type t() :: %Riddler.Screens.Document{ id: String.t() | nil, kind: String.t(), metadata: %{optional(String.t()) => term()}, schema_version: number() | nil, 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"}