Trilha Phoenix · Rotas
Mix.install(
[
{:phoenix, "~> 1.8.0"},
{:phoenix_live_view, "~> 1.1"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:bandit, "~> 1.5"},
{:jason, "~> 1.4"},
{:kino, "~> 0.14"}
],
consolidate_protocols: false,
config: [
phoenix: [json_library: Jason, plug_init_mode: :runtime],
hello: [
{HelloWeb.Endpoint,
[
adapter: Bandit.PhoenixAdapter,
http: [ip: {127, 0, 0, 1}, port: 4010],
server: true,
debug_errors: true,
secret_key_base: String.duplicate("trilha-phoenix-", 5),
render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false]
]}
]
]
)
defmodule Trilha do
@moduledoc """
Apoio deste notebook: faz requisições ao endpoint, mostra o resultado,
lista as rotas, confere os exercícios e guarda as perguntas e as soluções.
Não faz parte do Phoenix. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
# --- requisições -----------------------------------------------------------
@doc "Despacha uma requisição para o endpoint, sem passar pela rede."
def requisicao(metodo \\ :get, caminho) do
descartar_respostas_antigas()
conn =
Phoenix.ConnTest.build_conn()
|> Phoenix.ConnTest.dispatch(@endpoint, metodo, caminho, nil)
# Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
# devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
if conn.state == :sent and conn.status == nil do
{:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
else
{:ok, conn}
end
rescue
erro -> {:erro, desembrulhar(erro)}
end
# Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
# processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
# próxima página de erro já foi enviada e não a renderiza.
defp descartar_respostas_antigas do
receive do
{ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
descartar_respostas_antigas()
{:plug_conn, :sent} ->
descartar_respostas_antigas()
after
0 -> :ok
end
end
defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
defp desembrulhar(erro), do: erro
@doc "O que o router decide para uma requisição, sem executá-la."
def rota(metodo \\ :get, caminho) do
so_caminho = caminho |> String.split("?") |> hd()
Phoenix.Router.route_info(@router, verbo(metodo), so_caminho, "localhost")
rescue
_ -> :error
end
defp verbo(metodo), do: metodo |> to_string() |> String.upcase()
@doc "Faz a requisição e mostra o que o router decidiu e como o conn terminou."
def mostrar(metodo \\ :get, caminho) do
if is_binary(caminho) and String.starts_with?(caminho, "/") do
Kino.Markdown.new(
Enum.join(
["### #{verbo(metodo)} #{caminho}", md_rota(rota(metodo, caminho)), md_resultado(requisicao(metodo, caminho))],
"\n\n"
)
)
else
Kino.Markdown.new("O caminho precisa começar com `/`, por exemplo `/hello`.")
end
end
@doc "As rotas do router, como `mix phx.routes` mostra. O filtro guarda só as linhas que o contêm."
def rotas(filtro \\ "") do
linhas =
@router
|> Phoenix.Router.ConsoleFormatter.format()
|> String.split("\n", trim: true)
|> Enum.filter(&String.contains?(&1, filtro))
Kino.Markdown.new(cerca(Enum.join(["$ mix phx.routes" | linhas], "\n"), "console"))
end
defp md_rota(:error) do
"**Router** — `Phoenix.Router.route_info/4` devolveu `:error`: nenhuma rota casa com esse verbo e esse caminho."
end
defp md_rota(info) do
tabela(
"**Router** — o que `Phoenix.Router.route_info/4` devolve",
[
{":route", info.route},
{":plug", info.plug},
{":plug_opts", info.plug_opts},
{":path_params", info.path_params},
{":pipe_through", info.pipe_through}
]
)
end
defp md_resultado({:erro, erro}) do
"**Resultado** — a requisição levantou uma exceção:\n\n" <>
cerca(Exception.format_banner(:error, erro), "")
end
defp md_resultado({:ok, conn}) do
tabela(
"**O `conn` no fim da viagem**",
[
{"conn.status", conn.status},
{"conn.halted", conn.halted},
{"conn.path_params", conn.path_params},
{"conn.params", conn.params},
{"conn.assigns", conn.assigns},
{"conn.private.phoenix_controller", conn.private[:phoenix_controller]},
{"conn.private.phoenix_action", conn.private[:phoenix_action]},
{"conn.private.phoenix_format", conn.private[:phoenix_format]},
{"content-type", Plug.Conn.get_resp_header(conn, "content-type")}
]
) <> "\n\n**`conn.resp_body`**\n\n" <> cerca(to_string(conn.resp_body), "")
end
defp tabela(titulo, pares) do
linhas =
Enum.map(pares, fn {campo, valor} ->
texto = valor |> inspect(limit: 30, printable_limit: 300) |> String.replace("|", "\\|") |> String.replace("`", "'")
"| `#{campo}` | `#{texto}` |"
end)
Enum.join([titulo, "", "| Campo | Valor |", "| --- | --- |" | linhas], "\n")
end
defp cerca(texto, linguagem) do
marca = String.duplicate("`", 3)
marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
end
# --- perguntas -------------------------------------------------------------
@doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
def pergunta(id) do
%{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)
quadro = Kino.Frame.new()
letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)
formulario =
Kino.Control.form(
[resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
submit: "Conferir"
)
Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
retorno =
cond do
resposta == nil -> "Escolha uma opção antes de conferir."
resposta == correta -> "**Certo.** " <> explicacao
true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
end
Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
end)
Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
end
defp perguntas do
%{
duplicada: %{
texto:
"um router tem duas rotas iguais: `get \"/hello\", HelloController, :primeira` e, logo abaixo, `get \"/hello\", HelloController, :segunda`. Uma requisição `GET /hello` vai para qual action?",
opcoes: [
a: "`:primeira`, e o compilador avisa que a segunda rota nunca vai casar.",
b: "`:segunda`, porque a última definição substitui a anterior.",
c: "As duas, uma depois da outra.",
d: "Nenhuma: o router não compila com rotas repetidas."
],
correta: :a,
explicacao:
"As macros do router transformam cada rota em uma cláusula de função com pattern matching. As cláusulas são testadas de cima para baixo, como em qualquer função Elixir, e a primeira que casa vence. A segunda cláusula tem exatamente o mesmo padrão da primeira, então o compilador percebe que ela nunca será alcançada e avisa."
},
resources_quantas: %{
texto: "a linha `resources \"/users\", UserController` gera quantas rotas na saída de `mix phx.routes`?",
opcoes: [
a: "4, uma para cada letra de CRUD.",
b: "7, uma por action.",
c: "8.",
d: "1, que o controller desmembra depois."
],
correta: :c,
explicacao:
"São sete actions (`index`, `show`, `new`, `create`, `edit`, `update` e `delete`), mas `update` aparece duas vezes: uma para `PATCH` e outra para `PUT`. Por isso o resumo do guia fala em 8 cláusulas."
},
users_new: %{
texto:
"entre as rotas de `resources \"/users\"` estão `GET /users/new` e `GET /users/:id`, e o segmento `:id` casa com qualquer texto. Chega `GET /users/new`. O que acontece?",
opcoes: [
a: "Vai para `show`, com `\"id\" => \"new\"`.",
b: "Vai para `new`, porque `resources` declara `/users/new` antes de `/users/:id` e a primeira rota que casa vence.",
c: "O Phoenix levanta um erro de rota ambígua.",
d: "Vai para `new`, porque o router sempre prefere segmentos fixos a segmentos dinâmicos."
],
correta: :b,
explicacao:
"O router não calcula qual rota é \"mais específica\": ele testa as cláusulas na ordem em que foram geradas. A ordem que `resources` usa (veja na saída de `mix phx.routes`) põe `/users/new` antes de `/users/:id`. Guarde a opção (d): ela é falsa, e isso volta mais adiante, na seção sobre organizar as rotas."
},
p_desconhecida: %{
texto: "um módulo usa `~p\"/unknown/123\"`, mas o router não tem nenhuma rota para esse caminho. O que acontece?",
opcoes: [
a: "O módulo não compila.",
b: "O módulo compila com um aviso `no route path for HelloWeb.Router matches \"/unknown/123\"`, e a função devolve a string normalmente.",
c: "Compila sem aviso; o erro só aparece quando alguém seguir o link.",
d: "O Phoenix cria a rota que falta."
],
correta: :b,
explicacao:
"A verificação acontece em tempo de compilação e produz um aviso, não um erro: o código continua funcionando, e o `~p` devolve o caminho como texto. O valor do aviso é aparecer na hora em que você escreve o link quebrado (ou renomeia uma rota), e não quando um usuário clica nele em produção."
},
nested_params: %{
texto:
"com `resources \"/users\", UserController do resources \"/posts\", PostController end`, chega `GET /users/42/posts/17`. Que `params` a action `show` do `PostController` recebe?",
opcoes: [
a: "`%{\"id\" => \"42\", \"post_id\" => \"17\"}`",
b: "`%{\"id\" => \"17\", \"user_id\" => \"42\"}`",
c: "`%{\"users\" => \"42\", \"posts\" => \"17\"}`",
d: "`%{\"id\" => \"17\"}`"
],
correta: :b,
explicacao:
"O recurso de dentro continua usando `:id` para o próprio identificador. O de fora vira `:user_id`: o nome do recurso no singular mais `_id`. Os valores chegam como strings, porque vêm da URL."
},
api_html: %{
texto:
"a rota `GET /api/reviews` passa pela pipeline `:api`, que só tem `plug :accepts, [\"json\"]`. Chega `GET /api/reviews?_format=html`. O que acontece?",
opcoes: [
a: "A action roda e responde em HTML.",
b: "A action roda e responde em JSON, ignorando o pedido.",
c: "O plug `accepts` levanta `Phoenix.NotAcceptableError`, e a resposta sai com status 406.",
d: "404, porque a rota só existe para JSON."
],
correta: :c,
explicacao:
"A rota casa (o router olha só para o verbo e o caminho), e o conn entra na pipeline `:api`. O `accepts` lê o formato pedido, pelo parâmetro `_format` ou pelo cabeçalho `accept`, e o compara com a lista aceita. Como `html` não está na lista, ele levanta `Phoenix.NotAcceptableError`, que tem status 406 (Not Acceptable). A action nem chega a rodar."
},
organizar_ordem: %{
texto:
"no router do exemplo abaixo, o escopo público vem primeiro e declara `get \"/reviews/:id\"`; o escopo com `:auth` vem depois e declara `get \"/reviews/new\"`. Chega `GET /reviews/new` sem usuário. O que acontece?",
opcoes: [
a: "A pipeline `:auth` barra a requisição.",
b: "A requisição cai em `show`, com `\"id\" => \"new\"`, e não passa por `:auth`.",
c: "O router não compila, porque há rotas conflitantes.",
d: "404."
],
correta: :b,
explicacao:
"É a mesma regra da pergunta sobre `/users/new`: a primeira rota que casa vence, e `/reviews/:id` casa com `/reviews/new`. Separar as rotas em escopos não muda a ordem de teste, que é a ordem em que aparecem no arquivo. A rota de `new` precisa vir antes da de `show`."
},
forward_path: %{
texto: "com `forward \"/jobs\", BackgroundJob.Plug`, chega `GET /jobs/pending`. Que `conn.path_info` o `BackgroundJob.Plug` enxerga?",
opcoes: [
a: "`[\"jobs\", \"pending\"]`",
b: "`[\"pending\"]`",
c: "`[]`",
d: "`\"/jobs/pending\"`"
],
correta: :b,
explicacao:
"O `forward` tira o prefixo de `path_info` e o move para `conn.script_name`. Assim o plug de destino pode casar as próprias sub-rotas (`/pending`, `/active`) sem saber em que caminho foi montado."
}
}
end
# --- soluções --------------------------------------------------------------
@doc "Um botão que revela a solução de um exercício."
def solucao(id) do
quadro = Kino.Frame.new()
botao = Kino.Control.button("Mostrar a solução")
Kino.listen(botao, fn _evento ->
Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
end)
Kino.Layout.grid([botao, quadro])
end
defp solucoes do
%{
users: ~S'''
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
get "/hello", HelloController, :index
get "/hello/:messenger", HelloController, :show
resources "/users", UserController
end
''',
only_except: ~S'''
# Dentro do scope "/", HelloWeb, depois de resources "/users":
resources "/posts", PostController, only: [:index, :show]
resources "/comments", CommentController, except: [:delete]
''',
verificadas: ~S'''
defmodule Exemplo.Rotas do
use HelloWeb, :verified_routes
def usuario(id), do: ~p"/users/#{id}"
def usuario_admin(id), do: ~p"/users/#{id}?#{[admin: true]}"
def completa, do: url(~p"/users")
end
''',
aninhadas: ~S'''
# No scope "/", HelloWeb, a linha de users ganha um bloco:
resources "/users", UserController do
resources "/posts", PostController
end
''',
slug: ~S'''
defmodule Hello.Post do
@derive {Phoenix.Param, key: :slug}
defstruct [:id, :slug, :title]
end
''',
escopos: ~S'''
scope "/", HelloWeb do
pipe_through :browser
# ... rotas anteriores ...
resources "/reviews", ReviewController
end
scope "/admin", HelloWeb.Admin do
pipe_through :browser
resources "/reviews", ReviewController
end
''',
api: ~S'''
scope "/api", HelloWeb do
pipe_through :api
resources "/reviews", ReviewController
end
''',
organizar: ~S'''
pipeline :auth do
plug HelloWeb.Authentication
end
# Este escopo vem ANTES do escopo público, para que /reviews/new
# seja testada antes de /reviews/:id.
scope "/", HelloWeb do
pipe_through [:browser, :auth]
get "/reviews/new", ReviewController, :new
post "/reviews", ReviewController, :create
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
# ... as outras rotas ...
get "/reviews", ReviewController, :index
get "/reviews/:id", ReviewController, :show
# (a linha resources "/reviews", ReviewController sai daqui)
end
''',
forward: ~S'''
# No fim do router, fora do scope "/", HelloWeb (sem o alias HelloWeb,
# o nome BackgroundJob.Plug é usado como está):
scope "/" do
pipe_through [:auth]
forward "/jobs", BackgroundJob.Plug
end
''',
brecho: ~S'''
# No scope "/", HelloWeb (o público):
resources "/produtos", ProdutoController, only: [:index, :show]
# Um escopo novo:
scope "/vendedor", HelloWeb.Vendedor do
pipe_through [:browser, :auth]
resources "/produtos", ProdutoController
end
# Os controllers, na célula da seção:
defmodule HelloWeb.ProdutoController, do: use(HelloWeb.Eco)
defmodule HelloWeb.Vendedor.ProdutoController, do: use(HelloWeb.Eco)
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício olhando as rotas e fazendo requisições de verdade."
def conferir(id) do
resultado =
Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
case executar(teste) do
:ok -> {:cont, ["- Passou: " <> descricao | feitos]}
{:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n " <> dica | feitos]}
end
end)
Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
end
defp executar(teste) do
teste.()
rescue
erro -> {:erro, "O teste levantou uma exceção: `" <> Exception.format_banner(:error, erro) <> "`"}
end
@acoes [
{:get, "", :index},
{:get, "/:id/edit", :edit},
{:get, "/new", :new},
{:get, "/:id", :show},
{:post, "", :create},
{:patch, "/:id", :update},
{:put, "/:id", :update},
{:delete, "/:id", :delete}
]
defp verificacoes do
%{
users:
for {metodo, sufixo, acao} <- @acoes do
{"Existe `#{verbo(metodo)} /users#{sufixo}` → `HelloWeb.UserController #{inspect(acao)}`.",
fn -> rota_existe(metodo, "/users" <> sufixo, HelloWeb.UserController, acao) end}
end ++
[
{"`GET /users/42` chega à action `show`.",
fn -> responde(:get, "/users/42", 200, "HelloWeb.UserController :show") end}
],
only_except: [
{"`/posts` tem só `index` e `show`.",
fn -> rotas_do_caminho("/posts", HelloWeb.PostController, [:index, :show]) end},
{"`/comments` tem todas as actions menos `delete`.",
fn -> rotas_do_caminho("/comments", HelloWeb.CommentController, [:index, :edit, :new, :show, :create, :update, :update]) end},
{"`DELETE /comments/1` não casa com nenhuma rota.", fn -> sem_rota(:delete, "/comments/1") end},
{"`GET /posts/7` chega à action `show`.", fn -> responde(:get, "/posts/7", 200, "HelloWeb.PostController :show") end}
],
verificadas: [
{"`Exemplo.Rotas.usuario(17)` devolve `\"/users/17\"`.", fn -> igual(Exemplo.Rotas, :usuario, [17], "/users/17") end},
{"`Exemplo.Rotas.usuario_admin(17)` devolve `\"/users/17?admin=true\"`.",
fn -> igual(Exemplo.Rotas, :usuario_admin, [17], "/users/17?admin=true") end},
{"`Exemplo.Rotas.completa()` devolve a URL completa de `/users`.",
fn -> igual(Exemplo.Rotas, :completa, [], apply(@endpoint, :url, []) <> "/users") end}
],
aninhadas: [
{"Existe `GET /users/:user_id/posts/:id` → `HelloWeb.PostController :show`.",
fn -> rota_existe(:get, "/users/:user_id/posts/:id", HelloWeb.PostController, :show) end},
{"Existe `POST /users/:user_id/posts` → `HelloWeb.PostController :create`.",
fn -> rota_existe(:post, "/users/:user_id/posts", HelloWeb.PostController, :create) end},
{"`GET /users/42/posts/17` recebe `\"user_id\" => \"42\"`.",
fn -> responde(:get, "/users/42/posts/17", 200, ~s("user_id" => "42")) end},
{"`GET /users/42/posts/17` recebe `\"id\" => \"17\"`.", fn -> responde(:get, "/users/42/posts/17", 200, ~s("id" => "17")) end},
{"As rotas de `/users` continuam lá.", fn -> responde(:get, "/users/42", 200, "HelloWeb.UserController :show") end}
],
slug: [
{"O módulo `Hello.Post` existe e define uma struct.",
fn ->
if Code.ensure_loaded?(Hello.Post) and function_exported?(Hello.Post, :__struct__, 0),
do: :ok,
else: {:erro, "Avalie a célula que define `Hello.Post`."}
end},
{"`Phoenix.Param.to_param/1` de um `%Hello.Post{}` devolve o slug.",
fn ->
case Phoenix.Param.to_param(struct(Hello.Post, id: 17, slug: "ola-mundo")) do
"ola-mundo" -> :ok
outro -> {:erro, "Devolveu `#{inspect(outro)}`. Acrescente `@derive {Phoenix.Param, key: :slug}` antes do `defstruct`."}
end
end}
],
escopos: [
{"`GET /admin/reviews/7` chega a `HelloWeb.Admin.ReviewController :show`.",
fn -> responde(:get, "/admin/reviews/7", 200, "HelloWeb.Admin.ReviewController :show") end},
{"O escopo `/admin` tem as 8 rotas de `reviews`.",
fn -> rotas_do_caminho("/admin/reviews", HelloWeb.Admin.ReviewController, [:index, :edit, :new, :show, :create, :update, :update, :delete]) end},
{"`GET /reviews/7` chega a `HelloWeb.ReviewController :show`.",
fn -> responde(:get, "/reviews/7", 200, "HelloWeb.ReviewController :show") end}
],
api: [
{"`GET /api/reviews` passa só pela pipeline `:api`.",
fn ->
case rota(:get, "/api/reviews") do
%{pipe_through: [:api]} -> :ok
%{pipe_through: outras} -> {:erro, "A rota passa por `#{inspect(outras)}`."}
:error -> {:erro, "Nenhuma rota casa com `GET /api/reviews`. Descomente e complete o escopo `/api`."}
end
end},
{"`GET /api/reviews` chega a `HelloWeb.ReviewController :index`.",
fn -> responde(:get, "/api/reviews", 200, "HelloWeb.ReviewController :index") end},
{"`GET /api/reviews?_format=html` é recusada com 406.",
fn ->
case requisicao(:get, "/api/reviews?_format=html") do
{:erro, %Phoenix.NotAcceptableError{}} -> :ok
{:ok, conn} -> {:erro, "A requisição foi atendida com status `#{conn.status}`. A pipeline `:api` deveria recusá-la."}
{:erro, erro} -> {:erro, explicar(erro)}
end
end}
],
organizar: [
{"`GET /reviews` responde sem login.", fn -> responde(:get, "/reviews", 200, "HelloWeb.ReviewController :index") end},
{"`GET /reviews/7` responde sem login.", fn -> responde(:get, "/reviews/7", 200, "HelloWeb.ReviewController :show") end},
{"`GET /reviews/new` sem usuário é barrada com 401.", fn -> barrada(:get, "/reviews/new") end},
{"`GET /reviews/new?user=ana` chega à action `new`.",
fn -> responde(:get, "/reviews/new?user=ana", 200, "HelloWeb.ReviewController :new") end},
{"`POST /reviews` sem usuário é barrada com 401.", fn -> barrada(:post, "/reviews") end},
{"`POST /reviews?user=ana` chega à action `create`.",
fn -> responde(:post, "/reviews?user=ana", 200, "HelloWeb.ReviewController :create") end},
{"`GET /reviews/new` passa pelas pipelines `[:browser, :auth]`.",
fn ->
case rota(:get, "/reviews/new") do
%{pipe_through: [:browser, :auth]} -> :ok
%{pipe_through: outras} -> {:erro, "A rota passa por `#{inspect(outras)}`."}
:error -> {:erro, "Nenhuma rota casa com `GET /reviews/new`."}
end
end}
],
forward: [
{"`GET /jobs/pending?user=ana` chega ao `BackgroundJob.Plug`.", fn -> responde(:get, "/jobs/pending?user=ana", 200, "pendentes") end},
{"`GET /jobs/active?user=ana` chega ao `BackgroundJob.Plug`.", fn -> responde(:get, "/jobs/active?user=ana", 200, "ativos") end},
{"`GET /jobs/pending` sem usuário é barrada com 401.", fn -> barrada(:get, "/jobs/pending") end}
],
brecho: [
{"`GET /produtos` responde sem login, em `HelloWeb.ProdutoController :index`.",
fn -> responde(:get, "/produtos", 200, "HelloWeb.ProdutoController :index") end},
{"`GET /produtos/3` responde sem login, em `:show`.", fn -> responde(:get, "/produtos/3", 200, "HelloWeb.ProdutoController :show") end},
{"`POST /produtos` não existe.", fn -> sem_rota(:post, "/produtos") end},
{"`DELETE /produtos/3` não existe.", fn -> sem_rota(:delete, "/produtos/3") end},
{"`GET /vendedor/produtos` sem usuário é barrada com 401.", fn -> barrada(:get, "/vendedor/produtos") end},
{"`GET /vendedor/produtos?user=ana` chega a `HelloWeb.Vendedor.ProdutoController :index`.",
fn -> responde(:get, "/vendedor/produtos?user=ana", 200, "HelloWeb.Vendedor.ProdutoController :index") end},
{"`GET /vendedor/produtos/new?user=ana` chega a `:new`.",
fn -> responde(:get, "/vendedor/produtos/new?user=ana", 200, "HelloWeb.Vendedor.ProdutoController :new") end},
{"`DELETE /vendedor/produtos/3?user=ana` chega a `:delete`.",
fn -> responde(:delete, "/vendedor/produtos/3?user=ana", 200, "HelloWeb.Vendedor.ProdutoController :delete") end}
]
}
end
defp rota_existe(metodo, caminho, plug, acao) do
if Enum.any?(Phoenix.Router.routes(@router), &match?(%{verb: ^metodo, path: ^caminho, plug: ^plug, plug_opts: ^acao}, &1)),
do: :ok,
else:
{:erro,
"O router não tem a rota `#{verbo(metodo)} #{caminho}` → `#{inspect(plug)} #{inspect(acao)}`. Confira com `Trilha.rotas()`."}
end
defp rotas_do_caminho(prefixo, plug, esperadas) do
acoes =
@router
|> Phoenix.Router.routes()
|> Enum.filter(&(&1.path == prefixo or String.starts_with?(&1.path, prefixo <> "/")))
|> Enum.filter(&(&1.plug == plug))
|> Enum.map(& &1.plug_opts)
if Enum.sort(acoes) == Enum.sort(esperadas),
do: :ok,
else:
{:erro,
"As rotas de `#{prefixo}` levam às actions `#{inspect(acoes)}`; o esperado é `#{inspect(Enum.uniq(esperadas))}` (com `:update` duas vezes quando houver, por causa de PATCH e PUT)."}
end
defp sem_rota(metodo, caminho) do
case rota(metodo, caminho) do
:error -> :ok
info -> {:erro, "`#{verbo(metodo)} #{caminho}` ainda casa com `#{inspect(info.plug)} #{inspect(info.plug_opts)}`."}
end
end
defp responde(metodo, caminho, status, trecho) do
case requisicao(metodo, caminho) do
{:ok, %{status: ^status} = conn} ->
corpo = to_string(conn.resp_body)
if String.contains?(corpo, trecho),
do: :ok,
else: {:erro, "`#{verbo(metodo)} #{caminho}` respondeu `#{resumo(corpo)}`. Era esperado algo com `#{trecho}`."}
{:ok, conn} ->
{:erro, "`#{verbo(metodo)} #{caminho}` respondeu com status `#{inspect(conn.status)}` (`#{resumo(to_string(conn.resp_body))}`)."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end
defp barrada(metodo, caminho) do
case requisicao(metodo, caminho) do
{:ok, %{status: 401, halted: true}} ->
:ok
{:ok, conn} ->
corpo = to_string(conn.resp_body)
if String.contains?(corpo, ":show") do
{:erro,
"A requisição caiu em `#{resumo(corpo)}`: uma rota com segmento dinâmico (`/:id`) casou antes. Mude a ordem das rotas."}
else
{:erro, "A requisição foi atendida com status `#{inspect(conn.status)}` (`#{resumo(corpo)}`), sem passar pela pipeline `:auth`."}
end
{:erro, erro} ->
{:erro, explicar(erro)}
end
end
defp igual(modulo, funcao, argumentos, esperado) do
if Code.ensure_loaded?(modulo) and function_exported?(modulo, funcao, length(argumentos)) do
case apply(modulo, funcao, argumentos) do
^esperado -> :ok
outro -> {:erro, "Devolveu `#{inspect(outro)}`, e o esperado é `#{inspect(esperado)}`."}
end
else
{:erro, "Defina `#{inspect(modulo)}.#{funcao}/#{length(argumentos)}` e avalie a célula."}
end
end
defp resumo(texto), do: texto |> String.slice(0, 120) |> String.replace("`", "'")
defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
"Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
nome = inspect(modulo)
cond do
funcao == :init ->
"Etapa: **router**. A rota aponta para `#{nome}`, mas esse módulo não existe. Confira o alias do `scope` e se o módulo foi definido."
String.ends_with?(nome, "Controller") ->
"Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`."
true ->
"A requisição levantou `UndefinedFunctionError` para `#{nome}.#{funcao}/#{aridade}`."
end
end
defp explicar(erro), do: "A requisição levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook acompanha o guia Routing da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. A aplicação Hello volta no estado em que terminou o notebook do ciclo de vida da requisição, com as páginas /hello e /hello/:messenger. Os experimentos do notebook de Plug ficaram de fora.
As convenções são as mesmas dos anteriores: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo. A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes, porque todos usam a mesma porta.
Neste notebook você vai editar sobretudo a célula do router. Três funções de apoio ajudam a ver o efeito de cada mudança:
Trilha.rotas()mostra a mesma tabela quemix phx.routesimprime no terminal.Trilha.rotas("/users")mostra só as linhas que contêm/users.Trilha.mostrar(:get, "/users/7")pergunta ao router o que ele faria com a requisição e depois a despacha pelo endpoint.Trilha.conferir(...)confere cada exercício olhando as rotas e fazendo requisições de verdade.
Avalie a primeira célula, no topo, antes de tudo.
A aplicação Hello dentro do notebook
lib/hello_web.ex ganhou uma função em relação aos notebooks anteriores: verified_routes/0, que habilita o sigil ~p. Como no projeto gerado, ela é incluída em controller e html, e também pode ser usada sozinha com use HelloWeb, :verified_routes.
defmodule HelloWeb do
def router do
quote do
use Phoenix.Router, helpers: false
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def html do
quote do
use Phoenix.Component
alias HelloWeb.Layouts
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: HelloWeb.Endpoint,
router: HelloWeb.Router,
statics: ~w(assets fonts images favicon.ico robots.txt)
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
lib/hello_web/router.ex: a célula que você vai editar ao longo do notebook. Ela está com as rotas do notebook do ciclo de vida e com o comentário do escopo /api que todo projeto novo traz.
O router vem antes dos controllers e das views por um motivo prático. Em um projeto, o compilador organiza a ordem sozinho. Aqui quem decide é a ordem das células, e todo módulo que usa ~p consulta o router ao ser compilado. Por isso o router e o endpoint precisam existir antes.
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
get "/hello", HelloController, :index
get "/hello/:messenger", HelloController, :show
end
# Other scopes may use custom stacks.
# scope "/api", HelloWeb do
# pipe_through :api
# end
end
lib/hello_web/endpoint.ex
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
@session_options [
store: :cookie,
key: "_hello_key",
signing_salt: "trilha00",
same_site: "Lax"
]
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug HelloWeb.Router
end
lib/hello_web/components/layouts.ex e lib/hello_web/controllers/: os layouts, as views de erro (HTML e JSON) e as páginas dos notebooks anteriores.
defmodule HelloWeb.Layouts do
use Phoenix.Component
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hello</title>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, default: %{}
slot :inner_block, required: true
def app(assigns) do
~H"""
<header>
<strong>Hello</strong> · layout da aplicação
</header>
<main>
{render_slot(@inner_block)}
</main>
"""
end
end
defmodule HelloWeb.ErrorHTML do
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
defmodule HelloWeb.ErrorJSON do
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
defmodule HelloWeb.PageController do
use HelloWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end
defmodule HelloWeb.PageHTML do
use HelloWeb, :html
def home(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Welcome to Phoenix!</h1>
</Layouts.app>
"""
end
end
defmodule HelloWeb.HelloController do
use HelloWeb, :controller
def index(conn, _params) do
render(conn, :index)
end
def show(conn, %{"messenger" => messenger}) do
render(conn, :show, messenger: messenger)
end
end
defmodule HelloWeb.HelloHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<section>
<h2>Hello World, from Phoenix!</h2>
</section>
"""
end
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<section>
<h2>Hello World, from {@messenger}!</h2>
</section>
</Layouts.app>
"""
end
end
Apoio do notebook: controllers de eco. O guia cria rotas para UserController, PostController, ReviewController e outros que nunca escreve, porque o assunto é o router. Para que essas rotas respondam aqui, a célula abaixo dá a cada um desses controllers as sete actions de um recurso, e cada action responde em texto dizendo quem foi chamado e com quais params. Isso não existe no Phoenix; é só um jeito de enxergar para onde o router mandou a requisição.
defmodule HelloWeb.Eco do
defmacro __using__(_opts) do
quote do
use HelloWeb, :controller
def index(conn, params), do: HelloWeb.Eco.responder(conn, params)
def show(conn, params), do: HelloWeb.Eco.responder(conn, params)
def new(conn, params), do: HelloWeb.Eco.responder(conn, params)
def create(conn, params), do: HelloWeb.Eco.responder(conn, params)
def edit(conn, params), do: HelloWeb.Eco.responder(conn, params)
def update(conn, params), do: HelloWeb.Eco.responder(conn, params)
def delete(conn, params), do: HelloWeb.Eco.responder(conn, params)
end
end
def responder(conn, params) do
controller = Phoenix.Controller.controller_module(conn)
action = Phoenix.Controller.action_name(conn)
Phoenix.Controller.text(conn, "#{inspect(controller)} #{inspect(action)} #{inspect(params)}")
end
end
defmodule HelloWeb.UserController, do: use(HelloWeb.Eco)
defmodule HelloWeb.PostController, do: use(HelloWeb.Eco)
defmodule HelloWeb.CommentController, do: use(HelloWeb.Eco)
defmodule HelloWeb.ReviewController, do: use(HelloWeb.Eco)
defmodule HelloWeb.Admin.ReviewController, do: use(HelloWeb.Eco)
defmodule HelloWeb.Admin.ImageController, do: use(HelloWeb.Eco)
defmodule HelloWeb.Admin.UserController, do: use(HelloWeb.Eco)
A célula abaixo inicia o endpoint, o equivalente a mix phx.server.
{:ok, _pid} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Aplicação no ar em <http://localhost:4010>.")
O router
Na documentação: Routing
O router é o centro de distribuição de uma aplicação Phoenix. Ele casa requisições HTTP com actions de controllers, liga os handlers de canais de tempo real e define as pipelines de transformação aplicadas a cada grupo de rotas.
A primeira linha do módulo, use HelloWeb, :router, só torna as funções de router do Phoenix disponíveis no nosso router. Os nomes do router e dos controllers levam o nome da aplicação com o sufixo Web (HelloWeb.Router, HelloWeb.PageController).
Dentro do bloco scope está a primeira rota:
get "/", PageController, :home
get é uma macro do Phoenix que corresponde ao verbo HTTP GET. Existem macros equivalentes para os outros verbos: post, put, patch, delete, options, connect, trace e head. Escopos e pipelines têm seções próprias mais adiante.
Por que macros?
O guia explica por que o router usa tantas macros, se o Phoenix tenta evitá-las. São dois motivos:
- Velocidade. As macros compilam todas as rotas em uma única função com uma cláusula de pattern matching por rota, e a máquina virtual do Erlang otimiza muito bem esse tipo de código. É essa função que roda a cada requisição para escolher o controller.
- Segurança. Para cada rota, as macros também registram metadados usados pelas rotas verificadas (
~p), que você vai ver mais adiante: o compilador passa a avisar quando um link aponta para uma rota que não existe.
Macros só atuam em tempo de compilação. Depois de compilado, o router é uma função comum, e o Phoenix oferece introspecção das rotas com mix phx.routes.
Trilha.pergunta(:duplicada)
A célula abaixo define um router separado, só para o experimento, com a mesma rota duas vezes. Leia o aviso que aparece na saída da célula e depois o resultado.
defmodule Exemplo.RouterDuplicado do
use Phoenix.Router, helpers: false
get "/hello", HelloWeb.HelloController, :primeira
get "/hello", HelloWeb.HelloController, :segunda
end
Phoenix.Router.route_info(Exemplo.RouterDuplicado, "GET", "/hello", "localhost")
O aviso, this clause cannot match because a previous clause at line 4 always matches, aponta para a linha 5 da célula, a segunda rota. Ele não fala de rotas, e sim de cláusulas: a cláusula gerada pela segunda rota nunca pode casar, porque a da linha 4 casa sempre antes. O resultado de route_info confirma que a requisição vai para :primeira.
Examinando as rotas
Na documentação: Examining routes
Em um projeto, mix phx.routes lista todas as rotas da aplicação. Aqui, Trilha.rotas() mostra a mesma saída, gerada pelo mesmo código do Phoenix.
Trilha.rotas()
Cada linha tem o verbo, o caminho, o controller e a action. A primeira diz que qualquer requisição GET para a raiz da aplicação é tratada pela action home do HelloWeb.PageController.
Resources
Na documentação: Resources
Além das macros de verbos, o router tem outras. A mais importante é resources, que declara de uma vez as rotas de um recurso.
Trilha.pergunta(:resources_quantas)
Sua vez
Na célula do router, acrescente dentro do scope "/", HelloWeb do, depois das rotas de /hello:
resources "/users", UserController
Avalie a célula do router e veja as rotas novas:
Trilha.rotas("/users")
Trilha.conferir(:users)
Trilha.solucao(:users)
Essa é a matriz padrão de verbos, caminhos e actions. Por um tempo ela foi chamada de "rotas RESTful", mas hoje a maioria considera o nome impreciso. Linha por linha:
| Requisição | Action | O que costuma fazer |
|---|---|---|
GET /users |
index |
mostra todos os usuários |
GET /users/:id/edit |
edit |
busca um usuário pelo ID e o mostra em um formulário de edição |
GET /users/new |
new |
mostra um formulário para criar um usuário |
GET /users/:id |
show |
mostra um usuário identificado pelo ID |
POST /users |
create |
salva um usuário novo |
PATCH /users/:id |
update |
salva as alterações de um usuário |
PUT /users/:id |
update |
também salva as alterações |
DELETE /users/:id |
delete |
remove o usuário |
Do lado do JavaScript, é o par method e URL de um fetch: fetch("/users/7", {method: "DELETE"}) cai na última linha.
Trilha.pergunta(:users_new)
Confira com o router de verdade. Compare :plug_opts e :path_params nas duas requisições:
Trilha.mostrar(:get, "/users/new")
Trilha.mostrar(:get, "/users/42")
O explorador abaixo despacha qualquer requisição. Experimente os oito pares da tabela, um verbo que não existe para um caminho (DELETE /users) e um caminho que não existe (GET /usuarios).
formulario =
Kino.Control.form(
[
verbo: Kino.Input.select("Verbo", get: "GET", post: "POST", put: "PUT", patch: "PATCH", delete: "DELETE"),
caminho: Kino.Input.text("Caminho", default: "/users/42/edit")
],
submit: "Enviar requisição"
)
quadro = Kino.Frame.new()
Kino.listen(formulario, fn %{data: %{verbo: verbo, caminho: caminho}} ->
Kino.Frame.render(quadro, Trilha.mostrar(verbo, caminho))
end)
Kino.Layout.grid([formulario, quadro])
Sua vez: :only e :except
Quando não precisamos de todas as rotas, as opções :only e :except filtram as actions.
Acrescente ao scope "/", HelloWeb do router:
- um recurso
"/posts", atendido porPostController, só de leitura: apenasindexeshow; - um recurso
"/comments", atendido porCommentController, com todas as actions menosdelete.
Trilha.rotas("/posts")
Trilha.rotas("/comments")
Trilha.conferir(:only_except)
Trilha.solucao(:only_except)
A documentação de Phoenix.Router.resources/4 descreve as outras opções, como :param (para trocar o nome :id) e :as.
Rotas verificadas
Na documentação: Verified Routes
O módulo Phoenix.VerifiedRoutes confere, em tempo de compilação, os caminhos escritos com o sigil ~p contra as rotas do router. Você escreve caminhos em controllers, templates e testes como se fossem strings, e o compilador garante que eles casam com alguma rota.
Trilha.pergunta(:p_desconhecida)
O guia faz o teste no iex -S mix. Aqui ele vira uma célula. Avalie e procure o aviso na saída:
defmodule RouteExample do
use HelloWeb, :verified_routes
def example do
~p"/comments"
~p"/unknown/123"
end
end
RouteExample.example()
A primeira chamada, ~p"/comments", não gera aviso, porque existe uma rota para ela. A segunda gera, como deveria. E a função continua funcionando: devolveu a string "/unknown/123". Você pode escrever caminhos no código sem medo de deixá-los desatualizados: se você renomear uma rota, cada ~p que apontava para ela vira um aviso.
Os projetos Phoenix já vêm prontos para usar ~p em toda a camada web. Em templates:
<a href={~p"/"}>Welcome Page!</a>
<a href={~p"/comments"}>View Comments</a>
E em um controller, para redirecionar:
redirect(conn, to: ~p"/comments/#{comment}")
Mais sobre rotas verificadas
Na documentação: More on verified routes
A query string pode ser escrita direto no caminho ou interpolada a partir de uma lista de palavras-chave ou de um mapa:
defmodule Exemplo.QueryStrings do
use HelloWeb, :verified_routes
def todas do
[
~p"/users/17?admin=true&active=false",
~p"/users/17?#{[admin: true]}",
~p"/users/17?#{%{admin: true}}"
]
end
end
Exemplo.QueryStrings.todas()
Para uma URL completa em vez de um caminho, envolva o ~p em url/1, importada em todo lugar em que ~p está disponível. Ela monta host, porta e protocolo a partir da configuração do endpoint. Em um projeto, essa configuração fica em config/dev.exs; aqui ela está na primeira célula, com a porta 4010.
defmodule Exemplo.Url do
use HelloWeb, :verified_routes
def usuarios, do: url(~p"/users")
end
Exemplo.Url.usuarios()
Sua vez
Complete o módulo com três funções:
usuario(id)devolve o caminho de um usuário, por exemplo"/users/17";usuario_admin(id)devolve o mesmo caminho com a query stringadmin=true, montada a partir de uma lista de palavras-chave;completa()devolve a URL completa de/users.
defmodule Exemplo.Rotas do
use HelloWeb, :verified_routes
# Defina aqui usuario/1, usuario_admin/1 e completa/0.
end
Trilha.conferir(:verificadas)
Trilha.solucao(:verificadas)
Recursos aninhados
Na documentação: Nested resources
Recursos podem ser aninhados. Suponha que um usuário possa escrever muitos posts e que cada post pertença a um único usuário. A relação se representa assim no router:
resources "/users", UserController do
resources "/posts", PostController
end
Trilha.pergunta(:nested_params)
Sua vez
Na célula do router, troque a linha resources "/users", UserController pela versão aninhada acima. A linha resources "/posts", PostController, only: [:index, :show] do exercício anterior continua onde está.
Trilha.rotas("/users")
Além das oito rotas de users, aparecem oito rotas de posts dentro de /users/:user_id. Todas pertencem a um usuário: index, por exemplo, deve listar só os posts daquele usuário.
Trilha.mostrar(:get, "/users/42/posts/17")
Trilha.conferir(:aninhadas)
Trilha.solucao(:aninhadas)
Interpolando structs
Para montar o caminho de uma rota aninhada, cada ID vai no seu lugar:
defmodule Exemplo.Aninhadas do
use HelloWeb, :verified_routes
def post_de_usuario(user_id, post_id), do: ~p"/users/#{user_id}/posts/#{post_id}"
end
Exemplo.Aninhadas.post_de_usuario(42, 17)
As rotas verificadas também aceitam structs, por meio do protocolo Phoenix.Param. Quando a aplicação tiver structs como %User{} e %Post{}, dá para interpolá-las direto, e o Phoenix escolhe o campo certo (por padrão, :id). As structs abaixo fazem esse papel; no guia de Ecto elas vão ser schemas de banco de dados.
defmodule Hello.User do
defstruct [:id, :name]
end
defmodule Hello.Post do
defstruct [:id, :slug, :title]
end
Uma struct não pode ser usada na mesma célula que a define, por isso o exemplo fica na célula seguinte:
defmodule Exemplo.Links do
use HelloWeb, :verified_routes
def post(user, post), do: ~p"/users/#{user}/posts/#{post}"
end
user = %Hello.User{id: 42, name: "Ana"}
post = %Hello.Post{id: 17, slug: "ola-mundo", title: "Olá, mundo"}
Exemplo.Links.post(user, post)
Repare que não foi preciso escrever user.id nem post.id. Isso facilita trocar IDs por slugs mais tarde, sem mexer em nenhum ~p.
Sua vez: slugs
Faça os caminhos de posts usarem o campo :slug no lugar do :id. Uma struct escolhe o campo com @derive {Phoenix.Param, key: :slug}, escrito antes do defstruct. Edite a célula de Hello.Post, avalie-a e rode de novo a célula de Exemplo.Links: o caminho deve terminar em /posts/ola-mundo.
Trilha.conferir(:slug)
Trilha.solucao(:slug)
Note que o router não mudou: a rota continua sendo /users/:user_id/posts/:id. O nome do segmento é só um rótulo; quem decide o que vai nele é quem monta o caminho.
Rotas com escopo
Na documentação: Scoped routes
Escopos agrupam rotas sob um prefixo de caminho comum e um conjunto de plugs. São usados para áreas administrativas, APIs e, principalmente, APIs com versão.
O exemplo do guia: um site com avaliações (reviews) escritas pelos usuários, que um administrador precisa aprovar. As avaliações vistas pelo público e as vistas pela administração têm semânticas diferentes e provavelmente não compartilham o controller. Os caminhos públicos seriam /reviews, /reviews/1234, /reviews/1234/edit; os da administração, os mesmos com o prefixo /admin.
O escopo da administração fica assim:
scope "/admin", HelloWeb.Admin do
pipe_through :browser
resources "/reviews", ReviewController
end
O primeiro argumento do scope é o prefixo de caminho. O segundo é o prefixo de módulo: dentro desse bloco, ReviewController significa HelloWeb.Admin.ReviewController.
Sua vez
Na célula do router:
- acrescente
resources "/reviews", ReviewControlleraoscope "/", HelloWeb; - acrescente o escopo
/adminacima, como um bloco novo depois doscope "/", HelloWeb.
Trilha.rotas("reviews")
O mesmo ReviewController aparece com dois módulos diferentes: HelloWeb.ReviewController e HelloWeb.Admin.ReviewController.
Trilha.conferir(:escopos)
Trilha.solucao(:escopos)
Se a administração cuidar de vários recursos, todos podem ficar no mesmo escopo, e cada rota sai com o prefixo de caminho e de módulo corretos:
scope "/admin", HelloWeb.Admin do
pipe_through :browser
resources "/images", ImageController
resources "/reviews", ReviewController
resources "/users", UserController
end
Escopos aninhados
Escopos podem ser aninhados, mas com cuidado: o aninhamento costuma deixar o router mais difícil de ler. O exemplo do guia é uma API com versão. A célula abaixo monta um router separado só com esse trecho e imprime a saída de mix phx.routes para ele:
defmodule Exemplo.RouterApi do
use Phoenix.Router, helpers: false
pipeline :api do
plug :accepts, ["json"]
end
scope "/api", HelloWeb.Api do
pipe_through :api
scope "/v1", V1 do
resources "/images", ImageController
resources "/reviews", ReviewController
resources "/users", UserController
end
end
end
Exemplo.RouterApi |> Phoenix.Router.ConsoleFormatter.format() |> IO.puts()
Os prefixos se somam: o caminho começa com /api/v1 e o módulo com HelloWeb.Api.V1. Os avisos de init/1 is undefined só dizem que esses controllers não existem; o router compila mesmo assim, como o guia já tinha dito sobre o UserController.
Também é permitido ter vários escopos com o mesmo caminho, como dois scope "/" com prefixos de módulo diferentes, desde que nenhuma rota se repita. Se uma rota se repetir, aparece o mesmo aviso que você viu na seção "Por que macros?".
Pipelines
Na documentação: Pipelines
Pipelines são sequências de plugs que podem ser ligadas a escopos. As rotas ficam dentro de escopos, e um escopo pode passar por várias pipelines. Quando uma rota casa, o Phoenix invoca todos os plugs de todas as pipelines associadas a ela.
As pipelines :browser e :api
Na documentação: The :browser and :api pipelines
O Phoenix define duas pipelines por padrão. A :browser prepara as rotas que renderizam páginas para o navegador; a :api prepara as rotas que produzem dados para uma API.
A :browser tem seis plugs:
plug :accepts, ["html"]define o formato, ou os formatos, aceitos;:fetch_sessionbusca os dados da sessão e os torna disponíveis no conn;:fetch_live_flashbusca as mensagens flash do LiveView e as junta às do controller;:put_root_layoutguarda o root layout usado na renderização;:protect_from_forgeryprotege os envios de formulário contra falsificação de requisição entre sites (CSRF);:put_secure_browser_headersacrescenta cabeçalhos de segurança à resposta.
A :api, por enquanto, só tem plug :accepts, ["json"].
Toda requisição passa primeiro pelos plugs do endpoint e só depois o router tenta casar o verbo e o caminho. Se uma rota casa, as pipelines do escopo dela rodam antes do controller. Se nenhuma casa, nenhuma pipeline roda e o resultado é um 404.
Sua vez: o escopo da API
Na célula do router, descomente o escopo /api do fim do arquivo e acrescente a ele resources "/reviews", ReviewController, como no guia. Depois veja a requisição:
Trilha.mostrar(:get, "/api/reviews")
Na tabela, :pipe_through é [:api] e conn.private.phoenix_format é "json": foi o accepts que gravou o formato. (O controller de eco responde em texto mesmo assim, porque chama text/2. Formatos de resposta são assunto do guia de Controllers.)
Trilha.pergunta(:api_html)
Trilha.mostrar(:get, "/api/reviews?_format=html")
O caminho inverso também é recusado: as rotas da :browser só aceitam HTML.
Trilha.mostrar(:get, "/reviews?_format=json")
Trilha.conferir(:api)
Trilha.solucao(:api)
Pipelines em escopos aninhados
Rotas fora de qualquer escopo não têm pipelines. Em escopos aninhados, o router invoca primeiro os pipe_through dos escopos de fora e depois o do escopo de dentro. Outro router de experimento:
defmodule Exemplo.RouterAninhado do
use Phoenix.Router, helpers: false
pipeline :browser do
end
pipeline :admin do
end
scope "/" do
pipe_through :browser
get "/reviews", HelloWeb.ReviewController, :index
scope "/admin" do
pipe_through :admin
get "/reviews", HelloWeb.Admin.ReviewController, :index
end
end
end
for caminho <- ["/reviews", "/admin/reviews"] do
{caminho, Phoenix.Router.route_info(Exemplo.RouterAninhado, "GET", caminho, "localhost").pipe_through}
end
Criando pipelines
Na documentação: Creating new pipelines
Novas pipelines são criadas com a macro pipeline/2, que recebe um átomo com o nome e um bloco com os plugs:
pipeline :auth do
plug HelloWeb.Authentication
end
scope "/reviews", HelloWeb do
pipe_through [:browser, :auth]
resources "/", ReviewController
end
O guia supõe que existe um plug HelloWeb.Authentication que faz a autenticação. A célula abaixo o define. Em uma aplicação real, a autenticação vem do mix phx.gen.auth, assunto de um guia próprio. Aqui, como no notebook de Plug, o "usuário logado" é só o parâmetro user da query string; sem ele, o plug responde 401 e interrompe a requisição.
lib/hello_web/plugs/authentication.ex
defmodule HelloWeb.Authentication do
import Plug.Conn
def init(opts), do: opts
def call(%Plug.Conn{params: %{"user" => user}} = conn, _opts) when user != "" do
assign(conn, :current_user, user)
end
def call(conn, _opts) do
conn
|> put_resp_content_type("text/plain")
|> send_resp(401, "Faça login: acrescente ?user=seu_nome à URL.")
|> halt()
end
end
Pipelines também são plugs, então uma pipeline pode ser plugada dentro de outra. A :auth poderia incluir a :browser, e os escopos passariam a declarar só pipe_through :auth:
pipeline :auth do
plug :browser
plug :ensure_authenticated_user
plug :ensure_user_owns_review
end
Como organizar as rotas
Na documentação: How to organize my routes?
No Phoenix, é comum definir várias pipelines, cada uma com uma função: :browser e :api atendem tipos de cliente diferentes, e pipelines de autenticação e autorização exigem, por exemplo, um usuário logado ou um administrador. Com as pipelines prontas, as rotas são agrupadas pelos escopos que passam por elas.
No exemplo de avaliações, qualquer pessoa pode lê-las, mas só usuários logados podem criá-las. O guia sugere algo assim:
scope "/", HelloWeb do
pipe_through [:browser]
get "/reviews", ReviewController, :index
get "/reviews/:id", ReviewController, :show
end
scope "/", HelloWeb do
pipe_through [:browser, :auth]
get "/reviews/new", ReviewController, :new
post "/reviews", ReviewController, :create
end
A vantagem é a auditoria: basta olhar o router para saber quais rotas exigem autenticação e quais não.
Trilha.pergunta(:organizar_ordem)
Sua vez
No router, aplique essa organização às avaliações públicas, mas na ordem certa:
- crie a pipeline
:authcomplug HelloWeb.Authentication; - tire
resources "/reviews", ReviewControllerdoscope "/", HelloWeb, deixando neleget "/reviews"eget "/reviews/:id"; - crie um escopo com
pipe_through [:browser, :auth]paraget "/reviews/new"epost "/reviews", posicionado de modo que/reviews/newseja testada antes de/reviews/:id.
As rotas de /admin/reviews e /api/reviews ficam como estão.
Trilha.mostrar(:get, "/reviews/new")
Trilha.conferir(:organizar)
Trilha.solucao(:organizar)
Você pode criar quantos escopos quiser. Como as pipelines são reutilizáveis, cada escopo compõe as que precisa.
Forward
Na documentação: Forward
A macro forward/4 envia todas as requisições que começam com um caminho para um plug. O exemplo do guia é uma parte do sistema que roda tarefas em segundo plano e tem a própria interface web para mostrar o estado delas.
O plug de destino pode ser qualquer plug de módulo. A célula abaixo o escreve com Plug.Router, o roteador mínimo que vem com a biblioteca Plug, com duas sub-rotas, /pending e /active. Cada resposta mostra também path_info e script_name.
defmodule BackgroundJob.Plug do
use Plug.Router
plug :match
plug :dispatch
get "/pending" do
responder(conn, "Jobs pendentes: 3")
end
get "/active" do
responder(conn, "Jobs ativos: 1")
end
match _ do
send_resp(conn, 404, "BackgroundJob.Plug não conhece #{conn.request_path}")
end
defp responder(conn, texto) do
send_resp(conn, 200, """
#{texto}
path_info: #{inspect(conn.path_info)}
script_name: #{inspect(conn.script_name)}
""")
end
end
Trilha.pergunta(:forward_path)
Sua vez
O guia mostra o forward atrás de pipelines que exigem um administrador logado. Faça o equivalente com a pipeline :auth: no fim da célula do router, acrescente um escopo "/" que passe por :auth e encaminhe "/jobs" para BackgroundJob.Plug.
Atenção ao segundo argumento do scope: dentro de scope "/", HelloWeb do, o nome BackgroundJob.Plug viraria HelloWeb.BackgroundJob.Plug, que não existe.
Trilha.mostrar(:get, "/jobs/pending?user=ana")
Trilha.conferir(:forward)
Trilha.solucao(:forward)
Trilha.rotas("/jobs")
Na tabela de rotas, o forward aparece com o verbo *: ele aceita qualquer verbo, e quem decide o resto é o plug de destino.
O guia faz uma ressalva: não encaminhe para outro endpoint Phoenix. Os plugs do endpoint seriam invocados duas vezes, uma pelo seu e outra pelo de destino, o que pode causar erros.
Resumo
Na documentação: Summary
- Rotas que começam com o nome de um verbo HTTP viram uma cláusula da função de casamento.
- Rotas declaradas com
resourcesviram 8 cláusulas. :onlye:exceptrestringem as rotas de um recurso.- Qualquer uma dessas rotas pode ser aninhada.
- Qualquer uma dessas rotas pode ficar em um escopo com um caminho.
~pconfere os caminhos contra o router em tempo de compilação.
Para consulta rápida, a documentação tem uma folha de referência de rotas com todas as formas de declarar rotas, escopos e pipelines.
Sem roteiro: a vitrine do Brechó
Agora o mesmo assunto sem instruções passo a passo. O Brechó, o mini-marketplace da trilha, precisa de dois conjuntos de rotas para produtos:
- uma vitrine pública, só de leitura:
GET /produtoseGET /produtos/:id, atendidas porHelloWeb.ProdutoController, sem login; - uma área do vendedor em
/vendedor/produtos, com todas as actions de um recurso, atendidas porHelloWeb.Vendedor.ProdutoController, que exige login pela pipeline:auth.
Na vitrine não pode existir nenhuma rota que altere produtos: POST /produtos e DELETE /produtos/3 não devem casar.
Os dois controllers podem ser de eco: defmodule HelloWeb.ProdutoController, do: use(HelloWeb.Eco). Use a célula abaixo para eles e edite a do router.
# lib/hello_web/controllers/produto_controller.ex e vendedor/produto_controller.ex
Trilha.conferir(:brecho)
Trilha.solucao(:brecho)
No seu projeto
Repita o guia em um projeto gerado por mix phx.new hello. A correspondência entre as células e os arquivos é esta:
| Neste notebook | No projeto |
|---|---|
| Célula do router | lib/hello_web/router.ex |
Célula de HelloWeb |
lib/hello_web.ex (o verified_routes/0 já vem pronto) |
Trilha.rotas() |
mix phx.routes |
Trilha.mostrar(:get, "/users/7") |
Abrir http://localhost:4000/users/7 e ler o log do servidor |
Módulos de experimento com ~p |
iex -S mix e o mesmo defmodule |
HelloWeb.Authentication |
lib/hello_web/plugs/authentication.ex |
BackgroundJob.Plug |
Um módulo em lib/, ou uma biblioteca |
| Controllers de eco | Não há equivalente; o projeto avisa que o controller não existe |
Roteiro:
- Rode
mix phx.routesno projeto novo. Acrescenteresources "/users", UserControllerao router e rode de novo. O comando funciona mesmo sem o controller existir, porque lê só o router. - Acesse
http://localhost:4000/usersno navegador e leia a página de erro: a rota casou, masHelloWeb.UserControllernão existe. - Escreva um
~p"/naoexiste"em algum template e observe o aviso do compilador no terminal domix phx.server. - Crie o escopo
/admin, a pipeline:authe oforwarddeste notebook, e confira cada passo commix phx.routes. - Abra a documentação de
Phoenix.Router. É uma página de referência de módulo, como as que aparecem no fim do EPUB da documentação: leia a descrição descope/3e compare com o que você fez.
Revisão
Escreva em uma célula de texto, com as suas palavras: como o router escolhe a rota de uma requisição, o que resources gera, para que servem escopos e pipelines, e o que o ~p verifica. Depois compare com os pontos abaixo.
- O router casa o par verbo e caminho com um par controller e action. Cada rota vira uma cláusula de função, e as cláusulas são testadas na ordem do arquivo: a primeira que casa vence. Não existe preferência por rotas "mais específicas".
resourcesgera as 8 rotas de um recurso (7 actions, comupdateem PATCH e PUT).:onlye:exceptfiltram as actions.- Em recursos aninhados, o identificador do recurso de fora vira
:user_id, e o de dentro continua:id. scopesoma um prefixo de caminho e, opcionalmente, um prefixo de módulo às rotas do bloco.- Pipelines são sequências de plugs ligadas a escopos com
pipe_through. Elas só rodam quando uma rota do escopo casa, e rodam antes do controller. Pipelines são plugs, e uma pode incluir outra. plug :acceptsrecusa com 406 um formato que não está na lista.forwardentrega tudo o que começa com um caminho a outro plug, que vê o resto do caminho empath_info.~pverifica, em tempo de compilação, se um caminho casa com alguma rota, e interpola structs pelo protocoloPhoenix.Param.
Próximo notebook: 04_controllers.livemd, sobre o que acontece depois que o router escolhe a action.