Repository: wojtekmach/req
Pull request #88 (2022-04-22 23:10:51)

New API
Major changes:

  * Add high-level functional API: `Req.new(options) |> Req.request(options)`

  * Add `Req.Request.options` field that steps can read from. Also, make
    all steps be arity 1.

    When using "High-level" API, we now run all steps by default. (The
    steps, by looking at `request.options`, can decide to be no-op.)

  * Remove `put_default_steps/2` step.

  * Remove `run_steps/2` step.

Deprecations:

  * Deprecate calling `Req.post!(url, body)` in favour of `Req.post(url,
    body: body)`. Same for `Req.put!`.

  * Deprecate setting `retry: [delay: delay, max_retries: max_retries]`
    in favour of `[retry_delay: delay, max_retries: max_retries]`.

  * Deprecate setting `follow_redirects: [location_trusted: trusted]`
    in favour of `[location_trusted: trusted]`.

  * Deprecate setting `cache: [dir: dir]` in favour of `cache_dir: dir`.

  * Deprecate `Req.Request.build/3` in favour of `Req.Request.new/1`.


jonatanklosko: Exciting! :cat:

Patch:
diff --git a/README.md b/README.md
index 8dd5ccb..c0923e2 100644
--- a/README.md
+++ b/README.md
@@ -10,26 +10,28 @@ Req is an HTTP client with a focus on ease of use and composability, built on to
 
 ## Features
 
-  * Extensibility via request, response, and error steps
+  * An easy to use high-level API: `Req`, `Req.request/1`, `Req.get!/2`, `Req.post!/2`, etc.
+
+  * Extensibility via request, response, and error steps.
 
   * Automatic body decompression (via [`decompress`](`Req.Steps.decompress/1`) step)
 
   * Automatic body encoding and decoding (via [`encode_body`](`Req.Steps.encode_body/1`)
     and [`decode_body`](`Req.Steps.decode_body/1`) steps)
 
-  * Encode params as query string (via [`put_params`](`Req.Steps.put_params/2`) step)
+  * Encode params as query string (via [`put_params`](`Req.Steps.put_params/1`) step)
 
-  * Basic, bearer and `.netrc` authentication (via [`auth`](`Req.Steps.auth/2`) step)
+  * Basic, bearer, and `.netrc` authentication (via [`auth`](`Req.Steps.auth/1`) step)
 
-  * Range requests (via [`put_range`](`Req.Steps.put_range/2`) step)
+  * Range requests (via [`put_range`](`Req.Steps.put_range/1`) step)
 
-  * Follows redirects (via [`follow_redirects`](`Req.Steps.follow_redirects/2`) step)
+  * Follows redirects (via [`follow_redirects`](`Req.Steps.follow_redirects/1`) step)
 
-  * Retries on errors (via [`retry`](`Req.Steps.retry/2`) step)
+  * Retries on errors (via [`retry`](`Req.Steps.retry/1`) step)
 
-  * Basic HTTP caching (via [`cache`](`Req.Steps.put_if_modified_since/2`) step)
+  * Basic HTTP caching (via [`cache`](`Req.Steps.put_if_modified_since/1`) step)
 
-  * Setting base URL (via [`put_base_url`](`Req.Steps.put_base_url/2`) step)
+  * Setting base URL (via [`put_base_url`](`Req.Steps.put_base_url/1`) step)
 
 ## Usage
 
@@ -37,26 +39,38 @@ The easiest way to use Req is with [`Mix.install/2`](https://hexdocs.pm/mix/Mix.
 
 ```elixir
 Mix.install([
-  {:req, "~> 0.2.0"}
+  {:req, "~> 0.3.0"}
 ])
 
 Req.get!("https://api.github.com/repos/elixir-lang/elixir").body["description"]
 #=> "Elixir is a dynamic, functional language designed for building scalable and maintainable applications"
 ```
 
-If you want to use Req in a Mix project, you can add the above
-dependency to your `mix.exs`.
+If you want to use Req in a Mix project, you can add the above dependency to your `mix.exs`.
 
-Example POST request:
+If you are planning to make several similar requests, you can build up a request struct with
+desired common options and re-use it:
 
 ```elixir
-Req.post!("https://httpbin.org/post", {:form, comments: "hello!"}).body["form"]
-#=> %{"comments" => "hello!"}
+req = Req.new(base_url: "https://api.github.com")
+
+Req.get!(req, url: "/repos/sneako/finch").body["description"]
+#=> "Elixir HTTP client, focused on performance"
+
+Req.get!(req, url: "/repos/elixir-mint/mint").body["description"]
+#=> "Functional HTTP client for Elixir with support for HTTP/1 and HTTP/2."
 ```
 
-## Low-level API
+See [`Req.request/1`](https://hexdocs.pm/req/Req.html#request/1) for more information on available
+options.
+
+## How Req Works
 
-Under the hood, Req works by passing a [`%Req.Request{}`](`Req.Request`) struct through a series of steps.
+Virtually all of Req's functionality is broken down into individual pieces - steps. Req works by
+running the request struct through these steps. You can easily reuse or rearrange built-in steps
+or write new ones.
+
+There are three types of steps: request, response, and error.
 
 Request steps are used to refine the data that will be sent to the server.
 
@@ -75,13 +89,23 @@ Req.get!("https://api.github.com/repos/elixir-lang/elixir")
 is equivalent to this composition of lower-level API functions and steps:
 
 ```elixir
-Req.Request.build(:get, "https://api.github.com/repos/elixir-lang/elixir")
-|> Req.Steps.put_default_steps()
+Req.Request.new(method: :get, url: "https://api.github.com/repos/elixir-lang/elixir")
+|> Req.Request.append_request_steps([
+  &Req.Steps.encode_headers/1,
+  &Req.Steps.put_default_headers/1,
+  # ...
+])
+|> Req.Request.append_response_steps([
+  &Req.Steps.retry/1,
+  &Req.Steps.follow_redirects/1,
+  # ...
+|> Req.Request.append_error_steps([
+  &Req.Steps.retry/1,
+  # ...
+])
 |> Req.Request.run!()
 ```
 
-(See `Req.Request.build/3`, `Req.Steps.put_default_steps/2`, and `Req.Request.run!/1` for more information.)
-
 We can also build more complex flows like returning a response from a request step
 or an error from a response step. See `Req.Request` documentation for more information.
 
diff --git a/lib/req.ex b/lib/req.ex
index 40a940d..fd7c061 100644
--- a/lib/req.ex
+++ b/lib/req.ex
@@ -1,101 +1,411 @@
 defmodule Req do
   @external_resource "README.md"
 
-  @moduledoc "README.md"
-             |> File.read!()
-             |> String.split("<!-- MDOC !-->")
-             |> Enum.fetch!(1)
+  @moduledoc """
+  The high-level API.
+
+  Req is composed of three main pieces:
+
+    * `Req` - the high-level API (you're here!)
+
+    * `Req.Request` - the low-level API and the request struct
+
+    * `Req.Steps` - the collection of built-in steps
+
+  The high-level API is what most users of Req will use most of the time.
+
+  ## Examples
+
+  Making a GET request with `Req.get!/1`:
+
+      iex> Req.get!("https://api.github.com/repos/elixir-lang/elixir").body["description"]
+      "Elixir is a dynamic, functional language designed for building scalable and maintainable applications"
+
+  Same, but by explicitly building request struct first:
+
+      iex> req = Req.new(base_url: "https://api.github.com")
+      iex> Req.get!(req, url: "/repos/elixir-lang/elixir").body["description"]
+      "Elixir is a dynamic, functional language designed for building scalable and maintainable applications"
+
+  Making a POST request with `Req.post!/2`:
+
+      iex> Req.post!("https://httpbin.org/post", body: {:form, comments: "hello!"}).body["form"]
+      %{"comments" => "hello!"}
+  """
 
   @type url() :: URI.t() | String.t()
 
   @type method() :: :get | :post | :put | :delete
 
+  @doc """
+  Returns a new request struct with built-in steps.
+
+  See `request/1` for a list of available options. See `Req.Request` module documentation
+  for more information on the underlying request struct.
+
+  ## Examples
+
+      iex> req = Req.new(url: "https://elixir-lang.org")
+      iex> req.method
+      :get
+      iex> URI.to_string(req.url)
+      "https://elixir-lang.org"
+
+  """
+  @spec new(options :: keyword()) :: Req.Request.t()
+  def new(options \\ []) do
+    {adapter, options} = Keyword.pop(options, :adapter, &Req.Steps.run_finch/1)
+    {method, options} = Keyword.pop(options, :method, :get)
+    {url, options} = Keyword.pop(options, :url, nil)
+    {headers, options} = Keyword.pop(options, :headers, [])
+    {body, options} = Keyword.pop(options, :body, "")
+    options = Map.new(options)
+
+    %Req.Request{
+      adapter: adapter,
+      method: method,
+      url: url && URI.parse(url),
+      headers: headers,
+      body: body,
+      options: options,
+      request_steps: [
+        &Req.Steps.encode_headers/1,
+        &Req.Steps.put_default_headers/1,
+        &Req.Steps.encode_body/1,
+        &Req.Steps.put_base_url/1,
+        &Req.Steps.auth/1,
+        &Req.Steps.put_params/1,
+        &Req.Steps.put_range/1,
+        &Req.Steps.put_if_modified_since/1
+      ],
+      response_steps: [
+        &Req.Steps.retry/1,
+        &Req.Steps.follow_redirects/1,
+        &Req.Steps.decompress/1,
+        &Req.Steps.decode_body/1
+      ],
+      error_steps: [
+        &Req.Steps.retry/1
+      ]
+    }
+  end
+
   @doc """
   Makes a GET request.
 
-  See `request/3` for a list of supported options.
+  See `request/1` for a list of supported options.
+
+  ## Examples
+
+  With URL:
+
+      iex> Req.get!("https://api.github.com/repos/elixir-lang/elixir").body["description"]
+      "Elixir is a dynamic, functional language designed for building scalable and maintainable applications"
+
+  With request struct:
+
+      iex> req = Req.new(base_url: "https://api.github.com")
+      iex> Req.get!(req, url: "/repos/elixir-lang/elixir").status
+      200
+
   """
-  @spec get!(url(), keyword()) :: Req.Response.t()
-  def get!(url, options \\ []) do
-    request!(:get, url, options)
+  @spec get!(url() | Req.Request.t(), options :: keyword()) :: Req.Response.t()
+  def get!(url_or_request, options \\ [])
+
+  def get!(%Req.Request{} = request, options) do
+    request!(%{request | method: :get}, options)
+  end
+
+  def get!(url, options) do
+    request!([method: :get, url: URI.parse(url)] ++ options)
   end
 
   @doc """
   Makes a POST request.
 
-  See `request/3` for a list of supported options.
+  See `request/1` for a list of supported options.
+
+  ## Examples
+
+  With URL:
+
+      iex> Req.post!("https://httpbin.org/post", body: {:form, comments: "hello!"}).body["form"]
+      %{"comments" => "hello!"}
+
+  With request struct:
+
+      iex> req = Req.new(url: "https://httpbin.org/post")
+      iex> Req.post!(req, body: {:form, comments: "hello!"}).body["form"]
+      %{"comments" => "hello!"}
   """
-  @spec post!(url(), body :: term(), keyword()) :: Req.Response.t()
-  def post!(url, body, options \\ []) do
-    options = Keyword.put(options, :body, body)
-    request!(:post, url, options)
+  @spec post!(url() | Req.Request.t(), options :: keyword()) :: Req.Response.t()
+  def post!(url_or_request, options \\ [])
+
+  def post!(%Req.Request{} = request, options) do
+    request!(%{request | method: :post}, options)
+  end
+
+  def post!(url, options) do
+    if Keyword.keyword?(options) do
+      request!([method: :post, url: URI.parse(url)] ++ options)
+    else
+      IO.warn("Req.post!(url, body) is deprecated in favour of Req.post!(url, body: body)")
+      request!(method: :post, url: URI.parse(url), body: options)
+    end
+  end
+
+  @doc false
+  def post!(url, body, options) do
+    IO.warn(
+      "Req.post!(url, body, options) is deprecated in favour of " <>
+        "Req.post!(url, [body: body] ++ options)"
+    )
+
+    request!([method: :post, url: URI.parse(url), body: body] ++ options)
   end
 
   @doc """
   Makes a PUT request.
 
-  See `request/3` for a list of supported options.
+  See `request/1` for a list of supported options.
+
+  ## Examples
+
+  With URL:
+
+      iex> Req.put!("https://httpbin.org/anything", body: "the body").body |> Map.take(["data", "method"])
+      %{"data" => "the body", "method" => "PUT"}
+
+  With request struct:
+
+      iex> req = Req.new(url: "https://httpbin.org/anything")
+      iex> Req.put!(req, body: "the body").body |> Map.take(["data", "method"])
+      %{"data" => "the body", "method" => "PUT"}
+
   """
-  @spec put!(url(), body :: term(), keyword()) :: Req.Response.t()
-  def put!(url, body, options \\ []) do
-    options = Keyword.put(options, :body, body)
-    request!(:put, url, options)
+  @spec put!(url() | Req.Request.t(), options :: keyword()) :: Req.Response.t()
+  def put!(url_or_request, options \\ [])
+
+  def put!(%Req.Request{} = request, options) do
+    request!(%{request | method: :put}, options)
+  end
+
+  def put!(url, options) do
+    if Keyword.keyword?(options) do
+      request!([method: :put, url: URI.parse(url)] ++ options)
+    else
+      IO.warn("Req.put!(url, body) is deprecated in favour of Req.put!(url, body: body)")
+      request!(url: URI.parse(url), body: options)
+    end
+  end
+
+  @doc false
+  def put!(%URI{} = url, {type, _} = body, options) when type in [:form, :json] do
+    IO.warn(
+      "Req.put!(url, {:#{type}, #{type}}, options) is deprecated in favour of " <>
+        "Req.put!(url, [body: {:#{type}, #{type}}] ++ options)"
+    )
+
+    request!([method: :put, url: url, body: body] ++ options)
   end
 
   @doc """
   Makes a DELETE request.
 
-  See `request/3` for a list of supported options.
+  See `request/1` for a list of supported options.
   """
-  @spec delete!(url(), keyword()) :: Req.Response.t()
+  @spec delete!(url(), options :: keyword()) :: Req.Response.t()
   def delete!(url, options \\ []) do
-    request!(:delete, url, options)
+    request!([method: :delete, url: url] ++ options)
   end
 
   @doc """
   Makes an HTTP request.
 
+  `request/1` and `request/2` functions give three ways of making requests:
+
+    1. With a list of options, for example:
+
+       ```
+       Req.request(url: url)
+       ```
+
+    2. With a request struct, for example:
+
+       ```
+       Req.new(url: url) |> Req.request()
+       ```
+
+    3. With a request struct and more options, for example:
+
+       ```
+       Req.new(base_url: base_url) |> Req.request(url: url)
+       ```
+
+  This function as well as all the other ones in this module accept the same set of options described below.
+
   ## Options
 
-    * `:headers` - request headers, defaults to `[]`
+  Basic request options:
+
+    * `:method` - the request method, defaults to `:get`.
+
+    * `:url` - the request URL.
+
+    * `:headers` - the request headers. The headers are automatically encoded using the
+      [`encode_headers`](`Req.Steps.encode_headers/1`) step.
+
+    * `:body` - the request body. The body is automatically encoded using the
+      [`encode_body`](`Req.Steps.encode_body/1`) step.
+
+  Additional URL options:
+
+    * `:base_url` - if set, the URL is prepended with this base URL (via
+      [`put_base_url`](`Req.Steps.put_base_url/1`) step).
+
+    * `:params` - if set, appends parameters to the request query string (via
+      [`put_params`](`Req.Steps.put_params/1`) step).
+
+  Authentication options:
+
+    * `:auth` - sets request authentication (via [`auth`](`Req.Steps.auth/1`) step).
+
+  Response body options:
+
+    * `:raw` - if set to `true`, disables automatic body decompression
+      ([`decompress`](`Req.Steps.decompress/1`) step) and and decoding
+      ([`decode_body`](`Req.Steps.decode_body/1`) step). Defaults to `false`.
+
+  Response redirect options ([`follow_redirects`](`Req.Steps.follow_redirects/1`) step):
+
+    * `:follow_redirects` - if set to `false`, disables automatic response redirects. Defaults to `true`.
+
+    * `:max_redirects` - the maximum number of redirects. Defaults to `3`.
+
+  Caching options ([`put_if_modified_since`](`Req.Steps.put_if_modified_since/1`) step):
+
+    * `:cache` - if `true`, performs caching. Defaults to `false`.
+
+    * `:cache_dir` - the directory to store the cache, defaults to `<user_cache_dir>/req`
+      (see: `:filename.basedir/3`)
+
+  Request adapter:
+
+    * `:adapter` - adapter to use to make the actual HTTP request. See `:adapter` field description
+      in the `Req.Request` module documentation for more information. Defaults to calling [`run_finch`](`Req.Steps.run_finch/1`).
+
+  Finch options ([`run_finch`](`Req.Steps.run_finch/1`) step)
+
+    * `:finch` - the `Finch` pool to use. Defaults to `Req.Finch` which is automatically started by `Req`.
 
-    * `:body` - request body, defaults to `""`
+    * `:finch_options` - options passed down to Finch when making the request, defaults to `[]`.
+       See `Finch.request/3` for a list of available options.
 
-    * `:finch` - Finch pool to use, defaults to `Req.Finch` which is automatically started
-      by the application. See `Finch` module documentation for more information on starting pools.
+  ## Examples
 
-    * `:finch_options` - Options passed down to Finch when making the request, defaults to `[]`.
-      See `Finch.request/3` for more information.
+  With options keywords list:
 
-  The `options` are passed down to `Req.Steps.put_default_steps/2`, see its documentation for more
-  information how they are being used.
+      iex> {:ok, response} = Req.request(url: "https://api.github.com/repos/elixir-lang/elixir")
+      iex> response.status
+      200
+      iex> response.body
+      "Elixir is a dynamic, functional language designed for building scalable and maintainable applications"
 
-  The `options` are merged with default options set with `default_options/1`.
+  With request struct:
+
+      iex> req = Req.new(url: "https://api.github.com/repos/elixir-lang/elixir")
+      iex> {:ok, response} = Req.request(req)
+      iex> response.status
+      200
+
+  With request struct and options:
+
+      iex> req = Req.new(base_url: "https://api.github.com")
+      iex> {:ok, response} = Req.request(req, url: "/repos/elixir-lang/elixir")
+      iex> response.status
+      200
+  """
+  @spec request(Req.Request.t() | keyword()) ::
+          {:ok, Req.Response.t()} | {:error, Exception.t()}
+  def request(request_or_options)
+
+  def request(%Req.Request{} = request) do
+    request(request, [])
+  end
+
+  def request(options) do
+    request(Req.new(options), [])
+  end
+
+  @doc """
+  akes an HTTP request.
+
+  See `request/1` for more information.
   """
-  @spec request(method(), url(), keyword()) ::
+  @spec request(Req.Request.t(), options :: keyword()) ::
           {:ok, Req.Response.t()} | {:error, Exception.t()}
-  def request(method, url, options \\ []) do
+  def request(request, options) do
     options = Keyword.merge(default_options(), options)
 
-    method
-    |> Req.Request.build(url, options)
-    |> Req.Steps.put_default_steps(options)
-    |> Req.Request.run()
+    {request_options, options} = Keyword.split(options, [:method, :url, :headers, :body])
+
+    request =
+      Map.merge(request, Map.new(request_options), fn
+        :url, _, url -> URI.parse(url)
+        # TODO: merge headers
+        :headers, _, headers -> headers
+        _, _, value -> value
+      end)
+
+    request = update_in(request.options, &Map.merge(&1, Map.new(options)))
+    Req.Request.run(request)
+  end
+
+  @doc false
+  def request(method, url, options) do
+    IO.warn(
+      "Req.request(method, url, options) is deprecated in favour of " <>
+        "Req.request!([method: method, url: url] ++ options)"
+    )
+
+    request([method: method, url: URI.parse(url)] ++ options)
   end
 
   @doc """
   Makes an HTTP request and returns a response or raises an error.
 
-  See `request/3` for more information.
+  See `request/1` for more information.
+
+  ## Examples
+
+      iex> Req.request!(url: "https://api.github.com/repos/elixir-lang/elixir").status
+      200
   """
-  @spec request!(method(), url(), keyword()) :: Req.Response.t()
-  def request!(method, url, options \\ []) do
-    options = Keyword.merge(default_options(), options)
+  @spec request!(Req.Request.t() | keyword()) :: Req.Response.t()
+  def request!(request_or_options) do
+    case request(request_or_options) do
+      {:ok, response} -> response
+      {:error, exception} -> raise exception
+    end
+  end
 
-    method
-    |> Req.Request.build(url, options)
-    |> Req.Steps.put_default_steps(options)
-    |> Req.Request.run!()
+  @doc """
+  Makes an HTTP request and returns a response or raises an error.
+
+  See `request/1` for more information.
+
+  ## Examples
+
+      iex> Req.new(base_url: "https://api.github.com")
+      iex> Req.request!(req, url: "/repos/elixir-lang/elixir").status
+      200
+  """
+  @spec request!(Req.Request.t(), options :: keyword()) :: Req.Response.t()
+  def request!(request, options) do
+    case request(request, options) do
+      {:ok, response} -> response
+      {:error, exception} -> raise exception
+    end
   end
 
   @doc """
@@ -111,8 +421,7 @@ defmodule Req do
   @doc """
   Sets default options.
 
-  The default options are used by `get!/2`, `post!/3`, `put!/3`,
-  `delete!/2`, `request/3`, and `request!/3` functions.
+  The default options are used by `request/1`, `get!/2`, and similar functions.
 
   Avoid setting default options in libraries as they are global.
   """
diff --git a/lib/req/request.ex b/lib/req/request.ex
index 4f214dc..81e4107 100644
--- a/lib/req/request.ex
+++ b/lib/req/request.ex
@@ -1,8 +1,21 @@
 defmodule Req.Request do
   @moduledoc ~S"""
-  The request pipeline struct.
+  The low-level API and the request struct.
 
-  Struct fields:
+  Req is composed of three main pieces:
+
+    * `Req` - the high-level API
+
+    * `Req.Request` - the low-level API and the request struct (you're here!)
+
+    * `Req.Steps` - the collection of built-in steps
+
+  The low-level API and the request struct is the foundation of Req's extensibility. Virtually all
+  of the functionality is broken down into individual pieces - steps. Req works by running the
+  request struct through these steps. You can easily reuse or rearrange built-in steps or write new
+  ones.
+
+  ## The request struct
 
     * `:method` - the HTTP request method
 
@@ -12,12 +25,6 @@ defmodule Req.Request do
 
     * `:body` - the HTTP request body
 
-    * `:adapter` - a request step that makes the actual HTTP request
-
-    * `:unix_socket` - if set, connect through the given UNIX domain socket
-
-    * `:halted` - whether the request pipeline is halted. See `halt/1`
-
     * `:request_steps` - the list of request steps
 
     * `:response_steps` - the list of response steps
@@ -28,9 +35,18 @@ defmodule Req.Request do
       Prefix the keys with the name of your project to avoid any future
       conflicts. Only accepts `t:atom/0` keys.
 
+    * `:halted` - whether the request pipeline is halted. See `halt/1`
+
+    * `:adapter` - a request step that makes the actual HTTP request. The adapter
+      is automatically added by Req as the very last request step. The adapter must
+      return `{request, response}` or `{request, exception}`, thus triggering the
+      response or error pipeline, respectively. Defaults to `Req.Steps.run_finch/1`.
+
+    * `:unix_socket` - if set, connect through the given UNIX domain socket
+
   ## Steps
 
-  Under the hood, Req works by passing a [`%Req.Request{}`](`Req.Request`) struct through a series of steps.
+  Req has three types of steps: request, response, and error.
 
   Request steps are used to refine the data that will be sent to the server.
 
@@ -113,20 +129,36 @@ defmodule Req.Request do
       end
   """
 
-  defstruct [
-    :method,
-    :url,
-    headers: [],
-    body: "",
-    adapter: {Req.Steps, :run_finch, []},
-    unix_socket: nil,
-    halted: false,
-    request_steps: [],
-    response_steps: [],
-    error_steps: [],
-    private: %{},
-    location_trusted: false
-  ]
+  @type t() :: %Req.Request{
+          method: :get | :post | :put | :head | :delete,
+          url: URI.t(),
+          headers: [{binary(), binary()}],
+          body: binary(),
+          options: keyword(),
+          adapter: request_step(),
+          request_steps: [request_step()],
+          response_steps: [response_step()],
+          error_steps: [error_step()],
+          private: map()
+        }
+
+  @typep request_step() :: fun()
+  @typep response_step() :: fun()
+  @typep error_step() :: fun()
+
+  defstruct method: :get,
+            url: nil,
+            headers: [],
+            body: "",
+            options: %{},
+            adapter: &Req.Steps.run_finch/1,
+            unix_socket: nil,
+            halted: false,
+            request_steps: [],
+            response_steps: [],
+            error_steps: [],
+            private: %{},
+            location_trusted: false
 
   @doc """
   Sets the request adapter.
@@ -160,26 +192,8 @@ defmodule Req.Request do
     %{request | halted: true}
   end
 
-  @doc """
-  Builds a request pipeline.
-
-  ## Options
-
-    * `:headers` - request headers, defaults to `[]`
-
-    * `:body` - request body, defaults to `""`
-
-    * `:adapter` - adapter to use to make the actual HTTP request. Adapters are functions
-    specified like any other request step, but the adapter function is the last step
-    executed in the request pipeline. Defaults to calling `Req.Steps.run_finch/1`.
-
-    * `:finch` - Finch pool to use, defaults to `Req.Finch` which is automatically started
-      by the application. See `Finch` module documentation for more information on starting pools.
-
-    * `:finch_options` - Options passed down to Finch when making the request, defaults to `[]`.
-      See `Finch.request/3` for more information.
-
-  """
+  @doc false
+  @deprecated "Use new/1 instead"
   def build(method, url, options \\ []) do
     %Req.Request{
       method: method,
diff --git a/lib/req/steps.ex b/lib/req/steps.ex
index e023d28..ecde19a 100644
--- a/lib/req/steps.ex
+++ b/lib/req/steps.ex
@@ -1,128 +1,43 @@
 defmodule Req.Steps do
   @moduledoc """
-  A collection of built-in steps.
-  """
-
-  require Logger
-
-  ## Request steps
-
-  @doc """
-  Adds default steps.
-
-  ## Request steps
-
-    * `encode_headers/1`
-
-    * `put_default_headers/1`
-
-    * `encode_body/1`
-
-    * [`&put_base_url(&1, options[:base_url])`](`base_url/2`) (if `options[:base_url]` is set)
-
-    * [`&auth(&1, options[:auth])`](`auth/2`) (if `options[:auth]` is set)
-
-    * [`&put_params(&1, options[:params])`](`put_params/2`) (if `options[:params]` is set)
-
-    * [`&put_range(&1, options[:range])`](`put_range/2`) (if `options[:range]` is set)
-
-    * [`&run_steps(&1, options[:steps])`](`run_steps/2`) (if `options[:steps]` is set)
-
-  ## Response steps
-
-    * [`&retry(&1, options[:retry])`](`retry/2`) (if `options[:retry]` is set to
-      an atom true or a options keywords list)
+  The collection of built-in steps.
 
-    * [`&follow_redirects(&1, options[:follow_redirects])`](`follow_redirects/2`)
+  Req is composed of three main pieces:
 
-    * `decompress/1`
+    * `Req` - the high-level API
 
-    * `decode_body/1`
-
-  ## Error steps
-
-    * [`&retry(&1, options[:retry])`](`retry/2`) (if `options[:retry]` is set and is a
-      keywords list or an atom `true`)
-
-  ## Options
-
-    * `:base_url` - if set, adds the `put_base_url/2` step
-
-    * `:auth` - if set, adds the `auth/2` step
-
-    * `:params` - if set, adds the `put_params/2` step
-
-    * `:range` - if set, adds the `put_range/2` step
-
-    * `:cache` - if set to `true`, adds `put_if_modified_since/2` step
-
-    * `:raw` if set to `true`, skips `decompress/1` and `decode_body/1` steps
-
-    * `:retry` - if set, adds the `retry/2` step to response and error steps
-
-    * `:steps` - if set, runs the `run_steps/2` step with the given steps
-
-    * `:follow_redirects` - if set, runs the `follow_redirect/2` step with the given list
-       of options
+    * `Req.Request` - the low-level API and the request struct
 
+    * `Req.Steps` - the collection of built-in steps (you're here!)
   """
-  @doc step: :request
-  def put_default_steps(request, options \\ []) do
-    request_steps =
-      [
-        {Req.Steps, :encode_headers, []},
-        {Req.Steps, :put_default_headers, []},
-        {Req.Steps, :encode_body, []}
-      ] ++
-        maybe_steps(options[:base_url], [{Req.Steps, :put_base_url, [options[:base_url]]}]) ++
-        maybe_steps(options[:auth], [{Req.Steps, :auth, [options[:auth]]}]) ++
-        maybe_steps(options[:params], [{Req.Steps, :put_params, [options[:params]]}]) ++
-        maybe_steps(options[:range], [{Req.Steps, :put_range, [options[:range]]}]) ++
-        maybe_steps(options[:cache], [{Req.Steps, :put_if_modified_since, []}]) ++
-        maybe_steps(options[:steps], [{Req.Steps, :run_steps, [options[:steps]]}])
-
-    retry = options[:retry]
-    retry = if retry == true, do: [], else: retry
-
-    raw? = options[:raw] == true
-
-    response_steps =
-      maybe_steps(retry, [{Req.Steps, :retry, [retry]}]) ++
-        [{Req.Steps, :follow_redirects, [options[:follow_redirects]]}] ++
-        maybe_steps(not raw?, [
-          {Req.Steps, :decompress, []},
-          {Req.Steps, :decode_body, []}
-        ])
-
-    error_steps = maybe_steps(retry, [{Req.Steps, :retry, [retry]}])
 
-    request
-    |> Req.Request.append_request_steps(request_steps)
-    |> Req.Request.append_response_steps(response_steps)
-    |> Req.Request.append_error_steps(error_steps)
-  end
+  require Logger
 
-  defp maybe_steps(nil, _step), do: []
-  defp maybe_steps(false, _step), do: []
-  defp maybe_steps(_, steps), do: steps
+  ## Request steps
 
   @doc """
   Sets base URL for all requests.
 
+  ## Request Options:
+
+    * `:base_url` - the base URL
+
   ## Examples
 
-      iex> options = [base_url: "https://httpbin.org"]
-      iex> Req.get!("/status/200", options).status
+      iex> req = Req.new(base_url: "https://httpbin.org")
+      iex> Req.get!(req, url: "/status/200").status
       200
-      iex> Req.get!("/status/201", options).status
+      iex> Req.get!(req, url: "/status/201").status
       201
   """
   @doc step: :request
-  def put_base_url(request, base_url) when is_binary(base_url) do
+  def put_base_url(request)
+
+  def put_base_url(%{options: %{base_url: base_url}} = request) do
     # TODO: change build/3 so that the url is parsed later so that here it is not yet parsed
 
     unless match?(%{scheme: nil, host: nil}, request.url) do
-      raise "put_base_url/2 expects the request url to only contain a path, got: #{URI.to_string(request.url)}"
+      raise "put_base_url/1 expects the request url to only contain a path, got: #{URI.to_string(request.url)}"
     end
 
     # remove when we require Elixir v1.13
@@ -132,15 +47,22 @@ defmodule Req.Steps do
     %{request | url: url}
   end
 
+  def put_base_url(request) do
+    request
+  end
+
   @doc """
   Sets request authentication.
 
   `auth` can be one of:
 
     * `{username, password}` - uses Basic HTTP authentication
+
     * `{:bearer, token}` - uses Bearer HTTP authentication
+
     * `:netrc` - load credentials from `.netrc` at path specified in `NETRC` environment variable;
       if `NETRC` is not set, load `.netrc` in user's home directory
+
     * `{:netrc, path}` - load credentials from `path`
 
   ## Examples
@@ -163,23 +85,29 @@ defmodule Req.Steps do
       200
   """
   @doc step: :request
-  def auth(request, auth)
+  def auth(request) do
+    auth(request, Map.get(request.options, :auth))
+  end
+
+  defp auth(request, nil) do
+    request
+  end
 
-  def auth(request, {:bearer, token}) when is_binary(token) do
+  defp auth(request, {:bearer, token}) when is_binary(token) do
     put_new_header(request, "authorization", "Bearer #{token}")
   end
 
-  def auth(request, {username, password}) when is_binary(username) and is_binary(password) do
+  defp auth(request, {username, password}) when is_binary(username) and is_binary(password) do
     value = Base.encode64("#{username}:#{password}")
     put_new_header(request, "authorization", "Basic #{value}")
   end
 
-  def auth(request, :netrc) do
+  defp auth(request, :netrc) do
     path = System.get_env("NETRC") || Path.join(System.user_home!(), ".netrc")
     authenticate_with_netrc(request, path)
   end
 
-  def auth(request, {:netrc, path}) do
+  defp auth(request, {:netrc, path}) do
     authenticate_with_netrc(request, path)
   end
 
@@ -253,6 +181,10 @@ defmodule Req.Steps do
       iex> Req.get!("https://httpbin.org/user-agent", headers: [user_agent: :my_agent]).body
       %{"user-agent" => "my_agent"}
 
+      iex> headers = [x_expires_at: ~N[2022-01-01 09:00:00]]
+      iex> Req.get!("https://httpbin.org/headers", headers: headers).body["headers"]["X-Expires-At"]
+      "Sat, 01 Jan 2022 09:00:00 GMT"
+
   """
   @doc step: :request
   def encode_headers(request) do
@@ -298,7 +230,7 @@ defmodule Req.Steps do
 
   ## Examples
 
-      iex> Req.post!("https://httpbin.org/post", {:form, comments: "hello!"}).body["form"]
+      iex> Req.post!("https://httpbin.org/post", body: {:form, comments: "hello!"}).body["form"]
       %{"comments" => "hello!"}
 
   """
@@ -323,14 +255,26 @@ defmodule Req.Steps do
   @doc """
   Adds params to request query string.
 
+  ## Request Options
+
+    * `:params` - params to add to the request query string. Defaults to `[]`.
+
   ## Examples
 
-      iex> Req.get!("https://httpbin.org/anything/query", params: [x: "1", y: "2"]).body["args"]
+      iex> Req.get!("https://httpbin.org/anything/query", params: [x: 1, y: 2]).body["args"]
       %{"x" => "1", "y" => "2"}
 
   """
   @doc step: :request
-  def put_params(request, params) do
+  def put_params(request) do
+    put_params(request, Map.get(request.options, :params, []))
+  end
+
+  defp put_params(request, []) do
+    request
+  end
+
+  defp put_params(request, params) do
     encoded = URI.encode_query(params)
 
     update_in(request.url.query, fn
@@ -342,11 +286,13 @@ defmodule Req.Steps do
   @doc """
   Sets the "Range" request header.
 
-  `range` can be one of the following:
+  ## Request Options
+
+    * `:range` - can be one of the following:
 
-    * a string - returned as is
+        * a string - returned as is
 
-    * a `first..last` range - converted to `"bytes=<first>-<last>"`
+        * a `first..last` range - converted to `"bytes=<first>-<last>"`
 
   ## Examples
 
@@ -359,16 +305,18 @@ defmodule Req.Steps do
       {"content-range", "bytes 0-3/100"}
   """
   @doc step: :request
-  def put_range(request, range)
-
-  def put_range(request, binary) when is_binary(binary) do
-    put_header(request, "range", binary)
+  def put_range(%{options: %{range: range}} = request) when is_binary(range) do
+    put_header(request, "range", range)
   end
 
-  def put_range(request, first..last) do
+  def put_range(%{options: %{range: first..last}} = request) do
     put_header(request, "range", "bytes=#{first}-#{last}")
   end
 
+  def put_range(request) do
+    request
+  end
+
   @doc """
   Handles HTTP cache using `if-modified-since` header.
 
@@ -379,12 +327,14 @@ defmodule Req.Steps do
 
   ## Options
 
-    * `:dir` - the directory to store the cache, defaults to `<user_cache_dir>/req`
+    * `:cache` - if `true`, performs caching. Defaults to `false`.
+
+    * `:cache_dir` - the directory to store the cache, defaults to `<user_cache_dir>/req`
       (see: `:filename.basedir/3`)
 
   ## Examples
 
-      iex> url = "https://httpbin.org/html"
+      iex> url = "https://elixir-lang.org"
       iex> response1 = Req.get!(url, cache: true)
       iex> response2 = Req.get!(url, cache: true)
       iex> response1 == response2
@@ -392,12 +342,16 @@ defmodule Req.Steps do
 
   """
   @doc step: :request
-  def put_if_modified_since(request, options \\ []) do
-    dir = options[:dir] || :filename.basedir(:user_cache, 'req')
+  def put_if_modified_since(request) do
+    if request.options[:cache] do
+      dir = Map.get(request.options, :cache_dir) || :filename.basedir(:user_cache, 'req')
 
-    request
-    |> do_put_if_modified_since(dir)
-    |> Req.Request.prepend_response_steps([&handle_cache(&1, dir)])
+      request
+      |> do_put_if_modified_since(dir)
+      |> Req.Request.prepend_response_steps([&handle_cache(&1, dir)])
+    else
+      request
+    end
   end
 
   defp do_put_if_modified_since(request, dir) do
@@ -427,44 +381,18 @@ defmodule Req.Steps do
   end
 
   @doc """
-  Runs the given steps.
-
-  A step is a function that takes and returns a usually updated `state`.
-  The `state` is:
-
-    * a `request` struct for request steps
-
-    * a `{request, response}` tuple for response steps
-
-    * a `{request, exception}` tuple for error steps
-
-  A step can be one of the following:
-
-    * a 1-arity function
-
-    * a `{module, function, args}` tuple - calls `apply(module, function, [state | args])`
-
-    * a `{module, options}` tuple - calls `module.run(state, options)`
-
-    * a `module` atom - calls `module.run(state, [])`
+  Runs the request using `Finch`.
 
-  ## Examples
+  This is the default Req _adapter_. See `:adapter` field description in the `Req.Request` module
+  documentation for more information on adapters.
 
-      iex> inspect_host = fn request -> IO.inspect(request.url.host) ; request end
-      iex> Req.get!("https://httpbin.org/status/200", steps: [inspect_host]).status
-      # Outputs: "httpbin.org"
-      200
-
-  """
-  @doc step: :request
-  def run_steps(request, steps) when is_list(steps) do
-    Enum.reduce(steps, request, &Req.Request.run_step/2)
-  end
+  ## Request Options
 
-  @doc """
-  Runs the request using `Finch`.
+    * `:finch` - the name of the Finch pool. Defaults to `Req.Finch` which is automatically
+      started by Req.
 
-  This is the default adapter. See `Req.Request.build/3` for more information.
+    * `:finch_options` - options passed down to Finch when making the request, defaults to `[]`.
+       See `Finch.request/3` for a list of available options.
   """
   @doc step: :request
   def run_finch(request) do
@@ -472,7 +400,8 @@ defmodule Req.Steps do
       Finch.build(request.method, request.url, request.headers, request.body)
       |> Map.replace!(:unix_socket, request.unix_socket)
 
-    {finch_name, finch_options} = request.private.req_finch
+    finch_name = Map.get(request.options, :finch, Req.Finch)
+    finch_options = Map.get(request.options, :finch_options, [])
 
     case Finch.request(finch_request, finch_name, finch_options) do
       {:ok, response} ->
@@ -509,6 +438,10 @@ defmodule Req.Steps do
     {request, response}
   end
 
+  def decompress({request, response}) when request.options.raw == true do
+    {request, response}
+  end
+
   def decompress({request, response}) do
     compression_algorithms = get_content_encoding_header(response.headers)
     {request, update_in(response.body, &decompress_body(&1, compression_algorithms))}
@@ -565,6 +498,10 @@ defmodule Req.Steps do
     {request, response}
   end
 
+  def decode_body({request, response}) when request.options.raw == true do
+    {request, response}
+  end
+
   def decode_body({request, response}) do
     case format(request, response) do
       "json" ->
@@ -649,7 +586,7 @@ defmodule Req.Steps do
   | 301, 302, 303 | Changed to GET     |
   | 307, 308      | Method not changed |
 
-  ## Options
+  ## Request Options
 
     * `:location_trusted` - by default, authorization credentials are only sent
       on redirects to the same host. If `:location_trusted` is set to `true`, credentials
@@ -658,19 +595,19 @@ defmodule Req.Steps do
   ## Examples
 
       iex> Req.get!("http://api.github.com").status
-      # 23:24:11.670 [debug]  Req.follow_redirects/2: Redirecting to https://api.github.com/
+      # 23:24:11.670 [debug]  follow_redirects: redirecting to https://api.github.com/
       200
 
   """
   @doc step: :response
-  def follow_redirects(request_response, options \\ [])
+  def follow_redirects(request_response)
 
-  def follow_redirects({request, %{status: status} = response}, options)
+  def follow_redirects({request, %{status: status} = response})
       when status in [301, 302, 303, 307, 308] do
     {_, location} = List.keyfind(response.headers, "location", 0)
-    Logger.debug(["Req.follow_redirects/2: Redirecting to ", location])
+    Logger.debug(["follow_redirects: redirecting to ", location])
 
-    location_trusted = options[:location_trusted]
+    location_trusted = Map.get(request.options, :location_trusted)
     location_url = URI.parse(location)
 
     request =
@@ -683,10 +620,38 @@ defmodule Req.Steps do
     {Req.Request.halt(request), result}
   end
 
-  def follow_redirects(other, _) do
+  def follow_redirects(other) do
     other
   end
 
+  defp put_redirect_location(request, location_url) do
+    if location_url.host do
+      put_in(request.url, location_url)
+    else
+      update_in(request.url, &%{&1 | path: location_url.path, query: location_url.query})
+    end
+  end
+
+  defp put_redirect_request_method(request) when request.status in 307..308, do: request
+
+  defp put_redirect_request_method(request), do: %{request | method: :get}
+
+  defp remove_credentials_if_untrusted(request, true, _), do: request
+
+  defp remove_credentials_if_untrusted(request, _, location_url) do
+    if location_url.host == request.url.host do
+      request
+    else
+      remove_credentials(request)
+    end
+  end
+
+  defp remove_credentials(request) do
+    headers = List.keydelete(request.headers, "authorization", 0)
+    request = update_in(request.options, &Map.delete(&1, :auth))
+    %{request | headers: headers}
+  end
+
   ## Error steps
 
   @doc """
@@ -699,9 +664,11 @@ defmodule Req.Steps do
 
     * an exception
 
-  ## Options
+  ## Request Options
 
-    * `:delay` - sleep this number of milliseconds before making another attempt, defaults
+    * `:retry` - if `false`, disables automatic retries. Defaults to `true`.
+
+    * `:request_delay` - sleep this number of milliseconds before making another attempt, defaults
       to `2000`
 
     * `:max_retries` - maximum number of retry attempts, defaults to `2` (for a total of `3`
@@ -711,39 +678,39 @@ defmodule Req.Steps do
 
   With default options:
 
-      iex> Req.get!("https://httpbin.org/status/500,200", retry: true).status
-      # 19:02:08.463 [error] Req.retry/3: Got response with status 500. Will retry in 2000ms, 2 attempts left
-      # 19:02:10.710 [error] Req.retry/3: Got response with status 500. Will retry in 2000ms, 1 attempt left
+      iex> Req.get!("https://httpbin.org/status/500,200").status
+      # 19:02:08.463 [error] retry: got response with status 500, will retry in 2000ms, 2 attempts left
+      # 19:02:10.710 [error] retry: got response with status 500, will retry in 2000ms, 1 attempt left
       200
 
   With custom options:
 
-      iex> Req.get!("http://localhost:9999", retry: [delay: 100, max_retries: 3])
-      # 17:00:38.371 [error] Req.retry/3: Got exception. Will retry in 100ms, 3 attempts left
+      iex> Req.get!("http://localhost:9999", retry_delay: 100, max_retries: 3)
+      # 17:00:38.371 [error] retry: got exception, will retry in 100ms, 3 attempts left
       # 17:00:38.371 [error] ** (Mint.TransportError) connection refused
-      # 17:00:38.473 [error] Req.retry/3: Got exception. Will retry in 100ms, 2 attempts left
+      # 17:00:38.473 [error] retry: got exception, will retry in 100ms, 2 attempts left
       # 17:00:38.473 [error] ** (Mint.TransportError) connection refused
-      # 17:00:38.575 [error] Req.retry/3: Got exception. Will retry in 100ms, 1 attempt left
+      # 17:00:38.575 [error] retry: got exception, will retry in 100ms, 1 attempt left
       # 17:00:38.575 [error] ** (Mint.TransportError) connection refused
       ** (Mint.TransportError) connection refused
 
   """
   @doc step: :error
-  def retry(request_response_or_error, options)
+  def retry(request_response_or_error)
 
-  def retry({request, %{status: status} = response}, _options) when status < 500 do
+  def retry({request, %Req.Response{} = response}) when response.status < 500 do
     {request, response}
   end
 
-  def retry({request, response_or_exception}, options) when is_list(options) do
-    delay = Keyword.get(options, :delay, 2000)
-    max_retries = Keyword.get(options, :max_retries, 2)
-    retry_count = Req.Request.get_private(request, :retry_count, 0)
+  def retry({request, response_or_exception}) do
+    delay = Map.get(request.options, :retry_delay, 2000)
+    max_retries = Map.get(request.options, :max_retries, 2)
+    retry_count = Req.Request.get_private(request, :req_retry_count, 0)
 
     if retry_count < max_retries do
       log_retry(response_or_exception, retry_count, max_retries, delay)
       Process.sleep(delay)
-      request = Req.Request.put_private(request, :retry_count, retry_count + 1)
+      request = Req.Request.put_private(request, :req_retry_count, retry_count + 1)
 
       {_, result} = Req.Request.run(request)
       {Req.Request.halt(request), result}
@@ -759,12 +726,12 @@ defmodule Req.Steps do
         n -> "#{n} attempts"
       end
 
-    message = ["Will retry in #{delay}ms, ", retries_left, " left"]
+    message = ["will retry in #{delay}ms, ", retries_left, " left"]
 
     case response_or_exception do
       %{__exception__: true} = exception ->
         Logger.error([
-          "Req.retry/3: Got exception. ",
+          "retry: got exception, ",
           message
         ])
 
@@ -774,7 +741,7 @@ defmodule Req.Steps do
         ])
 
       response ->
-        Logger.error(["Req.retry/3: Got response with status #{response.status}. ", message])
+        Logger.error(["retry: got response with status #{response.status}, ", message])
     end
   end
 
@@ -840,33 +807,4 @@ defmodule Req.Steps do
   defp format_http_datetime(datetime) do
     Calendar.strftime(datetime, "%a, %d %b %Y %H:%M:%S GMT")
   end
-
-  defp put_redirect_location(request, location_url) do
-    if location_url.host do
-      put_in(request.url, location_url)
-    else
-      update_in(request.url, &%{&1 | path: location_url.path, query: location_url.query})
-    end
-  end
-
-  defp put_redirect_request_method(request) when request.status in 307..308, do: request
-
-  defp put_redirect_request_method(request), do: %{request | method: :get}
-
-  defp remove_credentials_if_untrusted(request, true, _), do: request
-
-  defp remove_credentials_if_untrusted(request, _, location_url) do
-    if location_url.host == request.url.host do
-      request
-    else
-      remove_credentials(request)
-    end
-  end
-
-  defp remove_credentials(request) do
-    headers = List.keydelete(request.headers, "authorization", 0)
-    request_steps = Enum.reject(request.request_steps, fn {_, step, _} -> step == :auth end)
-
-    %{request | headers: headers, request_steps: request_steps}
-  end
 end
diff --git a/mix.exs b/mix.exs
index 75bceb2..5b7c743 100644
--- a/mix.exs
+++ b/mix.exs
@@ -56,7 +56,7 @@ defmodule Req.MixProject do
 
   defp docs do
     [
-      main: "Req",
+      main: "readme",
       source_url: @source_url,
       source_ref: "v#{@version}",
       groups_for_functions: [
@@ -65,6 +65,7 @@ defmodule Req.MixProject do
         "Error steps": &(&1[:step] == :error)
       ],
       extras: [
+        "README.md",
         "CHANGELOG.md"
       ]
     ]


Test patch:
diff --git a/test/req/httpbin_test.exs b/test/req/httpbin_test.exs
index e96a5aa..435d20d 100644
--- a/test/req/httpbin_test.exs
+++ b/test/req/httpbin_test.exs
@@ -15,14 +15,14 @@ defmodule Req.HttpbinTest do
 
   doctest Req.Steps,
     only: [
-      auth: 2,
-      put_base_url: 2,
+      auth: 1,
+      put_base_url: 1,
       encode_headers: 1,
       encode_body: 1,
-      put_params: 2,
-      put_range: 2,
-      run_steps: 2,
-      put_if_modified_since: 2,
+      put_params: 1,
+      put_range: 1,
+      # run_steps: 1,
+      put_if_modified_since: 1,
       decompress: 1,
       decode_body: 1
     ]
diff --git a/test/req/request_test.exs b/test/req/request_test.exs
index 18d24e4..8823ae5 100644
--- a/test/req/request_test.exs
+++ b/test/req/request_test.exs
@@ -11,7 +11,7 @@ defmodule Req.RequestTest do
       Plug.Conn.send_resp(conn, 200, "ok")
     end)
 
-    request = Req.Request.build(:get, c.url <> "/ok")
+    request = new(url: c.url <> "/ok")
     assert {:ok, %{status: 200, body: "ok"}} = Req.Request.run(request)
   end
 
@@ -21,7 +21,7 @@ defmodule Req.RequestTest do
     end)
 
     request =
-      Req.Request.build(:get, c.url <> "/not-found")
+      new(url: c.url <> "/not-found")
       |> Req.Request.prepend_request_steps([
         fn request ->
           put_in(request.url.path, "/ok")
@@ -33,7 +33,7 @@ defmodule Req.RequestTest do
 
   test "request step returns response", c do
     request =
-      Req.Request.build(:get, c.url <> "/ok")
+      new(url: c.url <> "/ok")
       |> Req.Request.prepend_request_steps([
         fn request ->
           {request, %Req.Response{status: 200, body: "from cache"}}
@@ -50,7 +50,7 @@ defmodule Req.RequestTest do
 
   test "request step returns exception", c do
     request =
-      Req.Request.build(:get, c.url <> "/ok")
+      new(url: c.url <> "/ok")
       |> Req.Request.prepend_request_steps([
         fn request ->
           {request, RuntimeError.exception("oops")}
@@ -67,7 +67,7 @@ defmodule Req.RequestTest do
 
   test "request step halts with response", c do
     request =
-      Req.Request.build(:get, c.url <> "/ok")
+      new(url: c.url <> "/ok")
       |> Req.Request.prepend_request_steps([
         fn request ->
           {Req.Request.halt(request), %Req.Response{status: 200, body: "from cache"}}
@@ -86,7 +86,7 @@ defmodule Req.RequestTest do
 
   test "request step halts with exception", c do
     request =
-      Req.Request.build(:get, c.url <> "/ok")
+      new(url: c.url <> "/ok")
       |> Req.Request.prepend_request_steps([
         fn request ->
           {Req.Request.halt(request), RuntimeError.exception("oops")}
@@ -109,7 +109,7 @@ defmodule Req.RequestTest do
     end)
 
     request =
-      Req.Request.build(:get, c.url <> "/ok")
+      new(url: c.url <> "/ok")
       |> Req.Request.prepend_response_steps([
         fn {request, response} ->
           {request, update_in(response.body, &(&1 <> " - updated"))}
@@ -125,7 +125,7 @@ defmodule Req.RequestTest do
     end)
 
     request =
-      Req.Request.build(:get, c.url <> "/ok")
+      new(url: c.url <> "/ok")
       |> Req.Request.prepend_response_steps([
         fn {request, response} ->
           assert response.body == "ok"
@@ -147,7 +147,7 @@ defmodule Req.RequestTest do
     end)
 
     request =
-      Req.Request.build(:get, c.url <> "/ok")
+      new(url: c.url <> "/ok")
       |> Req.Request.prepend_response_steps([
         fn {request, response} ->
           {Req.Request.halt(request), update_in(response.body, &(&1 <> " - updated"))}
@@ -167,7 +167,7 @@ defmodule Req.RequestTest do
     end)
 
     request =
-      Req.Request.build(:get, c.url <> "/ok")
+      new(url: c.url <> "/ok")
       |> Req.Request.prepend_response_steps([
         fn {request, response} ->
           assert response.body == "ok"
@@ -186,7 +186,7 @@ defmodule Req.RequestTest do
     Bypass.down(c.bypass)
 
     request =
-      Req.Request.build(:get, c.url <> "/ok")
+      new(url: c.url <> "/ok")
       |> Req.Request.prepend_error_steps([
         fn {request, exception} ->
           assert exception.reason == :econnrefused
@@ -201,7 +201,7 @@ defmodule Req.RequestTest do
     Bypass.down(c.bypass)
 
     request =
-      Req.Request.build(:get, c.url <> "/ok")
+      new(url: c.url <> "/ok")
       |> Req.Request.prepend_response_steps([
         fn {request, response} ->
           {request, update_in(response.body, &(&1 <> " - updated"))}
@@ -222,7 +222,7 @@ defmodule Req.RequestTest do
     Bypass.down(c.bypass)
 
     request =
-      Req.Request.build(:get, c.url <> "/ok")
+      new(url: c.url <> "/ok")
       |> Req.Request.prepend_response_steps([
         &unreachable/1
       ])
@@ -239,6 +239,11 @@ defmodule Req.RequestTest do
 
   ## Helpers
 
+  defp new(options) do
+    options = Keyword.update(options, :url, nil, &URI.parse/1)
+    struct!(Req.Request, options)
+  end
+
   defp unreachable(_) do
     raise "unreachable"
   end
diff --git a/test/req/steps_test.exs b/test/req/steps_test.exs
index 01345eb..10807a0 100644
--- a/test/req/steps_test.exs
+++ b/test/req/steps_test.exs
@@ -8,7 +8,7 @@ defmodule Req.StepsTest do
 
   ## Request steps
 
-  test "put_base_url/2", c do
+  test "put_base_url/1", c do
     Bypass.expect(c.bypass, "GET", "/", fn conn ->
       Plug.Conn.send_resp(conn, 200, "ok")
     end)
@@ -17,7 +17,7 @@ defmodule Req.StepsTest do
     assert Req.get!("", base_url: c.url).status == 200
   end
 
-  test "auth/2: basic", c do
+  test "auth/1: basic", c do
     Bypass.expect(c.bypass, "GET", "/auth", fn conn ->
       expected = "Basic " <> Base.encode64("foo:bar")
 
@@ -34,7 +34,7 @@ defmodule Req.StepsTest do
     assert Req.get!(c.url <> "/auth", auth: {"foo", "bar"}).status == 200
   end
 
-  test "auth/2: bearer", c do
+  test "auth/1: bearer", c do
     Bypass.expect(c.bypass, "GET", "/auth", fn conn ->
       expected = "Bearer valid_token"
 
@@ -52,7 +52,7 @@ defmodule Req.StepsTest do
   end
 
   @tag :tmp_dir
-  test "auth/2: :netrc", c do
+  test "auth/1: :netrc", c do
     Bypass.expect(c.bypass, "GET", "/auth", fn conn ->
       expected = "Basic " <> Base.encode64("foo:bar")
 
@@ -102,7 +102,7 @@ defmodule Req.StepsTest do
   end
 
   @tag :tmp_dir
-  test "auth/2: {:netrc, path}", c do
+  test "auth/1: {:netrc, path}", c do
     Bypass.expect(c.bypass, "GET", "/auth", fn conn ->
       expected = "Basic " <> Base.encode64("foo:bar")
 
@@ -191,27 +191,27 @@ defmodule Req.StepsTest do
     assert %{body: "req/" <> _} = Req.get!(c.url <> "/user-agent")
   end
 
-  test "encode/2: json", c do
+  test "encode_body/1: json", c do
     Bypass.expect(c.bypass, "POST", "/json", fn conn ->
       conn = Plug.Parsers.call(conn, Plug.Parsers.init(parsers: [{:json, json_decoder: Jason}]))
       assert conn.body_params == %{"a" => 1}
       Plug.Conn.send_resp(conn, 200, "ok")
     end)
 
-    assert Req.post!(c.url <> "/json", {:json, %{a: 1}}).body == "ok"
+    assert Req.post!(c.url <> "/json", body: {:json, %{a: 1}}).body == "ok"
   end
 
-  test "encode/2: form", c do
+  test "encode_body/1: form", c do
     Bypass.expect(c.bypass, "POST", "/form", fn conn ->
       conn = Plug.Parsers.call(conn, Plug.Parsers.init(parsers: [:urlencoded]))
       assert conn.body_params == %{"a" => "1"}
       Plug.Conn.send_resp(conn, 200, "ok")
     end)
 
-    assert Req.post!(c.url <> "/form", {:form, a: 1}).body == "ok"
+    assert Req.post!(c.url <> "/form", body: {:form, a: 1}).body == "ok"
   end
 
-  test "params/2", c do
+  test "put_params/1", c do
     Bypass.expect(c.bypass, "GET", "/params", fn conn ->
       Plug.Conn.send_resp(conn, 200, conn.query_string)
     end)
@@ -220,7 +220,7 @@ defmodule Req.StepsTest do
     assert Req.get!(c.url <> "/params?x=1", params: [y: 2, z: 3]).body == "x=1&y=2&z=3"
   end
 
-  test "range/2", c do
+  test "put_range/1", c do
     Bypass.expect(c.bypass, "GET", "/range", fn conn ->
       [range] = Plug.Conn.get_req_header(conn, "range")
       Plug.Conn.send_resp(conn, 206, range)
@@ -230,27 +230,27 @@ defmodule Req.StepsTest do
     assert Req.get!(c.url <> "/range", range: 0..10).body == "bytes=0-10"
   end
 
-  defmodule MyStep do
-    def run(request, options) do
-      send(self(), {:got, options})
-      {request, %Req.Response{status: 200, body: "ok"}}
-    end
-  end
+  # defmodule MyStep do
+  #   def run(request, options) do
+  #     send(self(), {:got, options})
+  #     {request, %Req.Response{status: 200, body: "ok"}}
+  #   end
+  # end
 
-  test "run_steps/2" do
-    assert Req.get!("http:///", steps: [MyStep]).body == "ok"
-    assert_received {:got, []}
+  # test "run_steps/2" do
+  #   assert Req.get!("http:///", steps: [MyStep]).body == "ok"
+  #   assert_received {:got, []}
 
-    assert Req.get!("http:///", steps: [{MyStep, 42}]).body == "ok"
-    assert_received {:got, 42}
+  #   assert Req.get!("http:///", steps: [{MyStep, 42}]).body == "ok"
+  #   assert_received {:got, 42}
 
-    assert Req.get!("http:///", steps: [{MyStep, :run, [42]}]).body == "ok"
-    assert_received {:got, 42}
-  end
+  #   assert Req.get!("http:///", steps: [{MyStep, :run, [42]}]).body == "ok"
+  #   assert_received {:got, 42}
+  # end
 
   ## Response steps
 
-  test "decompress/2", c do
+  test "decompress/1", c do
     Bypass.expect(c.bypass, "GET", "/gzip", fn conn ->
       conn
       |> Plug.Conn.put_resp_header("content-encoding", "x-gzip")
@@ -260,7 +260,7 @@ defmodule Req.StepsTest do
     assert Req.get!(c.url <> "/gzip").body == "foo"
   end
 
-  test "decode/2: json", c do
+  test "decode_body/1: json", c do
     Bypass.expect(c.bypass, "GET", "/json", fn conn ->
       conn
       |> Plug.Conn.put_resp_content_type("application/json")
@@ -270,7 +270,7 @@ defmodule Req.StepsTest do
     assert Req.get!(c.url <> "/json").body == %{"a" => 1}
   end
 
-  test "decode/2: gzip", c do
+  test "decode_body/1: gzip", c do
     Bypass.expect(c.bypass, "GET", "/gzip", fn conn ->
       conn
       |> Plug.Conn.put_resp_content_type("application/x-gzip", nil)
@@ -281,7 +281,7 @@ defmodule Req.StepsTest do
   end
 
   @tag :tmp_dir
-  test "decode/2: tar (content-type)", c do
+  test "decode_body/1: tar (content-type)", c do
     files = [{'foo.txt', "bar"}]
 
     path = '#{c.tmp_dir}/foo.tar'
@@ -298,7 +298,7 @@ defmodule Req.StepsTest do
   end
 
   @tag :tmp_dir
-  test "decode/2: tar (path)", c do
+  test "decode_body/1: tar (path)", c do
     files = [{'foo.txt', "bar"}]
 
     path = '#{c.tmp_dir}/foo.tar'
@@ -315,7 +315,7 @@ defmodule Req.StepsTest do
   end
 
   @tag :tmp_dir
-  test "decode/2: tar.gz (path)", c do
+  test "decode_body/1: tar.gz (path)", c do
     files = [{'foo.txt', "bar"}]
 
     path = '#{c.tmp_dir}/foo.tar'
@@ -331,7 +331,7 @@ defmodule Req.StepsTest do
     assert Req.get!(c.url <> "/foo.tar.gz").body == files
   end
 
-  test "decode/2: zip (content-type)", c do
+  test "decode_body/1: zip (content-type)", c do
     files = [{'foo.txt', "bar"}]
 
     Bypass.expect(c.bypass, "GET", "/zip", fn conn ->
@@ -345,7 +345,7 @@ defmodule Req.StepsTest do
     assert Req.get!(c.url <> "/zip").body == files
   end
 
-  test "decode/2: zip (path)", c do
+  test "decode_body/1: zip (path)", c do
     files = [{'foo.txt', "bar"}]
 
     Bypass.expect(c.bypass, "GET", "/foo.zip", fn conn ->
@@ -359,7 +359,7 @@ defmodule Req.StepsTest do
     assert Req.get!(c.url <> "/foo.zip").body == files
   end
 
-  test "decode/2: csv", c do
+  test "decode_body/1: csv", c do
     csv = [
       ["x", "y"],
       ["1", "2"],
@@ -377,7 +377,7 @@ defmodule Req.StepsTest do
     assert Req.get!(c.url <> "/csv").body == csv
   end
 
-  test "follow_redirects/2: absolute", c do
+  test "follow_redirects/1: absolute", c do
     Bypass.expect(c.bypass, "GET", "/redirect", fn conn ->
       location = c.url <> "/ok"
 
@@ -392,10 +392,10 @@ defmodule Req.StepsTest do
 
     assert ExUnit.CaptureLog.capture_log(fn ->
              assert Req.get!(c.url <> "/redirect").status == 200
-           end) =~ "[debug] Req.follow_redirects/2: Redirecting to #{c.url}/ok"
+           end) =~ "[debug] follow_redirects: redirecting to #{c.url}/ok"
   end
 
-  test "follow_redirects/2: relative", c do
+  test "follow_redirects/1: relative", c do
     Bypass.expect(c.bypass, "GET", "/redirect", fn conn ->
       location =
         case conn.query_string do
@@ -416,16 +416,16 @@ defmodule Req.StepsTest do
              response = Req.get!(c.url <> "/redirect")
              assert response.status == 200
              assert response.body == ""
-           end) =~ "[debug] Req.follow_redirects/2: Redirecting to /ok"
+           end) =~ "[debug] follow_redirects: redirecting to /ok"
 
     assert ExUnit.CaptureLog.capture_log(fn ->
              response = Req.get!(c.url <> "/redirect?a=1")
              assert response.status == 200
              assert response.body == "a=1"
-           end) =~ "[debug] Req.follow_redirects/2: Redirecting to /ok?a=1"
+           end) =~ "[debug] follow_redirects: redirecting to /ok?a=1"
   end
 
-  test "follow_redirects/2: 301..303", c do
+  test "follow_redirects/1: 301..303", c do
     Bypass.expect(c.bypass, "POST", "/redirect", fn conn ->
       location = c.url <> "/ok"
 
@@ -439,11 +439,11 @@ defmodule Req.StepsTest do
     end)
 
     assert ExUnit.CaptureLog.capture_log(fn ->
-             assert Req.post!(c.url <> "/redirect", "body").status == 200
-           end) =~ "[debug] Req.follow_redirects/2: Redirecting to #{c.url}/ok"
+             assert Req.post!(c.url <> "/redirect", body: "body").status == 200
+           end) =~ "[debug] follow_redirects: redirecting to #{c.url}/ok"
   end
 
-  test "follow_redirects/2: auth - same host", c do
+  test "follow_redirects/1: auth - same host", c do
     auth_header = {"authorization", "Basic " <> Base.encode64("foo:bar")}
 
     Bypass.expect(c.bypass, "GET", "/redirect", fn conn ->
@@ -463,10 +463,10 @@ defmodule Req.StepsTest do
 
     assert ExUnit.CaptureLog.capture_log(fn ->
              assert Req.get!(c.url <> "/redirect", auth: {"foo", "bar"}).status == 200
-           end) =~ "[debug] Req.follow_redirects/2: Redirecting to #{c.url}/auth"
+           end) =~ "[debug] follow_redirects: redirecting to #{c.url}/auth"
   end
 
-  test "follow_redirects/2: auth - location trusted" do
+  test "follow_redirects/1: auth - location trusted" do
     adapter = fn request ->
       case request.url.host do
         "original" ->
@@ -497,9 +497,9 @@ defmodule Req.StepsTest do
              assert Req.get!("http://original",
                       adapter: adapter,
                       auth: {"authorization", "credentials"},
-                      follow_redirects: [location_trusted: true]
+                      location_trusted: true
                     ).status == 200
-           end) =~ "[debug] Req.follow_redirects/2: Redirecting to http://untrusted"
+           end) =~ "[debug] follow_redirects: redirecting to http://untrusted"
   end
 
   test "follow_redirects/2: auth - different host" do
@@ -534,7 +534,7 @@ defmodule Req.StepsTest do
                       adapter: adapter,
                       auth: {"authorization", "credentials"}
                     ).status == 200
-           end) =~ "[debug] Req.follow_redirects/2: Redirecting to http://untrusted"
+           end) =~ "[debug] follow_redirects: redirecting to http://untrusted"
   end
 
   ## Error steps
@@ -552,15 +552,14 @@ defmodule Req.StepsTest do
     end)
 
     request =
-      Req.Request.build(:get, c.url <> "/retry")
+      Req.new(url: c.url <> "/retry", retry_delay: 10)
       |> Req.Request.prepend_response_steps([
-        &Req.Steps.retry(&1, delay: 10),
         fn {request, response} ->
           {request, update_in(response.body, &(&1 <> " - updated"))}
         end
       ])
 
-    assert {:ok, %{status: 200, body: "ok - updated"}} = Req.Request.run(request)
+    assert Req.get!(request).body == "ok - updated"
     assert Agent.get(:counter, & &1) == 3
   end
 
@@ -574,15 +573,14 @@ defmodule Req.StepsTest do
     end)
 
     request =
-      Req.Request.build(:get, c.url <> "/retry")
+      Req.new(url: c.url <> "/retry", retry_delay: 10)
       |> Req.Request.prepend_response_steps([
-        &Req.Steps.retry(&1, delay: 10),
         fn {request, response} ->
           {request, update_in(response.body, &(&1 <> " - updated"))}
         end
       ])
 
-    assert {:ok, %{status: 500, body: "oops - updated"}} = Req.Request.run(request)
+    assert Req.get!(request).body == "oops - updated"
     assert_received :ping
     assert_received :ping
     assert_received :ping
@@ -611,11 +609,9 @@ defmodule Req.StepsTest do
       end
     end)
 
-    request =
-      Req.Request.build(:get, c.url <> "/cache")
-      |> Req.Request.prepend_request_steps([&Req.Steps.put_if_modified_since(&1, dir: c.tmp_dir)])
+    request = Req.new(url: c.url <> "/cache", cache: true, cache_dir: c.tmp_dir)
 
-    response = Req.Request.run!(request)
+    response = Req.get!(request)
     assert response.status == 200
     assert response.body == "ok"
     assert_received :cache_miss
@@ -657,14 +653,14 @@ defmodule Req.StepsTest do
     end)
 
     request =
-      Req.Request.build(:get, c.url <> "/cache")
-      |> Req.Request.prepend_request_steps([&Req.Steps.put_if_modified_since(&1, dir: c.tmp_dir)])
-      |> Req.Request.prepend_response_steps([
-        &Req.Steps.retry(&1, delay: 10),
-        &Req.Steps.decode_body/1
-      ])
-
-    response = Req.Request.run!(request)
+      Req.new(
+        url: c.url <> "/cache",
+        retry_delay: 10,
+        cache: true,
+        cache_dir: c.tmp_dir
+      )
+
+    response = Req.get!(request)
     assert response.status == 200
     assert response.body == %{"a" => 1}
     assert_received :cache_miss
@@ -677,4 +673,40 @@ defmodule Req.StepsTest do
     assert_received :cache_hit
     refute_received _
   end
+
+  test "run_finch/1: pool timeout", c do
+    Bypass.stub(c.bypass, "GET", "/", fn conn ->
+      Plug.Conn.send_resp(conn, 200, "ok")
+    end)
+
+    options = [finch_options: [pool_timeout: 0]]
+    assert {:timeout, _} = catch_exit(Req.get!(c.url <> "/", options))
+  end
+
+  test "run_finch/1: receive timeout", c do
+    pid = self()
+
+    Bypass.stub(c.bypass, "GET", "/", fn conn ->
+      send(pid, :ping)
+      Process.sleep(10)
+      Plug.Conn.send_resp(conn, 200, "ok")
+    end)
+
+    options = [finch_options: [receive_timeout: 0], retry_delay: 10]
+
+    log =
+      ExUnit.CaptureLog.capture_log(fn ->
+        assert {:error, %Mint.TransportError{reason: :timeout}} =
+                 Req.request([method: :get, url: c.url <> "/"] ++ options)
+
+        assert_receive :ping
+        assert_receive :ping
+        assert_receive :ping
+        refute_receive _
+      end)
+
+    refute log =~ "3 attempts left"
+    assert log =~ "2 attempts left"
+    assert log =~ "1 attempt left"
+  end
 end
diff --git a/test/req_test.exs b/test/req_test.exs
index de863c9..29616bb 100644
--- a/test/req_test.exs
+++ b/test/req_test.exs
@@ -1,6 +1,11 @@
 defmodule ReqTest do
   use ExUnit.Case, async: true
 
+  doctest Req,
+    only: [
+      new: 1
+    ]
+
   setup do
     bypass = Bypass.open()
     [bypass: bypass, url: "http://localhost:#{bypass.port}"]
@@ -17,24 +22,24 @@ defmodule ReqTest do
       assert Req.get!(c.url <> "/json").body == %{"a" => 1}
     end
 
-    test "post!/3", c do
+    test "post!/", c do
       Bypass.expect(c.bypass, "POST", "/json", fn conn ->
         conn
         |> Plug.Conn.put_resp_content_type("application/json")
         |> Plug.Conn.send_resp(200, Jason.encode_to_iodata!(%{"a" => 1}))
       end)
 
-      assert Req.post!(c.url <> "/json", {:json, %{foo: "bar"}}).body == %{"a" => 1}
+      assert Req.post!(c.url <> "/json", body: {:json, %{foo: "bar"}}).body == %{"a" => 1}
     end
 
-    test "put!/3", c do
+    test "put!/", c do
       Bypass.expect(c.bypass, "PUT", "/json", fn conn ->
         conn
         |> Plug.Conn.put_resp_content_type("application/json")
         |> Plug.Conn.send_resp(200, Jason.encode_to_iodata!(%{"a" => 1}))
       end)
 
-      assert Req.put!(c.url <> "/json", {:json, %{foo: "bar"}}).body == %{"a" => 1}
+      assert Req.put!(c.url <> "/json", body: {:json, %{foo: "bar"}}).body == %{"a" => 1}
     end
 
     test "delete!/2", c do
@@ -60,31 +65,4 @@ defmodule ReqTest do
 
     assert Req.get!(c.url <> "/json+gzip", raw: true).body == raw
   end
-
-  ## Finch errors
-
-  test "pool timeout", c do
-    Bypass.stub(c.bypass, "GET", "/", fn conn ->
-      Plug.Conn.send_resp(conn, 200, "ok")
-    end)
-
-    options = [finch_options: [pool_timeout: 0]]
-    assert {:timeout, _} = catch_exit(Req.get!(c.url <> "/", options))
-  end
-
-  test "receive timeout", c do
-    Bypass.stub(c.bypass, "GET", "/", fn conn ->
-      Process.sleep(1000)
-      Plug.Conn.send_resp(conn, 200, "ok")
-    end)
-
-    options = [finch_options: [receive_timeout: 0]]
-
-    assert {:error, %Mint.TransportError{reason: :timeout}} =
-             Req.request(:get, c.url <> "/", options)
-
-    # TODO:
-    # when this is uncommented, we'll get an exit shutdown, figure out why
-    # Process.sleep(1000)
-  end
 end