Trilha Phoenix · Controllers
Mix.install(
[
{:phoenix, "~> 1.8.0"},
{:phoenix_live_view, "~> 1.1"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:bandit, "~> 1.5"},
{:jason, "~> 1.4"},
{:kino, "~> 0.14"}
],
consolidate_protocols: false,
config: [
phoenix: [json_library: Jason, plug_init_mode: :runtime],
hello: [
{HelloWeb.Endpoint,
[
adapter: Bandit.PhoenixAdapter,
http: [ip: {127, 0, 0, 1}, port: 4010],
server: true,
debug_errors: true,
secret_key_base: String.duplicate("trilha-phoenix-", 5),
render_errors: [formats: [html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON], layout: false]
]}
]
]
)
defmodule Trilha do
@moduledoc """
Apoio deste notebook: faz requisições ao endpoint, mostra o resultado,
segue redirects como um navegador, confere os exercícios e guarda as
perguntas e as soluções.
Não faz parte do Phoenix. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
# --- requisições -----------------------------------------------------------
@doc "Despacha uma requisição para o endpoint, sem passar pela rede."
def requisicao(metodo \\ :get, caminho) do
despachar(Phoenix.ConnTest.build_conn(), metodo, caminho)
end
defp despachar(conn, metodo, caminho) do
descartar_respostas_antigas()
conn = Phoenix.ConnTest.dispatch(conn, @endpoint, metodo, caminho, nil)
# Para NoRouteError (e só para ela), o Phoenix envia a página de erro e
# devolve o conn sem relançar a exceção. Reconstruímos a exceção aqui.
if conn.state == :sent and conn.status == nil do
{:erro, Phoenix.Router.NoRouteError.exception(conn: conn, router: @router)}
else
{:ok, conn}
end
rescue
erro -> {:erro, desembrulhar(erro)}
end
# Cada resposta enviada pelo adaptador de testes deixa duas mensagens no
# processo que fez a requisição. Se ficarem acumuladas, o Phoenix acha que a
# próxima página de erro já foi enviada e não a renderiza.
defp descartar_respostas_antigas do
receive do
{ref, {status, cabecalhos, _corpo}} when is_reference(ref) and is_integer(status) and is_list(cabecalhos) ->
descartar_respostas_antigas()
{:plug_conn, :sent} ->
descartar_respostas_antigas()
after
0 -> :ok
end
end
defp desembrulhar(%Plug.Conn.WrapperError{reason: motivo}), do: desembrulhar(motivo)
defp desembrulhar(erro), do: erro
@doc "O que o router decide para uma requisição, sem executá-la."
def rota(metodo \\ :get, caminho) do
so_caminho = caminho |> String.split("?") |> hd()
Phoenix.Router.route_info(@router, verbo(metodo), so_caminho, "localhost")
rescue
_ -> :error
end
defp verbo(metodo), do: metodo |> to_string() |> String.upcase()
@doc "Faz a requisição e mostra o que o router decidiu e como a resposta saiu."
def mostrar(metodo \\ :get, caminho) do
if is_binary(caminho) and String.starts_with?(caminho, "/") do
Kino.Markdown.new(
Enum.join(
["### #{verbo(metodo)} #{caminho}", md_rota(rota(metodo, caminho)), md_resultado(requisicao(metodo, caminho))],
"\n\n"
)
)
else
Kino.Markdown.new("O caminho precisa começar com `/`, por exemplo `/hello`.")
end
end
defp md_rota(:error) do
"**Router** — `Phoenix.Router.route_info/4` devolveu `:error`: nenhuma rota casa com esse verbo e esse caminho."
end
defp md_rota(info) do
"**Router** — a rota `#{info.route}` casou e aponta para `#{inspect(info.plug)}`, action `#{inspect(info.plug_opts)}`."
end
defp md_resultado({:erro, erro}) do
"**Resultado** — a requisição levantou uma exceção:\n\n" <>
cerca(Exception.format_banner(:error, erro), "")
end
defp md_resultado({:ok, conn}) do
tabela(
"**A resposta**",
[
{"conn.status", conn.status},
{"content-type", Plug.Conn.get_resp_header(conn, "content-type")},
{"location", Plug.Conn.get_resp_header(conn, "location")},
{"conn.private.phoenix_action", conn.private[:phoenix_action]},
{"conn.private.phoenix_format", conn.private[:phoenix_format]},
{"conn.private.phoenix_view", conn.private[:phoenix_view]},
{"conn.private.phoenix_template", conn.private[:phoenix_template]},
{"conn.params", conn.params},
{"conn.assigns", conn.assigns}
]
) <> "\n\n" <> md_corpo(conn)
end
defp md_corpo(conn) do
case to_string(conn.resp_body) do
"" -> "**`conn.resp_body`**: vazio (`\"\"`)."
corpo -> "**`conn.resp_body`**\n\n" <> cerca(corpo, linguagem(conn))
end
end
defp linguagem(conn) do
tipo = conn |> Plug.Conn.get_resp_header("content-type") |> List.first("")
cond do
String.contains?(tipo, "json") -> "json"
String.contains?(tipo, "xml") -> "xml"
String.contains?(tipo, "html") -> "html"
true -> ""
end
end
# --- navegação com redirects -----------------------------------------------
@doc """
Faz GET em `caminho` e segue os redirects como um navegador, levando o
cookie de sessão de uma requisição para a seguinte. Com `recarregar: true`,
repete a última requisição no fim, como quem aperta F5.
"""
def navegar(caminho, opcoes \\ []) do
passos = passos(caminho, opcoes)
linhas =
passos
|> Enum.with_index(1)
|> Enum.map(fn {{c, resultado}, i} -> linha(i, c, resultado) end)
cabecalho = [
"### GET #{caminho}, seguindo os redirects",
"",
"| # | Requisição | Status | `location` | `conn.assigns.flash` | Flash guardada na sessão |",
"| --- | --- | --- | --- | --- | --- |"
]
Kino.Markdown.new(Enum.join(cabecalho ++ linhas, "\n") <> "\n\n" <> md_fim(List.last(passos)))
end
defp passos(caminho, opcoes) do
passos = seguir(Phoenix.ConnTest.build_conn(), caminho, [])
case {opcoes[:recarregar], List.last(passos)} do
{true, {ultimo, {:ok, conn}}} -> passos ++ [{ultimo, despachar(Phoenix.ConnTest.recycle(conn), :get, ultimo)}]
_ -> passos
end
end
defp seguir(conn, caminho, feitos) when length(feitos) < 5 do
resultado = despachar(conn, :get, caminho)
feitos = feitos ++ [{caminho, resultado}]
with {:ok, resp} <- resultado,
true <- resp.status in 300..399,
[destino] <- Plug.Conn.get_resp_header(resp, "location"),
true <- String.starts_with?(destino, "/") do
seguir(Phoenix.ConnTest.recycle(resp), destino, feitos)
else
_ -> feitos
end
end
defp seguir(_conn, _caminho, feitos), do: feitos
# O que a resposta deixou na sessão (que viaja no cookie) sob a chave da flash.
defp flash_na_sessao(conn) do
case conn.private[:plug_session] do
%{} = sessao -> Map.get(sessao, "phoenix_flash")
_ -> nil
end
end
defp linha(i, caminho, {:ok, conn}) do
local = conn |> Plug.Conn.get_resp_header("location") |> List.first("")
"| #{i} | `GET #{caminho}` | `#{conn.status}` | `#{local}` | `#{escapar(inspect(conn.assigns[:flash]))}` | `#{escapar(inspect(flash_na_sessao(conn)))}` |"
end
defp linha(i, caminho, {:erro, erro}) do
"| #{i} | `GET #{caminho}` | exceção: `#{escapar(Exception.format_banner(:error, erro))}` | | | |"
end
defp md_fim({_caminho, {:ok, conn}}) do
externo =
case Plug.Conn.get_resp_header(conn, "location") do
[destino] when conn.status in 300..399 ->
if String.starts_with?(destino, "/"),
do: "",
else: "O destino `#{destino}` fica fora da aplicação. Um navegador sairia dela aqui.\n\n"
_ ->
""
end
externo <> "Corpo da última resposta:\n\n" <> cerca(to_string(conn.resp_body), linguagem(conn))
end
defp md_fim(_), do: ""
# --- formatação ------------------------------------------------------------
defp tabela(titulo, pares) do
linhas =
Enum.map(pares, fn {campo, valor} ->
"| `#{campo}` | `#{valor |> inspect(limit: 30, printable_limit: 300) |> escapar()}` |"
end)
Enum.join([titulo, "", "| Campo | Valor |", "| --- | --- |" | linhas], "\n")
end
defp escapar(texto), do: texto |> String.replace("|", "\\|") |> String.replace("`", "'")
defp cerca(texto, linguagem) do
marca = String.duplicate("`", 3)
marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
end
# --- perguntas -------------------------------------------------------------
@doc "Uma pergunta de múltipla escolha, respondida antes de ver a explicação."
def pergunta(id) do
%{texto: texto, opcoes: opcoes, correta: correta, explicacao: explicacao} = Map.fetch!(perguntas(), id)
quadro = Kino.Frame.new()
letras = Enum.map(opcoes, fn {letra, _texto} -> {letra, to_string(letra)} end)
lista = Enum.map_join(opcoes, "\n", fn {letra, opcao} -> "* **#{letra})** #{opcao}" end)
formulario =
Kino.Control.form(
[resposta: Kino.Input.select("Sua resposta", [{nil, "Escolha"} | letras])],
submit: "Conferir"
)
Kino.listen(formulario, fn %{data: %{resposta: resposta}} ->
retorno =
cond do
resposta == nil -> "Escolha uma opção antes de conferir."
resposta == correta -> "**Certo.** " <> explicacao
true -> "**Ainda não.** Releia o trecho acima e tente outra opção."
end
Kino.Frame.render(quadro, Kino.Markdown.new(retorno))
end)
Kino.Layout.grid([Kino.Markdown.new("**Antes de continuar:** " <> texto <> "\n\n" <> lista), formulario, quadro])
end
defp perguntas do
%{
nome_action: %{
texto: "você troca a rota para `get \"/\", PageController, :index`, mas o `PageController` continua definindo só `home/2`. O que acontece com `GET /`?",
opcoes: [
a: "O Phoenix procura uma action parecida e chama `home/2`.",
b: "O router recusa a requisição com 404, como se a rota não existisse.",
c: "A rota casa, e a chamada a `index/2` falha com `UndefinedFunctionError`, que vira status 500.",
d: "O router não compila enquanto a action não existir."
],
correta: :c,
explicacao:
"O nome da action é só um átomo guardado na rota. O router casa `GET /` normalmente e despacha para `HelloWeb.PageController.index/2`. Como a função não existe, a chamada levanta `UndefinedFunctionError`, que o Phoenix responde com 500. A única exigência sobre o nome de uma action é que ele seja o mesmo na rota e no controller."
},
tipo_texto: %{
texto: "a action `show` passa a responder com `text(conn, \"From messenger \#{messenger}\")`. Qual será o cabeçalho `content-type` da resposta?",
opcoes: [
a: "`text/html; charset=utf-8`, porque a rota está na pipeline `:browser`.",
b: "`text/plain; charset=utf-8`.",
c: "Nenhum: `text/2` só define o corpo.",
d: "`application/octet-stream`."
],
correta: :b,
explicacao:
"Cada função de resposta define o próprio content-type: `text/2` usa `text/plain`, `json/2` usa `application/json` e `html/2` usa `text/html`, todos com `charset=utf-8`. A pipeline `:browser` decide quais formatos a requisição pode pedir, não o tipo da resposta. Sem template e sem view, também não há root layout: o corpo é exatamente a string passada."
},
formato_recusado: %{
texto: "a pipeline `:browser` tem `plug :accepts, [\"html\"]`. Chega `GET /?_format=json`. O que acontece?",
opcoes: [
a: "O Phoenix ignora o parâmetro e responde HTML.",
b: "O plug `:accepts` levanta `Phoenix.NotAcceptableError`, e a resposta tem status 406.",
c: "A action roda e `render/2` falha porque não existe `PageJSON`.",
d: "O router responde 404."
],
correta: :b,
explicacao:
"O parâmetro `_format` é lido pelo plug `:accepts`, ainda na pipeline. Como `json` não está na lista de formatos aceitos, ele levanta `Phoenix.NotAcceptableError` antes de a requisição chegar ao controller. A exceção tem status 406 (Not Acceptable)."
},
view_json: %{
texto: "com `json` aceito na pipeline, chega `GET /?_format=json`. A action faz `render(conn, :home)`. Qual função produz o corpo da resposta?",
opcoes: [
a: "`HelloWeb.PageHTML.home/1`, e o Phoenix converte o HTML em JSON.",
b: "`HelloWeb.PageJSON.home/1`, que devolve um mapa; o Phoenix o codifica com o Jason.",
c: "`HelloWeb.PageController.home/2`, de novo, agora com `_format`.",
d: "`Jason.encode!/1` aplicado ao `conn.assigns`."
],
correta: :b,
explicacao:
"`render/2` escolhe a view pelo formato da requisição. O controller tem `formats: [:html, :json]`, e então o nome da view deriva do controller e do formato: `PageController` mais `:json` dá `PageJSON`. O template `:home` é a função `home/1` dessa view. Ela recebe os assigns e devolve dados, e o Phoenix os codifica como JSON."
},
sem_tipo: %{
texto: "uma action termina com `send_resp(conn, 201, \"\")`, sem mais nada. Qual será o `content-type` da resposta?",
opcoes: [
a: "`text/html`, o padrão do Phoenix.",
b: "`application/json`, porque o status 201 é típico de APIs.",
c: "Nenhum. `send_resp/3` envia só o status, os cabeçalhos já definidos e o corpo.",
d: "`text/plain`, porque o corpo é uma string."
],
correta: :c,
explicacao:
"`send_resp/3` vem do Plug e não adivinha nada: envia o status, os cabeçalhos que já estiverem no conn e o corpo. Sem content-type, cada navegador decide como tratar a resposta (o guia lembra que o Safari a baixa como arquivo). Por isso o guia combina `send_resp/3` com `put_resp_content_type/2`."
},
redirect_to: %{
texto: "o que acontece quando uma action chama `redirect(conn, to: \"https://elixir-lang.org/\")`?",
opcoes: [
a: "O navegador é redirecionado para o site do Elixir.",
b: "O Phoenix levanta `ArgumentError`: `:to` só aceita caminhos dentro da aplicação.",
c: "O Phoenix redireciona para `/https://elixir-lang.org/`.",
d: "O redirect é ignorado e a action devolve status 200."
],
correta: :b,
explicacao:
"Por segurança, `:to` só aceita caminhos da própria aplicação. Isso impede que um valor vindo do usuário transforme o redirect em uma porta para outro site (o chamado open redirect). Para sair da aplicação existe a opção `:external`, que deixa a intenção explícita no código."
},
flash_redirect: %{
texto: "uma action faz `put_flash(:error, \"...\")` e depois `redirect/2`. A mensagem aparece na página seguinte, que é outra requisição. Onde ela ficou guardada nesse meio-tempo?",
opcoes: [
a: "Em uma variável global do controller.",
b: "No `location` do redirect, como parâmetro da URL.",
c: "Na sessão, que viaja no cookie: a requisição seguinte lê a mensagem e a apaga.",
d: "No banco de dados."
],
correta: :c,
explicacao:
"Cada requisição é atendida do zero, então o que sobrevive de uma para outra precisa viajar no cookie de sessão. Quando a resposta é um redirect, o Phoenix grava a flash na sessão. A requisição seguinte a lê para `conn.assigns.flash` e a retira da sessão. Por isso a mensagem aparece uma vez e some ao recarregar."
}
}
end
# --- soluções --------------------------------------------------------------
@doc "Um botão que revela a solução de um exercício."
def solucao(id) do
quadro = Kino.Frame.new()
botao = Kino.Control.button("Mostrar a solução")
Kino.listen(botao, fn _evento ->
Kino.Frame.render(quadro, Kino.Markdown.new(cerca(Map.fetch!(solucoes(), id), "elixir")))
end)
Kino.Layout.grid([botao, quadro])
end
defp solucoes do
%{
index: ~S'''
# No router, dentro do scope "/":
get "/", PageController, :index
# No PageController:
def index(conn, _params) do
render(conn, :home)
end
''',
assigns: ~S'''
# No HelloController:
def show(conn, %{"messenger" => messenger}) do
conn
|> assign(:messenger, messenger)
|> assign(:receiver, "Dweezil")
|> render(:show)
end
# No HelloHTML:
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<section>
<h2>Hello World, from {@messenger}!</h2>
<p>To: {@receiver}</p>
</section>
</Layouts.app>
"""
end
''',
formato_json: ~S'''
# No router, na pipeline :browser:
plug :accepts, ["html", "json"]
# lib/hello_web/controllers/page_json.ex
defmodule HelloWeb.PageJSON do
def home(_assigns) do
%{message: "this is some JSON"}
end
end
''',
criado: ~S'''
def criado(conn, _params) do
conn
|> put_resp_content_type("text/plain")
|> send_resp(201, "")
end
''',
xml: ~S'''
defmodule HelloWeb.PageXML do
require EEx
EEx.function_from_string(
:def,
:home,
"""
<?xml version="1.0" encoding="UTF-8"?>
<%= @content %>
""",
[:assigns]
)
end
''',
aceito: ~S'''
def aceito(conn, _params) do
conn
|> put_status(:accepted)
|> render(:home)
end
''',
redirect: ~S'''
# No router, dentro do scope "/":
get "/redirect_test", PageController, :redirect_test
# No PageController:
def redirect_test(conn, _params) do
render(conn, :home)
end
# No LabController:
def redirecionar(conn, _params) do
redirect(conn, to: ~p"/redirect_test")
end
''',
flash: ~S'''
def aviso(conn, _params) do
conn
|> put_flash(:error, "Let's pretend we have an error.")
|> redirect(to: ~p"/redirect_test")
end
''',
produtos: ~S'''
# No router, dentro do scope "/":
get "/produtos", ProdutoController, :index
get "/produtos/:id", ProdutoController, :show
defmodule HelloWeb.ProdutoController do
use HelloWeb, :controller
def index(conn, _params) do
render(conn, :index, produtos: Hello.Catalogo.list_produtos())
end
def show(conn, %{"id" => id}) do
case Hello.Catalogo.get_produto(id) do
nil ->
conn
|> put_flash(:error, "Produto não encontrado.")
|> redirect(to: ~p"/produtos")
produto ->
render(conn, :show, produto: produto)
end
end
end
defmodule HelloWeb.ProdutoHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h2>Produtos</h2>
<ul>
<li :for={produto <- @produtos}>{produto.nome}</li>
</ul>
</Layouts.app>
"""
end
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h2>{@produto.nome}</h2>
<p>R$ {@produto.preco}</p>
</Layouts.app>
"""
end
end
defmodule HelloWeb.ProdutoJSON do
def show(%{produto: produto}) do
%{id: produto.id, nome: produto.nome, preco: produto.preco}
end
end
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício fazendo requisições de verdade à aplicação."
def conferir(id) do
resultado =
Enum.reduce_while(Map.fetch!(verificacoes(), id), [], fn {descricao, teste}, feitos ->
case executar(teste) do
:ok -> {:cont, ["- Passou: " <> descricao | feitos]}
{:erro, dica} -> {:halt, ["- **Ainda não:** " <> descricao <> "\n\n " <> dica | feitos]}
end
end)
Kino.Markdown.new(resultado |> Enum.reverse() |> Enum.join("\n"))
end
defp executar(teste) do
teste.()
rescue
erro -> {:erro, "O teste levantou uma exceção: `" <> Exception.format_banner(:error, erro) <> "`"}
end
defp verificacoes do
%{
index: [
{"`GET /` responde com status 200.", fn -> status("/", 200) end},
{"A rota de `/` aponta para a action `index`.", fn -> action("/", :index) end},
{"A página continua mostrando `Welcome to Phoenix!`.", fn -> contem("/", "Welcome to Phoenix!") end}
],
assigns: [
{"`GET /hello/Frank` responde HTML.", fn -> tipo("/hello/Frank", "text/html") end},
{"`conn.assigns.messenger` termina como `\"Frank\"`.", fn -> assign_igual("/hello/Frank", :messenger, "Frank") end},
{"`conn.assigns.receiver` termina como `\"Dweezil\"`.", fn -> assign_igual("/hello/Frank", :receiver, "Dweezil") end},
{"A página mostra `Hello World, from Frank!`.", fn -> contem("/hello/Frank", "Hello World, from Frank!") end},
{"A página mostra `To: Dweezil`.", fn -> contem("/hello/Frank", "To: Dweezil") end}
],
formato_json: [
{"`GET /?_format=json` responde com status 200.", fn -> status("/?_format=json", 200) end},
{"A resposta tem content-type `application/json`.", fn -> tipo("/?_format=json", "application/json") end},
{"O corpo é `{\"message\": \"this is some JSON\"}`.",
fn -> json_igual("/?_format=json", %{"message" => "this is some JSON"}) end},
{"`GET /`, sem `_format`, continua respondendo HTML.", fn -> tipo("/", "text/html") end}
],
criado: [
{"`GET /lab/criado` responde com status 201.", fn -> status("/lab/criado", 201) end},
{"A resposta tem content-type `text/plain`.", fn -> tipo("/lab/criado", "text/plain") end},
{"O corpo é vazio.", fn -> corpo_igual("/lab/criado", "") end}
],
xml: [
{"`GET /lab/xml` responde com status 200.", fn -> status("/lab/xml", 200) end},
{"A resposta tem content-type `text/xml`.", fn -> tipo("/lab/xml", "text/xml") end},
{"O corpo começa pela declaração `<?xml`.",
fn ->
case requisicao(:get, "/lab/xml") do
{:ok, conn} ->
if conn.resp_body |> to_string() |> String.trim_leading() |> String.starts_with?("<?xml"),
do: :ok,
else: {:erro, "O corpo começa com outra coisa. A primeira linha do template deve ser `<?xml version=\"1.0\" encoding=\"UTF-8\"?>`."}
{:erro, erro} ->
{:erro, explicar(erro)}
end
end},
{"O corpo contém o assign `@content`.", fn -> contem("/lab/xml", "<message>this is some XML</message>") end}
],
aceito: [
{"`GET /lab/aceito` responde com status 202.", fn -> status("/lab/aceito", 202) end},
{"A página mostra `Welcome to Phoenix!`.", fn -> contem("/lab/aceito", "Welcome to Phoenix!") end}
],
redirect: [
{"`GET /redirect_test` responde 200 com a página inicial.", fn -> contem("/redirect_test", "Welcome to Phoenix!") end},
{"`GET /lab/redirecionar` responde com status 302.", fn -> status("/lab/redirecionar", 302) end},
{"O cabeçalho `location` aponta para `/redirect_test`.", fn -> destino("/lab/redirecionar", "/redirect_test") end}
],
flash: [
{"`GET /lab/aviso` redireciona para `/redirect_test`.", fn -> destino("/lab/aviso", "/redirect_test") end},
{"Seguindo o redirect, a página mostra a mensagem.",
fn -> passo_contem("/lab/aviso", 2, "pretend we have an error", true) end},
{"Recarregando a página, a mensagem some.",
fn -> passo_contem("/lab/aviso", 3, "pretend we have an error", false) end}
],
produtos: [
{"`GET /produtos` lista os três produtos.",
fn ->
Enum.find_value(["Jaqueta jeans", "Vitrola portátil", "Coleção de gibis"], :ok, fn nome ->
case contem("/produtos", nome) do
:ok -> nil
erro -> erro
end
end)
end},
{"`GET /produtos/2` mostra `Vitrola portátil` em HTML.",
fn ->
with :ok <- tipo("/produtos/2", "text/html"), do: contem("/produtos/2", "Vitrola portátil")
end},
{"`GET /produtos/2?_format=json` devolve `{\"id\": 2, \"nome\": \"Vitrola portátil\", \"preco\": 250}`.",
fn -> json_igual("/produtos/2?_format=json", %{"id" => 2, "nome" => "Vitrola portátil", "preco" => 250}) end},
{"`GET /produtos/99` redireciona para `/produtos`.", fn -> destino("/produtos/99", "/produtos") end},
{"Seguindo o redirect, a página mostra `Produto não encontrado.`.",
fn -> passo_contem("/produtos/99", 2, "Produto não encontrado.", true) end}
]
}
end
defp com_resposta(caminho, fun) do
case requisicao(:get, caminho) do
{:ok, conn} -> fun.(conn)
{:erro, erro} -> {:erro, explicar(erro)}
end
end
defp status(caminho, esperado) do
com_resposta(caminho, fn
%{status: ^esperado} -> :ok
conn -> {:erro, "A resposta veio com status `#{inspect(conn.status)}`."}
end)
end
defp action(caminho, esperada) do
com_resposta(caminho, fn conn ->
case conn.private[:phoenix_action] do
^esperada -> :ok
outra -> {:erro, "A requisição foi atendida pela action `#{inspect(outra)}`. Confira o átomo no fim da rota."}
end
end)
end
defp tipo(caminho, prefixo) do
com_resposta(caminho, fn conn ->
case Plug.Conn.get_resp_header(conn, "content-type") do
[valor] ->
if String.starts_with?(valor, prefixo),
do: :ok,
else: {:erro, "O content-type de `GET #{caminho}` é `#{valor}`."}
[] ->
{:erro, "A resposta de `GET #{caminho}` não tem content-type."}
end
end)
end
defp contem(caminho, texto) do
com_resposta(caminho, fn conn ->
if String.contains?(to_string(conn.resp_body), texto),
do: :ok,
else: {:erro, "A resposta de `GET #{caminho}` (status `#{inspect(conn.status)}`) não contém `#{texto}`."}
end)
end
defp corpo_igual(caminho, esperado) do
com_resposta(caminho, fn conn ->
if to_string(conn.resp_body) == esperado,
do: :ok,
else: {:erro, "O corpo de `GET #{caminho}` é `#{inspect(conn.resp_body)}`."}
end)
end
defp json_igual(caminho, esperado) do
com_resposta(caminho, fn conn ->
case Jason.decode(to_string(conn.resp_body)) do
{:ok, ^esperado} -> :ok
{:ok, outro} -> {:erro, "O JSON decodificado é `#{inspect(outro)}`."}
{:error, _} -> {:erro, "O corpo de `GET #{caminho}` não é JSON (status `#{inspect(conn.status)}`)."}
end
end)
end
defp assign_igual(caminho, chave, esperado) do
com_resposta(caminho, fn conn ->
case Map.fetch(conn.assigns, chave) do
{:ok, ^esperado} -> :ok
{:ok, outro} -> {:erro, "`conn.assigns.#{chave}` terminou como `#{inspect(outro)}`."}
:error -> {:erro, "O conn terminou sem o assign `:#{chave}`."}
end
end)
end
defp destino(caminho, esperado) do
com_resposta(caminho, fn conn ->
cond do
conn.status not in 300..399 ->
{:erro, "A resposta veio com status `#{inspect(conn.status)}`, que não é um redirect."}
Plug.Conn.get_resp_header(conn, "location") != [esperado] ->
{:erro, "O `location` é `#{inspect(Plug.Conn.get_resp_header(conn, "location"))}`; o esperado é `#{esperado}`."}
true ->
:ok
end
end)
end
defp passo_contem(caminho, n, texto, deve_conter) do
case Enum.at(passos(caminho, recarregar: true), n - 1) do
{c, {:ok, conn}} ->
case {String.contains?(to_string(conn.resp_body), texto), deve_conter} do
{igual, igual} -> :ok
{false, true} -> {:erro, "Na requisição #{n} (`GET #{c}`) a página não mostra `#{texto}`. O layout recebe `@flash`?"}
{true, false} -> {:erro, "Na requisição #{n} (`GET #{c}`) a mensagem ainda aparece."}
end
{_c, {:erro, erro}} ->
{:erro, explicar(erro)}
nil ->
{:erro, "A navegação parou antes da requisição #{n}. Confira com `Trilha.navegar(\"#{caminho}\")`."}
end
end
defp explicar(%Phoenix.Router.NoRouteError{} = erro) do
"Etapa: **router**. `#{Exception.message(erro)}`. Adicione a rota na célula do router e avalie-a de novo."
end
defp explicar(%Phoenix.NotAcceptableError{} = erro) do
"Etapa: **pipeline**. `#{Exception.message(erro)}`. O plug `:accepts` recusou o formato pedido."
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
nome = inspect(modulo)
cond do
String.ends_with?(nome, "Controller") and funcao == :init ->
"Etapa: **controller**. A rota aponta para `#{nome}`, mas esse módulo não existe. Defina-o e avalie a célula."
String.ends_with?(nome, "Controller") ->
"Etapa: **controller**. `#{nome}` existe, mas não define `#{funcao}/#{aridade}`. O átomo no fim da rota é o nome da action."
String.ends_with?(nome, ["HTML", "JSON", "XML"]) ->
"Etapa: **view**. O `render` procurou `#{nome}.#{funcao}/#{aridade}` e não achou. Defina o módulo da view e a função do template."
true ->
"A requisição levantou `UndefinedFunctionError` para `#{nome}.#{funcao}/#{aridade}`."
end
end
defp explicar(erro), do: "A requisição levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook acompanha o guia Controllers da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção. A aplicação Hello parte do ponto em que o notebook do ciclo de vida da requisição terminou: as páginas /, /hello e /hello/:messenger. Os experimentos do notebook de Plug (locale, mensagens) ficaram de fora, porque o guia de Controllers não os usa.
As convenções são as mesmas dos notebooks anteriores:
- Uma célula equivale a um arquivo. O título acima de cada célula diz qual arquivo ela representa em um projeto gerado por
mix phx.new hello. - Preveja antes de rodar. As perguntas marcadas com "Antes de continuar" vêm antes da explicação.
- Leia os erros. Algumas requisições falham de propósito.
A aplicação fica em http://localhost:4010. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes, porque todos usam a mesma porta.
Duas ferramentas novas aparecem aqui. Trilha.mostrar/2 agora destaca o status e os cabeçalhos content-type e location da resposta, que são o assunto do guia. Trilha.navegar/2 segue redirects como um navegador, levando o cookie de sessão de uma requisição para a outra.
Avalie a primeira célula, no topo, antes de tudo.
A aplicação Hello dentro do notebook
As células desta seção definem a aplicação. Avalie todas, na ordem.
A ordem importa mais do que nos notebooks anteriores. A partir daqui, controllers e templates usam o sigil ~p das rotas verificadas (assunto do guia de Routing), e o ~p consulta o router quando o módulo é compilado e o endpoint quando é executado. Por isso o router e o endpoint vêm antes dos controllers. Na primeira avaliação, a célula do router avisa que os controllers ainda não existem; eles são definidos logo abaixo, e o aviso pode ser ignorado.
lib/hello_web.ex ganhou o bloco verified_routes, incluído em controller e html, como no projeto gerado.
defmodule HelloWeb do
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def html do
quote do
use Phoenix.Component
alias HelloWeb.Layouts
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: HelloWeb.Endpoint,
router: HelloWeb.Router,
statics: HelloWeb.static_paths()
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
lib/hello_web/router.ex: além das rotas do guia anterior, há um escopo /lab. O guia altera várias vezes a action home para experimentar respostas diferentes; aqui esses experimentos acontecem em um controller de laboratório, para que as páginas dos exercícios continuem funcionando.
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
get "/hello", HelloController, :index
get "/hello/:messenger", HelloController, :show
end
# Laboratório deste notebook: usado a partir da seção "Enviando respostas diretamente".
scope "/lab", HelloWeb do
pipe_through :browser
get "/criado", LabController, :criado
get "/xml", LabController, :xml
get "/aceito", LabController, :aceito
get "/redirecionar", LabController, :redirecionar
get "/externo", LabController, :externo
get "/aviso", LabController, :aviso
end
end
lib/hello_web/endpoint.ex
defmodule HelloWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :hello
@session_options [
store: :cookie,
key: "_hello_key",
signing_salt: "trilha00",
same_site: "Lax"
]
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug HelloWeb.Router
end
lib/hello_web/components/layouts.ex: o layout da aplicação agora mostra as mensagens flash, assunto do fim do guia. No projeto gerado, quem faz isso é o componente <.flash_group flash={@flash} />, dos CoreComponents; aqui, dois parágrafos fazem o mesmo papel.
defmodule HelloWeb.Layouts do
use Phoenix.Component
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hello</title>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, default: %{}
slot :inner_block, required: true
def app(assigns) do
~H"""
<header>
<strong>Hello</strong> · layout da aplicação
</header>
<p :if={Phoenix.Flash.get(@flash, :info)} role="alert" class="flash-info">
{Phoenix.Flash.get(@flash, :info)}
</p>
<p :if={Phoenix.Flash.get(@flash, :error)} role="alert" class="flash-error">
{Phoenix.Flash.get(@flash, :error)}
</p>
<main>
{render_slot(@inner_block)}
</main>
"""
end
end
lib/hello_web/controllers/error_html.ex e error_json.ex: as views de erro, uma por formato. O notebook 05 é dedicado a elas.
defmodule HelloWeb.ErrorHTML do
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end
defmodule HelloWeb.ErrorJSON do
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end
lib/hello_web/controllers/page_controller.ex e page_html.ex: você vai editar esta célula.
defmodule HelloWeb.PageController do
use HelloWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end
defmodule HelloWeb.PageHTML do
use HelloWeb, :html
def home(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Welcome to Phoenix!</h1>
</Layouts.app>
"""
end
end
lib/hello_web/controllers/hello_controller.ex e hello_html.ex: também vai ser editada.
defmodule HelloWeb.HelloController do
use HelloWeb, :controller
def index(conn, _params) do
render(conn, :index)
end
def show(conn, %{"messenger" => messenger}) do
render(conn, :show, messenger: messenger)
end
end
defmodule HelloWeb.HelloHTML do
use HelloWeb, :html
def index(assigns) do
~H"""
<section>
<h2>Hello World, from Phoenix!</h2>
</section>
"""
end
def show(assigns) do
~H"""
<Layouts.app flash={@flash}>
<section>
<h2>Hello World, from {@messenger}!</h2>
</section>
</Layouts.app>
"""
end
end
A célula abaixo inicia o endpoint, o equivalente a mix phx.server.
{:ok, _pid} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Aplicação no ar em <http://localhost:4010>. Abra em outra aba: você deve ver *Welcome to Phoenix!*")
Se a célula falhar com :eaddrinuse, a porta 4010 já está ocupada: desconecte o runtime do outro notebook ou troque o número em http: [..., port: 4010] na primeira célula.
O que é um controller
Na documentação: Controllers
Controllers são módulos intermediários. As funções deles, chamadas actions, são invocadas pelo router em resposta a requisições HTTP. As actions reúnem os dados e executam os passos necessários antes de chamar a camada de apresentação, seja para renderizar um template, seja para devolver JSON.
Controllers também são construídos sobre o Plug e são, eles mesmos, plugs. Eles oferecem funções para quase tudo o que uma action precisa; o que faltar provavelmente está no próprio Plug, em Plug.Conn.
A primeira linha do PageController, use HelloWeb, :controller, executa o bloco controller de lib/hello_web.ex. A célula abaixo mostra que o resultado é um plug: o módulo tem init/1 e call/2, como os plugs de módulo do notebook anterior.
%{
init: function_exported?(HelloWeb.PageController, :init, 1),
call: function_exported?(HelloWeb.PageController, :call, 2),
home: function_exported?(HelloWeb.PageController, :home, 2)
}
Actions
Na documentação: Actions
Actions são funções comuns. Podem ter qualquer nome que siga as regras do Elixir. A única exigência é que o nome seja o mesmo na rota e no controller.
Trilha.pergunta(:nome_action)
Sua vez: renomear uma action
O guia troca o nome da action da página inicial de home para index. Faça a troca em duas etapas, conferindo o resultado de cada uma:
- Na célula do router, troque
:homepor:indexna rota de/. Avalie a célula e rode a célula abaixo. Leia a exceção. - Na célula do
PageController, renomeie a action paraindex. O corpo continuarender(conn, :home): o nome da action e o nome do template são coisas independentes. Avalie e rode de novo.
Trilha.mostrar(:get, "/")
Trilha.conferir(:index)
Trilha.solucao(:index)
Nomes por convenção
Mesmo podendo usar qualquer nome, há convenções que convém seguir. São as que o guia de Routing apresentou com resources:
| Action | O que faz |
|---|---|
index |
lista todos os itens de um tipo de recurso |
show |
mostra um item, pelo ID |
new |
mostra um formulário para criar um item |
create |
recebe os parâmetros de um item novo e o salva |
edit |
busca um item pelo ID e o mostra em um formulário de edição |
update |
recebe os parâmetros de um item editado e o salva |
delete |
recebe o ID de um item e o apaga |
Toda action recebe dois parâmetros, que o Phoenix fornece:
conn, a struct com as informações da requisição: host, segmentos do caminho, porta, query string e muito mais. Ela vem do Plug; a lista completa de campos está emPlug.Conn.params, o mapa com os parâmetros da requisição. A boa prática é usar pattern matching na assinatura para extrair o que interessa, como emdef show(conn, %{"messenger" => messenger}).
Quando a action não usa os parâmetros, o nome ganha um _ na frente (_params). Assim o compilador não reclama da variável não usada e a aridade continua a correta: o router sempre chama a action com dois argumentos.
Renderização
Na documentação: Rendering
Um controller pode responder de várias formas. A mais simples é texto puro, com text/2.
Trilha.pergunta(:tipo_texto)
Experimente: texto, JSON e HTML
Na célula do HelloController, troque o corpo da action show por cada uma das versões abaixo, uma de cada vez. Depois de avaliar cada versão, rode a célula de mostrar logo em seguida e compare o content-type e o conn.resp_body.
# 1. Texto puro
def show(conn, %{"messenger" => messenger}) do
text(conn, "From messenger #{messenger}")
end
# 2. JSON: o mapa é codificado pelo Jason
def show(conn, %{"messenger" => messenger}) do
json(conn, %{id: messenger})
end
# 3. HTML escrito à mão
def show(conn, %{"messenger" => messenger}) do
html(conn, "<p>From messenger #{messenger}</p>")
end
Trilha.mostrar(:get, "/hello/Frank")
O que observar:
- Com
text/2, o content-type étext/plain; charset=utf-8e o corpo é exatamente a string, sem o root layout. Em http://localhost:4010/hello/Frank o navegador mostra texto cru. - Com
json/2, o content-type éapplication/json; charset=utf-8e o corpo é{"id":"Frank"}. É o tipo de resposta que o seufetchrecebe e lê comresponse.json(). - Com
html/2, o content-type étext/html; charset=utf-8, mas a string vai como está: sem layout e sem escape. Tente/hello/%3Cb%3EFrank%3C%2Fb%3Ecom essa versão e compare com o escape que o HEEx fazia no notebook 01.
Nas três versões, conn.private.phoenix_template fica nil: nenhum template foi renderizado. (conn.private.phoenix_view aparece mesmo assim, porque o controller registra as views de cada formato antes de rodar a action; elas só não foram usadas.) Na maior parte do tempo, porém, usamos as views do Phoenix, por meio de render/3. Especialmente para HTML, as views trazem ganhos de desempenho e de segurança.
Passando valores ao template
Depois dos experimentos, volte a show para render(conn, :show, messenger: messenger).
Para o render/3 funcionar, controller e view compartilham o mesmo nome-raiz (Hello): HelloController requer HelloHTML. No projeto gerado, HelloHTML declara embed_templates "hello_html/*", e a pasta lib/hello_web/controllers/hello_html/ contém o show.html.heex. No notebook, como antes, o template é uma função.
O terceiro argumento de render/3 vira assigns. A mesma coisa pode ser feita com assign/3, de Plug.Conn, que devolve o conn e por isso encadeia bem com o pipe. use Phoenix.Controller importa Plug.Conn, então dá para escrever só assign/3. As três formas abaixo são equivalentes:
render(conn, :show, messenger: messenger, receiver: "Dweezil")
conn
|> assign(:messenger, messenger)
|> assign(:receiver, "Dweezil")
|> render(:show)
conn
|> Plug.Conn.assign(:messenger, messenger)
|> Plug.Conn.assign(:receiver, "Dweezil")
|> render(:show)
Depois que os assigns estão configurados, a action chama a camada de visualização. A view (HelloWeb.HelloHTML) renderiza o template show dentro do layout, e a resposta volta ao navegador.
Sua vez: dois assigns
- Na célula do
HelloController, reescrevashowcom a versão em pipeline:assign(:messenger, messenger),assign(:receiver, "Dweezil")erender(:show). - Na célula do
HelloHTML, acrescente ao templateshow, logo abaixo do<h2>, um parágrafo<p>To: {@receiver}</p>.
Trilha.conferir(:assigns)
Trilha.solucao(:assigns)
Novos formatos de renderização
Na documentação: New rendering formats
O trabalho da view não é só renderizar HTML. Views cuidam da apresentação dos dados: dado um conjunto de dados, o papel da view é apresentá-lo de forma útil em algum formato, seja HTML, JSON, CSV ou outro.
O controller entrega a renderização a uma view por formato. Os formatos que os controllers aceitam estão em lib/hello_web.ex:
use Phoenix.Controller, formats: [:html, :json]
Com isso, o Phoenix procura uma view HTML e uma view JSON derivadas do nome do controller, conforme o formato da requisição. A escolha pode ser escrita explicitamente no controller; esta linha faz exatamente o que o Phoenix já faz por padrão no PageController:
plug :put_view, html: HelloWeb.PageHTML, json: HelloWeb.PageJSON
{:ok, conn} = Trilha.requisicao(:get, "/")
conn.private.phoenix_view
O mapa acima é o que o Phoenix calculou para a página inicial: uma view para cada formato. Só a view HTML existe por enquanto.
O Phoenix permite trocar o formato pelo parâmetro _format na query string.
Trilha.pergunta(:formato_recusado)
Trilha.mostrar(:get, "/?_format=json")
A linha do router mostra que a rota casou; a exceção veio depois, da pipeline. Leia a mensagem: ela lista os formatos aceitos.
Sua vez: a página inicial em JSON
- Na célula do router, mude o plug da pipeline
:browserparaplug :accepts, ["html", "json"]. - Na célula abaixo, defina a view
PageJSONcom a funçãohome/1do guia: ela ignora os assigns e devolve o mapa%{message: "this is some JSON"}.
Trilha.pergunta(:view_json)
lib/hello_web/controllers/page_json.ex
defmodule HelloWeb.PageJSON do
# Defina aqui a função home/1.
end
Trilha.conferir(:formato_json)
Trilha.mostrar(:get, "/?_format=json")
A view JSON é só uma função que recebe os assigns e devolve dados. Repare também que o root layout não envolveu a resposta: put_root_layout foi configurado só para html:. Abra http://localhost:4010/?_format=json no navegador.
Na prática, aplicações que respondem nos dois formatos costumam separar as rotas em pipelines distintas, como a :api que já existe no router. O guia de JSON and APIs trata disso.
Trilha.solucao(:formato_json)
Enviando respostas diretamente
Na documentação: Sending responses directly
Quando nenhuma das opções acima serve, dá para compor a resposta com as funções do Plug. O guia faz isso alterando a action home; aqui, como dito, os experimentos acontecem no controller de laboratório, cujas rotas já estão no escopo /lab do router.
O laboratório declara o plug put_view da seção anterior, para renderizar os templates de PageHTML e PageJSON e uma view XML que ainda não existe. Avalie a célula. Você vai voltar a ela nas próximas seções.
lib/hello_web/controllers/lab_controller.ex
defmodule HelloWeb.LabController do
use HelloWeb, :controller
plug :put_view, html: HelloWeb.PageHTML, json: HelloWeb.PageJSON, xml: HelloWeb.PageXML
# Enviando respostas diretamente
def criado(conn, _params) do
send_resp(conn, 201, "")
end
# Definindo o content type
def xml(conn, _params) do
conn
|> put_resp_content_type("text/xml")
|> put_format(:xml)
|> render(:home, content: "<message>this is some XML</message>")
end
# Definindo o status HTTP: escreva aqui a action aceito/2.
# Redirecionamento: escreva aqui a action redirecionar/2.
def externo(conn, _params) do
redirect(conn, external: "https://elixir-lang.org/")
end
# Mensagens flash
def aviso(conn, _params) do
conn
|> put_flash(:error, "Let's pretend we have an error.")
|> render(:home)
end
end
A action criado responde com o status 201 (Created) e corpo vazio, usando Plug.Conn.send_resp/3.
Trilha.pergunta(:sem_tipo)
Trilha.mostrar(:get, "/lab/criado")
O content-type é []: nenhum cabeçalho. Em http://localhost:4010/lab/criado, a página fica em branco; a aba Network das ferramentas de desenvolvedor do navegador mostra o status 201.
Sua vez: um 201 com content type
Use put_resp_content_type/2 antes de send_resp/3 para declarar que a resposta é text/plain. Edite a action criado na célula do LabController.
Trilha.conferir(:criado)
Trilha.solucao(:criado)
Com as funções do Plug.Conn dá para montar exatamente a resposta que se precisa.
Definindo o content type
Na documentação: Setting the content type
Assim como o parâmetro _format, dá para renderizar qualquer formato trocando o cabeçalho Content-Type e fornecendo o template adequado. A action xml do laboratório é a versão do guia:
def xml(conn, _params) do
conn
|> put_resp_content_type("text/xml")
|> put_format(:xml)
|> render(:home, content: "<message>this is some XML</message>")
end
put_resp_content_type/2define o cabeçalho da resposta.put_format/2define o formato da renderização, sem passar pelo plug:accepts. É ele que faz orender/3procurar a view XML.render/3chama a funçãohome/1da view registrada para:xml.
Trilha.mostrar(:get, "/lab/xml")
A view XML ainda não existe. A exceção diz qual template ("home"), em qual formato (xml) e em qual view o render procurou.
O guia pede uma view PageXML com um template home.xml.eex. A extensão .eex indica que o template é renderizado pelo EEx, a biblioteca de templates que vem com o Elixir, e não pelo HEEx, que só entende HTML. No projeto, embed_templates "page_xml/*" transformaria o arquivo em uma função home(assigns). No notebook, EEx.function_from_string/5 faz essa mesma transformação a partir de uma string.
Sua vez: a view XML
Complete a célula abaixo. O template deve ter, na primeira linha, a declaração <?xml version="1.0" encoding="UTF-8"?> e, na segunda, o assign @content, escrito como <%= @content %> (no EEx a interpolação usa <%= %>, não chaves).
lib/hello_web/controllers/page_xml.ex
defmodule HelloWeb.PageXML do
require EEx
# EEx.function_from_string(:def, :home, "...template...", [:assigns])
end
Trilha.conferir(:xml)
Trilha.mostrar(:get, "/lab/xml")
O EEx não escapa nada: o valor de @content entrou como está, o que é o desejado para montar XML e é exatamente o motivo de o HTML usar HEEx. A lista de tipos MIME válidos está na biblioteca MIME.
Trilha.solucao(:xml)
Definindo o status HTTP
Na documentação: Setting the HTTP Status
O status da resposta é definido com put_status/2, de Plug.Conn, importado em todos os controllers. O segundo argumento pode ser um número ou um "nome amigável" em forma de átomo. A tabela de nomes está na documentação de Plug.Conn.Status.code/1:
for nome <- [:ok, :created, :accepted, :found, :not_found, :not_acceptable] do
{nome, Plug.Conn.Status.code(nome)}
end
Use a célula acima para descobrir o nome de outros status. O valor precisa ser um status válido: troque um dos átomos por :inexistente e veja o FunctionClauseError, porque nenhuma cláusula de code/1 aceita esse átomo.
Sua vez: um 202
No LabController, escreva a action aceito/2. Ela coloca o status 202 com put_status/2 (use o átomo) e renderiza o template :home.
Trilha.conferir(:aceito)
Trilha.solucao(:aceito)
Redirecionamento
Na documentação: Redirection
Muitas vezes é preciso redirecionar no meio de uma requisição. Uma action create bem-sucedida costuma redirecionar para a show do recurso recém-criado, ou para a index. Para isso existe redirect/2, que diferencia redirecionar para um caminho dentro da aplicação e redirecionar para uma URL.
Sua vez: redirect_test
O guia cria uma rota /redirect_test, que renderiza a página inicial em outro endereço, e faz a action home redirecionar para ela. Aqui o redirecionamento sai do laboratório:
- Na célula do router, dentro do
scope "/", acrescenteget "/redirect_test", PageController, :redirect_test. - Na célula do
PageController, acrescente a actionredirect_test/2, que fazrender(conn, :home). - No
LabController, escreva a actionredirecionar/2, que fazredirect(conn, to: ~p"/redirect_test").
O ~p é o sigil das rotas verificadas (Phoenix.VerifiedRoutes.sigil_p/2), a forma preferida de referenciar qualquer caminho da aplicação. Se a rota ainda não existir quando o LabController for avaliado, a célula mostra um aviso do compilador: no route path for HelloWeb.Router matches "/redirect_test". Avalie o router primeiro.
Trilha.conferir(:redirect)
Trilha.mostrar(:get, "/lab/redirecionar")
A resposta tem status 302 e o cabeçalho location. O corpo é só um aviso; quem faz o trabalho é o navegador, que lê o location e dispara uma segunda requisição. Trilha.navegar/2 faz o mesmo:
Trilha.navegar("/lab/redirecionar")
Abra http://localhost:4010/lab/redirecionar com a aba Network das ferramentas de desenvolvedor aberta. Você vai ver as mesmas duas requisições: um GET para /lab/redirecionar com status 302 e um GET para /redirect_test com status 200.
Trilha.solucao(:redirect)
Para fora da aplicação
Trilha.pergunta(:redirect_to)
A célula abaixo chama redirect/2 diretamente, com um conn de teste, do jeito errado:
Phoenix.Controller.redirect(Plug.Test.conn(:get, "/"), to: "https://elixir-lang.org/")
Para uma URL completa ou externa, a opção é :external. É o que faz a action externo do laboratório:
Trilha.navegar("/lab/externo")
Trilha.navegar/2 para no primeiro passo, porque o destino fica fora da aplicação.
Mensagens flash
Na documentação: Flash messages
Às vezes a action precisa dizer algo ao usuário: houve um erro ao salvar, ou só "bem-vindo de volta". Para isso existem as mensagens flash.
put_flash/3, de Phoenix.Controller, guarda uma mensagem como um par de chave e valor, que vai parar no assign @flash. Para mostrá-la, o template lê a mensagem com Phoenix.Flash.get/2, que recebe o mapa da flash e a chave. É o que o Layouts.app deste notebook faz para as chaves :info e :error; no projeto gerado, o componente <.flash_group flash={@flash} /> faz o mesmo, com estilo e botão de fechar.
A action aviso do laboratório é a primeira versão do guia: put_flash/3 seguido de render/2.
Trilha.navegar("/lab/aviso", recarregar: true)
Na requisição 1, a mensagem aparece na página. A requisição 2 repete a mesma URL, como quem recarrega a página: a action roda de novo e coloca a flash de novo. Repare na última coluna, que mostra o que a resposta deixou guardado na sessão sob a chave da flash: nil nas duas. Sem redirect, a mensagem vale só para a página que está sendo renderizada.
A flash fica interessante combinada com um redirect: a mensagem é definida em uma requisição e mostrada na seguinte.
Trilha.pergunta(:flash_redirect)
Sua vez: flash e redirect
No LabController, troque o render(:home) da action aviso por redirect(to: ~p"/redirect_test"). Depois navegue de novo:
Trilha.navegar("/lab/aviso", recarregar: true)
Leia a tabela linha por linha:
GET /lab/avisodefine a flash e responde 302. Como a resposta é um redirect, o Phoenix guarda a mensagem na sessão (última coluna), e a sessão viaja no cookie.GET /redirect_testchega com o cookie. A flash é lida da sessão paraconn.assigns.flash, aparece na página e sai da sessão: a última coluna volta anil.- Recarregar
/redirect_testnão traz mais a mensagem:conn.assigns.flashé%{}.
Trilha.conferir(:flash)
Trilha.solucao(:flash)
Além de put_flash/3, há clear_flash/1, que recebe só o conn e remove todas as mensagens flash que estiverem guardadas na sessão.
O Phoenix não impõe quais chaves usar na flash. Basta ser consistente. :info e :error, porém, são as comuns e as que os templates gerados tratam por padrão.
Páginas de erro
Na documentação: Error pages
O Phoenix tem duas views para erros, ErrorHTML e ErrorJSON, em lib/hello_web/controllers/. Elas tratam os erros de forma centralizada para requisições HTML e JSON. Como as views deste guia, as de erro respondem nos dois formatos. O notebook seguinte é dedicado a elas.
Uma prévia: a primeira célula do notebook registra as duas views em render_errors, e cada uma é chamada com o nome do template de erro, como "404.html" ou "404.json". Enquanto o notebook roda com debug_errors: true, como o ambiente de desenvolvimento, quem responde aos erros é a página de depuração. Por isso a célula abaixo chama as views diretamente:
%{
html: HelloWeb.ErrorHTML.render("404.html", %{}),
json: HelloWeb.ErrorJSON.render("404.json", %{})
}
Sem roteiro: produtos do Brechó
Junte o que o guia mostrou em uma funcionalidade pequena do Brechó, o marketplace de usados da trilha. O catálogo já existe, como dados fixos:
defmodule Hello.Catalogo do
@produtos [
%{id: 1, nome: "Jaqueta jeans", preco: 89},
%{id: 2, nome: "Vitrola portátil", preco: 250},
%{id: 3, nome: "Coleção de gibis", preco: 40}
]
def list_produtos, do: @produtos
def get_produto(id) when is_binary(id) do
Enum.find(@produtos, fn produto -> Integer.to_string(produto.id) == id end)
end
end
Crie:
GET /produtos, que lista o nome dos três produtos em HTML;GET /produtos/:id, que mostra o nome e o preço do produto em HTML, ou em JSON com?_format=json({"id": 2, "nome": "Vitrola portátil", "preco": 250});- para um id inexistente, um redirect para
/produtoscom a flash:errorProduto não encontrado..
Use uma célula para o controller e as views (ProdutoController, ProdutoHTML, ProdutoJSON) e edite a célula do router. Lembre-se de avaliar o router antes do controller, por causa do ~p.
# lib/hello_web/controllers/produto_controller.ex, produto_html.ex e produto_json.ex
Trilha.conferir(:produtos)
Trilha.solucao(:produtos)
No seu projeto
Repita o guia em um projeto gerado por mix phx.new hello. A correspondência entre as células e os arquivos é esta:
| Neste notebook | No projeto |
|---|---|
| Célula do router | lib/hello_web/router.ex |
Célula do PageController |
lib/hello_web/controllers/page_controller.ex |
Função home/1 de PageHTML |
lib/hello_web/controllers/page_html/home.html.heex |
Célula do PageJSON |
lib/hello_web/controllers/page_json.ex |
EEx.function_from_string em PageXML |
lib/hello_web/controllers/page_xml/home.xml.eex e embed_templates "page_xml/*" |
Parágrafos de flash em Layouts.app |
<.flash_group flash={@flash} />, dos CoreComponents |
Trilha.mostrar/2 |
Aba Network das ferramentas de desenvolvedor |
Trilha.navegar/2 |
Navegar e observar a sequência de requisições na aba Network |
Roteiro:
- Troque
homeporindexna rota e no controller. Antes de trocar no controller, recarregue a página e leia o erro. - Faça a action
showdoHelloControllerresponder comtext/2e depois comjson/2. Na aba Network, clique na requisição e compare o cabeçalhocontent-typede cada versão. - Crie o
PageJSON, aceitejsonna pipeline:browsere abrahttp://localhost:4000/?_format=json. - Faça a action
homeresponder comsend_resp(conn, 201, "")e observe o status 201 na aba Network. No Safari, a resposta sem content-type é baixada como arquivo. - Faça a action
homeredirecionar para/redirect_testcom uma flash. Na aba Network, veja o GET com status 302 seguido do GET com status 200, e recarregue para ver a mensagem sumir. - Leia o log do
mix phx.serverdurante o passo 5: cada uma das duas requisições aparece com o seu status.
No projeto gerado, a página inicial já chama <.flash_group flash={@flash} /> no começo do template, e as mensagens aparecem no canto superior direito.
Revisão
Escreva em uma célula de texto, com as suas palavras: o que uma action recebe, quais são as formas de responder e quando usar cada uma, como o formato escolhe a view e o que acontece entre put_flash/3, redirect/2 e a página seguinte. Depois compare com os pontos abaixo.
- Actions são funções que recebem
conneparams. O nome é livre, mas precisa ser o mesmo na rota e no controller, e as convenções (index,show,new,create,edit,update,delete) valem a pena. text/2,json/2ehtml/2respondem diretamente e definem o content-type.render/3passa por uma view e é a forma usual, especialmente para HTML.- Os assigns chegam ao template pelo terceiro argumento de
render/3ou porassign/3, que encadeia com o pipe. - A view é escolhida pelo formato:
PageControllermais:jsondáPageJSON. O plug:acceptsda pipeline decide quais formatos a requisição pode pedir, e_formatescolhe um deles.put_viewdeclara as views explicitamente. send_resp/3,put_resp_content_type/2eput_status/2, do Plug, montam qualquer resposta.put_format/2escolhe o formato da renderização no próprio controller.redirect/2responde 302 com o cabeçalholocation.to:aceita só caminhos da aplicação, de preferência escritos com~p;external:aceita URLs.- A flash vive na sessão: definida antes de um redirect, aparece na requisição seguinte e depois some.
Próximo notebook: 05_paginas_de_erro.livemd, sobre as views ErrorHTML e ErrorJSON e as exceções com status próprio.