From 81c4784bab4e175e07686357f24f21ba93249b3b Mon Sep 17 00:00:00 2001 From: Garry Hill Date: Mon, 9 Jun 2025 15:41:42 +0100 Subject: [PATCH] Add CORs headers to every response 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 --- lib/phoenix/sync/controller.ex | 4 +- lib/phoenix/sync/plug/cors.ex | 54 +++++++++++++++++++++++++++ lib/phoenix/sync/router.ex | 6 ++- test/phoenix/sync/controller_test.exs | 19 ++++++++++ test/phoenix/sync/plug/cors_test.exs | 51 +++++++++++++++++++++++++ test/phoenix/sync/router_test.exs | 29 ++++++++++++++ 6 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 lib/phoenix/sync/plug/cors.ex create mode 100644 test/phoenix/sync/plug/cors_test.exs diff --git a/lib/phoenix/sync/controller.ex b/lib/phoenix/sync/controller.ex index 6cff353..af82af0 100644 --- a/lib/phoenix/sync/controller.ex +++ b/lib/phoenix/sync/controller.ex @@ -43,6 +43,8 @@ defmodule Phoenix.Sync.Controller do """ + alias Phoenix.Sync.Plug.CORS + defmacro __using__(opts \\ []) do # validate that we're being used in the context of a Plug.Router impl Phoenix.Sync.Plug.Utils.env!(__CALLER__) @@ -98,6 +100,6 @@ defmodule Phoenix.Sync.Controller do {:ok, shape_api} = Phoenix.Sync.Adapter.PlugApi.predefined_shape(api, predefined_shape) - Phoenix.Sync.Adapter.PlugApi.call(shape_api, conn, params) + Phoenix.Sync.Adapter.PlugApi.call(shape_api, CORS.call(conn), params) end end diff --git a/lib/phoenix/sync/plug/cors.ex b/lib/phoenix/sync/plug/cors.ex new file mode 100644 index 0000000..8b2cdcc --- /dev/null +++ b/lib/phoenix/sync/plug/cors.ex @@ -0,0 +1,54 @@ +defmodule Phoenix.Sync.Plug.CORS do + @moduledoc """ + A `Plug` that adds the necessary CORS headers to responses from Electric sync + endpoints. + + `Phoenix.Sync.Controller.sync_render/4` and `Phoenix.Sync.Router.sync/2` + already include these headers so there's no need to add this plug to your + `Phoenix` or `Plug` router. This module is just exposed as a convenience. + """ + + @behaviour Plug + + @electric_headers [ + "electric-cursor", + "electric-handle", + "electric-offset", + "electric-schema", + "electric-up-to-date" + ] + + @expose_headers ["transfer-encoding" | @electric_headers] + + def init(opts) do + Map.new(opts) + end + + def call(conn) do + conn + |> Plug.Conn.put_resp_header("access-control-allow-origin", origin(conn)) + |> Plug.Conn.put_resp_header( + "access-control-allow-methods", + "GET, POST, PUT, DELETE, OPTIONS" + ) + |> Plug.Conn.put_resp_header( + "access-control-expose-headers", + Enum.join(@expose_headers, ", ") + ) + end + + def call(conn, _opts) do + call(conn) + end + + defp origin(conn) do + case Plug.Conn.get_req_header(conn, "origin") do + [] -> "*" + [origin] -> origin + end + end + + @doc false + @spec electric_headers() :: [String.t()] + def electric_headers, do: @electric_headers +end diff --git a/lib/phoenix/sync/router.ex b/lib/phoenix/sync/router.ex index bee3724..d8468db 100644 --- a/lib/phoenix/sync/router.ex +++ b/lib/phoenix/sync/router.ex @@ -216,7 +216,11 @@ defmodule Phoenix.Sync.Router do defp serve_shape(conn, api, shape) do {:ok, shape_api} = Phoenix.Sync.Adapter.PlugApi.predefined_shape(api, shape) - conn = Plug.Conn.fetch_query_params(conn) + conn = + conn + |> Plug.Conn.fetch_query_params() + |> Phoenix.Sync.Plug.CORS.call() + Phoenix.Sync.Adapter.PlugApi.call(shape_api, conn, conn.params) end end diff --git a/test/phoenix/sync/controller_test.exs b/test/phoenix/sync/controller_test.exs index d4da4dd..e4e7fb4 100644 --- a/test/phoenix/sync/controller_test.exs +++ b/test/phoenix/sync/controller_test.exs @@ -90,6 +90,16 @@ defmodule Phoenix.Sync.ControllerTest do ] = Jason.decode!(resp.resp_body) end + test "includes CORS headers", _ctx do + resp = + Phoenix.ConnTest.build_conn() + |> Phoenix.ConnTest.get("/todos/all", %{offset: "-1"}) + + assert resp.status == 200 + assert [expose] = Plug.Conn.get_resp_header(resp, "access-control-expose-headers") + assert String.contains?(expose, "electric-offset") + end + test "supports where clauses", _ctx do resp = Phoenix.ConnTest.build_conn() @@ -185,5 +195,14 @@ defmodule Phoenix.Sync.ControllerTest do "application/json; charset=utf-8" ] end + + test "includes CORS headers", ctx do + conn = conn(:get, "/shape/todos", %{"offset" => "-1"}) + + resp = PlugRouter.call(conn, PlugRouter.init(ctx.plug_opts)) + + assert [expose] = Plug.Conn.get_resp_header(resp, "access-control-expose-headers") + assert String.contains?(expose, "electric-offset") + end end end diff --git a/test/phoenix/sync/plug/cors_test.exs b/test/phoenix/sync/plug/cors_test.exs new file mode 100644 index 0000000..5cf95e3 --- /dev/null +++ b/test/phoenix/sync/plug/cors_test.exs @@ -0,0 +1,51 @@ +defmodule Phoenix.Sync.Plug.CorsHeadersTest do + use ExUnit.Case, async: true + + alias Phoenix.Sync.Plug.CORS + + import Plug.Test + + test "electric headers are up-to-date with current electric" do + # in test we always have electric as a dependency so we can test + # that our vendored headers are up-to-date + assert CORS.electric_headers() == Electric.Shapes.Api.Response.electric_headers() + end + + test "adds access-control-expose-headers header to response" do + resp = + conn(:get, "/sync/bananas") + |> CORS.call(%{}) + + [expose] = Plug.Conn.get_resp_header(resp, "access-control-expose-headers") + + for header <- CORS.electric_headers() do + assert String.contains?(expose, header) + end + + assert String.contains?(expose, "transfer-encoding") + end + + test "adds access-control-allow-origin header to response" do + resp = + conn(:get, "/v1/shape") + |> CORS.call(%{}) + + ["*"] = Plug.Conn.get_resp_header(resp, "access-control-allow-origin") + + resp = + conn(:get, "/large/noise") + |> Plug.Conn.put_req_header("origin", "https://example.com") + |> CORS.call(%{}) + + ["https://example.com"] = Plug.Conn.get_resp_header(resp, "access-control-allow-origin") + end + + test "adds access-control-allow-methods header to response" do + resp = + conn(:get, "/my-shape") + |> CORS.call(%{}) + + ["GET, POST, PUT, DELETE, OPTIONS"] = + Plug.Conn.get_resp_header(resp, "access-control-allow-methods") + end +end diff --git a/test/phoenix/sync/router_test.exs b/test/phoenix/sync/router_test.exs index 1b032c8..8434d1f 100644 --- a/test/phoenix/sync/router_test.exs +++ b/test/phoenix/sync/router_test.exs @@ -188,6 +188,25 @@ defmodule Phoenix.Sync.RouterTest do ] end + @tag table: { + "todos", + [ + "id int8 not null primary key generated always as identity", + "title text", + "completed boolean default false" + ] + } + @tag data: {"todos", ["title"], [["one"], ["two"], ["three"]]} + test "returns CORS headers", _ctx do + resp = + Phoenix.ConnTest.build_conn() + |> Phoenix.ConnTest.get("/sync/things-to-do", %{offset: "-1"}) + + assert resp.status == 200 + assert [expose] = Plug.Conn.get_resp_header(resp, "access-control-expose-headers") + assert String.contains?(expose, "electric-offset") + end + @tag table: { "ideas", [ @@ -465,5 +484,15 @@ defmodule Phoenix.Sync.RouterTest do ] = Jason.decode!(resp.resp_body) end end + + test "returns CORS headers", ctx do + resp = + conn(:get, "/shapes/todos", %{"offset" => "-1"}) + |> MyRouter.call(ctx.plug_opts) + + assert resp.status == 200 + assert [expose] = Plug.Conn.get_resp_header(resp, "access-control-expose-headers") + assert String.contains?(expose, "electric-offset") + end end end pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy