api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
2 changed files with 191 additions and 3 deletions
Showing only changes of commit e9ebf34f32 - Show all commits

feat(struct): tolerant from_map decode engine — zero atoms, never raises

Luna 2026-07-05 03:24:30 -03:00

View file

@ -1,7 +1,71 @@
defmodule Dexcord.Struct.Codec do
@moduledoc false
# Runtime walker behind every generated from_map/1, to_map/1, merge_map/2.
# All decode tolerance semantics live here, in one place.
def from_map(_module, _map), do: raise("not implemented until Task 5")
def to_map(_struct), do: raise("not implemented until Task 5")
def merge_map(_struct, _map), do: raise("not implemented until Task 5")
@doc false
def from_map(module, map) when is_map(map) and not is_struct(map) do
fields =
for f <- module.__fields__() do
{f.name, decode_field(f, map)}
end
struct!(module, fields)
end
def from_map(_module, _non_map), do: nil
@doc false
def decode_field(%{presence: true} = f, map), do: Map.has_key?(map, f.key)
def decode_field(f, map) do
case Map.fetch(map, f.key) do
:error -> f.default
{:ok, nil} -> nil
{:ok, value} -> decode_value(f.type, value, f.default)
end
end
# decode_value(type, non-nil wire value, default) — wrong container shape
# falls to the default; failed scalar casts keep the raw value.
defp decode_value(:snowflake, v, _d) when is_integer(v), do: v
defp decode_value(:snowflake, v, _d) when is_binary(v) do
case Dexcord.Snowflake.cast(v) do
{:ok, int} -> int
:error -> v
end
end
defp decode_value(:datetime, v, _d) when is_binary(v) do
case DateTime.from_iso8601(v) do
{:ok, dt, _offset} -> dt
{:error, _} -> v
end
end
defp decode_value({:struct, mod}, v, _d) when is_map(v) and not is_struct(v),
do: mod.from_map(v)
defp decode_value({:struct, _mod}, _v, d), do: d
defp decode_value({:list, inner}, v, _d) when is_list(v),
do: Enum.map(v, &decode_element(inner, &1))
defp decode_value({:list, _inner}, _v, d), do: d
defp decode_value({:enum, mod}, v, _d) when is_integer(v), do: mod.decode(v)
defp decode_value({:flags, _mod}, v, _d) when is_integer(v), do: v
# :string, :integer, :boolean, :raw, plus every failed scalar cast:
# the raw wire value passes through untouched.
defp decode_value(_type, v, _d), do: v
defp decode_element(_inner, nil), do: nil
defp decode_element(inner, v), do: decode_value(inner, v, nil)
def to_map(_struct), do: raise("not implemented until Task 6")
def merge_map(_struct, _map), do: raise("not implemented until Task 6")
end

View file

@ -1,8 +1,23 @@
defmodule Dexcord.StructTest do
use ExUnit.Case, async: true
alias Dexcord.Test.Gadget
alias Dexcord.Test.Widget
# A representative full wire map (string-keyed), reused across decode/encode tests.
defp full_wire_map do
%{
"id" => "123",
"name" => "w",
"count" => 7,
"enabled" => true,
"created_at" => "2026-07-04T12:00:00Z",
"gadget" => %{"id" => 9, "name" => "g"},
"gadgets" => [%{"id" => 1, "name" => "a"}, %{"id" => 2, "name" => "b"}],
"tag_ids" => ["111", 222]
}
end
describe "compile shape (Task 4)" do
test "struct has all declared keys with correct defaults" do
w = %Widget{}
@ -112,4 +127,113 @@ defmodule Dexcord.StructTest do
end
end
end
describe "from_map decode (Task 5)" do
test "api-surface.AC2.1: decodes declared fields including nested structs and lists" do
w = Widget.from_map(full_wire_map())
# snowflake string normalized to integer
assert w.id == 123
assert w.name == "w"
assert w.count == 7
assert w.enabled == true
# datetime parsed
assert %DateTime{} = w.created_at
assert w.created_at == ~U[2026-07-04 12:00:00Z]
# nested struct
assert w.gadget == %Gadget{id: 9, name: "g"}
# list of nested structs
assert w.gadgets == [%Gadget{id: 1, name: "a"}, %Gadget{id: 2, name: "b"}]
# list of snowflakes: string and int both normalized to integer
assert w.tag_ids == [111, 222]
end
test "api-surface.AC2.2: unknown keys ignored, decode identical, zero atoms minted" do
base = Widget.from_map(full_wire_map())
with_junk =
Widget.from_map(
Map.merge(full_wire_map(), %{
"totally_unknown_key_ab12cd" => 1,
"another_unknown_key_ab12cd" => %{"deep" => true}
})
)
assert with_junk == base
# zero atoms minted from wire data: these strings never became atoms.
assert_raise ArgumentError, fn ->
String.to_existing_atom("totally_unknown_key_ab12cd")
end
assert_raise ArgumentError, fn ->
String.to_existing_atom("another_unknown_key_ab12cd")
end
end
test "api-surface.AC2.3: wrong-shaped values never raise, fall to default/raw" do
# list where a map is expected -> nil (declared default for :gadget)
assert Widget.from_map(%{"gadget" => [1, 2]}).gadget == nil
# map where a list is expected -> declared default []
assert Widget.from_map(%{"gadgets" => %{"not" => "a list"}}).gadgets == []
# junk scalars keep the raw wire value
assert Widget.from_map(%{"count" => "not-a-number"}).count == "not-a-number"
assert Widget.from_map(%{"enabled" => "yes"}).enabled == "yes"
assert Widget.from_map(%{"created_at" => "not-a-date"}).created_at == "not-a-date"
assert Widget.from_map(%{"id" => "12x"}).id == "12x"
assert Widget.from_map(%{"color" => "red"}).color == "red"
assert Widget.from_map(%{"flags" => "3"}).flags == "3"
# whole-value junk -> nil
assert Widget.from_map("hello") == nil
assert Widget.from_map(42) == nil
# empty map -> all-defaults struct
assert Widget.from_map(%{}) == %Widget{}
end
test "enum and flags decode through the struct" do
assert Widget.from_map(%{"color" => 4}).color == :blue
assert Widget.from_map(%{"color" => 2}).color == {:unknown, 2}
# flags keep the raw integer, unknown bit (bit 7) preserved
assert Widget.from_map(%{"flags" => 0b1000_0001}).flags == 129
end
test "api-surface.AC2.6: tristate field decodes to :absent | nil | value" do
# key absent -> :absent
assert Widget.from_map(%{}).parent == :absent
# present with null -> nil
assert Widget.from_map(%{"parent" => nil}).parent == nil
# present with value -> self-referencing struct decodes
w = Widget.from_map(%{"parent" => %{"id" => 1, "name" => "p"}})
assert %Widget{} = w.parent
assert w.parent.id == 1
assert w.parent.name == "p"
end
test "api-surface.AC2.7: presence field decodes key-presence to boolean" do
# key absent -> false
assert Widget.from_map(%{}).premium == false
# present with null -> true (present-with-null still counts as present)
assert Widget.from_map(%{"premium" => nil}).premium == true
# present with value -> true
assert Widget.from_map(%{"premium" => true}).premium == true
end
test "list element null does not crash" do
w = Widget.from_map(%{"gadgets" => [%{"id" => 1, "name" => "a"}, nil]})
assert w.gadgets == [%Gadget{id: 1, name: "a"}, nil]
end
end
end