Skip to content

Commit

Permalink
Support multiple scope hosts. Closes phoenixframework#4744 (phoenixfr…
Browse files Browse the repository at this point in the history
…amework#4912)

* Support multiple scope hosts. Closes phoenixframework#4744
  • Loading branch information
chrismccord authored Aug 11, 2022
1 parent 3c7f494 commit f256e80
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 31 deletions.
27 changes: 16 additions & 11 deletions lib/phoenix/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -613,19 +613,24 @@ defmodule Phoenix.Router do
dispatch: dispatch,
verb_match: verb_match,
path_params: path_params,
host: host
hosts: hosts
} = expr

[clause] =
quote do
unquote(verb_match), unquote(host) ->
{unquote(build_metadata(route, path_params)),
fn var!(conn, :conn), %{path_params: var!(path_params, :conn)} -> unquote(prepare) end,
&unquote(Macro.var(pipe_name, __MODULE__))/1,
unquote(dispatch)}
end
new_acc_clauses =
Enum.reduce(hosts, acc_clauses, fn host, acc_clauses ->
[clause] =
quote do
unquote(verb_match), unquote(host) ->
{unquote(build_metadata(route, path_params)),
fn var!(conn, :conn), %{path_params: var!(path_params, :conn)} -> unquote(prepare) end,
&unquote(Macro.var(pipe_name, __MODULE__))/1,
unquote(dispatch)}
end

[clause | acc_clauses]
end)

{[clause | acc_clauses], acc_pipes, known_pipes}
{new_acc_clauses, acc_pipes, known_pipes}
end

defp build_metadata(route, path_params) do
Expand Down Expand Up @@ -996,7 +1001,7 @@ defmodule Phoenix.Router do
false, it resets the nested helper scopes.
* `:alias` - an alias (atom) containing the controller scope. When set to
false, it resets all nested aliases.
* `:host` - a string containing the host scope, or prefix host scope,
* `:host` - a string or list of strings containing the host scope, or prefix host scope,
ie `"foo.bar.com"`, `"foo."`
* `:private` - a map of private data to merge into the connection when a route matches
* `:assigns` - a map of data to merge into the connection when a route matches
Expand Down
17 changes: 11 additions & 6 deletions lib/phoenix/router/route.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ defmodule Phoenix.Router.Route do
* `:line` - the line the route was defined
* `:kind` - the kind of route, one of `:match`, `:forward`
* `:path` - the normalized path as string
* `:host` - the request host or host prefix
* `:hosts` - the this of request hosts or host prefixes
* `:plug` - the plug module
* `:plug_opts` - the plug options
* `:helper` - the name of the helper as a string (may be nil)
Expand All @@ -24,7 +24,7 @@ defmodule Phoenix.Router.Route do
* `:trailing_slash?` - whether or not the helper functions append a trailing slash
"""

defstruct [:verb, :line, :kind, :path, :host, :plug, :plug_opts,
defstruct [:verb, :line, :kind, :path, :hosts, :plug, :plug_opts,
:helper, :private, :pipe_through, :assigns, :metadata,
:trailing_slash?, :warn_on_verify?]

Expand All @@ -47,13 +47,13 @@ defmodule Phoenix.Router.Route do
and returns a `Phoenix.Router.Route` struct.
"""
@spec build(non_neg_integer, :match | :forward, atom, String.t, String.t | nil, atom, atom, atom | nil, list(atom), map, map, map, boolean, boolean) :: t
def build(line, kind, verb, path, host, plug, plug_opts, helper, pipe_through, private, assigns, metadata, trailing_slash?, warn_on_verify?)
when is_atom(verb) and (is_binary(host) or is_nil(host)) and
def build(line, kind, verb, path, hosts, plug, plug_opts, helper, pipe_through, private, assigns, metadata, trailing_slash?, warn_on_verify?)
when is_atom(verb) and is_list(hosts) and
is_atom(plug) and (is_binary(helper) or is_nil(helper)) and
is_list(pipe_through) and is_map(private) and is_map(assigns) and
is_map(metadata) and kind in [:match, :forward] and
is_boolean(trailing_slash?) do
%Route{kind: kind, verb: verb, path: path, host: host, private: private,
%Route{kind: kind, verb: verb, path: path, hosts: hosts, private: private,
plug: plug, plug_opts: plug_opts, helper: helper,
pipe_through: pipe_through, assigns: assigns, line: line, metadata: metadata,
trailing_slash?: trailing_slash?, warn_on_verify?: warn_on_verify?}
Expand All @@ -69,13 +69,18 @@ defmodule Phoenix.Router.Route do
path: path,
binding: binding,
dispatch: build_dispatch(route, forwards),
host: Plug.Router.Utils.build_host_match(route.host),
hosts: build_host_match(route.hosts),
path_params: build_path_params(binding),
prepare: build_prepare(route),
verb_match: verb_match(route.verb)
}
end

def build_host_match([]), do: [Plug.Router.Utils.build_host_match(nil)]
def build_host_match([_ | _] = hosts) do
for host <- hosts, do: Plug.Router.Utils.build_host_match(host)
end

defp verb_match(:*), do: Macro.var(:_verb, nil)
defp verb_match(verb), do: verb |> to_string() |> String.upcase()

Expand Down
28 changes: 24 additions & 4 deletions lib/phoenix/router/scope.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ defmodule Phoenix.Router.Scope do
@pipes :phoenix_pipeline_scopes
@top :phoenix_top_scopes

defstruct path: [], alias: [], as: [], pipes: [], host: nil, private: %{}, assigns: %{}, log: :debug, trailing_slash?: false
defstruct path: [], alias: [], as: [], pipes: [], hosts: [], private: %{}, assigns: %{}, log: :debug, trailing_slash?: false

@doc """
Initializes the scope.
Expand Down Expand Up @@ -50,7 +50,7 @@ defmodule Phoenix.Router.Scope do
register_forwards(module, path, plug)
end

Phoenix.Router.Route.build(line, kind, verb, path, top.host, alias, plug_opts, as, top.pipes, private, assigns, metadata, trailing_slash?, warn_on_verify?)
Phoenix.Router.Route.build(line, kind, verb, path, top.hosts, alias, plug_opts, as, top.pipes, private, assigns, metadata, trailing_slash?, warn_on_verify?)
end

defp register_forwards(module, path, plug) when is_atom(plug) do
Expand Down Expand Up @@ -133,7 +133,12 @@ defmodule Phoenix.Router.Scope do

alias = append_unless_false(top, opts, :alias, &Atom.to_string(&1))
as = append_unless_false(top, opts, :as, & &1)
host = Keyword.get(opts, :host)
hosts =
case Keyword.fetch(opts, :host) do
{:ok, val} -> validate_hosts!(val)
:error -> top.hosts
end

private = Keyword.get(opts, :private, %{})
assigns = Keyword.get(opts, :assigns, %{})

Expand All @@ -143,7 +148,7 @@ defmodule Phoenix.Router.Scope do
path: top.path ++ path,
alias: alias,
as: as,
host: host || top.host,
hosts: hosts,
pipes: top.pipes,
private: Map.merge(top.private, private),
assigns: Map.merge(top.assigns, assigns),
Expand All @@ -152,6 +157,21 @@ defmodule Phoenix.Router.Scope do
})
end

defp validate_hosts!(nil), do: []
defp validate_hosts!(host) when is_binary(host), do: [host]
defp validate_hosts!(hosts) when is_list(hosts) do
for host <- hosts do
unless is_binary(host), do: raise_invalid_host(host)

host
end
end
defp validate_hosts!(invalid), do: raise_invalid_host(invalid)

defp raise_invalid_host(host) do
raise ArgumentError, "expected router scope :host to be compile-time string or list of strings, got: #{inspect(host)}"
end

defp append_unless_false(top, opts, key, fun) do
case opts[key] do
false -> []
Expand Down
23 changes: 13 additions & 10 deletions test/phoenix/router/route_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ defmodule Phoenix.Router.RouteTest do
end

test "builds a route based on verb, path, plug, plug options and helper" do
route = build(1, :match, :get, "/foo/:bar", nil, Hello, :world, "hello_world", [:foo, :bar], %{foo: "bar"}, %{bar: "baz"}, %{log: :debug}, true, true)
route = build(1, :match, :get, "/foo/:bar", [], Hello, :world, "hello_world", [:foo, :bar], %{foo: "bar"}, %{bar: "baz"}, %{log: :debug}, true, true)
assert route.kind == :match
assert route.verb == :get
assert route.path == "/foo/:bar"
assert route.host == nil
assert route.hosts == []
assert route.line == 1
assert route.plug == Hello
assert route.plug_opts == :world
Expand All @@ -28,28 +28,31 @@ defmodule Phoenix.Router.RouteTest do
end

test "builds expressions based on the route" do
exprs = build(1, :match, :get, "/foo/:bar", nil, Hello, :world, "hello_world", [], %{}, %{}, %{}, false, true) |> exprs(%{})
exprs = build(1, :match, :get, "/foo/:bar", [], Hello, :world, "hello_world", [], %{}, %{}, %{}, false, true) |> exprs(%{})
assert exprs.verb_match == "GET"
assert exprs.path == ["foo", {:arg0, [], Phoenix.Router.Route}]
assert exprs.binding == [{"bar", {:arg0, [], Phoenix.Router.Route}}]
assert Macro.to_string(exprs.host) == "_"
assert Macro.to_string(exprs.hosts) == "[_]"

exprs = build(1, :match, :get, "/", "foo.", Hello, :world, "hello_world", [:foo, :bar], %{foo: "bar"}, %{bar: "baz"}, %{}, false, true) |> exprs(%{})
assert Macro.to_string(exprs.host) == "\"foo.\" <> _"
exprs = build(1, :match, :get, "/", ["foo."], Hello, :world, "hello_world", [:foo, :bar], %{foo: "bar"}, %{bar: "baz"}, %{}, false, true) |> exprs(%{})
assert Macro.to_string(exprs.hosts) == "[\"foo.\" <> _]"

exprs = build(1, :match, :get, "/", "foo.com", Hello, :world, "hello_world", [], %{foo: "bar"}, %{bar: "baz"}, %{}, false, true) |> exprs(%{})
assert Macro.to_string(exprs.host) == "\"foo.com\""
exprs = build(1, :match, :get, "/", ["foo.", "example.com"], Hello, :world, "hello_world", [:foo, :bar], %{foo: "bar"}, %{bar: "baz"}, %{}, false, true) |> exprs(%{})
assert Macro.to_string(exprs.hosts) == "[\"foo.\" <> _, \"example.com\"]"

exprs = build(1, :match, :get, "/", ["foo.com"], Hello, :world, "hello_world", [], %{foo: "bar"}, %{bar: "baz"}, %{}, false, true) |> exprs(%{})
assert Macro.to_string(exprs.hosts) == "[\"foo.com\"]"
end

test "builds a catch-all verb_match for match routes" do
route = build(1, :match, :*, "/foo/:bar", nil, __MODULE__, :world, "hello_world", [:foo, :bar], %{foo: "bar"}, %{bar: "baz"}, %{}, false, true)
route = build(1, :match, :*, "/foo/:bar", [], __MODULE__, :world, "hello_world", [:foo, :bar], %{foo: "bar"}, %{bar: "baz"}, %{}, false, true)
assert route.verb == :*
assert route.kind == :match
assert exprs(route, %{}).verb_match == {:_verb, [], nil}
end

test "builds a catch-all verb_match for forwarded routes" do
route = build(1, :forward, :*, "/foo", nil, __MODULE__, :world, "hello_world", [:foo], %{foo: "bar"}, %{bar: "baz"}, %{}, false, true)
route = build(1, :forward, :*, "/foo", [], __MODULE__, :world, "hello_world", [:foo], %{foo: "bar"}, %{bar: "baz"}, %{}, false, true)
assert route.verb == :*
assert route.kind == :forward
assert exprs(route, %{__MODULE__ => ["foo"]}).verb_match == {:_verb, [], nil}
Expand Down
42 changes: 42 additions & 0 deletions test/phoenix/router/scope_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ defmodule Phoenix.Router.ScopedRoutingTest do
def edit(conn, _params), do: text(conn, "api v1 users edit")
def foo_host(conn, _params), do: text(conn, "foo request from #{conn.host}")
def baz_host(conn, _params), do: text(conn, "baz request from #{conn.host}")
def multi_host(conn, _params), do: text(conn, "multi_host request from #{conn.host}")
def other_subdomain(conn, _params), do: text(conn, "other_subdomain request from #{conn.host}")
def proxy(conn, _) do
{controller, action} = conn.private.proxy_to
controller.call(conn, controller.init(action))
Expand Down Expand Up @@ -89,6 +91,16 @@ defmodule Phoenix.Router.ScopedRoutingTest do
end
end
end

# match www, no subdomain, and localhost
scope "/multi_host", host: ["www.", "example.com", "localhost"] do
get "/", Api.V1.UserController, :multi_host
end

# matched logged in subdomain user homepages
scope "/multi_host" do
get "/", Api.V1.UserController, :other_subdomain
end
end

setup do
Expand Down Expand Up @@ -147,6 +159,24 @@ defmodule Phoenix.Router.ScopedRoutingTest do
assert conn.resp_body == "baz request from baz.pang.com"
end

test "host scopes allows list of hosts" do
conn = call(Router, :get, "http://www.example.com/multi_host")
assert conn.status == 200
assert conn.resp_body == "multi_host request from www.example.com"

conn = call(Router, :get, "http://www.anotherwww.com/multi_host")
assert conn.status == 200
assert conn.resp_body == "multi_host request from www.anotherwww.com"

conn = call(Router, :get, "http://localhost/multi_host")
assert conn.status == 200
assert conn.resp_body == "multi_host request from localhost"

conn = call(Router, :get, "http://subdomain.example.com/multi_host")
assert conn.status == 200
assert conn.resp_body == "other_subdomain request from subdomain.example.com"
end

test "host 404s when failed match" do
conn = call(Router, :get, "http://foobar.com/host/users/1")
assert conn.status == 200
Expand All @@ -163,6 +193,18 @@ defmodule Phoenix.Router.ScopedRoutingTest do
end
end

test "bad host raises" do
assert_raise ArgumentError, "expected router scope :host to be compile-time string or list of strings, got: nil", fn ->
defmodule BadRouter do
use Phoenix.Router

scope "/admin", host: ["foo.", nil] do
get "/users/:id", Api.V1.UserController, :baz_host
end
end
end
end

test "private data in scopes" do
conn = call(Router, :get, "/api/users")
assert conn.status == 200
Expand Down

0 comments on commit f256e80

Please sign in to comment.