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:
- aceitar uma struct de conexão (
%Plug.Conn{}) como primeiro argumento e as opções da conexão como segundo; - 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:
- recebe uma conexão e as opções (que não usa);
- imprime algumas informações da conexão;
- 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 paracall/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/1recebe o locale padrão, usado quando não há um nos parâmetros, e o devolve sem alterações. O queinit/1devolve é o quecall/2recebe como segundo argumento.call/2tem duas cláusulas. A primeira usa pattern matching para extrair"locale"deconn.paramse 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/3faz parte do móduloPlug.Conne é a forma de guardar valores na estrutura do conn. Os valores ficam emconn.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.
- Na célula do router, acrescente essa linha no fim da pipeline
:browser. - Na célula das páginas, altere o template
homedeHelloWeb.PageHTMLpara 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_methodválido.Plug.Head: converte requisições HEAD em GET.Plug.Session: configura o gerenciamento de sessão.fetch_session/2ainda 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:
- defina uma função privada
marcar_origem/2que coloque o assign:origemcom o valor"controller"; - 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
showficou 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 chamarhalt/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
- Acrescente o
introspectaolib/hello_web/endpoint.exe observe o terminal domix phx.serverao navegar. Depois remova-o. - Crie
lib/hello_web/plugs/locale.ex, conecte o plug à pipeline:browsere mostre@localeemlib/hello_web/controllers/page_html/home.html.heex. - Mova o plug da pipeline para o
PageController, primeiro sem restrição e depois comwhen action in [:home]. Verifique em/hellose o assign ainda existe em cada caso. - Abra o
lib/hello_web/endpoint.exgerado e localize cada plug da lista desta lição, inclusive o blocoif 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, ecall/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.paramssó existe depois que um plug lê os parâmetros; no endpoint, quem faz isso é oPlug.Parsers.halt/1marca 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.