Dextrin.Registry (Dextrin v0.1.0)

Copy Markdown View Source

Extension point for both struct and custom-tag decoding/encoding. No entry for a given name → decode falls back to an opaque Dextrin.Struct/Dextrin.CustomTag — never a hard failure, since neither type requires a registration to be representable at all.

Struct entries only ever come from a compiled .dxns schema (Dextrin.Schema.compile/3) plus an optional materializer layered on top (put_struct_materializer/3) — there is no put_struct/3 that hand-writes a struct's shape directly, because struct is schema-dependent by design: the schema is what supplies field names/order/types, a materializer only decides what nicer decoded shape to produce from an already-schema-validated field map.

Every put_*/fetch_* pair here is a plain, immutable map update — Registry.t() is ordinary data threaded explicitly through Dextrin.decode/2, Dextrin.Schema.compile/3, etc.; nothing about it is a process, an ETS table, or otherwise global/mutable state.

Summary

Functions

The reverse of fetch_struct_module/2: given an application struct's own module, finds the schema name it was registered under (if any) — what lets automatic encode-time validation (Dextrin.Schema.validate_encode_tree/2) recognize a plain Elixir struct as "this is a Foo" without being told so explicitly, the same way a Dextrin.Struct's own name already identifies it.

Looks up a compiled schema by struct name, consulting the lazy resolver (if any) on a miss and caching the result. Whether schemas are all compiled up front (Dextrin.Schema.compile/3 once, at startup) or resolved on demand (a resolver reading a file, calling a schema service, whatever the caller wants) is entirely the caller's choice — this function is what makes both paths look identical to the rest of Dextrin, which never needs to know which one is in play.

Declares which Elixir module a schema's data is expected to be — used only for {:reference, name} checks during encode-time validation (Dextrin.Schema.validate_encode/3). Decode-time reference checks don't need this: they carry an internal provenance marker regardless of materializer shape (Dextrin.Schema.Validated). Encode-time has no wire data to carry that marker before anything's even been encoded — __struct__ is the one signal actually available pre-encode, and this is what a reference check compares it against. Independent of put_struct_materializer/3: you can declare a module here even if you use the default plain-map materialization for something else.

The reverse of put_tag/3: registers how to turn an application struct (keyed by its module) back into @name value on encode. put_tag/3 alone lets you decode @my-app/money 100 into %MyApp.Money{}, but never re-encode it, since dispatch on the way out has to happen by Elixir module, not by wire-format tag name — the two directions need separate lookup tables, not one shared one.

Marks (or unmarks) this registry as decoding a trusted source — the default — vs. an untrusted one. Currently the one thing this affects is keyword: trusted (the default) decodes it as a real Elixir atom (DXN.md §1.3's own type table: keyword's Elixir type is "Elixir atom"); untrusted decodes it as Dextrin.Keyword.t() instead, so a String.to_atom/1 call is never reachable from attacker-controlled text (which could otherwise exhaust the atom table). Pass trusted: false to Dextrin.decode/2/decode_binary/2 — or put_trusted(registry, false) on a reused registry — for any source you don't fully control; the default assumes you do.

Registers a named type_expr — a reusable name for a combination of the fixed type_expr vocabulary (Dextrin.Schema.TypeExpr), defined entirely in .dxns data, never Elixir code. Dextrin.Schema.compile/3 populates this automatically from a document's non-%schema{} entries; put_type_alias/3 itself is only needed to seed a base registry by hand (e.g. sharing one vocabulary across several compile/3 calls without redeclaring it each time).

Types

struct_materializer()

@type struct_materializer() :: (%{required(atom()) => term()} ->
                            {:ok, term()} | {:error, term()})

struct_resolver()

@type struct_resolver() :: (String.t() ->
                        {:ok, Dextrin.Schema.Compiled.t()} | :unknown)

t()

@type t() :: %Dextrin.Registry{
  materializers: %{optional(String.t()) => struct_materializer()},
  resolver: struct_resolver() | nil,
  struct_modules: %{optional(String.t()) => module()},
  structs: %{optional(String.t()) => Dextrin.Schema.Compiled.t()},
  tag_encoders: %{optional(module()) => {String.t(), tag_encoder()}},
  tags: %{optional(String.t()) => tag_decoder()},
  trusted: boolean(),
  type_aliases: %{optional(String.t()) => Dextrin.Schema.TypeExpr.t()}
}

tag_decoder()

@type tag_decoder() :: (Dextrin.Value.t() -> {:ok, term()} | {:error, term()})

tag_encoder()

@type tag_encoder() :: (struct() -> {:ok, Dextrin.Value.t()} | {:error, term()})

Functions

fetch_materializer(registry, name)

@spec fetch_materializer(t(), String.t()) :: {:ok, struct_materializer()} | :error

fetch_schema_name_for_module(registry, module)

@spec fetch_schema_name_for_module(t(), module()) :: {:ok, String.t()} | :error

The reverse of fetch_struct_module/2: given an application struct's own module, finds the schema name it was registered under (if any) — what lets automatic encode-time validation (Dextrin.Schema.validate_encode_tree/2) recognize a plain Elixir struct as "this is a Foo" without being told so explicitly, the same way a Dextrin.Struct's own name already identifies it.

fetch_struct_module(registry, name)

@spec fetch_struct_module(t(), String.t()) :: {:ok, module()} | :error

fetch_struct_schema(registry, name)

@spec fetch_struct_schema(t(), String.t()) ::
  {:ok, Dextrin.Schema.Compiled.t(), t()} | {:unknown, t()}

Looks up a compiled schema by struct name, consulting the lazy resolver (if any) on a miss and caching the result. Whether schemas are all compiled up front (Dextrin.Schema.compile/3 once, at startup) or resolved on demand (a resolver reading a file, calling a schema service, whatever the caller wants) is entirely the caller's choice — this function is what makes both paths look identical to the rest of Dextrin, which never needs to know which one is in play.

fetch_tag(registry, name)

@spec fetch_tag(t(), String.t()) :: {:ok, tag_decoder()} | :error

fetch_tag_encoder(registry, module)

@spec fetch_tag_encoder(t(), module()) :: {:ok, {String.t(), tag_encoder()}} | :error

fetch_type_alias(registry, name)

@spec fetch_type_alias(t(), String.t()) :: {:ok, Dextrin.Schema.TypeExpr.t()} | :error

new()

@spec new() :: t()

put_resolver(registry, resolver)

@spec put_resolver(t(), struct_resolver()) :: t()

put_struct_materializer(registry, name, materializer)

@spec put_struct_materializer(t(), String.t(), struct_materializer()) :: t()

put_struct_module(registry, name, module)

@spec put_struct_module(t(), String.t(), module()) :: t()

Declares which Elixir module a schema's data is expected to be — used only for {:reference, name} checks during encode-time validation (Dextrin.Schema.validate_encode/3). Decode-time reference checks don't need this: they carry an internal provenance marker regardless of materializer shape (Dextrin.Schema.Validated). Encode-time has no wire data to carry that marker before anything's even been encoded — __struct__ is the one signal actually available pre-encode, and this is what a reference check compares it against. Independent of put_struct_materializer/3: you can declare a module here even if you use the default plain-map materialization for something else.

put_struct_schema(registry, name, compiled)

@spec put_struct_schema(t(), String.t(), Dextrin.Schema.Compiled.t()) :: t()

put_tag(registry, name, decoder)

@spec put_tag(t(), String.t(), tag_decoder()) :: t()

put_tag_encoder(registry, module, name, encoder)

@spec put_tag_encoder(t(), module(), String.t(), tag_encoder()) :: t()

The reverse of put_tag/3: registers how to turn an application struct (keyed by its module) back into @name value on encode. put_tag/3 alone lets you decode @my-app/money 100 into %MyApp.Money{}, but never re-encode it, since dispatch on the way out has to happen by Elixir module, not by wire-format tag name — the two directions need separate lookup tables, not one shared one.

put_trusted(registry, trusted?)

@spec put_trusted(t(), boolean()) :: t()

Marks (or unmarks) this registry as decoding a trusted source — the default — vs. an untrusted one. Currently the one thing this affects is keyword: trusted (the default) decodes it as a real Elixir atom (DXN.md §1.3's own type table: keyword's Elixir type is "Elixir atom"); untrusted decodes it as Dextrin.Keyword.t() instead, so a String.to_atom/1 call is never reachable from attacker-controlled text (which could otherwise exhaust the atom table). Pass trusted: false to Dextrin.decode/2/decode_binary/2 — or put_trusted(registry, false) on a reused registry — for any source you don't fully control; the default assumes you do.

symbol is deliberately unaffected either way — it stays Dextrin.Symbol.t() regardless of trusted, mirroring encode/2's own choice to accept a bare atom as a stand-in for keyword, never for symbol.

put_type_alias(registry, name, type_expr)

@spec put_type_alias(t(), String.t(), Dextrin.Schema.TypeExpr.t()) :: t()

Registers a named type_expr — a reusable name for a combination of the fixed type_expr vocabulary (Dextrin.Schema.TypeExpr), defined entirely in .dxns data, never Elixir code. Dextrin.Schema.compile/3 populates this automatically from a document's non-%schema{} entries; put_type_alias/3 itself is only needed to seed a base registry by hand (e.g. sharing one vocabulary across several compile/3 calls without redeclaring it each time).