JSON: keep the key order Poison wrote

6696166 · Gabriel Morell · 2026-10-23 13:11

8 files +27 -4
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum/json.ex
+12 −2
@@ -23,7 +23,8 @@ defmodule RoomSanctum.JSON do
23 23
24 24 @doc """
25 25 `term` with everything Jason would refuse made into what Poison wrote
26 − for it. Dates and times are left alone: both write them as ISO 8601.
26 + for it, and every map made a `Jason.OrderedObject` in the key order
27 + Poison wrote. Dates and times are left alone: both write them as ISO 8601.
27 28 """
28 29 def normalize(%struct{} = value) when struct in [Date, Time, NaiveDateTime, DateTime], do: value
29 30 def normalize(%Jason.Fragment{} = value), do: value
@@ -32,7 +33,16 @@ defmodule RoomSanctum.JSON do
32 33 def normalize(%Range{} = value), do: Enum.to_list(value)
33 34 def normalize(%MapSet{} = value), do: value |> MapSet.to_list() |> normalize()
34 35 def normalize(%_{} = value), do: value |> Map.from_struct() |> normalize()
35 − def normalize(value) when is_map(value), do: Map.new(value, fn {k, v} -> {k, normalize(v)} end)
36 + # Poison wrote an object's keys in the reverse of the map's own order (it
37 + # folds over `Map.keys/1`, prepending), and Jason writes them in that order.
38 + # A client that reads a board in the order it arrived -- the Android app
39 + # lays its cards out that way -- would see them flip, so the order Poison
40 + # wrote is kept.
41 + def normalize(value) when is_map(value) do
42 + values = :lists.foldl(&[{&1, normalize(Map.fetch!(value, &1))} | &2], [], Map.keys(value))
43 + %Jason.OrderedObject{values: values}
44 + end
45 +
36 46 def normalize(value) when is_list(value), do: Enum.map(value, &normalize/1)
37 47 def normalize(value) when is_tuple(value), do: value |> Tuple.to_list() |> normalize()
38 48 def normalize(value), do: value
modified apps/room_sanctum/test/room_sanctum/json_test.exs
+15 −2
@@ -30,12 +30,25 @@ defmodule RoomSanctum.JSONTest do
30 30 end
31 31
32 32 test "ranges and sets become lists, a URI its string" do
33 − assert JSON.encode!(%{r: 1..3, u: URI.parse("https://example.com/x")}) ==
34 − ~s({"r":[1,2,3],"u":"https://example.com/x"})
33 + # String keys: a small map orders them, so the order written is fixed.
34 + assert JSON.encode!(%{"r" => 1..3, "u" => URI.parse("https://example.com/x")}) ==
35 + ~s({"u":"https://example.com/x","r":[1,2,3]})
35 36
36 37 assert JSON.encode!(MapSet.new([{1, 2}])) == ~s([[1,2]])
37 38 end
38 39
40 + test "an object's keys come out in the order Poison wrote them" do
41 + # The reverse of the map's own order. The Android app lays a board's
42 + # cards out in the order the keys arrive, so this is what it shows.
43 + board = %{
44 + "ephem-1" => %{"data" => [1], "query" => %{"name" => "Sky"}},
45 + "gtfs-2" => [%{"a" => 1, "b" => 2}]
46 + }
47 +
48 + assert JSON.encode!(board) ==
49 + ~s({"gtfs-2":[{"b":2,"a":1}],"ephem-1":{"query":{"name":"Sky"},"data":[1]}})
50 + end
51 +
39 52 test "pretty printing still works" do
40 53 assert JSON.encode!(%{a: 1}, pretty: true) == ~s({\n "a": 1\n})
41 54 end

Parents: 819251f