Skip to content

Commit 7387ce0

Browse files
committed
fix: normalize booleans on join payloads
To ensure better compatibility with existing users we will infer intent from non boolean values by normalizing and downcasing boolean strings e.g. False will be considered false boolean value
1 parent e86e984 commit 7387ce0

File tree

7 files changed

+260
-2
lines changed

7 files changed

+260
-2
lines changed

lib/realtime_web/channels/payloads/broadcast.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule RealtimeWeb.Channels.Payloads.Broadcast do
55
use Ecto.Schema
66
import Ecto.Changeset
77
alias RealtimeWeb.Channels.Payloads.Join
8+
alias RealtimeWeb.Channels.Payloads.ChangesetNormalizer
89

910
embedded_schema do
1011
field :ack, :boolean, default: false
@@ -13,7 +14,10 @@ defmodule RealtimeWeb.Channels.Payloads.Broadcast do
1314
end
1415

1516
def changeset(broadcast, attrs) do
16-
cast(broadcast, attrs, [:ack, :self], message: &Join.error_message/2)
17+
attrs = ChangesetNormalizer.normalize_boolean_fields(attrs, [:ack, :self])
18+
19+
broadcast
20+
|> cast(attrs, [:ack, :self], message: &Join.error_message/2)
1721
|> cast_embed(:replay, invalid_message: "unable to parse, expected a map")
1822
end
1923
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
defmodule RealtimeWeb.Channels.Payloads.ChangesetNormalizer do
2+
@moduledoc """
3+
Functions for normalizing changeset attributes before validation.
4+
Handles conversion of string boolean representations to actual booleans.
5+
"""
6+
7+
@doc """
8+
Normalizes a value that should be a boolean. Accepts:
9+
- Boolean values (true/false) - returned as-is
10+
- Strings "true", "True", "TRUE", etc. - converted to true
11+
- Strings "false", "False", "FALSE", etc. - converted to false
12+
- Any other value - returned as-is (will fail validation later)
13+
"""
14+
def normalize_boolean(value) when is_boolean(value), do: value
15+
16+
def normalize_boolean(value) when is_binary(value) do
17+
case String.downcase(value) do
18+
"true" -> true
19+
"false" -> false
20+
_ -> value
21+
end
22+
end
23+
24+
def normalize_boolean(value), do: value
25+
26+
@doc """
27+
Normalizes boolean fields in an attrs map for the given field names.
28+
"""
29+
def normalize_boolean_fields(attrs, field_names) when is_map(attrs) do
30+
Enum.reduce(field_names, attrs, fn field_name, acc ->
31+
field_key_string = to_string(field_name)
32+
field_key_atom = field_name
33+
34+
acc
35+
|> maybe_normalize_field(field_key_string)
36+
|> maybe_normalize_field(field_key_atom)
37+
end)
38+
end
39+
40+
def normalize_boolean_fields(attrs, _field_names), do: attrs
41+
42+
defp maybe_normalize_field(attrs, key) do
43+
case Map.fetch(attrs, key) do
44+
{:ok, value} -> Map.put(attrs, key, normalize_boolean(value))
45+
:error -> attrs
46+
end
47+
end
48+
end

lib/realtime_web/channels/payloads/config.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ defmodule RealtimeWeb.Channels.Payloads.Config do
88
alias RealtimeWeb.Channels.Payloads.Broadcast
99
alias RealtimeWeb.Channels.Payloads.Presence
1010
alias RealtimeWeb.Channels.Payloads.PostgresChange
11+
alias RealtimeWeb.Channels.Payloads.ChangesetNormalizer
1112

1213
embedded_schema do
1314
embeds_one :broadcast, Broadcast
@@ -24,6 +25,7 @@ defmodule RealtimeWeb.Channels.Payloads.Config do
2425
{k, v} -> {k, v}
2526
end)
2627
|> Map.new()
28+
|> ChangesetNormalizer.normalize_boolean_fields([:private])
2729

2830
config
2931
|> cast(attrs, [:private], message: &Join.error_message/2)

lib/realtime_web/channels/payloads/presence.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ defmodule RealtimeWeb.Channels.Payloads.Presence do
55
use Ecto.Schema
66
import Ecto.Changeset
77
alias RealtimeWeb.Channels.Payloads.Join
8+
alias RealtimeWeb.Channels.Payloads.ChangesetNormalizer
89

910
embedded_schema do
1011
field :enabled, :boolean, default: true
1112
field :key, :any, default: UUID.uuid1(), virtual: true
1213
end
1314

1415
def changeset(presence, attrs) do
16+
attrs = ChangesetNormalizer.normalize_boolean_fields(attrs, [:enabled])
17+
1518
cast(presence, attrs, [:enabled, :key], message: &Join.error_message/2)
1619
end
1720
end

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule Realtime.MixProject do
44
def project do
55
[
66
app: :realtime,
7-
version: "2.73.5",
7+
version: "2.73.6",
88
elixir: "~> 1.18",
99
elixirc_paths: elixirc_paths(Mix.env()),
1010
start_permanent: Mix.env() == :prod,
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
defmodule RealtimeWeb.Channels.Payloads.ChangesetNormalizerTest do
2+
use ExUnit.Case, async: true
3+
4+
alias RealtimeWeb.Channels.Payloads.ChangesetNormalizer
5+
6+
describe "normalize_boolean/1" do
7+
test "returns true for boolean true" do
8+
assert ChangesetNormalizer.normalize_boolean(true) == true
9+
end
10+
11+
test "returns false for boolean false" do
12+
assert ChangesetNormalizer.normalize_boolean(false) == false
13+
end
14+
15+
test "converts string 'true' in any case to boolean true" do
16+
assert ChangesetNormalizer.normalize_boolean("true") == true
17+
assert ChangesetNormalizer.normalize_boolean("True") == true
18+
assert ChangesetNormalizer.normalize_boolean("TRUE") == true
19+
end
20+
21+
test "converts string 'false' in any case to boolean false" do
22+
assert ChangesetNormalizer.normalize_boolean("false") == false
23+
assert ChangesetNormalizer.normalize_boolean("False") == false
24+
assert ChangesetNormalizer.normalize_boolean("FALSE") == false
25+
end
26+
27+
test "returns other string values as-is" do
28+
assert ChangesetNormalizer.normalize_boolean("test") == "test"
29+
assert ChangesetNormalizer.normalize_boolean(1) == 1
30+
assert ChangesetNormalizer.normalize_boolean(nil) == nil
31+
assert ChangesetNormalizer.normalize_boolean(%{}) == %{}
32+
assert ChangesetNormalizer.normalize_boolean([]) == []
33+
end
34+
end
35+
36+
describe "normalize_boolean_fields/2" do
37+
test "normalizes boolean fields with string keys" do
38+
attrs = %{"enabled" => "true", "ack" => "false"}
39+
result = ChangesetNormalizer.normalize_boolean_fields(attrs, [:enabled, :ack])
40+
41+
assert result == %{"enabled" => true, "ack" => false}
42+
end
43+
44+
test "normalizes boolean fields with atom keys" do
45+
attrs = %{enabled: "True", ack: "False"}
46+
result = ChangesetNormalizer.normalize_boolean_fields(attrs, [:enabled, :ack])
47+
48+
assert result == %{enabled: true, ack: false}
49+
end
50+
51+
test "normalizes mixed string and atom keys" do
52+
attrs = %{"enabled" => "true", :ack => "false"}
53+
result = ChangesetNormalizer.normalize_boolean_fields(attrs, [:enabled, :ack])
54+
55+
assert result == %{"enabled" => true, :ack => false}
56+
end
57+
58+
test "leaves non-boolean fields unchanged" do
59+
attrs = %{"enabled" => "true", "name" => "test", "count" => 42}
60+
result = ChangesetNormalizer.normalize_boolean_fields(attrs, [:enabled])
61+
62+
assert result == %{"enabled" => true, "name" => "test", "count" => 42}
63+
end
64+
65+
test "leaves fields not in the list unchanged" do
66+
attrs = %{"enabled" => "true", "other" => "false"}
67+
result = ChangesetNormalizer.normalize_boolean_fields(attrs, [:enabled])
68+
69+
assert result == %{"enabled" => true, "other" => "false"}
70+
end
71+
72+
test "handles missing fields gracefully" do
73+
attrs = %{"name" => "test"}
74+
result = ChangesetNormalizer.normalize_boolean_fields(attrs, [:enabled, :ack])
75+
76+
assert result == %{"name" => "test"}
77+
end
78+
79+
test "handles actual boolean values without changing them" do
80+
attrs = %{"enabled" => true, "ack" => false}
81+
result = ChangesetNormalizer.normalize_boolean_fields(attrs, [:enabled, :ack])
82+
83+
assert result == %{"enabled" => true, "ack" => false}
84+
end
85+
86+
test "leaves invalid boolean strings as-is for validation to catch" do
87+
attrs = %{"enabled" => "invalid", "ack" => "test"}
88+
result = ChangesetNormalizer.normalize_boolean_fields(attrs, [:enabled, :ack])
89+
90+
assert result == %{"enabled" => "invalid", "ack" => "test"}
91+
end
92+
93+
test "returns non-map values as-is" do
94+
assert ChangesetNormalizer.normalize_boolean_fields("not a map", [:enabled]) == "not a map"
95+
assert ChangesetNormalizer.normalize_boolean_fields(nil, [:enabled]) == nil
96+
assert ChangesetNormalizer.normalize_boolean_fields([], [:enabled]) == []
97+
end
98+
99+
test "handles empty field list" do
100+
attrs = %{"enabled" => "true"}
101+
result = ChangesetNormalizer.normalize_boolean_fields(attrs, [])
102+
103+
assert result == %{"enabled" => "true"}
104+
end
105+
end
106+
end

test/realtime_web/channels/payloads/join_test.exs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,5 +119,100 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do
119119

120120
assert {:ok, %Join{config: %Config{postgres_changes: []}}} = Join.validate(config)
121121
end
122+
123+
test "accepts string 'true' for boolean fields" do
124+
config = %{
125+
"config" => %{
126+
"private" => "true",
127+
"broadcast" => %{"ack" => "true", "self" => "true"},
128+
"presence" => %{"enabled" => "true"}
129+
}
130+
}
131+
132+
assert {:ok, %Join{config: config_result}} = Join.validate(config)
133+
134+
assert %Config{
135+
private: true,
136+
broadcast: %Broadcast{ack: true, self: true},
137+
presence: %Presence{enabled: true}
138+
} = config_result
139+
end
140+
141+
test "accepts string 'True' for boolean fields" do
142+
config = %{
143+
"config" => %{
144+
"private" => "True",
145+
"broadcast" => %{"ack" => "True", "self" => "True"},
146+
"presence" => %{"enabled" => "True"}
147+
}
148+
}
149+
150+
assert {:ok, %Join{config: config_result}} = Join.validate(config)
151+
152+
assert %Config{
153+
private: true,
154+
broadcast: %Broadcast{ack: true, self: true},
155+
presence: %Presence{enabled: true}
156+
} = config_result
157+
end
158+
159+
test "accepts string 'false' for boolean fields" do
160+
config = %{
161+
"config" => %{
162+
"private" => "false",
163+
"broadcast" => %{"ack" => "false", "self" => "false"},
164+
"presence" => %{"enabled" => "false"}
165+
}
166+
}
167+
168+
assert {:ok, %Join{config: config_result}} = Join.validate(config)
169+
170+
assert %Config{
171+
private: false,
172+
broadcast: %Broadcast{ack: false, self: false},
173+
presence: %Presence{enabled: false}
174+
} = config_result
175+
end
176+
177+
test "accepts string 'False' for boolean fields" do
178+
config = %{
179+
"config" => %{
180+
"private" => "False",
181+
"broadcast" => %{"ack" => "False", "self" => "False"},
182+
"presence" => %{"enabled" => "False"}
183+
}
184+
}
185+
186+
assert {:ok, %Join{config: config_result}} = Join.validate(config)
187+
188+
assert %Config{
189+
private: false,
190+
broadcast: %Broadcast{ack: false, self: false},
191+
presence: %Presence{enabled: false}
192+
} = config_result
193+
end
194+
195+
test "rejects invalid boolean strings" do
196+
config = %{
197+
"config" => %{
198+
"private" => "yes",
199+
"broadcast" => %{"ack" => "a", "self" => "b"},
200+
"presence" => %{"enabled" => "no"}
201+
}
202+
}
203+
204+
assert {:error, :invalid_join_payload, errors} = Join.validate(config)
205+
206+
assert errors == %{
207+
config: %{
208+
private: ["unable to parse, expected boolean"],
209+
broadcast: %{
210+
ack: ["unable to parse, expected boolean"],
211+
self: ["unable to parse, expected boolean"]
212+
},
213+
presence: %{enabled: ["unable to parse, expected boolean"]}
214+
}
215+
}
216+
end
122217
end
123218
end

0 commit comments

Comments
 (0)