Trilha Phoenix · Ciclo de vida da requisição
Mix.install(
[
{:phoenix, "~> 1.8.0"},
{:phoenix_live_view, "~> 1.1"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:bandit, "~> 1.5"},
{:jason, "~> 1.4"},
{:kino, "~> 0.14"}
],
consolidate_protocols: false,
config: [
phoenix: [json_library: Jason, plug_init_mode: :runtime],
hello: [
{HelloWeb.Endpoint,
[
adapter: Bandit.PhoenixAdapter,
http: [ip: {127, 0, 0, 1}, port: 4010],
server: true,
debug_errors: true,
secret_key_base: String.duplicate("trilha-phoenix-", 5),
render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false]
]}
]
]
)
defmodule Trilha do
@moduledoc """
Apoio deste notebook: faz requisições ao endpoint, mostra o resultado,
confere os exercícios e guarda as perguntas e as soluções.
Não faz parte do Phoenix. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
# --- requisições -----------------------------------------------------------
@doc "Despacha uma requisição para o endpoint, sem passar pela rede."
def requisicao(metodo \\ :get, caminho) do
descartar_respostas_antigas()
conn =
Phoenix.ConnTest.build_conn()
|> Phoenix.ConnTest.dispatch(@endpoint, metodo, caminho, nil)
# Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
# devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
if conn.state == :sent and conn.status == nil do
{:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
else
{:ok, conn}
end
rescue
erro -> {:erro, desembrulhar(erro)}
end
# Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
# processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
# próxima página de erro já foi enviada e não a renderiza.
defp descartar_respostas_antigas do
receive do
{ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
descartar_respostas_antigas()
{:plug_conn, :sent} ->
descartar_respostas_antigas()
after
0 -> :ok
end
end
defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
defp desembrulhar(erro), do: erro
@doc "O que o router decide para uma requisição, sem executá-la."
def rota(metodo \\ :get, caminho) do
so_caminho = caminho |> String.split("?") |> hd()
Phoenix.Router.route_info(@router, verbo(metodo), so_caminho, "localhost")
rescue
_ -> :error
end
defp verbo(metodo), do: metodo |> to_string() |> String.upcase()
@doc "Faz a requisição e mostra o que o router decidiu e como o conn terminou."
def mostrar(metodo \\ :get, caminho) do
if is_binary(caminho) and String.starts_with?(caminho, "/") do
Kino.Markdown.new(
Enum.join(
["### #{verbo(metodo)} #{caminho}", md_rota(rota(metodo, caminho)), md_resultado(requisicao(metodo, caminho))],
"\n\n"
)
)
else
Kino.Markdown.new("O caminho precisa começar com `/`, por exemplo `/hello`.")
end
end
defp md_rota(:error) do
"**Router** — `Phoenix.Router.route_info/4` devolveu `:error`: nenhuma rota casa com esse verbo e esse caminho."
end
defp md_rota(info) do
tabela(
"**Router** — o que `Phoenix.Router.route_info/4` devolve",
[
{":route", info.route},
{":plug", info.plug},
{":plug_opts", info.plug_opts},
{":path_params", info.path_params},
{":pipe_through", info.pipe_through}
]
)
end
defp md_resultado({:erro, erro}) do
"**Resultado** — a requisição levantou uma exceção:\n\n" <>
cerca(Exception.format_banner(:error, erro), "")
end
defp md_resultado({:ok, conn}) do
tabela(
"**O `conn` no fim da viagem**",
[
{"conn.status", conn.status},
{"conn.state", conn.state},
{"conn.path_params", conn.path_params},
{"conn.params", conn.params},
{"conn.assigns", conn.assigns},
{"conn.private.phoenix_controller", conn.private[:phoenix_controller]},
{"conn.private.phoenix_action", conn.private[:phoenix_action]},
{"conn.private.phoenix_view", conn.private[:phoenix_view]},
{"conn.private.phoenix_template", conn.private[:phoenix_template]},
{"content-type", Plug.Conn.get_resp_header(conn, "content-type")}
]
) <> "\n\n**`conn.resp_body`**\n\n" <> cerca(to_string(conn.resp_body), "html")
end
defp tabela(titulo, pares) do
linhas =
Enum.map(pares, fn {campo, valor} ->
texto = valor |> inspect(limit: 30, printable_limit: 300) |> String.replace("|", "\\|") |> String.replace("`", "'")
"| `#{campo}` | `#{texto}` |"
end)
Enum.join([titulo, "", "| Campo | Valor |", "| --- | --- |" | linhas], "\n")
end
defp cerca(texto, linguagem) do
marca = String.duplicate("`", 3)
marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
end
# --- perguntas -------------------------------------------------------------
@doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
def pergunta(id) do
%{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)
quadro = Kino.Frame.new()
letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)
formulario =
Kino.Control.form(
[resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
submit: "Conferir"
)
Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
retorno =
cond do
resposta == nil -> "Escolha uma opção antes de conferir."
resposta == correta -> "**Certo.** " <> explicacao
true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
end
Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
end)
Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
end
defp perguntas do
%{
sem_rota: %{
texto: "o router acima só tem a rota `get \"/\", PageController, :home`. O que acontece com uma requisição `GET /hello`?",
opcoes: [
a: "O Phoenix responde com uma página em branco e status 200.",
b: "O Phoenix redireciona para a rota `/`.",
c: "O router levanta uma exceção, e a resposta tem status 404.",
d: "O servidor é encerrado e precisa ser iniciado de novo."
],
correta: :c,
explicacao:
"Quando nenhuma rota casa, o router levanta `Phoenix.Router.NoRouteError`. Essa exceção implementa o protocolo `Plug.Exception` com status 404, e é por isso que a resposta sai como 404. A exceção fica restrita ao processo que atende aquela requisição; o servidor continua atendendo as demais."
},
verbo_caminho: %{
texto: "seu JavaScript executa `fetch(\"/hello\", {method: \"POST\"})`. A rota `get \"/hello\", HelloController, :index` atende essa requisição?",
opcoes: [
a: "Sim, porque o caminho é o mesmo.",
b: "Não. A rota casa com o par verbo e caminho, e o verbo aqui é POST.",
c: "Sim, porque o Phoenix converte POST em GET quando não há corpo.",
d: "Depende do que a action `index` fizer."
],
correta: :b,
explicacao:
"O router mapeia pares de verbo e caminho. `get \"/hello\"` só casa com `GET /hello` (e com `HEAD /hello`, que o `Plug.Head` do endpoint converte em GET). Para atender `POST /hello` é preciso declarar `post \"/hello\", ...`."
},
controller_falta: %{
texto: "a rota `get \"/hello\", HelloController, :index` já existe, mas o módulo `HelloWeb.HelloController` ainda não. O router aceita a rota mesmo assim?",
opcoes: [
a: "Não. O router não compila enquanto o controller não existir.",
b: "Sim. O router guarda só o nome do módulo; o erro aparece quando uma requisição chega a essa rota.",
c: "Sim, e o Phoenix cria um controller vazio automaticamente.",
d: "Sim, e a requisição responde 404 como se a rota não existisse."
],
correta: :b,
explicacao:
"A rota registra o módulo e a action como dados. O compilador pode emitir um aviso de módulo inexistente, mas o router compila. A falha acontece em tempo de execução, quando o router tenta despachar para o controller: um `UndefinedFunctionError`, que vira status 500, diferente do 404 de rota inexistente."
},
view_nome: %{
texto: "a action `index` do `HelloWeb.HelloController` chama `render(conn, :index)` em uma requisição HTML. Qual função o Phoenix invoca para produzir o HTML?",
opcoes: [
a: "`HelloWeb.HelloController.index/1`",
b: "`HelloWeb.HelloView.render/2`",
c: "`HelloWeb.HelloHTML.index/1`",
d: "`HelloWeb.Layouts.index/1`"
],
correta: :c,
explicacao:
"Com `use Phoenix.Controller, formats: [:html, :json]`, o nome da view é derivado do nome do controller e do formato: `HelloController` + `:html` resulta em `HelloHTML`. O template `:index` é a função `index/1` desse módulo, que recebe os assigns."
},
camadas: %{
texto: "você quer que uma verificação aconteça em toda requisição que chega à aplicação, qualquer que seja a rota. Em qual camada ela deve ficar?",
opcoes: [
a: "No endpoint.",
b: "No router, dentro de um `scope`.",
c: "Em cada controller.",
d: "Na view."
],
correta: :a,
explicacao:
"O endpoint contém o caminho inicial e comum a todas as requisições. O router já é específico: só as rotas de um `scope` passam pelas pipelines dele. Uma requisição que não casa com nenhuma rota nunca chega a uma pipeline, mas já passou pelo endpoint."
},
chaves: %{
texto: "com a rota `get \"/hello/:messenger\", HelloController, :show`, chega `GET /hello/Frank?lang=pt`. Qual é o valor de `params` recebido pela action?",
opcoes: [
a: "`%{messenger: \"Frank\", lang: \"pt\"}`",
b: "`%{\"messenger\" => \"Frank\"}`",
c: "`%{\"lang\" => \"pt\", \"messenger\" => \"Frank\"}`",
d: "`[\"Frank\", \"pt\"]`"
],
correta: :c,
explicacao:
"`conn.params` reúne os parâmetros do caminho, da query string e do corpo da requisição. As chaves são sempre strings, porque vêm de entrada externa: convertê-las em átomos permitiria que um cliente criasse átomos sem limite, e átomos não são coletados pelo garbage collector."
},
clausula: %{
texto: "a action é `def show(conn, %{\"messenger\" => messenger})`. Se ela for chamada com `params` igual a `%{}`, o que acontece?",
opcoes: [
a: "`messenger` recebe `nil`.",
b: "`messenger` recebe uma string vazia.",
c: "Nenhuma cláusula casa, e o Phoenix levanta `Phoenix.ActionClauseError`.",
d: "A action é ignorada e o template é renderizado sem o assign."
],
correta: :c,
explicacao:
"O sinal de igual em Elixir é uma asserção de pattern matching, não uma atribuição com valor padrão. Se o mapa não tem a chave, a cláusula não casa. O Phoenix converte o `FunctionClauseError` da action em `Phoenix.ActionClauseError`, que responde com status 400 (Bad Request)."
}
}
end
# --- soluções --------------------------------------------------------------
@doc "Um botão que revela a solução de um exercício."
def solucao(id) do
quadro = Kino.Frame.new()
botao = Kino.Control.button("Mostrar a solução")
Kino.listen(botao, fn _evento ->
Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
end)
Kino.Layout.grid([botao, quadro])
end
defp solucoes do
%{
rota_hello: ~S'''
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
get "/hello", HelloController, :index
end
''',
controller_hello: ~S'''
defmodule HelloWeb.HelloController do
use HelloWeb, :controller
def index(conn, _params) do
render(conn, :index)
end
end
''',
view_hello: ~S'''
defmodule HelloWeb.HelloHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<section>
<h2>Hello World, from Phoenix!</h2>
</section>
"""
end
end
''',
messenger: ~S'''
# No router, dentro do scope "/":
get "/hello/:messenger", HelloController, :show
# No HelloController:
def show(conn, %{"messenger" => messenger}) do
render(conn, :show, messenger: messenger)
end
# No HelloHTML:
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<section>
<h2>Hello World, from {@messenger}!</h2>
</section>
</Layouts.app>
"""
end
''',
sobre: ~S'''
# No router, dentro do scope "/":
get "/sobre", SobreController, :index
defmodule HelloWeb.SobreController do
use HelloWeb, :controller
def index(conn, _params) do
render(conn, :index, versao: Application.spec(:phoenix, :vsn))
end
end
defmodule HelloWeb.SobreHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h2>Sobre o Hello</h2>
<p>Feito com Phoenix {@versao}.</p>
</Layouts.app>
"""
end
end
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício fazendo requisições de verdade à aplicação."
def conferir(id) do
resultado =
Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
case executar(teste) do
:ok -> {:cont, ["- Passou: " <> descricao | feitos]}
{:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n " <> dica | feitos]}
end
end)
Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
end
defp executar(teste) do
teste.()
rescue
erro -> {:erro, "O teste levantou uma exceção: `" <> Exception.format_banner(:error, erro) <> "`"}
end
defp verificacoes do
%{
hello: [
{"`GET /hello` responde com status 200.", fn -> status("/hello", 200) end},
{"A página mostra `Hello World, from Phoenix!`.", fn -> contem("/hello", "Hello World, from Phoenix!") end},
{"A resposta passa pelo root layout.", fn -> contem("/hello", "</html>") end}
],
messenger: [
{"`GET /hello/Frank` mostra `Hello World, from Frank!`.", fn -> contem("/hello/Frank", "Hello World, from Frank!") end},
{"`GET /hello/Ana` mostra `Hello World, from Ana!`.", fn -> contem("/hello/Ana", "Hello World, from Ana!") end},
{"`GET /hello` continua funcionando.", fn -> contem("/hello", "Hello World, from Phoenix!") end}
],
sobre: [
{"`GET /sobre` responde com status 200.", fn -> status("/sobre", 200) end},
{"A página mostra `Sobre o Hello`.", fn -> contem("/sobre", "Sobre o Hello") end},
{"O controller passa o assign `:versao` ao template.",
fn ->
case requisicao(:get, "/sobre") do
{:ok, %{assigns: %{versao: _}}} -> :ok
{:ok, _} -> {:erro, "O `conn` terminou sem o assign `:versao`. Passe-o no terceiro argumento do `render/3`."}
{:erro, erro} -> {:erro, explicar(erro)}
end
end}
]
}
end
defp status(caminho, esperado) do
case requisicao(:get, caminho) do
{:ok, %{status: ^esperado}} -> :ok
{:ok, conn} -> {:erro, "A resposta veio com status `#{inspect(conn.status)}`."}
{:erro, erro} -> {:erro, explicar(erro)}
end
end
defp contem(caminho, texto) do
case requisicao(:get, caminho) do
{:ok, conn} ->
if String.contains?(to_string(conn.resp_body), texto),
do: :ok,
else: {:erro, "A resposta de `GET #{caminho}` não contém esse texto. Confira o template."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end
defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
"Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
nome = inspect(modulo)
cond do
String.ends_with?(nome, "Controller") and funcao == :init ->
"Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."
String.ends_with?(nome, "Controller") ->
"Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`. O átomo no fim da rota é o nome da action."
String.ends_with?(nome, "HTML") ->
"Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou. Defina o módulo da view e a função do template."
true ->
"A requisição levantou `UndefinedFunctionError` para `#{nome}.#{funcao}/#{aridade}`."
end
end
defp explicar(erro), do: "A requisição levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook acompanha o guia Request life-cycle da documentação do Phoenix 1.8, seção por seção, na mesma ordem. Cada seção traz o link para o trecho original. A diferença é que aqui o código roda: uma aplicação Phoenix de verdade é definida nas células e fica no ar em http://localhost:4010 enquanto o notebook estiver aberto.
Três convenções:
- Uma célula equivale a um arquivo. O título acima de cada célula diz qual arquivo ela representa em um projeto gerado por
mix phx.new hello. Editar a célula e avaliá-la de novo equivale a salvar o arquivo. - Preveja antes de rodar. As perguntas marcadas com "Antes de continuar" vêm antes da explicação. Responda primeiro.
- Leia os erros. Em vários pontos você vai rodar uma requisição que falha de propósito. A mensagem da exceção é parte do conteúdo.
Os nomes seguem a documentação (Hello, HelloWeb), para que você possa ler o guia original lado a lado. No fim há uma seção para repetir os passos no seu projeto.
A primeira célula, lá no topo, instala as dependências e define o módulo Trilha, que é só apoio do notebook: faz requisições ao endpoint e confere os exercícios. Avalie-a antes de tudo (a primeira vez leva alguns minutos).
A aplicação Hello dentro do notebook
Um projeto gerado por mix phx.new hello tem dezenas de arquivos. As células desta seção definem o subconjunto que participa do ciclo de vida de uma requisição HTML. Ficaram de fora, porque não mudam o caminho da requisição: assets (Tailwind, esbuild), CoreComponents, Gettext, Ecto, LiveDashboard, o socket do LiveView e o recarregamento de código.
Você não precisa entender tudo agora. O guia volta a cada peça.
lib/hello_web.ex define o que use HelloWeb, :router, use HelloWeb, :controller e use HelloWeb, :html injetam em cada módulo. É uma versão reduzida do arquivo gerado.
defmodule HelloWeb do
def router do
quote do
use Phoenix.Router, helpers: false
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
end
end
def html do
quote do
use Phoenix.Component
alias HelloWeb.Layouts
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
lib/hello_web/components/layouts.ex define o root layout e o layout da aplicação. No projeto gerado, o root layout fica em um arquivo de template (layouts/root.html.heex); aqui ele é uma função, o que é equivalente.
defmodule HelloWeb.Layouts do
use Phoenix.Component
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hello</title>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, default: %{}
slot :inner_block, required: true
def app(assigns) do
~H"""
<header>
<strong>Hello</strong> · layout da aplicação
</header>
<main>
{render_slot(@inner_block)}
</main>
"""
end
end
lib/hello_web/controllers/error_html.ex, error_json.ex, page_controller.ex e page_html.ex: as páginas de erro (em HTML e em JSON) e a página inicial que todo projeto novo tem.
defmodule HelloWeb.ErrorHTML do
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
defmodule HelloWeb.ErrorJSON do
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
defmodule HelloWeb.PageController do
use HelloWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end
defmodule HelloWeb.PageHTML do
use HelloWeb, :html
def home(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Welcome to Phoenix!</h1>
</Layouts.app>
"""
end
end
lib/hello_web/router.ex: é esta célula que você vai editar ao longo do guia. Ela está como em um projeto recém-gerado.
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
end
lib/hello_web/endpoint.ex: a porta de entrada. A lista de plugs é a do projeto gerado, sem os que servem assets e recarregam código.
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
@session_options [
store: :cookie,
key: "_hello_key",
signing_salt: "trilha00",
same_site: "Lax"
]
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug HelloWeb.Router
end
A célula abaixo inicia o endpoint, o equivalente a mix phx.server. Em um projeto, quem faz isso é a árvore de supervisão em lib/hello/application.ex.
{:ok, _pid} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Aplicação no ar em <http://localhost:4010>. Abra em outra aba: você deve ver *Welcome to Phoenix!*")
Se a célula falhar com :eaddrinuse, a porta 4010 já está ocupada. Troque o número em http: [..., port: 4010] na primeira célula do notebook e reconecte o runtime.
Adicionando uma página
Na documentação: Adding a new page
Quando o navegador acessa http://localhost:4010/, ele envia uma requisição HTTP para o serviço que estiver escutando nesse endereço, no caso a nossa aplicação Phoenix. A requisição é composta de um verbo e um caminho:
| Barra de endereços | Verbo | Caminho |
|---|---|---|
http://localhost:4010/ |
GET | / |
http://localhost:4010/hello |
GET | /hello |
http://localhost:4010/hello/world |
GET | /hello/world |
Existem outros verbos. O envio de um formulário, por exemplo, normalmente usa POST. Do lado do JavaScript, é o que você define na opção method do fetch.
Aplicações web tratam requisições mapeando cada par de verbo e caminho para uma parte específica da aplicação. No Phoenix, quem faz esse mapeamento é o router. Para adicionar uma página, portanto, a primeira tarefa é adicionar uma rota.
A requisição como dado
Dentro do Phoenix, a requisição é representada por uma struct, %Plug.Conn{}, chamada por convenção de conn. A célula abaixo cria um conn do mesmo jeito que os testes fazem, sem rede, e mostra os campos que descrevem a requisição.
conn = Plug.Test.conn(:get, "/hello/world?lang=pt")
Map.take(conn, [:method, :host, :request_path, :path_info, :query_string, :params, :status, :state])
Repare em três coisas, e confira cada uma no resultado:
request_pathé o caminho como texto epath_infoé o mesmo caminho dividido em segmentos. É compath_infoque o router trabalha.paramsainda é%Plug.Conn.Unfetched{}. Os parâmetros só são lidos quando um plug pede (no endpoint, é oPlug.Parsers).statusénilestateé:unset: nenhuma resposta foi definida. O mesmoconnque descreve a requisição vai carregar a resposta.
Troque o verbo e o caminho na célula e avalie de novo. O que muda em path_info quando o caminho é /?
Uma nova rota
Na documentação: A new route
O router mapeia pares únicos de verbo e caminho para pares de controller e action. Controllers são módulos Elixir. Actions são funções definidas nesses módulos.
A rota da página inicial é esta:
get "/", PageController, :home
Ela diz: uma requisição GET para o caminho raiz é tratada pela função home/2 do módulo HelloWeb.PageController. O prefixo HelloWeb vem do segundo argumento de scope "/", HelloWeb do.
Por enquanto, ignore as pipelines e o scope. Eles são assunto do guia de Routing; aqui o foco é adicionar uma rota.
Trilha.pergunta(:sem_rota)
Agora confira com a aplicação de verdade. Trilha.mostrar/2 faz duas coisas: pergunta ao router o que ele faria com a requisição (Phoenix.Router.route_info/4) e depois despacha a requisição pelo endpoint.
Trilha.mostrar(:get, "/hello")
Abra também http://localhost:4010/hello no navegador. Como a aplicação está configurada com debug_errors: true, igual ao ambiente de desenvolvimento de um projeto, você vê a página de depuração com a mesma exceção e a lista de rotas disponíveis.
Sua vez
A página que vamos construir diz "Hello World, from Phoenix!" em /hello.
Volte à célula do router, na seção anterior, e adicione dentro do bloco scope "/", HelloWeb do uma rota que mapeie GET /hello para a action index de um HelloWeb.HelloController que ainda não existe. Avalie a célula e depois rode a célula abaixo.
Trilha.mostrar(:get, "/hello")
Se a rota estiver certa, a tabela do router agora mostra :plug igual a HelloWeb.HelloController e :plug_opts igual a :index. E o resultado mudou: a exceção já não é NoRouteError. Leia a nova mensagem antes de seguir. Ela diz qual é a próxima peça que falta.
Trilha.solucao(:rota_hello)
Trilha.pergunta(:verbo_caminho)
Um novo controller
Na documentação: A new controller
Trilha.pergunta(:controller_falta)
Controllers são módulos Elixir, e actions são funções definidas neles. O propósito de uma action é reunir os dados e executar as tarefas necessárias para a renderização. A rota que você escreveu pede um módulo HelloWeb.HelloController com uma função index/2.
Toda action recebe dois argumentos:
conn, a struct que carrega os dados da requisição;params, os parâmetros da requisição.
Quando params não é usado, o nome ganha um _ na frente para evitar o aviso do compilador.
O centro da action do guia é render(conn, :index). Essa chamada diz ao Phoenix para renderizar o template index. Os módulos responsáveis por renderizar são as views. Por padrão, o nome da view é derivado do nome do controller (HelloController) e do formato (HTML): o Phoenix espera um módulo HelloWeb.HelloHTML que defina uma função index/1.
A linha use HelloWeb, :controller executa o bloco controller da célula lib/hello_web.ex. A discussão completa fica para o guia de Controllers.
Sua vez
lib/hello_web/controllers/hello_controller.ex: complete a célula com a action index/2.
defmodule HelloWeb.HelloController do
use HelloWeb, :controller
# Defina aqui a action index/2. Ela deve renderizar o template :index.
end
Trilha.mostrar(:get, "/hello")
A exceção mudou de novo. Agora o router achou a rota, o controller existe, a action rodou, e o render procurou a view.
Trilha.solucao(:controller_hello)
Uma nova view
Na documentação: A new view
Trilha.pergunta(:view_nome)
As views do Phoenix são a camada de apresentação. Espera-se que o resultado de renderizar index seja uma página HTML completa, e para isso usam-se templates.
Um template pode ser definido de duas formas, sem diferença de desempenho ou de comportamento em tempo de execução:
Como função no módulo da view. A função recebe assigns e usa o sigil ~H para declarar o conteúdo:
def index(assigns) do
~H"""
Hello!
"""
end
Dentro do ~H escreve-se HEEx, de "HTML + EEx". EEx é a biblioteca de templates que vem com o Elixir. HEEx é uma extensão do Phoenix que entende HTML: valida a estrutura das tags em tempo de compilação, suporta componentes e escapa o conteúdo interpolado, o que protege contra injeção de HTML.
Como arquivo. No projeto, o mais comum é a view declarar
embed_templates "hello_html/*"
e cada template viver em um arquivo NOME.FORMATO.LINGUAGEM dentro da pasta hello_html, por exemplo index.html.heex. Na compilação, o Phoenix transforma cada arquivo em uma função index(assigns), igual à da primeira forma.
lib/hello_web
├── controllers
│ ├── hello_controller.ex
│ ├── hello_html.ex
│ ├── hello_html
│ ├── index.html.heex
O nome do controller (HelloController), o da view (HelloHTML) e os dos arquivos (hello_controller.ex, hello_html.ex) seguem a mesma convenção. A pasta dos templates poderia ter outro nome, desde que o embed_templates aponte para ela.
Um notebook não tem pasta de templates, então aqui usamos a primeira forma.
Sua vez
lib/hello_web/controllers/hello_html.ex: defina a função index/1 com o conteúdo do template do guia, uma <section> com um <h2> dizendo Hello World, from Phoenix!.
defmodule HelloWeb.HelloHTML do
use HelloWeb, :html
# Defina aqui a função index/1, usando o sigil ~H.
end
Trilha.conferir(:hello)
Quando os três itens passarem, abra http://localhost:4010/hello. Rota, controller, view e template: são as quatro peças de uma página.
Trilha.solucao(:view_hello)
Layouts
Na documentação: Layouts
O template index tem só uma tag <section>, mas a página recebida é um documento HTML completo. Veja o corpo da resposta:
Trilha.mostrar(:get, "/hello")
O template foi renderizado dentro de outro: o root layout, a função root/1 de HelloWeb.Layouts (no projeto, o arquivo lib/hello_web/components/layouts/root.html.heex). Nele há esta linha:
{@inner_content}
É ela que injeta o template da página no layout antes de o HTML ser enviado. Quem liga as duas coisas é um plug da pipeline :browser do router:
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
O root layout é mínimo de propósito: contém basicamente o <head> e a estrutura comum a todas as páginas. Menus, barra lateral e similares ficam no layout da aplicação (Layouts.app), que aparece mais adiante.
Experimente
- Na célula de
HelloWeb.Layouts, troque o texto do<title>e avalie a célula. Rode de novo a célula acima e ache a mudança emconn.resp_body. - Rode a célula abaixo. O plug
put_root_layoutguarda a escolha emconn.private, a área doconnreservada a bibliotecas.
{:ok, conn} = Trilha.requisicao(:get, "/hello")
conn.private[:phoenix_root_layout]
Do endpoint às views
Na documentação: From endpoint to views
Com a primeira página pronta, dá para olhar o ciclo inteiro.
Toda requisição HTTP começa no endpoint da aplicação, o módulo HelloWeb.Endpoint (lib/hello_web/endpoint.ex). Assim como o router, o endpoint tem várias chamadas a plug. Plug é uma biblioteca e uma especificação para compor aplicações web, e é parte essencial de como o Phoenix trata requisições. Cada plug define uma fatia do processamento. O guia seguinte é dedicado a ele.
O esqueleto do endpoint de um projeto gerado é este:
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
plug Plug.Static, ...
plug Plug.RequestId
plug Plug.Telemetry, ...
plug Plug.Parsers, ...
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, ...
plug HelloWeb.Router
end
O último plug é o módulo HelloWeb.Router. É assim que o endpoint delega o restante do processamento ao router. O router mapeia o par verbo e caminho a um controller, e o controller pede a uma view que renderize um template.
flowchart LR
N[Navegador] -->|"GET /hello"| E[Endpoint]
E -->|"plug HelloWeb.Router"| R[Router]
R -->|"pipe_through :browser"| P[Pipeline]
P -->|"HelloController, :index"| C[Controller]
C -->|"render(conn, :index)"| V[View]
V -->|"HTML em conn.resp_body"| N
Cada camada tem uma responsabilidade distinta:
- Endpoint (
Phoenix.Endpoint): contém o caminho inicial e comum a todas as requisições. Se algo deve acontecer em todas as requisições, o lugar é o endpoint. - Router (
Phoenix.Router): despacha pares de verbo e caminho para controllers. Também permite delimitar funcionalidades por escopo; por exemplo, algumas páginas exigem autenticação e outras não. - Controller (
Phoenix.Controller): recupera as informações da requisição, conversa com o domínio de negócio e prepara os dados para a camada de apresentação. - View: recebe os dados estruturados do controller e os converte em uma apresentação para o usuário. As views costumam ser nomeadas pelo formato que renderizam.
Trilha.pergunta(:camadas)
Explorador de requisições
O formulário abaixo despacha a requisição que você montar e mostra a decisão do router e o conn final. Tudo vem da aplicação que está rodando: nada é simulado.
formulario =
Kino.Control.form(
[
verbo: Kino.Input.select("Verbo", get: "GET", post: "POST", put: "PUT", delete: "DELETE"),
caminho: Kino.Input.text("Caminho", default: "/hello")
],
submit: "Enviar requisição"
)
quadro = Kino.Frame.new()
Kino.listen(formulario, fn %{data: %{verbo: verbo, caminho: caminho}} ->
Kino.Frame.render(quadro, Trilha.mostrar(verbo, caminho))
end)
Kino.Layout.grid([formulario, quadro])
Use o explorador para responder, observando o resultado:
- Em
GET /hello, quais chaves deconn.privateregistram o controller e a action escolhidos? Que valor temconn.private.phoenix_view? - Em
GET /nada, a tabela doconnnão aparece. Em qual camada a requisição parou, e como você sabe? - Em
GET /hello?nome=Ana, onde o valor"Ana"aparece? Ele influenciou a escolha da rota? conn.assignstem a chave:flashmesmo sem o controller ter passado nada. Qual plug da pipeline:browsera colocou lá?
A célula seguinte lista as rotas que o router conhece, a mesma informação que mix phx.routes imprime no terminal de um projeto.
HelloWeb.Router
|> Phoenix.Router.routes()
|> Enum.map(&Map.take(&1, [:verb, :path, :plug, :plug_opts]))
Outra página
Na documentação: Another new page
A segunda página acrescenta um pouco de complexidade: ela reconhece um pedaço da URL, chama-o de "messenger" e o passa pelo controller até o template, para que o messenger diga hello.
Outra rota
Na documentação: Another new route
A rota reaproveita o HelloController e aponta para uma nova action, show:
get "/hello/:messenger", HelloController, :show
A sintaxe :messenger no caminho define um segmento dinâmico. O Phoenix pega o valor que estiver nessa posição da URL e o converte em um parâmetro. Em http://localhost:4010/hello/Frank, o valor de "messenger" é "Frank".
Trilha.pergunta(:chaves)
Outra action
Na documentação: Another new action
Requisições para a nova rota são tratadas pela action show do HelloWeb.HelloController. Desta vez é preciso extrair o messenger dos parâmetros para repassá-lo ao template:
def show(conn, %{"messenger" => messenger}) do
render(conn, :show, messenger: messenger)
end
O terceiro argumento do render/3 é uma lista de pares chave e valor. Aqui :messenger é a chave e a variável messenger é o valor. Esses pares são acrescentados aos assigns do conn.
Se a action precisar do mapa completo de parâmetros além da variável messenger, a assinatura pode capturar os dois:
def show(conn, %{"messenger" => messenger} = params) do
...
end
Vale lembrar duas coisas: as chaves do mapa params são sempre strings, e o sinal de igual não representa atribuição, e sim uma asserção de pattern matching.
Trilha.pergunta(:clausula)
Outro template
Na documentação: Another new template
O template da action show é parecido com o index, com a diferença de que mostra o nome do messenger:
<Layouts.app flash={@flash}>
<section>
<h2>Hello World, from {@messenger}!</h2>
</section>
</Layouts.app>
Ele usa três recursos do HEEx:
- Assigns, como
@messengere@flash. Os valores que o controller passa à view são coletivamente chamados de assigns. Dá para acessá-los comoassigns.messenger, mas o HEEx oferece a sintaxe@, mais limpa. - Interpolação de conteúdo, como
{@messenger}. Qualquer código Elixir entre chaves é executado, e o resultado substitui a expressão no HTML gerado. - Tags de componente de função, como
<Layouts.app>. Templates são chamados de componentes de função justamente porque podem ser compostos. Existe uma funçãoHelloWeb.Layouts.app/1, que define o layout da aplicação, e o template a invoca passando o próprio conteúdo.
Os três recursos se combinam: o assign @flash é passado como valor Elixir ao componente <Layouts.app>. Mensagens flash são mensagens temporárias, como avisos de sucesso ou de erro; elas voltam no guia de Components e HEEx.
Sua vez
Faça as três mudanças e avalie cada célula alterada:
- Na célula do router, adicione a rota
get "/hello/:messenger", HelloController, :showabaixo da rota de/hello. - Na célula do
HelloController, adicione a actionshow/2. - Na célula do
HelloHTML, adicione a funçãoshow/1com o template acima.
Trilha.conferir(:messenger)
Depois explore a requisição completa. Confira em conn.path_params, conn.params e conn.assigns por onde o valor "Frank" passou até chegar ao HTML.
Trilha.mostrar(:get, "/hello/Frank?lang=pt")
Abra http://localhost:4010/hello/Frank e troque o nome na URL. O que vier depois de /hello/ aparece na página.
Trilha.solucao(:messenger)
Experimente: o escape do HEEx
Um valor que vem da URL é entrada externa. Rode a célula e procure, em conn.resp_body, como o HEEx escreveu os sinais de menor e maior.
Trilha.mostrar(:get, "/hello/%3Cb%3EFrank%3C%2Fb%3E")
O segmento decodificado é <b>Frank</b>. A interpolação {@messenger} escapa o valor, e o navegador mostra o texto em vez de interpretar a tag. É essa a proteção contra injeção de HTML mencionada na seção das views.
Sem roteiro: a página Sobre
Agora o mesmo caminho sem instruções passo a passo. Crie uma página em /sobre que:
- responda a
GET /sobrecom status 200; - mostre o título
Sobre o Hellodentro do layout da aplicação; - receba do controller um assign
:versaoe o mostre no template. Um valor possível éApplication.spec(:phoenix, :vsn), a versão do Phoenix em uso.
Use a célula abaixo para o controller e a view (dois módulos podem ficar na mesma célula) e edite a célula do router.
# lib/hello_web/controllers/sobre_controller.ex e sobre_html.ex
Trilha.conferir(:sobre)
Trilha.solucao(:sobre)
No seu projeto
Repita o guia em um projeto de verdade. A correspondência entre as células e os arquivos é esta:
| Neste notebook | No projeto gerado por mix phx.new hello |
|---|---|
| Célula do router | lib/hello_web/router.ex |
Célula do HelloController |
lib/hello_web/controllers/hello_controller.ex |
Célula do HelloHTML |
lib/hello_web/controllers/hello_html.ex |
Função index/1 com ~H |
lib/hello_web/controllers/hello_html/index.html.heex |
Função root/1 de Layouts |
lib/hello_web/components/layouts/root.html.heex |
| Célula que inicia o endpoint | mix phx.server |
Trilha.mostrar(:get, "/hello") |
Abrir http://localhost:4000/hello e ler o log do servidor |
Lista de Phoenix.Router.routes/1 |
mix phx.routes |
Roteiro:
- Adicione as rotas
/helloe/hello/:messenger, oHelloControllere a viewHelloHTML, desta vez comembed_templates "hello_html/*"e os templates em arquivos.html.heex. - Antes de criar cada peça, acesse a URL no navegador e leia a página de erro. A sequência de erros deve ser a mesma que você viu aqui.
- Observe o terminal do
mix phx.servera cada requisição. O log mostra o verbo, o caminho, o controller, a action, os parâmetros e as pipelines. - Rode
mix phx.routesantes e depois de adicionar as rotas.
O projeto gerado tem mais do que o notebook mostrou: o endpoint inclui Plug.Static e o bloco de recarregamento de código, e os templates têm acesso aos CoreComponents. Nada disso muda a ordem das camadas.
Revisão
Escreva em uma célula de texto, com as suas palavras, o que acontece entre o Enter na barra de endereços e a página aparecer, nomeando os módulos envolvidos em GET /hello/Frank. Depois compare com os pontos abaixo.
- Uma requisição é um verbo e um caminho. O router mapeia pares de verbo e caminho para pares de controller e action.
- Toda requisição entra pelo endpoint, que é uma sequência de plugs. O último deles é o router.
- O router casa a rota, faz o
connpassar pelas pipelines do escopo e despacha para o controller. - A action recebe
conneparams. As chaves deparamssão strings. O pattern matching na assinatura extrai os valores. render/3acrescenta assigns aoconne invoca a view, cujo nome deriva do controller e do formato.- O template é uma função que recebe os assigns. Ele é renderizado dentro do root layout por meio de
{@inner_content}. - O mesmo
connatravessa todas as camadas e termina carregando a resposta.
Próximo notebook: 02_plug.livemd, sobre a peça com que endpoint, router e controller são construídos.