Powered by AppSignal & Oban Pro

Trilha Phoenix · Ciclo de vida da requisição

01_ciclo_de_vida_da_requisicao.livemd

Trilha Phoenix · Ciclo de vida da requisição

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,
  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

  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.state", conn.state},
        {"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_view", conn.private[:phoenix_view]},
        {"conn.private.phoenix_template", conn.private[:phoenix_template]},
        {"content-type", Plug.Conn.get_resp_header(conn, "content-type")}
      ]
    ) <> "\n\n**`conn.resp_body`**\n\n" <> cerca(to_string(conn.resp_body), "html")
  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
    %{
      sem_rota: %{
        texto: "o router acima só tem a rota `get \"/\", PageController, :home`. O que acontece com uma requisição `GET /hello`?",
        opcoes: [
          a: "O Phoenix responde com uma página em branco e status 200.",
          b: "O Phoenix redireciona para a rota `/`.",
          c: "O router levanta uma exceção, e a resposta tem status 404.",
          d: "O servidor é encerrado e precisa ser iniciado de novo."
        ],
        correta: :c,
        explicacao:
          "Quando nenhuma rota casa, o router levanta `Phoenix.Router.NoRouteError`. Essa exceção implementa o protocolo `Plug.Exception` com status 404, e é por isso que a resposta sai como 404. A exceção fica restrita ao processo que atende aquela requisição; o servidor continua atendendo as demais."
      },
      verbo_caminho: %{
        texto: "seu JavaScript executa `fetch(\"/hello\", {method: \"POST\"})`. A rota `get \"/hello\", HelloController, :index` atende essa requisição?",
        opcoes: [
          a: "Sim, porque o caminho é o mesmo.",
          b: "Não. A rota casa com o par verbo e caminho, e o verbo aqui é POST.",
          c: "Sim, porque o Phoenix converte POST em GET quando não há corpo.",
          d: "Depende do que a action `index` fizer."
        ],
        correta: :b,
        explicacao:
          "O router mapeia pares de verbo e caminho. `get \"/hello\"` só casa com `GET /hello` (e com `HEAD /hello`, que o `Plug.Head` do endpoint converte em GET). Para atender `POST /hello` é preciso declarar `post \"/hello\", ...`."
      },
      controller_falta: %{
        texto: "a rota `get \"/hello\", HelloController, :index` já existe, mas o módulo `HelloWeb.HelloController` ainda não. O router aceita a rota mesmo assim?",
        opcoes: [
          a: "Não. O router não compila enquanto o controller não existir.",
          b: "Sim. O router guarda só o nome do módulo; o erro aparece quando uma requisição chega a essa rota.",
          c: "Sim, e o Phoenix cria um controller vazio automaticamente.",
          d: "Sim, e a requisição responde 404 como se a rota não existisse."
        ],
        correta: :b,
        explicacao:
          "A rota registra o módulo e a action como dados. O compilador pode emitir um aviso de módulo inexistente, mas o router compila. A falha acontece em tempo de execução, quando o router tenta despachar para o controller: um `UndefinedFunctionError`, que vira status 500, diferente do 404 de rota inexistente."
      },
      view_nome: %{
        texto: "a action `index` do `HelloWeb.HelloController` chama `render(conn, :index)` em uma requisição HTML. Qual função o Phoenix invoca para produzir o HTML?",
        opcoes: [
          a: "`HelloWeb.HelloController.index/1`",
          b: "`HelloWeb.HelloView.render/2`",
          c: "`HelloWeb.HelloHTML.index/1`",
          d: "`HelloWeb.Layouts.index/1`"
        ],
        correta: :c,
        explicacao:
          "Com `use Phoenix.Controller, formats: [:html, :json]`, o nome da view é derivado do nome do controller e do formato: `HelloController` + `:html` resulta em `HelloHTML`. O template `:index` é a função `index/1` desse módulo, que recebe os assigns."
      },
      camadas: %{
        texto: "você quer que uma verificação aconteça em toda requisição que chega à aplicação, qualquer que seja a rota. Em qual camada ela deve ficar?",
        opcoes: [
          a: "No endpoint.",
          b: "No router, dentro de um `scope`.",
          c: "Em cada controller.",
          d: "Na view."
        ],
        correta: :a,
        explicacao:
          "O endpoint contém o caminho inicial e comum a todas as requisições. O router já é específico: só as rotas de um `scope` passam pelas pipelines dele. Uma requisição que não casa com nenhuma rota nunca chega a uma pipeline, mas já passou pelo endpoint."
      },
      chaves: %{
        texto: "com a rota `get \"/hello/:messenger\", HelloController, :show`, chega `GET /hello/Frank?lang=pt`. Qual é o valor de `params` recebido pela action?",
        opcoes: [
          a: "`%{messenger: \"Frank\", lang: \"pt\"}`",
          b: "`%{\"messenger\" => \"Frank\"}`",
          c: "`%{\"lang\" => \"pt\", \"messenger\" => \"Frank\"}`",
          d: "`[\"Frank\", \"pt\"]`"
        ],
        correta: :c,
        explicacao:
          "`conn.params` reúne os parâmetros do caminho, da query string e do corpo da requisição. As chaves são sempre strings, porque vêm de entrada externa: convertê-las em átomos permitiria que um cliente criasse átomos sem limite, e átomos não são coletados pelo garbage collector."
      },
      clausula: %{
        texto: "a action é `def show(conn, %{\"messenger\" => messenger})`. Se ela for chamada com `params` igual a `%{}`, o que acontece?",
        opcoes: [
          a: "`messenger` recebe `nil`.",
          b: "`messenger` recebe uma string vazia.",
          c: "Nenhuma cláusula casa, e o Phoenix levanta `Phoenix.ActionClauseError`.",
          d: "A action é ignorada e o template é renderizado sem o assign."
        ],
        correta: :c,
        explicacao:
          "O sinal de igual em Elixir é uma asserção de pattern matching, não uma atribuição com valor padrão. Se o mapa não tem a chave, a cláusula não casa. O Phoenix converte o `FunctionClauseError` da action em `Phoenix.ActionClauseError`, que responde com status 400 (Bad Request)."
      }
    }
  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
    %{
      rota_hello: ~S'''
      scope "/", HelloWeb do
        pipe_through :browser

        get "/", PageController, :home
        get "/hello", HelloController, :index
      end
      ''',
      controller_hello: ~S'''
      defmodule HelloWeb.HelloController do
        use HelloWeb, :controller

        def index(conn, _params) do
          render(conn, :index)
        end
      end
      ''',
      view_hello: ~S'''
      defmodule HelloWeb.HelloHTML do
        use HelloWeb, :html

        def index(assigns) do
          ~H"""
          <section>
            <h2>Hello World, from Phoenix!</h2>
          </section>
          """
        end
      end
      ''',
      messenger: ~S'''
      # No router, dentro do scope "/":
      get "/hello/:messenger", HelloController, :show

      # No HelloController:
      def show(conn, %{"messenger" => messenger}) do
        render(conn, :show, messenger: messenger)
      end

      # No HelloHTML:
      def show(assigns) do
        ~H"""
        <Layouts.app flash={@flash}>
          <section>
            <h2>Hello World, from {@messenger}!</h2>
          </section>
        </Layouts.app>
        """
      end
      ''',
      sobre: ~S'''
      # No router, dentro do scope "/":
      get "/sobre", SobreController, :index

      defmodule HelloWeb.SobreController do
        use HelloWeb, :controller

        def index(conn, _params) do
          render(conn, :index, versao: Application.spec(:phoenix, :vsn))
        end
      end

      defmodule HelloWeb.SobreHTML do
        use HelloWeb, :html

        def index(assigns) do
          ~H"""
          <Layouts.app flash={@flash}>
            <h2>Sobre o Hello</h2>
            <p>Feito com Phoenix {@versao}.</p>
          </Layouts.app>
          """
        end
      end
      '''
    }
  end

  # --- conferência dos exercícios ----------------------------------------------

  @doc "Confere um exercício fazendo requisições de verdade à aplicação."
  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

  defp verificacoes do
    %{
      hello: [
        {"`GET /hello` responde com status 200.", fn -> status("/hello", 200) end},
        {"A página mostra `Hello World, from Phoenix!`.", fn -> contem("/hello", "Hello World, from Phoenix!") end},
        {"A resposta passa pelo root layout.", fn -> contem("/hello", "</html>") end}
      ],
      messenger: [
        {"`GET /hello/Frank` mostra `Hello World, from Frank!`.", fn -> contem("/hello/Frank", "Hello World, from Frank!") end},
        {"`GET /hello/Ana` mostra `Hello World, from Ana!`.", fn -> contem("/hello/Ana", "Hello World, from Ana!") end},
        {"`GET /hello` continua funcionando.", fn -> contem("/hello", "Hello World, from Phoenix!") end}
      ],
      sobre: [
        {"`GET /sobre` responde com status 200.", fn -> status("/sobre", 200) end},
        {"A página mostra `Sobre o Hello`.", fn -> contem("/sobre", "Sobre o Hello") end},
        {"O controller passa o assign `:versao` ao template.",
         fn ->
           case requisicao(:get, "/sobre") do
             {:ok, %{assigns: %{versao: _}}} -> :ok
             {:ok, _} -> {:erro, "O `conn` terminou sem o assign `:versao`. Passe-o no terceiro argumento do `render/3`."}
             {:erro, erro} -> {:erro, explicar(erro)}
           end
         end}
      ]
    }
  end

  defp status(caminho, esperado) do
    case requisicao(:get, caminho) do
      {:ok, %{status: ^esperado}} -> :ok
      {:ok, conn} -> {:erro, "A resposta veio com status `#{inspect(conn.status)}`."}
      {:erro, erro} -> {:erro, explicar(erro)}
    end
  end

  defp contem(caminho, texto) do
    case requisicao(:get, caminho) do
      {:ok, conn} ->
        if String.contains?(to_string(conn.resp_body), texto),
          do: :ok,
          else: {:erro, "A resposta de `GET #{caminho}` não contém esse texto. Confira o template."}

      {:erro, erro} ->
        {:erro, explicar(erro)}
    end
  end

  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
      String.ends_with?(nome, "Controller") and funcao == :init ->
        "Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."

      String.ends_with?(nome, "Controller") ->
        "Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`. O átomo no fim da rota é o nome da action."

      String.ends_with?(nome, "HTML") ->
        "Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou. Defina o módulo da view e a função do template."

      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 Request life-cycle da documentação do Phoenix 1.8, seção por seção, na mesma ordem. Cada seção traz o link para o trecho original. A diferença é que aqui o código roda: uma aplicação Phoenix de verdade é definida nas células e fica no ar em http://localhost:4010 enquanto o notebook estiver aberto.

Três convenções:

  • Uma célula equivale a um arquivo. O título acima de cada célula diz qual arquivo ela representa em um projeto gerado por mix phx.new hello. Editar a célula e avaliá-la de novo equivale a salvar o arquivo.
  • Preveja antes de rodar. As perguntas marcadas com "Antes de continuar" vêm antes da explicação. Responda primeiro.
  • Leia os erros. Em vários pontos você vai rodar uma requisição que falha de propósito. A mensagem da exceção é parte do conteúdo.

Os nomes seguem a documentação (Hello, HelloWeb), para que você possa ler o guia original lado a lado. No fim há uma seção para repetir os passos no seu projeto.

A primeira célula, lá no topo, instala as dependências e define o módulo Trilha, que é só apoio do notebook: faz requisições ao endpoint e confere os exercícios. Avalie-a antes de tudo (a primeira vez leva alguns minutos).

A aplicação Hello dentro do notebook

Um projeto gerado por mix phx.new hello tem dezenas de arquivos. As células desta seção definem o subconjunto que participa do ciclo de vida de uma requisição HTML. Ficaram de fora, porque não mudam o caminho da requisição: assets (Tailwind, esbuild), CoreComponents, Gettext, Ecto, LiveDashboard, o socket do LiveView e o recarregamento de código.

Você não precisa entender tudo agora. O guia volta a cada peça.

lib/hello_web.ex define o que use HelloWeb, :router, use HelloWeb, :controller e use HelloWeb, :html injetam em cada módulo. É uma versão reduzida do arquivo gerado.

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
    end
  end

  def html do
    quote do
      use Phoenix.Component

      alias HelloWeb.Layouts
    end
  end

  defmacro __using__(which) when is_atom(which) do
    apply(__MODULE__, which, [])
  end
end

lib/hello_web/components/layouts.ex define o root layout e o layout da aplicação. No projeto gerado, o root layout fica em um arquivo de template (layouts/root.html.heex); aqui ele é uma função, o que é equivalente.

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

lib/hello_web/controllers/error_html.ex, error_json.ex, page_controller.ex e page_html.ex: as páginas de erro (em HTML e em JSON) e a página inicial que todo projeto novo tem.

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

lib/hello_web/router.ex: é esta célula que você vai editar ao longo do guia. Ela está como em um projeto recém-gerado.

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
  end
end

lib/hello_web/endpoint.ex: a porta de entrada. A lista de plugs é a do projeto gerado, sem os que servem assets e recarregam código.

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

A célula abaixo inicia o endpoint, o equivalente a mix phx.server. Em um projeto, quem faz isso é a árvore de supervisão em lib/hello/application.ex.

{:ok, _pid} = Kino.start_child(HelloWeb.Endpoint)

Kino.Markdown.new("Aplicação no ar em <http://localhost:4010>. Abra em outra aba: você deve ver *Welcome to Phoenix!*")

Se a célula falhar com :eaddrinuse, a porta 4010 já está ocupada. Troque o número em http: [..., port: 4010] na primeira célula do notebook e reconecte o runtime.

Adicionando uma página

Na documentação: Adding a new page

Quando o navegador acessa http://localhost:4010/, ele envia uma requisição HTTP para o serviço que estiver escutando nesse endereço, no caso a nossa aplicação Phoenix. A requisição é composta de um verbo e um caminho:

Barra de endereços Verbo Caminho
http://localhost:4010/ GET /
http://localhost:4010/hello GET /hello
http://localhost:4010/hello/world GET /hello/world

Existem outros verbos. O envio de um formulário, por exemplo, normalmente usa POST. Do lado do JavaScript, é o que você define na opção method do fetch.

Aplicações web tratam requisições mapeando cada par de verbo e caminho para uma parte específica da aplicação. No Phoenix, quem faz esse mapeamento é o router. Para adicionar uma página, portanto, a primeira tarefa é adicionar uma rota.

A requisição como dado

Dentro do Phoenix, a requisição é representada por uma struct, %Plug.Conn{}, chamada por convenção de conn. A célula abaixo cria um conn do mesmo jeito que os testes fazem, sem rede, e mostra os campos que descrevem a requisição.

conn = Plug.Test.conn(:get, "/hello/world?lang=pt")

Map.take(conn, [:method, :host, :request_path, :path_info, :query_string, :params, :status, :state])

Repare em três coisas, e confira cada uma no resultado:

  • request_path é o caminho como texto e path_info é o mesmo caminho dividido em segmentos. É com path_info que o router trabalha.
  • params ainda é %Plug.Conn.Unfetched{}. Os parâmetros só são lidos quando um plug pede (no endpoint, é o Plug.Parsers).
  • status é nil e state é :unset: nenhuma resposta foi definida. O mesmo conn que descreve a requisição vai carregar a resposta.

Troque o verbo e o caminho na célula e avalie de novo. O que muda em path_info quando o caminho é /?

Uma nova rota

Na documentação: A new route

O router mapeia pares únicos de verbo e caminho para pares de controller e action. Controllers são módulos Elixir. Actions são funções definidas nesses módulos.

A rota da página inicial é esta:

get "/", PageController, :home

Ela diz: uma requisição GET para o caminho raiz é tratada pela função home/2 do módulo HelloWeb.PageController. O prefixo HelloWeb vem do segundo argumento de scope "/", HelloWeb do.

Por enquanto, ignore as pipelines e o scope. Eles são assunto do guia de Routing; aqui o foco é adicionar uma rota.

Trilha.pergunta(:sem_rota)

Agora confira com a aplicação de verdade. Trilha.mostrar/2 faz duas coisas: pergunta ao router o que ele faria com a requisição (Phoenix.Router.route_info/4) e depois despacha a requisição pelo endpoint.

Trilha.mostrar(:get, "/hello")

Abra também http://localhost:4010/hello no navegador. Como a aplicação está configurada com debug_errors: true, igual ao ambiente de desenvolvimento de um projeto, você vê a página de depuração com a mesma exceção e a lista de rotas disponíveis.

Sua vez

A página que vamos construir diz "Hello World, from Phoenix!" em /hello.

Volte à célula do router, na seção anterior, e adicione dentro do bloco scope "/", HelloWeb do uma rota que mapeie GET /hello para a action index de um HelloWeb.HelloController que ainda não existe. Avalie a célula e depois rode a célula abaixo.

Trilha.mostrar(:get, "/hello")

Se a rota estiver certa, a tabela do router agora mostra :plug igual a HelloWeb.HelloController e :plug_opts igual a :index. E o resultado mudou: a exceção já não é NoRouteError. Leia a nova mensagem antes de seguir. Ela diz qual é a próxima peça que falta.

Trilha.solucao(:rota_hello)
Trilha.pergunta(:verbo_caminho)

Um novo controller

Na documentação: A new controller

Trilha.pergunta(:controller_falta)

Controllers são módulos Elixir, e actions são funções definidas neles. O propósito de uma action é reunir os dados e executar as tarefas necessárias para a renderização. A rota que você escreveu pede um módulo HelloWeb.HelloController com uma função index/2.

Toda action recebe dois argumentos:

  1. conn, a struct que carrega os dados da requisição;
  2. params, os parâmetros da requisição.

Quando params não é usado, o nome ganha um _ na frente para evitar o aviso do compilador.

O centro da action do guia é render(conn, :index). Essa chamada diz ao Phoenix para renderizar o template index. Os módulos responsáveis por renderizar são as views. Por padrão, o nome da view é derivado do nome do controller (HelloController) e do formato (HTML): o Phoenix espera um módulo HelloWeb.HelloHTML que defina uma função index/1.

A linha use HelloWeb, :controller executa o bloco controller da célula lib/hello_web.ex. A discussão completa fica para o guia de Controllers.

Sua vez

lib/hello_web/controllers/hello_controller.ex: complete a célula com a action index/2.

defmodule HelloWeb.HelloController do
  use HelloWeb, :controller

  # Defina aqui a action index/2. Ela deve renderizar o template :index.
end
Trilha.mostrar(:get, "/hello")

A exceção mudou de novo. Agora o router achou a rota, o controller existe, a action rodou, e o render procurou a view.

Trilha.solucao(:controller_hello)

Uma nova view

Na documentação: A new view

Trilha.pergunta(:view_nome)

As views do Phoenix são a camada de apresentação. Espera-se que o resultado de renderizar index seja uma página HTML completa, e para isso usam-se templates.

Um template pode ser definido de duas formas, sem diferença de desempenho ou de comportamento em tempo de execução:

Como função no módulo da view. A função recebe assigns e usa o sigil ~H para declarar o conteúdo:

def index(assigns) do
  ~H"""
  Hello!
  """
end

Dentro do ~H escreve-se HEEx, de "HTML + EEx". EEx é a biblioteca de templates que vem com o Elixir. HEEx é uma extensão do Phoenix que entende HTML: valida a estrutura das tags em tempo de compilação, suporta componentes e escapa o conteúdo interpolado, o que protege contra injeção de HTML.

Como arquivo. No projeto, o mais comum é a view declarar

embed_templates "hello_html/*"

e cada template viver em um arquivo NOME.FORMATO.LINGUAGEM dentro da pasta hello_html, por exemplo index.html.heex. Na compilação, o Phoenix transforma cada arquivo em uma função index(assigns), igual à da primeira forma.

lib/hello_web
├── controllers
│   ├── hello_controller.ex
│   ├── hello_html.ex
│   ├── hello_html
│       ├── index.html.heex

O nome do controller (HelloController), o da view (HelloHTML) e os dos arquivos (hello_controller.ex, hello_html.ex) seguem a mesma convenção. A pasta dos templates poderia ter outro nome, desde que o embed_templates aponte para ela.

Um notebook não tem pasta de templates, então aqui usamos a primeira forma.

Sua vez

lib/hello_web/controllers/hello_html.ex: defina a função index/1 com o conteúdo do template do guia, uma <section> com um <h2> dizendo Hello World, from Phoenix!.

defmodule HelloWeb.HelloHTML do
  use HelloWeb, :html

  # Defina aqui a função index/1, usando o sigil ~H.
end
Trilha.conferir(:hello)

Quando os três itens passarem, abra http://localhost:4010/hello. Rota, controller, view e template: são as quatro peças de uma página.

Trilha.solucao(:view_hello)

Layouts

Na documentação: Layouts

O template index tem só uma tag <section>, mas a página recebida é um documento HTML completo. Veja o corpo da resposta:

Trilha.mostrar(:get, "/hello")

O template foi renderizado dentro de outro: o root layout, a função root/1 de HelloWeb.Layouts (no projeto, o arquivo lib/hello_web/components/layouts/root.html.heex). Nele há esta linha:

{@inner_content}

É ela que injeta o template da página no layout antes de o HTML ser enviado. Quem liga as duas coisas é um plug da pipeline :browser do router:

plug :put_root_layout, html: {HelloWeb.Layouts, :root}

O root layout é mínimo de propósito: contém basicamente o <head> e a estrutura comum a todas as páginas. Menus, barra lateral e similares ficam no layout da aplicação (Layouts.app), que aparece mais adiante.

Experimente

  1. Na célula de HelloWeb.Layouts, troque o texto do <title> e avalie a célula. Rode de novo a célula acima e ache a mudança em conn.resp_body.
  2. Rode a célula abaixo. O plug put_root_layout guarda a escolha em conn.private, a área do conn reservada a bibliotecas.
{:ok, conn} = Trilha.requisicao(:get, "/hello")

conn.private[:phoenix_root_layout]

Do endpoint às views

Na documentação: From endpoint to views

Com a primeira página pronta, dá para olhar o ciclo inteiro.

Toda requisição HTTP começa no endpoint da aplicação, o módulo HelloWeb.Endpoint (lib/hello_web/endpoint.ex). Assim como o router, o endpoint tem várias chamadas a plug. Plug é uma biblioteca e uma especificação para compor aplicações web, e é parte essencial de como o Phoenix trata requisições. Cada plug define uma fatia do processamento. O guia seguinte é dedicado a ele.

O esqueleto do endpoint de um projeto gerado é este:

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  plug Plug.Static, ...
  plug Plug.RequestId
  plug Plug.Telemetry, ...
  plug Plug.Parsers, ...
  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, ...
  plug HelloWeb.Router
end

O último plug é o módulo HelloWeb.Router. É assim que o endpoint delega o restante do processamento ao router. O router mapeia o par verbo e caminho a um controller, e o controller pede a uma view que renderize um template.

flowchart LR
  N[Navegador] -->|"GET /hello"| E[Endpoint]
  E -->|"plug HelloWeb.Router"| R[Router]
  R -->|"pipe_through :browser"| P[Pipeline]
  P -->|"HelloController, :index"| C[Controller]
  C -->|"render(conn, :index)"| V[View]
  V -->|"HTML em conn.resp_body"| N

Cada camada tem uma responsabilidade distinta:

  • Endpoint (Phoenix.Endpoint): contém o caminho inicial e comum a todas as requisições. Se algo deve acontecer em todas as requisições, o lugar é o endpoint.
  • Router (Phoenix.Router): despacha pares de verbo e caminho para controllers. Também permite delimitar funcionalidades por escopo; por exemplo, algumas páginas exigem autenticação e outras não.
  • Controller (Phoenix.Controller): recupera as informações da requisição, conversa com o domínio de negócio e prepara os dados para a camada de apresentação.
  • View: recebe os dados estruturados do controller e os converte em uma apresentação para o usuário. As views costumam ser nomeadas pelo formato que renderizam.
Trilha.pergunta(:camadas)

Explorador de requisições

O formulário abaixo despacha a requisição que você montar e mostra a decisão do router e o conn final. Tudo vem da aplicação que está rodando: nada é simulado.

formulario =
  Kino.Control.form(
    [
      verbo: Kino.Input.select("Verbo", get: "GET", post: "POST", put: "PUT", delete: "DELETE"),
      caminho: Kino.Input.text("Caminho", default: "/hello")
    ],
    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])

Use o explorador para responder, observando o resultado:

  1. Em GET /hello, quais chaves de conn.private registram o controller e a action escolhidos? Que valor tem conn.private.phoenix_view?
  2. Em GET /nada, a tabela do conn não aparece. Em qual camada a requisição parou, e como você sabe?
  3. Em GET /hello?nome=Ana, onde o valor "Ana" aparece? Ele influenciou a escolha da rota?
  4. conn.assigns tem a chave :flash mesmo sem o controller ter passado nada. Qual plug da pipeline :browser a colocou lá?

A célula seguinte lista as rotas que o router conhece, a mesma informação que mix phx.routes imprime no terminal de um projeto.

HelloWeb.Router
|> Phoenix.Router.routes()
|> Enum.map(&Map.take(&1, [:verb, :path, :plug, :plug_opts]))

Outra página

Na documentação: Another new page

A segunda página acrescenta um pouco de complexidade: ela reconhece um pedaço da URL, chama-o de "messenger" e o passa pelo controller até o template, para que o messenger diga hello.

Outra rota

Na documentação: Another new route

A rota reaproveita o HelloController e aponta para uma nova action, show:

get "/hello/:messenger", HelloController, :show

A sintaxe :messenger no caminho define um segmento dinâmico. O Phoenix pega o valor que estiver nessa posição da URL e o converte em um parâmetro. Em http://localhost:4010/hello/Frank, o valor de "messenger" é "Frank".

Trilha.pergunta(:chaves)

Outra action

Na documentação: Another new action

Requisições para a nova rota são tratadas pela action show do HelloWeb.HelloController. Desta vez é preciso extrair o messenger dos parâmetros para repassá-lo ao template:

def show(conn, %{"messenger" => messenger}) do
  render(conn, :show, messenger: messenger)
end

O terceiro argumento do render/3 é uma lista de pares chave e valor. Aqui :messenger é a chave e a variável messenger é o valor. Esses pares são acrescentados aos assigns do conn.

Se a action precisar do mapa completo de parâmetros além da variável messenger, a assinatura pode capturar os dois:

def show(conn, %{"messenger" => messenger} = params) do
  ...
end

Vale lembrar duas coisas: as chaves do mapa params são sempre strings, e o sinal de igual não representa atribuição, e sim uma asserção de pattern matching.

Trilha.pergunta(:clausula)

Outro template

Na documentação: Another new template

O template da action show é parecido com o index, com a diferença de que mostra o nome do messenger:

<Layouts.app flash={@flash}>
  <section>
    <h2>Hello World, from {@messenger}!</h2>
  </section>
</Layouts.app>

Ele usa três recursos do HEEx:

  • Assigns, como @messenger e @flash. Os valores que o controller passa à view são coletivamente chamados de assigns. Dá para acessá-los como assigns.messenger, mas o HEEx oferece a sintaxe @, mais limpa.
  • Interpolação de conteúdo, como {@messenger}. Qualquer código Elixir entre chaves é executado, e o resultado substitui a expressão no HTML gerado.
  • Tags de componente de função, como <Layouts.app>. Templates são chamados de componentes de função justamente porque podem ser compostos. Existe uma função HelloWeb.Layouts.app/1, que define o layout da aplicação, e o template a invoca passando o próprio conteúdo.

Os três recursos se combinam: o assign @flash é passado como valor Elixir ao componente <Layouts.app>. Mensagens flash são mensagens temporárias, como avisos de sucesso ou de erro; elas voltam no guia de Components e HEEx.

Sua vez

Faça as três mudanças e avalie cada célula alterada:

  1. Na célula do router, adicione a rota get "/hello/:messenger", HelloController, :show abaixo da rota de /hello.
  2. Na célula do HelloController, adicione a action show/2.
  3. Na célula do HelloHTML, adicione a função show/1 com o template acima.
Trilha.conferir(:messenger)

Depois explore a requisição completa. Confira em conn.path_params, conn.params e conn.assigns por onde o valor "Frank" passou até chegar ao HTML.

Trilha.mostrar(:get, "/hello/Frank?lang=pt")

Abra http://localhost:4010/hello/Frank e troque o nome na URL. O que vier depois de /hello/ aparece na página.

Trilha.solucao(:messenger)

Experimente: o escape do HEEx

Um valor que vem da URL é entrada externa. Rode a célula e procure, em conn.resp_body, como o HEEx escreveu os sinais de menor e maior.

Trilha.mostrar(:get, "/hello/%3Cb%3EFrank%3C%2Fb%3E")

O segmento decodificado é <b>Frank</b>. A interpolação {@messenger} escapa o valor, e o navegador mostra o texto em vez de interpretar a tag. É essa a proteção contra injeção de HTML mencionada na seção das views.

Sem roteiro: a página Sobre

Agora o mesmo caminho sem instruções passo a passo. Crie uma página em /sobre que:

  • responda a GET /sobre com status 200;
  • mostre o título Sobre o Hello dentro do layout da aplicação;
  • receba do controller um assign :versao e o mostre no template. Um valor possível é Application.spec(:phoenix, :vsn), a versão do Phoenix em uso.

Use a célula abaixo para o controller e a view (dois módulos podem ficar na mesma célula) e edite a célula do router.

# lib/hello_web/controllers/sobre_controller.ex e sobre_html.ex
Trilha.conferir(:sobre)
Trilha.solucao(:sobre)

No seu projeto

Repita o guia em um projeto de verdade. A correspondência entre as células e os arquivos é esta:

Neste notebook No projeto gerado por mix phx.new hello
Célula do router lib/hello_web/router.ex
Célula do HelloController lib/hello_web/controllers/hello_controller.ex
Célula do HelloHTML lib/hello_web/controllers/hello_html.ex
Função index/1 com ~H lib/hello_web/controllers/hello_html/index.html.heex
Função root/1 de Layouts lib/hello_web/components/layouts/root.html.heex
Célula que inicia o endpoint mix phx.server
Trilha.mostrar(:get, "/hello") Abrir http://localhost:4000/hello e ler o log do servidor
Lista de Phoenix.Router.routes/1 mix phx.routes

Roteiro:

  1. Adicione as rotas /hello e /hello/:messenger, o HelloController e a view HelloHTML, desta vez com embed_templates "hello_html/*" e os templates em arquivos .html.heex.
  2. Antes de criar cada peça, acesse a URL no navegador e leia a página de erro. A sequência de erros deve ser a mesma que você viu aqui.
  3. Observe o terminal do mix phx.server a cada requisição. O log mostra o verbo, o caminho, o controller, a action, os parâmetros e as pipelines.
  4. Rode mix phx.routes antes e depois de adicionar as rotas.

O projeto gerado tem mais do que o notebook mostrou: o endpoint inclui Plug.Static e o bloco de recarregamento de código, e os templates têm acesso aos CoreComponents. Nada disso muda a ordem das camadas.

Revisão

Escreva em uma célula de texto, com as suas palavras, o que acontece entre o Enter na barra de endereços e a página aparecer, nomeando os módulos envolvidos em GET /hello/Frank. Depois compare com os pontos abaixo.

  • Uma requisição é um verbo e um caminho. O router mapeia pares de verbo e caminho para pares de controller e action.
  • Toda requisição entra pelo endpoint, que é uma sequência de plugs. O último deles é o router.
  • O router casa a rota, faz o conn passar pelas pipelines do escopo e despacha para o controller.
  • A action recebe conn e params. As chaves de params são strings. O pattern matching na assinatura extrai os valores.
  • render/3 acrescenta assigns ao conn e invoca a view, cujo nome deriva do controller e do formato.
  • O template é uma função que recebe os assigns. Ele é renderizado dentro do root layout por meio de {@inner_content}.
  • O mesmo conn atravessa todas as camadas e termina carregando a resposta.

Próximo notebook: 02_plug.livemd, sobre a peça com que endpoint, router e controller são construídos.