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 support for protocol upgrades #1119

Merged
merged 11 commits into from
Oct 31, 2022
10 changes: 10 additions & 0 deletions lib/plug/adapters/test/conn.ex
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ defmodule Plug.Adapters.Test.Conn do
:ok
end

def upgrade(%{owner: owner, ref: ref} = state, :supported = protocol, opts) do
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't this be :websocket instead of :supported? Because I think folks may want to test that a given connection upgrades at certain moments?

Copy link
Member

Choose a reason for hiding this comment

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

In fact, maybe we should allow everything and only support :unsupported to test the :unsupported cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'd intentionally used :supported and :unsupported to help illustrate that this implementation was only useful to test whether or not an upgrade is supported or not (specifically, users wouldn't get confused seeing :websocket and wondering why a full-fledged client upgrade didn't work).

The test process still gets send a message in this case, which should be sufficient for users to test against. Really, all this needs to do is be able to replicate the return value for a supported and unsupported upgrade.

Copy link
Member

Choose a reason for hiding this comment

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

But as-is it means I wouldn’t be able to test that a specific route is triggering a websocket upgrade, right? Because we would get a function clause error?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh yes, I see your concern now. I suppose given that this is a test plug all bets are off with respect to upgrades (or anything else, really) actually doing anything.

I'll update

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

send(owner, {ref, :upgrade, {protocol, opts}})
{:ok, state}
end

def upgrade(%{owner: owner, ref: ref}, :unsupported = protocol, opts) do
send(owner, {ref, :upgrade, {protocol, opts}})
{:error, :not_supported}
end

def push(%{owner: owner, ref: ref}, path, headers) do
send(owner, {ref, :push, {path, headers}})
:ok
Expand Down
148 changes: 83 additions & 65 deletions lib/plug/conn.ex
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ defmodule Plug.Conn do
The connection state is used to track the connection lifecycle. It starts as
`:unset` but is changed to `:set` (via `resp/3`) or `:set_chunked`
(used only for `before_send` callbacks by `send_chunked/2`) or `:file`
(when invoked via `send_file/3`). Its final result is `:sent`, `:file` or
`:chunked` depending on the response model.
(when invoked via `send_file/3`). Its final result is `:sent`, `:file`, `:chunked`
or `:upgraded` depending on the response model.

## Private fields

Expand Down Expand Up @@ -150,6 +150,17 @@ defmodule Plug.Conn do
Even though 404 has been overridden, the `:not_found` atom can still be used
to set the status to 404 as well as the new atom `:actually_this_was_found`
inflected from the reason phrase "Actually This Was Found".

## Protocol Upgrades

Plug provides basic support for protocol upgrades via the `upgrade_adapter/3`
function to facilitate connection upgrades to protocols such as WebSockets.
As the name suggests, this functionality is adapter-dependent and the
functionality & requirements of a given upgrade require explicit coordination
between a Plug application & the underlying adapter. Plug provides upgrade
related functionality only to the extent necessary to allow a Plug application
to request protocol upgrades from the underlying adapter. See the documentation
for `upgrade_adapter/3` for details.
"""

@type adapter :: {module, term}
Expand All @@ -172,7 +183,7 @@ defmodule Plug.Conn do
@type scheme :: :http | :https
@type secret_key_base :: binary | nil
@type segments :: [binary]
@type state :: :unset | :set | :set_chunked | :set_file | :file | :chunked | :sent
@type state :: :unset | :set | :set_chunked | :set_file | :file | :chunked | :sent | :upgraded
@type status :: atom | int_status

@type t :: %__MODULE__{
Expand Down Expand Up @@ -390,7 +401,7 @@ defmodule Plug.Conn do
atoms is available in `Plug.Conn.Status`.

Raises a `Plug.Conn.AlreadySentError` if the connection has already been
`:sent` or `:chunked`.
`:sent`, `:chunked` or `:upgraded`.
mtrudel marked this conversation as resolved.
Show resolved Hide resolved

## Examples

Expand All @@ -399,7 +410,10 @@ defmodule Plug.Conn do

"""
@spec put_status(t, status) :: t
def put_status(%Conn{state: :sent}, _status), do: raise(AlreadySentError)
def put_status(%Conn{state: state}, _status) when state not in @unsent do
josevalim marked this conversation as resolved.
Show resolved Hide resolved
raise AlreadySentError
end

def put_status(%Conn{} = conn, nil), do: %{conn | status: nil}
def put_status(%Conn{} = conn, status), do: %{conn | status: Plug.Conn.Status.code(status)}

Expand All @@ -408,7 +422,7 @@ defmodule Plug.Conn do

It expects the connection state to be `:set`, otherwise raises an
`ArgumentError` for `:unset` connections or a `Plug.Conn.AlreadySentError` for
already `:sent` connections.
already `:sent`, `:chunked` or `:upgraded` connections.

At the end sets the connection state to `:sent`.

Expand Down Expand Up @@ -451,7 +465,7 @@ defmodule Plug.Conn do
If available, the file is sent directly over the socket using
the operating system `sendfile` operation.

It expects a connection that has not been `:sent` yet and sets its
It expects a connection that has not been `:sent`, `:chunked` or `:upgraded` yet and sets its
state to `:file` afterwards. Otherwise raises `Plug.Conn.AlreadySentError`.

## Examples
Expand Down Expand Up @@ -494,7 +508,7 @@ defmodule Plug.Conn do
@doc """
Sends the response headers as a chunked response.

It expects a connection that has not been `:sent` yet and sets its
It expects a connection that has not been `:sent` or `:upgraded` yet and sets its
state to `:chunked` afterwards. Otherwise, raises `Plug.Conn.AlreadySentError`.
After `send_chunked/2` is called, chunks can be sent to the client via
the `chunk/2` function.
Expand Down Expand Up @@ -590,7 +604,7 @@ defmodule Plug.Conn do
Sets the response to the given `status` and `body`.

It sets the connection state to `:set` (if not already `:set`)
and raises `Plug.Conn.AlreadySentError` if it was already `:sent`.
and raises `Plug.Conn.AlreadySentError` if it was already `:sent`, `:chunked` or `:upgraded`.

If you also want to send the response, use `send_resp/1` after this
or use `send_resp/3`.
Expand Down Expand Up @@ -674,7 +688,7 @@ defmodule Plug.Conn do
headers that aren't lowercase will raise a `Plug.Conn.InvalidHeaderError`.

Raises a `Plug.Conn.AlreadySentError` if the connection has already been
`:sent` or `:chunked`.
`:sent`, `:chunked` or `:upgraded`.

## Examples

Expand All @@ -684,11 +698,7 @@ defmodule Plug.Conn do
@spec prepend_req_headers(t, headers) :: t
def prepend_req_headers(conn, headers)

def prepend_req_headers(%Conn{state: :sent}, _headers) do
raise AlreadySentError
end

def prepend_req_headers(%Conn{state: :chunked}, _headers) do
def prepend_req_headers(%Conn{state: state}, _headers) when state not in @unsent do
josevalim marked this conversation as resolved.
Show resolved Hide resolved
raise AlreadySentError
end

Expand Down Expand Up @@ -723,11 +733,7 @@ defmodule Plug.Conn do
@spec merge_req_headers(t, Enum.t()) :: t
def merge_req_headers(conn, headers)

def merge_req_headers(%Conn{state: :sent}, _headers) do
raise AlreadySentError
end

def merge_req_headers(%Conn{state: :chunked}, _headers) do
def merge_req_headers(%Conn{state: state}, _headers) when state not in @unsent do
raise AlreadySentError
end

Expand Down Expand Up @@ -762,7 +768,7 @@ defmodule Plug.Conn do
headers that aren't lowercase will raise a `Plug.Conn.InvalidHeaderError`.

Raises a `Plug.Conn.AlreadySentError` if the connection has already been
`:sent` or `:chunked`.
`:sent`, `:chunked` or `:upgraded`.

## Examples

Expand All @@ -772,7 +778,7 @@ defmodule Plug.Conn do
@spec put_req_header(t, binary, binary) :: t
def put_req_header(conn, key, value)

def put_req_header(%Conn{state: :sent}, _key, _value) do
def put_req_header(%Conn{state: state}, _key, _value) when state not in @unsent do
raise AlreadySentError
end

Expand All @@ -786,7 +792,7 @@ defmodule Plug.Conn do
Deletes a request header if present.

Raises a `Plug.Conn.AlreadySentError` if the connection has already been
`:sent` or `:chunked`.
`:sent`, `:chunked` or `:upgraded`.

## Examples

Expand All @@ -796,11 +802,7 @@ defmodule Plug.Conn do
@spec delete_req_header(t, binary) :: t
def delete_req_header(conn, key)

def delete_req_header(%Conn{state: :sent}, _key) do
raise AlreadySentError
end

def delete_req_header(%Conn{state: :chunked}, _key) do
def delete_req_header(%Conn{state: state}, _key) when state not in @unsent do
raise AlreadySentError
end

Expand All @@ -814,7 +816,7 @@ defmodule Plug.Conn do
value.

Raises a `Plug.Conn.AlreadySentError` if the connection has already been
`:sent` or `:chunked`.
`:sent`, `:chunked` or `:upgraded`.

Only the first value of the header `key` is updated if present.

Expand All @@ -831,11 +833,7 @@ defmodule Plug.Conn do
@spec update_req_header(t, binary, binary, (binary -> binary)) :: t
def update_req_header(conn, key, initial, fun)

def update_req_header(%Conn{state: :sent}, _key, _initial, _fun) do
raise AlreadySentError
end

def update_req_header(%Conn{state: :chunked}, _key, _initial, _fun) do
def update_req_header(%Conn{state: state}, _key, _initial, _fun) when state not in @unsent do
raise AlreadySentError
end

Expand Down Expand Up @@ -875,7 +873,7 @@ defmodule Plug.Conn do
headers that aren't lowercase will raise a `Plug.Conn.InvalidHeaderError`.

Raises a `Plug.Conn.AlreadySentError` if the connection has already been
`:sent` or `:chunked`.
`:sent`, `:chunked` or `:upgraded`.

Raises a `Plug.Conn.InvalidHeaderError` if the header value contains control
feed (`\r`) or newline (`\n`) characters.
Expand All @@ -886,11 +884,7 @@ defmodule Plug.Conn do

"""
@spec put_resp_header(t, binary, binary) :: t
def put_resp_header(%Conn{state: :sent}, _key, _value) do
raise AlreadySentError
end

def put_resp_header(%Conn{state: :chunked}, _key, _value) do
def put_resp_header(%Conn{state: state}, _key, _value) when state not in @unsent do
raise AlreadySentError
end

Expand All @@ -916,7 +910,7 @@ defmodule Plug.Conn do
headers that aren't lowercase will raise a `Plug.Conn.InvalidHeaderError`.

Raises a `Plug.Conn.AlreadySentError` if the connection has already been
`:sent` or `:chunked`.
`:sent`, `:chunked` or `:upgraded`.

Raises a `Plug.Conn.InvalidHeaderError` if the header value contains control
feed (`\r`) or newline (`\n`) characters.
Expand All @@ -929,11 +923,7 @@ defmodule Plug.Conn do
@spec prepend_resp_headers(t, headers) :: t
def prepend_resp_headers(conn, headers)

def prepend_resp_headers(%Conn{state: :sent}, _headers) do
raise AlreadySentError
end

def prepend_resp_headers(%Conn{state: :chunked}, _headers) do
def prepend_resp_headers(%Conn{state: state}, _headers) when state not in @unsent do
raise AlreadySentError
end

Expand Down Expand Up @@ -965,11 +955,7 @@ defmodule Plug.Conn do
@spec merge_resp_headers(t, Enum.t()) :: t
def merge_resp_headers(conn, headers)

def merge_resp_headers(%Conn{state: :sent}, _headers) do
raise AlreadySentError
end

def merge_resp_headers(%Conn{state: :chunked}, _headers) do
def merge_resp_headers(%Conn{state: state}, _headers) when state not in @unsent do
raise AlreadySentError
end

Expand All @@ -993,19 +979,15 @@ defmodule Plug.Conn do
Deletes a response header if present.

Raises a `Plug.Conn.AlreadySentError` if the connection has already been
`:sent` or `:chunked`.
`:sent`, `:chunked` or `:upgraded`.

## Examples

Plug.Conn.delete_resp_header(conn, "content-type")

"""
@spec delete_resp_header(t, binary) :: t
def delete_resp_header(%Conn{state: :sent}, _key) do
raise AlreadySentError
end

def delete_resp_header(%Conn{state: :chunked}, _key) do
def delete_resp_header(%Conn{state: state}, _key) when state not in @unsent do
raise AlreadySentError
end

Expand All @@ -1019,7 +1001,7 @@ defmodule Plug.Conn do
value.

Raises a `Plug.Conn.AlreadySentError` if the connection has already been
`:sent` or `:chunked`.
`:sent`, `:chunked` or `:upgraded`.

Only the first value of the header `key` is updated if present.

Expand All @@ -1036,11 +1018,7 @@ defmodule Plug.Conn do
@spec update_resp_header(t, binary, binary, (binary -> binary)) :: t
def update_resp_header(conn, key, initial, fun)

def update_resp_header(%Conn{state: :sent}, _key, _initial, _fun) do
raise AlreadySentError
end

def update_resp_header(%Conn{state: :chunked}, _key, _initial, _fun) do
def update_resp_header(%Conn{state: state}, _key, _initial, _fun) when state not in @unsent do
raise AlreadySentError
end

Expand Down Expand Up @@ -1388,6 +1366,44 @@ defmodule Plug.Conn do
defp adapter_inform(%Conn{adapter: {adapter, payload}}, status, headers),
do: adapter.inform(payload, status, headers)

@doc """
Request a protocol upgrade from the underlying HTTP adapter.
mtrudel marked this conversation as resolved.
Show resolved Hide resolved

The precise semantics of an upgrade are deliberately left unspecified here in order to
support arbitrary upgrades, even to protocols which may not exist today. The primary intent of
this function is solely to allow an application to issue an upgrade request, not to manage how
a given protocol upgrade takes place or what APIs the application must support in order to serve
this updated protocol. For details in this regard, consult the documentation of the underlying
adapter (such a Plug.Cowboy or Bandit).

Takes an argument describing the requested upgrade (for example, `:websocket`), and an argument
which contains arbitrary data which the underlying adapter is expected to interpret in the
context of the requested upgrade.

If the upgrade is accepted by the adapter, the returned `Plug.Conn` will have a `state` of
`:upgraded`. This state is considered equivalently to a 'sent' state, and is subject to the same
limitation on subsequent mutating operations.

If the adapter does not support the requested upgrade then this is a noop and the returned
`Plug.Conn` will be unchanged. The application can detect this and operate on the conn as it
normally would in order to indicate an upgrade failure to the client.
"""
@spec upgrade_adapter(t, atom, term) :: t
josevalim marked this conversation as resolved.
Show resolved Hide resolved
def upgrade_adapter(%Conn{adapter: {adapter, payload}, state: state} = conn, protocol, opts)
mtrudel marked this conversation as resolved.
Show resolved Hide resolved
when state in @unsent do
case adapter.upgrade(payload, protocol, opts) do
{:ok, payload} ->
%{conn | adapter: {adapter, payload}, state: :upgraded}

_ ->
conn
end
end

def upgrade_adapter(_conn, _protocol, _opts) do
raise AlreadySentError
end

@doc """
Pushes a resource to the client.

Expand Down Expand Up @@ -1847,8 +1863,10 @@ defmodule Plug.Conn do
validate_header_value!("set-cookie", cookie)
end

defp update_cookies(%Conn{state: :sent}, _fun), do: raise(AlreadySentError)
defp update_cookies(%Conn{state: :chunked}, _fun), do: raise(AlreadySentError)
defp update_cookies(%Conn{state: state}, _fun) when state not in @unsent do
raise AlreadySentError
end

defp update_cookies(%Conn{cookies: %Unfetched{}} = conn, _fun), do: conn
defp update_cookies(%Conn{cookies: cookies} = conn, fun), do: %{conn | cookies: fun.(cookies)}

Expand Down
12 changes: 12 additions & 0 deletions lib/plug/conn/adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ defmodule Plug.Conn.Adapter do
@callback inform(payload, status :: Conn.status(), headers :: Keyword.t()) ::
:ok | {:error, term}

@doc """
Attempt to upgrade the connection with the client.

If the adapter does not support the indicated upgrade, then `{:error, :not_supported}` should be
be returned.

If the adapter supports the indicated upgrade but is unable to proceed with it (due to
a negotiation error, invalid opts being passed to this function, or some other reason), then an
arbitrary error may be returned.
"""
@callback upgrade(payload, protocol :: atom, opts :: term) :: {:ok, payload} | {:error, term}

@doc """
Returns peer information such as the address, port and ssl cert.
"""
Expand Down