Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Mint.HTTP.recv_response/3 #447

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 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
3 changes: 3 additions & 0 deletions lib/mint/core/util.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ defmodule Mint.Core.Util do

alias Mint.Types

defguard is_timeout(timeout)
when (is_integer(timeout) and timeout >= 0) or timeout == :infinity

@spec hostname(keyword(), String.t()) :: String.t()
def hostname(opts, address) when is_list(opts) do
case Keyword.fetch(opts, :hostname) do
Expand Down
143 changes: 143 additions & 0 deletions lib/mint/http.ex
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ defmodule Mint.HTTP do

alias Mint.{Types, TunnelProxy, UnsafeProxy}
alias Mint.Core.{Transport, Util}
require Util

@behaviour Mint.Core.Conn

Expand Down Expand Up @@ -872,6 +873,148 @@ defmodule Mint.HTTP do
| {:error, t(), Types.error(), [Types.response()]}
def recv(conn, byte_count, timeout), do: conn_module(conn).recv(conn, byte_count, timeout)

@version Mix.Project.config()[:version]

@doc """
Sends a request and receives a response from the socket in a blocking way.

This function is a convenience for sending a request with `request/5` and repeatedly calling `recv/3`.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
This function is a convenience for sending a request with `request/5` and repeatedly calling `recv/3`.
This function is a convenience for sending a request with `request/5` and calling `recv/3`
repeatedly until a full response is received.


The result is either:

* `{:ok, conn, response}` where `conn` is the updated connection and `response` is a map
with the following keys:

* `:status` - HTTP response status, an integer.

* `:headers` - HTTP response headers, a list of tuples `{header_name, header_value}`.
Copy link
Contributor

Choose a reason for hiding this comment

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

Also trailing headers?


* `:body` - HTTP response body, a binary.

* `{:error, conn, reason}` where `conn` is the updated connection and `reason` is the cause
of the error. It is important to store the returned connection over the old connection in
case of errors too, because the state of the connection might change when there are errors
as well. An error when sending a request **does not** necessarily mean that the connection
is closed. Use `open?/1` to verify that the connection is open.

Contrary to `recv/3`, this function does not return partial responses on errors. Use
`recv/3` for full control.

> #### Error {: .error}
Copy link
Contributor

Choose a reason for hiding this comment

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

We said we'd also raise if the mode is not :passive, right?

>
> This function can only be used for one-off requests. If there is another concurrent request,
> started by `request/5`, it will crash.

## Options

* `:timeout` - the maximum amount of time in milliseconds waiting to receive the response.
Setting to `:infinity`, disables the timeout. Defaults to `:infinity`.
Comment on lines +910 to +911
Copy link
Contributor

Choose a reason for hiding this comment

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

Mh, are we ok with a default that potentially blocks forever? Maybe 30s is a better default?


## Examples

iex> {:ok, conn} = Mint.HTTP.connect(:https, "httpbin.org", 443, mode: :passive)
iex> {:ok, conn, response} = Mint.HTTP.request_and_response(conn, "GET", "/user-agent", [], nil)
iex> response
%{
status: 200,
headers: [{"date", ...}, ...],
body: "{\\n \\"user-agent\\": \\"#{@version}\\"\\n}\\n"
}
iex> Mint.HTTP.open?(conn)
true
wojtekmach marked this conversation as resolved.
Show resolved Hide resolved
"""
@spec request_and_response(
t(),
method :: String.t(),
path :: String.t(),
Types.headers(),
body :: iodata() | nil | :stream,
options :: [{:timeout, timeout()}]
) ::
{:ok, t(), response}
| {:error, t(), Types.error()}
when response: %{
status: non_neg_integer(),
headers: [{binary(), binary()}],
body: binary()
wojtekmach marked this conversation as resolved.
Show resolved Hide resolved
}
def request_and_response(conn, method, path, headers, body, options \\ []) do
options = keyword_validate!(options, timeout: :infinity)

with {:ok, conn, ref} <- request(conn, method, path, headers, body) do
recv_response(conn, ref, options[:timeout])
end
end

defp recv_response(conn, request_ref, timeout) when Util.is_timeout(timeout) do
recv_response([], {nil, [], ""}, conn, request_ref, timeout)
end

defp recv_response(
[{:status, ref, new_status} | rest],
{_status, headers, body},
conn,
ref,
timeout
) do
recv_response(rest, {new_status, headers, body}, conn, ref, timeout)
end

defp recv_response(
[{:headers, ref, new_headers} | rest],
{status, headers, body},
conn,
ref,
timeout
) do
recv_response(rest, {status, headers ++ new_headers, body}, conn, ref, timeout)
end

defp recv_response([{:data, ref, data} | rest], {status, headers, body}, conn, ref, timeout) do
recv_response(rest, {status, headers, [body, data]}, conn, ref, timeout)
end

defp recv_response([{:done, ref} | _rest], {status, headers, body}, conn, ref, _timeout) do
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't we error out here if there is trailing response data? That is, this should always be [{:done, ref}] no?

response = %{status: status, headers: headers, body: IO.iodata_to_binary(body)}
{:ok, conn, response}
end

defp recv_response([{:error, ref, error} | _rest], _acc, conn, ref, _timeout) do
{:error, conn, error}
end

# Ignore entries from other requests.
defp recv_response([entry | _rest], _acc, _conn, _ref, _timeout) when is_tuple(entry) do
ref = elem(entry, 1)
raise "received unexpected response from request #{inspect(ref)}"
end

defp recv_response([], acc, conn, ref, timeout) do
start_time = System.monotonic_time(:millisecond)

case recv(conn, 0, timeout) do
{:ok, conn, entries} ->
timeout =
if is_integer(timeout) do
timeout - System.monotonic_time(:millisecond) - start_time
else
timeout
end

recv_response(entries, acc, conn, ref, timeout)

{:error, conn, reason, _responses} ->
{:error, conn, reason}
end
end

# TODO: Remove when we require Elixir v1.13
if Version.match?(System.version(), ">= 1.13.0") do
wojtekmach marked this conversation as resolved.
Show resolved Hide resolved
defp keyword_validate!(keyword, values), do: Keyword.validate!(keyword, values)
else
defp keyword_validate!(keyword, _values), do: keyword
end

@doc """
Changes the mode of the underlying socket.

Expand Down
2 changes: 1 addition & 1 deletion test/http_test.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule Mint.HTTPTest do
use ExUnit.Case, async: true
doctest Mint.HTTP
doctest Mint.HTTP, except: [request_and_response: 6]
end
92 changes: 92 additions & 0 deletions test/mint/http1/conn_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,98 @@
assert responses == [{:status, ref, 200}]
end

describe "Mint.HTTP.request_and_response/6" do
test "receives response", %{port: port, server_ref: server_ref} do

Check failure on line 464 in test/mint/http1/conn_test.exs

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.12, OTP 24.3)

test Mint.HTTP.request_and_response/6 receives response (Mint.HTTP1Test)

Check failure on line 464 in test/mint/http1/conn_test.exs

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.12, OTP 23.3.1)

test Mint.HTTP.request_and_response/6 receives response (Mint.HTTP1Test)
assert {:ok, conn} = HTTP1.connect(:http, "localhost", port, mode: :passive)
assert_receive {^server_ref, server_socket}

:ok = :gen_tcp.send(server_socket, "HTTP/1.1 200 OK\r\n")
:ok = :gen_tcp.send(server_socket, "content-type: text/plain\r\n")
:ok = :gen_tcp.send(server_socket, "content-length: 10\r\n\r\n")
:ok = :gen_tcp.send(server_socket, "hello")
:ok = :gen_tcp.send(server_socket, "world")

{:ok, conn, response} = Mint.HTTP.request_and_response(conn, "GET", "/", [], nil)

assert response == %{
body: "helloworld",
headers: [{"content-type", "text/plain"}, {"content-length", "10"}],
status: 200
}

assert HTTP1.open?(conn)
end

test "handles trailers", %{port: port, server_ref: server_ref} do

Check failure on line 485 in test/mint/http1/conn_test.exs

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.12, OTP 24.3)

test Mint.HTTP.request_and_response/6 handles trailers (Mint.HTTP1Test)

Check failure on line 485 in test/mint/http1/conn_test.exs

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.12, OTP 23.3.1)

test Mint.HTTP.request_and_response/6 handles trailers (Mint.HTTP1Test)
assert {:ok, conn} = HTTP1.connect(:http, "localhost", port, mode: :passive)
assert_receive {^server_ref, server_socket}

:ok = :gen_tcp.send(server_socket, "HTTP/1.1 200 OK\r\n")
:ok = :gen_tcp.send(server_socket, "transfer-encoding: chunked\r\n")
:ok = :gen_tcp.send(server_socket, "trailer: x-trailer\r\n\r\n")
:ok = :gen_tcp.send(server_socket, "5\r\nhello\r\n")
:ok = :gen_tcp.send(server_socket, "5\r\nworld\r\n0\r\n")
:ok = :gen_tcp.send(server_socket, "x-trailer: foo\r\n\r\n")

{:ok, conn, response} = Mint.HTTP.request_and_response(conn, "GET", "/", [], nil)

assert response == %{
body: "helloworld",
headers: [
{"transfer-encoding", "chunked"},
{"trailer", "x-trailer"},
{"x-trailer", "foo"}
],
status: 200
}

assert HTTP1.open?(conn)
end

test "handles errors", %{port: port, server_ref: server_ref} do

Check failure on line 511 in test/mint/http1/conn_test.exs

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.12, OTP 24.3)

test Mint.HTTP.request_and_response/6 handles errors (Mint.HTTP1Test)

Check failure on line 511 in test/mint/http1/conn_test.exs

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.12, OTP 23.3.1)

test Mint.HTTP.request_and_response/6 handles errors (Mint.HTTP1Test)
assert {:ok, conn} = HTTP1.connect(:http, "localhost", port, mode: :passive)
assert_receive {^server_ref, server_socket}

:ok = :gen_tcp.send(server_socket, "HTTP/1.1 200 OK\r\n")
:ok = :gen_tcp.close(server_socket)

assert {:error, _conn, %Mint.TransportError{reason: :closed}} =
Mint.HTTP.request_and_response(conn, "GET", "/", [], nil)
end

test "handles timeout", %{port: port, server_ref: server_ref} do
assert {:ok, conn} = HTTP1.connect(:http, "localhost", port, mode: :passive)
assert_receive {^server_ref, _server_socket}

assert {:error, conn, %Mint.TransportError{reason: :timeout}} =
Mint.HTTP.request_and_response(conn, "GET", "/", [], nil, timeout: 0)

refute HTTP1.open?(conn)
end

test "raises on multiple requests " do

Check failure on line 532 in test/mint/http1/conn_test.exs

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.12, OTP 24.3)

test Mint.HTTP.request_and_response/6 raises on multiple requests (Mint.HTTP1Test)

Check failure on line 532 in test/mint/http1/conn_test.exs

View workflow job for this annotation

GitHub Actions / Test (Elixir 1.12, OTP 23.3.1)

test Mint.HTTP.request_and_response/6 raises on multiple requests (Mint.HTTP1Test)
{:ok, port, server_ref} =
TestServer.start(fn %{socket: socket} ->
:ok = :gen_tcp.send(socket, "HTTP/1.1 200 OK\r\n")
:ok = :gen_tcp.send(socket, "content-type: text/plain\r\n")
:ok = :gen_tcp.send(socket, "content-length: 10\r\n\r\n")
:ok = :gen_tcp.send(socket, "hello")
:ok = :gen_tcp.send(socket, "world")
end)

assert {:ok, conn} = HTTP1.connect(:http, "localhost", port, mode: :passive)
assert_receive {^server_ref, _server_socket}

{:ok, conn, ref} = Mint.HTTP.request(conn, "GET", "/", [], nil)

assert_raise RuntimeError,
"received unexpected response from request #{inspect(ref)}",
fn ->
Mint.HTTP.request_and_response(conn, "GET", "/", [], nil)
end
end
end

test "changing the connection mode with set_mode/2",
%{conn: conn, server_socket: server_socket} do
assert_raise ArgumentError, ~r"can't use recv/3", fn ->
Expand Down
Loading
Loading