Riddler.Screens.Document (Riddler v0.3.1)

Copy Markdown View Source

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 a schema_version other 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 already document.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_key is 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's style is 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's validates is there and is not a boolean.
  • document.invalid_required - a question's required is 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 the pattern format declares a pattern that 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: a pattern on 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

Types

An admitted screen: its key, its title, and its nodes in order.

t()

An admitted document.

Functions

Turns a decoded JSON document into the struct, or answers nil.

Returns {:ok, document} or every finding against it.

Types

screen()

@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.

t()

@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

admit(raw)

@spec admit(term()) :: t() | nil

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

validate(document)

@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"}