Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions lib/req/steps.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ defmodule Req.Steps do
:http_errors,
:decode_body,
:decode_json,
:expect,
:redirect,
:redirect_trusted,
:redirect_log_level,
Expand Down Expand Up @@ -89,6 +90,7 @@ defmodule Req.Steps do
decompress_body: &Req.Steps.decompress_body/1,
verify_checksum: &Req.Steps.verify_checksum/1,
decode_body: &Req.Steps.decode_body/1,
expect: &Req.Steps.expect/1,
output: &Req.Steps.output/1
)
|> Req.Request.prepend_error_steps(retry: &Req.Steps.retry/1)
Expand Down Expand Up @@ -2049,6 +2051,92 @@ defmodule Req.Steps do
{request, response}
end

@doc """
Expect that response matches the given status.

This step ensures the HTTP response has the given expected status, otherwise it
returns `Req.UnexpectedResponseError`.

## Request Options

* `:expect` - the expected HTTP response status. Can be one of the following:

* integer
* range
* list of integers/ranges

> #### Order Matters! {: .info}
>
> By default, `expect/1` runs AFTER `retry/1`, `redirect/1`, `decompress_body/1`,
> and `decode_body/1` steps.
>
> This means that, for example, HTTP 503 error would be first retried,
> HTTP 307 redirect would be first followed, and the response body
> would be first decompressed and decoded before checking for expected HTTP status.
> If this is undesirable, re-arrange or disable and manually run given steps.

> #### Sensitive Response Data {: .warning}
>
> This steps returns `Req.UnexpectedResponseError` which contains full `Req.Response`.
> Since response headers/body can contain sensitive data, be careful about raising
> this error and automatically logging it, sending to exception trackers, etc.

## Examples

iex> resp = Req.get!("https://httpbin.org/status/200", expect: 200)
iex> resp.status
200

iex> Req.get!("https://httpbin.org/status/404", expect: 200..299)
** (Req.UnexpectedResponseError) expected status 200..299, got: 404

iex> {:error, e} = Req.get("https://httpbin.org/status/404", expect: 200..299)
iex> e.expected_status
200..299
iex> e.response.status
404
"""
@doc step: :response
def expect(request_response)

def expect({request, response}) do
if expect = request.options[:expect] do
if expect_success?(response.status, expect) do
{request, response}
else
{request,
Req.UnexpectedResponseError.exception(expected_status: expect, response: response)}
end
else
{request, response}
end
end

defp expect_success?(status, status) do
true
end

defp expect_success?(_, other_status) when is_integer(other_status) do
false
end

defp expect_success?(status, %Range{} = statuses) do
status in statuses
end

defp expect_success?(status, [expect | tail])
when is_integer(expect) or is_struct(expect, Range) do
if expect_success?(status, expect) do
true
else
expect_success?(status, tail)
end
end

defp expect_success?(_status, []) do
false
end

## Error steps

@doc """
Expand Down
26 changes: 26 additions & 0 deletions lib/req/unexpected_response_error.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
defmodule Req.UnexpectedResponseError do
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another idea for calling this:

Suggested change
defmodule Req.UnexpectedResponseError do
defmodule Req.UnexpectedResponseStatusError do

@moduledoc """
An exception returned by `Req.Steps.expect/1` when response has unexpected status.

The public fields are:

* `:expected_status` - the expected HTTP response status

* `:response` - the HTTP response
"""

defexception [:expected_status, :response]

@impl true
def message(%{expected_status: expected_status, response: response}) do
"""
expected status #{inspect(expected_status)}, got: #{response.status}

headers:
#{inspect(response.headers, pretty: true)}

body:
#{inspect(response.body, pretty: true)}\
"""
end
end
3 changes: 2 additions & 1 deletion test/req/integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ defmodule Req.IntegrationTest do
put_range: 1,
cache: 1,
decompress_body: 1,
handle_http_errors: 1
handle_http_errors: 1,
expect: 1
]

@tag :s3
Expand Down
36 changes: 36 additions & 0 deletions test/req/steps_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,42 @@ defmodule Req.StepsTest do
end
end

describe "expect" do
test "status integer" do
plug = fn conn ->
Plug.Conn.send_resp(conn, 200, "ok")
end

assert Req.get!(plug: plug, expect: 200).body == "ok"
assert {:error, e} = Req.get(plug: plug, expect: 201)
assert Exception.message(e) =~ "expected status 201, got: 200"
end

test "status range" do
plug = fn conn ->
Plug.Conn.send_resp(conn, 200, "ok")
end

assert Req.get!(plug: plug, expect: 200..201).body == "ok"
assert {:error, e} = Req.get(plug: plug, expect: 201..202)
assert Exception.message(e) =~ "expected status 201..202, got: 200"
end

test "status list" do
plug = fn conn ->
Plug.Conn.send_resp(conn, 200, "ok")
end

assert Req.get!(plug: plug, expect: [200, 201]).body == "ok"
assert {:error, e} = Req.get(plug: plug, expect: [201, 202])
assert Exception.message(e) =~ "expected status [201, 202], got: 200"

assert Req.get!(plug: plug, expect: [200..201]).body == "ok"
assert {:error, e} = Req.get(plug: plug, expect: [201..202])
assert Exception.message(e) =~ "expected status [201..202], got: 200"
end
end

## Error steps

describe "retry" do
Expand Down
Loading