/
router.ex
471 lines (361 loc) · 15.9 KB
/
router.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
defmodule Phoenix.LiveView.Router do
@moduledoc """
Provides LiveView routing for Phoenix routers.
"""
@cookie_key "__phoenix_flash__"
@doc """
Defines a LiveView route.
A LiveView can be routed to by using the `live` macro with a path and
the name of the LiveView:
live "/thermostat", ThermostatLive
By default, you can generate a route to this LiveView by using the `live_path` helper:
live_path(@socket, ThermostatLive)
## Actions and live navigation
It is common for a LiveView to have multiple states and multiple URLs.
For example, you can have a single LiveView that lists all articles on
your web app. For each article there is an "Edit" button which, when
pressed, opens up a modal on the same page to edit the article. It is a
best practice to use live navigation in those cases, so when you click
edit, the URL changes to "/articles/1/edit", even though you are still
within the same LiveView. Similarly, you may also want to show a "New"
button, which opens up the modal to create new entries, and you want
this to be reflected in the URL as "/articles/new".
In order to make it easier to recognize the current "action" your
LiveView is on, you can pass the action option when defining LiveViews
too:
live "/articles", ArticleLive.Index, :index
live "/articles/new", ArticleLive.Index, :new
live "/articles/:id/edit", ArticleLive.Index, :edit
When an action is given, the generated route helpers are named after
the LiveView itself (in the same way as for a controller). For the example
above, we will have:
article_index_path(@socket, :index)
article_index_path(@socket, :new)
article_index_path(@socket, :edit, 123)
The current action will always be available inside the LiveView as
the `@live_action` assign, that can be used to render a LiveComponent:
<%= if @live_action == :new do %>
<.live_component module={MyAppWeb.ArticleLive.FormComponent} id="form" />
<% end %>
Or can be used to show or hide parts of the template:
<%= if @live_action == :edit do %>
<%= render("form.html", user: @user) %>
<% end %>
Note that `@live_action` will be `nil` if no action is given on the route definition.
## Options
* `:container` - an optional tuple for the HTML tag and DOM attributes to
be used for the LiveView container. For example: `{:li, style: "color: blue;"}`.
See `Phoenix.Component.live_render/3` for more information and examples.
* `:as` - optionally configures the named helper. Defaults to `:live` when
using a LiveView without actions or defaults to the LiveView name when using
actions.
* `:metadata` - a map to optional feed metadata used on telemetry events and route info,
for example: `%{route_name: :foo, access: :user}`.
* `:private` - an optional map of private data to put in the plug connection.
for example: `%{route_name: :foo, access: :user}`.
## Examples
defmodule MyApp.Router
use Phoenix.Router
import Phoenix.LiveView.Router
scope "/", MyApp do
pipe_through [:browser]
live "/thermostat", ThermostatLive
live "/clock", ClockLive
live "/dashboard", DashboardLive, container: {:main, class: "row"}
end
end
iex> MyApp.Router.Helpers.live_path(MyApp.Endpoint, MyApp.ThermostatLive)
"/thermostat"
"""
defmacro live(path, live_view, action \\ nil, opts \\ []) do
quote bind_quoted: binding() do
{action, router_options} =
Phoenix.LiveView.Router.__live__(__MODULE__, live_view, action, opts)
Phoenix.Router.get(path, Phoenix.LiveView.Plug, action, router_options)
end
end
@doc """
Defines a live session for live redirects within a group of live routes.
`live_session/3` allow routes defined with `live/4` to support
`live_redirect` from the client with navigation purely over the existing
websocket connection. This allows live routes defined in the router to
mount a new root LiveView without additional HTTP requests to the server.
## Security Considerations
You must always perform authentication and authorization in your LiveViews.
If your application handle both regular HTTP requests and LiveViews, then
you must perform authentication and authorization on both. This is important
because `live_redirect`s *do not go through the plug pipeline*.
`live_session` can be used to draw boundaries between groups of LiveViews.
Redirecting between `live_session`s will always force a full page reload
and establish a brand new LiveView connection. This is useful when LiveViews
require different authentication strategies or simply when they use different
root layouts (as the root layout is not updated between live redirects).
Please [read our guide on the security model](security-model.md) for a
detailed description and general tips on authentication, authorization,
and more.
## Options
* `:session` - The optional extra session map or MFA tuple to be merged with
the LiveView session. For example, `%{"admin" => true}`, `{MyMod, :session, []}`.
For MFA, the function is invoked, passing the `Plug.Conn` struct is prepended
to the arguments list.
* `:root_layout` - The optional root layout tuple for the initial HTTP render to
override any existing root layout set in the router.
* `:on_mount` - The optional list of hooks to attach to the mount lifecycle _of
each LiveView in the session_. See `Phoenix.LiveView.on_mount/1`. Passing a
single value is also accepted.
* `:layout` - The optional layout the LiveView will be rendered in.
## Examples
scope "/", MyAppWeb do
pipe_through :browser
live_session :default do
live "/feed", FeedLive, :index
live "/status", StatusLive, :index
live "/status/:id", StatusLive, :show
end
live_session :admin, on_mount: MyAppWeb.AdminLiveAuth do
live "/admin", AdminDashboardLive, :index
live "/admin/posts", AdminPostLive, :index
end
end
In the example above, we have two live sessions. Live navigation between live views
in the different sessions is not possible and will always require a full page reload.
This is important in the example above because the `:admin` live session has authentication
requirements, defined by `on_mount: MyAppWeb.AdminLiveAuth`, that the other LiveViews
do not have.
If you have both regular HTTP routes (via get, post, etc) and `live` routes, then
you need to perform the same authentication and authorization rules in both.
For example, if you were to add a `get "/admin/health"` entry point inside the
`:admin` live session above, then you must create your own plug that performs the
same authentication and authorization rules as `MyAppWeb.AdminLiveAuth`, and then
pipe through it:
live_session :admin, on_mount: MyAppWeb.AdminLiveAuth do
scope "/" do
# Regular routes
pipe_through [MyAppWeb.AdminPlugAuth]
get "/admin/health", AdminHealthController, :index
# Live routes
live "/admin", AdminDashboardLive, :index
live "/admin/posts", AdminPostLive, :index
end
end
The opposite is also true, if you have regular http routes and you want to
add your own `live` routes, the same authentication and authorization checks
executed by the plugs listed in `pipe_through` must be ported to LiveViews
and be executed via `on_mount` hooks.
"""
defmacro live_session(name, opts \\ [], do: block) do
opts =
if Macro.quoted_literal?(opts) do
Macro.prewalk(opts, &expand_alias(&1, __CALLER__))
else
opts
end
quote do
unquote(__MODULE__).__live_session__(__MODULE__, unquote(opts), unquote(name))
unquote(block)
Module.delete_attribute(__MODULE__, :phoenix_live_session_current)
end
end
defp expand_alias({:__aliases__, _, _} = alias, env),
do: Macro.expand(alias, %{env | function: {:mount, 3}})
defp expand_alias(other, _env), do: other
@doc false
def __live_session__(module, opts, name) do
Module.register_attribute(module, :phoenix_live_sessions, accumulate: true)
vsn = session_vsn(module)
unless is_atom(name) do
raise ArgumentError, """
expected live_session name to be an atom, got: #{inspect(name)}
"""
end
extra = validate_live_session_opts(opts, module, name)
if nested = Module.get_attribute(module, :phoenix_live_session_current) do
raise """
attempting to define live_session #{inspect(name)} inside #{inspect(nested.name)}.
live_session definitions cannot be nested.
"""
end
if name in Module.get_attribute(module, :phoenix_live_sessions) do
raise """
attempting to redefine live_session #{inspect(name)}.
live_session routes must be declared in a single named block.
"""
end
current = %{name: name, extra: extra, vsn: vsn}
Module.put_attribute(module, :phoenix_live_session_current, current)
Module.put_attribute(module, :phoenix_live_sessions, name)
end
@live_session_opts [:layout, :on_mount, :root_layout, :session]
defp validate_live_session_opts(opts, module, _name) when is_list(opts) do
Enum.reduce(opts, %{}, fn
{:session, val}, acc when is_map(val) or (is_tuple(val) and tuple_size(val) == 3) ->
Map.put(acc, :session, val)
{:session, bad_session}, _acc ->
raise ArgumentError, """
invalid live_session :session
expected a map with string keys or an MFA tuple, got #{inspect(bad_session)}
"""
{:root_layout, {mod, template}}, acc when is_atom(mod) and is_binary(template) ->
template = Phoenix.LiveView.Utils.normalize_layout(template, "live_session :root_layout")
Map.put(acc, :root_layout, {mod, String.to_atom(template)})
{:root_layout, {mod, template}}, acc when is_atom(mod) and is_atom(template) ->
Map.put(acc, :root_layout, {mod, template})
{:root_layout, false}, acc ->
Map.put(acc, :root_layout, false)
{:root_layout, bad_layout}, _acc ->
raise ArgumentError, """
invalid live_session :root_layout
expected a tuple with the view module and template string or atom name, got #{inspect(bad_layout)}
"""
{:layout, {mod, template}}, acc when is_atom(mod) and is_binary(template) ->
template = Phoenix.LiveView.Utils.normalize_layout(template, "live_session :layout")
Map.put(acc, :layout, {mod, template})
{:layout, {mod, template}}, acc when is_atom(mod) and is_atom(template) ->
Map.put(acc, :layout, {mod, template})
{:layout, false}, acc ->
Map.put(acc, :layout, false)
{:layout, bad_layout}, _acc ->
raise ArgumentError, """
invalid live_session :layout
expected a tuple with the view module and template string or atom name, got #{inspect(bad_layout)}
"""
{:on_mount, on_mount}, acc ->
hooks = Enum.map(List.wrap(on_mount), &Phoenix.LiveView.Lifecycle.on_mount(module, &1))
Map.put(acc, :on_mount, hooks)
{key, _val}, _acc ->
raise ArgumentError, """
unknown live_session option "#{inspect(key)}"
Supported options include: #{inspect(@live_session_opts)}
"""
end)
end
defp validate_live_session_opts(invalid, _module, name) do
raise ArgumentError, """
expected second argument to live_session to be a list of options, got:
live_session #{inspect(name)}, #{inspect(invalid)}
"""
end
@doc """
Fetches the LiveView and merges with the controller flash.
Replaces the default `:fetch_flash` plug used by `Phoenix.Router`.
## Examples
defmodule MyAppWeb.Router do
use LiveGenWeb, :router
import Phoenix.LiveView.Router
pipeline :browser do
...
plug :fetch_live_flash
end
...
end
"""
def fetch_live_flash(%Plug.Conn{} = conn, _) do
case cookie_flash(conn) do
{conn, nil} ->
Phoenix.Controller.fetch_flash(conn, [])
{conn, flash} ->
conn
|> Phoenix.Controller.fetch_flash([])
|> Phoenix.Controller.merge_flash(flash)
end
end
@doc false
def __live__(router, live_view, action, opts)
when is_list(action) and is_list(opts) do
__live__(router, live_view, nil, Keyword.merge(action, opts))
end
def __live__(router, live_view, action, opts)
when is_atom(action) and is_list(opts) do
live_session =
Module.get_attribute(router, :phoenix_live_session_current) ||
%{name: :default, extra: %{}, vsn: session_vsn(router)}
live_view = Phoenix.Router.scoped_alias(router, live_view)
{private, metadata, warn_on_verify, opts} = validate_live_opts!(opts)
opts =
opts
|> Keyword.put(:router, router)
|> Keyword.put(:action, action)
{as_helper, as_action} = inferred_as(live_view, opts[:as], action)
metadata =
metadata
|> Map.put(:phoenix_live_view, {live_view, action, opts, live_session})
|> Map.put_new(:log_module, live_view)
|> Map.put_new(:log_function, :mount)
{as_action,
alias: false,
as: as_helper,
warn_on_verify: warn_on_verify,
private: Map.put(private, :phoenix_live_view, {live_view, opts, live_session}),
metadata: metadata}
end
defp validate_live_opts!(opts) do
{private, opts} = Keyword.pop(opts, :private, %{})
{metadata, opts} = Keyword.pop(opts, :metadata, %{})
{warn_on_verify, opts} = Keyword.pop(opts, :warn_on_verify, false)
Enum.each(opts, fn
{:container, {tag, attrs}} when is_atom(tag) and is_list(attrs) ->
:ok
{:container, val} ->
raise ArgumentError, """
expected live :container to be a tuple matching {atom, attrs :: list}, got: #{inspect(val)}
"""
{:as, as} when is_atom(as) ->
:ok
{:as, bad_val} ->
raise ArgumentError, """
expected live :as to be an atom, got: #{inspect(bad_val)}
"""
{key, %{} = meta} when key in [:metadata, :private] and is_map(meta) ->
:ok
{key, bad_val} when key in [:metadata, :private] ->
raise ArgumentError, """
expected live :#{key} to be a map, got: #{inspect(bad_val)}
"""
{key, val} ->
raise ArgumentError, """
unknown live option :#{key}.
Supported options include: :container, :as, :metadata, :private, :warn_on_verify.
Got: #{inspect([{key, val}])}
"""
end)
{private, metadata, warn_on_verify, opts}
end
defp inferred_as(live_view, as, nil), do: {as || :live, live_view}
defp inferred_as(live_view, nil, action) do
live_view
|> Module.split()
|> Enum.drop_while(&(not String.ends_with?(&1, "Live")))
|> Enum.map(&(&1 |> String.replace_suffix("Live", "") |> Macro.underscore()))
|> Enum.reject(&(&1 == ""))
|> Enum.join("_")
|> case do
"" ->
raise ArgumentError,
"could not infer :as option because a live action was given and the LiveView " <>
"does not have a \"Live\" suffix. Please pass :as explicitly or make sure your " <>
"LiveView is named like \"FooLive\" or \"FooLive.Index\""
as ->
{String.to_atom(as), action}
end
end
defp inferred_as(_live_view, as, action), do: {as, action}
defp cookie_flash(%Plug.Conn{cookies: %{@cookie_key => token}} = conn) do
endpoint = Phoenix.Controller.endpoint_module(conn)
flash =
case Phoenix.LiveView.Utils.verify_flash(endpoint, token) do
%{} = flash when flash != %{} -> flash
%{} -> nil
end
{Plug.Conn.delete_resp_cookie(conn, @cookie_key), flash}
end
defp cookie_flash(%Plug.Conn{} = conn), do: {conn, nil}
defp session_vsn(module) do
if vsn = Module.get_attribute(module, :phoenix_session_vsn) do
vsn
else
vsn = System.system_time()
Module.put_attribute(module, :phoenix_session_vsn, vsn)
vsn
end
end
end