diff --git a/lib/mint/core/util.ex b/lib/mint/core/util.ex index 83c1b96d..e7940683 100644 --- a/lib/mint/core/util.ex +++ b/lib/mint/core/util.ex @@ -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 diff --git a/lib/mint/http.ex b/lib/mint/http.ex index a689e20f..e04871b7 100644 --- a/lib/mint/http.ex +++ b/lib/mint/http.ex @@ -123,6 +123,7 @@ defmodule Mint.HTTP do alias Mint.{Types, TunnelProxy, UnsafeProxy} alias Mint.Core.{Transport, Util} + require Util @behaviour Mint.Core.Conn @@ -872,6 +873,149 @@ 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`. + + 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}`. + + * `: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} + > + > 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`. + + ## 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 + + """ + @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: Types.status(), + headers: Types.headers(), + body: binary() + } + 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 + 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 function_exported?(Keyword, :validate!, 2) do + 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. diff --git a/test/http_test.exs b/test/http_test.exs index 70e307c2..c88d4fd3 100644 --- a/test/http_test.exs +++ b/test/http_test.exs @@ -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 diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index 779be260..0f765010 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -460,6 +460,98 @@ defmodule Mint.HTTP1Test do assert responses == [{:status, ref, 200}] end + describe "Mint.HTTP.request_and_response/6" do + test "receives response", %{port: port, server_ref: server_ref} do + 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 + 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 + 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 + {: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 -> diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs index 41bcd4d3..16b2f85d 100644 --- a/test/mint/http2/conn_test.exs +++ b/test/mint/http2/conn_test.exs @@ -1129,7 +1129,11 @@ defmodule Mint.HTTP2Test do hbf: hbf, flags: set_flags(:headers, [:end_headers]) ), - data(stream_id: stream_id, data: "some data", flags: set_flags(:data, [])), + data( + stream_id: stream_id, + data: "some data", + flags: set_flags(:data, []) + ), headers( stream_id: stream_id, hbf: trailer_hbf1, @@ -2163,6 +2167,150 @@ defmodule Mint.HTTP2Test do end end + describe "Mint.HTTP.request_and_response/6" do + @describetag connect_options: [mode: :passive] + + test "receives response", context do + pid = self() + + Task.start_link(fn -> + context = Map.merge(context, start_server_async(context)) + [conn: conn] = start_connection(context) + send(pid, {:conn, conn}) + + assert_recv_frames [headers(stream_id: stream_id)] + + data = + server_encode_frames([ + headers( + stream_id: stream_id, + hbf: + server_encode_headers([ + {":status", "200"}, + {"content-type", "text/plain"}, + {"content-length", "10"} + ]), + flags: set_flags(:headers, [:end_headers]) + ), + data( + stream_id: stream_id, + data: "helloworld", + flags: set_flags(:data, [:end_stream]) + ) + ]) + + :ok = :ssl.send(server_get_socket(), data) + Process.sleep(:infinity) + end) + + assert_receive {:conn, conn} + assert {: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 HTTP2.open?(conn) + end + + test "handles trailers", context do + pid = self() + + Task.start_link(fn -> + context = Map.merge(context, start_server_async(context)) + [conn: conn] = start_connection(context) + send(pid, {:conn, conn}) + + assert_recv_frames [headers(stream_id: stream_id)] + + <> = + server_encode_headers([{"x-trailer", "foo"}]) + + data = + server_encode_frames([ + headers( + stream_id: stream_id, + hbf: + server_encode_headers([ + {":status", "200"}, + {"content-type", "text/plain"} + ]), + flags: set_flags(:headers, [:end_headers]) + ), + data( + stream_id: stream_id, + data: "helloworld", + flags: set_flags(:data, []) + ), + headers( + stream_id: stream_id, + hbf: trailer_hbf1, + flags: set_flags(:headers, [:end_stream]) + ), + continuation( + stream_id: stream_id, + hbf: trailer_hbf2, + flags: set_flags(:continuation, [:end_headers]) + ) + ]) + + :ok = :ssl.send(server_get_socket(), data) + Process.sleep(:infinity) + end) + + assert_receive {:conn, conn} + assert {:ok, conn, response} = Mint.HTTP.request_and_response(conn, "GET", "/", [], nil) + + assert response == %{ + body: "helloworld", + headers: [{"content-type", "text/plain"}, {"x-trailer", "foo"}], + status: 200 + } + + assert HTTP2.open?(conn) + end + + test "handles errors", context do + pid = self() + + Task.start_link(fn -> + context = Map.merge(context, start_server_async(context)) + [conn: conn] = start_connection(context) + send(pid, {:conn, conn}) + + assert_recv_frames [headers(stream_id: _stream_id)] + :ok = :ssl.close(server_get_socket()) + end) + + assert_receive {:conn, conn} + + assert {:error, conn, %Mint.TransportError{reason: :closed}} = + Mint.HTTP.request_and_response(conn, "GET", "/", [], nil) + + refute HTTP2.open?(conn) + end + + test "handles timeout", context do + pid = self() + + Task.start_link(fn -> + context = Map.merge(context, start_server_async(context)) + [conn: conn] = start_connection(context) + send(pid, {:conn, conn}) + Process.sleep(:infinity) + end) + + assert_receive {:conn, conn} + + assert {:error, conn, %Mint.TransportError{reason: :timeout}} = + Mint.HTTP.request_and_response(conn, "GET", "/", [], nil, timeout: 0) + + refute HTTP2.open?(conn) + end + end + describe "ping" do test "if we send a PING we then get a :pong reply", %{conn: conn} do assert {:ok, conn, ref} = HTTP2.ping(conn) diff --git a/test/support/mint/http1/test_server.ex b/test/support/mint/http1/test_server.ex index 79632fa0..1a1e2533 100644 --- a/test/support/mint/http1/test_server.ex +++ b/test/support/mint/http1/test_server.ex @@ -1,17 +1,17 @@ defmodule Mint.HTTP1.TestServer do - def start do + def start(fun \\ nil) do {:ok, listen_socket} = :gen_tcp.listen(0, mode: :binary, packet: :raw) server_ref = make_ref() parent = self() - spawn_link(fn -> loop(listen_socket, parent, server_ref) end) + spawn_link(fn -> loop(listen_socket, parent, server_ref, fun) end) with {:ok, port} <- :inet.port(listen_socket) do {:ok, port, server_ref} end end - defp loop(listen_socket, parent, server_ref) do + defp loop(listen_socket, parent, server_ref, fun) do case :gen_tcp.accept(listen_socket) do {:ok, socket} -> send(parent, {server_ref, socket}) @@ -22,7 +22,11 @@ defmodule Mint.HTTP1.TestServer do {:error, :einval} -> :ok end - loop(listen_socket, parent, server_ref) + if fun do + fun.(%{socket: socket, parent: parent}) + end + + loop(listen_socket, parent, server_ref, fun) {:error, :closed} -> :ok