Powered by AppSignal & Oban Pro

Trilha Phoenix · Plug

02_plug.livemd

Trilha Phoenix · Plug

Mix.install(
  [
    {:phoenix, "~> 1.8.0"},
    {:phoenix_live_view, "~> 1.1"},
    {:phoenix_ecto, "~> 4.6"},
    {:ecto_sql, "~> 3.13"},
    {:postgrex, ">= 0.0.0"},
    {:bandit, "~> 1.5"},
    {:jason, "~> 1.4"},
    {:kino, "~> 0.14"}
  ],
  consolidate_protocols: false,
  config: [
    phoenix: [json_library: Jason, plug_init_mode: :runtime],
    hello: [
      {HelloWeb.Endpoint,
       [
         adapter: Bandit.PhoenixAdapter,
         http: [ip: {127, 0, 0, 1}, port: 4010],
         server: true,
         debug_errors: true,
         secret_key_base: String.duplicate("trilha-phoenix-", 5),
         render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false]
       ]}
    ]
  ]
)

defmodule Trilha do
  @moduledoc """
  Apoio deste notebook: faz requisições ao endpoint, mostra o resultado,
  confere os exercícios e guarda as perguntas e as soluções.

  Não faz parte do Phoenix. Você não precisa ler este módulo para seguir o guia.
  """

  @endpoint HelloWeb.Endpoint
  @router HelloWeb.Router

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

  @doc "Despacha uma requisição para o endpoint, sem passar pela rede."
  def requisicao(metodo \\ :get, caminho) do
    descartar_respostas_antigas()

    conn =
      Phoenix.ConnTest.build_conn()
      |> Phoenix.ConnTest.dispatch(@endpoint, metodo, caminho, nil)

    # Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
    # devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
    if conn.state == :sent and conn.status == nil do
      {:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
    else
      {:ok, conn}
    end
  rescue
    erro -> {:erro, desembrulhar(erro)}
  end

  # Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
  # processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
  # próxima página de erro já foi enviada e não a renderiza.
  defp descartar_respostas_antigas do
    receive do
      {ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
        descartar_respostas_antigas()

      {:plug_conn, :sent} ->
        descartar_respostas_antigas()
    after
      0 -> :ok
    end
  end

  defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
  defp desembrulhar(erro), do: erro

  @doc "O que o router decide para uma requisição, sem executá-la."
  def rota(metodo \\ :get, caminho) do
    so_caminho = caminho |> String.split("?") |> hd()
    Phoenix.Router.route_info(@router, verbo(metodo), so_caminho, "localhost")
  rescue
    _ -> :error
  end

  defp verbo(metodo), do: metodo |> to_string() |> String.upcase()

  @doc "Faz a requisição e mostra o que o router decidiu e como o conn terminou."
  def mostrar(metodo \\ :get, caminho) do
    if is_binary(caminho) and String.starts_with?(caminho, "/") do
      Kino.Markdown.new(
        Enum.join(
          ["### #{verbo(metodo)} #{caminho}", md_rota(rota(metodo, caminho)), md_resultado(requisicao(metodo, caminho))],
          "\n\n"
        )
      )
    else
      Kino.Markdown.new("O caminho precisa começar com `/`, por exemplo `/hello`.")
    end
  end

  defp md_rota(:error) do
    "**Router** — `Phoenix.Router.route_info/4` devolveu `:error`: nenhuma rota casa com esse verbo e esse caminho."
  end

  defp md_rota(info) do
    tabela(
      "**Router** — o que `Phoenix.Router.route_info/4` devolve",
      [
        {":route", info.route},
        {":plug", info.plug},
        {":plug_opts", info.plug_opts},
        {":path_params", info.path_params},
        {":pipe_through", info.pipe_through}
      ]
    )
  end

  defp md_resultado({:erro, erro}) do
    "**Resultado** — a requisição levantou uma exceção:\n\n" <>
      cerca(Exception.format_banner(:error, erro), "")
  end

  defp md_resultado({:ok, conn}) do
    tabela(
      "**O `conn` no fim da viagem**",
      [
        {"conn.status", conn.status},
        {"conn.state", conn.state},
        {"conn.path_params", conn.path_params},
        {"conn.params", conn.params},
        {"conn.assigns", conn.assigns},
        {"conn.private.phoenix_controller", conn.private[:phoenix_controller]},
        {"conn.private.phoenix_action", conn.private[:phoenix_action]},
        {"conn.private.phoenix_view", conn.private[:phoenix_view]},
        {"conn.private.phoenix_template", conn.private[:phoenix_template]},
        {"content-type", Plug.Conn.get_resp_header(conn, "content-type")}
      ]
    ) <> "\n\n**`conn.resp_body`**\n\n" <> cerca(to_string(conn.resp_body), "html")
  end

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

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

  defp cerca(texto, linguagem) do
    marca = String.duplicate("`", 3)
    marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
  end

  # --- perguntas -------------------------------------------------------------

  @doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
  def pergunta(id) do
    %{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)

    quadro = Kino.Frame.new()

    letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
    lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)

    formulario =
      Kino.Control.form(
        [resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
        submit: "Conferir"
      )

    Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
      retorno =
        cond do
          resposta == nil -> "Escolha uma opção antes de conferir."
          resposta == correta -> "**Certo.** " <> explicacao
          true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
        end

      Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
    end)

    Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
  end

  defp perguntas do
    %{
      plug_valido: %{
        texto: "qual destas funções cumpre o contrato de um plug de função?",
        opcoes: [
          a: "`def saudar(conn, _opts), do: \"Olá\"`",
          b: "`def saudar(params), do: Map.put(params, :saudacao, \"Olá\")`",
          c: "`def saudar(conn, _opts), do: IO.puts(\"Olá\")`",
          d: "`def saudar(conn, _opts), do: Plug.Conn.assign(conn, :saudacao, \"Olá\")`"
        ],
        correta: :d,
        explicacao:
          "O contrato tem duas partes: receber a struct `%Plug.Conn{}` e as opções, e devolver uma struct `%Plug.Conn{}`. A opção (c) recebe os argumentos certos, mas `IO.puts/1` devolve `:ok`; o plug seguinte receberia `:ok` no lugar do conn, e o Plug levanta um erro dizendo que esperava um `Plug.Conn`."
      },
      locale_bruto: %{
        texto: "a célula a seguir cria um conn com `Plug.Test.conn(:get, \"/?locale=fr\")` e o passa direto para `HelloWeb.Plugs.Locale.call/2`, com o padrão `\"en\"`. Qual será o valor de `conn.assigns.locale`?",
        opcoes: [
          a: "`\"fr\"`, porque a query string tem `locale=fr`.",
          b: "`\"en\"`, porque a primeira cláusula de `call/2` não casa.",
          c: "Nenhum: a chamada levanta um erro.",
          d: "`nil`."
        ],
        correta: :b,
        explicacao:
          "Um conn recém-criado tem `params: %Plug.Conn.Unfetched{}`. A query string está em `conn.query_string`, mas ainda não foi lida para `conn.params`. O padrão `%Plug.Conn{params: %{\"locale\" => loc}}` não casa com uma struct `Unfetched`, e a segunda cláusula aplica o valor padrão. Na aplicação isso não acontece, porque o `Plug.Parsers` do endpoint lê os parâmetros antes de o router rodar."
      },
      init_call: %{
        texto: "um plug de módulo é usado em uma pipeline que atende mil requisições. Quantas vezes `init/1` e `call/2` são chamadas, com a configuração padrão do Plug?",
        opcoes: [
          a: "`init/1` mil vezes e `call/2` mil vezes.",
          b: "`init/1` uma vez, quando a pipeline é compilada, e `call/2` mil vezes.",
          c: "`init/1` mil vezes e `call/2` uma vez.",
          d: "As duas uma vez só; o resultado é guardado em cache."
        ],
        correta: :b,
        explicacao:
          "Por padrão, o `Plug.Builder` chama `init/1` em tempo de compilação e embute o resultado na pipeline; `call/2` roda a cada requisição com esse resultado. Por isso o trabalho caro de preparação fica em `init/1`. Em desenvolvimento, projetos Phoenix configuram `plug_init_mode: :runtime`, e aí `init/1` também roda a cada requisição, para que mudanças de configuração valham sem recompilar."
      },
      onde_roda: %{
        texto: "o plug `HelloWeb.Plugs.Locale` está na pipeline `:browser` do router. Chega `GET /nada?locale=fr`, que não casa com nenhuma rota. O plug é executado?",
        opcoes: [
          a: "Sim, porque todo plug do router roda em toda requisição.",
          b: "Sim, mas o resultado é descartado.",
          c: "Não. As pipelines só rodam depois que uma rota casa.",
          d: "Depende da ordem das rotas no router."
        ],
        correta: :c,
        explicacao:
          "Quando uma rota casa, o Phoenix invoca os plugs de todas as pipelines associadas a ela. Sem rota, não há pipeline a invocar: o router levanta `NoRouteError` antes. Os plugs do endpoint, ao contrário, já rodaram, porque vêm antes do router."
      },
      ordem_controller: %{
        texto: "uma rota passa pela pipeline `:browser`, e o controller declara `plug :verificar`. Em que ordem as coisas rodam?",
        opcoes: [
          a: "`:verificar`, depois a pipeline `:browser`, depois a action.",
          b: "A pipeline `:browser`, depois a action, depois `:verificar`.",
          c: "A pipeline `:browser`, depois `:verificar`, depois a action.",
          d: "A ordem não é garantida."
        ],
        correta: :c,
        explicacao:
          "O router executa as pipelines da rota e então despacha para o controller. O controller é ele mesmo um plug com uma pipeline própria: os plugs declarados nele rodam na ordem em que aparecem, e a action é o último passo."
      },
      halt: %{
        texto: "em uma pipeline com os plugs `:a`, `:b` e `:c`, nessa ordem, o plug `:b` devolve `halt(conn)` sem definir nenhuma resposta. O que acontece?",
        opcoes: [
          a: "`:c` não é invocado, e nenhuma resposta foi definida no conn.",
          b: "`:c` não é invocado, e o Plug envia automaticamente uma resposta 403.",
          c: "`:c` é invocado, mas não pode alterar o conn.",
          d: "A requisição é encerrada com uma exceção."
        ],
        correta: :a,
        explicacao:
          "`halt/1` só marca o campo `halted` do conn como `true`. A pipeline consulta esse campo depois de cada plug e deixa de invocar os seguintes. `halt/1` não envia nem escolhe resposta: quem interrompe deve antes definir uma, por exemplo com `redirect/2` ou `send_resp/3`."
      }
    }
  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
    %{
      funcao: ~S'''
      defmodule Exercicio do
        import Plug.Conn

        def marcar(conn, _opts) do
          put_resp_header(conn, "x-trilha", "plug")
        end
      end
      ''',
      introspect: ~S'''
      # No fim da célula do endpoint:
        plug :introspect
        plug HelloWeb.Router

        def introspect(conn, _opts) do
          IO.puts("""
          Verb: #{inspect(conn.method)}
          Host: #{inspect(conn.host)}
          Headers: #{inspect(conn.req_headers)}
          """)

          conn
        end
      end
      ''',
      locale: ~S'''
      # No router, no fim da pipeline :browser:
      plug HelloWeb.Plugs.Locale, "en"

      # No PageHTML, dentro de <Layouts.app>, depois do </h1>:
      <p>Locale: {@locale}</p>
      ''',
      controller_plug: ~S'''
      defmodule HelloWeb.HelloController do
        use HelloWeb, :controller

        plug :marcar_origem when action in [:show]

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

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

        defp marcar_origem(conn, _opts) do
          assign(conn, :origem, "controller")
        end
      end
      ''',
      mensagens: ~S'''
      defp authorize_message(conn, _) do
        if Hello.Authorizer.can_access?(conn.assigns[:user], conn.assigns[:message]) do
          conn
        else
          conn
          |> put_flash(:info, "You can't access that page")
          |> redirect(to: "/")
          |> halt()
        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
    %{
      funcao: [
        {"O módulo `Exercicio` define `marcar/2`.",
         fn ->
           if Code.ensure_loaded?(Exercicio) and function_exported?(Exercicio, :marcar, 2),
             do: :ok,
             else: {:erro, "Defina `def marcar(conn, _opts)` no módulo `Exercicio` e avalie a célula."}
         end},
        {"`marcar/2` devolve um `%Plug.Conn{}`.",
         fn ->
           case apply(Exercicio, :marcar, [Plug.Test.conn(:get, "/"), []]) do
             %Plug.Conn{} -> :ok
             outro -> {:erro, "A função devolveu `#{inspect(outro, limit: 5)}`. Um plug sempre devolve o conn."}
           end
         end},
        {"O conn devolvido tem o cabeçalho de resposta `x-trilha: plug`.",
         fn ->
           conn = apply(Exercicio, :marcar, [Plug.Test.conn(:get, "/"), []])

           case Plug.Conn.get_resp_header(conn, "x-trilha") do
             ["plug"] -> :ok
             outro -> {:erro, "`get_resp_header(conn, \"x-trilha\")` devolveu `#{inspect(outro)}`. Use `Plug.Conn.put_resp_header/3`."}
           end
         end}
      ],
      locale: [
        {"`GET /` termina com `conn.assigns.locale` igual a `\"en\"`.", fn -> assign_igual("/", :locale, "en") end},
        {"`GET /?locale=fr` termina com `conn.assigns.locale` igual a `\"fr\"`.", fn -> assign_igual("/?locale=fr", :locale, "fr") end},
        {"`GET /?locale=xx` volta ao padrão `\"en\"`.", fn -> assign_igual("/?locale=xx", :locale, "en") end},
        {"A página inicial mostra `Locale: fr` em `GET /?locale=fr`.", fn -> contem("/?locale=fr", "Locale: fr") end}
      ],
      controller_plug: [
        {"`GET /hello/Frank` termina com `conn.assigns.origem` igual a `\"controller\"`.",
         fn -> assign_igual("/hello/Frank", :origem, "controller") end},
        {"`GET /hello` não tem o assign `:origem`.",
         fn ->
           case requisicao(:get, "/hello") do
             {:ok, conn} ->
               if Map.has_key?(conn.assigns, :origem),
                 do: {:erro, "O plug rodou também na action `index`. Restrinja-o com `when action in [:show]`."},
                 else: :ok

             {:erro, erro} ->
               {:erro, explicar(erro)}
           end
         end}
      ],
      mensagens: [
        {"`GET /messages/1?user=ana` mostra a mensagem da Ana.", fn -> contem("/messages/1?user=ana", "Oi, Ana") end},
        {"`GET /messages/1`, sem usuário, é redirecionada para `/` e interrompida.", fn -> redirecionada("/messages/1") end},
        {"`GET /messages/99?user=ana`, mensagem inexistente, é redirecionada e interrompida.", fn -> redirecionada("/messages/99?user=ana") end},
        {"`GET /messages/2?user=ana`, mensagem de outra pessoa, é redirecionada e interrompida.", fn -> redirecionada("/messages/2?user=ana") end}
      ]
    }
  end

  defp assign_igual(caminho, chave, esperado) do
    case requisicao(:get, caminho) do
      {:ok, 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}`. O plug está declarado no lugar pedido?"}
        end

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

  defp redirecionada(caminho) do
    case requisicao(:get, caminho) do
      {:ok, conn} ->
        cond do
          conn.status != 302 ->
            {:erro, "A resposta veio com status `#{inspect(conn.status)}`; o esperado é 302, o status de `redirect/2`."}

          Plug.Conn.get_resp_header(conn, "location") != ["/"] ->
            {:erro, "O redirecionamento deveria apontar para `/`."}

          not conn.halted ->
            {:erro, "O conn foi redirecionado, mas `conn.halted` é `false`. Falta chamar `halt/1`."}

          true ->
            :ok
        end

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

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

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

  defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
    "Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
  end

  defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
    nome = inspect(modulo)

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

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

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

      true ->
        "A requisição levantou `UndefinedFunctionError` para `#{nome}.#{funcao}/#{aridade}`."
    end
  end

  defp explicar(erro), do: "A requisição levantou uma exceção:\n\n  `" <> Exception.format_banner(:error, erro) <> "`"
end

Como usar este notebook

Este notebook acompanha o guia Plug da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. Ele parte do ponto em que o notebook anterior terminou: a aplicação Hello com as páginas /hello e /hello/:messenger.

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

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

O que é o Plug

Na documentação: Plug

O Plug está no centro da camada HTTP do Phoenix. Interagimos com plugs em todas as etapas do ciclo de vida da requisição, e os componentes principais do Phoenix (endpoints, routers e controllers) são, internamente, plugs.

O Plug é duas coisas:

  • uma especificação para módulos que podem ser compostos entre aplicações web;
  • uma camada de abstração sobre os adaptadores de conexão de diferentes servidores web (no projeto gerado, o Bandit).

A ideia básica é unificar o conceito de conexão sobre o qual se opera. Isso difere de outras camadas de middleware HTTP, como o Rack, em que requisição e resposta são objetos separados na pilha de middleware. No Plug existe uma única struct, %Plug.Conn{}, que contém os dados da requisição e os da resposta.

No nível mais simples, a especificação tem duas formas: plugs de função e plugs de módulo.

A aplicação Hello dentro do notebook

As células desta seção definem a aplicação no estado final do guia anterior. Avalie todas. Ao longo do notebook você vai editar o endpoint, o router, a página inicial e o HelloController.

lib/hello_web.ex

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

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

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

      import Plug.Conn
    end
  end

  def html do
    quote do
      use Phoenix.Component

      alias HelloWeb.Layouts
    end
  end

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

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

defmodule HelloWeb.Layouts do
  use Phoenix.Component

  def root(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Hello</title>
      </head>
      <body>
        {@inner_content}
      </body>
    </html>
    """
  end

  attr :flash, :map, default: %{}
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <header>
      <strong>Hello</strong> · layout da aplicação
    </header>
    <main>
      {render_slot(@inner_block)}
    </main>
    """
  end
end

lib/hello_web/controllers/: as páginas de erro, a página inicial e as páginas do guia anterior.

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

defmodule HelloWeb.ErrorJSON do
  def render(template, _assigns) do
    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
  end
end

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

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

defmodule HelloWeb.PageHTML do
  use HelloWeb, :html

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

lib/hello_web/controllers/hello_controller.ex e hello_html.ex

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_web/router.ex

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
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
{: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!*")

Plugs de função

Na documentação: Function plugs

Para atuar como plug, uma função precisa:

  1. aceitar uma struct de conexão (%Plug.Conn{}) como primeiro argumento e as opções da conexão como segundo;
  2. devolver uma struct de conexão.

Qualquer função que cumpra esses dois critérios serve. Este é o exemplo do guia:

defmodule Exemplo do
  def introspect(conn, _opts) do
    IO.puts("""
    Verb: #{inspect(conn.method)}
    Host: #{inspect(conn.host)}
    Headers: #{inspect(conn.req_headers)}
    """)

    conn
  end
end

A função:

  1. recebe uma conexão e as opções (que não usa);
  2. imprime algumas informações da conexão;
  3. devolve a conexão.

Como um plug é uma função comum, dá para chamá-lo diretamente. A célula abaixo cria um conn sem rede e o entrega ao plug. Observe a saída impressa e o valor devolvido.

conn = Plug.Test.conn(:get, "/hello")

devolvido = Exemplo.introspect(conn, [])

devolvido == conn

O resultado true confirma que este plug devolve o conn sem alterações. Um plug pode também devolver um conn modificado: como as estruturas de dados em Elixir são imutáveis, "modificar" significa devolver uma nova struct, e é ela que o plug seguinte recebe.

Trilha.pergunta(:plug_valido)

Sua vez: um plug que altera o conn

Escreva um plug de função marcar/2 que acrescente à resposta o cabeçalho x-trilha com o valor plug. A função de Plug.Conn para isso é put_resp_header/3. Todos os campos e funções da conexão estão na documentação de Plug.Conn.

defmodule Exercicio do
  import Plug.Conn

  # Defina aqui marcar/2.
end
Trilha.conferir(:funcao)
Trilha.solucao(:funcao)

O plug no endpoint

O guia coloca o introspect no endpoint, logo antes de a requisição ser delegada ao router. Plugs de função são conectados passando o nome da função como átomo:

defmodule HelloWeb.Endpoint do
  ...

  plug :introspect
  plug HelloWeb.Router

  def introspect(conn, _opts) do
    IO.puts """
    Verb: #{inspect(conn.method)}
    Host: #{inspect(conn.host)}
    Headers: #{inspect(conn.req_headers)}
    """

    conn
  end
end

Faça essa mudança na célula do endpoint: acrescente plug :introspect antes de plug HelloWeb.Router e defina a função introspect/2 dentro do módulo. Avalie a célula do endpoint e a que o inicia, depois rode:

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

A saída do IO.puts aparece acima do resultado da célula. Em um projeto, ela aparece no terminal do mix phx.server a cada requisição. Requisições feitas pelo navegador a http://localhost:4010 também passam pelo plug, mas são atendidas por outro processo, e a saída delas não é mostrada nesta célula.

Agora uma requisição para uma rota que não existe:

Trilha.requisicao(:get, "/nada")

O introspect imprimiu mesmo assim, e só depois veio o NoRouteError. Um plug do endpoint roda em todas as requisições, inclusive nas que o router vai recusar.

Trilha.solucao(:introspect)

Plugs de módulo

Na documentação: Module plugs

O plug de módulo define a transformação da conexão em um módulo. O módulo precisa implementar duas funções:

  • init/1, que inicializa os argumentos ou opções a serem passados para call/2;
  • call/2, que executa a transformação da conexão. call/2 é um plug de função como os anteriores: recebe o conn e as opções, devolve o conn.

O exemplo do guia é um plug que coloca a chave :locale na conexão, para uso posterior em outros plugs, nas actions e nas views.

lib/hello_web/plugs/locale.ex

defmodule HelloWeb.Plugs.Locale do
  import Plug.Conn

  @locales ["en", "fr", "de"]

  def init(default), do: default

  def call(%Plug.Conn{params: %{"locale" => loc}} = conn, _default) when loc in @locales do
    assign(conn, :locale, loc)
  end

  def call(conn, default) do
    assign(conn, :locale, default)
  end
end

Lendo o módulo:

  • init/1 recebe o locale padrão, usado quando não há um nos parâmetros, e o devolve sem alterações. O que init/1 devolve é o que call/2 recebe como segundo argumento.
  • call/2 tem duas cláusulas. A primeira usa pattern matching para extrair "locale" de conn.params e uma guarda (when loc in @locales) para aceitar só os valores previstos. A segunda cobre todos os outros casos e aplica o padrão.
  • assign/3 faz parte do módulo Plug.Conn e é a forma de guardar valores na estrutura do conn. Os valores ficam em conn.assigns.
Trilha.pergunta(:init_call)

Chamando o plug diretamente

Trilha.pergunta(:locale_bruto)
opts = HelloWeb.Plugs.Locale.init("en")
conn = Plug.Test.conn(:get, "/?locale=fr")

%{
  query_string: conn.query_string,
  params_antes: conn.params,
  assigns_depois: HelloWeb.Plugs.Locale.call(conn, opts).assigns
}

Agora leia os parâmetros antes de chamar o plug. Plug.Conn.fetch_query_params/1 é o que preenche conn.params a partir da query string.

conn = Plug.Test.conn(:get, "/?locale=fr") |> Plug.Conn.fetch_query_params()

%{
  params_antes: conn.params,
  assigns_depois: HelloWeb.Plugs.Locale.call(conn, opts).assigns
}

Troque fr por de e depois por xx na célula acima. Qual cláusula de call/2 trata cada caso?

Sua vez: o plug na pipeline

O guia conecta o plug ao router, acrescentando plug HelloWeb.Plugs.Locale, "en" no fim da pipeline :browser. O segundo argumento, "en", é o que init/1 recebe.

  1. Na célula do router, acrescente essa linha no fim da pipeline :browser.
  2. Na célula das páginas, altere o template home de HelloWeb.PageHTML para mostrar o assign depois do </h1>:
<p>Locale: {@locale}</p>

Avalie as duas células e confira:

Trilha.conferir(:locale)

Abra http://localhost:4010/ e depois http://localhost:4010/?locale=fr. O assign definido pelo plug na pipeline chegou ao template sem que o controller fizesse nada: o conn que o controller recebe já traz :locale em assigns.

Trilha.solucao(:locale)

Onde plugar

Na documentação: Where to plug

Endpoint, router e controllers aceitam plugs.

Plugs do endpoint

Na documentação: Endpoint plugs

O endpoint organiza os plugs comuns a todas as requisições e os aplica antes de despachar para o router, com suas pipelines específicas. Os plugs padrão do endpoint de um projeto gerado, em ordem:

  • Plug.Static: serve os arquivos estáticos. Como vem antes do logger, requisições de arquivos estáticos não são registradas no log.
  • Phoenix.LiveDashboard.RequestLogger: prepara o Request Logger do LiveDashboard, que permite transmitir os logs de requisições para o painel.
  • Plug.RequestId: gera um identificador único para cada requisição.
  • Plug.Telemetry: acrescenta pontos de instrumentação. É por meio deles que o Phoenix registra no log o caminho, o status e o tempo de cada requisição.
  • Plug.Parsers: lê o corpo da requisição quando há um parser conhecido para o tipo de conteúdo. Por padrão trata conteúdo URL-encoded, multipart e JSON (com Jason). Se o content-type não puder ser interpretado, o corpo fica intacto.
  • Plug.MethodOverride: converte o método para PUT, PATCH ou DELETE em requisições POST que tragam um parâmetro _method válido.
  • Plug.Head: converte requisições HEAD em GET.
  • Plug.Session: configura o gerenciamento de sessão. fetch_session/2 ainda precisa ser chamado explicitamente antes de usar a sessão; este plug só define como ela é buscada.

A aplicação do notebook tem todos, menos Plug.Static e o RequestLogger. No meio do endpoint gerado há ainda um bloco condicional, executado só em desenvolvimento:

if code_reloading? do
  socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
  plug Phoenix.LiveReloader
  plug Phoenix.CodeReloader
  plug Phoenix.Ecto.CheckRepoStatus, otp_app: :hello
end

Ele habilita o live reloading (mudanças em CSS são aplicadas no navegador sem recarregar a página), o code reloading (mudanças no código valem sem reiniciar o servidor) e a verificação do repositório (um erro legível quando o banco não está atualizado).

Dois desses plugs deixam marcas fáceis de observar. Rode e relacione cada valor com o plug que o produziu:

{:ok, conn} = Trilha.requisicao(:get, "/hello?nome=Ana")

%{
  "x-request-id (Plug.RequestId)" => Plug.Conn.get_resp_header(conn, "x-request-id"),
  "conn.params (Plug.Parsers)" => conn.params
}
{:ok, conn} = Trilha.requisicao(:head, "/hello")

%{metodo_visto_pelo_router: conn.method, status: conn.status}

O router só tem uma rota get "/hello", e mesmo assim a requisição HEAD foi atendida: o Plug.Head trocou o método antes de o conn chegar ao router.

Plugs do router

Na documentação: Router plugs

No router, os plugs são declarados dentro de pipelines. Rotas são definidas dentro de escopos, e um escopo pode passar por várias pipelines. Quando uma rota casa, o Phoenix invoca todos os plugs de todas as pipelines associadas a ela. Acessar /, por exemplo, passa pela pipeline :browser e invoca todos os plugs dela.

As pipelines são, elas mesmas, plugs. O guia de Routing discute isso e cada plug da pipeline :browser.

Trilha.pergunta(:onde_roda)

Confira nas duas requisições abaixo. Na primeira, a tabela do conn mostra :locale em conn.assigns. Na segunda, o introspect do endpoint imprime, e o router recusa a requisição antes de qualquer pipeline.

Trilha.mostrar(:get, "/hello/Frank?locale=de")
Trilha.mostrar(:get, "/nada?locale=de")

Plugs do controller

Na documentação: Controller plugs

Controllers também são plugs, então isto é válido:

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  plug HelloWeb.Plugs.Locale, "en"

Os plugs de controller têm um recurso próprio: podem ser executados só em determinadas actions.

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  plug HelloWeb.Plugs.Locale, "en" when action in [:index]

Nesse caso o plug só roda para a action index.

Trilha.pergunta(:ordem_controller)

Sua vez: um plug restrito a uma action

Na célula do HelloController:

  1. defina uma função privada marcar_origem/2 que coloque o assign :origem com o valor "controller";
  2. declare-a como plug, restrita à action show.

O use HelloWeb, :controller já importa Plug.Conn, então assign/3 está disponível.

Trilha.conferir(:controller_plug)
Trilha.solucao(:controller_plug)

Plugs como composição

Na documentação: Plugs as composition

Ao seguir o contrato do plug, a requisição vira uma série de transformações explícitas. Para ver o efeito disso, o guia imagina um cenário em que é preciso verificar uma série de condições e, se uma falhar, redirecionar ou interromper. Sem plugs, o resultado seria este:

defmodule HelloWeb.MessageController do
  use HelloWeb, :controller

  def show(conn, params) do
    case Authenticator.find_user(conn) do
      {:ok, user} ->
        case find_message(params["id"]) do
          nil ->
            conn |> put_flash(:info, "That message wasn't found") |> redirect(to: ~p"/")
          message ->
            if Authorizer.can_access?(user, message) do
              render(conn, :show, page: message)
            else
              conn |> put_flash(:info, "You can't access that page") |> redirect(to: ~p"/")
            end
        end
      :error ->
        conn |> put_flash(:info, "You must be logged in") |> redirect(to: ~p"/")
    end
  end
end

Poucos passos de autenticação e autorização já exigem aninhamento e duplicação. A versão com plugs converte cada bloco aninhado em uma transformação da sequência e usa halt(conn) em cada caminho de falha.

O que halt/1 faz

Antes da versão do guia, um experimento mínimo. O módulo abaixo usa Plug.Builder, a mesma base com que o endpoint monta a sua sequência de plugs. Cada plug registra a própria passagem em conn.assigns.passou_por.

defmodule Exemplo.Pipeline do
  use Plug.Builder

  plug :ler_parametros
  plug :exigir_senha
  plug :registrar_fim

  def ler_parametros(conn, _opts) do
    conn |> fetch_query_params() |> registrar(:ler_parametros)
  end

  def exigir_senha(conn, _opts) do
    if conn.params["senha"] == "abre" do
      registrar(conn, :exigir_senha)
    else
      conn |> registrar(:exigir_senha) |> halt()
    end
  end

  def registrar_fim(conn, _opts), do: registrar(conn, :registrar_fim)

  defp registrar(conn, nome) do
    assign(conn, :passou_por, (conn.assigns[:passou_por] || []) ++ [nome])
  end
end
Trilha.pergunta(:halt)
for caminho <- ["/?senha=abre", "/?senha=errada"] do
  conn = Plug.Test.conn(:get, caminho) |> Exemplo.Pipeline.call(Exemplo.Pipeline.init([]))

  %{caminho: caminho, passou_por: conn.assigns.passou_por, halted: conn.halted, status: conn.status}
end

No segundo caso, registrar_fim não aparece em passou_por, halted é true e status continua nil: a sequência foi interrompida e nenhuma resposta foi definida.

A versão do guia

Para o exemplo rodar, os três módulos abaixo fazem o papel de Authenticator, Authorizer e find_message do guia. O "usuário logado" vem do parâmetro user da query string, o que só serve para o exercício.

defmodule Hello.Authenticator do
  def find_user(%Plug.Conn{params: %{"user" => user}}) when user != "", do: {:ok, user}
  def find_user(_conn), do: :error
end

defmodule Hello.Messages do
  @messages %{
    "1" => %{id: "1", owner: "ana", text: "Oi, Ana"},
    "2" => %{id: "2", owner: "bia", text: "Oi, Bia"}
  }

  def find_message(id), do: Map.get(@messages, id)
end

defmodule Hello.Authorizer do
  def can_access?(user, message), do: message.owner == user
end

lib/hello_web/controllers/message_controller.ex e message_html.ex: a versão com plugs. Dois dos três plugs estão escritos como no guia (com "/" no lugar de ~p"/", que depende das rotas verificadas, assunto do guia de Routing). O terceiro é seu.

defmodule HelloWeb.MessageController do
  use HelloWeb, :controller

  plug :authenticate
  plug :fetch_message
  plug :authorize_message

  def show(conn, _params) do
    render(conn, :show, page: conn.assigns[:message])
  end

  defp authenticate(conn, _) do
    case Hello.Authenticator.find_user(conn) do
      {:ok, user} ->
        assign(conn, :user, user)

      :error ->
        conn |> put_flash(:info, "You must be logged in") |> redirect(to: "/") |> halt()
    end
  end

  defp fetch_message(conn, _) do
    case Hello.Messages.find_message(conn.params["id"]) do
      nil ->
        conn |> put_flash(:info, "That message wasn't found") |> redirect(to: "/") |> halt()

      message ->
        assign(conn, :message, message)
    end
  end

  # Sua vez: se o usuário em conn.assigns[:user] puder acessar a mensagem em
  # conn.assigns[:message] (Hello.Authorizer.can_access?/2), devolva o conn.
  # Caso contrário, defina o flash "You can't access that page", redirecione
  # para "/" e interrompa.
  defp authorize_message(conn, _) do
    conn
  end
end

defmodule HelloWeb.MessageHTML do
  use HelloWeb, :html

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

Acrescente a rota na célula do router, dentro do scope "/":

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

Depois complete authorize_message/2 e confira:

Trilha.conferir(:mensagens)
Trilha.solucao(:mensagens)

Observe o que mudou em relação à versão aninhada:

  • a action show ficou com uma linha, porque só roda quando as três condições já foram satisfeitas;
  • cada plug lê do conn o que o anterior deixou em assigns (:user, depois :message);
  • em cada caminho de falha, a ordem é definir a resposta (redirect/2) e então chamar halt/1. halt/1 é o que diz ao Plug para não invocar o plug seguinte.

Use o explorador para ver os quatro casos. Compare conn.assigns e conn.status entre eles.

formulario =
  Kino.Control.form(
    [caminho: Kino.Input.text("Caminho (GET)", default: "/messages/1?user=ana")],
    submit: "Enviar requisição"
  )

quadro = Kino.Frame.new()

Kino.listen(formulario, fn %{data: %{caminho: caminho}} ->
  Kino.Frame.render(quadro, Trilha.mostrar(:get, caminho))
end)

Kino.Layout.grid([formulario, quadro])

No seu projeto

  1. Acrescente o introspect ao lib/hello_web/endpoint.ex e observe o terminal do mix phx.server ao navegar. Depois remova-o.
  2. Crie lib/hello_web/plugs/locale.ex, conecte o plug à pipeline :browser e mostre @locale em lib/hello_web/controllers/page_html/home.html.heex.
  3. Mova o plug da pipeline para o PageController, primeiro sem restrição e depois com when action in [:home]. Verifique em /hello se o assign ainda existe em cada caso.
  4. Abra o lib/hello_web/endpoint.ex gerado e localize cada plug da lista desta lição, inclusive o bloco if code_reloading?.

Revisão

Escreva em uma célula de texto, com as suas palavras: o que é um plug, quais são as duas formas, em que três lugares ele pode ser declarado e o que halt/1 faz. Depois compare com os pontos abaixo.

  • Um plug de função recebe %Plug.Conn{} e opções, e devolve %Plug.Conn{}.
  • Um plug de módulo implementa init/1, que prepara as opções, e call/2, que é um plug de função.
  • Requisição e resposta vivem na mesma struct. Cada plug devolve um conn, e o seguinte o recebe.
  • Plugs do endpoint rodam em todas as requisições. Plugs de uma pipeline do router rodam só quando uma rota daquele escopo casa. Plugs de um controller rodam depois das pipelines e antes da action, e podem ser restritos a actions.
  • conn.params só existe depois que um plug lê os parâmetros; no endpoint, quem faz isso é o Plug.Parsers.
  • halt/1 marca o conn como interrompido, e os plugs seguintes da sequência não são invocados. Ele não define a resposta.
  • Endpoint, router, pipelines e controllers são plugs.

Para ir além: a documentação do projeto Plug lista os plugs prontos e as funções de Plug.Conn.