Skip to content

Nested response hashes return String keys, while specs stub Symbol keys #206

Description

@felipefreitag

Summary

Resend::Request#process_response symbolizes response keys with data.transform_keys!(&:to_sym) (lib/resend/request.rb:75), but transform_keys! is shallow. Only the top-level Hash is converted — any Hash nested inside the response keeps its String keys.

That produces an inconsistency between response shapes:

  • flat responses (e.g. Resend::Domains.get) → all keys are Symbols
  • responses containing a nested data array (list endpoints and batch endpoints) → top level is Symbols, each nested entry is Strings

So this returns nil:

Resend::ApiKeys.list[:data].first[:id]   # => nil
Resend::ApiKeys.list[:data].first["id"]  # => "..." (works)

Verified against the live API on v1.6.0. For a list call the top-level keys come back as [:object, :has_more, :data] while each entry's keys are ["id", "name", "created_at", "last_used_at"].

This is not limited to list endpoints. Any nested Hash is affected, including batch responses. Live output from Resend::Suppressions::Batch.add:

3 BATCH ADD    sent=3 returned=2
   nested key access: entry[:id]=nil  entry["id"]="019fa3b0-ac48-721e-ac97-b2ffb121a18b"

So callers of the batch APIs hit the same thing when reading id/deleted off the returned entries.

Why the specs don't catch it

The specs stub Resend::Request#perform and return Symbol-keyed Hashes:

resp = { object: "list", has_more: false, data: [ { id: "...", name: "..." } ] }
allow_any_instance_of(Resend::Request).to receive(:perform).and_return(resp)

Because perform is stubbed, process_response never runs, so the stub bypasses the very code that would have converted keys — and asserts a shape real HTTP never produces for nested entries. The suite is green while the documented/expected access pattern is wrong.

There's a second-order effect worth noting: process_response has a branch specifically for mocks (resp.respond_to?(:parsed_response) ? resp.parsed_response : resp, request.rb:73), which makes stubbed Hashes flow through differently from real HTTParty::Response objects. That's the seam where test and production behaviour diverge.

Repro

require "resend"
Resend.api_key = ENV["RESEND_API_KEY"]

res = Resend::ApiKeys.list
puts res.keys.inspect                  # => [:object, :has_more, :data]
puts res[:data].first.keys.inspect     # => ["id", "name", "created_at", "last_used_at"]
puts res[:data].first[:id].inspect     # => nil        <-- surprising
puts res[:data].first["id"].inspect    # => "..."      <-- required

Impact

Callers reasonably assume Symbol keys throughout, since that's what flat get responses and every spec fixture show. Reading entry[:id] silently yields nil rather than raising, so it tends to surface downstream as a confusing nil rather than at the access site.

Possible directions

  1. Deep-symbolize in process_response — makes the API self-consistent, but is a breaking change for anyone who already worked around this with String keys. Would want a minor/major bump and a changelog note.
  2. Leave the runtime behaviour as-is and fix the specs so nested entries use String keys, making the suite reflect reality. Non-breaking, and stops the fixtures from teaching the wrong shape.
  3. Both: fix the specs now to document actual behaviour, and treat deep symbolization as a separate deliberate breaking change.

Option 2 is the minimum I'd suggest, since right now the fixtures actively assert something the API doesn't do.

Happy to send a PR for whichever direction you prefer.

Notes

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions