Powered by AppSignal & Oban Pro

Trilha Phoenix · Páginas de erro

05_paginas_de_erro.livemd

Trilha Phoenix · Páginas de erro

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 a resposta que
  foi enviada (inclusive quando a requisição termina em exceção), grava os
  arquivos de template, 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

  # --- arquivos e configuração -------------------------------------------------

  @doc "A pasta que faz o papel de lib/hello_web/controllers no notebook."
  def pasta, do: Path.join(System.tmp_dir!(), "trilha_phoenix_05/lib/hello_web/controllers")

  @doc "Grava um arquivo de template dentro de pasta/0."
  def arquivo(caminho, conteudo) do
    destino = Path.join(pasta(), caminho)
    File.mkdir_p!(Path.dirname(destino))
    File.write!(destino, conteudo)

    Kino.Markdown.new(
      "Arquivo gravado: `lib/hello_web/controllers/#{caminho}`.\n\n" <>
        "O `embed_templates` lê os arquivos quando o módulo é compilado: avalie de novo a célula do `HelloWeb.ErrorHTML`."
    )
  end

  @doc "Altera a configuração do endpoint, como uma linha de config/dev.exs."
  def config(opcoes) do
    atual = Application.get_env(:hello, @endpoint)
    Application.put_env(:hello, @endpoint, Keyword.merge(atual, opcoes))
    linha = opcoes |> inspect() |> String.slice(1..-2//1)

    Kino.Markdown.new(
      "`config :hello, HelloWeb.Endpoint, #{linha}`\n\n" <>
        "Agora avalie a célula do endpoint, logo abaixo, para que ele seja compilado com essa configuração."
    )
  end

  # --- requisições -----------------------------------------------------------

  @doc """
  Despacha uma requisição para o endpoint, sem passar pela rede, e devolve a
  resposta que foi enviada: status, content-type, corpo e a exceção, se houve.

  Opções: `accept:` (o cabeçalho Accept) e `params:` (o corpo de um POST).
  """
  def resposta(metodo, caminho, opcoes \\ []) do
    descartar_respostas_antigas()

    conn =
      case opcoes[:accept] do
        nil -> Phoenix.ConnTest.build_conn()
        accept -> Plug.Conn.put_req_header(Phoenix.ConnTest.build_conn(), "accept", accept)
      end

    {final, excecao} =
      try do
        {Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, opcoes[:params]), nil}
      rescue
        erro -> {nil, desembrulhar(erro)}
      end

    # 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.
    excecao =
      if excecao == nil and final != nil and final.status in [nil, 404] and rota(metodo, caminho) == :error do
        Phoenix.Router.NoRouteError.exception(conn: final, router: @router)
      else
        excecao
      end

    # Cada resposta enviada pelo adaptador de testes chega como uma mensagem.
    receive do
      {ref, {status, cabecalhos, corpo}} when is_reference(ref) ->
        tipo = cabecalhos |> List.keyfind("content-type", 0, {nil, nil}) |> elem(1)
        %{status: status, content_type: tipo, corpo: corpo, excecao: excecao}
    after
      0 -> %{status: nil, content_type: nil, corpo: nil, excecao: excecao}
    end
  end

  defp descartar_respostas_antigas do
    receive do
      {ref, {status, cabecalhos, _}} 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

  defp rota(metodo, 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 "A resposta veio da página de depuração do Plug.Debugger?"
  def depuracao?(%{content_type: tipo, corpo: corpo}) do
    (is_binary(tipo) and String.starts_with?(tipo, "text/markdown")) or
      (is_binary(corpo) and String.contains?(corpo, ~s(name="error-status")))
  end

  defp quem_respondeu(%{excecao: nil}), do: "a action do controller, sem exceção"

  defp quem_respondeu(resposta) do
    cond do
      depuracao?(resposta) ->
        "a página de depuração do `Plug.Debugger` (o endpoint está com `debug_errors: true`)"

      is_binary(resposta.content_type) and String.contains?(resposta.content_type, "json") ->
        "a view de erro `HelloWeb.ErrorJSON`"

      true ->
        "a view de erro `HelloWeb.ErrorHTML`"
    end
  end

  @doc "Faz a requisição e mostra a exceção, quem respondeu e a resposta enviada."
  def mostrar(metodo, caminho, opcoes \\ []) do
    if is_binary(caminho) and String.starts_with?(caminho, "/") do
      r = resposta(metodo, caminho, opcoes)

      titulo =
        "### #{verbo(metodo)} #{caminho}" <>
          if(opcoes[:accept], do: " (Accept: `#{opcoes[:accept]}`)", else: "")

      excecao =
        case r.excecao do
          nil -> "**Exceção:** nenhuma."
          erro -> "**Exceção:** `" <> String.replace(Exception.format_banner(:error, erro), "`", "'") <> "`"
        end

      Kino.Markdown.new(
        Enum.join(
          [
            titulo,
            excecao,
            "**Quem escreveu a resposta:** " <> quem_respondeu(r),
            tabela("**A resposta enviada**", [{"status", r.status}, {"content-type", r.content_type}]),
            "**Corpo**" <> corpo_md(r)
          ],
          "\n\n"
        )
      )
    else
      Kino.Markdown.new("O caminho precisa começar com `/`, por exemplo `/hello`.")
    end
  end

  defp corpo_md(%{corpo: nil}), do: ": nenhum corpo foi enviado."

  defp corpo_md(%{corpo: corpo, content_type: tipo}) do
    limite = 1200

    {texto, aviso} =
      if String.length(corpo) > limite,
        do: {String.slice(corpo, 0, limite), " (os primeiros #{limite} caracteres de #{String.length(corpo)})"},
        else: {corpo, ""}

    linguagem =
      cond do
        tipo == nil -> ""
        String.contains?(tipo, "json") -> "json"
        String.contains?(tipo, "html") -> "html"
        true -> "text"
      end

    aviso <> "\n\n" <> cerca(texto, linguagem)
  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
    %{
      producao: %{
        texto: "com `debug_errors: false`, como em produção, o que o navegador recebe em `GET /such/a/wrong/path`, uma rota que não existe?",
        opcoes: [
          a: "A página de depuração, com a exceção e a lista de rotas.",
          b: "Status 404 e o texto `Not Found`, sem nenhuma marcação HTML.",
          c: "Status 200 e uma página em branco.",
          d: "Um redirecionamento para `/`."
        ],
        correta: :b,
        explicacao:
          "Sem o `Plug.Debugger`, quem escreve a resposta é a view de erro configurada em `render_errors`. A `HelloWeb.ErrorHTML` gerada só tem uma função `render/2`, que devolve a mensagem padrão do status: para 404, `Not Found`. O status continua 404, porque vem da exceção (`NoRouteError`), não da view."
      },
      fallback: %{
        texto: "para uma rota inexistente, o Phoenix renderiza o template `404` no formato HTML com a view `HelloWeb.ErrorHTML`. Essa view não define nenhuma função chamada `404`. O que acontece?",
        opcoes: [
          a: "O Phoenix levanta `UndefinedFunctionError` e responde 500.",
          b: "O Phoenix chama `render(\"404.html\", assigns)` na mesma view.",
          c: "O Phoenix usa uma página de erro própria, embutida no framework.",
          d: "A resposta sai com o corpo vazio."
        ],
        correta: :b,
        explicacao:
          "Ao renderizar um template, o Phoenix procura primeiro uma função com o nome do template e aridade 1 (`404/1`). Se ela não existe, recorre a `render/2`, passando o nome do template junto com o formato: `\"404.html\"`. É essa a função que a `ErrorHTML` gerada define."
      },
      precedencia: %{
        texto: "a `HelloWeb.ErrorHTML` tem o template `404.html.heex` embutido com `embed_templates` (portanto uma função `404/1`) e também uma cláusula `def render(\"404.html\", _assigns)`. Qual das duas produz a página 404?",
        opcoes: [
          a: "A cláusula de `render/2`, porque foi escrita à mão.",
          b: "O template: `render/2` só é chamado quando não existe a função `404/1`.",
          c: "Nenhuma: o módulo não compila com as duas.",
          d: "Depende da ordem em que aparecem no módulo."
        ],
        correta: :b,
        explicacao:
          "A busca é sempre a mesma: primeiro a função do template, depois `render/2`. Com o template embutido, a cláusula `render(\"404.html\", _)` nunca é alcançada; vira código morto. O guia diz que a função sobrepõe o template; na versão 1.8.16 acontece o contrário, e você pode conferir isso neste notebook. A recomendação do guia continua valendo: remova a cláusula, para que ninguém se engane lendo o módulo."
      },
      layout: %{
        texto: "a página 404 do guia repete o `<head>` e o cabeçalho em vez de usar `<Layouts.app>`. Por quê?",
        opcoes: [
          a: "Porque componentes não funcionam dentro de views de erro.",
          b: "Para evitar erros em cascata: se o erro tiver vindo do layout, renderizá-lo de novo falharia outra vez.",
          c: "Porque o Phoenix proíbe layouts em respostas com status diferente de 200.",
          d: "Por desempenho: o layout deixaria a página de erro lenta."
        ],
        correta: :b,
        explicacao:
          "A página de erro precisa funcionar justamente quando algo deu errado. Quanto menos ela depender do resto da aplicação, menor a chance de falhar também. Pelo mesmo motivo a configuração usa `render_errors: [..., layout: false]`, e o root layout também não é aplicado."
      },
      formato_sem_rota: %{
        texto: "chega `GET /api/nada`, sem cabeçalho `Accept`. Não existe rota para esse caminho. Em que formato sai a página de erro?",
        opcoes: [
          a: "JSON, porque o caminho começa com `/api`.",
          b: "HTML: nenhuma pipeline rodou, e sem `Accept` nem `_format` vale o primeiro formato de `render_errors`.",
          c: "Texto puro, porque o formato é desconhecido.",
          d: "O formato do último controller que respondeu."
        ],
        correta: :b,
        explicacao:
          "O `plug :accepts, [\"json\"]` da pipeline `:api` só roda depois que uma rota casa. Sem rota, o formato é negociado na hora de renderizar o erro, a partir do cabeçalho `Accept` ou do parâmetro `_format`. Se a requisição não diz nada, vale o primeiro formato da lista `render_errors: [formats: [html: ..., json: ...]]`, que é HTML."
      },
      status_padrao: %{
        texto: "a action levanta `HelloWeb.SomethingNotFoundError`, definida só com `defexception [:message]`. Com que status sai a resposta?",
        opcoes: [
          a: "404, porque o nome da exceção termina em `NotFoundError`.",
          b: "500.",
          c: "400.",
          d: "Nenhum: a conexão é encerrada sem resposta."
        ],
        correta: :b,
        explicacao:
          "Por padrão, Plug e Phoenix tratam toda exceção como erro 500. O nome não importa. Para outro status, a exceção precisa implementar o protocolo `Plug.Exception` ou ter um campo `plug_status`."
      },
      acoes_producao: %{
        texto: "a exceção agora tem uma ação, \"Run seeds\". Quem acessa a aplicação em produção, com `debug_errors: false`, vê esse botão?",
        opcoes: [
          a: "Sim, em qualquer ambiente.",
          b: "Sim, mas só quem estiver logado.",
          c: "Não. As ações só aparecem na página de depuração, quando `debug_errors` é `true`.",
          d: "Não, porque o botão só aparece em respostas JSON."
        ],
        correta: :c,
        explicacao:
          "As ações são desenhadas pelo `Plug.Debugger`, que só existe no endpoint compilado com `debug_errors: true`. Em produção quem responde é a `ErrorHTML`, que recebe o status e ignora as ações. O handler executa código na máquina do servidor, e é por isso que ele fica restrito ao desenvolvimento."
      }
    }
  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
    %{
      producao: ~S'''
      # Na célula config/dev.exs:
      Trilha.config(debug_errors: false)

      # Depois avalie a célula do endpoint.
      ''',
      clausula: ~S'''
      defmodule HelloWeb.ErrorHTML do
        use HelloWeb, :html

        # embed_templates "error_html/*", root: Trilha.pasta()

        # A cláusula específica vem antes da genérica.
        def render("404.html", _assigns) do
          "Page Not Found"
        end

        def render(template, _assigns) do
          Phoenix.Controller.status_message_from_template(template)
        end
      end
      ''',
      template: ~S'''
      defmodule HelloWeb.ErrorHTML do
        use HelloWeb, :html

        embed_templates "error_html/*", root: Trilha.pasta()

        def render(template, _assigns) do
          Phoenix.Controller.status_message_from_template(template)
        end
      end
      ''',
      json: ~S'''
      defmodule HelloWeb.ErrorJSON do
        def render("404.json", _assigns) do
          %{errors: %{detail: "Recurso não encontrado"}}
        end

        def render(template, _assigns) do
          %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
        end
      end
      ''',
      status_404: ~S'''
      # Jeito 1: implementar o protocolo.
      defimpl Plug.Exception, for: HelloWeb.SomethingNotFoundError do
        def status(_exception), do: 404
        def actions(_exception), do: []
      end

      # Jeito 2: na célula da exceção, um campo plug_status.
      defmodule HelloWeb.SomethingNotFoundError do
        defexception [:message, plug_status: 404]
      end
      ''',
      acionavel: ~S'''
      defimpl Plug.Exception, for: HelloWeb.SomethingNotFoundError do
        def status(_exception), do: 404

        def actions(_exception) do
          [
            %{
              label: "Run seeds",
              handler: {Hello.Messages, :seed, []}
            }
          ]
        end
      end
      ''',
      vendido: ~S'''
      # No router, dentro do scope "/":
      get "/produtos/:id", ProdutoController, :show

      defmodule HelloWeb.ProdutoVendidoError do
        defexception [:message, plug_status: 410]
      end

      defmodule HelloWeb.ProdutoController do
        use HelloWeb, :controller

        def show(conn, %{"id" => id}) do
          case Hello.Catalogo.buscar(id) do
            nil -> raise HelloWeb.SomethingNotFoundError, "no product with id #{id}"
            %{vendido: true} = produto -> raise HelloWeb.ProdutoVendidoError, "#{produto.nome} was sold"
            produto -> render(conn, :show, produto: produto)
          end
        end
      end

      defmodule HelloWeb.ProdutoHTML do
        use HelloWeb, :html

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

      # O template, com Trilha.arquivo/2:
      Trilha.arquivo("error_html/410.html.heex", ~S"""
      <!DOCTYPE html>
      <html lang="pt-BR">
        <head>
          <meta charset="utf-8"/>
          <title>Item vendido</title>
        </head>
        <body>
          <p>Este item já foi vendido.</p>
          <p><a href="/">Voltar à vitrine</a></p>
        </body>
      </html>
      """)

      # Por fim, avalie de novo a célula do HelloWeb.ErrorHTML.
      '''
    }
  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

  @dica_depuracao "A resposta veio da página de depuração: o endpoint ainda está compilado com `debug_errors: true`. Na célula `config/dev.exs`, use `Trilha.config(debug_errors: false)`, avalie-a e avalie a célula do endpoint."

  defp verificacoes do
    %{
      producao: [
        {"`GET /such/a/wrong/path` não passa mais pela página de depuração.",
         fn -> if depuracao?(resposta(:get, "/such/a/wrong/path")), do: {:erro, @dica_depuracao}, else: :ok end},
        {"A resposta tem status 404.", fn -> status("/such/a/wrong/path", 404) end},
        {"O corpo é exatamente `Not Found`.", fn -> corpo_igual("/such/a/wrong/path", "Not Found") end}
      ],
      clausula: [
        {"O endpoint está sem a página de depuração.", fn -> sem_depuracao() end},
        {"`GET /such/a/wrong/path` responde 404 com o corpo `Page Not Found`.",
         fn ->
           case corpo_igual("/such/a/wrong/path", "Page Not Found") do
             :ok -> :ok
             {:erro, dica} -> {:erro, dica <> " A cláusula de `\"404.html\"` precisa vir antes da cláusula genérica, que casa com qualquer template."}
           end
         end},
        {"O template 500 continua com a mensagem padrão.",
         fn -> igual(Phoenix.Template.render_to_string(HelloWeb.ErrorHTML, "500", "html", %{}), "Internal Server Error") end}
      ],
      template: [
        {"O endpoint está sem a página de depuração.", fn -> sem_depuracao() end},
        {"`HelloWeb.ErrorHTML` tem a função `404/1`, gerada a partir de `404.html.heex`.",
         fn ->
           if function_exported?(HelloWeb.ErrorHTML, :"404", 1),
             do: :ok,
             else: {:erro, "A função não existe. Grave o arquivo (célula do template), descomente o `embed_templates` e avalie a célula do `ErrorHTML`."}
         end},
        {"A cláusula `render(\"404.html\", _)` foi removida.",
         fn ->
           case HelloWeb.ErrorHTML.render("404.html", %{}) do
             "Not Found" -> :ok
             outro -> {:erro, "`render(\"404.html\", %{})` ainda devolve `#{inspect(outro)}`. Remova a cláusula: com o template embutido, ela nunca é usada."}
           end
         end},
        {"`GET /such/a/wrong/path` responde 404 com a página do template.",
         fn -> corpo_contem("/such/a/wrong/path", "Sorry, the page you are looking for does not exist.") end},
        {"O template não passou pelo layout: a página tem um único `<html>`.",
         fn ->
           r = resposta(:get, "/such/a/wrong/path")
           n = length(String.split(r.corpo || "", "<html")) - 1
           if n == 1, do: :ok, else: {:erro, "Achei #{n} tags `<html>` no corpo."}
         end}
      ],
      json: [
        {"`GET /such/a/wrong/path` com `Accept: application/json` responde 404 em JSON.",
         fn ->
           r = resposta(:get, "/such/a/wrong/path", accept: "application/json")

           cond do
             depuracao?(r) -> {:erro, @dica_depuracao}
             r.status != 404 -> {:erro, "O status foi `#{inspect(r.status)}`."}
             not String.contains?(r.content_type || "", "json") -> {:erro, "O content-type foi `#{r.content_type}`."}
             true -> :ok
           end
         end},
        {"O corpo é `{\"errors\":{\"detail\":\"Recurso não encontrado\"}}`.",
         fn ->
           r = resposta(:get, "/such/a/wrong/path", accept: "application/json")

           case Jason.decode(r.corpo || "") do
             {:ok, %{"errors" => %{"detail" => "Recurso não encontrado"}}} -> :ok
             {:ok, outro} -> {:erro, "O JSON recebido foi `#{Jason.encode!(outro)}`."}
             _ -> {:erro, "O corpo não é JSON."}
           end
         end},
        {"O erro 500 em JSON continua com a mensagem padrão.",
         fn -> igual(HelloWeb.ErrorJSON.render("500.json", %{}), %{errors: %{detail: "Internal Server Error"}}) end}
      ],
      status_404: [
        {"`Plug.Exception.status/1` devolve 404 para `HelloWeb.SomethingNotFoundError`.",
         fn -> igual(Plug.Exception.status(struct(HelloWeb.SomethingNotFoundError, message: "x")), 404) end},
        {"`GET /messages/99` responde 404.", fn -> status("/messages/99", 404) end},
        {"`GET /api/messages/99` responde 404 em JSON.",
         fn ->
           r = resposta(:get, "/api/messages/99")

           cond do
             r.status != 404 -> {:erro, "O status foi `#{inspect(r.status)}`."}
             not String.contains?(r.content_type || "", "json") -> {:erro, "O content-type foi `#{r.content_type}`."}
             true -> :ok
           end
         end},
        {"`GET /messages/1` continua respondendo 200.", fn -> status("/messages/1", 200) end}
      ],
      acionavel: [
        {"`Plug.Exception.actions/1` devolve a ação `Run seeds` com o handler `{Hello.Messages, :seed, []}`.",
         fn ->
           acoes = Plug.Exception.actions(struct(HelloWeb.SomethingNotFoundError, message: "x"))

           if Enum.any?(acoes, &match?(%{label: "Run seeds", handler: {Hello.Messages, :seed, []}}, &1)),
             do: :ok,
             else: {:erro, "`actions/1` devolveu `#{inspect(acoes)}`."}
         end},
        {"Com as mensagens apagadas, a página de depuração de `GET /messages` mostra o botão `Run seeds`.",
         fn ->
           Hello.Messages.clear()
           r = resposta(:get, "/messages", accept: "text/html")

           cond do
             not depuracao?(r) ->
               {:erro, "A resposta veio da `ErrorHTML`, não da página de depuração. Use `Trilha.config(debug_errors: true)` na célula `config/dev.exs` e avalie a célula do endpoint."}

             String.contains?(r.corpo, "Run seeds") ->
               :ok

             true ->
               {:erro, "A página de depuração não tem o botão. A exceção levantada foi `#{inspect(r.excecao)}`."}
           end
         end},
        {"Clicar no botão executa o handler e redireciona de volta para `/messages`.",
         fn ->
           Hello.Messages.clear()
           pagina = resposta(:get, "/messages", accept: "text/html")

           case Regex.run(~r/name="encoded_handler" value="([^"]+)"/, pagina.corpo || "") do
             [_, handler] ->
               clique =
                 resposta(:post, "/__plug__/debugger/action",
                   params: %{"encoded_handler" => handler, "last_path" => "/messages"}
                 )

               cond do
                 clique.status != 302 -> {:erro, "O clique respondeu com status `#{inspect(clique.status)}`."}
                 Hello.Messages.list() == [] -> {:erro, "O handler rodou, mas as mensagens continuam vazias."}
                 true -> :ok
               end

             nil ->
               {:erro, "Não achei o formulário do botão na página de depuração."}
           end
         end},
        {"Depois do clique, `GET /messages` lista as mensagens.", fn -> corpo_contem("/messages", "Oi, Ana", false) end}
      ],
      vendido: [
        {"`HelloWeb.ProdutoVendidoError` tem status 410.",
         fn -> igual(Plug.Exception.status(struct(HelloWeb.ProdutoVendidoError, message: "x")), 410) end},
        {"`GET /produtos/1` responde 200 e mostra `Jaqueta jeans`.",
         fn ->
           with :ok <- status("/produtos/1", 200), do: corpo_contem("/produtos/1", "Jaqueta jeans", false)
         end},
        {"`GET /produtos/2`, item vendido, responde 410.", fn -> status("/produtos/2", 410) end},
        {"A página do 410 diz `Este item já foi vendido`.", fn -> corpo_contem("/produtos/2", "Este item já foi vendido") end},
        {"`GET /produtos/99` responde 404.", fn -> status("/produtos/99", 404) end}
      ]
    }
  end

  defp sem_depuracao do
    if depuracao?(resposta(:get, "/such/a/wrong/path")), do: {:erro, @dica_depuracao}, else: :ok
  end

  defp igual(valor, esperado) do
    if valor == esperado, do: :ok, else: {:erro, "Esperado `#{inspect(esperado)}`, veio `#{inspect(valor)}`."}
  end

  defp status(caminho, esperado) do
    r = resposta(:get, caminho)

    if r.status == esperado do
      :ok
    else
      excecao = if r.excecao, do: " A requisição levantou `" <> Exception.format_banner(:error, r.excecao) <> "`.", else: ""
      {:erro, "`GET #{caminho}` respondeu com status `#{inspect(r.status)}`." <> excecao}
    end
  end

  defp corpo_igual(caminho, esperado) do
    r = resposta(:get, caminho)

    cond do
      depuracao?(r) -> {:erro, @dica_depuracao}
      r.corpo == esperado -> :ok
      true -> {:erro, "O corpo de `GET #{caminho}` foi `#{inicio(r.corpo)}`."}
    end
  end

  defp corpo_contem(caminho, texto, exigir_sem_depuracao \\ true) do
    r = resposta(:get, caminho)

    cond do
      exigir_sem_depuracao and depuracao?(r) -> {:erro, @dica_depuracao}
      String.contains?(r.corpo || "", texto) -> :ok
      true -> {:erro, "A resposta de `GET #{caminho}` (status `#{inspect(r.status)}`) não contém esse texto. Ela começa com `#{inicio(r.corpo)}`."}
    end
  end

  defp inicio(nil), do: "(vazio)"
  defp inicio(corpo), do: corpo |> String.slice(0, 120) |> String.replace("`", "'") |> String.replace("\n", " ")
end

# Começa sem templates gravados por execuções anteriores.
File.rm_rf!(Trilha.pasta())
File.mkdir_p!(Path.join(Trilha.pasta(), "error_html"))

Como usar este notebook

Este notebook acompanha o guia Custom Error Pages da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. Ele parte da aplicação Hello no ponto em que o notebook 01_ciclo_de_vida_da_requisicao.livemd terminou: a página inicial, /hello e /hello/:messenger. Para este guia, a aplicação ganha também uma lista de mensagens, que serve de exemplo de recurso que pode não ser encontrado.

As convenções são as mesmas dos notebooks anteriores: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo, mais ainda neste guia. A aplicação fica em http://localhost:4010. Se outro notebook estiver com o runtime ligado, desconecte-o antes, porque todos usam a mesma porta.

Duas novidades:

  • Uma célula de configuração. A célula config/dev.exs muda a configuração do endpoint, como você faria no arquivo de mesmo nome do projeto. Depois de alterá-la, avalie a célula do endpoint, logo abaixo dela.
  • Templates em arquivos. A página de erro do guia é um arquivo .heex. O notebook grava esse arquivo de verdade, numa pasta temporária que faz o papel de lib/hello_web/controllers, e a view o embute com embed_templates, como no projeto.

Trilha.mostrar/3 faz uma requisição e mostra a resposta que foi enviada: status, content-type e corpo, além da exceção, quando houver, e de quem escreveu a resposta. Neste guia isso importa, porque quase todas as requisições terminam em exceção e ainda assim enviam uma resposta.

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: o router e o endpoint vêm antes das views e dos controllers porque o sigil ~p, que aparece em um dos templates, consulta o router quando o módulo é compilado e o endpoint quando a página é renderizada.

lib/hello_web.ex: além do que os notebooks anteriores tinham, define verified_routes, que habilita o ~p nos controllers e nos templates.

defmodule HelloWeb do
  def router do
    quote do
      use Phoenix.Router, helpers: false

      import Plug.Conn
      import Phoenix.Controller
      import Phoenix.LiveView.Router
    end
  end

  def controller do
    quote do
      use Phoenix.Controller, formats: [:html, :json]

      import Plug.Conn

      unquote(verified_routes())
    end
  end

  def html do
    quote do
      use Phoenix.Component

      alias HelloWeb.Layouts

      unquote(verified_routes())
    end
  end

  def verified_routes do
    quote do
      use Phoenix.VerifiedRoutes,
        endpoint: HelloWeb.Endpoint,
        router: HelloWeb.Router,
        statics: ~w(assets fonts images favicon.ico robots.txt)
    end
  end

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

lib/hello_web/router.ex: as rotas do notebook 01, mais as de mensagens. /api/messages/:id usa a pipeline :api e responde JSON.

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
    get "/messages", MessageController, :index
    get "/messages/:id", MessageController, :show
  end

  scope "/api", HelloWeb do
    pipe_through :api

    get "/messages/:id", MessageController, :show
  end
end

Avaliar o router agora mostra avisos de que os controllers ainda não existem (init/1 is undefined). É esperado: eles vêm logo abaixo.

config/dev.exs: o trecho de configuração que este guia altera. Em desenvolvimento, debug_errors vem ligado.

Trilha.config(debug_errors: true)

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

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: como o mix phx.new gera na versão 1.8. A única diferença está na linha comentada do embed_templates: no projeto, ela procura a pasta error_html ao lado do próprio arquivo; aqui, a opção root: aponta para a pasta temporária do notebook.

defmodule HelloWeb.ErrorHTML do
  use HelloWeb, :html

  # If you want to customize your error pages,
  # uncomment the embed_templates/1 call below
  # and add pages to the error directory:
  #
  #   * lib/hello_web/controllers/error_html/404.html.heex
  #   * lib/hello_web/controllers/error_html/500.html.heex
  #
  # embed_templates "error_html/*", root: Trilha.pasta()

  # The default is to render a plain text page based on
  # the template name. For example, "404.html" becomes
  # "Not Found".
  def render(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

lib/hello_web/controllers/error_json.ex

defmodule HelloWeb.ErrorJSON do
  # If you want to customize a particular status code,
  # you may add your own clauses, such as:
  #
  # def render("500.json", _assigns) do
  #   %{errors: %{detail: "Internal Server Error"}}
  # end

  # By default, Phoenix returns the status message from
  # the template name. For example, "404.json" becomes
  # "Not Found".
  def render(template, _assigns) do
    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
  end
end

lib/hello_web/controllers/: a página inicial e as páginas do notebook 01.

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  def home(conn, _params) do
    render(conn, :home)
  end
end

defmodule HelloWeb.PageHTML do
  use HelloWeb, :html

  def home(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h1>Welcome to Phoenix!</h1>
    </Layouts.app>
    """
  end
end

defmodule HelloWeb.HelloController do
  use HelloWeb, :controller

  def index(conn, _params) do
    render(conn, :index)
  end

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

defmodule HelloWeb.HelloHTML do
  use HelloWeb, :html

  def index(assigns) do
    ~H"""
    <section>
      <h2>Hello World, from Phoenix!</h2>
    </section>
    """
  end

  def show(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <section>
        <h2>Hello World, from {@messenger}!</h2>
      </section>
    </Layouts.app>
    """
  end
end

lib/hello/messages.ex: as mensagens ficam guardadas em um Agent, um processo que mantém um valor. É só o apoio do exemplo; em um projeto elas viriam do banco de dados. clear/0 apaga todas e seed/0 recria as duas de exemplo.

defmodule Hello.Messages do
  use Agent

  @seeds %{
    "1" => %{id: "1", text: "Oi, Ana"},
    "2" => %{id: "2", text: "Oi, Bia"}
  }

  def start_link(_), do: Agent.start_link(fn -> @seeds end, name: __MODULE__)

  def list, do: Agent.get(__MODULE__, fn mensagens -> mensagens |> Map.values() |> Enum.sort_by(& &1.id) end)
  def find_message(id), do: Agent.get(__MODULE__, &Map.get(&1, id))
  def clear, do: Agent.update(__MODULE__, fn _ -> %{} end)
  def seed, do: Agent.update(__MODULE__, fn _ -> @seeds end)
end

lib/hello_web.ex, no fim do arquivo: a exceção do guia, ainda na forma mais simples.

defmodule HelloWeb.SomethingNotFoundError do
  defexception [:message]
end

lib/hello_web/controllers/message_controller.ex, message_html.ex e message_json.ex: quando a mensagem não existe, ou não há nenhuma, a action levanta a exceção.

defmodule HelloWeb.MessageController do
  use HelloWeb, :controller

  def index(conn, _params) do
    case Hello.Messages.list() do
      [] -> raise HelloWeb.SomethingNotFoundError, "there are no messages yet"
      messages -> render(conn, :index, messages: messages)
    end
  end

  def show(conn, %{"id" => id}) do
    case Hello.Messages.find_message(id) do
      nil -> raise HelloWeb.SomethingNotFoundError, "no message with id #{id}"
      message -> render(conn, :show, message: message)
    end
  end
end

defmodule HelloWeb.MessageHTML do
  use HelloWeb, :html

  def index(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h2>Messages</h2>
      <ul>
        <li :for={message <- @messages}>
          <a href={~p"/messages/#{message.id}"}>{message.text}</a>
        </li>
      </ul>
    </Layouts.app>
    """
  end

  def show(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <h2>Message {@message.id}</h2>
      <p>{@message.text}</p>
    </Layouts.app>
    """
  end
end

defmodule HelloWeb.MessageJSON do
  def show(%{message: message}) do
    %{data: %{id: message.id, text: message.text}}
  end
end

A célula abaixo inicia as mensagens e o endpoint, o equivalente a mix phx.server.

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

Kino.Markdown.new("Aplicação no ar em <http://localhost:4010>. Abra <http://localhost:4010/messages>: você deve ver as duas mensagens.")

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.

As views de erro

Na documentação: The Error Views

Projetos novos têm duas views de erro, ErrorHTML e ErrorJSON, em lib/hello_web/controllers/. Elas tratam os erros de um jeito geral para cada formato, a partir de um lugar só. Você já as avaliou na seção anterior. Quem as liga ao endpoint é a configuração render_errors, que está na primeira célula do notebook:

render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false]

Antes de mexer nelas, veja o que acontece hoje com uma rota que não existe:

Trilha.mostrar(:get, "/such/a/wrong/path")

A exceção é Phoenix.Router.NoRouteError, o status é 404, e quem escreveu a resposta não foi a ErrorHTML: foi a página de depuração do Plug.Debugger. Em desenvolvimento, o Phoenix depura os erros por padrão e mostra uma página detalhada, com a exceção, o stacktrace e os dados da requisição. Na versão HTML, para rotas inexistentes, ela lista também as rotas da aplicação.

O corpo veio em Markdown porque a requisição do Trilha não diz que aceita HTML. O navegador diz, e recebe a página completa. Compare:

Trilha.mostrar(:get, "/such/a/wrong/path", accept: "text/html")

Abra também http://localhost:4010/such/a/wrong/path no navegador.

A página de produção

Para ver a página que a aplicação serviria em produção, o guia desliga debug_errors no config/dev.exs.

Trilha.pergunta(:producao)

Sua vez

  1. Na célula config/dev.exs, na seção anterior, troque true por false e avalie a célula.
  2. Avalie a célula do endpoint, logo abaixo dela.

debug_errors é lido quando o módulo do endpoint é compilado: é nesse momento que use Phoenix.Endpoint decide se inclui o Plug.Debugger. Por isso basta recompilar o endpoint; o servidor que está no ar passa a usar a versão nova na requisição seguinte. No projeto, o guia manda reiniciar o servidor, e o efeito é o mesmo: a configuração é lida de novo e o endpoint é recompilado.

Trilha.mostrar(:get, "/such/a/wrong/path")
Trilha.conferir(:producao)
Trilha.solucao(:producao)

Não é uma página interessante: só a string Not Found, sem marcação nem estilo.

De onde vem o Not Found

Trilha.pergunta(:fallback)

A resposta está na ErrorHTML:

def render(template, _assigns) do
  Phoenix.Controller.status_message_from_template(template)
end

render/2 recebe um template e um mapa de assigns, que aqui é ignorado. Quando um controller chama render(conn, :some_template), o Phoenix procura primeiro uma função some_template/1 no módulo da view. Se ela não existe, recorre a render/2, passando o nome do template e o formato, por exemplo "some_template.html". Para os erros vale a mesma regra: o Phoenix renderiza o template com o nome do status, 404, e a ErrorHTML não tem uma função 404/1.

A célula abaixo renderiza alguns templates da ErrorHTML diretamente, sem requisição. status_message_from_template/1 lê o número no nome do template e devolve a mensagem padrão do HTTP para ele.

for template <- ["404", "500", "403", "401"] do
  {template, Phoenix.Template.render_to_string(HelloWeb.ErrorHTML, template, "html", %{})}
end

Para ter uma página de erro própria, então, basta definir uma cláusula de render/2 para o template certo.

Sua vez: uma cláusula para o 404

Na célula da HelloWeb.ErrorHTML, acrescente a cláusula do guia:

def render("404.html", _assigns) do
  "Page Not Found"
end

A posição importa. As cláusulas de uma função são testadas de cima para baixo, e a cláusula genérica, render(template, _assigns), casa com qualquer template.

Trilha.conferir(:clausula)
Trilha.solucao(:clausula)

Um template para o 404

O guia vai além da string e cria um template em lib/hello_web/controllers/error_html/404.html.heex, com uma página HTML completa. Estas são as mudanças em relação ao template do guia: foram retiradas as linhas que carregam CSS, JavaScript e a imagem do logo, porque a aplicação do notebook não serve arquivos estáticos.

A célula abaixo grava o arquivo. Avalie-a.

Trilha.arquivo("error_html/404.html.heex", ~S"""
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <title>Welcome to Phoenix!</title>
  </head>
  <body>
    <header>
      <section class="container">
        <nav>
          <ul>
            <li><a href="https://phoenix.hexdocs.pm/overview.html">Get Started</a></li>
          </ul>
        </nav>
      </section>
    </header>
    <main class="container">
      <section class="phx-hero">
        <p>Sorry, the page you are looking for does not exist.</p>
      </section>
    </main>
  </body>
</html>
""")

O arquivo sozinho não muda nada. É preciso dizer ao Phoenix para embutir os templates da pasta no módulo, com embed_templates. Na compilação, cada arquivo vira uma função com o nome dele: 404.html.heex vira 404/1.

Trilha.pergunta(:precedencia)

Sua vez: embutir o template

Na célula da HelloWeb.ErrorHTML:

  1. descomente a linha embed_templates "error_html/*", root: Trilha.pasta();
  2. remova a cláusula render("404.html", _assigns) que você acabou de escrever.
Trilha.conferir(:template)

Abra http://localhost:4010/such/a/wrong/path no navegador: a página nova aparece no lugar da string.

Trilha.solucao(:template)

Para conferir a pergunta acima por conta própria, devolva a cláusula render("404.html", _assigns) à ErrorHTML mantendo o embed_templates, avalie a célula e rode Trilha.mostrar(:get, "/such/a/wrong/path"). A página continua sendo a do template. Depois remova a cláusula de novo.

Experimente: os assigns de um template de erro

O template de erro recebe assigns, como qualquer template. Entre eles estão @status, o status da resposta, e @reason, a exceção. Na célula que grava o arquivo, acrescente <p>Status: {@status}</p> logo depois do parágrafo Sorry..., avalie essa célula e depois a da ErrorHTML (o embed_templates lê os arquivos na compilação), e rode:

Trilha.mostrar(:get, "/such/a/wrong/path")

Mostrar @reason para o usuário não é uma boa ideia em produção: a mensagem da exceção pode expor detalhes internos da aplicação.

Sem o layout da aplicação

Trilha.pergunta(:layout)

O template 404 não foi renderizado pelo layout da aplicação, embora a página de erro devesse ter a cara do resto do site. É de propósito: evita erros em cascata. Se a aplicação falhou por causa de um erro no layout, tentar renderizar o layout de novo só dispara outro erro. O ideal é reduzir ao mínimo as dependências e a lógica dos templates de erro e compartilhar só o necessário.

Erros em JSON

A ErrorJSON funciona do mesmo jeito, mas render/2 devolve um mapa, que o Phoenix converte em JSON. Qual das duas views responde depende do formato da requisição.

Trilha.mostrar(:get, "/such/a/wrong/path", accept: "application/json")

O parâmetro _format tem o mesmo efeito do cabeçalho:

Trilha.mostrar(:get, "/such/a/wrong/path?_format=json")
Trilha.pergunta(:formato_sem_rota)
Trilha.mostrar(:get, "/api/nada")

O caminho começa com /api, mas nenhuma rota casou e a pipeline :api não rodou. Quando uma rota casa, o formato já foi negociado pela pipeline, e o erro sai nele. Você vai ver isso na seção seguinte, com /api/messages/99.

Sua vez: o 404 em JSON

Na célula da HelloWeb.ErrorJSON, faça o template 404.json devolver %{errors: %{detail: "Recurso não encontrado"}}. Os outros status continuam com a mensagem padrão. O comentário no topo da célula mostra a forma da cláusula.

Trilha.conferir(:json)
Trilha.solucao(:json)

Exceções próprias

Na documentação: Custom exceptions

O Elixir tem a macro defexception/1 para definir exceções. Exceções são structs, e structs precisam ser definidas dentro de módulos. Para criar uma exceção, define-se um módulo novo, por convenção com "Error" no nome, e dentro dele defexception/1. O guia sugere o arquivo lib/hello_web.ex. É a célula que você avaliou na seção da aplicação:

defmodule HelloWeb.SomethingNotFoundError do
  defexception [:message]
end

Ela é levantada assim: raise HelloWeb.SomethingNotFoundError, "oops". A célula abaixo levanta a exceção e a captura, para mostrar que o valor levantado é uma struct comum.

try do
  raise HelloWeb.SomethingNotFoundError, "oops"
rescue
  erro -> {erro, Exception.message(erro)}
end

O MessageController levanta essa exceção quando a mensagem pedida não existe.

Trilha.pergunta(:status_padrao)
Trilha.mostrar(:get, "/messages/99")
Plug.Exception.status(%HelloWeb.SomethingNotFoundError{message: "oops"})

Por padrão, Plug e Phoenix tratam todas as exceções como erros 500. O Plug oferece um protocolo, Plug.Exception, com o qual uma exceção define o próprio status e as ações que pode oferecer na página de depuração. É ele que a última célula consultou.

Para que HelloWeb.SomethingNotFoundError responda com 404, há dois caminhos. O primeiro é implementar o protocolo, também em lib/hello_web.ex:

defimpl Plug.Exception, for: HelloWeb.SomethingNotFoundError do
  def status(_exception), do: 404
  def actions(_exception), do: []
end

O segundo é definir um campo plug_status na própria struct da exceção:

defmodule HelloWeb.SomethingNotFoundError do
  defexception [:message, plug_status: 404]
end

Phoenix.Router.NoRouteError, a exceção das rotas inexistentes, usa o segundo caminho. Implementar o protocolo à mão é útil em algumas situações, como para oferecer erros acionáveis, assunto da próxima seção.

Sua vez: um 404 para a exceção

Implemente o protocolo na célula abaixo (o primeiro caminho) ou acrescente o campo plug_status à célula da exceção, na seção da aplicação (o segundo). Em seguida, confira.

# lib/hello_web.ex: implemente aqui Plug.Exception para HelloWeb.SomethingNotFoundError.
Trilha.conferir(:status_404)

Agora as duas versões de /messages/99. A resposta é a mesma página 404 da seção anterior, porque é a mesma view de erro renderizando o template 404. Na rota da API, o formato já tinha sido negociado pela pipeline :api, e quem responde é a ErrorJSON.

Trilha.mostrar(:get, "/messages/99")
Trilha.mostrar(:get, "/api/messages/99")
Trilha.solucao(:status_404)

Erros acionáveis

Na documentação: Actionable errors

Ações de exceção são funções que podem ser disparadas a partir da página de erro. Elas são uma lista de mapas, cada um com um label e um handler a executar. O Phoenix, por exemplo, mostra um erro quando há migrations pendentes e oferece na página um botão que as executa.

Quando debug_errors é true, as ações aparecem na página de erro como uma coleção de botões, e seguem este formato:

[
  %{
    label: String.t(),
    handler: {module(), function :: atom(), args :: []}
  }
]

O handler é uma tupla de módulo, função e argumentos, e não uma função anônima, porque ele vai e volta pelo navegador: o botão é um formulário que envia o handler assinado com o secret_key_base do endpoint, e o Plug.Debugger só o executa se a assinatura conferir.

No guia, a ação roda o arquivo de seeds ({Code, :eval_file, ["priv/repo/seeds.exs"]}). O notebook não tem banco nem arquivo de seeds, e o equivalente é Hello.Messages.seed/0, que recria as mensagens de exemplo.

Trilha.pergunta(:acoes_producao)

Sua vez: o botão "Run seeds"

  1. Na célula config/dev.exs, volte para Trilha.config(debug_errors: true), avalie-a e avalie a célula do endpoint. Sem o Plug.Debugger não há botões.
  2. Na célula do defimpl da seção anterior, troque actions/1 para devolver a ação abaixo. Se você usou o campo plug_status, escreva agora o defimpl completo, com status/1 devolvendo 404.
def actions(_exception) do
  [
    %{
      label: "Run seeds",
      handler: {Hello.Messages, :seed, []}
    }
  ]
end
  1. Apague as mensagens com a célula abaixo. Sem mensagens, GET /messages levanta a exceção.
Hello.Messages.clear()
  1. Abra http://localhost:4010/messages no navegador. A página de depuração mostra o botão Run seeds. Clique nele: o Plug.Debugger executa Hello.Messages.seed() e redireciona de volta para /messages, que agora lista as mensagens.

A conferência faz o mesmo caminho sem o navegador: apaga as mensagens, pede /messages aceitando HTML, encontra o formulário do botão na página e o envia.

Trilha.conferir(:acionavel)
Trilha.solucao(:acionavel)

A versão em Markdown da página de depuração, a que o Trilha recebe sem pedir HTML, não tem os botões. Confira:

Hello.Messages.clear()
Trilha.mostrar(:get, "/messages")
Hello.Messages.seed()

Sem roteiro: o item vendido

O Brechó, o mini-marketplace da trilha, precisa de uma página para itens que já foram vendidos. Uma página 404 estaria errada: o item existiu, só não está mais disponível. Para isso o HTTP tem o status 410, Gone.

A célula abaixo é o catálogo de exemplo. Avalie-a.

defmodule Hello.Catalogo do
  @produtos %{
    "1" => %{id: "1", nome: "Jaqueta jeans", vendido: false},
    "2" => %{id: "2", nome: "Vitrola", vendido: true}
  }

  def buscar(id), do: Map.get(@produtos, id)
end

Faça, sem roteiro:

  • uma rota GET /produtos/:id, um HelloWeb.ProdutoController e uma view que mostra o nome do produto;
  • uma exceção HelloWeb.ProdutoVendidoError com status 410, levantada pela action quando o produto está vendido;
  • para um id que não existe, a action levanta HelloWeb.SomethingNotFoundError, que já responde 404;
  • um template error_html/410.html.heex, gravado com Trilha.arquivo/2, com o texto Este item já foi vendido.

A conferência olha o corpo das páginas de erro, então deixe debug_errors em false antes de conferir.

# lib/hello_web/controllers/produto_controller.ex, produto_html.ex e o template 410
Trilha.conferir(:vendido)
Trilha.solucao(:vendido)

No seu projeto

Repita o guia em um projeto gerado por mix phx.new hello. A correspondência é esta:

Neste notebook No projeto
Trilha.config(debug_errors: false) debug_errors: false em config/dev.exs e reiniciar o servidor
Trilha.arquivo("error_html/404.html.heex", ...) Criar lib/hello_web/controllers/error_html/404.html.heex
embed_templates "error_html/*", root: ... embed_templates "error_html/*"
Trilha.mostrar(:get, caminho, accept: ...) curl -i -H "accept: application/json" http://localhost:4000/...
Célula da exceção e do defimpl Fim de lib/hello_web.ex

Roteiro:

  1. Com debug_errors: false, acesse http://localhost:4000/such/a/wrong/path e veja o Not Found.
  2. Crie a pasta error_html, o template 404.html.heex, e descomente o embed_templates em lib/hello_web/controllers/error_html.ex. Recarregue a página.
  3. Rode curl -i http://localhost:4000/such/a/wrong/path com e sem o cabeçalho accept: application/json e compare o content-type das respostas.
  4. Defina HelloWeb.SomethingNotFoundError, levante-a em uma action e observe no log do servidor o status com que a resposta sai, antes e depois do defimpl Plug.Exception.
  5. Volte debug_errors para true antes de continuar a trabalhar. A página de depuração é a melhor ferramenta para entender um erro em desenvolvimento.

O projeto gerado tem uma pasta priv/repo com seeds.exs, e a ação do guia ({Code, :eval_file, ["priv/repo/seeds.exs"]}) funciona como está escrita.

Revisão

Escreva em uma célula de texto, com as suas palavras: o que acontece entre uma action levantar uma exceção e o navegador receber a página de erro, em desenvolvimento e em produção. Depois compare com os pontos abaixo.

  • Com debug_errors: true, o Plug.Debugger responde com a página de depuração, em HTML para quem aceita HTML e em Markdown para os demais. É a configuração de desenvolvimento.
  • Com debug_errors: false, quem responde é a view de erro configurada em render_errors para o formato da requisição: ErrorHTML ou ErrorJSON.
  • O nome do template de erro é o status: 404, 500. O Phoenix procura primeiro a função do template (404/1) e depois render/2 com "404.html".
  • embed_templates transforma cada arquivo .heex da pasta em uma função com o nome do arquivo. Um template embutido tem precedência sobre uma cláusula de render/2.
  • Páginas de erro não usam o layout, para que um erro no layout não impeça a página de erro de aparecer.
  • Sem rota, nenhuma pipeline roda, e o formato do erro vem do cabeçalho Accept ou de _format. Com rota, ele vem da negociação feita pela pipeline.
  • Toda exceção vira 500, a menos que implemente Plug.Exception ou tenha o campo plug_status.
  • As ações de Plug.Exception.actions/1 aparecem como botões só na página de depuração. O handler é uma tupla de módulo, função e argumentos.

Próximo notebook: 06_componentes_e_heex.livemd, sobre function components, HEEx e layouts.