From 802316acdde6d35f3b76ccb03fb0ec40248d9d02 Mon Sep 17 00:00:00 2001 From: Michael Davis Date: Thu, 17 Feb 2022 09:18:58 -0600 Subject: [PATCH] refactor API to respect Mint's opaque structures (#19) --- CHANGELOG.md | 41 +++ README.md | 79 ++-- coveralls.json | 2 +- examples/echo.exs | 12 +- examples/phoenixchat_herokuapp.exs | 16 +- lib/mint/web_socket.ex | 341 ++++++++++++------ mix.exs | 7 +- mix.lock | 5 +- test/fixtures/autobahn_client.ex | 50 +-- test/fixtures/http_handler.ex | 11 + ...websocket_supervisor.ex => test_server.ex} | 5 +- test/mint/web_socket_test.exs | 171 ++++++--- test/test_helper.exs | 2 +- 13 files changed, 487 insertions(+), 255 deletions(-) create mode 100644 test/fixtures/http_handler.ex rename test/fixtures/{websocket_supervisor.ex => test_server.ex} (80%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f166a226..2b08546c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## UNRELEASED + +This release is a breaking change from the 0.1.0 series. This update removes +all instances where Mint.WebSocket would access opaque `t:Mint.HTTP.t/0` fields +or call private functions within `Mint.HTTP1`, so now Mint.WebSocket should be +more compatible with future changes to Mint. + +#### Upgrade guide + +First, add the `scheme` argument to calls to `Mint.WebSocket.upgrade/5`. +For connections formed with `Mint.HTTP.connect(:http, ..)`, use the `:ws` +scheme. For `Mint.HTTP.connect(:https, ..)`, use `:wss`. + + +```diff +- Mint.WebSocket.upgrade(conn, path, headers) ++ Mint.WebSocket.upgrade(scheme, conn, path, headers) +``` + +Then replace calls to `Mint.HTTP.stream/2` and/or `Mint.HTTP.recv/3` and +`Mint.HTTP.stream_request_body/3` with the new `Mint.WebSocket` wrappers. +This is safe to do even when these functions are being used to send and +receive data in normal HTTP requests: the functionality only changes when +the connection is an established HTTP/1 WebSocket. + +### Added + +- Added `Mint.WebSocket.stream/2` which wraps `Mint.HTTP.stream/2` +- Added `Mint.WebSocket.recv/3` which wraps `Mint.HTTP.recv/3` +- Added `Mint.WebSocket.stream_request_body/3` which wraps `Mint.HTTP.stream_request_body/3` + +### Changed + +- Changed function signature of `Mint.Websocket.upgrade/5` to accept the + WebSocket's scheme (`:ws` or `:wss`) as the first argument +- Added an optional `opts` argument to `Mint.WebSocket.new/5` to control + active vs. passive mode on the socket +- Restricted compatible Mint versions to `~> 1.4` + - `Mint.WebSocket` now uses `Mint.HTTP.get_protocol/1` which was + introduced in `1.4.0`. + ## 0.1.4 - 2021-07-06 ### Fixed diff --git a/README.md b/README.md index 9d071fd0..b8f058ad 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,34 @@ (Unofficial) HTTP/1 and HTTP/2 WebSocket support for Mint 🌱 +## Usage + +`Mint.WebSocket` works together with `Mint.HTTP` API. For example, +this snippet shows sending and receiving a text frame of "hello world" to a +WebSocket server which echos our frames: + +```elixir +# bootstrap +{:ok, conn} = Mint.HTTP.connect(:http, "echo", 9000) + +{:ok, conn, ref} = Mint.WebSocket.upgrade(:ws, conn, "/", []) + +http_get_message = receive(do: (message -> message)) +{:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} = + Mint.WebSocket.stream(conn, http_get_message) + +{:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers) + +# send the hello world frame +{:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, "hello world"}) +{:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data) + +# receive the hello world reply frame +hello_world_echo_message = receive(do: (message -> message)) +{:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, hello_world_echo_message) +{:ok, websocket, [{:text, "hello world"}]} = Mint.WebSocket.decode(websocket, data) +``` + ## What is Mint? Mint is a _functional_ HTTP/1 and HTTP/2 client library written in Elixir. @@ -15,9 +43,10 @@ Mint is a _functional_ HTTP/1 and HTTP/2 client library written in Elixir. Why does it matter that it's functional? Isn't Elixir functional? Existing WebSocket implementations like -[`:gun`](https://github.com/ninenines/gun), -[`:websocket_client`](https://github.com/jeremyong/websocket_client), -or [`WebSockex`](https://github.com/Azolo/websockex) work by spawning and +[`:gun`](https://github.com/ninenines/gun) / +[`:websocket_client`](https://github.com/jeremyong/websocket_client) / +[`Socket`](https://github.com/meh/elixir-socket) / +[`WebSockex`](https://github.com/Azolo/websockex) work by spawning and passing messages among processes. This is a very convenient interface in Elixir and Erlang, but it does not allow the author much control over the WebSocket connection. @@ -58,48 +87,6 @@ If `Mint.WebSocket.upgrade/4` returns Then the server does not support HTTP/2 WebSockets or does not have them enabled. -Support for HTTP/2 extended CONNECT was added to Mint in version `1.4.0`. -If you need HTTP/2 support, make sure you require that version as a minimum. - -```elixir -# mix.exs -def deps do - [ - {:mint_web_socket, "~> 0.1"}, - {:mint, "~> 1.4"}, - # .. - ] -end -``` - -## Usage - -`Mint.WebSocket` piggybacks much of the existing `Mint.HTTP` API. For example, -this snippet shows sending and receiving a text frame of "hello world" to a -WebSocket server which echos our frames: - -```elixir -# bootstrap -{:ok, conn} = Mint.HTTP.connect(:http, "echo", 9000) - -{:ok, conn, ref} = Mint.WebSocket.upgrade(conn, "/", []) - -http_get_message = receive(do: (message -> message)) -{:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} = - Mint.HTTP.stream(conn, http_get_message) - -{:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers) - -# send the hello world frame -{:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, "hello world"}) -{:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) - -# receive the hello world reply frame -hello_world_echo_message = receive(do: (message -> message)) -{:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, hello_world_echo_message) -{:ok, websocket, [{:text, "hello world"}]} = Mint.WebSocket.decode(websocket, data) -``` - ## Development workflow Interested in developing `Mint.WebSocket`? The `docker-compose.yml` sets up @@ -108,7 +95,7 @@ fuzzing server. ``` (host)$ docker-compose up -d -(host)$ docker-compose exec app /bin/bash +(host)$ docker-compose exec app bash (app)$ mix deps.get (app)$ mix test (app)$ iex -S mix diff --git a/coveralls.json b/coveralls.json index 50ffe808..65fb72a6 100644 --- a/coveralls.json +++ b/coveralls.json @@ -6,5 +6,5 @@ "terminal_options": { "file_column_width": 60 }, - "skip_files": ["^deps", "^test/compare/"] + "skip_files": ["^deps", "^test/compare/", "^test/fixtures/websocket_"] } diff --git a/examples/echo.exs b/examples/echo.exs index ce36c69a..9b4efac8 100644 --- a/examples/echo.exs +++ b/examples/echo.exs @@ -6,31 +6,31 @@ require Logger Logger.debug("Connected to https://echo.websocket.org:443") Logger.debug("Upgrading to WebSocket protocol on /") -{:ok, conn, ref} = Mint.WebSocket.upgrade(conn, "/", []) +{:ok, conn, ref} = Mint.WebSocket.upgrade(:wss, conn, "/", []) message = receive(do: (message -> message)) {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} = - Mint.HTTP.stream(conn, message) + Mint.WebSocket.stream(conn, message) {:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers) Logger.debug("WebSocket established") frame = {:text, "Rock it with Mint.WebSocket"} Logger.debug("Sending frame #{inspect(frame)}") {:ok, websocket, data} = Mint.WebSocket.encode(websocket, frame) -{:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) +{:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data) message = receive(do: (message -> message)) -{:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, message) +{:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message) {:ok, websocket, frames} = Mint.WebSocket.decode(websocket, data) Logger.debug("Received frames #{inspect(frames)}") frame = :close Logger.debug("Sending frame #{inspect(frame)}") {:ok, websocket, data} = Mint.WebSocket.encode(websocket, frame) -{:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) +{:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data) message = receive(do: (message -> message)) -{:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, message) +{:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message) {:ok, websocket, frames} = Mint.WebSocket.decode(websocket, data) Logger.debug("Received frames #{inspect(frames)}") diff --git a/examples/phoenixchat_herokuapp.exs b/examples/phoenixchat_herokuapp.exs index 416e27a8..a4f55760 100644 --- a/examples/phoenixchat_herokuapp.exs +++ b/examples/phoenixchat_herokuapp.exs @@ -1,33 +1,33 @@ -# N.B. this is a phoenix v1.3 server that sends pings periodically +# this is a phoenix v1.3 server that sends pings periodically # see https://phoenixchat.herokuapp.com for the in-browser version {:ok, conn} = Mint.HTTP.connect(:https, "phoenixchat.herokuapp.com", 443) -{:ok, conn, ref} = Mint.WebSocket.upgrade(conn, "/ws", []) +{:ok, conn, ref} = Mint.WebSocket.upgrade(:wss, conn, "/ws", []) http_get_message = receive(do: (message -> message)) {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} = - Mint.HTTP.stream(conn, http_get_message) + Mint.WebSocket.stream(conn, http_get_message) {:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers) {:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, ~s[{"topic":"rooms:lobby","event":"phx_join","payload":{},"ref":1}]}) -{:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) +{:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data) message = receive(do: (message -> message)) -{:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, message) +{:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message) {:ok, websocket, messages} = Mint.WebSocket.decode(websocket, data) IO.inspect(messages) message = receive(do: (message -> message)) -{:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, message) +{:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message) {:ok, websocket, messages} = Mint.WebSocket.decode(websocket, data) IO.inspect(messages) message = receive(do: (message -> message)) -{:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, message) +{:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message) {:ok, websocket, messages} = Mint.WebSocket.decode(websocket, data) IO.inspect(messages) message = receive(do: (message -> message)) -{:ok, _conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, message) +{:ok, _conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, message) {:ok, _websocket, messages} = Mint.WebSocket.decode(websocket, data) IO.inspect(messages) diff --git a/lib/mint/web_socket.ex b/lib/mint/web_socket.ex index f490375e..6df32945 100644 --- a/lib/mint/web_socket.ex +++ b/lib/mint/web_socket.ex @@ -3,10 +3,8 @@ defmodule Mint.WebSocket do (Unofficial) WebSocket support for the Mint functional HTTP client Like Mint, `Mint.WebSocket` provides a functional, process-less interface - for operating a WebSocket connection. `Mint.WebSocket` is an extension - to Mint: the sending and receiving of messages is done with Mint functions. - Prospective Mint.WebSocket users should first familiarize themselves with - `Mint.HTTP`. + for operating a WebSocket connection. Prospective Mint.WebSocket users + may wish to first familiarize themselves with `Mint.HTTP`. Mint.WebSocket is not fully spec-conformant on its own. Runtime behaviors such as responding to pings with pongs must be implemented by the user of @@ -15,43 +13,43 @@ defmodule Mint.WebSocket do ## Usage A connection formed with `Mint.HTTP.connect/4` can be upgraded to a WebSocket - connection with `upgrade/4`. + connection with `upgrade/5`. ```elixir {:ok, conn} = Mint.HTTP.connect(:http, "localhost", 9_000) - {:ok, conn, ref} = Mint.WebSocket.upgrade(conn, "/", []) + {:ok, conn, ref} = Mint.WebSocket.upgrade(:ws, conn, "/", []) ``` - `upgrade/4` sends an upgrade request to the remote server. The WebSocket + `upgrade/5` sends an upgrade request to the remote server. The WebSocket connection is then built by awaiting the HTTP response from the server. ```elixir http_reply_message = receive(do: (message -> message)) {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} = - Mint.HTTP.stream(conn, http_reply_message) + Mint.WebSocket.stream(conn, http_reply_message) {:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers) ``` - Now that the WebSocket connection has been formed, we use the `websocket` - data structure to encode and decode frames, and the - `Mint.HTTP.stream_request_body/3` and `Mint.HTTP.stream/2` functions from - Mint to perform sending and receiving of encoded frames. + Once the WebSocket connection has been established, use the `websocket` + data structure to encode and decode frames with `encode/2` and `decode/2`, + and send and stream messages with `stream_request_body/3` and `stream/2`. - For example, we'll send a "hello world" text frame across our connection. + For example, one may send a "hello world" text frame across a connection + like so: ```elixir {:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, "hello world"}) - {:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) + {:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data) ``` - And let's say that the server is echoing our messages; let's receive our - echoed "hello world" text frame. + Say that the remote is echoing messages. Use `stream/2` and `decode/2` to + decode a received WebSocket frame: ```elixir echo_message = receive(do: (message -> message)) - {:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, echo_message) + {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, echo_message) {:ok, websocket, [{:text, "hello world"}]} = Mint.WebSocket.decode(websocket, data) ``` @@ -62,14 +60,15 @@ defmodule Mint.WebSocket do writing, very few HTTP/2 server libraries support or enable HTTP/2 WebSockets by default. - `upgrade/4` works on both HTTP/1 and HTTP/2 connections. In order to select + `upgrade/5` works on both HTTP/1 and HTTP/2 connections. In order to select HTTP/2, the `:http2` protocol should be explicitly selected in `Mint.HTTP.connect/4`. ```elixir - {:ok, %Mint.HTTP2{} = conn} = + {:ok, conn} = Mint.HTTP.connect(:http, "websocket.example", 80, protocols: [:http2]) - {:ok, conn, ref} = Mint.WebSocket.upgrade(conn, "/", []) + :http2 = Mint.HTTP.protocol(conn) + {:ok, conn, ref} = Mint.WebSocket.upgrade(:ws, conn, "/", []) ``` If the server does not support the extended CONNECT method needed to bootstrap @@ -96,8 +95,8 @@ defmodule Mint.WebSocket do {:ok, conn} = Mint.HTTP.connect(:https, "websocket.example", 443) ``` - And use `upgrade/4` to upgrade the connection to WebSocket. See the - Mint documentation on SSL for more information. + And pass the `:wss` scheme to `upgrade/5`. See the Mint documentation + on SSL for more information. ## Extensions @@ -113,6 +112,8 @@ defmodule Mint.WebSocket do alias __MODULE__.{Utils, Extension, Frame} alias Mint.WebSocketError + alias Mint.{HTTP1, HTTP2} + import Mint.HTTP, only: [get_private: 2, put_private: 3, protocol: 1] @typedoc """ An immutable data structure representing a WebSocket connection @@ -137,13 +138,8 @@ defmodule Mint.WebSocket do * `:pong` - shorthand for `{:pong, ""}` * `:close` - shorthand for `{:close, nil, nil}` - These may be passed to `encode/2`. - - + These may be passed to `encode/2`. Frames decoded with `decode/2` are always + in `t:frame/0` format. """ @type shorthand_frame :: :ping | :pong | :close @@ -174,10 +170,10 @@ defmodule Mint.WebSocket do ```elixir {:ok, websocket, data} = Mint.WebSocket.encode(websocket, :close) - {:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) + {:ok, conn} = Mint.WebSocket.stream_request_body(conn, ref, data) close_response = receive(do: (message -> message)) - {:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, close_response) + {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.stream(conn, close_response) {:ok, websocket, [{:close, 1_000, ""}]} = Mint.WebSocket.decode(websocket, data) Mint.HTTP.close(conn) @@ -205,6 +201,10 @@ defmodule Mint.WebSocket do an extended CONNECT request which opens a stream to be used for the WebSocket connection. + The `scheme` argument should be either `:ws` or `:wss`, using `:ws` for + connections established by passing `:http` to `Mint.HTTP.connect/4` and + `:wss` corresponding to `:https`. + ## Options * `:extensions` - a list of extensions to negotiate. See the extensions @@ -231,10 +231,11 @@ defmodule Mint.WebSocket do ```elixir {:ok, conn} = Mint.HTTP.connect(:http, "localhost", 9_000) {:ok, conn, ref} = - Mint.WebSocket.upgrade(conn, "/", [], extensions: [Mint.WebSocket.PerMessageDeflate]) + Mint.WebSocket.upgrade(:ws, conn, "/", [], extensions: [Mint.WebSocket.PerMessageDeflate]) # or provide params: {:ok, conn, ref} = Mint.WebSocket.upgrade( + :ws, conn, "/", [], @@ -243,49 +244,49 @@ defmodule Mint.WebSocket do ``` """ @spec upgrade( + scheme :: :ws | :wss, conn :: Mint.HTTP.t(), path :: String.t(), headers :: Mint.Types.headers(), opts :: Keyword.t() ) :: {:ok, Mint.HTTP.t(), Mint.Types.request_ref()} | {:error, Mint.HTTP.t(), error()} - def upgrade(conn, path, headers, opts \\ []) + def upgrade(scheme, conn, path, headers, opts \\ []) when scheme in ~w[ws wss]a do + conn = put_private(conn, :scheme, scheme) - def upgrade(%Mint.HTTP1{} = conn, path, headers, opts) do + do_upgrade(scheme, Mint.HTTP.protocol(conn), conn, path, headers, opts) + end + + defp do_upgrade(_scheme, :http1, conn, path, headers, opts) do nonce = Utils.random_nonce() extensions = get_extensions(opts) conn = conn - |> put_in([Access.key(:private), :sec_websocket_key], nonce) - |> put_in([Access.key(:private), :extensions], extensions) + |> put_private(:sec_websocket_key, nonce) + |> put_private(:extensions, extensions) headers = Utils.headers({:http1, nonce}, extensions) ++ headers - Mint.HTTP.request(conn, "GET", path, headers, nil) + Mint.HTTP1.request(conn, "GET", path, headers, nil) end - def upgrade( - %Mint.HTTP2{server_settings: %{enable_connect_protocol: true}} = conn, - path, - headers, - opts - ) do - extensions = get_extensions(opts) - conn = put_in(conn.private[:extensions], extensions) - - headers = - [ - {":scheme", conn.scheme}, - {":path", path}, - {":protocol", "websocket"} - | headers - ] ++ Utils.headers(:http2, extensions) + defp do_upgrade(scheme, :http2, conn, path, headers, opts) do + if HTTP2.get_server_setting(conn, :enable_connect_protocol) == true do + extensions = get_extensions(opts) + conn = put_private(conn, :extensions, extensions) - Mint.HTTP.request(conn, "CONNECT", path, headers, :stream) - end + headers = + [ + {":scheme", if(scheme == :ws, do: "http", else: "https")}, + {":path", path}, + {":protocol", "websocket"} + | headers + ] ++ Utils.headers(:http2, extensions) - def upgrade(%Mint.HTTP2{} = conn, _path, _headers, _opts) do - {:error, conn, %WebSocketError{reason: :extended_connect_disabled}} + Mint.HTTP2.request(conn, "CONNECT", path, headers, :stream) + else + {:error, conn, %WebSocketError{reason: :extended_connect_disabled}} + end end @doc """ @@ -295,52 +296,216 @@ defmodule Mint.WebSocket do This function will setup any extensions accepted by the server using the `c:Mint.WebSocket.Extension.init/2` callback. + ## Options + + * `:mode` - (default: `:active`) either `:active` or `:passive`. This + corresponds to the same option in `Mint.HTTP.connect/4`. + ## Examples ```elixir http_reply = receive(do: (message -> message)) {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, headers}, {:done, ^ref}]} = - Mint.HTTP.stream(conn, http_reply) + Mint.WebSocket.stream(conn, http_reply) {:ok, conn, websocket} = - Mint.WebSocket.new(conn, ref, status, resp_headers) + Mint.WebSocket.new(:ws, conn, ref, status, resp_headers) ``` """ - @spec new(Mint.HTTP.t(), reference(), Mint.Types.status(), Mint.Types.headers()) :: + @spec new( + Mint.HTTP.t(), + reference(), + Mint.Types.status(), + Mint.Types.headers() + ) :: {:ok, Mint.HTTP.t(), t()} | {:error, Mint.HTTP.t(), error()} - def new(%Mint.HTTP1{} = conn, _request_ref, status, _response_headers) - when status != 101 do + def new(conn, request_ref, status, response_headers, opts \\ []) do + websockets = [request_ref | get_private(conn, :websockets) || []] + + conn = + conn + |> put_private(:websockets, websockets) + |> put_private(:mode, Keyword.get(opts, :mode, :active)) + + do_new(protocol(conn), conn, status, response_headers) + end + + defp do_new(:http1, conn, status, _response_headers) when status != 101 do {:error, conn, %WebSocketError{reason: :connection_not_upgraded}} end - def new(%Mint.HTTP1{} = conn, request_ref, _status, response_headers) do - with :ok <- Utils.check_accept_nonce(conn.private[:sec_websocket_key], response_headers), + defp do_new(:http1, conn, _status, response_headers) do + with :ok <- Utils.check_accept_nonce(get_private(conn, :sec_websocket_key), response_headers), {:ok, extensions} <- - Extension.accept_extensions(conn.private.extensions, response_headers) do - conn = re_open_request(conn, request_ref) - + Extension.accept_extensions(get_private(conn, :extensions), response_headers) do {:ok, conn, %__MODULE__{extensions: extensions}} else {:error, reason} -> {:error, conn, reason} end end - def new(%Mint.HTTP2{} = conn, _request_ref, status, response_headers) - when status in 200..299 do + defp do_new(:http2, conn, status, response_headers) + when status in 200..299 do with {:ok, extensions} <- - Extension.accept_extensions(conn.private.extensions, response_headers) do + Extension.accept_extensions(get_private(conn, :extensions), response_headers) do {:ok, conn, %__MODULE__{extensions: extensions}} end end - def new(%Mint.HTTP2{} = conn, _request_ref, _status, _response_headers) do + defp do_new(:http2, conn, _status, _response_headers) do {:error, conn, %WebSocketError{reason: :connection_not_upgraded}} end + @doc """ + A wrapper around `Mint.HTTP.stream/2` for streaming HTTP and WebSocket + messages + + This function does not decode WebSocket frames. Instead, once a WebSocket + connection has been established, decode any `{:data, request_ref, data}` + frames with `decode/2`. + + This function is a drop-in replacement for `Mint.HTTP.stream/2` which + enables streaming WebSocket data after the bootstrapping HTTP/1 connection + has concluded. It decodes both WebSocket and regular HTTP messages. + + ## Examples + + message = receive(do: (message -> message)) + {:ok, conn, [{:data, ^websocket_ref, data}]} = + Mint.WebSocket.stream(conn, message) + {:ok, websocket, [{:text, "hello world!"}]} = + Mint.WebSocket.decode(websocket, data) + """ + @spec stream(Mint.HTTP.t(), term()) :: + {:ok, Mint.HTTP.t(), [Mint.Types.response()]} + | {:error, Mint.HTTP.t(), Mint.Types.error(), [Mint.Types.response()]} + | :unknown + def stream(conn, message) do + with :http1 <- protocol(conn), + # HTTP/1 only allows one WebSocket per connection + [request_ref] <- get_private(conn, :websockets) do + stream_http1(conn, request_ref, message) + else + _ -> Mint.HTTP.stream(conn, message) + end + end + + # we take manual control of the :gen_tcp and :ssl messages in HTTP/1 because + # we have taken over the transport + defp stream_http1(conn, request_ref, message) do + socket = HTTP1.get_socket(conn) + tag = if get_private(conn, :scheme) == :ws, do: :tcp, else: :ssl + + case message do + {^tag, ^socket, data} -> + reset_mode(conn, [{:data, request_ref, data}]) + + _ -> + HTTP1.stream(conn, message) + end + end + + defp reset_mode(conn, responses) do + module = if get_private(conn, :scheme) == :ws, do: :inet, else: :ssl + + with :active <- get_private(conn, :mode), + {:error, reason} <- module.setopts(HTTP1.get_socket(conn), active: :once) do + {:error, conn, %Mint.TransportError{reason: reason}, responses} + else + _ -> {:ok, conn, responses} + end + end + + @doc """ + Receives data from the socket + + This function is used instead of `stream/2` when the connection is + in `:passive` mode. You must pass the `mode: :passive` option to + `new/5` in order to use `recv/3`. + + This function wraps `Mint.HTTP.recv/3`. See the `Mint.HTTP.recv/3` + documentation for more information. + + ## Examples + + {:ok, conn, [{:data, ^ref, data}]} = Mint.WebSocket.recv(conn, 0, 5_000) + {:ok, websocket, [{:text, "hello world!"}]} = + Mint.WebSocket.decode(websocket, data) + """ + @spec recv(Mint.HTTP.t(), non_neg_integer(), timeout()) :: + {:ok, Mint.HTTP.t(), [Mint.Types.response()]} + | {:error, t(), Mint.Types.error(), [Mint.Types.response()]} + def recv(conn, byte_count, timeout) do + with :http1 <- protocol(conn), + [request_ref] <- get_private(conn, :websockets) do + recv_http1(conn, request_ref, byte_count, timeout) + else + _ -> Mint.HTTP.recv(conn, byte_count, timeout) + end + end + + defp recv_http1(conn, request_ref, byte_count, timeout) do + module = if get_private(conn, :scheme) == :ws, do: :gen_tcp, else: :ssl + socket = HTTP1.get_socket(conn) + + case module.recv(socket, byte_count, timeout) do + {:ok, data} -> + {:ok, conn, [{:data, request_ref, data}]} + + {:error, error} -> + {:error, conn, error, []} + end + end + + @doc """ + Streams chunks of data on the connection + + `stream_request_body/3` should be used to send encoded data on an + established WebSocket connection that has already been upgraded with + `upgrade/5`. + + This function is a wrapper around `Mint.HTTP.stream_request_body/3`. It + delegates to that function unless the `request_ref` belongs to an HTTP/1 + WebSocket connection. When the request is an HTTP/1 WebSocket, this + function allows sending data on a request which Mint considers to be + closed, but is actually a valid WebSocket connection. + + See the `Mint.HTTP.stream_request_body/3` documentation for more + information. + + ## Examples + + {:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, "hello world!"}) + {:ok, conn} = Mint.WebSocket.stream_request_body(conn, websocket_ref, data) + """ + @spec stream_request_body( + Mint.HTTP.t(), + Mint.Types.request_ref(), + iodata() | :eof | {:eof, trailing_headers :: Mint.Types.headers()} + ) :: {:ok, Mint.HTTP.t()} | {:error, Mint.HTTP.t(), error()} + def stream_request_body(conn, request_ref, data) do + with :http1 <- protocol(conn), + [^request_ref] <- get_private(conn, :websockets), + data when is_binary(data) or is_list(data) <- data do + stream_request_body_http1(conn, data) + else + _ -> Mint.HTTP.stream_request_body(conn, request_ref, data) + end + end + + defp stream_request_body_http1(conn, data) do + transport = if get_private(conn, :scheme) == :ws, do: :gen_tcp, else: :ssl + + case transport.send(Mint.HTTP1.get_socket(conn), data) do + :ok -> {:ok, conn} + {:error, reason} -> {:error, conn, %Mint.TransportError{reason: reason}} + end + end + @doc """ Encodes a frame into a binary - The resulting binary may be sent with `Mint.HTTP.stream_request_body/3`. + The resulting binary may be sent with `stream_request_body/3`. This function will invoke the `c:Mint.WebSocket.Extension.encode/2` callback for any accepted extensions. @@ -349,7 +514,7 @@ defmodule Mint.WebSocket do ```elixir {:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, "hello world"}) - {:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) + {:ok, conn} = Mint.WebSocket.stream_request_body(conn, websocket_ref, data) ``` """ @spec encode(t(), shorthand_frame() | frame()) :: {:ok, t(), binary()} | {:error, t(), any()} @@ -375,36 +540,6 @@ defmodule Mint.WebSocket do {:ok, t(), [frame() | {:error, term()}]} | {:error, t(), any()} defdelegate decode(websocket, data), to: Frame - # we re-open the request in the conn for HTTP1 connections because a :done - # will complete the request - defp re_open_request(%Mint.HTTP1{} = conn, request_ref) do - request = new_request(request_ref, nil, :stream, :identity) - %{conn | request: %{request | state: :body}, streaming_request: request} - end - - defp new_request(ref, method, body, encoding) do - state = - if body == :stream do - {:stream_request, encoding} - else - :status - end - - %{ - ref: ref, - state: state, - method: method, - version: nil, - status: nil, - headers_buffer: [], - data_buffer: [], - content_length: nil, - connection: [], - transfer_encoding: [], - body: nil - } - end - defp get_extensions(opts) do opts |> Keyword.get(:extensions, []) diff --git a/mix.exs b/mix.exs index a0470e2d..731a6f0d 100644 --- a/mix.exs +++ b/mix.exs @@ -43,12 +43,7 @@ defmodule MintWebSocket.MixProject do defp deps do [ - {:mint, "~> 1.0"}, - # for running the :http2 tests (mix test --include http2) - # {:mint, - # git: "https://github.com/elixir-mint/mint.git", - # ref: "488a6ba5fd418a52f697a8d5f377c629ea96af92", - # override: true}, + {:mint, "~> 1.4 and >= 1.4.1"}, {:ex_doc, "~> 0.24", only: [:dev], runtime: false}, {:castore, ">= 0.0.0", only: [:dev]}, {:jason, ">= 0.0.0", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index 5c65a7e9..bc794788 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "bless": {:hex, :bless, "1.1.0", "b27571e49a4ba22d92412184740b186448a27c3ddf2e9e687851e937932b5a60", [:mix], [], "hexpm", "ea5dc19a36609f927d4035a8409393cd3016b5c482245cffa30b3bd6669c617e"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "castore": {:hex, :castore, "0.1.10", "b01a007416a0ae4188e70b3b306236021b16c11474038ead7aff79dd75538c23", [:mix], [], "hexpm", "a48314e0cb45682db2ea27b8ebfa11bd6fa0a6e21a65e5772ad83ca136ff2665"}, + "castore": {:hex, :castore, "0.1.13", "ccf3ab251ffaebc4319f41d788ce59a6ab3f42b6c27e598ad838ffecee0b04f9", [:mix], [], "hexpm", "a14a7eecfec7e20385493dbb92b0d12c5d77ecfd6307de10102d58c94e8c49c0"}, "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, @@ -12,6 +12,7 @@ "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "gun": {:hex, :gun, "2.0.0-rc.2", "7c489a32dedccb77b6e82d1f3c5a7dadfbfa004ec14e322cdb5e579c438632d2", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "6b9d1eae146410d727140dbf8b404b9631302ecc2066d1d12f22097ad7d254fc"}, "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, + "hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, @@ -19,7 +20,7 @@ "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mint": {:hex, :mint, "1.3.0", "396b3301102f7b775e103da5a20494b25753aed818d6d6f0ad222a3a018c3600", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "a9aac960562e43ca69a77e5176576abfa78b8398cec5543dd4fb4ab0131d5c1e"}, + "mint": {:hex, :mint, "1.4.1", "49b3b6ea35a9a38836d2ad745251b01ca9ec062f7cb66f546bf22e6699137126", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "cd261766e61011a9079cccf8fa9d826e7a397c24fbedf0e11b49312bea629b58"}, "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, diff --git a/test/fixtures/autobahn_client.ex b/test/fixtures/autobahn_client.ex index 1ba6d2d2..6d93e238 100644 --- a/test/fixtures/autobahn_client.ex +++ b/test/fixtures/autobahn_client.ex @@ -13,7 +13,7 @@ defmodule AutobahnClient do when frame == :close or (is_tuple(frame) and elem(frame, 0) == :close) def get_case_count do - %{messages: [{:text, count} | _]} = connect("/getCaseCount") |> get_once() + %{messages: [{:text, count} | _]} = connect("/getCaseCount") |> decode_buffer() String.to_integer(count) end @@ -26,14 +26,14 @@ defmodule AutobahnClient do def get_case_status(case_number) do %{messages: [{:text, status} | _]} = - connect("/getCaseStatus?case=#{case_number}&agent=Mint") |> get_once() + connect("/getCaseStatus?case=#{case_number}&agent=Mint") |> decode_buffer() Jason.decode!(status)["behavior"] end def get_case_info(case_number) do %{messages: [{:text, status} | _]} = - connect("/getCaseInfo?case=#{case_number}&agent=Mint") |> get_once() + connect("/getCaseInfo?case=#{case_number}&agent=Mint") |> decode_buffer() Jason.decode!(status, keys: :atoms) end @@ -52,38 +52,37 @@ defmodule AutobahnClient do end end - defp get_once(state) do - case decode_buffer(state) do - %{messages: []} = state -> state |> recv() |> get_once() - state -> state - end - end - def connect(resource, extensions \\ []) do :ok = flush() host = System.get_env("FUZZINGSERVER_HOST") || "localhost" {:ok, conn} = Mint.HTTP.connect(:http, host, 9001) - {:ok, conn, ref} = Mint.WebSocket.upgrade(conn, resource, [], extensions: extensions) + {:ok, conn, ref} = Mint.WebSocket.upgrade(:ws, conn, resource, [], extensions: extensions) http_get_message = receive(do: (message -> message)) - {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} = - Mint.HTTP.stream(conn, http_get_message) + {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers} | rest]} = + Mint.WebSocket.stream(conn, http_get_message) + + buffer = + case rest do + [{:data, ^ref, data}, {:done, ^ref}] -> data + [{:done, ^ref}] -> <<>> + end {:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers) %__MODULE__{ next: :cont, - conn: %{conn | buffer: <<>>}, + conn: conn, ref: ref, websocket: websocket, - buffer: conn.buffer + buffer: buffer } end def recv(%{ref: ref} = state) do - {:ok, conn, messages} = Mint.HTTP.stream(state.conn, receive(do: (message -> message))) + {:ok, conn, messages} = Mint.WebSocket.stream(state.conn, receive(do: (message -> message))) %__MODULE__{ state @@ -94,19 +93,9 @@ defmodule AutobahnClient do end def decode_buffer(state) do - case Mint.WebSocket.decode(state.websocket, state.buffer) do - {:ok, websocket, messages} -> - %__MODULE__{state | messages: messages, buffer: <<>>, websocket: websocket} + {:ok, websocket, messages} = Mint.WebSocket.decode(state.websocket, state.buffer) - {:error, websocket, reason} -> - Logger.debug( - "Could not parse buffer #{inspect(state.buffer, printable_limit: 30)}" <> - " because #{inspect(reason)}, sending close" - ) - - %__MODULE__{state | websocket: websocket} - |> close(close_code_for_reason(reason), "Malformed payload") - end + %__MODULE__{state | messages: messages, buffer: <<>>, websocket: websocket} end def loop(state) do @@ -162,7 +151,7 @@ defmodule AutobahnClient do with {:ok, %Mint.WebSocket{} = websocket, data} <- Mint.WebSocket.encode(state.websocket, frame), - {:ok, conn} <- Mint.HTTP.stream_request_body(state.conn, state.ref, data) do + {:ok, conn} <- Mint.WebSocket.stream_request_body(state.conn, state.ref, data) do Logger.debug("Sent.") %__MODULE__{state | conn: conn, websocket: websocket, sent_close?: is_close_frame(frame)} else @@ -189,9 +178,6 @@ defmodule AutobahnClient do %__MODULE__{state | conn: conn, next: :stop} end - defp close_code_for_reason({:invalid_utf8, _data}), do: 1007 - defp close_code_for_reason(_), do: 1002 - defp join_data_frames(messages, ref) do messages |> Enum.filter(fn diff --git a/test/fixtures/http_handler.ex b/test/fixtures/http_handler.ex new file mode 100644 index 00000000..e54aff67 --- /dev/null +++ b/test/fixtures/http_handler.ex @@ -0,0 +1,11 @@ +defmodule HttpHandler do + @moduledoc """ + A Cowboy HTTP handler that serves a GET request in the test suite + """ + + def init(req, state) do + req = :cowboy_req.reply(200, %{"content_type" => "text/plain"}, "hi!", req) + + {:ok, req, state} + end +end diff --git a/test/fixtures/websocket_supervisor.ex b/test/fixtures/test_server.ex similarity index 80% rename from test/fixtures/websocket_supervisor.ex rename to test/fixtures/test_server.ex index 456a7787..945f4416 100644 --- a/test/fixtures/websocket_supervisor.ex +++ b/test/fixtures/test_server.ex @@ -1,4 +1,4 @@ -defmodule WebsocketSupervisor do +defmodule TestServer do @moduledoc """ A supervisor for the WebsocketHandler """ @@ -10,7 +10,8 @@ defmodule WebsocketSupervisor do :cowboy_router.compile([ {:_, [ - {'/', WebsocketHandler, []} + {'/', WebsocketHandler, []}, + {'/http_get', HttpHandler, []} ]} ]) diff --git a/test/mint/web_socket_test.exs b/test/mint/web_socket_test.exs index e977a40f..0439800c 100644 --- a/test/mint/web_socket_test.exs +++ b/test/mint/web_socket_test.exs @@ -1,110 +1,185 @@ defmodule Mint.WebSocketTest do use ExUnit.Case, async: true - describe "given an HTTP/1 connection to an echo server" do + alias Mint.{HTTP1, HTTP2, WebSocket} + + setup_all do + # a cowboy test server used by the HTTP/2 tests + start_supervised!(TestServer) + :ok + end + + describe "given an active HTTP/1 connection to an echo server" do setup do host = System.get_env("ECHO_HOST") || "localhost" - {:ok, conn} = Mint.HTTP.connect(:http, host, 9000) + {:ok, conn} = HTTP1.connect(:http, host, 9000) [conn: conn] end test "we can send and hello-world frame and receive an echo reply", %{conn: conn} do - {:ok, conn, ref} = Mint.WebSocket.upgrade(conn, "/", []) + {:ok, conn, ref} = WebSocket.upgrade(:ws, conn, "/", []) assert_receive http_get_message {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} = - Mint.HTTP.stream(conn, http_get_message) + WebSocket.stream(conn, http_get_message) - {:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers) + {:ok, conn, websocket} = WebSocket.new(conn, ref, status, resp_headers) # send the hello world frame - {:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, "hello world"}) - {:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) + {:ok, websocket, data} = WebSocket.encode(websocket, {:text, "hello world"}) + {:ok, conn} = WebSocket.stream_request_body(conn, ref, data) # receive the hello world reply frame assert_receive hello_world_echo_message - {:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, hello_world_echo_message) - assert {:ok, websocket, [{:text, "hello world"}]} = Mint.WebSocket.decode(websocket, data) + {:ok, conn, [{:data, ^ref, data}]} = WebSocket.stream(conn, hello_world_echo_message) + assert {:ok, websocket, [{:text, "hello world"}]} = WebSocket.decode(websocket, data) # send a ping frame - {:ok, websocket, data} = Mint.WebSocket.encode(websocket, :ping) - {:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) + {:ok, websocket, data} = WebSocket.encode(websocket, :ping) + {:ok, conn} = WebSocket.stream_request_body(conn, ref, data) # receive a pong frame assert_receive pong_message - {:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, pong_message) - assert {:ok, websocket, [{:pong, ""}]} = Mint.WebSocket.decode(websocket, data) + {:ok, conn, [{:data, ^ref, data}]} = WebSocket.stream(conn, pong_message) + assert {:ok, websocket, [{:pong, ""}]} = WebSocket.decode(websocket, data) # send a close frame - {:ok, websocket, data} = Mint.WebSocket.encode(websocket, :close) - {:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) + {:ok, websocket, data} = WebSocket.encode(websocket, :close) + {:ok, conn} = WebSocket.stream_request_body(conn, ref, data) # receive a close frame assert_receive close_message - {:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, close_message) - assert {:ok, _websocket, [{:close, 1_000, ""}]} = Mint.WebSocket.decode(websocket, data) + {:ok, conn, [{:data, ^ref, data}]} = WebSocket.stream(conn, close_message) + assert {:ok, _websocket, [{:close, 1_000, ""}]} = WebSocket.decode(websocket, data) - {:ok, _conn} = Mint.HTTP.close(conn) + {:ok, _conn} = HTTP1.close(conn) end end - describe "given an HTTP/2 connection to an echo server" do + describe "given a passive HTTP/1 connection to an echo server" do setup do - start_supervised!(WebsocketSupervisor) - - {:ok, conn} = Mint.HTTP.connect(:http, "localhost", 7070, protocols: [:http2]) + host = System.get_env("ECHO_HOST") || "localhost" + {:ok, conn} = HTTP1.connect(:http, host, 9000, mode: :passive) [conn: conn] end - @tag :http2 - test "we can send and hello-world frame and receive an echo reply", %{conn: conn} do - {:ok, conn, ref} = - Mint.WebSocket.upgrade(conn, "/", [], extensions: [Mint.WebSocket.PerMessageDeflate]) + test "we can send and receive frames (with recv/3)", %{conn: conn} do + {:ok, conn, ref} = WebSocket.upgrade(:ws, conn, "/", []) + + {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}, {:done, ^ref}]} = + WebSocket.recv(conn, 0, 5_000) + + {:ok, conn, websocket} = WebSocket.new(conn, ref, status, resp_headers, mode: :passive) + + # send the hello world frame + {:ok, websocket, data} = WebSocket.encode(websocket, {:text, "hello world"}) + {:ok, conn} = WebSocket.stream_request_body(conn, ref, data) + + # receive the hello world reply frame + {:ok, conn, [{:data, ^ref, data}]} = WebSocket.recv(conn, 0, 5_000) + assert {:ok, websocket, [{:text, "hello world"}]} = WebSocket.decode(websocket, data) + + # send a close frame + {:ok, websocket, data} = WebSocket.encode(websocket, :close) + {:ok, conn} = WebSocket.stream_request_body(conn, ref, data) + + # receive a close frame + {:ok, conn, [{:data, ^ref, data}]} = WebSocket.recv(conn, 0, 5_000) + assert {:ok, _websocket, [{:close, 1_000, ""}]} = WebSocket.decode(websocket, data) + + {:ok, _conn} = HTTP1.close(conn) + end + end + + describe "given an HTTP/2 WebSocket connection to an echo server" do + setup do + {:ok, conn} = HTTP2.connect(:http, "localhost", 7070) - assert_receive http_connect_message + {:ok, conn, ref} = + WebSocket.upgrade(:ws, conn, "/", [], extensions: [WebSocket.PerMessageDeflate]) {:ok, conn, [{:status, ^ref, status}, {:headers, ^ref, resp_headers}]} = - case Mint.HTTP.stream(conn, http_connect_message) do - {:ok, conn, []} -> - assert_receive http_connect_message - Mint.HTTP.stream(conn, http_connect_message) + stream_until_responses(conn) + + {:ok, conn, websocket} = WebSocket.new(conn, ref, status, resp_headers) - other -> - other - end + [conn: conn, ref: ref, websocket: websocket] + end - {:ok, conn, websocket} = Mint.WebSocket.new(conn, ref, status, resp_headers) + @tag :http2 + test "we can send and hello-world frame and receive an echo reply", c do + ref = c.ref # send the hello world frame - {:ok, websocket, data} = Mint.WebSocket.encode(websocket, {:text, "hello world"}) - {:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) + {:ok, websocket, data} = WebSocket.encode(c.websocket, {:text, "hello world"}) + {:ok, conn} = WebSocket.stream_request_body(c.conn, ref, data) # receive the hello world reply frame assert_receive hello_world_echo_message - {:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, hello_world_echo_message) - assert {:ok, websocket, [{:text, "hello world"}]} = Mint.WebSocket.decode(websocket, data) + {:ok, conn, [{:data, ^ref, data}]} = WebSocket.stream(conn, hello_world_echo_message) + assert {:ok, websocket, [{:text, "hello world"}]} = WebSocket.decode(websocket, data) # send a ping frame - {:ok, websocket, data} = Mint.WebSocket.encode(websocket, :ping) - {:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) + {:ok, websocket, data} = WebSocket.encode(websocket, :ping) + {:ok, conn} = WebSocket.stream_request_body(conn, ref, data) # receive a pong frame assert_receive pong_message - {:ok, conn, [{:data, ^ref, data}]} = Mint.HTTP.stream(conn, pong_message) - assert {:ok, websocket, [{:pong, ""}]} = Mint.WebSocket.decode(websocket, data) + {:ok, conn, [{:data, ^ref, data}]} = WebSocket.stream(conn, pong_message) + assert {:ok, websocket, [{:pong, ""}]} = WebSocket.decode(websocket, data) # send a close frame - {:ok, websocket, data} = Mint.WebSocket.encode(websocket, :close) - {:ok, conn} = Mint.HTTP.stream_request_body(conn, ref, data) + {:ok, websocket, data} = WebSocket.encode(websocket, :close) + {:ok, conn} = WebSocket.stream_request_body(conn, ref, data) # receive a close frame assert_receive close_message - {:ok, conn, [{:data, ^ref, data}, {:done, ^ref}]} = Mint.HTTP.stream(conn, close_message) - assert {:ok, _websocket, [{:close, 1_000, ""}]} = Mint.WebSocket.decode(websocket, data) - {:ok, _conn} = Mint.HTTP.close(conn) + {:ok, conn, [{:data, ^ref, data}, {:done, ^ref}]} = WebSocket.stream(conn, close_message) + + assert {:ok, _websocket, [{:close, 1_000, ""}]} = WebSocket.decode(websocket, data) + + {:ok, _conn} = HTTP2.close(conn) + end + + test "we can multiplex WebSocket and HTTP traffic", c do + websocket_ref = c.ref + + {:ok, conn, http_ref} = HTTP2.request(c.conn, "GET", "/http_get", [], nil) + + assert_receive http_get_response + + assert {:ok, conn, + [ + {:status, ^http_ref, 200}, + {:headers, ^http_ref, _headers}, + {:data, ^http_ref, "hi!"}, + {:done, ^http_ref} + ]} = WebSocket.stream(conn, http_get_response) + + # send the hello world frame + {:ok, websocket, data} = WebSocket.encode(c.websocket, {:text, "hello world"}) + {:ok, conn} = WebSocket.stream_request_body(conn, websocket_ref, data) + + # receive the hello world reply frame + assert_receive hello_world_echo_message + + {:ok, conn, [{:data, ^websocket_ref, data}]} = + WebSocket.stream(conn, hello_world_echo_message) + + assert {:ok, _websocket, [{:text, "hello world"}]} = WebSocket.decode(websocket, data) + + {:ok, _conn} = HTTP2.close(conn) + end + end + + # cowboy's WebSocket is a little weird here, is it sending SETTINGS frames and then + # frames with content? will need to crack open WireShark to tell + defp stream_until_responses(conn) do + with {:ok, conn, []} <- WebSocket.stream(conn, receive(do: (message -> message))) do + stream_until_responses(conn) end end end diff --git a/test/test_helper.exs b/test/test_helper.exs index fa878058..fb2f49a4 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,2 +1,2 @@ -ExUnit.configure(assert_receive_timeout: 500, exclude: [:http2, compression: :stress]) +ExUnit.configure(assert_receive_timeout: 500, exclude: [compression: :stress]) ExUnit.start()