api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
5 changed files with 141 additions and 10 deletions
Showing only changes of commit 202be23ba0 - Show all commits

feat(struct): :number type, string-wire flags, wire_string opt, include_fields

Luna 2026-07-05 03:46:10 -03:00

View file

@ -1,5 +1,11 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
locals_without_parens: [field: 2, field: 3, hydrate: 2, discord_struct: 1]
locals_without_parens: [
field: 2,
field: 3,
hydrate: 2,
discord_struct: 1,
include_fields: 1
]
]

View file

@ -17,16 +17,26 @@ defmodule Dexcord.Struct do
end
end
Field types: `:snowflake`, `:string`, `:integer`, `:boolean`, `:datetime`,
`{:struct, Module}`, `{:list, type}`, `{:enum, Module}`, `{:flags, Module}`,
`:raw` (kept verbatim). Modifiers: `default:`, `tristate: true`
(`:absent | nil | value`), `presence: true` (key-presence boolean).
Field types: `:snowflake`, `:string`, `:integer`, `:number` (int or float),
`:boolean`, `:datetime`, `{:struct, Module}`, `{:list, type}`,
`{:enum, Module}`, `{:flags, Module}`, `:raw` (kept verbatim). Modifiers:
`default:`, `tristate: true` (`:absent | nil | value`), `presence: true`
(key-presence boolean), `wire_string: true` (re-encode an integer value as a
decimal string, for bitfields that exceed 2^53 like permissions).
`{:flags, Module}` decodes both integers and decimal strings (Discord sends
permission bitfields as strings); pair it with `wire_string: true` to
round-trip back to a string.
`include_fields Provider` pulls a shared field group from a plain-data module
exporting `__included_fields__/0` (a list of `{name, type, opts}`); the fields
land in declaration position, indistinguishable from hand-written `field`s.
`hydrate slot, from: :id_field, type: Module` declares a cache-fillable
slot (always `nil` after decode; populated only by `Dexcord.Cache.fill/1`).
"""
@scalar_types [:snowflake, :string, :integer, :boolean, :datetime, :raw]
@scalar_types [:snowflake, :string, :integer, :number, :boolean, :datetime, :raw]
defmacro __using__(_opts) do
quote do
@ -40,7 +50,7 @@ defmodule Dexcord.Struct do
defmacro discord_struct(do: block) do
quote do
try do
import Dexcord.Struct, only: [field: 2, field: 3, hydrate: 2]
import Dexcord.Struct, only: [field: 2, field: 3, hydrate: 2, include_fields: 1]
unquote(block)
after
:ok
@ -55,6 +65,15 @@ defmodule Dexcord.Struct do
end
end
@doc false
defmacro include_fields(mod) do
quote do
Enum.each(unquote(mod).__included_fields__(), fn {name, type, opts} ->
Module.put_attribute(__MODULE__, :dexcord_fields, {name, type, opts})
end)
end
end
@doc false
defmacro hydrate(name, opts) do
quote do
@ -152,7 +171,8 @@ defmodule Dexcord.Struct do
type: type,
default: default,
tristate: tristate,
presence: presence
presence: presence,
wire_string: Keyword.get(opts, :wire_string, false)
}
end
@ -201,6 +221,7 @@ defmodule Dexcord.Struct do
defp base_typespec(:snowflake, _m), do: quote(do: Dexcord.Snowflake.t())
defp base_typespec(:string, _m), do: quote(do: String.t())
defp base_typespec(:integer, _m), do: quote(do: integer())
defp base_typespec(:number, _m), do: quote(do: number())
defp base_typespec(:boolean, _m), do: quote(do: boolean())
defp base_typespec(:datetime, _m), do: quote(do: DateTime.t())
defp base_typespec(:raw, _m), do: quote(do: term())

View file

@ -59,6 +59,15 @@ defmodule Dexcord.Struct.Codec do
defp decode_value({:flags, _mod}, v, _d) when is_integer(v), do: v
# Permission-style bitfields arrive as decimal strings (they exceed 2^53).
# Parse a clean decimal string to an integer; anything else stays raw.
defp decode_value({:flags, _mod}, v, _d) when is_binary(v) do
case Integer.parse(v) do
{int, ""} -> int
_ -> v
end
end
# :string, :integer, :boolean, :raw, plus every failed scalar cast:
# the raw wire value passes through untouched.
defp decode_value(_type, v, _d), do: v
@ -80,7 +89,16 @@ defmodule Dexcord.Struct.Codec do
defp encode_field(%{presence: true}, true), do: {:ok, nil}
defp encode_field(%{presence: true}, false), do: :skip
defp encode_field(_f, nil), do: :skip
defp encode_field(f, value), do: {:ok, encode_value(f.type, value)}
defp encode_field(f, value) do
encoded = encode_value(f.type, value)
if f.wire_string and is_integer(encoded) do
{:ok, Integer.to_string(encoded)}
else
{:ok, encoded}
end
end
defp encode_value(:snowflake, v) when is_integer(v), do: Integer.to_string(v)
defp encode_value(:datetime, %DateTime{} = v), do: DateTime.to_iso8601(v)

View file

@ -1,6 +1,7 @@
defmodule Dexcord.StructTest do
use ExUnit.Case, async: true
alias Dexcord.Test.Doohickey
alias Dexcord.Test.Gadget
alias Dexcord.Test.Widget
@ -198,7 +199,9 @@ defmodule Dexcord.StructTest do
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"
# a decimal-string flags value now decodes to an integer (permission
# bitfields arrive as strings); a non-numeric junk string stays raw.
assert Widget.from_map(%{"flags" => "3x"}).flags == "3x"
# whole-value junk -> nil
assert Widget.from_map("hello") == nil
@ -308,6 +311,61 @@ defmodule Dexcord.StructTest do
end
end
describe "DSL extensions (Phase 2, Task 1)" do
test ":number decodes float and integer verbatim, junk stays raw" do
assert Doohickey.from_map(%{"ratio" => 3.5}).ratio == 3.5
assert Doohickey.from_map(%{"ratio" => 3}).ratio == 3
assert Doohickey.from_map(%{"ratio" => "not-a-number"}).ratio == "not-a-number"
end
test ":number resolves to the :number field type" do
by_name = Map.new(Doohickey.__fields__(), &{&1.name, &1})
assert by_name[:ratio].type == :number
end
test "string-wire flags decode: decimal string -> integer, junk stays raw, int passes through" do
assert Doohickey.from_map(%{"bits" => "17"}).bits == 17
assert Doohickey.from_map(%{"bits" => "12x"}).bits == "12x"
assert Doohickey.from_map(%{"bits" => 5}).bits == 5
end
test "wire_string: true encodes integer flags to a decimal string" do
m = Doohickey.to_map(%Doohickey{perms: 17})
assert m["perms"] == "17"
end
test "without wire_string a flags field encodes as the raw integer" do
m = Doohickey.to_map(%Doohickey{bits: 17})
assert m["bits"] == 17
end
test "wire_string is recorded on the field table" do
by_name = Map.new(Doohickey.__fields__(), &{&1.name, &1})
assert by_name[:perms].wire_string == true
assert by_name[:bits].wire_string == false
end
test "include_fields injects the shared group in declaration position" do
names = Enum.map(Doohickey.__fields__(), & &1.name)
assert names == [:id, :ratio, :perms, :bits, :shared_a, :shared_b, :tail]
end
test "included fields carry their declared types and defaults" do
by_name = Map.new(Doohickey.__fields__(), &{&1.name, &1})
assert by_name[:shared_a].type == :string
assert by_name[:shared_b].type == {:list, :snowflake}
assert by_name[:shared_b].default == []
assert %Doohickey{}.shared_b == []
end
test "included fields decode identically to hand-declared fields" do
d = Doohickey.from_map(%{"shared_a" => "x", "shared_b" => ["1", 2], "tail" => "end"})
assert d.shared_a == "x"
assert d.shared_b == [1, 2]
assert d.tail == "end"
end
end
describe "merge_map partial update (Task 6)" do
test "api-surface.AC2.8: updates only keys present in the partial map" do
w = Widget.from_map(full_wire_map())

View file

@ -40,3 +40,31 @@ defmodule Dexcord.Test.Widget do
hydrate :gadget_full, from: :id, type: Dexcord.Test.Gadget
end
end
# A plain-data field-group provider for exercising `include_fields/1`.
defmodule Dexcord.Test.SharedGroup do
@moduledoc false
def __included_fields__ do
[
{:shared_a, :string, []},
{:shared_b, {:list, :snowflake}, [default: []]}
]
end
end
# Exercises Task 1's DSL extensions: `:number`, string-wire flags, the
# `wire_string:` opt, and `include_fields/1` interleaved with own fields.
defmodule Dexcord.Test.Doohickey do
@moduledoc false
use Dexcord.Struct
discord_struct do
field :id, :snowflake
field :ratio, :number
field :perms, {:flags, Dexcord.Test.TestFlags}, wire_string: true
field :bits, {:flags, Dexcord.Test.TestFlags}
include_fields Dexcord.Test.SharedGroup
field :tail, :string
end
end