From aa42b8a90a3a9d590f9bdc71984c405a71bd0842 Mon Sep 17 00:00:00 2001 From: Russ Smith Date: Tue, 23 Jun 2026 14:11:36 -0700 Subject: [PATCH] Make Payload case-insensitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crystal's JSON parsing is case-sensitive, so a payload like `{"Command": "Subscribe"}` failed two ways: 1. `Payload.from_json` raised `Missing JSON attribute: command` because the key was `Command` rather than `command`. 2. Even once parsed, `Connection#receive` dispatched on `payload.command == "subscribe"`, so a `Subscribe` value never matched. Fix both: - `Payload.from_json` normalizes the top-level keys (command/identifier/data) to lowercase before deserializing. Nested values — channel names and the identifier/data blobs — are left exactly as sent, since those stay case-sensitive. - `Connection#receive` compares the command case-insensitively. Adds a Payload unit spec for mixed-case top-level keys and an end-to-end `#receive` spec exercising a `{"Command": "Subscribe", ...}` payload. Fixes #108 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BMW1EsdCMExBhm3tg7TQhs --- spec/cable/connection_spec.cr | 12 ++++++++++++ spec/cable/payload_spec.cr | 14 ++++++++++++++ src/cable/connection.cr | 10 +++++++--- src/cable/payload.cr | 25 +++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/spec/cable/connection_spec.cr b/spec/cable/connection_spec.cr index b56bc30..779102d 100644 --- a/spec/cable/connection_spec.cr +++ b/spec/cable/connection_spec.cr @@ -65,6 +65,18 @@ describe Cable::Connection do end end + it "accepts a case-insensitive command and keys (issue #108)" do + connect do |connection, socket| + connection.receive({"Command" => "Subscribe", "Identifier" => {channel: "ChatChannel", room: "1"}.to_json}.to_json) + sleep 100.milliseconds + + socket.messages.should contain({"type" => "confirm_subscription", "identifier" => {channel: "ChatChannel", room: "1"}.to_json}.to_json) + + connection.close + socket.close + end + end + it "accepts without params hash key" do connect do |connection, socket| connection.receive({"command" => "subscribe", "identifier" => {channel: "AppearanceChannel"}.to_json}.to_json) diff --git a/spec/cable/payload_spec.cr b/spec/cable/payload_spec.cr index 785fde1..bcb2ed4 100644 --- a/spec/cable/payload_spec.cr +++ b/spec/cable/payload_spec.cr @@ -36,6 +36,20 @@ describe Cable::Payload do payload.action.should eq("invite") end + it "parses case-insensitive top-level keys (issue #108)" do + payload_json = { + "Command" => "message", + "Identifier" => {channel: "ChatChannel"}.to_json, + "DATA" => {invite_id: 3, action: "invite"}.to_json, + }.to_json + + payload = Cable::Payload.from_json(payload_json) + payload.command.should eq("message") + payload.channel.should eq("ChatChannel") + payload.data.should eq({"invite_id" => 3}) + payload.action.should eq("invite") + end + it "raises a SerializableError when the identifier is not a string" do payload_json = { command: "subscribe", diff --git a/src/cable/connection.cr b/src/cable/connection.cr index 25d4685..97807a4 100644 --- a/src/cable/connection.cr +++ b/src/cable/connection.cr @@ -123,9 +123,13 @@ module Cable return unless message.presence payload = Cable::Payload.from_json(message) - return subscribe(payload) if payload.command == "subscribe" - return unsubscribe(payload) if payload.command == "unsubscribe" - return message(payload) if payload.command == "message" + # Compare case-insensitively so a command like "Subscribe" still + # dispatches correctly (see issue #108). + case payload.command.downcase + when "subscribe" then subscribe(payload) + when "unsubscribe" then unsubscribe(payload) + when "message" then message(payload) + end end def subscribe(payload : Cable::Payload) diff --git a/src/cable/payload.cr b/src/cable/payload.cr index a87f5d3..2b78709 100644 --- a/src/cable/payload.cr +++ b/src/cable/payload.cr @@ -39,6 +39,31 @@ module Cable @[JSON::Field(ignore: true)] getter action : String = "" + # Crystal's JSON parsing is case-sensitive, but cable clients in the wild + # aren't always consistent about the casing of the top-level protocol keys. + # Normalize them to lowercase before deserializing so a payload such as + # `{"Command": "subscribe", ...}` is accepted instead of raising + # "Missing JSON attribute: command". + # + # Only the top-level keys (command/identifier/data) are touched; the nested + # values — channel names and the identifier/data blobs — are left exactly as + # sent, since those remain case-sensitive. See issue #108. + def self.from_json(string : String) : self + if object = JSON.parse(string).as_h? + string = String.build do |io| + JSON.build(io) do |json| + json.object do + object.each do |key, value| + json.field(key.downcase) { value.to_json(json) } + end + end + end + end + end + + new(JSON::PullParser.new(string)) + end + # After the Payload is deserialized, parse the data. # This will ensure we know if it's an action. def after_initialize