From 36986fe93dccfe66e309d735dabcb8e140b51aec Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 21 Apr 2025 10:00:14 +0200 Subject: [PATCH 01/26] Detect preflight requests These typically come from grpcweb clients doing OPTIONS checks for CORS --- lib/grpc/server.ex | 10 ++++++++-- lib/grpc/server/adapters/cowboy/handler.ex | 4 ++++ lib/grpc/server/stream.ex | 7 +++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/grpc/server.ex b/lib/grpc/server.ex index 461d88e1..1bf5b100 100644 --- a/lib/grpc/server.ex +++ b/lib/grpc/server.ex @@ -152,7 +152,8 @@ defmodule GRPC.Server do path = "/#{service_name}/#{name}" grpc_type = GRPC.Service.grpc_type(rpc) - def __call_rpc__(unquote(path), :post, stream) do + def __call_rpc__(unquote(path), http_method, stream) + when http_method == :post or http_method == :options do GRPC.Server.call( unquote(service_mod), %{ @@ -271,6 +272,10 @@ defmodule GRPC.Server do end end + defp do_handle_request(false, res_stream, %{is_preflight?: true} = stream, func_name) do + call_with_interceptors(res_stream, func_name, stream, []) + end + defp do_handle_request( false, res_stream, @@ -339,7 +344,8 @@ defmodule GRPC.Server do ) do GRPC.Telemetry.server_span(server, endpoint, func_name, stream, fn -> last = fn r, s -> - reply = apply(server, func_name, [r, s]) + # no response is rquired for preflight requests + reply = if stream.is_preflight?, do: [], else: apply(server, func_name, [r, s]) if res_stream do {:ok, stream} diff --git a/lib/grpc/server/adapters/cowboy/handler.ex b/lib/grpc/server/adapters/cowboy/handler.ex index eb1e3d88..598fb03d 100644 --- a/lib/grpc/server/adapters/cowboy/handler.ex +++ b/lib/grpc/server/adapters/cowboy/handler.ex @@ -70,6 +70,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do codec: codec, http_method: http_method, compressor: compressor, + is_preflight?: preflight?(req), http_transcode: transcode?(req) } @@ -627,6 +628,9 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do _ -> false end end + defp preflight?(%{method: method}), do: method == "OPTIONS" + defp preflight?(%{method: "OPTIONS"}), do: true + defp preflight?(_), do: false defp send_error(req, error, state, reason) do trailers = HTTP2.server_trailers(error.status, error.message) diff --git a/lib/grpc/server/stream.ex b/lib/grpc/server/stream.ex index 1c0df8c9..7d33e801 100644 --- a/lib/grpc/server/stream.ex +++ b/lib/grpc/server/stream.ex @@ -34,6 +34,8 @@ defmodule GRPC.Server.Stream do # compressor mainly is used in client decompressing, responses compressing should be set by # `GRPC.Server.set_compressor` compressor: module() | nil, + # notes that this is a preflight request, and not an actual request for data (e.g. in grpcweb) + is_preflight?: boolean(), # For http transcoding http_method: GRPC.Server.Router.http_method(), http_transcode: boolean(), @@ -54,10 +56,15 @@ defmodule GRPC.Server.Stream do adapter: nil, local: nil, compressor: nil, + is_preflight?: false, http_method: :post, http_transcode: false, __interface__: %{send_reply: &__MODULE__.send_reply/3} + def send_reply(%{is_preflight?: true} = stream, _reply, opts) do + do_send_reply(stream, [], opts) + end + def send_reply( %{grpc_type: :server_stream, codec: codec, http_transcode: true, rpc: rpc} = stream, reply, From c39c83498cfe23fea54d384c06ae7510a840b276 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 21 Apr 2025 10:02:48 +0200 Subject: [PATCH 02/26] Detect client types: grpc, grpcweb, web This replaces the `http_transcode` member of the `Server.Stream` struct as transcoding is correlated with being a web client (*not* a grpcweb client, however!) Knowing the type of client also allows for detection of when to send different types of headers, for e.g. CORS --- lib/grpc/server.ex | 5 +- lib/grpc/server/adapters/cowboy/handler.ex | 68 +++++++++++----------- lib/grpc/server/stream.ex | 13 +++-- 3 files changed, 44 insertions(+), 42 deletions(-) diff --git a/lib/grpc/server.ex b/lib/grpc/server.ex index 1bf5b100..98c44729 100644 --- a/lib/grpc/server.ex +++ b/lib/grpc/server.ex @@ -179,8 +179,7 @@ defmodule GRPC.Server do | service_name: unquote(service_name), method_name: unquote(to_string(name)), grpc_type: unquote(grpc_type), - http_method: unquote(http_method), - http_transcode: unquote(http_transcode) + http_method: unquote(http_method) }, unquote(Macro.escape(put_elem(rpc, 0, func_name))), unquote(func_name) @@ -253,7 +252,7 @@ defmodule GRPC.Server do codec: codec, adapter: adapter, payload: payload, - http_transcode: true + client_type: :web } = stream, func_name ) do diff --git a/lib/grpc/server/adapters/cowboy/handler.ex b/lib/grpc/server/adapters/cowboy/handler.ex index 598fb03d..f6f8f289 100644 --- a/lib/grpc/server/adapters/cowboy/handler.ex +++ b/lib/grpc/server/adapters/cowboy/handler.ex @@ -28,7 +28,8 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do @type stream_state :: %{ pid: server_rpc_pid :: pid, handling_timer: timeout_timer_ref :: reference, - pending_reader: nil | pending_reader + pending_reader: nil | pending_reader, + client_type: GRPC.Server.Stream.client_type } @type init_result :: {:cowboy_loop, :cowboy_req.req(), stream_state} | {:ok, :cowboy_req.req(), init_state} @@ -56,11 +57,10 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do |> String.downcase() |> String.to_existing_atom() - with {:ok, sub_type, content_type} <- find_content_type_subtype(req), + with {:ok, client_type, sub_type, content_type} <- find_content_type_subtype(req), {:ok, codec} <- find_codec(sub_type, content_type, server), {:ok, compressor} <- find_compressor(req, server) do stream_pid = self() - stream = %GRPC.Server.Stream{ server: server, endpoint: endpoint, @@ -71,7 +71,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do http_method: http_method, compressor: compressor, is_preflight?: preflight?(req), - http_transcode: transcode?(req) + client_type: client_type } server_rpc_pid = :proc_lib.spawn_link(__MODULE__, :call_rpc, [server, route, stream]) @@ -90,7 +90,16 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do ) end - {:cowboy_loop, req, %{pid: server_rpc_pid, handling_timer: timer_ref, pending_reader: nil}} + { + :cowboy_loop, + req, + %{ + pid: server_rpc_pid, + handling_timer: timer_ref, + pending_reader: nil, + client_type: client_type + } + } else {:error, error} -> Logger.error(fn -> inspect(error) end) @@ -122,12 +131,9 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do content_type end - find_subtype(content_type) - end - - defp find_subtype(content_type) do - {:ok, subtype} = extract_subtype(content_type) - {:ok, subtype, content_type} + {:ok, client_type, subtype} = extract_subtype(content_type) + client_type = resolve_client_type(req, client_type, subtype) + {:ok, client_type, subtype, content_type} end defp find_compressor(req, server) do @@ -601,41 +607,37 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do end end - defp extract_subtype("application/json"), do: {:ok, "json"} - defp extract_subtype("application/grpc"), do: {:ok, "proto"} - defp extract_subtype("application/grpc+"), do: {:ok, "proto"} - defp extract_subtype("application/grpc;"), do: {:ok, "proto"} - defp extract_subtype(<<"application/grpc+", rest::binary>>), do: {:ok, rest} - defp extract_subtype(<<"application/grpc;", rest::binary>>), do: {:ok, rest} + defp extract_subtype("application/json"), do: {:ok, :grpc, "json"} + defp extract_subtype("application/grpc"), do: {:ok, :grpc, "proto"} + defp extract_subtype("application/grpc+"), do: {:ok, :grpc, "proto"} + defp extract_subtype("application/grpc;"), do: {:ok, :grpc, "proto"} + defp extract_subtype(<<"application/grpc+", rest::binary>>), do: {:ok, :grpc, rest} + defp extract_subtype(<<"application/grpc;", rest::binary>>), do: {:ok, :grpc, rest} - defp extract_subtype("application/grpc-web"), do: {:ok, "proto"} - defp extract_subtype("application/grpc-web+"), do: {:ok, "proto"} - defp extract_subtype("application/grpc-web;"), do: {:ok, "proto"} - defp extract_subtype("application/grpc-web-text"), do: {:ok, "text"} - defp extract_subtype("application/grpc-web+" <> rest), do: {:ok, rest} - defp extract_subtype("application/grpc-web-text+" <> rest), do: {:ok, rest} + defp extract_subtype("application/grpc-web"), do: {:ok, :grpcweb, "proto"} + defp extract_subtype("application/grpc-web+"), do: {:ok, :grpcweb, "proto"} + defp extract_subtype("application/grpc-web;"), do: {:ok, :grpcweb, "proto"} + defp extract_subtype("application/grpc-web-text"), do: {:ok, :grpcweb, "text"} + defp extract_subtype("application/grpc-web+" <> rest), do: {:ok, :grpcweb, rest} + defp extract_subtype("application/grpc-web-text+" <> rest), do: {:ok, :grpcweb, rest} defp extract_subtype(type) do Logger.warning("Got unknown content-type #{type}, please create an issue.") - {:ok, "proto"} + {:ok, :grpc, "proto"} end - defp transcode?(%{version: "HTTP/1.1"}), do: true + defp resolve_client_type(%{version: "HTTP/1.1"}, _detected_client_type, _type_subtype), do: :web + defp resolve_client_type(_req, _detected_client_type, "json"), do: :web + defp resolve_client_type(%{method: "OPTIONS"}, _detected_client_type, _type_subtype), do: :grpcweb + defp resolve_client_type(_req, detected_client_type, _type_subtype), do: detected_client_type - defp transcode?(req) do - case find_content_type_subtype(req) do - {:ok, "json", _} -> true - _ -> false - end - end - defp preflight?(%{method: method}), do: method == "OPTIONS" defp preflight?(%{method: "OPTIONS"}), do: true defp preflight?(_), do: false defp send_error(req, error, state, reason) do trailers = HTTP2.server_trailers(error.status, error.message) - status = if transcode?(req), do: GRPC.Status.http_code(error.status), else: 200 + status = if state.client_type == :web, do: GRPC.Status.http_code(error.status), else: 200 if pid = Map.get(state, :pid) do exit_handler(pid, reason) diff --git a/lib/grpc/server/stream.ex b/lib/grpc/server/stream.ex index 7d33e801..36709c94 100644 --- a/lib/grpc/server/stream.ex +++ b/lib/grpc/server/stream.ex @@ -16,6 +16,7 @@ defmodule GRPC.Server.Stream do * `:payload` - the payload needed by the adapter * `:local` - local data initialized by user """ + @type client_type :: :grpc | :grpcweb | :web @type t :: %__MODULE__{ server: atom(), @@ -31,6 +32,7 @@ defmodule GRPC.Server.Stream do payload: any(), adapter: atom(), local: any(), + client_type: client_type, # compressor mainly is used in client decompressing, responses compressing should be set by # `GRPC.Server.set_compressor` compressor: module() | nil, @@ -38,7 +40,6 @@ defmodule GRPC.Server.Stream do is_preflight?: boolean(), # For http transcoding http_method: GRPC.Server.Router.http_method(), - http_transcode: boolean(), __interface__: map() } @@ -55,10 +56,10 @@ defmodule GRPC.Server.Stream do payload: nil, adapter: nil, local: nil, + client_type: :grpc, compressor: nil, is_preflight?: false, http_method: :post, - http_transcode: false, __interface__: %{send_reply: &__MODULE__.send_reply/3} def send_reply(%{is_preflight?: true} = stream, _reply, opts) do @@ -66,7 +67,7 @@ defmodule GRPC.Server.Stream do end def send_reply( - %{grpc_type: :server_stream, codec: codec, http_transcode: true, rpc: rpc} = stream, + %{grpc_type: :server_stream, codec: codec, rpc: rpc, client_type: :web} = stream, reply, opts ) do @@ -76,7 +77,7 @@ defmodule GRPC.Server.Stream do do_send_reply(stream, [codec.encode(response), "\n"], opts) end - def send_reply(%{codec: codec, http_transcode: true, rpc: rpc} = stream, reply, opts) do + def send_reply(%{codec: codec, rpc: rpc, client_type: :web} = stream, reply, opts) do rule = GRPC.Service.rpc_options(rpc, :http) || %{value: %{}} response = GRPC.Server.Transcode.map_response_body(rule.value, reply) @@ -88,14 +89,14 @@ defmodule GRPC.Server.Stream do end defp do_send_reply( - %{adapter: adapter, codec: codec, http_transcode: http_transcode} = stream, + %{adapter: adapter, codec: codec, client_type: client_type} = stream, data, opts ) do opts = opts |> Keyword.put(:codec, codec) - |> Keyword.put(:http_transcode, http_transcode) + |> Keyword.put(:http_transcode, client_type == :web) adapter.send_reply(stream.payload, data, opts) From e5368256794e6ba1a5db9be7c8ee76a55aa89278 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 21 Apr 2025 10:03:46 +0200 Subject: [PATCH 03/26] Add a CORS header injection Interceptor It an option `:allow` option to define which origins are allowed. This may be a static string or a function which will be passed the current `req` and `stream` structs to choose what to allow. --- lib/grpc/server/interceptors/cors.ex | 61 ++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 lib/grpc/server/interceptors/cors.ex diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex new file mode 100644 index 00000000..1c2c1f58 --- /dev/null +++ b/lib/grpc/server/interceptors/cors.ex @@ -0,0 +1,61 @@ +defmodule GRPC.Server.Interceptors.CORS do + @moduledoc """ + Sends CORS headers when the client is calling the rpc via web transcoding or grpcweb. + + ## Options + + * `allowed` - A string contianing the allowed origin(s), or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to `"*"`, which will allow all origins to access this endpoint. + + ## Usage + + defmodule Your.Endpoint do + use GRPC.Endpoint + + intercept GRPC.Server.Interceptors.CORS + end + + defmodule Your.Endpoint do + use GRPC.Endpoint + + intercept GRPC.Server.Interceptors.CORS, allowed: "some.origin" + end + + + defmodule Your.Endpoint do + use GRPC.Endpoint + + def allowed_origin(req, stream), do: "calculated.origin" + intercept GRPC.Server.Interceptors.CORS, allowed: &Your.Endpoint.allowed_origin/2 + end + """ + + @behaviour GRPC.Server.Interceptor + @impl true + def init(opts) do + # the funky first clause matches a 2-arity remote is_function + # note that this function is run in the context of a macro, which brings some limitations with it + case Keyword.get(opts, :allow) do + {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun + static when is_binary(static) -> static + _ ->"*" + end + end + + @impl true + def call(req, stream, next, allowed) do + if stream.client_type != :grpc do + stream.adapter.set_headers(stream.payload, %{ + "access-control-allow-origin" => resolve_allowed(req, stream, allowed), + "access-control-allow-headers" => "content-type, x-grpc-web, x-user-agent, x-api-key" + }) + end + + next.(req, stream) + end + + defp resolve_allowed(req, stream, allowed_fn) when is_function(allowed_fn, 2) do + allowed_fn.(req, stream) + end + + defp resolve_allowed(_req, _stream, allowed), do: allowed +end From 39353bcff6c935ca2993eac243d8f057a4d3a479 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 21 Apr 2025 23:37:11 +0200 Subject: [PATCH 04/26] Reintroduce the boolean http_transcode member of the Stream struct NOTE: will merge this with commit c39c83498cfe23fea54d384c06ae7510a840b276 post-review/discussion. I am leaving it as a separate commit for now for ease of continued discussion on the current PR. --- lib/grpc/server/adapters/cowboy/handler.ex | 1 + lib/grpc/server/stream.ex | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/grpc/server/adapters/cowboy/handler.ex b/lib/grpc/server/adapters/cowboy/handler.ex index f6f8f289..02f57050 100644 --- a/lib/grpc/server/adapters/cowboy/handler.ex +++ b/lib/grpc/server/adapters/cowboy/handler.ex @@ -69,6 +69,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do local: opts[:local], codec: codec, http_method: http_method, + http_transcode: client_type == :web, compressor: compressor, is_preflight?: preflight?(req), client_type: client_type diff --git a/lib/grpc/server/stream.ex b/lib/grpc/server/stream.ex index 36709c94..564bc477 100644 --- a/lib/grpc/server/stream.ex +++ b/lib/grpc/server/stream.ex @@ -40,6 +40,7 @@ defmodule GRPC.Server.Stream do is_preflight?: boolean(), # For http transcoding http_method: GRPC.Server.Router.http_method(), + http_transcode: boolean(), __interface__: map() } @@ -60,6 +61,7 @@ defmodule GRPC.Server.Stream do compressor: nil, is_preflight?: false, http_method: :post, + http_transcode: false, __interface__: %{send_reply: &__MODULE__.send_reply/3} def send_reply(%{is_preflight?: true} = stream, _reply, opts) do @@ -67,7 +69,7 @@ defmodule GRPC.Server.Stream do end def send_reply( - %{grpc_type: :server_stream, codec: codec, rpc: rpc, client_type: :web} = stream, + %{grpc_type: :server_stream, codec: codec, http_transcode: true, rpc: rpc} = stream, reply, opts ) do @@ -77,7 +79,7 @@ defmodule GRPC.Server.Stream do do_send_reply(stream, [codec.encode(response), "\n"], opts) end - def send_reply(%{codec: codec, rpc: rpc, client_type: :web} = stream, reply, opts) do + def send_reply(%{codec: codec, http_transcode: true, rpc: rpc} = stream, reply, opts) do rule = GRPC.Service.rpc_options(rpc, :http) || %{value: %{}} response = GRPC.Server.Transcode.map_response_body(rule.value, reply) @@ -89,14 +91,14 @@ defmodule GRPC.Server.Stream do end defp do_send_reply( - %{adapter: adapter, codec: codec, client_type: client_type} = stream, + %{adapter: adapter, codec: codec, http_transcode: http_transcode} = stream, data, opts ) do opts = opts |> Keyword.put(:codec, codec) - |> Keyword.put(:http_transcode, client_type == :web) + |> Keyword.put(:http_transcode, http_transcode) adapter.send_reply(stream.payload, data, opts) From ed4389853bcdc34fdb877d1adac5a337aefc8752 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Mon, 21 Apr 2025 23:58:57 +0200 Subject: [PATCH 05/26] web can be detected in extract_subtype --- lib/grpc/server/adapters/cowboy/handler.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/grpc/server/adapters/cowboy/handler.ex b/lib/grpc/server/adapters/cowboy/handler.ex index 02f57050..06950713 100644 --- a/lib/grpc/server/adapters/cowboy/handler.ex +++ b/lib/grpc/server/adapters/cowboy/handler.ex @@ -608,7 +608,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do end end - defp extract_subtype("application/json"), do: {:ok, :grpc, "json"} + defp extract_subtype("application/json"), do: {:ok, :web, "json"} defp extract_subtype("application/grpc"), do: {:ok, :grpc, "proto"} defp extract_subtype("application/grpc+"), do: {:ok, :grpc, "proto"} defp extract_subtype("application/grpc;"), do: {:ok, :grpc, "proto"} @@ -628,7 +628,6 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do end defp resolve_client_type(%{version: "HTTP/1.1"}, _detected_client_type, _type_subtype), do: :web - defp resolve_client_type(_req, _detected_client_type, "json"), do: :web defp resolve_client_type(%{method: "OPTIONS"}, _detected_client_type, _type_subtype), do: :grpcweb defp resolve_client_type(_req, detected_client_type, _type_subtype), do: detected_client_type From 2da7bb2e32f074a6c9c15a3280f8fc3ce5cb7677 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 22 Apr 2025 00:07:51 +0200 Subject: [PATCH 06/26] change access_type to access_mode, :web to :http_transcoding this more accurately and directly maps to the grpc terminology, and is probably clearer. --- lib/grpc/server.ex | 4 ++-- lib/grpc/server/adapters/cowboy/handler.ex | 26 +++++++++++----------- lib/grpc/server/interceptors/cors.ex | 2 +- lib/grpc/server/stream.ex | 14 ++++++------ 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/lib/grpc/server.ex b/lib/grpc/server.ex index 98c44729..3e8e17f3 100644 --- a/lib/grpc/server.ex +++ b/lib/grpc/server.ex @@ -252,7 +252,7 @@ defmodule GRPC.Server do codec: codec, adapter: adapter, payload: payload, - client_type: :web + access_mode: :http_transcoding } = stream, func_name ) do @@ -272,7 +272,7 @@ defmodule GRPC.Server do end defp do_handle_request(false, res_stream, %{is_preflight?: true} = stream, func_name) do - call_with_interceptors(res_stream, func_name, stream, []) + call_with_interceptors(res_stream, func_name, stream, []) end defp do_handle_request( diff --git a/lib/grpc/server/adapters/cowboy/handler.ex b/lib/grpc/server/adapters/cowboy/handler.ex index 06950713..803d1795 100644 --- a/lib/grpc/server/adapters/cowboy/handler.ex +++ b/lib/grpc/server/adapters/cowboy/handler.ex @@ -29,7 +29,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do pid: server_rpc_pid :: pid, handling_timer: timeout_timer_ref :: reference, pending_reader: nil | pending_reader, - client_type: GRPC.Server.Stream.client_type + access_mode: GRPC.Server.Stream.access_mode } @type init_result :: {:cowboy_loop, :cowboy_req.req(), stream_state} | {:ok, :cowboy_req.req(), init_state} @@ -57,7 +57,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do |> String.downcase() |> String.to_existing_atom() - with {:ok, client_type, sub_type, content_type} <- find_content_type_subtype(req), + with {:ok, access_mode, sub_type, content_type} <- find_content_type_subtype(req), {:ok, codec} <- find_codec(sub_type, content_type, server), {:ok, compressor} <- find_compressor(req, server) do stream_pid = self() @@ -69,10 +69,10 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do local: opts[:local], codec: codec, http_method: http_method, - http_transcode: client_type == :web, + http_transcode: access_mode == :http_transcoding, compressor: compressor, is_preflight?: preflight?(req), - client_type: client_type + access_mode: access_mode } server_rpc_pid = :proc_lib.spawn_link(__MODULE__, :call_rpc, [server, route, stream]) @@ -98,7 +98,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do pid: server_rpc_pid, handling_timer: timer_ref, pending_reader: nil, - client_type: client_type + access_mode: access_mode } } else @@ -132,9 +132,9 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do content_type end - {:ok, client_type, subtype} = extract_subtype(content_type) - client_type = resolve_client_type(req, client_type, subtype) - {:ok, client_type, subtype, content_type} + {:ok, access_mode, subtype} = extract_subtype(content_type) + access_mode = resolve_access_mode(req, access_mode, subtype) + {:ok, access_mode, subtype, content_type} end defp find_compressor(req, server) do @@ -608,7 +608,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do end end - defp extract_subtype("application/json"), do: {:ok, :web, "json"} + defp extract_subtype("application/json"), do: {:ok, :http_transcoding, "json"} defp extract_subtype("application/grpc"), do: {:ok, :grpc, "proto"} defp extract_subtype("application/grpc+"), do: {:ok, :grpc, "proto"} defp extract_subtype("application/grpc;"), do: {:ok, :grpc, "proto"} @@ -627,9 +627,9 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do {:ok, :grpc, "proto"} end - defp resolve_client_type(%{version: "HTTP/1.1"}, _detected_client_type, _type_subtype), do: :web - defp resolve_client_type(%{method: "OPTIONS"}, _detected_client_type, _type_subtype), do: :grpcweb - defp resolve_client_type(_req, detected_client_type, _type_subtype), do: detected_client_type + defp resolve_access_mode(%{version: "HTTP/1.1"}, _detected_access_mode, _type_subtype), do: :http_transcoding + defp resolve_access_mode(%{method: "OPTIONS"}, _detected_access_mode, _type_subtype), do: :grpcweb + defp resolve_access_mode(_req, detected_access_mode, _type_subtype), do: detected_access_mode defp preflight?(%{method: "OPTIONS"}), do: true defp preflight?(_), do: false @@ -637,7 +637,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do defp send_error(req, error, state, reason) do trailers = HTTP2.server_trailers(error.status, error.message) - status = if state.client_type == :web, do: GRPC.Status.http_code(error.status), else: 200 + status = if state.access_mode == :http_transcoding, do: GRPC.Status.http_code(error.status), else: 200 if pid = Map.get(state, :pid) do exit_handler(pid, reason) diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex index 1c2c1f58..c562682b 100644 --- a/lib/grpc/server/interceptors/cors.ex +++ b/lib/grpc/server/interceptors/cors.ex @@ -43,7 +43,7 @@ defmodule GRPC.Server.Interceptors.CORS do @impl true def call(req, stream, next, allowed) do - if stream.client_type != :grpc do + if stream.access_mode != :grpc do stream.adapter.set_headers(stream.payload, %{ "access-control-allow-origin" => resolve_allowed(req, stream, allowed), "access-control-allow-headers" => "content-type, x-grpc-web, x-user-agent, x-api-key" diff --git a/lib/grpc/server/stream.ex b/lib/grpc/server/stream.ex index 564bc477..9afbacfc 100644 --- a/lib/grpc/server/stream.ex +++ b/lib/grpc/server/stream.ex @@ -16,7 +16,7 @@ defmodule GRPC.Server.Stream do * `:payload` - the payload needed by the adapter * `:local` - local data initialized by user """ - @type client_type :: :grpc | :grpcweb | :web + @type access_mode :: :grpc | :grpcweb | :http_transcoding @type t :: %__MODULE__{ server: atom(), @@ -32,7 +32,7 @@ defmodule GRPC.Server.Stream do payload: any(), adapter: atom(), local: any(), - client_type: client_type, + access_mode: access_mode, # compressor mainly is used in client decompressing, responses compressing should be set by # `GRPC.Server.set_compressor` compressor: module() | nil, @@ -57,7 +57,7 @@ defmodule GRPC.Server.Stream do payload: nil, adapter: nil, local: nil, - client_type: :grpc, + access_mode: :grpc, compressor: nil, is_preflight?: false, http_method: :post, @@ -69,7 +69,7 @@ defmodule GRPC.Server.Stream do end def send_reply( - %{grpc_type: :server_stream, codec: codec, http_transcode: true, rpc: rpc} = stream, + %{grpc_type: :server_stream, codec: codec, access_mode: :http_transcoding, rpc: rpc} = stream, reply, opts ) do @@ -79,7 +79,7 @@ defmodule GRPC.Server.Stream do do_send_reply(stream, [codec.encode(response), "\n"], opts) end - def send_reply(%{codec: codec, http_transcode: true, rpc: rpc} = stream, reply, opts) do + def send_reply(%{codec: codec, access_mode: :http_transcoding, rpc: rpc} = stream, reply, opts) do rule = GRPC.Service.rpc_options(rpc, :http) || %{value: %{}} response = GRPC.Server.Transcode.map_response_body(rule.value, reply) @@ -91,14 +91,14 @@ defmodule GRPC.Server.Stream do end defp do_send_reply( - %{adapter: adapter, codec: codec, http_transcode: http_transcode} = stream, + %{adapter: adapter, codec: codec, access_mode: access_mode} = stream, data, opts ) do opts = opts |> Keyword.put(:codec, codec) - |> Keyword.put(:http_transcode, http_transcode) + |> Keyword.put(:http_transcode, access_mode == :http_transcoding) adapter.send_reply(stream.payload, data, opts) From c25d76ae3693425d45a4b4ecd45d469541bd3d4e Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 22 Apr 2025 11:31:17 +0200 Subject: [PATCH 07/26] default opts to an empty list, whitespace fix --- lib/grpc/server/interceptors/cors.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex index c562682b..52fe3fc6 100644 --- a/lib/grpc/server/interceptors/cors.ex +++ b/lib/grpc/server/interceptors/cors.ex @@ -31,13 +31,13 @@ defmodule GRPC.Server.Interceptors.CORS do @behaviour GRPC.Server.Interceptor @impl true - def init(opts) do + def init(opts \\ []) do # the funky first clause matches a 2-arity remote is_function # note that this function is run in the context of a macro, which brings some limitations with it case Keyword.get(opts, :allow) do {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun static when is_binary(static) -> static - _ ->"*" + _ -> "*" end end From 03614160c2ff9ef9b8f7308b125610b33ceff598 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 22 Apr 2025 11:31:44 +0200 Subject: [PATCH 08/26] add tests for the CORS header interceptor --- test/grpc/server/interceptors/cors_test.exs | 111 ++++++++++++++++++++ test/support/test_adapter.exs | 5 + 2 files changed, 116 insertions(+) create mode 100644 test/grpc/server/interceptors/cors_test.exs diff --git a/test/grpc/server/interceptors/cors_test.exs b/test/grpc/server/interceptors/cors_test.exs new file mode 100644 index 00000000..09e751cb --- /dev/null +++ b/test/grpc/server/interceptors/cors_test.exs @@ -0,0 +1,111 @@ +defmodule GRPC.Server.Interceptors.CORSTest.Endpoint do + use GRPC.Endpoint + intercept(GRPC.Server.Interceptors.CORS, allow: &GRPC.Server.Interceptors.CORSTest.allow_origin/2) +end + +defmodule GRPC.Server.Interceptors.CORSTest do + use ExUnit.Case, async: false + + alias GRPC.Server.Interceptors.CORS, as: CORSInterceptor + alias GRPC.Server.Stream + + defmodule FakeRequest do + defstruct [] + end + + @server_name :server + @rpc {1, 2, 3} + @adaptor GRPC.Test.ServerAdapter + @function_header_value "from-function" + + def allow_origin(_req, _stream), do: @function_header_value + + test "Sends headers CORS for for http transcoding and grpcweb requests" do + request = %FakeRequest{} + stream = %Stream{adapter: @adaptor, server: @server_name, rpc: @rpc} + + {:ok, :ok} = + CORSInterceptor.call( + request, + %{stream | access_mode: :http_transcode}, + fn _request, _stream -> {:ok, :ok} end, + CORSInterceptor.init() + ) + + assert_received({:setting_headers, _headers}, "Failed to set CORS headers during grpcweb") + + {:ok, :ok} = + CORSInterceptor.call( + request, + %{stream | access_mode: :grpcweb}, + fn _request, _stream -> {:ok, :ok} end, + CORSInterceptor.init() + ) + + assert_received({:setting_headers, _headers}, "Failed to set CORS headers during grpcweb") + end + + test "Does not send CORS headers for normal grpc requests" do + request = %FakeRequest{} + stream = %Stream{adapter: @adaptor, server: @server_name} + + {:ok, :ok} = + CORSInterceptor.call( + request, + %{stream | access_mode: :grpc}, + fn _request, _stream -> {:ok, :ok} end, + CORSInterceptor.init() + ) + + refute_received({:setting_headers, _headers}, "Set CORS headers during grpc") + end + + test "Default CORS allow origin header allows all" do + request = %FakeRequest{} + stream = %Stream{adapter: @adaptor, server: @server_name, access_mode: :grpcweb} + + {:ok, :ok} = + CORSInterceptor.call( + request, + stream, + fn _request, _stream -> {:ok, :ok} end, + CORSInterceptor.init() + ) + assert_received({:setting_headers, %{"access-control-allow-origin" => "*"}}, "Incorrect default header") + end + + test "CORS allow origin header value is configuraable with a static string" do + request = %FakeRequest{} + stream = %Stream{adapter: @adaptor, server: @server_name, access_mode: :grpcweb} + domain = "https://mydomain.io" + + {:ok, :ok} = + CORSInterceptor.call( + request, + %{stream | access_mode: :grpcweb}, + fn _request, _stream -> {:ok, :ok} end, + CORSInterceptor.init(allow: domain) + ) + + assert_received({:setting_headers, %{"access-control-allow-origin" => ^domain}}, "Incorrect static header") + end + + test "CORS allow origin header value is configuraable with a two-arity function" do + request = %FakeRequest{} + stream = %Stream{adapter: @adaptor, server: @server_name, access_mode: :grpcweb} + + # fetch the interceptor state from the fake endpoint + [{_interceptor, interceptor_state}] = GRPC.Server.Interceptors.CORSTest.Endpoint.__meta__(:interceptors) + + {:ok, :ok} = + CORSInterceptor.call( + request, + %{stream | access_mode: :grpcweb}, + fn _request, _stream -> {:ok, :ok} end, + interceptor_state + ) + + assert_received({:setting_headers, %{"access-control-allow-origin" => @function_header_value}}, "Incorrect header when using function") + + end +end diff --git a/test/support/test_adapter.exs b/test/support/test_adapter.exs index ff603557..4fe3c161 100644 --- a/test/support/test_adapter.exs +++ b/test/support/test_adapter.exs @@ -37,4 +37,9 @@ defmodule GRPC.Test.ServerAdapter do def has_sent_headers?(_stream) do false end + + def set_headers(stream, headers) do + send(self(), {:setting_headers, headers}) + stream + end end From 7ea68abf9f46470578de6df9e783b4e4dd4aa678 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Tue, 22 Apr 2025 18:44:16 +0200 Subject: [PATCH 09/26] Assign to a temporary variable and assign that to Stream.http_transcode --- lib/grpc/server/adapters/cowboy/handler.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/grpc/server/adapters/cowboy/handler.ex b/lib/grpc/server/adapters/cowboy/handler.ex index 803d1795..437b56f0 100644 --- a/lib/grpc/server/adapters/cowboy/handler.ex +++ b/lib/grpc/server/adapters/cowboy/handler.ex @@ -61,6 +61,8 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do {:ok, codec} <- find_codec(sub_type, content_type, server), {:ok, compressor} <- find_compressor(req, server) do stream_pid = self() + http_transcode = access_mode == :http_transcoding + stream = %GRPC.Server.Stream{ server: server, endpoint: endpoint, @@ -69,7 +71,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do local: opts[:local], codec: codec, http_method: http_method, - http_transcode: access_mode == :http_transcoding, + http_transcode: http_transcode, compressor: compressor, is_preflight?: preflight?(req), access_mode: access_mode From e547b8d00801d0248a1de095bfd1b5b29587b2bf Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 23 Apr 2025 10:50:28 +0200 Subject: [PATCH 10/26] provide access to request headers in the Stream object --- lib/grpc/server/adapters/cowboy/handler.ex | 19 ++++++++++++++----- lib/grpc/server/stream.ex | 2 ++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/grpc/server/adapters/cowboy/handler.ex b/lib/grpc/server/adapters/cowboy/handler.ex index 437b56f0..13e9e3f7 100644 --- a/lib/grpc/server/adapters/cowboy/handler.ex +++ b/lib/grpc/server/adapters/cowboy/handler.ex @@ -29,7 +29,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do pid: server_rpc_pid :: pid, handling_timer: timeout_timer_ref :: reference, pending_reader: nil | pending_reader, - access_mode: GRPC.Server.Stream.access_mode + access_mode: GRPC.Server.Stream.access_mode() } @type init_result :: {:cowboy_loop, :cowboy_req.req(), stream_state} | {:ok, :cowboy_req.req(), init_state} @@ -62,6 +62,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do {:ok, compressor} <- find_compressor(req, server) do stream_pid = self() http_transcode = access_mode == :http_transcoding + request_headers = :cowboy_req.headers(req) stream = %GRPC.Server.Stream{ server: server, @@ -71,6 +72,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do local: opts[:local], codec: codec, http_method: http_method, + http_request_headers: request_headers, http_transcode: http_transcode, compressor: compressor, is_preflight?: preflight?(req), @@ -82,7 +84,7 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do req = :cowboy_req.set_resp_headers(HTTP2.server_headers(stream), req) - timeout = :cowboy_req.header("grpc-timeout", req) + timeout = Map.get(request_headers, "grpc-timeout") timer_ref = if is_binary(timeout) do @@ -629,8 +631,12 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do {:ok, :grpc, "proto"} end - defp resolve_access_mode(%{version: "HTTP/1.1"}, _detected_access_mode, _type_subtype), do: :http_transcoding - defp resolve_access_mode(%{method: "OPTIONS"}, _detected_access_mode, _type_subtype), do: :grpcweb + defp resolve_access_mode(%{version: "HTTP/1.1"}, _detected_access_mode, _type_subtype), + do: :http_transcoding + + defp resolve_access_mode(%{method: "OPTIONS"}, _detected_access_mode, _type_subtype), + do: :grpcweb + defp resolve_access_mode(_req, detected_access_mode, _type_subtype), do: detected_access_mode defp preflight?(%{method: "OPTIONS"}), do: true @@ -639,7 +645,10 @@ defmodule GRPC.Server.Adapters.Cowboy.Handler do defp send_error(req, error, state, reason) do trailers = HTTP2.server_trailers(error.status, error.message) - status = if state.access_mode == :http_transcoding, do: GRPC.Status.http_code(error.status), else: 200 + status = + if state.access_mode == :http_transcoding, + do: GRPC.Status.http_code(error.status), + else: 200 if pid = Map.get(state, :pid) do exit_handler(pid, reason) diff --git a/lib/grpc/server/stream.ex b/lib/grpc/server/stream.ex index 9afbacfc..06911371 100644 --- a/lib/grpc/server/stream.ex +++ b/lib/grpc/server/stream.ex @@ -40,6 +40,7 @@ defmodule GRPC.Server.Stream do is_preflight?: boolean(), # For http transcoding http_method: GRPC.Server.Router.http_method(), + http_request_headers: map(), http_transcode: boolean(), __interface__: map() } @@ -61,6 +62,7 @@ defmodule GRPC.Server.Stream do compressor: nil, is_preflight?: false, http_method: :post, + http_request_headers: %{}, http_transcode: false, __interface__: %{send_reply: &__MODULE__.send_reply/3} From 61f4a7876a59d089811133522d2a904cc316ff83 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 23 Apr 2025 11:08:22 +0200 Subject: [PATCH 11/26] only set cors when the sec-fetch-mode header indicates it --- lib/grpc/server/interceptors/cors.ex | 3 +- lib/grpc/server/stream.ex | 7 +- test/grpc/server/interceptors/cors_test.exs | 84 ++++++++++++++++++--- 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex index 52fe3fc6..7e3e7d66 100644 --- a/lib/grpc/server/interceptors/cors.ex +++ b/lib/grpc/server/interceptors/cors.ex @@ -43,7 +43,8 @@ defmodule GRPC.Server.Interceptors.CORS do @impl true def call(req, stream, next, allowed) do - if stream.access_mode != :grpc do + if stream.access_mode != :grpc and + Map.get(stream.http_request_headers, "sec-fetch-mode") == "cors" do stream.adapter.set_headers(stream.payload, %{ "access-control-allow-origin" => resolve_allowed(req, stream, allowed), "access-control-allow-headers" => "content-type, x-grpc-web, x-user-agent, x-api-key" diff --git a/lib/grpc/server/stream.ex b/lib/grpc/server/stream.ex index 06911371..fa0a7c19 100644 --- a/lib/grpc/server/stream.ex +++ b/lib/grpc/server/stream.ex @@ -40,7 +40,7 @@ defmodule GRPC.Server.Stream do is_preflight?: boolean(), # For http transcoding http_method: GRPC.Server.Router.http_method(), - http_request_headers: map(), + http_request_headers: map(), http_transcode: boolean(), __interface__: map() } @@ -71,7 +71,8 @@ defmodule GRPC.Server.Stream do end def send_reply( - %{grpc_type: :server_stream, codec: codec, access_mode: :http_transcoding, rpc: rpc} = stream, + %{grpc_type: :server_stream, codec: codec, access_mode: :http_transcoding, rpc: rpc} = + stream, reply, opts ) do @@ -81,7 +82,7 @@ defmodule GRPC.Server.Stream do do_send_reply(stream, [codec.encode(response), "\n"], opts) end - def send_reply(%{codec: codec, access_mode: :http_transcoding, rpc: rpc} = stream, reply, opts) do + def send_reply(%{codec: codec, access_mode: :http_transcoding, rpc: rpc} = stream, reply, opts) do rule = GRPC.Service.rpc_options(rpc, :http) || %{value: %{}} response = GRPC.Server.Transcode.map_response_body(rule.value, reply) diff --git a/test/grpc/server/interceptors/cors_test.exs b/test/grpc/server/interceptors/cors_test.exs index 09e751cb..a17c5656 100644 --- a/test/grpc/server/interceptors/cors_test.exs +++ b/test/grpc/server/interceptors/cors_test.exs @@ -1,6 +1,9 @@ defmodule GRPC.Server.Interceptors.CORSTest.Endpoint do use GRPC.Endpoint - intercept(GRPC.Server.Interceptors.CORS, allow: &GRPC.Server.Interceptors.CORSTest.allow_origin/2) + + intercept(GRPC.Server.Interceptors.CORS, + allow: &GRPC.Server.Interceptors.CORSTest.allow_origin/2 + ) end defmodule GRPC.Server.Interceptors.CORSTest do @@ -17,12 +20,39 @@ defmodule GRPC.Server.Interceptors.CORSTest do @rpc {1, 2, 3} @adaptor GRPC.Test.ServerAdapter @function_header_value "from-function" + @default_http_headers %{ + "accept" => "application/grpc-web-text", + "accept-encoding" => "gzip, deflate, br, zstd", + "accept-language" => "en-US,en;q=0.5", + "connection" => "keep-alive", + "content-length" => "20", + "content-type" => "application/grpc-web-text", + "dnt" => "1", + "host" => "http://myhost:4100", + "priority" => "u=0", + "referer" => "http://localhost:3000/", + "sec-fetch-dest" => "empty", + "sec-fetch-mode" => "cors", + "sec-fetch-site" => "same-site", + "user-agent" => "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0", + "x-grpc-web" => "1", + "x-user-agent" => "grpc-web-javascript/0.1" + } def allow_origin(_req, _stream), do: @function_header_value + def create_stream() do + %Stream{ + adapter: @adaptor, + server: @server_name, + rpc: @rpc, + http_request_headers: @default_http_headers + } + end + test "Sends headers CORS for for http transcoding and grpcweb requests" do request = %FakeRequest{} - stream = %Stream{adapter: @adaptor, server: @server_name, rpc: @rpc} + stream = create_stream() {:ok, :ok} = CORSInterceptor.call( @@ -47,7 +77,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do test "Does not send CORS headers for normal grpc requests" do request = %FakeRequest{} - stream = %Stream{adapter: @adaptor, server: @server_name} + stream = create_stream() {:ok, :ok} = CORSInterceptor.call( @@ -62,7 +92,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do test "Default CORS allow origin header allows all" do request = %FakeRequest{} - stream = %Stream{adapter: @adaptor, server: @server_name, access_mode: :grpcweb} + stream = Map.put(create_stream(), :access_mode, :grpcweb) {:ok, :ok} = CORSInterceptor.call( @@ -71,12 +101,16 @@ defmodule GRPC.Server.Interceptors.CORSTest do fn _request, _stream -> {:ok, :ok} end, CORSInterceptor.init() ) - assert_received({:setting_headers, %{"access-control-allow-origin" => "*"}}, "Incorrect default header") + + assert_received( + {:setting_headers, %{"access-control-allow-origin" => "*"}}, + "Incorrect default header" + ) end test "CORS allow origin header value is configuraable with a static string" do request = %FakeRequest{} - stream = %Stream{adapter: @adaptor, server: @server_name, access_mode: :grpcweb} + stream = Map.put(create_stream(), :access_mode, :grpcweb) domain = "https://mydomain.io" {:ok, :ok} = @@ -87,15 +121,19 @@ defmodule GRPC.Server.Interceptors.CORSTest do CORSInterceptor.init(allow: domain) ) - assert_received({:setting_headers, %{"access-control-allow-origin" => ^domain}}, "Incorrect static header") + assert_received( + {:setting_headers, %{"access-control-allow-origin" => ^domain}}, + "Incorrect static header" + ) end test "CORS allow origin header value is configuraable with a two-arity function" do request = %FakeRequest{} - stream = %Stream{adapter: @adaptor, server: @server_name, access_mode: :grpcweb} + stream = Map.put(create_stream(), :access_mode, :grpcweb) # fetch the interceptor state from the fake endpoint - [{_interceptor, interceptor_state}] = GRPC.Server.Interceptors.CORSTest.Endpoint.__meta__(:interceptors) + [{_interceptor, interceptor_state}] = + GRPC.Server.Interceptors.CORSTest.Endpoint.__meta__(:interceptors) {:ok, :ok} = CORSInterceptor.call( @@ -105,7 +143,33 @@ defmodule GRPC.Server.Interceptors.CORSTest do interceptor_state ) - assert_received({:setting_headers, %{"access-control-allow-origin" => @function_header_value}}, "Incorrect header when using function") + assert_received( + {:setting_headers, %{"access-control-allow-origin" => @function_header_value}}, + "Incorrect header when using function" + ) + end + + test "CORS only on cors sec-fetch-mode" do + request = %FakeRequest{} + + stream = %{ + create_stream() + | access_mode: :grpcweb, + http_request_headers: Map.put(@default_http_headers, "sec-fetch-mode", "same-origin") + } + + # fetch the interceptor state from the fake endpoint + [{_interceptor, interceptor_state}] = + GRPC.Server.Interceptors.CORSTest.Endpoint.__meta__(:interceptors) + + {:ok, :ok} = + CORSInterceptor.call( + request, + %{stream | access_mode: :grpcweb}, + fn _request, _stream -> {:ok, :ok} end, + CORSInterceptor.init(allow: "*") + ) + refute_received({:setting_headers, _}, "Set CORS header") end end From a22f2fab8a0a16f9790c514c35f4725fbbb97c78 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 23 Apr 2025 17:37:17 +0200 Subject: [PATCH 12/26] another test: if there is NO sec-fetch-mode header --- test/grpc/server/interceptors/cors_test.exs | 22 ++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/test/grpc/server/interceptors/cors_test.exs b/test/grpc/server/interceptors/cors_test.exs index a17c5656..77aa00c4 100644 --- a/test/grpc/server/interceptors/cors_test.exs +++ b/test/grpc/server/interceptors/cors_test.exs @@ -158,9 +158,25 @@ defmodule GRPC.Server.Interceptors.CORSTest do http_request_headers: Map.put(@default_http_headers, "sec-fetch-mode", "same-origin") } - # fetch the interceptor state from the fake endpoint - [{_interceptor, interceptor_state}] = - GRPC.Server.Interceptors.CORSTest.Endpoint.__meta__(:interceptors) + {:ok, :ok} = + CORSInterceptor.call( + request, + %{stream | access_mode: :grpcweb}, + fn _request, _stream -> {:ok, :ok} end, + CORSInterceptor.init(allow: "*") + ) + + refute_received({:setting_headers, _}, "Set CORS header") + end + + test "No CORS if missing sec-fetch-mode header" do + request = %FakeRequest{} + + stream = %{ + create_stream() + | access_mode: :grpcweb, + http_request_headers: Map.delete(@default_http_headers, "sec-fetch-mode") + } {:ok, :ok} = CORSInterceptor.call( From bcd4ddba9e531caa2f92adedad76a1ce26cf7cb1 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 23 Apr 2025 20:12:48 +0200 Subject: [PATCH 13/26] Make access-control-allow-headers configurable, and optional Only send access-control-allow-headers when the client requests it with access-control-request-headers and allow the same configurability as access-control-allow-origin provides --- lib/grpc/server/interceptors/cors.ex | 61 +++++++++++---- test/grpc/server/interceptors/cors_test.exs | 85 ++++++++++++++++++++- 2 files changed, 130 insertions(+), 16 deletions(-) diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex index 7e3e7d66..aa9a169b 100644 --- a/lib/grpc/server/interceptors/cors.ex +++ b/lib/grpc/server/interceptors/cors.ex @@ -34,29 +34,64 @@ defmodule GRPC.Server.Interceptors.CORS do def init(opts \\ []) do # the funky first clause matches a 2-arity remote is_function # note that this function is run in the context of a macro, which brings some limitations with it - case Keyword.get(opts, :allow) do - {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun - static when is_binary(static) -> static - _ -> "*" - end + allowed_origin = + case Keyword.get(opts, :allow_origin) do + {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun + static when is_binary(static) -> static + _ -> "*" + end + + allowed_headers = + case Keyword.get(opts, :allow_headers) do + {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun + static when is_binary(static) -> static + _ -> nil + end + + {allowed_origin, allowed_headers} end @impl true - def call(req, stream, next, allowed) do + def call(req, stream, next, {allowed_origin, allowed_headers}) do if stream.access_mode != :grpc and Map.get(stream.http_request_headers, "sec-fetch-mode") == "cors" do - stream.adapter.set_headers(stream.payload, %{ - "access-control-allow-origin" => resolve_allowed(req, stream, allowed), - "access-control-allow-headers" => "content-type, x-grpc-web, x-user-agent, x-api-key" - }) + headers = + %{} + |> add_allowed_origins(req, stream, allowed_origin) + |> add_allowed_headers(req, stream, allowed_headers) + + stream.adapter.set_headers(stream.payload, headers) end next.(req, stream) end - defp resolve_allowed(req, stream, allowed_fn) when is_function(allowed_fn, 2) do - allowed_fn.(req, stream) + defp add_allowed_origins(headers, req, stream, allowed) do + value = + case allowed do + allowed when is_function(allowed, 2) -> allowed.(req, stream) + allowed -> allowed + end + + Map.put(headers, "access-control-allow-origin", value) + end + + defp add_allowed_headers( + headers, + req, + %{http_request_headers: %{"access-control-request-headers" => requested}} = stream, + allowed + ) do + # include an access-control-allow-headers header only when a request headers is sent + value = + case allowed do + nil -> requested + allowed when is_function(allowed, 2) -> allowed.(req, stream) + allowed -> allowed + end + + Map.put(headers, "access-control-allow-headers", value) end - defp resolve_allowed(_req, _stream, allowed), do: allowed + defp add_allowed_headers(headers, _req, _stream, _allowed), do: headers end diff --git a/test/grpc/server/interceptors/cors_test.exs b/test/grpc/server/interceptors/cors_test.exs index 77aa00c4..b2fdee1f 100644 --- a/test/grpc/server/interceptors/cors_test.exs +++ b/test/grpc/server/interceptors/cors_test.exs @@ -2,7 +2,8 @@ defmodule GRPC.Server.Interceptors.CORSTest.Endpoint do use GRPC.Endpoint intercept(GRPC.Server.Interceptors.CORS, - allow: &GRPC.Server.Interceptors.CORSTest.allow_origin/2 + allow_origin: &GRPC.Server.Interceptors.CORSTest.allow_origin/2, + allow_headers: &GRPC.Server.Interceptors.CORSTest.allow_headers/2 ) end @@ -38,8 +39,11 @@ defmodule GRPC.Server.Interceptors.CORSTest do "x-grpc-web" => "1", "x-user-agent" => "grpc-web-javascript/0.1" } + @requested_allowed_headers "Authorized" + @custom_allowed_headers "MySpecialHeader,AndAnother" def allow_origin(_req, _stream), do: @function_header_value + def allow_headers(_req, _stream), do: @custom_allowed_headers def create_stream() do %Stream{ @@ -118,7 +122,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do request, %{stream | access_mode: :grpcweb}, fn _request, _stream -> {:ok, :ok} end, - CORSInterceptor.init(allow: domain) + CORSInterceptor.init(allow_origin: domain) ) assert_received( @@ -149,6 +153,81 @@ defmodule GRPC.Server.Interceptors.CORSTest do ) end + test "CORS Access-Control-Allowed-Headers is included in response when clients request it" do + request = %FakeRequest{} + + stream = %{ + create_stream() + | access_mode: :grpcweb, + http_request_headers: Map.put(@default_http_headers, "access-control-request-headers", @requested_allowed_headers) + } + + {:ok, :ok} = + CORSInterceptor.call( + request, + %{stream | access_mode: :grpcweb}, + fn _request, _stream -> {:ok, :ok} end, + CORSInterceptor.init() + ) + + assert_received( + {:setting_headers, %{ "access-control-allow-headers" => @requested_allowed_headers }}, + "Incorrect header when using function" + ) + end + + test "CORS Access-Control-Allowed-Headers is configurable with a static string" do + request = %FakeRequest{} + + stream = %{ + create_stream() + | access_mode: :grpcweb, + http_request_headers: Map.put(@default_http_headers, "access-control-request-headers", @requested_allowed_headers) + } + + allowed_headers = "Test" + + {:ok, :ok} = + CORSInterceptor.call( + request, + %{stream | access_mode: :grpcweb}, + fn _request, _stream -> {:ok, :ok} end, + CORSInterceptor.init(allow_headers: allowed_headers) + ) + + assert_received( + {:setting_headers, %{ "access-control-allow-headers" => ^allowed_headers }}, + "Incorrect header when using function" + ) + end + + test "CORS Access-Control-Allowed-Headers is configurable with a two-arity function" do + request = %FakeRequest{} + + stream = %{ + create_stream() + | access_mode: :grpcweb, + http_request_headers: Map.put(@default_http_headers, "access-control-request-headers", @requested_allowed_headers) + } + # fetch the interceptor state from the fake endpoint + [{_interceptor, interceptor_state}] = + GRPC.Server.Interceptors.CORSTest.Endpoint.__meta__(:interceptors) + + + {:ok, :ok} = + CORSInterceptor.call( + request, + %{stream | access_mode: :grpcweb}, + fn _request, _stream -> {:ok, :ok} end, + interceptor_state + ) + + assert_received( + {:setting_headers, %{ "access-control-allow-headers" => @custom_allowed_headers }}, + "Incorrect header when using function" + ) + end + test "CORS only on cors sec-fetch-mode" do request = %FakeRequest{} @@ -163,7 +242,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do request, %{stream | access_mode: :grpcweb}, fn _request, _stream -> {:ok, :ok} end, - CORSInterceptor.init(allow: "*") + CORSInterceptor.init(allow_origin: "*") ) refute_received({:setting_headers, _}, "Set CORS header") From 81e1def55d5fb15215723da40d0583df75c62af0 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 23 Apr 2025 20:29:05 +0200 Subject: [PATCH 14/26] align variable names with http header names --- lib/grpc/server/interceptors/cors.ex | 37 ++++++++++++++-------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex index aa9a169b..2722af99 100644 --- a/lib/grpc/server/interceptors/cors.ex +++ b/lib/grpc/server/interceptors/cors.ex @@ -4,7 +4,8 @@ defmodule GRPC.Server.Interceptors.CORS do ## Options - * `allowed` - A string contianing the allowed origin(s), or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to `"*"`, which will allow all origins to access this endpoint. + * `allow_origin` - A string containing the allowed origin(s), or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to `"*"`, which will allow all origins to access this endpoint. + * `allow_headers` - A string containing the allowed headers, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to the value of the `"access-control-request-headers"` request header from the client. ## Usage @@ -17,15 +18,15 @@ defmodule GRPC.Server.Interceptors.CORS do defmodule Your.Endpoint do use GRPC.Endpoint - intercept GRPC.Server.Interceptors.CORS, allowed: "some.origin" + intercept GRPC.Server.Interceptors.CORS, allow_origin: "some.origin" end defmodule Your.Endpoint do use GRPC.Endpoint - def allowed_origin(req, stream), do: "calculated.origin" - intercept GRPC.Server.Interceptors.CORS, allowed: &Your.Endpoint.allowed_origin/2 + def allow_origin(req, stream), do: "calculated.origin" + intercept GRPC.Server.Interceptors.CORS, allow: &Your.Endpoint.allow_origin/2 end """ @@ -34,31 +35,31 @@ defmodule GRPC.Server.Interceptors.CORS do def init(opts \\ []) do # the funky first clause matches a 2-arity remote is_function # note that this function is run in the context of a macro, which brings some limitations with it - allowed_origin = + allow_origin = case Keyword.get(opts, :allow_origin) do {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun static when is_binary(static) -> static _ -> "*" end - allowed_headers = + allow_headers = case Keyword.get(opts, :allow_headers) do {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun static when is_binary(static) -> static _ -> nil end - {allowed_origin, allowed_headers} + {allow_origin, allow_headers} end @impl true - def call(req, stream, next, {allowed_origin, allowed_headers}) do + def call(req, stream, next, {allow_origin, allow_headers}) do if stream.access_mode != :grpc and Map.get(stream.http_request_headers, "sec-fetch-mode") == "cors" do headers = %{} - |> add_allowed_origins(req, stream, allowed_origin) - |> add_allowed_headers(req, stream, allowed_headers) + |> add_allowed_origins(req, stream, allow_origin) + |> add_allowed_headers(req, stream, allow_headers) stream.adapter.set_headers(stream.payload, headers) end @@ -66,11 +67,11 @@ defmodule GRPC.Server.Interceptors.CORS do next.(req, stream) end - defp add_allowed_origins(headers, req, stream, allowed) do + defp add_allowed_origins(headers, req, stream, allow) do value = - case allowed do - allowed when is_function(allowed, 2) -> allowed.(req, stream) - allowed -> allowed + case allow do + allow when is_function(allow, 2) -> allow.(req, stream) + allow -> allow end Map.put(headers, "access-control-allow-origin", value) @@ -80,14 +81,14 @@ defmodule GRPC.Server.Interceptors.CORS do headers, req, %{http_request_headers: %{"access-control-request-headers" => requested}} = stream, - allowed + allow ) do # include an access-control-allow-headers header only when a request headers is sent value = - case allowed do + case allow do nil -> requested - allowed when is_function(allowed, 2) -> allowed.(req, stream) - allowed -> allowed + allow when is_function(allow, 2) -> allow.(req, stream) + allow -> allow end Map.put(headers, "access-control-allow-headers", value) From f6493d6dc1402248ff4aa44bd1cbf0232e53fb3c Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 23 Apr 2025 20:29:45 +0200 Subject: [PATCH 15/26] Add a CORS example to the README --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 0c6326ec..dc156571 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,39 @@ end See full application code in [helloworld_transcoding](examples/helloworld_transcoding) example. +### **CORS** + +When accessing grpc from a browser via http transcoding or grpcweb, CORS headers may be required for the browser to allow access to the grpc endpoint. Adding CORS headers is can be done by using the included `Interceptor` in your `Endpoint` module: + +```elixir +# Define your endpoint +defmodule Helloworld.Endpoint do + use GRPC.Endpoint + + intercept GRPC.Server.Interceptors.Logger + intercept GRPC.Server.Interceptors.CORS + run Helloworld.Greeter.Server +end +``` + +By default, the CORS `Interceptor` responds to CORS requests with a permissive response that allows *all* points of origin to access the service. This may be undesirable, particularly in production environments. For this reason, both the allowed origin and headers are configurable: + +```elixir +# Define your endpoint +defmodule Helloworld.Endpoint do + use GRPC.Endpoint + + intercept GRPC.Server.Interceptors.Logger + intercept GRPC.Server.Interceptors.CORS, allow_origin: "mydomain.com", allow_headers: "Authorization" + run Helloworld.Greeter.Server +end +``` + +The set of configuration directives supported include: + + * `allow_origin` - A string containing the allowed origin(s), or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to `"*"`, which will allow all origins to access this endpoint. + * `allow_headers` - A string containing the allowed headers, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to the value of the `"access-control-request-headers"` request header from the client. + ### **Start Application** 1. Start gRPC Server in your supervisor tree or Application module: From a32654f4d38bdfe4a8643989bc5f237015697a08 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Wed, 23 Apr 2025 20:31:29 +0200 Subject: [PATCH 16/26] format --- test/grpc/server/interceptors/cors_test.exs | 29 ++++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/test/grpc/server/interceptors/cors_test.exs b/test/grpc/server/interceptors/cors_test.exs index b2fdee1f..66788a66 100644 --- a/test/grpc/server/interceptors/cors_test.exs +++ b/test/grpc/server/interceptors/cors_test.exs @@ -159,7 +159,12 @@ defmodule GRPC.Server.Interceptors.CORSTest do stream = %{ create_stream() | access_mode: :grpcweb, - http_request_headers: Map.put(@default_http_headers, "access-control-request-headers", @requested_allowed_headers) + http_request_headers: + Map.put( + @default_http_headers, + "access-control-request-headers", + @requested_allowed_headers + ) } {:ok, :ok} = @@ -171,7 +176,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do ) assert_received( - {:setting_headers, %{ "access-control-allow-headers" => @requested_allowed_headers }}, + {:setting_headers, %{"access-control-allow-headers" => @requested_allowed_headers}}, "Incorrect header when using function" ) end @@ -182,7 +187,12 @@ defmodule GRPC.Server.Interceptors.CORSTest do stream = %{ create_stream() | access_mode: :grpcweb, - http_request_headers: Map.put(@default_http_headers, "access-control-request-headers", @requested_allowed_headers) + http_request_headers: + Map.put( + @default_http_headers, + "access-control-request-headers", + @requested_allowed_headers + ) } allowed_headers = "Test" @@ -196,7 +206,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do ) assert_received( - {:setting_headers, %{ "access-control-allow-headers" => ^allowed_headers }}, + {:setting_headers, %{"access-control-allow-headers" => ^allowed_headers}}, "Incorrect header when using function" ) end @@ -207,13 +217,18 @@ defmodule GRPC.Server.Interceptors.CORSTest do stream = %{ create_stream() | access_mode: :grpcweb, - http_request_headers: Map.put(@default_http_headers, "access-control-request-headers", @requested_allowed_headers) + http_request_headers: + Map.put( + @default_http_headers, + "access-control-request-headers", + @requested_allowed_headers + ) } + # fetch the interceptor state from the fake endpoint [{_interceptor, interceptor_state}] = GRPC.Server.Interceptors.CORSTest.Endpoint.__meta__(:interceptors) - {:ok, :ok} = CORSInterceptor.call( request, @@ -223,7 +238,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do ) assert_received( - {:setting_headers, %{ "access-control-allow-headers" => @custom_allowed_headers }}, + {:setting_headers, %{"access-control-allow-headers" => @custom_allowed_headers}}, "Incorrect header when using function" ) end From 0ea8c8e797e8f164d21af68d70fe832ce69f73d4 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Sun, 27 Apr 2025 00:02:04 +0200 Subject: [PATCH 17/26] Verbage. Co-authored-by: Paulo Valente <16843419+polvalente@users.noreply.github.com> --- README.md | 2 +- lib/grpc/server/interceptors/cors.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dc156571..9850c59d 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ See full application code in [helloworld_transcoding](examples/helloworld_transc ### **CORS** -When accessing grpc from a browser via http transcoding or grpcweb, CORS headers may be required for the browser to allow access to the grpc endpoint. Adding CORS headers is can be done by using the included `Interceptor` in your `Endpoint` module: +When accessing gRPC from a browser via HTTP transcoding or gRPC-Web, CORS headers may be required for the browser to allow access to the gRPC endpoint. Adding CORS headers can be done by using the included `Interceptor` in your `Endpoint` module: ```elixir # Define your endpoint diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex index 2722af99..de5a46f5 100644 --- a/lib/grpc/server/interceptors/cors.ex +++ b/lib/grpc/server/interceptors/cors.ex @@ -1,6 +1,6 @@ defmodule GRPC.Server.Interceptors.CORS do @moduledoc """ - Sends CORS headers when the client is calling the rpc via web transcoding or grpcweb. + Sends CORS headers when the client is using RPC via Web transcoding or gRPC-web. ## Options From 18ad11f69d38a4b75e312ba617b1d59e93ba5216 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Sun, 27 Apr 2025 00:45:07 +0200 Subject: [PATCH 18/26] remove default for allow_origin in CORS interceptor --- README.md | 80 +++++++++------------ lib/grpc/server/interceptors/cors.ex | 5 +- test/grpc/server/interceptors/cors_test.exs | 14 ++-- 3 files changed, 43 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 9850c59d..77ec1caa 100644 --- a/README.md +++ b/README.md @@ -11,26 +11,26 @@ An Elixir implementation of [gRPC](http://www.grpc.io/). ## Table of contents -- [Installation](#installation) -- [Usage](#usage) - - [Simple RPC](#simple-rpc) - - [HTTP Transcoding](#http-transcoding) - - [Start Application](#start-application) -- [Features](#features) -- [Benchmark](#benchmark) -- [Contributing](#contributing) +- [Installation](#installation) +- [Usage](#usage) + - [Simple RPC](#simple-rpc) + - [HTTP Transcoding](#http-transcoding) + - [Start Application](#start-application) +- [Features](#features) +- [Benchmark](#benchmark) +- [Contributing](#contributing) ## Installation The package can be installed as: - ```elixir - def deps do - [ - {:grpc, "~> 0.9"} - ] - end - ``` +```elixir +def deps do + [ + {:grpc, "~> 0.9"} + ] +end +``` ## Usage @@ -96,7 +96,7 @@ end We will use this module [in the gRPC server startup section](#start-application). -**__Note:__** For other types of RPC call like streams see [here](interop/lib/interop/server.ex). +****Note:**** For other types of RPC call like streams see [here](interop/lib/interop/server.ex). ### **HTTP Transcoding** @@ -152,6 +152,7 @@ mix protobuf.generate \ ``` 3. Enable http_transcode option in your Server module + ```elixir defmodule Helloworld.Greeter.Server do use GRPC.Server, @@ -169,20 +170,12 @@ See full application code in [helloworld_transcoding](examples/helloworld_transc ### **CORS** -When accessing gRPC from a browser via HTTP transcoding or gRPC-Web, CORS headers may be required for the browser to allow access to the gRPC endpoint. Adding CORS headers can be done by using the included `Interceptor` in your `Endpoint` module: - -```elixir -# Define your endpoint -defmodule Helloworld.Endpoint do - use GRPC.Endpoint +When accessing gRPC from a browser via HTTP transcoding or gRPC-Web, CORS headers may be required for the browser to allow access to the gRPC endpoint. Adding CORS headers can be done by using the included `Interceptor` in your `Endpoint` module, configuring it with an `allow_origin` and, optionally, `allow_headers`: - intercept GRPC.Server.Interceptors.Logger - intercept GRPC.Server.Interceptors.CORS - run Helloworld.Greeter.Server -end -``` +- `allow_origin` - Required. A string containing the allowed origin, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. +- `allow_headers` - A string containing the allowed headers, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. If not provided, the value of the `"access-control-request-headers"` request header from the client will be used in the response. -By default, the CORS `Interceptor` responds to CORS requests with a permissive response that allows *all* points of origin to access the service. This may be undesirable, particularly in production environments. For this reason, both the allowed origin and headers are configurable: +Example: ```elixir # Define your endpoint @@ -190,16 +183,11 @@ defmodule Helloworld.Endpoint do use GRPC.Endpoint intercept GRPC.Server.Interceptors.Logger - intercept GRPC.Server.Interceptors.CORS, allow_origin: "mydomain.com", allow_headers: "Authorization" + intercept GRPC.Server.Interceptors.CORS, allow_origin: "mydomain.io" run Helloworld.Greeter.Server end ``` -The set of configuration directives supported include: - - * `allow_origin` - A string containing the allowed origin(s), or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to `"*"`, which will allow all origins to access this endpoint. - * `allow_headers` - A string containing the allowed headers, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to the value of the `"access-control-request-headers"` request header from the client. - ### **Start Application** 1. Start gRPC Server in your supervisor tree or Application module: @@ -256,18 +244,18 @@ The accepted options for configuration are the ones listed on [Mint.HTTP.connect ## Features -- Various kinds of RPC: - - [Unary](https://grpc.io/docs/what-is-grpc/core-concepts/#unary-rpc) - - [Server-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#server-streaming-rpc) - - [Client-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#client-streaming-rpc) - - [Bidirectional-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#bidirectional-streaming-rpc) -- [HTTP Transcoding](https://cloud.google.com/endpoints/docs/grpc/transcoding) -- [TLS Authentication](https://grpc.io/docs/guides/auth/#supported-auth-mechanisms) -- [Error handling](https://grpc.io/docs/guides/error/) -- Interceptors (See [`GRPC.Endpoint`](https://github.com/elixir-grpc/grpc/blob/master/lib/grpc/endpoint.ex)) -- [Connection Backoff](https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md) -- Data compression -- [gRPC Reflection](https://github.com/elixir-grpc/grpc-reflection) +- Various kinds of RPC: + - [Unary](https://grpc.io/docs/what-is-grpc/core-concepts/#unary-rpc) + - [Server-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#server-streaming-rpc) + - [Client-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#client-streaming-rpc) + - [Bidirectional-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#bidirectional-streaming-rpc) +- [HTTP Transcoding](https://cloud.google.com/endpoints/docs/grpc/transcoding) +- [TLS Authentication](https://grpc.io/docs/guides/auth/#supported-auth-mechanisms) +- [Error handling](https://grpc.io/docs/guides/error/) +- Interceptors (See [`GRPC.Endpoint`](https://github.com/elixir-grpc/grpc/blob/master/lib/grpc/endpoint.ex)) +- [Connection Backoff](https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md) +- Data compression +- [gRPC Reflection](https://github.com/elixir-grpc/grpc-reflection) ## Benchmark diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex index de5a46f5..3601aa2f 100644 --- a/lib/grpc/server/interceptors/cors.ex +++ b/lib/grpc/server/interceptors/cors.ex @@ -4,8 +4,8 @@ defmodule GRPC.Server.Interceptors.CORS do ## Options - * `allow_origin` - A string containing the allowed origin(s), or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to `"*"`, which will allow all origins to access this endpoint. - * `allow_headers` - A string containing the allowed headers, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to the value of the `"access-control-request-headers"` request header from the client. + * `allow_origin` - Required. A string containing the allowed origin, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. + *`allow_headers` - A string containing the allowed headers, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. If not defined, the value of the `"access-control-request-headers"` request header from the client will be used in the response. ## Usage @@ -39,7 +39,6 @@ defmodule GRPC.Server.Interceptors.CORS do case Keyword.get(opts, :allow_origin) do {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun static when is_binary(static) -> static - _ -> "*" end allow_headers = diff --git a/test/grpc/server/interceptors/cors_test.exs b/test/grpc/server/interceptors/cors_test.exs index 66788a66..920ee815 100644 --- a/test/grpc/server/interceptors/cors_test.exs +++ b/test/grpc/server/interceptors/cors_test.exs @@ -63,7 +63,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do request, %{stream | access_mode: :http_transcode}, fn _request, _stream -> {:ok, :ok} end, - CORSInterceptor.init() + CORSInterceptor.init(allow_origin: "*") ) assert_received({:setting_headers, _headers}, "Failed to set CORS headers during grpcweb") @@ -73,7 +73,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do request, %{stream | access_mode: :grpcweb}, fn _request, _stream -> {:ok, :ok} end, - CORSInterceptor.init() + CORSInterceptor.init(allow_origin: "*") ) assert_received({:setting_headers, _headers}, "Failed to set CORS headers during grpcweb") @@ -88,7 +88,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do request, %{stream | access_mode: :grpc}, fn _request, _stream -> {:ok, :ok} end, - CORSInterceptor.init() + CORSInterceptor.init(allow_origin: "*") ) refute_received({:setting_headers, _headers}, "Set CORS headers during grpc") @@ -103,7 +103,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do request, stream, fn _request, _stream -> {:ok, :ok} end, - CORSInterceptor.init() + CORSInterceptor.init(allow_origin: "*") ) assert_received( @@ -172,7 +172,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do request, %{stream | access_mode: :grpcweb}, fn _request, _stream -> {:ok, :ok} end, - CORSInterceptor.init() + CORSInterceptor.init(allow_origin: "*") ) assert_received( @@ -202,7 +202,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do request, %{stream | access_mode: :grpcweb}, fn _request, _stream -> {:ok, :ok} end, - CORSInterceptor.init(allow_headers: allowed_headers) + CORSInterceptor.init(allow_origin: "*", allow_headers: allowed_headers) ) assert_received( @@ -277,7 +277,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do request, %{stream | access_mode: :grpcweb}, fn _request, _stream -> {:ok, :ok} end, - CORSInterceptor.init(allow: "*") + CORSInterceptor.init(allow_origin: "*") ) refute_received({:setting_headers, _}, "Set CORS header") From b51feda8a9a2930e221c27a0e32c8e33ca81d121 Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Sun, 27 Apr 2025 02:30:37 +0200 Subject: [PATCH 19/26] raise on failed allow_origin type, and test for that --- lib/grpc/server/interceptors/cors.ex | 1 + test/grpc/server/interceptors/cors_test.exs | 26 +++++++-------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex index 3601aa2f..ce5254f9 100644 --- a/lib/grpc/server/interceptors/cors.ex +++ b/lib/grpc/server/interceptors/cors.ex @@ -39,6 +39,7 @@ defmodule GRPC.Server.Interceptors.CORS do case Keyword.get(opts, :allow_origin) do {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun static when is_binary(static) -> static + _ -> raise ArgumentError, message: "allow_header must be a string or a 2-arity remote function" end allow_headers = diff --git a/test/grpc/server/interceptors/cors_test.exs b/test/grpc/server/interceptors/cors_test.exs index 920ee815..7a68afc1 100644 --- a/test/grpc/server/interceptors/cors_test.exs +++ b/test/grpc/server/interceptors/cors_test.exs @@ -94,24 +94,6 @@ defmodule GRPC.Server.Interceptors.CORSTest do refute_received({:setting_headers, _headers}, "Set CORS headers during grpc") end - test "Default CORS allow origin header allows all" do - request = %FakeRequest{} - stream = Map.put(create_stream(), :access_mode, :grpcweb) - - {:ok, :ok} = - CORSInterceptor.call( - request, - stream, - fn _request, _stream -> {:ok, :ok} end, - CORSInterceptor.init(allow_origin: "*") - ) - - assert_received( - {:setting_headers, %{"access-control-allow-origin" => "*"}}, - "Incorrect default header" - ) - end - test "CORS allow origin header value is configuraable with a static string" do request = %FakeRequest{} stream = Map.put(create_stream(), :access_mode, :grpcweb) @@ -131,6 +113,14 @@ defmodule GRPC.Server.Interceptors.CORSTest do ) end + test "CORS allow origin init does not accept non-string arguments" do + assert_raise(ArgumentError, fn -> CORSInterceptor.init(allow_origin: :atom) end) + assert_raise(ArgumentError, fn -> CORSInterceptor.init(allow_origin: 1) end) + assert_raise(ArgumentError, fn -> CORSInterceptor.init(allow_origin: 1.0) end) + assert_raise(ArgumentError, fn -> CORSInterceptor.init(allow_origin: []) end) + assert_raise(ArgumentError, fn -> CORSInterceptor.init(allow_origin: %{}) end) + end + test "CORS allow origin header value is configuraable with a two-arity function" do request = %FakeRequest{} stream = Map.put(create_stream(), :access_mode, :grpcweb) From 0bb4c5620995aef6035ae7f31e659a7c59fd281a Mon Sep 17 00:00:00 2001 From: Aaron Seigo Date: Sun, 27 Apr 2025 03:17:36 +0200 Subject: [PATCH 20/26] allow_origin, not allow_header --- lib/grpc/server/interceptors/cors.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex index ce5254f9..24d0d49e 100644 --- a/lib/grpc/server/interceptors/cors.ex +++ b/lib/grpc/server/interceptors/cors.ex @@ -39,7 +39,7 @@ defmodule GRPC.Server.Interceptors.CORS do case Keyword.get(opts, :allow_origin) do {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun static when is_binary(static) -> static - _ -> raise ArgumentError, message: "allow_header must be a string or a 2-arity remote function" + _ -> raise ArgumentError, message: "allow_origin must be a string or a 2-arity remote function" end allow_headers = From 5c832ca356325ac61d1b66efa6d31221fb8beb3f Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Sun, 27 Apr 2025 01:13:37 -0300 Subject: [PATCH 21/26] test: add missing compile-time test --- lib/grpc/server/interceptors/cors.ex | 43 +++++++++++++++----- test/grpc/server/interceptors/cors_test.exs | 45 +++++++++++++++++++-- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex index 24d0d49e..2f42bc5b 100644 --- a/lib/grpc/server/interceptors/cors.ex +++ b/lib/grpc/server/interceptors/cors.ex @@ -4,8 +4,10 @@ defmodule GRPC.Server.Interceptors.CORS do ## Options - * `allow_origin` - Required. A string containing the allowed origin, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. - *`allow_headers` - A string containing the allowed headers, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. If not defined, the value of the `"access-control-request-headers"` request header from the client will be used in the response. + * `:allow_origin` - Required. A string containing the allowed origin, or a function capture (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. + * `:allow_headers` - A string containing the allowed headers, or a function capture + (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to `nil`. + If defined as `nil`, the value of the `"access-control-request-headers"` request header from the client will be used in the response. ## Usage @@ -33,20 +35,41 @@ defmodule GRPC.Server.Interceptors.CORS do @behaviour GRPC.Server.Interceptor @impl true def init(opts \\ []) do - # the funky first clause matches a 2-arity remote is_function - # note that this function is run in the context of a macro, which brings some limitations with it + # Function captures are represented as their AST in this step + # because of a Macro.escape call in the __before_compile__ step + # in endpoint.ex. + # This is not a full-on Macro context, so binary concatenations and + # variables are handled before this step. + + opts = Keyword.validate!(opts, [:allow_origin, allow_headers: nil]) + allow_origin = case Keyword.get(opts, :allow_origin) do - {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun - static when is_binary(static) -> static - _ -> raise ArgumentError, message: "allow_origin must be a string or a 2-arity remote function" + {:&, [], [{:/, [], [_signature, 2]}]} = fun -> + fun + + binary when is_binary(binary) -> + binary + + other -> + raise ArgumentError, + "allow_origin must be a string or a 2-arity remote function, got: #{inspect(other)}" end allow_headers = case Keyword.get(opts, :allow_headers) do - {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun - static when is_binary(static) -> static - _ -> nil + {:&, [], [{:/, [], [_signature, 2]}]} = fun -> + fun + + binary when is_binary(binary) -> + binary + + nil -> + nil + + other -> + raise ArgumentError, + ":allow_headers must be a string, a 2-arity remote function, or nil, got: #{inspect(other)}" end {allow_origin, allow_headers} diff --git a/test/grpc/server/interceptors/cors_test.exs b/test/grpc/server/interceptors/cors_test.exs index 7a68afc1..7805ab7b 100644 --- a/test/grpc/server/interceptors/cors_test.exs +++ b/test/grpc/server/interceptors/cors_test.exs @@ -1,4 +1,4 @@ -defmodule GRPC.Server.Interceptors.CORSTest.Endpoint do +defmodule GRPC.Server.Interceptors.CORSTest.Endpoint.FunctionCapture do use GRPC.Endpoint intercept(GRPC.Server.Interceptors.CORS, @@ -7,6 +7,19 @@ defmodule GRPC.Server.Interceptors.CORSTest.Endpoint do ) end +defmodule GRPC.Server.Interceptors.CORSTest.Endpoint.BinaryConcatenation do + use GRPC.Endpoint + + origin1 = "https://subdomain1.domain.com" + origin2 = "https://subdomain2.domain.com" + + intercept( + GRPC.Server.Interceptors.CORS, + allow_origin: origin1 <> "," <> origin2, + allow_headers: "MySpecialHeader,AndAnother" + ) +end + defmodule GRPC.Server.Interceptors.CORSTest do use ExUnit.Case, async: false @@ -127,7 +140,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do # fetch the interceptor state from the fake endpoint [{_interceptor, interceptor_state}] = - GRPC.Server.Interceptors.CORSTest.Endpoint.__meta__(:interceptors) + GRPC.Server.Interceptors.CORSTest.Endpoint.FunctionCapture.__meta__(:interceptors) {:ok, :ok} = CORSInterceptor.call( @@ -143,6 +156,32 @@ defmodule GRPC.Server.Interceptors.CORSTest do ) end + test "CORS allow origin header value is configuraable with binary concatenation" do + request = %FakeRequest{} + stream = Map.put(create_stream(), :access_mode, :grpcweb) + + # fetch the interceptor state from the fake endpoint + [{_interceptor, interceptor_state}] = + GRPC.Server.Interceptors.CORSTest.Endpoint.BinaryConcatenation.__meta__(:interceptors) + + {:ok, :ok} = + CORSInterceptor.call( + request, + %{stream | access_mode: :grpcweb}, + fn _request, _stream -> {:ok, :ok} end, + interceptor_state + ) + + assert_received( + {:setting_headers, + %{ + "access-control-allow-origin" => + "https://subdomain1.domain.com,https://subdomain2.domain.com" + }}, + "Incorrect header when using function" + ) + end + test "CORS Access-Control-Allowed-Headers is included in response when clients request it" do request = %FakeRequest{} @@ -217,7 +256,7 @@ defmodule GRPC.Server.Interceptors.CORSTest do # fetch the interceptor state from the fake endpoint [{_interceptor, interceptor_state}] = - GRPC.Server.Interceptors.CORSTest.Endpoint.__meta__(:interceptors) + GRPC.Server.Interceptors.CORSTest.Endpoint.FunctionCapture.__meta__(:interceptors) {:ok, :ok} = CORSInterceptor.call( From 4ba946063d103d4761f0de906d22fdb3d39b326d Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Sun, 27 Apr 2025 01:14:48 -0300 Subject: [PATCH 22/26] chore: format readme --- README.md | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 77ec1caa..764bca26 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,14 @@ An Elixir implementation of [gRPC](http://www.grpc.io/). ## Table of contents -- [Installation](#installation) -- [Usage](#usage) - - [Simple RPC](#simple-rpc) - - [HTTP Transcoding](#http-transcoding) - - [Start Application](#start-application) -- [Features](#features) -- [Benchmark](#benchmark) -- [Contributing](#contributing) +- [Installation](#installation) +- [Usage](#usage) + - [Simple RPC](#simple-rpc) + - [HTTP Transcoding](#http-transcoding) + - [Start Application](#start-application) +- [Features](#features) +- [Benchmark](#benchmark) +- [Contributing](#contributing) ## Installation @@ -172,8 +172,8 @@ See full application code in [helloworld_transcoding](examples/helloworld_transc When accessing gRPC from a browser via HTTP transcoding or gRPC-Web, CORS headers may be required for the browser to allow access to the gRPC endpoint. Adding CORS headers can be done by using the included `Interceptor` in your `Endpoint` module, configuring it with an `allow_origin` and, optionally, `allow_headers`: -- `allow_origin` - Required. A string containing the allowed origin, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. -- `allow_headers` - A string containing the allowed headers, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. If not provided, the value of the `"access-control-request-headers"` request header from the client will be used in the response. +- `allow_origin` - Required. A string containing the allowed origin, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. +- `allow_headers` - A string containing the allowed headers, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. If not provided, the value of the `"access-control-request-headers"` request header from the client will be used in the response. Example: @@ -244,18 +244,18 @@ The accepted options for configuration are the ones listed on [Mint.HTTP.connect ## Features -- Various kinds of RPC: - - [Unary](https://grpc.io/docs/what-is-grpc/core-concepts/#unary-rpc) - - [Server-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#server-streaming-rpc) - - [Client-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#client-streaming-rpc) - - [Bidirectional-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#bidirectional-streaming-rpc) -- [HTTP Transcoding](https://cloud.google.com/endpoints/docs/grpc/transcoding) -- [TLS Authentication](https://grpc.io/docs/guides/auth/#supported-auth-mechanisms) -- [Error handling](https://grpc.io/docs/guides/error/) -- Interceptors (See [`GRPC.Endpoint`](https://github.com/elixir-grpc/grpc/blob/master/lib/grpc/endpoint.ex)) -- [Connection Backoff](https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md) -- Data compression -- [gRPC Reflection](https://github.com/elixir-grpc/grpc-reflection) +- Various kinds of RPC: + - [Unary](https://grpc.io/docs/what-is-grpc/core-concepts/#unary-rpc) + - [Server-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#server-streaming-rpc) + - [Client-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#client-streaming-rpc) + - [Bidirectional-streaming](https://grpc.io/docs/what-is-grpc/core-concepts/#bidirectional-streaming-rpc) +- [HTTP Transcoding](https://cloud.google.com/endpoints/docs/grpc/transcoding) +- [TLS Authentication](https://grpc.io/docs/guides/auth/#supported-auth-mechanisms) +- [Error handling](https://grpc.io/docs/guides/error/) +- Interceptors (See [`GRPC.Endpoint`](https://github.com/elixir-grpc/grpc/blob/master/lib/grpc/endpoint.ex)) +- [Connection Backoff](https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md) +- Data compression +- [gRPC Reflection](https://github.com/elixir-grpc/grpc-reflection) ## Benchmark From 6700d4e4e334329febee21b75ebfdb29c99bd394 Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Sun, 27 Apr 2025 01:16:23 -0300 Subject: [PATCH 23/26] chore: fix Note bold --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 764bca26..52dff799 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ end We will use this module [in the gRPC server startup section](#start-application). -****Note:**** For other types of RPC call like streams see [here](interop/lib/interop/server.ex). +**Note:** For other types of RPC call like streams see [here](interop/lib/interop/server.ex). ### **HTTP Transcoding** From 7c34baa11853a649d05190e05863ff8127bcc79f Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Sun, 27 Apr 2025 01:20:12 -0300 Subject: [PATCH 24/26] docs: cleanup readme linking --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 52dff799..bab47f76 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ An Elixir implementation of [gRPC](http://www.grpc.io/). - [Usage](#usage) - [Simple RPC](#simple-rpc) - [HTTP Transcoding](#http-transcoding) + - [CORS](#cors) - [Start Application](#start-application) - [Features](#features) - [Benchmark](#benchmark) @@ -170,10 +171,7 @@ See full application code in [helloworld_transcoding](examples/helloworld_transc ### **CORS** -When accessing gRPC from a browser via HTTP transcoding or gRPC-Web, CORS headers may be required for the browser to allow access to the gRPC endpoint. Adding CORS headers can be done by using the included `Interceptor` in your `Endpoint` module, configuring it with an `allow_origin` and, optionally, `allow_headers`: - -- `allow_origin` - Required. A string containing the allowed origin, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. -- `allow_headers` - A string containing the allowed headers, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. If not provided, the value of the `"access-control-request-headers"` request header from the client will be used in the response. +When accessing gRPC from a browser via HTTP transcoding or gRPC-Web, CORS headers may be required for the browser to allow access to the gRPC endpoint. Adding CORS headers can be done by using `GRPC.Server.Interceptors.CORS` as an interceptor in your `GRPC.Endpoint` module, configuring it as decribed in the module documentation: Example: From 99e6593005bbaf104d549e021aa614a524d26a37 Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Sun, 27 Apr 2025 01:22:05 -0300 Subject: [PATCH 25/26] docs: proper module linking in exdoc --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bab47f76..8e1f0b58 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ The accepted options for configuration are the ones listed on [Mint.HTTP.connect - [HTTP Transcoding](https://cloud.google.com/endpoints/docs/grpc/transcoding) - [TLS Authentication](https://grpc.io/docs/guides/auth/#supported-auth-mechanisms) - [Error handling](https://grpc.io/docs/guides/error/) -- Interceptors (See [`GRPC.Endpoint`](https://github.com/elixir-grpc/grpc/blob/master/lib/grpc/endpoint.ex)) +- [Interceptors](`GRPC.Endpoint`) - [Connection Backoff](https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md) - Data compression - [gRPC Reflection](https://github.com/elixir-grpc/grpc-reflection) From 3d3933c58160a3902ffbbd2b475898bbe379cf27 Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Sun, 27 Apr 2025 01:29:40 -0300 Subject: [PATCH 26/26] fix: do not use Keyword.validate due to Elixir 1.12 support --- lib/grpc/server/interceptors/cors.ex | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/grpc/server/interceptors/cors.ex b/lib/grpc/server/interceptors/cors.ex index 2f42bc5b..4b5b5d48 100644 --- a/lib/grpc/server/interceptors/cors.ex +++ b/lib/grpc/server/interceptors/cors.ex @@ -41,10 +41,18 @@ defmodule GRPC.Server.Interceptors.CORS do # This is not a full-on Macro context, so binary concatenations and # variables are handled before this step. - opts = Keyword.validate!(opts, [:allow_origin, allow_headers: nil]) + # TODO: use Keyword.validate! once we drop support for Elixir < 1.13 + + {allow_origin, opts} = Keyword.pop(opts, :allow_origin) + {allow_headers, opts} = Keyword.pop(opts, :allow_headers) + + if opts != [] do + raise ArgumentError, + "valid keys are [:allow_origin, :allow_headers], got: #{inspect(opts)}" + end allow_origin = - case Keyword.get(opts, :allow_origin) do + case allow_origin do {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun @@ -57,7 +65,7 @@ defmodule GRPC.Server.Interceptors.CORS do end allow_headers = - case Keyword.get(opts, :allow_headers) do + case allow_headers do {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun