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 Plug.Conn.register_before_chunk/2 #1154

Merged
merged 6 commits into from
Jun 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions lib/plug/conn.ex
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ 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`, `:chunked`
(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 @@ -155,10 +155,10 @@ defmodule Plug.Conn do

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
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
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.
"""
Expand Down Expand Up @@ -553,6 +553,8 @@ defmodule Plug.Conn do
"""
@spec chunk(t, body) :: {:ok, t} | {:error, term} | no_return
def chunk(%Conn{adapter: {adapter, payload}, state: :chunked} = conn, chunk) do
conn = run_before_chunk(conn, chunk)

if iodata_empty?(chunk) do
{:ok, conn}
else
Expand Down Expand Up @@ -1685,7 +1687,7 @@ defmodule Plug.Conn do
end

@doc """
Returns session value for the given `key`.
Returns session value for the given `key`.

Returns the `default` value if `key` does not exist.
If `default` is not provided, `nil` is used.
Expand Down Expand Up @@ -1808,6 +1810,36 @@ defmodule Plug.Conn do
update_in(conn.private[:before_send], &[callback | &1 || []])
end

@doc ~S"""
Registers a callback to be invoked before a chunk is sent by `chunk/2`.

Callbacks are invoked in the reverse order they are registered, that is, callbacks which
are registered first are invoked last.

## Examples

This example logs the size of the chunk about to be sent:

register_before_chunk(conn, fn _conn, chunk ->
Logger.info("Sending #{IO.iodata_length(chunk)} bytes")
conn
end)

"""
@doc since: "1.15.0"
@spec register_before_chunk(t, (t, body -> t)) :: t
def register_before_chunk(conn, callback)

def register_before_chunk(%Conn{state: state}, _callback)
when state not in @unsent do
raise AlreadySentError
end

def register_before_chunk(%Conn{} = conn, callback)
when is_function(callback, 2) do
update_in(conn.private[:before_chunk], &[callback | &1 || []])
end

@doc """
Halts the Plug pipeline by preventing further plugs downstream from being
invoked. See the docs for `Plug.Builder` for more information on halting a
Expand Down Expand Up @@ -1844,6 +1876,17 @@ defmodule Plug.Conn do
%{conn | resp_headers: merge_headers(conn.resp_headers, conn.resp_cookies)}
end

defp run_before_chunk(%Conn{private: private} = conn, chunk) do
initial_state = conn.state
conn = Enum.reduce(private[:before_chunk] || [], conn, & &1.(&2, chunk))

if conn.state != initial_state do
raise ArgumentError, "cannot send or change response from run_before_chunk/2 callback"
end

%{conn | resp_headers: merge_headers(conn.resp_headers, conn.resp_cookies)}
end

defp merge_headers(headers, cookies) do
Enum.reduce(cookies, headers, fn {key, opts}, acc ->
value =
Expand Down
48 changes: 48 additions & 0 deletions test/plug/conn_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,46 @@ defmodule Plug.ConnTest do
assert get_resp_header(conn, "x-body") == ["CHUNK"]
end

test "chunk/2 runs before_chunk callbacks" do
feynmanliang marked this conversation as resolved.
Show resolved Hide resolved
pid = self()

conn(:get, "/foo")
|> register_before_chunk(fn conn, chunk ->
send(pid, {:before_chunk, 1, chunk})
conn
end)
|> register_before_chunk(fn conn, chunk ->
send(pid, {:before_chunk, 2, chunk})
conn
end)
|> send_chunked(200)
|> chunk("CHUNK")

assert_received {:before_chunk, 2, "CHUNK"}
assert_received {:before_chunk, 1, "CHUNK"}
end

test "chunk/2 uses the updated conn from before_chunk callbacks" do
pid = self()

conn =
conn(:get, "/foo")
|> register_before_chunk(fn conn, _chunk ->
{count, conn} = get_and_update_in(conn.assigns[:test_counter], &{&1, (&1 || 0) + 1})
send(pid, {:before_chunk, count})
conn
end)
|> send_chunked(200)

{:ok, conn} = chunk(conn, "CHUNK")
{:ok, conn} = chunk(conn, "CHUNK")
{:ok, _} = chunk(conn, "CHUNK")

assert_received {:before_chunk, nil}
assert_received {:before_chunk, 1}
assert_received {:before_chunk, 2}
end
Copy link
Member

Choose a reason for hiding this comment

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

Ah, we also need to add tests for:

  • the AlreadySentError exception
  • the error that you raised when the state of the conn changes
  • a test where you modify the connection in one register_before_chunk/2 callback (like adding a header or something) and you make sure that the new connection is passed to further callbacks

Copy link
Contributor Author

@feynmanliang feynmanliang Jun 3, 2023

Choose a reason for hiding this comment

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

I added a test for 1 (https://github.com/elixir-plug/plug/pull/1154/files#diff-5115eb93e759386d8da8b04d13db4e24d30f2d9ece59b85fe63068236aac9dfeR1452-R1458). For 2-3, almost all the methods which modify Conn raise an AlreadySentError since conn.state == :chunked fails the state in @unsent guard.

We could:

  1. Modify the conn manually inside the callback for the purpose of the test, but that feels a bit contrived and not representative of real world use.
  2. Or alternatively since the initial response has been sent having a conn argument could be misleading misleading and we could remove it alongside the extra logic (ArugmentError, merging headers) in run_before_chunk.
  3. Or... we could add :chunked as an @unsent state. But I don't think this is semantically correct, since the connection status / headers / cookies will have already been sent by the earlier call to send_chunked.

I prefer 2, but curious if you had any thoughts or if I am missing something?

Copy link
Member

Choose a reason for hiding this comment

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

I think we should simply document the conn is read-only, as you can't really do anything useful with them. The main goal is to get a copy of any assigns or headers stored in it.

Copy link
Member

Choose a reason for hiding this comment

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

Actually, scratch that, I can see a connection is being returned all the way up.

@feynmanliang you can set a assigns/private field, like a counter, and measure it is being bumped. That should be supported :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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


test "inform/3 performs an informational request" do
conn = conn(:get, "/foo") |> inform(103, [{"link", "</style.css>; rel=preload; as=style"}])
assert {103, [{"link", "</style.css>; rel=preload; as=style"}]} in sent_informs(conn)
Expand Down Expand Up @@ -1409,6 +1449,14 @@ defmodule Plug.ConnTest do
end
end

test "register_before_chunk/2 raises when a response has already been sent" do
conn = send_resp(conn(:get, "/"), 200, "ok")

assert_raise Plug.Conn.AlreadySentError, fn ->
register_before_chunk(conn, fn _ -> nil end)
end
end

test "does not delegate to connections' adapter's chunk/2 when called with an empty chunk" do
defmodule RaisesOnEmptyChunkAdapter do
defdelegate send_chunked(state, status, headers), to: Plug.Adapters.Test.Conn
Expand Down