Skip to content

Commit 6702aed

Browse files
authored
Add CORs headers to every response (#62)
Considering the use cases of the sync router macro and controller functions, it makes sense to just always whitelist the electric- headers required for the client. Fixes #26
1 parent f04ed0c commit 6702aed

File tree

6 files changed

+161
-2
lines changed

6 files changed

+161
-2
lines changed

lib/phoenix/sync/controller.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ defmodule Phoenix.Sync.Controller do
4343
4444
"""
4545

46+
alias Phoenix.Sync.Plug.CORS
47+
4648
defmacro __using__(opts \\ []) do
4749
# validate that we're being used in the context of a Plug.Router impl
4850
Phoenix.Sync.Plug.Utils.env!(__CALLER__)
@@ -98,6 +100,6 @@ defmodule Phoenix.Sync.Controller do
98100

99101
{:ok, shape_api} = Phoenix.Sync.Adapter.PlugApi.predefined_shape(api, predefined_shape)
100102

101-
Phoenix.Sync.Adapter.PlugApi.call(shape_api, conn, params)
103+
Phoenix.Sync.Adapter.PlugApi.call(shape_api, CORS.call(conn), params)
102104
end
103105
end

lib/phoenix/sync/plug/cors.ex

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
defmodule Phoenix.Sync.Plug.CORS do
2+
@moduledoc """
3+
A `Plug` that adds the necessary CORS headers to responses from Electric sync
4+
endpoints.
5+
6+
`Phoenix.Sync.Controller.sync_render/4` and `Phoenix.Sync.Router.sync/2`
7+
already include these headers so there's no need to add this plug to your
8+
`Phoenix` or `Plug` router. This module is just exposed as a convenience.
9+
"""
10+
11+
@behaviour Plug
12+
13+
@electric_headers [
14+
"electric-cursor",
15+
"electric-handle",
16+
"electric-offset",
17+
"electric-schema",
18+
"electric-up-to-date"
19+
]
20+
21+
@expose_headers ["transfer-encoding" | @electric_headers]
22+
23+
def init(opts) do
24+
Map.new(opts)
25+
end
26+
27+
def call(conn) do
28+
conn
29+
|> Plug.Conn.put_resp_header("access-control-allow-origin", origin(conn))
30+
|> Plug.Conn.put_resp_header(
31+
"access-control-allow-methods",
32+
"GET, POST, PUT, DELETE, OPTIONS"
33+
)
34+
|> Plug.Conn.put_resp_header(
35+
"access-control-expose-headers",
36+
Enum.join(@expose_headers, ", ")
37+
)
38+
end
39+
40+
def call(conn, _opts) do
41+
call(conn)
42+
end
43+
44+
defp origin(conn) do
45+
case Plug.Conn.get_req_header(conn, "origin") do
46+
[] -> "*"
47+
[origin] -> origin
48+
end
49+
end
50+
51+
@doc false
52+
@spec electric_headers() :: [String.t()]
53+
def electric_headers, do: @electric_headers
54+
end

lib/phoenix/sync/router.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,11 @@ defmodule Phoenix.Sync.Router do
216216
defp serve_shape(conn, api, shape) do
217217
{:ok, shape_api} = Phoenix.Sync.Adapter.PlugApi.predefined_shape(api, shape)
218218

219-
conn = Plug.Conn.fetch_query_params(conn)
219+
conn =
220+
conn
221+
|> Plug.Conn.fetch_query_params()
222+
|> Phoenix.Sync.Plug.CORS.call()
223+
220224
Phoenix.Sync.Adapter.PlugApi.call(shape_api, conn, conn.params)
221225
end
222226
end

test/phoenix/sync/controller_test.exs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ defmodule Phoenix.Sync.ControllerTest do
9090
] = Jason.decode!(resp.resp_body)
9191
end
9292

93+
test "includes CORS headers", _ctx do
94+
resp =
95+
Phoenix.ConnTest.build_conn()
96+
|> Phoenix.ConnTest.get("/todos/all", %{offset: "-1"})
97+
98+
assert resp.status == 200
99+
assert [expose] = Plug.Conn.get_resp_header(resp, "access-control-expose-headers")
100+
assert String.contains?(expose, "electric-offset")
101+
end
102+
93103
test "supports where clauses", _ctx do
94104
resp =
95105
Phoenix.ConnTest.build_conn()
@@ -185,5 +195,14 @@ defmodule Phoenix.Sync.ControllerTest do
185195
"application/json; charset=utf-8"
186196
]
187197
end
198+
199+
test "includes CORS headers", ctx do
200+
conn = conn(:get, "/shape/todos", %{"offset" => "-1"})
201+
202+
resp = PlugRouter.call(conn, PlugRouter.init(ctx.plug_opts))
203+
204+
assert [expose] = Plug.Conn.get_resp_header(resp, "access-control-expose-headers")
205+
assert String.contains?(expose, "electric-offset")
206+
end
188207
end
189208
end
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
defmodule Phoenix.Sync.Plug.CorsHeadersTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Phoenix.Sync.Plug.CORS
5+
6+
import Plug.Test
7+
8+
test "electric headers are up-to-date with current electric" do
9+
# in test we always have electric as a dependency so we can test
10+
# that our vendored headers are up-to-date
11+
assert CORS.electric_headers() == Electric.Shapes.Api.Response.electric_headers()
12+
end
13+
14+
test "adds access-control-expose-headers header to response" do
15+
resp =
16+
conn(:get, "/sync/bananas")
17+
|> CORS.call(%{})
18+
19+
[expose] = Plug.Conn.get_resp_header(resp, "access-control-expose-headers")
20+
21+
for header <- CORS.electric_headers() do
22+
assert String.contains?(expose, header)
23+
end
24+
25+
assert String.contains?(expose, "transfer-encoding")
26+
end
27+
28+
test "adds access-control-allow-origin header to response" do
29+
resp =
30+
conn(:get, "/v1/shape")
31+
|> CORS.call(%{})
32+
33+
["*"] = Plug.Conn.get_resp_header(resp, "access-control-allow-origin")
34+
35+
resp =
36+
conn(:get, "/large/noise")
37+
|> Plug.Conn.put_req_header("origin", "https://example.com")
38+
|> CORS.call(%{})
39+
40+
["https://example.com"] = Plug.Conn.get_resp_header(resp, "access-control-allow-origin")
41+
end
42+
43+
test "adds access-control-allow-methods header to response" do
44+
resp =
45+
conn(:get, "/my-shape")
46+
|> CORS.call(%{})
47+
48+
["GET, POST, PUT, DELETE, OPTIONS"] =
49+
Plug.Conn.get_resp_header(resp, "access-control-allow-methods")
50+
end
51+
end

test/phoenix/sync/router_test.exs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,25 @@ defmodule Phoenix.Sync.RouterTest do
188188
]
189189
end
190190

191+
@tag table: {
192+
"todos",
193+
[
194+
"id int8 not null primary key generated always as identity",
195+
"title text",
196+
"completed boolean default false"
197+
]
198+
}
199+
@tag data: {"todos", ["title"], [["one"], ["two"], ["three"]]}
200+
test "returns CORS headers", _ctx do
201+
resp =
202+
Phoenix.ConnTest.build_conn()
203+
|> Phoenix.ConnTest.get("/sync/things-to-do", %{offset: "-1"})
204+
205+
assert resp.status == 200
206+
assert [expose] = Plug.Conn.get_resp_header(resp, "access-control-expose-headers")
207+
assert String.contains?(expose, "electric-offset")
208+
end
209+
191210
@tag table: {
192211
"ideas",
193212
[
@@ -465,5 +484,15 @@ defmodule Phoenix.Sync.RouterTest do
465484
] = Jason.decode!(resp.resp_body)
466485
end
467486
end
487+
488+
test "returns CORS headers", ctx do
489+
resp =
490+
conn(:get, "/shapes/todos", %{"offset" => "-1"})
491+
|> MyRouter.call(ctx.plug_opts)
492+
493+
assert resp.status == 200
494+
assert [expose] = Plug.Conn.get_resp_header(resp, "access-control-expose-headers")
495+
assert String.contains?(expose, "electric-offset")
496+
end
468497
end
469498
end

0 commit comments

Comments
 (0)