Powered by AppSignal & Oban Pro

Trilha Phoenix · Rotas

03_rotas.livemd

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 que mix phx.routes imprime 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:

  1. um recurso "/posts", atendido por PostController, só de leitura: apenas index e show;
  2. um recurso "/comments", atendido por CommentController, com todas as actions menos delete.
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 string admin=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:

  1. acrescente resources "/reviews", ReviewController ao scope "/", HelloWeb;
  2. acrescente o escopo /admin acima, como um bloco novo depois do scope "/", 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_session busca os dados da sessão e os torna disponíveis no conn;
  • :fetch_live_flash busca as mensagens flash do LiveView e as junta às do controller;
  • :put_root_layout guarda o root layout usado na renderização;
  • :protect_from_forgery protege os envios de formulário contra falsificação de requisição entre sites (CSRF);
  • :put_secure_browser_headers acrescenta 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:

  1. crie a pipeline :auth com plug HelloWeb.Authentication;
  2. tire resources "/reviews", ReviewController do scope "/", HelloWeb, deixando nele get "/reviews" e get "/reviews/:id";
  3. crie um escopo com pipe_through [:browser, :auth] para get "/reviews/new" e post "/reviews", posicionado de modo que /reviews/new seja 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 resources viram 8 cláusulas.
  • :only e :except restringem as rotas de um recurso.
  • Qualquer uma dessas rotas pode ser aninhada.
  • Qualquer uma dessas rotas pode ficar em um escopo com um caminho.
  • ~p confere 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 /produtos e GET /produtos/:id, atendidas por HelloWeb.ProdutoController, sem login;
  • uma área do vendedor em /vendedor/produtos, com todas as actions de um recurso, atendidas por HelloWeb.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:

  1. Rode mix phx.routes no projeto novo. Acrescente resources "/users", UserController ao router e rode de novo. O comando funciona mesmo sem o controller existir, porque lê só o router.
  2. Acesse http://localhost:4000/users no navegador e leia a página de erro: a rota casou, mas HelloWeb.UserController não existe.
  3. Escreva um ~p"/naoexiste" em algum template e observe o aviso do compilador no terminal do mix phx.server.
  4. Crie o escopo /admin, a pipeline :auth e o forward deste notebook, e confira cada passo com mix phx.routes.
  5. 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 de scope/3 e 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".
  • resources gera as 8 rotas de um recurso (7 actions, com update em PATCH e PUT). :only e :except filtram as actions.
  • Em recursos aninhados, o identificador do recurso de fora vira :user_id, e o de dentro continua :id.
  • scope soma 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 :accepts recusa com 406 um formato que não está na lista.
  • forward entrega tudo o que começa com um caminho a outro plug, que vê o resto do caminho em path_info.
  • ~p verifica, em tempo de compilação, se um caminho casa com alguma rota, e interpola structs pelo protocolo Phoenix.Param.

Próximo notebook: 04_controllers.livemd, sobre o que acontece depois que o router escolhe a action.