Skip to content

Commit ac0cb24

Browse files
committed
Add basic support for localized routes
1 parent 6443bd5 commit ac0cb24

File tree

2 files changed

+131
-31
lines changed

2 files changed

+131
-31
lines changed

lib/phoenix/verified_routes.ex

+110-30
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,11 @@ defmodule Phoenix.VerifiedRoutes do
7777
To verify routes in your application modules, such as controller, templates, and views,
7878
`use Phoenix.VerifiedRoutes`, which supports the following options:
7979
80-
* `:router` - The required router to verify ~p paths against
81-
* `:endpoint` - The optional endpoint for ~p script_name and URL generation
82-
* `:statics` - The optional list of static directories to treat as verified paths
80+
* `:router` - The required router to verify `~p` paths against
81+
* `:endpoint` - Optional endpoint for URL generation
82+
* `:statics` - Optional list of static directories to treat as verified paths
83+
* `:path_prefixes` - Optional list of path prefixes to be added to every generated path.
84+
See "Path prefixes" for more information
8385
8486
For example:
8587
@@ -88,7 +90,7 @@ defmodule Phoenix.VerifiedRoutes do
8890
endpoint: AppWeb.Endpoint,
8991
statics: ~w(images)
9092
91-
## Usage
93+
## Connection/socket-based route generation
9294
9395
The majority of path and URL generation needs your application will be met
9496
with `~p` and `url/1`, where all information necessary to construct the path
@@ -108,32 +110,77 @@ defmodule Phoenix.VerifiedRoutes do
108110
such as library code, or application code that relies on multiple routers. In such cases,
109111
the router module can be provided explicitly to `path/3` and `url/3`.
110112
111-
## Tracking Warnings
113+
## Tracking warnings
112114
113115
All static path segments must start with forward slash, and you must have a static segment
114116
between dynamic interpolations in order for a route to be verified without warnings.
115-
For example, the following path generates proper warnings
117+
For example, imagine you have these two routes:
116118
117-
~p"/media/posts/#{post}"
119+
get "/media/posts/:id"
120+
get "/media/images/:id"
118121
119-
While this one will not allow the compiler to see the full path:
122+
The following route will be verified and emit a warning as it does not match the router:
120123
121-
type = "posts"
124+
~p"/media/post/#{post}"
125+
126+
However the one below will not, the "post" segment is dynamic:
127+
128+
type = "post"
122129
~p"/media/#{type}/#{post}"
123130
124-
In such cases, it's better to write a function such as `media_path/1` which branches
125-
on different `~p`'s to handle each type.
131+
If you find yourself needing to generate dynamic URLs which are defined statically
132+
in the router, that's a good indicator you should refactor it into one or more
133+
function, such as `posts_path/1` and `images_path/1`.
126134
127135
Like any other compilation warning, the Elixir compiler will warn any time the file
128-
that a ~p resides in changes, or if the router is changed. To view previously issued
129-
warnings for files that lack new changes, the `--all-warnings` flag may be passed to
130-
the `mix compile` task. For the following will show all warnings the compiler
131-
has previously encountered when compiling the current application code:
136+
that a `~p` resides in changes, or if the router is changed.
137+
138+
## Localized routes and path prefixes
139+
140+
Applications that need to support internationalization (i18n) and localization (l10n)
141+
often do so at the URL level. In such cases, there are different approaches one can
142+
choose.
143+
144+
One option is to perform i18n at the domain level. You can have `example.com` (in which
145+
you would detect the locale based on the "Accept-Language" HTTP header), `en.example.com`,
146+
`en-GB.example.com` and so forth. In this case, you would have a plug that looks at the
147+
host and at HTTP headers and calls `Gettext.get_locale/1` accordingly. The biggest benefit
148+
of this approach is that you don't have to change the routes in your application and
149+
verified routes works as is.
150+
151+
Some applications, however, like to the locale as part of the URL prefix:
152+
153+
scope "/:locale" do
154+
get "/posts"
155+
get "/images"
156+
end
132157
133-
$ mix compile --all-warnings
158+
For such cases, VerifiedRoutes allow you to configure a `path_prefixes` option, which
159+
is a list of segments to prepend to the URL. For example:
134160
135-
*Note: Elixir >= 1.14.0 is required for comprehensive warnings. Older versions
136-
will compile properly, but no warnings will be issued.
161+
use Phoenix.VerifiedRoutes,
162+
router: AppWeb.Router,
163+
endpoint: AppWeb.Endpoint,
164+
path_prefixes: [{Gettext, :get_locale, []}]
165+
166+
The above will prepend `"/#{Gettext.get_locale()}"` to every path and url generated with
167+
`~p`. If your website has a handful of URLs that do not require the locale prefix, then
168+
we suggest defining them in a separate module, where you use `Phoenix.VerifiedRoutes`
169+
without the prefix option:
170+
171+
defmodule UnlocalizedRoutes do
172+
use Phoenix.VerifiedRoutes,
173+
router: AppWeb.Router,
174+
endpoint: AppWeb.Endpoint,
175+
176+
# Since :path_prefixes was not declared,
177+
# the code below won't prepend the locale and still be verified
178+
def root, do: ~p"/"
179+
end
180+
181+
Finally, for even more complex use cases, where the whole URL needs to localized,
182+
see projects such as [Routex](https://github.com/BartOtten/routex) and
183+
[CLDR.Routes](https://github.com/elixir-cldr/cldr_routes).
137184
'''
138185
@doc false
139186
defstruct router: nil,
@@ -175,7 +222,20 @@ defmodule Phoenix.VerifiedRoutes do
175222
other -> raise ArgumentError, "expected statics to be a list, got: #{inspect(other)}"
176223
end
177224

178-
Module.put_attribute(mod, :phoenix_verified_statics, statics)
225+
path_prefixes =
226+
case Keyword.get(opts, :path_prefixes, []) do
227+
list when is_list(list) ->
228+
list
229+
230+
other ->
231+
raise ArgumentError,
232+
"expected path_prefixes to be a list of zero-arity functions, got: #{inspect(other)}"
233+
end
234+
235+
Module.put_attribute(mod, :phoenix_verified_config, %{
236+
statics: statics,
237+
path_prefixes: path_prefixes
238+
})
179239
end
180240

181241
@after_verify_supported Version.match?(System.version(), ">= 1.14.0")
@@ -805,7 +865,7 @@ defmodule Phoenix.VerifiedRoutes do
805865
end
806866

807867
defp build_route(route_ast, sigil_p, env, endpoint_ctx, router) do
808-
statics = Module.get_attribute(env.module, :phoenix_verified_statics, [])
868+
config = Module.get_attribute(env.module, :phoenix_verified_config, [])
809869

810870
router =
811871
case Macro.expand(router, env) do
@@ -821,7 +881,7 @@ defmodule Phoenix.VerifiedRoutes do
821881
end
822882

823883
{static?, meta, test_path, path_ast, static_ast} =
824-
rewrite_path(route_ast, endpoint_ctx, router, statics)
884+
rewrite_path(route_ast, endpoint_ctx, router, config)
825885

826886
route = %__MODULE__{
827887
router: router,
@@ -844,25 +904,30 @@ defmodule Phoenix.VerifiedRoutes do
844904
end
845905
end
846906

847-
defp rewrite_path(route, endpoint, router, statics) do
907+
defp rewrite_path(route, endpoint, router, config) do
848908
{:<<>>, meta, segments} = route
849909
{path_rewrite, query_rewrite} = verify_segment(segments, route)
910+
path_rewrite = compile_prefixes(config.path_prefixes, meta) ++ path_rewrite
850911

851912
rewrite_route =
852-
quote generated: true do
853-
query_str = unquote({:<<>>, meta, query_rewrite})
854-
path_str = unquote({:<<>>, meta, path_rewrite})
913+
if query_rewrite == [] do
914+
{:<<>>, meta, path_rewrite}
915+
else
916+
quote generated: true do
917+
query_str = unquote({:<<>>, meta, query_rewrite})
918+
path_str = unquote({:<<>>, meta, path_rewrite})
855919

856-
if query_str == "" do
857-
path_str
858-
else
859-
path_str <> "?" <> query_str
920+
if query_str == "" do
921+
path_str
922+
else
923+
path_str <> "?" <> query_str
924+
end
860925
end
861926
end
862927

863928
test_path = Enum.map_join(path_rewrite, &if(is_binary(&1), do: &1, else: "1"))
864929

865-
static? = static_path?(test_path, statics)
930+
static? = static_path?(test_path, config.statics)
866931

867932
path_ast =
868933
quote generated: true do
@@ -877,6 +942,21 @@ defmodule Phoenix.VerifiedRoutes do
877942
{static?, meta, test_path, path_ast, static_ast}
878943
end
879944

945+
defp compile_prefixes(path_prefixes, meta) do
946+
Enum.flat_map(path_prefixes, fn
947+
{module, fun, args} when is_atom(module) and is_atom(fun) and is_list(args) ->
948+
[
949+
"/",
950+
{:"::", meta,
951+
[{{:., meta, [module, fun]}, meta, Macro.escape(args)}, {:binary, meta, nil}]}
952+
]
953+
954+
other ->
955+
raise ArgumentError,
956+
":path_prefixes option in VerifiedRoutes must be a {mod, fun, args} and return a string, got: #{inspect(other)}"
957+
end)
958+
end
959+
880960
defp attr!(%{function: nil}, _) do
881961
raise "Phoenix.VerifiedRoutes can only be used inside functions, please move your usage of ~p to functions"
882962
end

test/phoenix/verified_routes_test.exs

+21-1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ defmodule Phoenix.VerifiedRoutesTest do
4747

4848
forward "/router_forward", AdminRouter
4949
forward "/plug_forward", UserController
50+
51+
scope "/:locale" do
52+
get "/foo", PostController, :show
53+
get "/bar", PostController, :show
54+
end
5055
end
5156

5257
defmodule CatchAllWarningRouter do
@@ -192,6 +197,22 @@ defmodule Phoenix.VerifiedRoutesTest do
192197
:code.delete(__MODULE__.Hash)
193198
end
194199

200+
test ":path_prefixes" do
201+
defmodule PathPrefixes do
202+
use Phoenix.VerifiedRoutes,
203+
endpoint: unquote(@endpoint),
204+
router: unquote(@router),
205+
path_prefixes: [{__MODULE__, :locale, [{1, 2, 3}]}]
206+
207+
def locale({1, 2, 3}), do: "en"
208+
def foo, do: ~p"/foo"
209+
def bar, do: ~p"/bar"
210+
end
211+
212+
assert PathPrefixes.foo() == "/en/foo"
213+
assert PathPrefixes.bar() == "/en/bar"
214+
end
215+
195216
test "unverified_path" do
196217
assert unverified_path(conn_with_script_name(), @router, "/posts") == "/api/posts"
197218
assert unverified_path(@endpoint, @router, "/posts") == "/posts"
@@ -521,7 +542,6 @@ defmodule Phoenix.VerifiedRoutesTest do
521542

522543
assert warnings =~
523544
~r"test/phoenix/verified_routes_test.exs:#{line}:(\d+:)? Phoenix.VerifiedRoutesTest.Forwards.test/0"
524-
525545
after
526546
:code.purge(__MODULE__.Forwards)
527547
:code.delete(__MODULE__.Forwards)

0 commit comments

Comments
 (0)