Skip to content

Commit

Permalink
Ensure response.parsed_body support for pattern matching
Browse files Browse the repository at this point in the history
Both `Nokogiri` and `Minitest` have merged the PRs mentioned to
integrate support for Ruby's Pattern matching
(sparklemotion/nokogiri#2523 and
minitest/minitest#936, respectively).

This commit adds coverage for those new assertions, and incorporates
examples into the documentation for the `response.parsed_body` method.

In order to incorporate pattern-matching support for JSON responses,
this commit changes the response parser to call `JSON.parse` with
[object_class: ActiveSupport::HashWithIndifferentAccess][object_class],
since String instances for `Hash` keys are incompatible with Ruby's
syntactically pattern matching.

For example:

```ruby
irb(main):001:0> json = {"key" => "value"}
=> {"key"=>"value"}
irb(main):002:0> json in {key: /value/}
=> false

irb(main):001:0> json = {"key" => "value"}
=> {"key"=>"value"}
irb(main):002:0> json in {"key" => /value/}
.../3.2.0/lib/ruby/gems/3.2.0/gems/irb-1.7.4/lib/irb/workspace.rb:113:in `eval': (irb):2: syntax error, unexpected terminator, expecting literal content or tSTRING_DBEG or tSTRING_DVAR or tLABEL_END (SyntaxError)
json in {"key" => /value/}
             ^

        .../ruby/3.2.0/lib/ruby/gems/3.2.0/gems/irb-1.7.4/exe/irb:9:in `<top (required)>'
        .../ruby/3.2.0/bin/irb:25:in `load'
        .../ruby/3.2.0/bin/irb:25:in `<main>'
```

When the Hash maps String keys to Symbol keys, it's able to be pattern
matched:

```ruby
irb(main):005:0> json = {"key" => "value"}.with_indifferent_access
=> {"key"=>"value"}
irb(main):006:0> json in {key: /value/}
=> true
```

[object_class]: https://docs.ruby-lang.org/en/3.2/JSON.html#module-JSON-label-Parsing+Options
  • Loading branch information
seanpdoyle committed Aug 23, 2023
1 parent ed5af00 commit 0f4ab82
Show file tree
Hide file tree
Showing 6 changed files with 84 additions and 8 deletions.
8 changes: 8 additions & 0 deletions actionpack/CHANGELOG.md
@@ -1,3 +1,11 @@
* Parse JSON `response.parsed_body` with `ActiveSupport::HashWithIndifferentAccess`

Integrate with Minitest's new `assert_pattern` by parsing the JSON contents
of `response.parsed_body` with `ActiveSupport::HashWithIndifferentAccess`, so
that it's pattern-matching compatible.

*Sean Doyle*

* Add support for Playwright as a driver for system tests.

*Yuki Nishijima*
Expand Down
2 changes: 1 addition & 1 deletion actionpack/lib/action_dispatch/testing/request_encoder.rb
Expand Up @@ -53,6 +53,6 @@ def self.register_encoder(mime_name, param_encoder: nil, response_parser: nil)
end

register_encoder :html, response_parser: -> body { Rails::Dom::Testing.html_document.parse(body) }
register_encoder :json, response_parser: -> body { JSON.parse(body) }
register_encoder :json, response_parser: -> body { JSON.parse(body, object_class: ActiveSupport::HashWithIndifferentAccess) }
end
end
16 changes: 15 additions & 1 deletion actionpack/lib/action_dispatch/testing/test_response.rb
Expand Up @@ -23,15 +23,29 @@ def self.from_response(response)
# response.parsed_body.class # => Nokogiri::HTML5::Document
# response.parsed_body.to_html # => "<!DOCTYPE html>\n<html>\n..."
#
# assert_pattern { response.parsed_body.at("main") => { content: "Hello, world" } }
#
# response.parsed_body.at("main") => {name:, content:}
# assert_equal "main", name
# assert_equal "Some main content", content
#
# get "/posts.json"
# response.content_type # => "application/json; charset=utf-8"
# response.parsed_body.class # => Array
# response.parsed_body # => [{"id"=>42, "title"=>"Title"},...
#
# assert_pattern { response.parsed_body => [{ id: 42 }] }
#
# get "/posts/42.json"
# response.content_type # => "application/json; charset=utf-8"
# response.parsed_body.class # => Hash
# response.parsed_body.class # => ActiveSupport::HashWithIndifferentAccess
# response.parsed_body # => {"id"=>42, "title"=>"Title"}
#
# assert_pattern { response.parsed_body => [{ title: /title/i }] }
#
# response.parsed_body => {id:, title:}
# assert_equal 42, id
# assert_equal "Title", title
def parsed_body
@parsed_body ||= response_parser.call(body)
end
Expand Down
7 changes: 7 additions & 0 deletions actionpack/test/dispatch/test_response_test.rb
Expand Up @@ -25,6 +25,7 @@ def assert_response_code_range(range, predicate)
assert_equal response.body, response.parsed_body

response = ActionDispatch::TestResponse.create(200, { "Content-Type" => "application/json" }, '{ "foo": "fighters" }')
assert_kind_of ActiveSupport::HashWithIndifferentAccess, response.parsed_body
assert_equal({ "foo" => "fighters" }, response.parsed_body)

response = ActionDispatch::TestResponse.create(200, { "Content-Type" => "text/html" }, <<~HTML)
Expand All @@ -38,4 +39,10 @@ def assert_response_code_range(range, predicate)
assert_kind_of(Nokogiri::XML::Document, response.parsed_body)
assert_equal(response.parsed_body.at_xpath("/html/body/div").text, "Content")
end

if RUBY_VERSION >= "3.1"
require_relative "./test_response_test/pattern_matching_test_cases"

include PatternMatchingTestCases
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true

module TestResponseTest::PatternMatchingTestCases
extend ActiveSupport::Concern

included do
test "JSON response Hash pattern matching" do
response = ActionDispatch::TestResponse.create(200, { "Content-Type" => "application/json" }, '{ "foo": "fighters" }')

# rubocop:disable Lint/Syntax
assert_pattern { response.parsed_body => { foo: /fighter/ } }
# rubocop:enable Lint/Syntax
end

test "JSON response Array pattern matching" do
response = ActionDispatch::TestResponse.create(200, { "Content-Type" => "application/json" }, '[{ "foo": "fighters" }, { "nir": "vana" }]')
# rubocop:disable Lint/Syntax
assert_pattern { response.parsed_body => [{ foo: /fighter/ }, { nir: /vana/ }] }
# rubocop:enable Lint/Syntax
end

test "HTML response pattern matching" do
response = ActionDispatch::TestResponse.create(200, { "Content-Type" => "text/html" }, <<~HTML)
<html>
<head></head>
<body>
<main><h1>Some main content</h1></main>
</body>
</html>
HTML
html = response.parsed_body

# rubocop:disable Lint/Syntax
html.at("main") => {name:, content:}
# rubocop:enable Lint/Syntax

assert_equal "main", name
assert_equal "Some main content", content

# rubocop:disable Lint/Syntax
assert_pattern { html.at("main") => { content: "Some main content" } }
assert_pattern { html.at("main") => { content: /content/ } }
assert_pattern { html.at("main") => { children: [{ name: "h1", content: /content/ }] } }
# rubocop:enable Lint/Syntax
end
end
end
12 changes: 6 additions & 6 deletions activestorage/test/controllers/direct_uploads_controller_test.rb
Expand Up @@ -138,11 +138,11 @@ class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::Integrati
test "creating new direct upload" do
checksum = OpenSSL::Digest::MD5.base64digest("Hello")
metadata = {
"foo": "bar",
"my_key_1": "my_value_1",
"my_key_2": "my_value_2",
"platform": "my_platform",
"library_ID": "12345"
"foo" => "bar",
"my_key_1" => "my_value_1",
"my_key_2" => "my_value_2",
"platform" => "my_platform",
"library_ID" => "12345"
}

post rails_direct_uploads_url, params: { blob: {
Expand All @@ -153,7 +153,7 @@ class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::Integrati
assert_equal "hello.txt", details["filename"]
assert_equal 6, details["byte_size"]
assert_equal checksum, details["checksum"]
assert_equal metadata, details["metadata"].deep_transform_keys(&:to_sym)
assert_equal metadata, details["metadata"]
assert_equal "text/plain", details["content_type"]
assert_match(/rails\/active_storage\/disk/, details["direct_upload"]["url"])
assert_equal({ "Content-Type" => "text/plain" }, details["direct_upload"]["headers"])
Expand Down

0 comments on commit 0f4ab82

Please sign in to comment.