From 3a3330eaf6862c99ac36efbc80671486b48060e1 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Sun, 3 May 2026 23:12:10 -0600 Subject: [PATCH 1/6] Add failing spec: router merges any body into params regardless of Content-Type --- spec/integration/hanami/router/params_spec.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/integration/hanami/router/params_spec.rb b/spec/integration/hanami/router/params_spec.rb index 5c785bbe..88156874 100644 --- a/spec/integration/hanami/router/params_spec.rb +++ b/spec/integration/hanami/router/params_spec.rb @@ -118,6 +118,18 @@ end end + context "JSON payload without body parser" do + # See: https://github.com/hanami/router/issues/237 + it "doesn't merge a JSON body into params" do + input = JSON.generate("foo" => "bar") + env = Rack::MockRequest.env_for("/submit?from=ui", method: "POST", params: input) + env["CONTENT_TYPE"] = "application/json" + subject.call(env) + + expect(env["router.params"]).to eq(from: "ui") + end + end + context "priority" do it "gives first level priority to path variables" do expected = "23" From 25c2f1df935f006e3479394562166bb9643ed974 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Sun, 3 May 2026 20:20:35 -0600 Subject: [PATCH 2/6] Only parse_nested_query for urlencoded form submissions --- lib/hanami/router.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/hanami/router.rb b/lib/hanami/router.rb index 5220a4d0..eb4c0f7d 100644 --- a/lib/hanami/router.rb +++ b/lib/hanami/router.rb @@ -1040,7 +1040,7 @@ def _params(env, params) params ||= {} env[PARAMS] ||= {} - if !env.key?(ROUTER_PARSED_BODY) && (input = env[::Rack::RACK_INPUT]) and input.rewind + if !env.key?(ROUTER_PARSED_BODY) && _form_urlencoded?(env) && (input = env[::Rack::RACK_INPUT]) and input.rewind env[PARAMS].merge!(::Rack::Utils.parse_nested_query(input.read)) input.rewind end @@ -1052,6 +1052,12 @@ def _params(env, params) env end + # @since x.x.x + # @api private + def _form_urlencoded?(env) + ::Rack::Request.new(env).media_type == "application/x-www-form-urlencoded" + end + # @since 2.0.0 # @api private def _not_allowed_fixed(env) From 866895be134f3b3410adea37aa3482b93caa5840 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Sun, 3 May 2026 20:51:18 -0600 Subject: [PATCH 3/6] Extract constant for media type --- lib/hanami/router.rb | 2 +- lib/hanami/router/constants.rb | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/hanami/router.rb b/lib/hanami/router.rb index eb4c0f7d..63c0b685 100644 --- a/lib/hanami/router.rb +++ b/lib/hanami/router.rb @@ -1055,7 +1055,7 @@ def _params(env, params) # @since x.x.x # @api private def _form_urlencoded?(env) - ::Rack::Request.new(env).media_type == "application/x-www-form-urlencoded" + ::Rack::Request.new(env).media_type == FORM_URLENCODED_MEDIA_TYPE end # @since 2.0.0 diff --git a/lib/hanami/router/constants.rb b/lib/hanami/router/constants.rb index 0cc903c2..99acf7e4 100644 --- a/lib/hanami/router/constants.rb +++ b/lib/hanami/router/constants.rb @@ -5,5 +5,9 @@ class Router # @api private # @since 2.0.0 ROUTER_PARSED_BODY = "router.parsed_body" + + # @api private + # @since x.x.x + FORM_URLENCODED_MEDIA_TYPE = "application/x-www-form-urlencoded" end end From 948c9281701e0442170675c45e7a8b5298e9bb4e Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Sun, 3 May 2026 23:10:17 -0600 Subject: [PATCH 4/6] Refactor into helper method, use guard clauses --- lib/hanami/router.rb | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/hanami/router.rb b/lib/hanami/router.rb index 63c0b685..1045126c 100644 --- a/lib/hanami/router.rb +++ b/lib/hanami/router.rb @@ -1040,11 +1040,7 @@ def _params(env, params) params ||= {} env[PARAMS] ||= {} - if !env.key?(ROUTER_PARSED_BODY) && _form_urlencoded?(env) && (input = env[::Rack::RACK_INPUT]) and input.rewind - env[PARAMS].merge!(::Rack::Utils.parse_nested_query(input.read)) - input.rewind - end - + _merge_form_urlencoded_body!(env) env[PARAMS].merge!(::Rack::Utils.parse_nested_query(env[::Rack::QUERY_STRING])) env[PARAMS].merge!(params) env[PARAMS] = Params.deep_symbolize(env[PARAMS]) @@ -1052,6 +1048,18 @@ def _params(env, params) env end + # @since x.x.x + # @api private + def _merge_form_urlencoded_body!(env) + return if env.key?(ROUTER_PARSED_BODY) + return unless _form_urlencoded?(env) + return unless (input = env[::Rack::RACK_INPUT]) + + input.rewind + env[PARAMS].merge!(::Rack::Utils.parse_nested_query(input.read)) + input.rewind # leave the stream readable for downstream consumers + end + # @since x.x.x # @api private def _form_urlencoded?(env) From e297be08167b5328f1aca29ea0a284eb1e38024e Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Mon, 4 May 2026 22:33:00 -0600 Subject: [PATCH 5/6] Add spec for multipart payload --- spec/integration/hanami/router/params_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/integration/hanami/router/params_spec.rb b/spec/integration/hanami/router/params_spec.rb index 88156874..c3aae785 100644 --- a/spec/integration/hanami/router/params_spec.rb +++ b/spec/integration/hanami/router/params_spec.rb @@ -130,6 +130,16 @@ end end + context "multipart payload without body parser" do + # See: https://github.com/hanami/router/issues/296 + it "doesn't merge a multipart body into params" do + env, _contents = multipart_fixture("foo.xml") + subject.call(env) + + expect(env["router.params"]).to eq({}) + end + end + context "priority" do it "gives first level priority to path variables" do expected = "23" From 0cbfee2802d667c6522bfff80333d0c674d8ee56 Mon Sep 17 00:00:00 2001 From: Sean Collins Date: Tue, 12 May 2026 00:20:40 -0600 Subject: [PATCH 6/6] Detect form-urlencoded via env["CONTENT_TYPE"], not Rack::Request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constructing `Rack::Request.new(env)` just to read `#media_type` ran on every request — including GETs that have no body to parse — costing one allocation per check (plus four more on POSTs once `#media_type` parses the header). Read `env["CONTENT_TYPE"]` directly instead: exact match for the bare type, `start_with?("...;")` for the params form. This also lets the helper short-circuit before touching `rack.input`. Previously, main called `input.rewind` unconditionally inside the body parse guard; the new check skips that work whenever `CONTENT_TYPE` is absent or non-form, so GETs no longer pay the rewind cost at all. Measured locally: the form-urlencoded check is 3-6x faster across GET, form POST, and JSON POST shapes, with zero allocations on GETs and bare form POSTs (down from 1 and 5 respectively). --- lib/hanami/router.rb | 6 +++++- lib/hanami/router/constants.rb | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/hanami/router.rb b/lib/hanami/router.rb index 1045126c..2eafe03b 100644 --- a/lib/hanami/router.rb +++ b/lib/hanami/router.rb @@ -1063,7 +1063,11 @@ def _merge_form_urlencoded_body!(env) # @since x.x.x # @api private def _form_urlencoded?(env) - ::Rack::Request.new(env).media_type == FORM_URLENCODED_MEDIA_TYPE + content_type = env[CONTENT_TYPE] + return false unless content_type + + content_type == FORM_URLENCODED_MEDIA_TYPE || + content_type.start_with?("#{FORM_URLENCODED_MEDIA_TYPE};") end # @since 2.0.0 diff --git a/lib/hanami/router/constants.rb b/lib/hanami/router/constants.rb index 99acf7e4..c46d4175 100644 --- a/lib/hanami/router/constants.rb +++ b/lib/hanami/router/constants.rb @@ -6,6 +6,10 @@ class Router # @since 2.0.0 ROUTER_PARSED_BODY = "router.parsed_body" + # @api private + # @since x.x.x + CONTENT_TYPE = "CONTENT_TYPE" + # @api private # @since x.x.x FORM_URLENCODED_MEDIA_TYPE = "application/x-www-form-urlencoded"