Tagged JSON encoding and decoding for Layer A structs.
Every Layer A struct (ALLM.Message, ALLM.Request, ALLM.ChatResult, and
so on — including the five ALLM.Error.* structs) is encoded as a tagged
wrapper of the shape:
%{"__type__" => "ALLM.Message", "data" => %{...every field...}}so the decoder can re-hydrate nested values without the caller knowing
their types at decode time. ChatResult containing a Thread containing
Messages decodes cleanly by recursing on the "__type__" tag. See the
Phase 1 design, Non-obvious decision #3 "Tagged JSON encoding", and
sub-phase 1.5 "Field-Error Vocabulary" for the atom set returned on decode
failure.
Encoding
encode_tagged/2 is called by each struct's defimpl Jason.Encoder and
emits the tagged wrapper. All struct fields are serialized — including
nil values — so the decoder sees the complete shape. Do not reach for
@derive {Jason.Encoder, only: [...]}: it silently drops nil fields and
breaks round-trip equality.
Convenience wrappers to_json!/1 and to_iodata!/1 call Jason.encode!/1
and Jason.encode_to_iodata!/1 respectively. Encoding raises
Jason.EncodeError on unencodable values (e.g. a PID or an anonymous
function in a metadata map); this is by design — see Phase 1 design §1.5.
Decoding
from_json/2 decodes the JSON with Jason.decode/1 then dispatches on
the "__type__" string. For each Layer A struct module, the decoder
invokes that module's private-ish __from_tagged__/1 hydrator which
rebuilds the struct with atom keys, restoring atom-typed fields via
String.to_existing_atom/1. Nested tagged maps are recursed through
hydrate/1.
Pass as: Module to decode an untagged JSON object as a specific Layer A
struct — the escape hatch for third-party JSON.
Examples
iex> msg = ALLM.Message.new(role: :user, content: "hi")
iex> json = ALLM.Serializer.to_json!(msg)
iex> {:ok, decoded} = ALLM.Serializer.from_json(json)
iex> decoded == msg
true
iex> json = Jason.encode!(%{"role" => "user", "content" => "hi", "name" => nil, "tool_call_id" => nil, "metadata" => %{}})
iex> {:ok, %ALLM.Message{role: :user}} = ALLM.Serializer.from_json(json, as: ALLM.Message)
iex> :ok
:ok
Summary
Functions
Called by each Layer A struct's defimpl Jason.Encoder — emits the tagged
wrapper %{"__type__" => inspect(module), "data" => Map.from_struct(value)}
and forwards to Jason.Encode.map/2.
Decode a tagged JSON string back to a Layer A struct.
Encode a Layer A struct to iodata via Jason.encode_to_iodata!/1.
Encode a Layer A struct to a JSON string via Jason.
Functions
@spec encode_tagged( struct(), Jason.Encode.opts() ) :: iodata()
Called by each Layer A struct's defimpl Jason.Encoder — emits the tagged
wrapper %{"__type__" => inspect(module), "data" => Map.from_struct(value)}
and forwards to Jason.Encode.map/2.
Every field is emitted, including nil values.
@spec from_json( String.t(), keyword() ) :: {:ok, struct() | map()} | {:error, ALLM.Error.ValidationError.t()}
Decode a tagged JSON string back to a Layer A struct.
Returns {:ok, struct} on success. On failure returns
{:error, %ALLM.Error.ValidationError{reason: :invalid_request, errors: [...]}}
with field-error tuples drawn from the Field-Error Vocabulary in the
Phase 1 design (§1.5).
Options
:as— module atom. Required when the top-level JSON is an untagged object (no"__type__"key). Ignored when the JSON is already tagged.
Examples
iex> msg = ALLM.Message.new(role: :user, content: "hi")
iex> json = ALLM.Serializer.to_json!(msg)
iex> {:ok, ^msg} = ALLM.Serializer.from_json(json)
iex> :ok
:ok
Encode a Layer A struct to iodata via Jason.encode_to_iodata!/1.
Encode a Layer A struct to a JSON string via Jason.
Raises Jason.EncodeError if any field value is not JSON-serializable (e.g.
a PID in :metadata). See module docs for rationale.
Examples
iex> msg = ALLM.Message.new(role: :user, content: "hi")
iex> json = ALLM.Serializer.to_json!(msg)
iex> decoded = Jason.decode!(json)
iex> decoded["__type__"]
"ALLM.Message"