Repository: wojtekmach/req
Pull request #489 (2025-07-10 15:41:19)

fix: fully encode path_params even with reserved characters
Fixes #488 

This pull request enhances URI encoding in the `Req` library by ensuring reserved characters are properly escaped and adds corresponding test cases to verify this behavior.

### Improvements to URI encoding:

* [`lib/req/steps.ex`](diffhunk://#diff-07dc6fd4ec95415d610a3e3ddf9374540d6d64d02eaf42df7a233d6f05b32cc1L547-R547): Updated the `URI.encode` call to use the `URI.char_unreserved?/1` function, ensuring proper handling of reserved characters during URI encoding.

### Additional test coverage:

* [`test/req/steps_test.exs`](diffhunk://#diff-dc476e4c9226d38862002c18df4835a1d51d98448df84548cd4e144fae0ec2e9R332-R346): Added a new test, `put_path_params properly escapes reserved characters`, to validate that reserved characters (e.g., `#`) are correctly escaped in both default and curly path parameter styles.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9de113..5c1d261 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # CHANGELOG
 
+## v0.5.15 (2025-07-14)
+
+  * [`Req.Response`]: Add [`Req.Response.to_map/1`].
+
 ## v0.5.14 (2025-07-02)
 
   * [`run_plug`]: Remove warning about `into: fun` with `{:halt, acc}` result.
@@ -1331,6 +1335,7 @@ See "Adapter" section in `Req.Request` module documentation for more information
 [`Req.Response.get_header/2`]:     https://hexdocs.pm/req/Req.Response.html#get_response/2
 [`Req.Response.delete_header/2`]:  https://hexdocs.pm/req/Req.Response.html#delete_header/2
 [`Req.Response.update_private/4`]: https://hexdocs.pm/req/Req.Response.html#update_private/4
+[`Req.Response.to_map/1`]:         https://hexdocs.pm/req/Req.Response.html#to_map/1
 [`Req.Response.Async`]:            https://hexdocs.pm/req/Req.Response.Async.html
 
 [`Req.Test`]: https://hexdocs.pm/req/Req.Test.html
diff --git a/lib/req/response.ex b/lib/req/response.ex
index 51e4bf9..36deb6b 100644
--- a/lib/req/response.ex
+++ b/lib/req/response.ex
@@ -71,6 +71,39 @@ defmodule Req.Response do
     new(Map.new(options))
   end
 
+  @doc """
+  Converts response to a map for interoperability with other libraries.
+
+  The resulting map has the folowing fields:
+
+    * `:status`
+    * `:headers`
+    * `:trailers`
+    * `:body`
+
+  Note, `body` can be any term since Req built-in and custom steps usually transform it.
+
+  ## Examples
+
+      iex> resp = Req.Response.new(status: 200, headers: %{"server" => ["test"]}, body: "hello")
+      iex> Req.Response.to_map(resp)
+      %{status: 200, body: "hello", headers: [{"server", "test"}], trailers: []}
+  """
+  @spec to_map(t()) :: %{
+          status: non_neg_integer(),
+          headers: [{binary(), binary()}],
+          trailers: [{binary(), binary()}],
+          body: term()
+        }
+  def to_map(%Req.Response{} = resp) do
+    %{
+      status: resp.status,
+      headers: Req.Fields.get_list(resp.headers),
+      trailers: Req.Fields.get_list(resp.trailers),
+      body: resp.body
+    }
+  end
+
   @doc """
   Builds or updates a response with JSON body.
 
diff --git a/lib/req/steps.ex b/lib/req/steps.ex
index 5d67d0f..d3defa5 100644
--- a/lib/req/steps.ex
+++ b/lib/req/steps.ex
@@ -544,7 +544,7 @@ defmodule Req.Steps do
         Regex.replace(regex, path, fn match, key ->
           case params[String.to_existing_atom(key)] do
             nil -> match
-            value -> value |> to_string() |> URI.encode()
+            value -> value |> to_string() |> URI.encode(&URI.char_unreserved?/1)
           end
         end)
     end)
@@ -1493,7 +1493,7 @@ defmodule Req.Steps do
   ## Options
 
     * `:raw` - if set to `true`, disables response body decompression. Defaults to `false`.
-    
+
       Note: setting `raw: true` also disables response body decoding in the `decode_body/1` step.
 
   ## Examples
diff --git a/mix.exs b/mix.exs
index 668aa28..1e6881f 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,7 +1,7 @@
 defmodule Req.MixProject do
   use Mix.Project
 
-  @version "0.5.14"
+  @version "0.5.15"
   @source_url "https://github.com/wojtekmach/req"
 
   def project do


Test patch:
diff --git a/test/req/steps_test.exs b/test/req/steps_test.exs
index f6fa593..3bb5281 100644
--- a/test/req/steps_test.exs
+++ b/test/req/steps_test.exs
@@ -329,6 +329,21 @@ defmodule Req.StepsTest do
     assert URI.to_string(req.url) == "http://foo/abc%7Cdef:bar"
   end
 
+  test "put_path_params properly escapes reserved characters" do
+    req =
+      Req.new(url: "http://foo/:id{ola}", path_params: [id: "abc#def"]) |> Req.Request.prepare()
+
+    assert URI.to_string(req.url) == "http://foo/abc%23def{ola}"
+
+    # With :curly style.
+
+    req =
+      Req.new(url: "http://foo/{id}:bar", path_params: [id: "abc#def"], path_params_style: :curly)
+      |> Req.Request.prepare()
+
+    assert URI.to_string(req.url) == "http://foo/abc%23def:bar"
+  end
+
   test "put_range" do
     req = Req.new(range: "bytes=0-10") |> Req.Request.prepare()
     assert Req.Request.get_header(req, "range") == ["bytes=0-10"]