Powered by AppSignal & Oban Pro

Trilha Phoenix · Controllers

04_controllers.livemd

Trilha Phoenix · Controllers

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,
  segue redirects como um navegador, 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
    despachar(Phoenix.ConnTest.build_conn(), metodo, caminho)
  end

  defp despachar(conn, metodo, caminho) do
    descartar_respostas_antigas()
    conn = Phoenix.ConnTest.dispatch(conn, @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 a resposta saiu."
  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
    "**Router** — a rota `#{info.route}` casou e aponta para `#{inspect(info.plug)}`, action `#{inspect(info.plug_opts)}`."
  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(
      "**A resposta**",
      [
        {"conn.status", conn.status},
        {"content-type", Plug.Conn.get_resp_header(conn, "content-type")},
        {"location", Plug.Conn.get_resp_header(conn, "location")},
        {"conn.private.phoenix_action", conn.private[:phoenix_action]},
        {"conn.private.phoenix_format", conn.private[:phoenix_format]},
        {"conn.private.phoenix_view", conn.private[:phoenix_view]},
        {"conn.private.phoenix_template", conn.private[:phoenix_template]},
        {"conn.params", conn.params},
        {"conn.assigns", conn.assigns}
      ]
    ) <> "\n\n" <> md_corpo(conn)
  end

  defp md_corpo(conn) do
    case to_string(conn.resp_body) do
      "" -> "**`conn.resp_body`**: vazio (`\"\"`)."
      corpo -> "**`conn.resp_body`**\n\n" <> cerca(corpo, linguagem(conn))
    end
  end

  defp linguagem(conn) do
    tipo = conn |> Plug.Conn.get_resp_header("content-type") |> List.first("")

    cond do
      String.contains?(tipo, "json") -> "json"
      String.contains?(tipo, "xml") -> "xml"
      String.contains?(tipo, "html") -> "html"
      true -> ""
    end
  end

  # --- navegação com redirects -----------------------------------------------

  @doc """
  Faz GET em `caminho` e segue os redirects como um navegador, levando o
  cookie de sessão de uma requisição para a seguinte. Com `recarregar: true`,
  repete a última requisição no fim, como quem aperta F5.
  """
  def navegar(caminho, opcoes \\ []) do
    passos = passos(caminho, opcoes)

    linhas =
      passos
      |> Enum.with_index(1)
      |> Enum.map(fn {{c, resultado}, i} -> linha(i, c, resultado) end)

    cabecalho = [
      "### GET #{caminho}, seguindo os redirects",
      "",
      "| # | Requisição | Status | `location` | `conn.assigns.flash` | Flash guardada na sessão |",
      "| --- | --- | --- | --- | --- | --- |"
    ]

    Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> md_fim(List.last(passos)))
  end

  defp passos(caminho, opcoes) do
    passos = seguir(Phoenix.ConnTest.build_conn(), caminho, [])

    case {opcoes[:recarregar], List.last(passos)} do
      {true, {ultimo, {:ok, conn}}} -> passos ++ [{ultimo, despachar(Phoenix.ConnTest.recycle(conn), :get, ultimo)}]
      _ -> passos
    end
  end

  defp seguir(conn, caminho, feitos) when length(feitos) < 5 do
    resultado = despachar(conn, :get, caminho)
    feitos = feitos ++ [{caminho, resultado}]

    with {:ok, resp} <- resultado,
         true <- resp.status in 300..399,
         [destino] <- Plug.Conn.get_resp_header(resp, "location"),
         true <- String.starts_with?(destino, "/") do
      seguir(Phoenix.ConnTest.recycle(resp), destino, feitos)
    else
      _ -> feitos
    end
  end

  defp seguir(_conn, _caminho, feitos), do: feitos

  # O que a resposta deixou na sessão (que viaja no cookie) sob a chave da flash.
  defp flash_na_sessao(conn) do
    case conn.private[:plug_session] do
      %{} = sessao -> Map.get(sessao, "phoenix_flash")
      _ -> nil
    end
  end

  defp linha(i, caminho, {:ok, conn}) do
    local = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
    "| #{i} | `GET #{caminho}` | `#{conn.status}` | `#{local}` | `#{escapar(inspect(conn.assigns[:flash]))}` | `#{escapar(inspect(flash_na_sessao(conn)))}` |"
  end

  defp linha(i, caminho, {:erro, erro}) do
    "| #{i} | `GET #{caminho}` | exceção: `#{escapar(Exception.format_banner(:error, erro))}` | | | |"
  end

  defp md_fim({_caminho, {:ok, conn}}) do
    externo =
      case Plug.Conn.get_resp_header(conn, "location") do
        [destino] when conn.status in 300..399 ->
          if String.starts_with?(destino, "/"),
            do: "",
            else: "O destino `#{destino}` fica fora da aplicação. Um navegador sairia dela aqui.\n\n"

        _ ->
          ""
      end

    externo <> "Corpo da última resposta:\n\n" <> cerca(to_string(conn.resp_body), linguagem(conn))
  end

  defp md_fim(_), do: ""

  # --- formatação ------------------------------------------------------------

  defp tabela(titulo, pares) do
    linhas =
      Enum.map(pares, fn {campo, valor} ->
        "| `#{campo}` | `#{valor |> inspect(limit: 30, printable_limit: 300) |> escapar()}` |"
      end)

    Enum.join([titulo, "", "| Campo | Valor |", "| --- | --- |" | linhas], "\n")
  end

  defp escapar(texto), do: texto |> String.replace("|", "\\|") |> String.replace("`", "'")

  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
    %{
      nome_action: %{
        texto: "você troca a rota para `get \"/\", PageController, :index`, mas o `PageController` continua definindo só `home/2`. O que acontece com `GET /`?",
        opcoes: [
          a: "O Phoenix procura uma action parecida e chama `home/2`.",
          b: "O router recusa a requisição com 404, como se a rota não existisse.",
          c: "A rota casa, e a chamada a `index/2` falha com `UndefinedFunctionError`, que vira status 500.",
          d: "O router não compila enquanto a action não existir."
        ],
        correta: :c,
        explicacao:
          "O nome da action é só um átomo guardado na rota. O router casa `GET /` normalmente e despacha para `HelloWeb.PageController.index/2`. Como a função não existe, a chamada levanta `UndefinedFunctionError`, que o Phoenix responde com 500. A única exigência sobre o nome de uma action é que ele seja o mesmo na rota e no controller."
      },
      tipo_texto: %{
        texto: "a action `show` passa a responder com `text(conn, \"From messenger \#{messenger}\")`. Qual será o cabeçalho `content-type` da resposta?",
        opcoes: [
          a: "`text/html; charset=utf-8`, porque a rota está na pipeline `:browser`.",
          b: "`text/plain; charset=utf-8`.",
          c: "Nenhum: `text/2` só define o corpo.",
          d: "`application/octet-stream`."
        ],
        correta: :b,
        explicacao:
          "Cada função de resposta define o próprio content-type: `text/2` usa `text/plain`, `json/2` usa `application/json` e `html/2` usa `text/html`, todos com `charset=utf-8`. A pipeline `:browser` decide quais formatos a requisição pode pedir, não o tipo da resposta. Sem template e sem view, também não há root layout: o corpo é exatamente a string passada."
      },
      formato_recusado: %{
        texto: "a pipeline `:browser` tem `plug :accepts, [\"html\"]`. Chega `GET /?_format=json`. O que acontece?",
        opcoes: [
          a: "O Phoenix ignora o parâmetro e responde HTML.",
          b: "O plug `:accepts` levanta `Phoenix.NotAcceptableError`, e a resposta tem status 406.",
          c: "A action roda e `render/2` falha porque não existe `PageJSON`.",
          d: "O router responde 404."
        ],
        correta: :b,
        explicacao:
          "O parâmetro `_format` é lido pelo plug `:accepts`, ainda na pipeline. Como `json` não está na lista de formatos aceitos, ele levanta `Phoenix.NotAcceptableError` antes de a requisição chegar ao controller. A exceção tem status 406 (Not Acceptable)."
      },
      view_json: %{
        texto: "com `json` aceito na pipeline, chega `GET /?_format=json`. A action faz `render(conn, :home)`. Qual função produz o corpo da resposta?",
        opcoes: [
          a: "`HelloWeb.PageHTML.home/1`, e o Phoenix converte o HTML em JSON.",
          b: "`HelloWeb.PageJSON.home/1`, que devolve um mapa; o Phoenix o codifica com o Jason.",
          c: "`HelloWeb.PageController.home/2`, de novo, agora com `_format`.",
          d: "`Jason.encode!/1` aplicado ao `conn.assigns`."
        ],
        correta: :b,
        explicacao:
          "`render/2` escolhe a view pelo formato da requisição. O controller tem `formats: [:html, :json]`, e então o nome da view deriva do controller e do formato: `PageController` mais `:json` dá `PageJSON`. O template `:home` é a função `home/1` dessa view. Ela recebe os assigns e devolve dados, e o Phoenix os codifica como JSON."
      },
      sem_tipo: %{
        texto: "uma action termina com `send_resp(conn, 201, \"\")`, sem mais nada. Qual será o `content-type` da resposta?",
        opcoes: [
          a: "`text/html`, o padrão do Phoenix.",
          b: "`application/json`, porque o status 201 é típico de APIs.",
          c: "Nenhum. `send_resp/3` envia só o status, os cabeçalhos já definidos e o corpo.",
          d: "`text/plain`, porque o corpo é uma string."
        ],
        correta: :c,
        explicacao:
          "`send_resp/3` vem do Plug e não adivinha nada: envia o status, os cabeçalhos que já estiverem no conn e o corpo. Sem content-type, cada navegador decide como tratar a resposta (o guia lembra que o Safari a baixa como arquivo). Por isso o guia combina `send_resp/3` com `put_resp_content_type/2`."
      },
      redirect_to: %{
        texto: "o que acontece quando uma action chama `redirect(conn, to: \"https://elixir-lang.org/\")`?",
        opcoes: [
          a: "O navegador é redirecionado para o site do Elixir.",
          b: "O Phoenix levanta `ArgumentError`: `:to` só aceita caminhos dentro da aplicação.",
          c: "O Phoenix redireciona para `/https://elixir-lang.org/`.",
          d: "O redirect é ignorado e a action devolve status 200."
        ],
        correta: :b,
        explicacao:
          "Por segurança, `:to` só aceita caminhos da própria aplicação. Isso impede que um valor vindo do usuário transforme o redirect em uma porta para outro site (o chamado open redirect). Para sair da aplicação existe a opção `:external`, que deixa a intenção explícita no código."
      },
      flash_redirect: %{
        texto: "uma action faz `put_flash(:error, \"...\")` e depois `redirect/2`. A mensagem aparece na página seguinte, que é outra requisição. Onde ela ficou guardada nesse meio-tempo?",
        opcoes: [
          a: "Em uma variável global do controller.",
          b: "No `location` do redirect, como parâmetro da URL.",
          c: "Na sessão, que viaja no cookie: a requisição seguinte lê a mensagem e a apaga.",
          d: "No banco de dados."
        ],
        correta: :c,
        explicacao:
          "Cada requisição é atendida do zero, então o que sobrevive de uma para outra precisa viajar no cookie de sessão. Quando a resposta é um redirect, o Phoenix grava a flash na sessão. A requisição seguinte a lê para `conn.assigns.flash` e a retira da sessão. Por isso a mensagem aparece uma vez e some ao recarregar."
      }
    }
  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
    %{
      index: ~S'''
      # No router, dentro do scope "/":
      get "/", PageController, :index

      # No PageController:
      def index(conn, _params) do
        render(conn, :home)
      end
      ''',
      assigns: ~S'''
      # No HelloController:
      def show(conn, %{"messenger" => messenger}) do
        conn
        |> assign(:messenger, messenger)
        |> assign(:receiver, "Dweezil")
        |> render(:show)
      end

      # No HelloHTML:
      def show(assigns) do
        ~H"""
        <Layouts.app flash={@flash}>
          <section>
            <h2>Hello World, from {@messenger}!</h2>
            <p>To: {@receiver}</p>
          </section>
        </Layouts.app>
        """
      end
      ''',
      formato_json: ~S'''
      # No router, na pipeline :browser:
      plug :accepts, ["html", "json"]

      # lib/hello_web/controllers/page_json.ex
      defmodule HelloWeb.PageJSON do
        def home(_assigns) do
          %{message: "this is some JSON"}
        end
      end
      ''',
      criado: ~S'''
      def criado(conn, _params) do
        conn
        |> put_resp_content_type("text/plain")
        |> send_resp(201, "")
      end
      ''',
      xml: ~S'''
      defmodule HelloWeb.PageXML do
        require EEx

        EEx.function_from_string(
          :def,
          :home,
          """
          <?xml version="1.0" encoding="UTF-8"?>
          <%= @content %>
          """,
          [:assigns]
        )
      end
      ''',
      aceito: ~S'''
      def aceito(conn, _params) do
        conn
        |> put_status(:accepted)
        |> render(:home)
      end
      ''',
      redirect: ~S'''
      # No router, dentro do scope "/":
      get "/redirect_test", PageController, :redirect_test

      # No PageController:
      def redirect_test(conn, _params) do
        render(conn, :home)
      end

      # No LabController:
      def redirecionar(conn, _params) do
        redirect(conn, to: ~p"/redirect_test")
      end
      ''',
      flash: ~S'''
      def aviso(conn, _params) do
        conn
        |> put_flash(:error, "Let's pretend we have an error.")
        |> redirect(to: ~p"/redirect_test")
      end
      ''',
      produtos: ~S'''
      # No router, dentro do scope "/":
      get "/produtos", ProdutoController, :index
      get "/produtos/:id", ProdutoController, :show

      defmodule HelloWeb.ProdutoController do
        use HelloWeb, :controller

        def index(conn, _params) do
          render(conn, :index, produtos: Hello.Catalogo.list_produtos())
        end

        def show(conn, %{"id" => id}) do
          case Hello.Catalogo.get_produto(id) do
            nil ->
              conn
              |> put_flash(:error, "Produto não encontrado.")
              |> redirect(to: ~p"/produtos")

            produto ->
              render(conn, :show, produto: produto)
          end
        end
      end

      defmodule HelloWeb.ProdutoHTML do
        use HelloWeb, :html

        def index(assigns) do
          ~H"""
          <Layouts.app flash={@flash}>
            <h2>Produtos</h2>
            <ul>
              <li :for={produto <- @produtos}>{produto.nome}</li>
            </ul>
          </Layouts.app>
          """
        end

        def show(assigns) do
          ~H"""
          <Layouts.app flash={@flash}>
            <h2>{@produto.nome}</h2>
            <p>R$ {@produto.preco}</p>
          </Layouts.app>
          """
        end
      end

      defmodule HelloWeb.ProdutoJSON do
        def show(%{produto: produto}) do
          %{id: produto.id, nome: produto.nome, preco: produto.preco}
        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
    %{
      index: [
        {"`GET /` responde com status 200.", fn -> status("/", 200) end},
        {"A rota de `/` aponta para a action `index`.", fn -> action("/", :index) end},
        {"A página continua mostrando `Welcome to Phoenix!`.", fn -> contem("/", "Welcome to Phoenix!") end}
      ],
      assigns: [
        {"`GET /hello/Frank` responde HTML.", fn -> tipo("/hello/Frank", "text/html") end},
        {"`conn.assigns.messenger` termina como `\"Frank\"`.", fn -> assign_igual("/hello/Frank", :messenger, "Frank") end},
        {"`conn.assigns.receiver` termina como `\"Dweezil\"`.", fn -> assign_igual("/hello/Frank", :receiver, "Dweezil") end},
        {"A página mostra `Hello World, from Frank!`.", fn -> contem("/hello/Frank", "Hello World, from Frank!") end},
        {"A página mostra `To: Dweezil`.", fn -> contem("/hello/Frank", "To: Dweezil") end}
      ],
      formato_json: [
        {"`GET /?_format=json` responde com status 200.", fn -> status("/?_format=json", 200) end},
        {"A resposta tem content-type `application/json`.", fn -> tipo("/?_format=json", "application/json") end},
        {"O corpo é `{\"message\": \"this is some JSON\"}`.",
         fn -> json_igual("/?_format=json", %{"message" => "this is some JSON"}) end},
        {"`GET /`, sem `_format`, continua respondendo HTML.", fn -> tipo("/", "text/html") end}
      ],
      criado: [
        {"`GET /lab/criado` responde com status 201.", fn -> status("/lab/criado", 201) end},
        {"A resposta tem content-type `text/plain`.", fn -> tipo("/lab/criado", "text/plain") end},
        {"O corpo é vazio.", fn -> corpo_igual("/lab/criado", "") end}
      ],
      xml: [
        {"`GET /lab/xml` responde com status 200.", fn -> status("/lab/xml", 200) end},
        {"A resposta tem content-type `text/xml`.", fn -> tipo("/lab/xml", "text/xml") end},
        {"O corpo começa pela declaração `<?xml`.",
         fn ->
           case requisicao(:get, "/lab/xml") do
             {:ok, conn} ->
               if conn.resp_body |> to_string() |> String.trim_leading() |> String.starts_with?("<?xml"),
                 do: :ok,
                 else: {:erro, "O corpo começa com outra coisa. A primeira linha do template deve ser `<?xml version=\"1.0\" encoding=\"UTF-8\"?>`."}

             {:erro, erro} ->
               {:erro, explicar(erro)}
           end
         end},
        {"O corpo contém o assign `@content`.", fn -> contem("/lab/xml", "<message>this is some XML</message>") end}
      ],
      aceito: [
        {"`GET /lab/aceito` responde com status 202.", fn -> status("/lab/aceito", 202) end},
        {"A página mostra `Welcome to Phoenix!`.", fn -> contem("/lab/aceito", "Welcome to Phoenix!") end}
      ],
      redirect: [
        {"`GET /redirect_test` responde 200 com a página inicial.", fn -> contem("/redirect_test", "Welcome to Phoenix!") end},
        {"`GET /lab/redirecionar` responde com status 302.", fn -> status("/lab/redirecionar", 302) end},
        {"O cabeçalho `location` aponta para `/redirect_test`.", fn -> destino("/lab/redirecionar", "/redirect_test") end}
      ],
      flash: [
        {"`GET /lab/aviso` redireciona para `/redirect_test`.", fn -> destino("/lab/aviso", "/redirect_test") end},
        {"Seguindo o redirect, a página mostra a mensagem.",
         fn -> passo_contem("/lab/aviso", 2, "pretend we have an error", true) end},
        {"Recarregando a página, a mensagem some.",
         fn -> passo_contem("/lab/aviso", 3, "pretend we have an error", false) end}
      ],
      produtos: [
        {"`GET /produtos` lista os três produtos.",
         fn ->
           Enum.find_value(["Jaqueta jeans", "Vitrola portátil", "Coleção de gibis"], :ok, fn nome ->
             case contem("/produtos", nome) do
               :ok -> nil
               erro -> erro
             end
           end)
         end},
        {"`GET /produtos/2` mostra `Vitrola portátil` em HTML.",
         fn ->
           with :ok <- tipo("/produtos/2", "text/html"), do: contem("/produtos/2", "Vitrola portátil")
         end},
        {"`GET /produtos/2?_format=json` devolve `{\"id\": 2, \"nome\": \"Vitrola portátil\", \"preco\": 250}`.",
         fn -> json_igual("/produtos/2?_format=json", %{"id" => 2, "nome" => "Vitrola portátil", "preco" => 250}) end},
        {"`GET /produtos/99` redireciona para `/produtos`.", fn -> destino("/produtos/99", "/produtos") end},
        {"Seguindo o redirect, a página mostra `Produto não encontrado.`.",
         fn -> passo_contem("/produtos/99", 2, "Produto não encontrado.", true) end}
      ]
    }
  end

  defp com_resposta(caminho, fun) do
    case requisicao(:get, caminho) do
      {:ok, conn} -> fun.(conn)
      {:erro, erro} -> {:erro, explicar(erro)}
    end
  end

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

  defp action(caminho, esperada) do
    com_resposta(caminho, fn conn ->
      case conn.private[:phoenix_action] do
        ^esperada -> :ok
        outra -> {:erro, "A requisição foi atendida pela action `#{inspect(outra)}`. Confira o átomo no fim da rota."}
      end
    end)
  end

  defp tipo(caminho, prefixo) do
    com_resposta(caminho, fn conn ->
      case Plug.Conn.get_resp_header(conn, "content-type") do
        [valor] ->
          if String.starts_with?(valor, prefixo),
            do: :ok,
            else: {:erro, "O content-type de `GET #{caminho}` é `#{valor}`."}

        [] ->
          {:erro, "A resposta de `GET #{caminho}` não tem content-type."}
      end
    end)
  end

  defp contem(caminho, texto) do
    com_resposta(caminho, fn conn ->
      if String.contains?(to_string(conn.resp_body), texto),
        do: :ok,
        else: {:erro, "A resposta de `GET #{caminho}` (status `#{inspect(conn.status)}`) não contém `#{texto}`."}
    end)
  end

  defp corpo_igual(caminho, esperado) do
    com_resposta(caminho, fn conn ->
      if to_string(conn.resp_body) == esperado,
        do: :ok,
        else: {:erro, "O corpo de `GET #{caminho}` é `#{inspect(conn.resp_body)}`."}
    end)
  end

  defp json_igual(caminho, esperado) do
    com_resposta(caminho, fn conn ->
      case Jason.decode(to_string(conn.resp_body)) do
        {:ok, ^esperado} -> :ok
        {:ok, outro} -> {:erro, "O JSON decodificado é `#{inspect(outro)}`."}
        {:error, _} -> {:erro, "O corpo de `GET #{caminho}` não é JSON (status `#{inspect(conn.status)}`)."}
      end
    end)
  end

  defp assign_igual(caminho, chave, esperado) do
    com_resposta(caminho, fn conn ->
      case Map.fetch(conn.assigns, chave) do
        {:ok, ^esperado} -> :ok
        {:ok, outro} -> {:erro, "`conn.assigns.#{chave}` terminou como `#{inspect(outro)}`."}
        :error -> {:erro, "O conn terminou sem o assign `:#{chave}`."}
      end
    end)
  end

  defp destino(caminho, esperado) do
    com_resposta(caminho, fn conn ->
      cond do
        conn.status not in 300..399 ->
          {:erro, "A resposta veio com status `#{inspect(conn.status)}`, que não é um redirect."}

        Plug.Conn.get_resp_header(conn, "location") != [esperado] ->
          {:erro, "O `location` é `#{inspect(Plug.Conn.get_resp_header(conn, "location"))}`; o esperado é `#{esperado}`."}

        true ->
          :ok
      end
    end)
  end

  defp passo_contem(caminho, n, texto, deve_conter) do
    case Enum.at(passos(caminho, recarregar: true), n - 1) do
      {c, {:ok, conn}} ->
        case {String.contains?(to_string(conn.resp_body), texto), deve_conter} do
          {igual, igual} -> :ok
          {false, true} -> {:erro, "Na requisição #{n} (`GET #{c}`) a página não mostra `#{texto}`. O layout recebe `@flash`?"}
          {true, false} -> {:erro, "Na requisição #{n} (`GET #{c}`) a mensagem ainda aparece."}
        end

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

      nil ->
        {:erro, "A navegação parou antes da requisição #{n}. Confira com `Trilha.navegar(\"#{caminho}\")`."}
    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(%Phoenix.NotAcceptableError{} = erro) do
    "Etapa: **pipeline**. `#{Exception.message(erro)}`. O plug `:accepts` recusou o formato pedido."
  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", "JSON", "XML"]) ->
        "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 Controllers da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. A aplicação Hello parte do ponto em que o notebook do ciclo de vida da requisição terminou: as páginas /, /hello e /hello/:messenger. Os experimentos do notebook de Plug (locale, mensagens) ficaram de fora, porque o guia de Controllers não os usa.

As convenções são as mesmas dos notebooks anteriores:

  • 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.
  • Preveja antes de rodar. As perguntas marcadas com "Antes de continuar" vêm antes da explicação.
  • Leia os erros. Algumas requisições falham de propósito.

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.

Duas ferramentas novas aparecem aqui. Trilha.mostrar/2 agora destaca o status e os cabeçalhos content-type e location da resposta, que são o assunto do guia. Trilha.navegar/2 segue redirects como um navegador, levando o cookie de sessão de uma requisição para a outra.

Avalie a primeira célula, no topo, antes de tudo.

A aplicação Hello dentro do notebook

As células desta seção definem a aplicação. Avalie todas, na ordem.

A ordem importa mais do que nos notebooks anteriores. A partir daqui, controllers e templates usam o sigil ~p das rotas verificadas (assunto do guia de Routing), e o ~p consulta o router quando o módulo é compilado e o endpoint quando é executado. Por isso o router e o endpoint vêm antes dos controllers. Na primeira avaliação, a célula do router avisa que os controllers ainda não existem; eles são definidos logo abaixo, e o aviso pode ser ignorado.

lib/hello_web.ex ganhou o bloco verified_routes, incluído em controller e html, como no projeto gerado.

defmodule HelloWeb do
  def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)

  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: HelloWeb.static_paths()
    end
  end

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

lib/hello_web/router.ex: além das rotas do guia anterior, há um escopo /lab. O guia altera várias vezes a action home para experimentar respostas diferentes; aqui esses experimentos acontecem em um controller de laboratório, para que as páginas dos exercícios continuem funcionando.

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

  # Laboratório deste notebook: usado a partir da seção "Enviando respostas diretamente".
  scope "/lab", HelloWeb do
    pipe_through :browser

    get "/criado", LabController, :criado
    get "/xml", LabController, :xml
    get "/aceito", LabController, :aceito
    get "/redirecionar", LabController, :redirecionar
    get "/externo", LabController, :externo
    get "/aviso", LabController, :aviso
  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: o layout da aplicação agora mostra as mensagens flash, assunto do fim do guia. No projeto gerado, quem faz isso é o componente <.flash_group flash={@flash} />, dos CoreComponents; aqui, dois parágrafos fazem o mesmo papel.

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>
    <p :if={Phoenix.Flash.get(@flash, :info)} role="alert" class="flash-info">
      {Phoenix.Flash.get(@flash, :info)}
    </p>
    <p :if={Phoenix.Flash.get(@flash, :error)} role="alert" class="flash-error">
      {Phoenix.Flash.get(@flash, :error)}
    </p>
    <main>
      {render_slot(@inner_block)}
    </main>
    """
  end
end

lib/hello_web/controllers/error_html.ex e error_json.ex: as views de erro, uma por formato. O notebook 05 é dedicado a elas.

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

lib/hello_web/controllers/page_controller.ex e page_html.ex: você vai editar esta célula.

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/controllers/hello_controller.ex e hello_html.ex: também vai ser editada.

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

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>. Abra em outra aba: você deve ver *Welcome to Phoenix!*")

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

O que é um controller

Na documentação: Controllers

Controllers são módulos intermediários. As funções deles, chamadas actions, são invocadas pelo router em resposta a requisições HTTP. As actions reúnem os dados e executam os passos necessários antes de chamar a camada de apresentação, seja para renderizar um template, seja para devolver JSON.

Controllers também são construídos sobre o Plug e são, eles mesmos, plugs. Eles oferecem funções para quase tudo o que uma action precisa; o que faltar provavelmente está no próprio Plug, em Plug.Conn.

A primeira linha do PageController, use HelloWeb, :controller, executa o bloco controller de lib/hello_web.ex. A célula abaixo mostra que o resultado é um plug: o módulo tem init/1 e call/2, como os plugs de módulo do notebook anterior.

%{
  init: function_exported?(HelloWeb.PageController, :init, 1),
  call: function_exported?(HelloWeb.PageController, :call, 2),
  home: function_exported?(HelloWeb.PageController, :home, 2)
}

Actions

Na documentação: Actions

Actions são funções comuns. Podem ter qualquer nome que siga as regras do Elixir. A única exigência é que o nome seja o mesmo na rota e no controller.

Trilha.pergunta(:nome_action)

Sua vez: renomear uma action

O guia troca o nome da action da página inicial de home para index. Faça a troca em duas etapas, conferindo o resultado de cada uma:

  1. Na célula do router, troque :home por :index na rota de /. Avalie a célula e rode a célula abaixo. Leia a exceção.
  2. Na célula do PageController, renomeie a action para index. O corpo continua render(conn, :home): o nome da action e o nome do template são coisas independentes. Avalie e rode de novo.
Trilha.mostrar(:get, "/")
Trilha.conferir(:index)
Trilha.solucao(:index)

Nomes por convenção

Mesmo podendo usar qualquer nome, há convenções que convém seguir. São as que o guia de Routing apresentou com resources:

Action O que faz
index lista todos os itens de um tipo de recurso
show mostra um item, pelo ID
new mostra um formulário para criar um item
create recebe os parâmetros de um item novo e o salva
edit busca um item pelo ID e o mostra em um formulário de edição
update recebe os parâmetros de um item editado e o salva
delete recebe o ID de um item e o apaga

Toda action recebe dois parâmetros, que o Phoenix fornece:

  • conn, a struct com as informações da requisição: host, segmentos do caminho, porta, query string e muito mais. Ela vem do Plug; a lista completa de campos está em Plug.Conn.
  • params, o mapa com os parâmetros da requisição. A boa prática é usar pattern matching na assinatura para extrair o que interessa, como em def show(conn, %{"messenger" => messenger}).

Quando a action não usa os parâmetros, o nome ganha um _ na frente (_params). Assim o compilador não reclama da variável não usada e a aridade continua a correta: o router sempre chama a action com dois argumentos.

Renderização

Na documentação: Rendering

Um controller pode responder de várias formas. A mais simples é texto puro, com text/2.

Trilha.pergunta(:tipo_texto)

Experimente: texto, JSON e HTML

Na célula do HelloController, troque o corpo da action show por cada uma das versões abaixo, uma de cada vez. Depois de avaliar cada versão, rode a célula de mostrar logo em seguida e compare o content-type e o conn.resp_body.

# 1. Texto puro
def show(conn, %{"messenger" => messenger}) do
  text(conn, "From messenger #{messenger}")
end

# 2. JSON: o mapa é codificado pelo Jason
def show(conn, %{"messenger" => messenger}) do
  json(conn, %{id: messenger})
end

# 3. HTML escrito à mão
def show(conn, %{"messenger" => messenger}) do
  html(conn, "<p>From messenger #{messenger}</p>")
end
Trilha.mostrar(:get, "/hello/Frank")

O que observar:

  • Com text/2, o content-type é text/plain; charset=utf-8 e o corpo é exatamente a string, sem o root layout. Em http://localhost:4010/hello/Frank o navegador mostra texto cru.
  • Com json/2, o content-type é application/json; charset=utf-8 e o corpo é {"id":"Frank"}. É o tipo de resposta que o seu fetch recebe e lê com response.json().
  • Com html/2, o content-type é text/html; charset=utf-8, mas a string vai como está: sem layout e sem escape. Tente /hello/%3Cb%3EFrank%3C%2Fb%3E com essa versão e compare com o escape que o HEEx fazia no notebook 01.

Nas três versões, conn.private.phoenix_template fica nil: nenhum template foi renderizado. (conn.private.phoenix_view aparece mesmo assim, porque o controller registra as views de cada formato antes de rodar a action; elas só não foram usadas.) Na maior parte do tempo, porém, usamos as views do Phoenix, por meio de render/3. Especialmente para HTML, as views trazem ganhos de desempenho e de segurança.

Passando valores ao template

Depois dos experimentos, volte a show para render(conn, :show, messenger: messenger).

Para o render/3 funcionar, controller e view compartilham o mesmo nome-raiz (Hello): HelloController requer HelloHTML. No projeto gerado, HelloHTML declara embed_templates "hello_html/*", e a pasta lib/hello_web/controllers/hello_html/ contém o show.html.heex. No notebook, como antes, o template é uma função.

O terceiro argumento de render/3 vira assigns. A mesma coisa pode ser feita com assign/3, de Plug.Conn, que devolve o conn e por isso encadeia bem com o pipe. use Phoenix.Controller importa Plug.Conn, então dá para escrever só assign/3. As três formas abaixo são equivalentes:

render(conn, :show, messenger: messenger, receiver: "Dweezil")

conn
|> assign(:messenger, messenger)
|> assign(:receiver, "Dweezil")
|> render(:show)

conn
|> Plug.Conn.assign(:messenger, messenger)
|> Plug.Conn.assign(:receiver, "Dweezil")
|> render(:show)

Depois que os assigns estão configurados, a action chama a camada de visualização. A view (HelloWeb.HelloHTML) renderiza o template show dentro do layout, e a resposta volta ao navegador.

Sua vez: dois assigns

  1. Na célula do HelloController, reescreva show com a versão em pipeline: assign(:messenger, messenger), assign(:receiver, "Dweezil") e render(:show).
  2. Na célula do HelloHTML, acrescente ao template show, logo abaixo do <h2>, um parágrafo <p>To: {@receiver}</p>.
Trilha.conferir(:assigns)
Trilha.solucao(:assigns)

Novos formatos de renderização

Na documentação: New rendering formats

O trabalho da view não é só renderizar HTML. Views cuidam da apresentação dos dados: dado um conjunto de dados, o papel da view é apresentá-lo de forma útil em algum formato, seja HTML, JSON, CSV ou outro.

O controller entrega a renderização a uma view por formato. Os formatos que os controllers aceitam estão em lib/hello_web.ex:

use Phoenix.Controller, formats: [:html, :json]

Com isso, o Phoenix procura uma view HTML e uma view JSON derivadas do nome do controller, conforme o formato da requisição. A escolha pode ser escrita explicitamente no controller; esta linha faz exatamente o que o Phoenix já faz por padrão no PageController:

plug :put_view, html: HelloWeb.PageHTML, json: HelloWeb.PageJSON
{:ok, conn} = Trilha.requisicao(:get, "/")

conn.private.phoenix_view

O mapa acima é o que o Phoenix calculou para a página inicial: uma view para cada formato. Só a view HTML existe por enquanto.

O Phoenix permite trocar o formato pelo parâmetro _format na query string.

Trilha.pergunta(:formato_recusado)
Trilha.mostrar(:get, "/?_format=json")

A linha do router mostra que a rota casou; a exceção veio depois, da pipeline. Leia a mensagem: ela lista os formatos aceitos.

Sua vez: a página inicial em JSON

  1. Na célula do router, mude o plug da pipeline :browser para plug :accepts, ["html", "json"].
  2. Na célula abaixo, defina a view PageJSON com a função home/1 do guia: ela ignora os assigns e devolve o mapa %{message: "this is some JSON"}.
Trilha.pergunta(:view_json)

lib/hello_web/controllers/page_json.ex

defmodule HelloWeb.PageJSON do
  # Defina aqui a função home/1.
end
Trilha.conferir(:formato_json)
Trilha.mostrar(:get, "/?_format=json")

A view JSON é só uma função que recebe os assigns e devolve dados. Repare também que o root layout não envolveu a resposta: put_root_layout foi configurado só para html:. Abra http://localhost:4010/?_format=json no navegador.

Na prática, aplicações que respondem nos dois formatos costumam separar as rotas em pipelines distintas, como a :api que já existe no router. O guia de JSON and APIs trata disso.

Trilha.solucao(:formato_json)

Enviando respostas diretamente

Na documentação: Sending responses directly

Quando nenhuma das opções acima serve, dá para compor a resposta com as funções do Plug. O guia faz isso alterando a action home; aqui, como dito, os experimentos acontecem no controller de laboratório, cujas rotas já estão no escopo /lab do router.

O laboratório declara o plug put_view da seção anterior, para renderizar os templates de PageHTML e PageJSON e uma view XML que ainda não existe. Avalie a célula. Você vai voltar a ela nas próximas seções.

lib/hello_web/controllers/lab_controller.ex

defmodule HelloWeb.LabController do
  use HelloWeb, :controller

  plug :put_view, html: HelloWeb.PageHTML, json: HelloWeb.PageJSON, xml: HelloWeb.PageXML

  # Enviando respostas diretamente
  def criado(conn, _params) do
    send_resp(conn, 201, "")
  end

  # Definindo o content type
  def xml(conn, _params) do
    conn
    |> put_resp_content_type("text/xml")
    |> put_format(:xml)
    |> render(:home, content: "<message>this is some XML</message>")
  end

  # Definindo o status HTTP: escreva aqui a action aceito/2.

  # Redirecionamento: escreva aqui a action redirecionar/2.

  def externo(conn, _params) do
    redirect(conn, external: "https://elixir-lang.org/")
  end

  # Mensagens flash
  def aviso(conn, _params) do
    conn
    |> put_flash(:error, "Let's pretend we have an error.")
    |> render(:home)
  end
end

A action criado responde com o status 201 (Created) e corpo vazio, usando Plug.Conn.send_resp/3.

Trilha.pergunta(:sem_tipo)
Trilha.mostrar(:get, "/lab/criado")

O content-type é []: nenhum cabeçalho. Em http://localhost:4010/lab/criado, a página fica em branco; a aba Network das ferramentas de desenvolvedor do navegador mostra o status 201.

Sua vez: um 201 com content type

Use put_resp_content_type/2 antes de send_resp/3 para declarar que a resposta é text/plain. Edite a action criado na célula do LabController.

Trilha.conferir(:criado)
Trilha.solucao(:criado)

Com as funções do Plug.Conn dá para montar exatamente a resposta que se precisa.

Definindo o content type

Na documentação: Setting the content type

Assim como o parâmetro _format, dá para renderizar qualquer formato trocando o cabeçalho Content-Type e fornecendo o template adequado. A action xml do laboratório é a versão do guia:

def xml(conn, _params) do
  conn
  |> put_resp_content_type("text/xml")
  |> put_format(:xml)
  |> render(:home, content: "<message>this is some XML</message>")
end
  • put_resp_content_type/2 define o cabeçalho da resposta.
  • put_format/2 define o formato da renderização, sem passar pelo plug :accepts. É ele que faz o render/3 procurar a view XML.
  • render/3 chama a função home/1 da view registrada para :xml.
Trilha.mostrar(:get, "/lab/xml")

A view XML ainda não existe. A exceção diz qual template ("home"), em qual formato (xml) e em qual view o render procurou.

O guia pede uma view PageXML com um template home.xml.eex. A extensão .eex indica que o template é renderizado pelo EEx, a biblioteca de templates que vem com o Elixir, e não pelo HEEx, que só entende HTML. No projeto, embed_templates "page_xml/*" transformaria o arquivo em uma função home(assigns). No notebook, EEx.function_from_string/5 faz essa mesma transformação a partir de uma string.

Sua vez: a view XML

Complete a célula abaixo. O template deve ter, na primeira linha, a declaração <?xml version="1.0" encoding="UTF-8"?> e, na segunda, o assign @content, escrito como <%= @content %> (no EEx a interpolação usa <%= %>, não chaves).

lib/hello_web/controllers/page_xml.ex

defmodule HelloWeb.PageXML do
  require EEx

  # EEx.function_from_string(:def, :home, "...template...", [:assigns])
end
Trilha.conferir(:xml)
Trilha.mostrar(:get, "/lab/xml")

O EEx não escapa nada: o valor de @content entrou como está, o que é o desejado para montar XML e é exatamente o motivo de o HTML usar HEEx. A lista de tipos MIME válidos está na biblioteca MIME.

Trilha.solucao(:xml)

Definindo o status HTTP

Na documentação: Setting the HTTP Status

O status da resposta é definido com put_status/2, de Plug.Conn, importado em todos os controllers. O segundo argumento pode ser um número ou um "nome amigável" em forma de átomo. A tabela de nomes está na documentação de Plug.Conn.Status.code/1:

for nome <- [:ok, :created, :accepted, :found, :not_found, :not_acceptable] do
  {nome, Plug.Conn.Status.code(nome)}
end

Use a célula acima para descobrir o nome de outros status. O valor precisa ser um status válido: troque um dos átomos por :inexistente e veja o FunctionClauseError, porque nenhuma cláusula de code/1 aceita esse átomo.

Sua vez: um 202

No LabController, escreva a action aceito/2. Ela coloca o status 202 com put_status/2 (use o átomo) e renderiza o template :home.

Trilha.conferir(:aceito)
Trilha.solucao(:aceito)

Redirecionamento

Na documentação: Redirection

Muitas vezes é preciso redirecionar no meio de uma requisição. Uma action create bem-sucedida costuma redirecionar para a show do recurso recém-criado, ou para a index. Para isso existe redirect/2, que diferencia redirecionar para um caminho dentro da aplicação e redirecionar para uma URL.

Sua vez: redirect_test

O guia cria uma rota /redirect_test, que renderiza a página inicial em outro endereço, e faz a action home redirecionar para ela. Aqui o redirecionamento sai do laboratório:

  1. Na célula do router, dentro do scope "/", acrescente get "/redirect_test", PageController, :redirect_test.
  2. Na célula do PageController, acrescente a action redirect_test/2, que faz render(conn, :home).
  3. No LabController, escreva a action redirecionar/2, que faz redirect(conn, to: ~p"/redirect_test").

O ~p é o sigil das rotas verificadas (Phoenix.VerifiedRoutes.sigil_p/2), a forma preferida de referenciar qualquer caminho da aplicação. Se a rota ainda não existir quando o LabController for avaliado, a célula mostra um aviso do compilador: no route path for HelloWeb.Router matches "/redirect_test". Avalie o router primeiro.

Trilha.conferir(:redirect)
Trilha.mostrar(:get, "/lab/redirecionar")

A resposta tem status 302 e o cabeçalho location. O corpo é só um aviso; quem faz o trabalho é o navegador, que lê o location e dispara uma segunda requisição. Trilha.navegar/2 faz o mesmo:

Trilha.navegar("/lab/redirecionar")

Abra http://localhost:4010/lab/redirecionar com a aba Network das ferramentas de desenvolvedor aberta. Você vai ver as mesmas duas requisições: um GET para /lab/redirecionar com status 302 e um GET para /redirect_test com status 200.

Trilha.solucao(:redirect)

Para fora da aplicação

Trilha.pergunta(:redirect_to)

A célula abaixo chama redirect/2 diretamente, com um conn de teste, do jeito errado:

Phoenix.Controller.redirect(Plug.Test.conn(:get, "/"), to: "https://elixir-lang.org/")

Para uma URL completa ou externa, a opção é :external. É o que faz a action externo do laboratório:

Trilha.navegar("/lab/externo")

Trilha.navegar/2 para no primeiro passo, porque o destino fica fora da aplicação.

Mensagens flash

Na documentação: Flash messages

Às vezes a action precisa dizer algo ao usuário: houve um erro ao salvar, ou só "bem-vindo de volta". Para isso existem as mensagens flash.

put_flash/3, de Phoenix.Controller, guarda uma mensagem como um par de chave e valor, que vai parar no assign @flash. Para mostrá-la, o template lê a mensagem com Phoenix.Flash.get/2, que recebe o mapa da flash e a chave. É o que o Layouts.app deste notebook faz para as chaves :info e :error; no projeto gerado, o componente <.flash_group flash={@flash} /> faz o mesmo, com estilo e botão de fechar.

A action aviso do laboratório é a primeira versão do guia: put_flash/3 seguido de render/2.

Trilha.navegar("/lab/aviso", recarregar: true)

Na requisição 1, a mensagem aparece na página. A requisição 2 repete a mesma URL, como quem recarrega a página: a action roda de novo e coloca a flash de novo. Repare na última coluna, que mostra o que a resposta deixou guardado na sessão sob a chave da flash: nil nas duas. Sem redirect, a mensagem vale só para a página que está sendo renderizada.

A flash fica interessante combinada com um redirect: a mensagem é definida em uma requisição e mostrada na seguinte.

Trilha.pergunta(:flash_redirect)

Sua vez: flash e redirect

No LabController, troque o render(:home) da action aviso por redirect(to: ~p"/redirect_test"). Depois navegue de novo:

Trilha.navegar("/lab/aviso", recarregar: true)

Leia a tabela linha por linha:

  1. GET /lab/aviso define a flash e responde 302. Como a resposta é um redirect, o Phoenix guarda a mensagem na sessão (última coluna), e a sessão viaja no cookie.
  2. GET /redirect_test chega com o cookie. A flash é lida da sessão para conn.assigns.flash, aparece na página e sai da sessão: a última coluna volta a nil.
  3. Recarregar /redirect_test não traz mais a mensagem: conn.assigns.flash é %{}.
Trilha.conferir(:flash)
Trilha.solucao(:flash)

Além de put_flash/3, há clear_flash/1, que recebe só o conn e remove todas as mensagens flash que estiverem guardadas na sessão.

O Phoenix não impõe quais chaves usar na flash. Basta ser consistente. :info e :error, porém, são as comuns e as que os templates gerados tratam por padrão.

Páginas de erro

Na documentação: Error pages

O Phoenix tem duas views para erros, ErrorHTML e ErrorJSON, em lib/hello_web/controllers/. Elas tratam os erros de forma centralizada para requisições HTML e JSON. Como as views deste guia, as de erro respondem nos dois formatos. O notebook seguinte é dedicado a elas.

Uma prévia: a primeira célula do notebook registra as duas views em render_errors, e cada uma é chamada com o nome do template de erro, como "404.html" ou "404.json". Enquanto o notebook roda com debug_errors: true, como o ambiente de desenvolvimento, quem responde aos erros é a página de depuração. Por isso a célula abaixo chama as views diretamente:

%{
  html: HelloWeb.ErrorHTML.render("404.html", %{}),
  json: HelloWeb.ErrorJSON.render("404.json", %{})
}

Sem roteiro: produtos do Brechó

Junte o que o guia mostrou em uma funcionalidade pequena do Brechó, o marketplace de usados da trilha. O catálogo já existe, como dados fixos:

defmodule Hello.Catalogo do
  @produtos [
    %{id: 1, nome: "Jaqueta jeans", preco: 89},
    %{id: 2, nome: "Vitrola portátil", preco: 250},
    %{id: 3, nome: "Coleção de gibis", preco: 40}
  ]

  def list_produtos, do: @produtos

  def get_produto(id) when is_binary(id) do
    Enum.find(@produtos, fn produto -> Integer.to_string(produto.id) == id end)
  end
end

Crie:

  • GET /produtos, que lista o nome dos três produtos em HTML;
  • GET /produtos/:id, que mostra o nome e o preço do produto em HTML, ou em JSON com ?_format=json ({"id": 2, "nome": "Vitrola portátil", "preco": 250});
  • para um id inexistente, um redirect para /produtos com a flash :error Produto não encontrado..

Use uma célula para o controller e as views (ProdutoController, ProdutoHTML, ProdutoJSON) e edite a célula do router. Lembre-se de avaliar o router antes do controller, por causa do ~p.

# lib/hello_web/controllers/produto_controller.ex, produto_html.ex e produto_json.ex
Trilha.conferir(:produtos)
Trilha.solucao(:produtos)

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 do PageController lib/hello_web/controllers/page_controller.ex
Função home/1 de PageHTML lib/hello_web/controllers/page_html/home.html.heex
Célula do PageJSON lib/hello_web/controllers/page_json.ex
EEx.function_from_string em PageXML lib/hello_web/controllers/page_xml/home.xml.eex e embed_templates "page_xml/*"
Parágrafos de flash em Layouts.app <.flash_group flash={@flash} />, dos CoreComponents
Trilha.mostrar/2 Aba Network das ferramentas de desenvolvedor
Trilha.navegar/2 Navegar e observar a sequência de requisições na aba Network

Roteiro:

  1. Troque home por index na rota e no controller. Antes de trocar no controller, recarregue a página e leia o erro.
  2. Faça a action show do HelloController responder com text/2 e depois com json/2. Na aba Network, clique na requisição e compare o cabeçalho content-type de cada versão.
  3. Crie o PageJSON, aceite json na pipeline :browser e abra http://localhost:4000/?_format=json.
  4. Faça a action home responder com send_resp(conn, 201, "") e observe o status 201 na aba Network. No Safari, a resposta sem content-type é baixada como arquivo.
  5. Faça a action home redirecionar para /redirect_test com uma flash. Na aba Network, veja o GET com status 302 seguido do GET com status 200, e recarregue para ver a mensagem sumir.
  6. Leia o log do mix phx.server durante o passo 5: cada uma das duas requisições aparece com o seu status.

No projeto gerado, a página inicial já chama <.flash_group flash={@flash} /> no começo do template, e as mensagens aparecem no canto superior direito.

Revisão

Escreva em uma célula de texto, com as suas palavras: o que uma action recebe, quais são as formas de responder e quando usar cada uma, como o formato escolhe a view e o que acontece entre put_flash/3, redirect/2 e a página seguinte. Depois compare com os pontos abaixo.

  • Actions são funções que recebem conn e params. O nome é livre, mas precisa ser o mesmo na rota e no controller, e as convenções (index, show, new, create, edit, update, delete) valem a pena.
  • text/2, json/2 e html/2 respondem diretamente e definem o content-type. render/3 passa por uma view e é a forma usual, especialmente para HTML.
  • Os assigns chegam ao template pelo terceiro argumento de render/3 ou por assign/3, que encadeia com o pipe.
  • A view é escolhida pelo formato: PageController mais :json dá PageJSON. O plug :accepts da pipeline decide quais formatos a requisição pode pedir, e _format escolhe um deles. put_view declara as views explicitamente.
  • send_resp/3, put_resp_content_type/2 e put_status/2, do Plug, montam qualquer resposta. put_format/2 escolhe o formato da renderização no próprio controller.
  • redirect/2 responde 302 com o cabeçalho location. to: aceita só caminhos da aplicação, de preferência escritos com ~p; external: aceita URLs.
  • A flash vive na sessão: definida antes de um redirect, aparece na requisição seguinte e depois some.

Próximo notebook: 05_paginas_de_erro.livemd, sobre as views ErrorHTML e ErrorJSON e as exceções com status próprio.