Trilha Phoenix · JSON e APIs
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: verifica a conexão com o PostgreSQL, registra migrations,
roda comandos curl de verdade contra o servidor, confere os exercícios e
guarda as perguntas e as soluções.
Não faz parte do Phoenix nem do Ecto. Você não precisa ler este módulo para seguir o guia.
"""
@endpoint HelloWeb.Endpoint
@router HelloWeb.Router
@repo Hello.Repo
# --- banco de dados ------------------------------------------------------------
@doc "Tenta uma conexão com o PostgreSQL usando a configuração do Hello.Repo."
def verificar_postgres(extra \\ []) do
config = Keyword.merge(apply(@repo, :config, []), extra)
onde = "`#{config[:username]}@#{config[:hostname]}:#{config[:port] || 5432}`"
# Conecta ao banco "postgres", que sempre existe: o banco da trilha talvez ainda não tenha sido criado.
resultado =
try do
Postgrex.Protocol.connect(Postgrex.Utils.default_opts(Keyword.put(config, :database, "postgres")))
rescue
erro -> {:error, erro}
end
case resultado do
{:ok, estado} ->
Postgrex.Protocol.disconnect(%RuntimeError{message: "fim da verificação"}, estado)
Kino.Markdown.new("**Conectou** ao PostgreSQL em #{onde}. Pode seguir.")
{:error, erro} ->
Kino.Markdown.new(
"**Não conectou** ao PostgreSQL em #{onde}. A mensagem do Postgrex:\n\n" <>
cerca(Exception.message(erro), "") <> "\n\n" <> dica_conexao(erro)
)
end
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28P01"}}) do
"**Senha recusada.** Confira `username` e `password` na célula `config/dev.exs`. " <>
"Com autenticação por senha, o PostgreSQL dá esta mesma resposta quando o usuário não existe."
end
defp dica_conexao(%Postgrex.Error{postgres: %{pg_code: "28000", message: mensagem}}) do
cond do
mensagem =~ ~r/ident|peer/i ->
"**O servidor não aceita senha para esta conexão.** Ele está configurado para autenticação `ident` ou `peer`, " <>
"o padrão de algumas distribuições Linux, como o Fedora. O `LEIA-ME.md` da trilha mostra como trocar para senha no `pg_hba.conf`."
mensagem =~ "does not exist" ->
"**O usuário não existe no PostgreSQL.** Crie-o no `psql` ou troque `username` na célula `config/dev.exs`."
true ->
"**O servidor recusou a conexão.** Leia a mensagem acima e confira o `pg_hba.conf` do servidor."
end
end
defp dica_conexao(%DBConnection.ConnectionError{message: mensagem}) do
cond do
mensagem =~ "econnrefused" ->
"**Nada responde nesse endereço.** O servidor PostgreSQL está parado, ou escuta em outra porta. O `LEIA-ME.md` mostra como iniciá-lo."
mensagem =~ "nxdomain" ->
"**O nome do host não existe.** Confira `hostname` na célula `config/dev.exs`."
true ->
"**A conexão falhou antes de chegar ao PostgreSQL.** Confira `hostname` e `port` na célula `config/dev.exs`."
end
end
defp dica_conexao(_erro), do: "Confira a configuração na célula `config/dev.exs`."
@doc "Apaga todas as tabelas do banco da trilha, inclusive schema_migrations."
def recomecar do
sql("DROP SCHEMA public CASCADE")
sql("CREATE SCHEMA public")
Kino.Markdown.new(
"O banco `#{apply(@repo, :config, [])[:database]}` está vazio: nenhuma tabela, nenhuma migration registrada."
)
end
@doc "Registra uma migration com a versão que, no projeto, viria do nome do arquivo."
def migracao(versao, modulo) do
:persistent_term.put({__MODULE__, :migracoes}, Map.put(mapa_migracoes(), versao, modulo))
nome = modulo |> Module.split() |> List.last() |> Macro.underscore()
Kino.Markdown.new("No projeto, esta migration seria o arquivo `priv/repo/migrations/#{versao}_#{nome}.exs`.")
end
@doc "As migrations registradas, como pares {versão, módulo}, em ordem de versão."
def migracoes, do: mapa_migracoes() |> Enum.sort()
defp mapa_migracoes, do: :persistent_term.get({__MODULE__, :migracoes}, %{})
@doc "Lista as tabelas e sequências do banco, como o comando \\d do psql."
def relacoes do
resultado =
sql("""
SELECT n.nspname AS "Schema", c.relname AS "Name",
CASE c.relkind WHEN 'r' THEN 'table' WHEN 'S' THEN 'sequence' WHEN 'v' THEN 'view' END AS "Type",
pg_get_userbyid(c.relowner) AS "Owner"
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'S', 'v')
ORDER BY c.relname
""")
if resultado.rows == [] do
Kino.Markdown.new("`\\d`: o banco não tem nenhuma tabela.")
else
Kino.Markdown.new("**`\\d`**\n\n" <> tabela_markdown(resultado))
end
end
@doc "Mostra as colunas, os índices e as chaves estrangeiras de uma tabela, como o comando \\d TABELA do psql."
def descrever(tabela) do
colunas =
sql(
"""
SELECT a.attname AS "Column", format_type(a.atttypid, a.atttypmod) AS "Type",
CASE WHEN a.attnotnull THEN 'not null' ELSE '' END AS "Nullable",
coalesce(pg_get_expr(d.adbin, d.adrelid), '') AS "Default"
FROM pg_attribute a
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = to_regclass($1) AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum
""",
[tabela]
)
if colunas.rows == [] do
Kino.Markdown.new("`\\d #{tabela}`: a tabela `#{tabela}` não existe.")
else
indices = sql("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname", [tabela])
lista = Enum.map_join(indices.rows, "\n", fn [definicao] -> "* `#{definicao}`" end)
chaves =
sql(
"""
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = to_regclass($1) AND contype = 'f' ORDER BY conname
""",
[tabela]
)
fks =
if chaves.rows == [],
do: "",
else: "\n\n**Chaves estrangeiras**\n\n" <> Enum.map_join(chaves.rows, "\n", fn [nome, definicao] -> "* `#{nome}` #{definicao}" end)
Kino.Markdown.new(
"**`\\d #{tabela}`**\n\n" <>
tabela_markdown(colunas) <> "\n\n**Índices**\n\n" <> if(lista == "", do: "nenhum", else: lista) <> fks
)
end
end
@doc "Executa um SQL e mostra o resultado como tabela."
def consultar(consulta, parametros \\ []) do
resultado = sql(consulta, parametros)
Kino.Markdown.new(cerca(consulta, "sql") <> "\n\n" <> tabela_markdown(resultado))
end
defp sql(consulta, parametros \\ []) do
apply(@repo, :query!, [consulta, parametros, [log: false]])
end
defp tabela_markdown(%{columns: colunas, rows: linhas}) do
cabecalho = "| " <> Enum.join(colunas, " | ") <> " |"
separador = "| " <> Enum.map_join(colunas, " | ", fn _ -> "---" end) <> " |"
corpo =
Enum.map(linhas, fn linha ->
"| " <> Enum.map_join(linha, " | ", &celula/1) <> " |"
end)
Enum.join([cabecalho, separador | corpo], "\n")
end
defp celula(nil), do: ""
defp celula(""), do: ""
defp celula(valor) when is_binary(valor), do: "`" <> String.replace(valor, "|", "\\|") <> "`"
defp celula(%Decimal{} = valor), do: "`" <> Decimal.to_string(valor) <> "`"
defp celula(valor), do: "`" <> (valor |> inspect() |> String.replace("|", "\\|")) <> "`"
defp cerca(texto, linguagem) do
marca = String.duplicate("`", 3)
marca <> linguagem <> "\n" <> String.trim(texto) <> "\n" <> marca
end
# --- curl ------------------------------------------------------------------------
@doc """
Roda um comando curl de verdade contra o servidor do notebook, por uma conexão
TCP, e mostra a resposta como o curl mostraria. Entende -i, -X, -H e -d.
"""
def curl(comando) do
{requisicao, resposta} = executar_curl(comando)
corpo = resumir_corpo(resposta)
cabeca = resposta.bruta |> String.split("\n\n", parts: 2) |> hd()
saida = if requisicao.incluir_cabecalhos, do: cabeca <> "\n\n" <> corpo, else: corpo
Kino.Markdown.new(cerca("$ " <> String.trim(comando), "console") <> "\n\n" <> cerca(saida, ""))
end
# A página de depuração do Plug.Debugger tem dezenas de KB; mostramos só o começo.
defp resumir_corpo(%{corpo: corpo} = resposta) when byte_size(corpo) > 3000 do
tipo = resposta.cabecalhos |> List.keyfind("content-type", 0, {nil, ""}) |> elem(1)
resumo =
cond do
tipo =~ "text/html" ->
titulo =
case Regex.run(~r{<title>(.*?)</title>}s, corpo) do
[_, t] -> String.trim(t)
nil -> "?"
end
"<!-- página HTML de #{byte_size(corpo)} bytes, título: #{titulo} -->"
true ->
corpo |> String.split("\n") |> Enum.take(12) |> Enum.join("\n")
end
resumo <> "\n[... #{byte_size(corpo)} bytes no total; o Trilha.curl mostra só o começo]"
end
defp resumir_corpo(%{corpo: corpo}), do: corpo
@doc "Como curl/1, mas devolve %{status:, cabecalhos:, corpo:, json:} para conferências."
def curl!(comando), do: comando |> executar_curl() |> elem(1)
defp executar_curl(comando) do
requisicao = interpretar_curl(comando)
{requisicao, enviar_http(requisicao)}
end
defp interpretar_curl(comando) do
palavras =
Regex.scan(~r/'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/, String.replace(comando, "\\\n", " "))
|> Enum.map(fn
[_, simples] -> simples
[_, "", duplas] -> String.replace(duplas, ~s(\\"), ~s("))
[_, "", "", solta] -> solta
end)
|> Enum.drop_while(&(&1 in ["$", "curl"]))
inicial = %{metodo: nil, url: nil, cabecalhos: [], dados: nil, incluir_cabecalhos: false}
requisicao = ler_opcoes_curl(palavras, inicial)
metodo = requisicao.metodo || if(requisicao.dados, do: "POST", else: "GET")
%{requisicao | metodo: metodo}
end
defp ler_opcoes_curl([], acc), do: acc
defp ler_opcoes_curl(["-H", cabecalho | resto], acc), do: ler_opcoes_curl(resto, %{acc | cabecalhos: acc.cabecalhos ++ [cabecalho]})
defp ler_opcoes_curl(["-d", dados | resto], acc), do: ler_opcoes_curl(resto, %{acc | dados: dados})
defp ler_opcoes_curl(["-X", metodo | resto], acc), do: ler_opcoes_curl(resto, %{acc | metodo: metodo})
defp ler_opcoes_curl(["-" <> letras | resto], acc) when letras in ["i", "iX", "Xi"] do
acc = %{acc | incluir_cabecalhos: true}
case {String.contains?(letras, "X"), resto} do
{true, [metodo | resto]} -> ler_opcoes_curl(resto, %{acc | metodo: metodo})
{false, _} -> ler_opcoes_curl(resto, acc)
end
end
defp ler_opcoes_curl([url | resto], acc), do: ler_opcoes_curl(resto, %{acc | url: url})
defp enviar_http(requisicao) do
porta = apply(@endpoint, :config, [:http])[:port]
%URI{path: caminho, query: consulta} = URI.parse(requisicao.url)
alvo = (caminho || "/") <> if(consulta, do: "?" <> consulta, else: "")
corpo = requisicao.dados || ""
tipo =
if requisicao.dados && !Enum.any?(requisicao.cabecalhos, &(&1 =~ ~r/^content-type:/i)),
do: ["Content-Type: application/x-www-form-urlencoded"],
else: []
# Como o curl: um cabeçalho passado com -H substitui o padrão de mesmo nome.
padrao =
Enum.reject(["User-Agent: curl/8", "Accept: */*"], fn linha ->
nome = linha |> String.split(":") |> hd() |> String.downcase()
Enum.any?(requisicao.cabecalhos, &(&1 |> String.split(":") |> hd() |> String.downcase() == nome))
end)
linhas =
["#{requisicao.metodo} #{alvo} HTTP/1.1", "Host: localhost:#{porta}"] ++
padrao ++ requisicao.cabecalhos ++ tipo ++
if(corpo != "", do: ["Content-Length: #{byte_size(corpo)}"], else: []) ++ ["Connection: close", "", corpo]
socket =
case :gen_tcp.connect(~c"127.0.0.1", porta, [:binary, active: false]) do
{:ok, socket} ->
socket
{:error, motivo} ->
raise "nada respondeu na porta #{porta} (#{inspect(motivo)}). Avalie a célula que inicia o HelloWeb.Endpoint."
end
:ok = :gen_tcp.send(socket, Enum.join(linhas, "\r\n"))
bruta = receber_tudo(socket, "")
:gen_tcp.close(socket)
[cabeca, corpo_resposta] = String.split(bruta, "\r\n\r\n", parts: 2)
[linha_status | linhas_cabecalho] = String.split(cabeca, "\r\n")
[_, status | _] = String.split(linha_status, " ")
cabecalhos =
Enum.map(linhas_cabecalho, fn linha ->
[nome, valor] = String.split(linha, ": ", parts: 2)
{String.downcase(nome), valor}
end)
json =
case Jason.decode(corpo_resposta) do
{:ok, valor} -> valor
_ -> nil
end
%{
status: String.to_integer(status),
cabecalhos: cabecalhos,
corpo: corpo_resposta,
json: json,
bruta: String.replace(cabeca, "\r\n", "\n") <> "\n\n" <> corpo_resposta
}
end
defp receber_tudo(socket, acc) do
case :gen_tcp.recv(socket, 0, 5000) do
{:ok, dados} -> receber_tudo(socket, acc <> dados)
{:error, :closed} -> acc
end
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_new_edit: %{
texto:
"o gerador pede `resources \"/urls\", UrlController, except: [:new, :edit]`. Por que uma API JSON dispensa as actions `new` e `edit`?",
opcoes: [
a: "Porque o Phoenix não sabe renderizar JSON nessas actions.",
b: "Porque `new` e `edit` existem para servir as páginas HTML com os formulários; um cliente de API manda os dados direto no `POST` e no `PUT`.",
c: "Porque a pipeline `:api` bloqueia essas duas rotas.",
d: "Porque JSON não consegue representar um formulário vazio."
],
correta: :b,
explicacao:
"Num site, `GET /urls/new` e `GET /urls/:id/edit` devolvem os formulários que depois enviam `POST /urls` e `PUT /urls/:id`. Um cliente de API (outro programa, um app de celular, o curl) monta os dados sozinho e chama direto `create` e `update`. As duas rotas não teriam o que mostrar."
},
urls_new: %{
texto:
"sem as rotas de `new`, o que acontece com `GET /api/urls/new`?",
opcoes: [
a: "404: nenhuma rota casa com esse caminho.",
b: "Cai na rota do `show`, com `id` igual a `\"new\"`; o Ecto não consegue converter `\"new\"` num inteiro e a resposta é 400.",
c: "Cai no `index` e lista as URLs.",
d: "405: o método não é permitido."
],
correta: :b,
explicacao:
"A rota do `show` é `/api/urls/:id`, e `:id` aceita qualquer segmento, inclusive `new`. `Repo.get!(Url, \"new\")` levanta `Ecto.Query.CastError`, porque a chave primária é um inteiro. O `phoenix_ecto` diz ao Plug que essa exceção corresponde ao status 400 (Bad Request): o erro foi do cliente, que mandou um id impossível."
},
o_que_devolve: %{
texto: "o que a função `HelloWeb.UrlJSON.index/1` devolve?",
opcoes: [
a: "Uma string com o JSON pronto.",
b: "Um map do Elixir; quem o transforma em JSON é o Phoenix, com a biblioteca Jason, depois que a view volta.",
c: "Um `%Plug.Conn{}` com o corpo da resposta.",
d: "Um template HEEx."
],
correta: :b,
explicacao:
"A view JSON só converte os dados da aplicação (structs do Ecto) em estruturas simples do Elixir: maps, listas, strings, números. O `render/3` do controller recebe esse map, codifica com `Jason` (a `json_library` configurada) e envia com o `content-type` `application/json`. Por isso dá para testar a view chamando a função direto, sem requisição."
},
ordem_rotas: %{
texto:
"no router, `get \"/:name\", HelloController, :show` vem depois de `resources \"/urls\", UrlController`. Se viesse antes, o que `GET /api/urls` responderia?",
opcoes: [
a: "A lista de URLs: o router escolhe sempre a rota mais específica.",
b: "`{\"greeting\":\"Hello urls!\"}`: o router testa as rotas na ordem em que foram declaradas, e `/:name` aceita `urls`.",
c: "Um erro de compilação: as duas rotas conflitam.",
d: "404."
],
correta: :b,
explicacao:
"O router do Phoenix vira cláusulas de função, na ordem em que as rotas aparecem, e a primeira que casa vence. `/:name` casa com qualquer caminho de um segmento, inclusive `/urls`. A célula abaixo confirma com `Phoenix.Router.route_info/4` num router com a ordem trocada."
},
sem_nome: %{
texto:
"`HelloJSON.greet/1` tem uma só cláusula, que exige a chave `\"name\"`. O que acontece com `curl http://localhost:4010/api`, sem nome?",
opcoes: [
a: "`{\"greeting\":\"Hello !\"}`.",
b: "`{\"greeting\":\"Hello nil!\"}`.",
c: "Status 500: nenhuma cláusula de `greet/1` casa com o map vazio (`FunctionClauseError`).",
d: "404: a rota exige o nome."
],
correta: :c,
explicacao:
"A rota `get \"/\"` existe, então o router chama o controller com `params` vazio, e o controller passa esse map para a view. `greet(%{\"name\" => name})` não casa com `%{}`, e o Elixir levanta `FunctionClauseError`. É um erro do servidor (500): a API não validou a entrada."
},
prioridade: %{
texto:
"`POST /api/name1?name=name3` com o corpo JSON `{\"name\": \"name2\"}`. Qual nome aparece na resposta?",
opcoes: [
a: "`name1`, o do caminho.",
b: "`name2`, o do corpo.",
c: "`name3`, o da query string.",
d: "Os três, numa lista."
],
correta: :a,
explicacao:
"O Plug junta tudo num só map, `params`, e quando há chaves repetidas a ordem de prioridade é: parâmetros do caminho, depois o corpo (de qualquer tipo), depois a query string. Os três continuam disponíveis em separado, em `conn.path_params`, `conn.body_params` e `conn.query_params`."
},
quando_fallback: %{
texto: "com `action_fallback HelloWeb.MyFallbackController`, quando o Phoenix chama o fallback?",
opcoes: [
a: "Sempre, depois de cada action.",
b: "Quando a action levanta uma exceção.",
c: "Quando a action devolve algo que não é um `%Plug.Conn{}`, como o `{:error, :not_found}` que escapou do `with`.",
d: "Quando a resposta tem status 400 ou maior."
],
correta: :c,
explicacao:
"Uma action normal devolve o `conn` que recebeu, transformado. Se ela devolve outra coisa, o Phoenix chama `MyFallbackController.call(conn, valor)` com o `conn` original e esse valor. Exceções não passam pelo fallback: elas seguem para a página de erro (ou para a de depuração, em desenvolvimento)."
},
sem_envelope: %{
texto:
"as actions `create` e `update` casam com `%{\"url\" => url_params}`. O que acontece com `PUT /api/urls/1` e o corpo `{\"title\": \"sem envelope\"}`, sem a chave `\"url\"`?",
opcoes: [
a: "O título é atualizado do mesmo jeito.",
b: "422, com um erro de validação no changeset.",
c: "400: nenhuma cláusula da action casa com os parâmetros, e o Phoenix levanta `Phoenix.ActionClauseError`, que vira Bad Request.",
d: "404."
],
correta: :c,
explicacao:
"O casamento de padrões acontece antes do contexto e do changeset: `update(conn, %{\"id\" => id, \"url\" => url_params})` não casa com `%{\"id\" => \"1\", \"title\" => ...}`. O Phoenix transforma o `FunctionClauseError` da action em `Phoenix.ActionClauseError`, que o Plug trata como 400: a requisição veio num formato que a API não aceita."
},
flags_api: %{
texto:
"você quer gerar uma API sem frontend, sem banco de dados, sem tradução e sem envio de e-mail. Quais opções passa ao `mix phx.new`?",
opcoes: [
a: "Só `--no-html`.",
b: "`--no-assets --no-dashboard --no-ecto --no-gettext --no-html --no-mailer`.",
c: "`--api`.",
d: "`--no-live --no-assets`."
],
correta: :b,
explicacao:
"Não existe uma opção `--api`: cada parte sai com a sua opção `--no-*`. `--no-html` tira as views HTML (e implica `--no-live`), `--no-assets` tira o esbuild e o Tailwind, `--no-ecto` o banco, `--no-gettext` a tradução, `--no-mailer` o Swoosh e `--no-dashboard` o LiveDashboard. Telemetry, PubSub e DNSCluster continuam; dá para tirar à mão."
}
}
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_api: ~S'''
# No router, o scope "/api" fica assim:
scope "/api", HelloWeb do
pipe_through :api
resources "/urls", UrlController, except: [:new, :edit]
end
''',
host: ~S'''
# Em HelloWeb.UrlJSON:
defp data(%Url{} = url) do
%{
id: url.id,
link: url.link,
title: url.title,
host: URI.parse(url.link).host
}
end
''',
eco: ~S'''
defmodule HelloWeb.EcoController do
use HelloWeb, :controller
def show(conn, params) do
json(conn, %{
path_params: conn.path_params,
body_params: conn.body_params,
query_params: conn.query_params,
params: params
})
end
end
''',
fallback: ~S'''
defmodule HelloWeb.MyFallbackController do
use HelloWeb, :controller
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> put_view(json: HelloWeb.ErrorJSON)
|> render(:"404")
end
def call(conn, {:error, :unauthorized}) do
conn
|> put_status(403)
|> put_view(json: HelloWeb.ErrorJSON)
|> render(:"403")
end
end
''',
link_valido: ~S'''
# Em Hello.Urls.Url:
def changeset(url, attrs) do
url
|> cast(attrs, [:link, :title])
|> validate_required([:link, :title])
|> validate_format(:link, ~r{^https?://}, message: "must start with http:// or https://")
end
''',
show_404: ~S'''
# Em Hello.Urls, junto de get_url!/1:
def fetch_url(id) do
case Repo.get(Url, id) do
nil -> {:error, :not_found}
%Url{} = url -> {:ok, url}
end
end
# No HelloWeb.UrlController:
def show(conn, %{"id" => id}) do
with {:ok, %Url{} = url} <- Urls.fetch_url(id) do
render(conn, :show, url: url)
end
end
''',
brecho: ~S'''
# lib/hello/vitrine.ex: mais uma função no contexto
def buscar_produto(id) do
case Repo.get(Produto, id) do
nil -> {:error, :not_found}
%Produto{} = produto -> {:ok, produto}
end
end
# lib/hello_web/router.ex, dentro do scope "/api":
resources "/produtos", ProdutoController, only: [:index, :show, :create]
post "/produtos/:id/vender", ProdutoController, :vender
# lib/hello_web/controllers/fallback_controller.ex: mais uma cláusula
def call(conn, {:error, :ja_vendido}) do
conn
|> put_status(:conflict)
|> put_view(json: HelloWeb.ErrorJSON)
|> render(:"409")
end
# lib/hello_web/controllers/produto_json.ex
defmodule HelloWeb.ProdutoJSON do
alias Hello.Vitrine.Produto
def index(%{produtos: produtos}) do
%{data: for(produto <- produtos, do: data(produto))}
end
def show(%{produto: produto}) do
%{data: data(produto)}
end
defp data(%Produto{} = produto) do
%{
id: produto.id,
nome: produto.nome,
preco_centavos: produto.preco_centavos,
vendido: produto.vendido
}
end
end
# lib/hello_web/controllers/produto_controller.ex
defmodule HelloWeb.ProdutoController do
use HelloWeb, :controller
alias Hello.Vitrine
alias Hello.Vitrine.Produto
action_fallback HelloWeb.FallbackController
def index(conn, _params) do
render(conn, :index, produtos: Vitrine.listar_disponiveis())
end
def show(conn, %{"id" => id}) do
with {:ok, produto} <- Vitrine.buscar_produto(id) do
render(conn, :show, produto: produto)
end
end
def create(conn, %{"produto" => attrs}) do
with {:ok, %Produto{} = produto} <- Vitrine.cadastrar_produto(attrs) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/produtos/#{produto}")
|> render(:show, produto: produto)
end
end
def vender(conn, %{"id" => id}) do
with {:ok, produto} <- Vitrine.buscar_produto(id),
{:ok, vendido} <- Vitrine.vender(produto) do
render(conn, :show, produto: vendido)
end
end
end
'''
}
end
# --- conferência dos exercícios ----------------------------------------------
@doc "Confere um exercício consultando o banco e fazendo requisições de verdade."
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, explicar(erro)}
end
defp verificacoes do
%{
rota_api: [
{"As rotas do `HelloWeb.UrlController` são as cinco actions de uma API, sem `new` e `edit`.",
fn ->
esperadas =
MapSet.new([
{:get, "/api/urls", :index},
{:get, "/api/urls/:id", :show},
{:post, "/api/urls", :create},
{:patch, "/api/urls/:id", :update},
{:put, "/api/urls/:id", :update},
{:delete, "/api/urls/:id", :delete}
])
obtidas = HelloWeb.UrlController |> rotas_de() |> MapSet.new(&{&1.verb, &1.path, &1.plug_opts})
cond do
MapSet.size(obtidas) == 0 ->
{:erro, "Nenhuma rota aponta para `HelloWeb.UrlController`. Acrescente a linha do `resources` dentro do scope `\"/api\"` e avalie a célula do router."}
obtidas == esperadas ->
:ok
Enum.any?(obtidas, fn {_, _, action} -> action in [:new, :edit] end) ->
{:erro, "Ainda há rotas para `new` ou `edit`. Use a opção `except: [:new, :edit]`."}
Enum.any?(obtidas, fn {_, caminho, _} -> not String.starts_with?(caminho, "/api/") end) ->
{:erro, "As rotas não começam com `/api`. O `resources` precisa ficar dentro do scope `\"/api\"`."}
true ->
{:erro, "As rotas ficaram `#{inspect(MapSet.to_list(obtidas))}`."}
end
end},
{"Essas rotas passam pela pipeline `:api`.",
fn ->
case Phoenix.Router.route_info(@router, "GET", "/api/urls", "localhost") do
%{plug: HelloWeb.UrlController, pipe_through: [:api]} -> :ok
%{plug: HelloWeb.UrlController, pipe_through: outras} -> {:erro, "As rotas passam por `#{inspect(outras)}`. Dentro do scope, use `pipe_through :api`."}
_ -> {:erro, "`GET /api/urls` não chega ao `UrlController`."}
end
end},
{"`curl http://localhost:4010/api/urls` responde 200 com `{\"data\": [...]}`.",
fn ->
resposta = chamar(:get, "/api/urls")
if resposta.status == 200 and is_list(resposta.json["data"]),
do: :ok,
else: {:erro, "A resposta veio com #{resumo(resposta)}. O `UrlController` foi avaliado depois do router?"}
end}
],
host: [
{"`GET /api/urls/:id` traz o campo `host`, tirado do link.",
fn ->
limpando(fn ->
url = criar_url("https://hexdocs.pm/phoenix/json_and_apis.html", "Conferência host")
resposta = chamar(:get, "/api/urls/#{url.id}")
case resposta.json do
%{"data" => %{"host" => "hexdocs.pm"}} ->
:ok
%{"data" => %{"host" => outro}} ->
{:erro, "O `host` veio `#{inspect(outro)}`; para `#{url.link}` o esperado é `\"hexdocs.pm\"`. Use `URI.parse(url.link).host`."}
%{"data" => dados} ->
{:erro, "O JSON não tem a chave `host`: veio `#{Jason.encode!(dados)}`. Acrescente-a em `data/1` e avalie a célula do `UrlJSON`."}
_ ->
{:erro, "A resposta veio com #{resumo(resposta)}."}
end
end)
end},
{"`GET /api/urls` traz o `host` em cada item.",
fn ->
limpando(fn ->
criar_url("https://elixir-lang.org/docs.html", "Conferência elixir")
criar_url("http://localhost:4010/api/urls", "Conferência local")
itens = chamar(:get, "/api/urls").json["data"] || []
errados = Enum.reject(itens, fn item -> item["host"] == URI.parse(item["link"]).host end)
if itens != [] and errados == [],
do: :ok,
else: {:erro, "Estes itens vieram sem o `host` certo: `#{Jason.encode!(errados)}`. O `index` usa o mesmo `data/1`."}
end)
end},
{"Os campos do gerador continuam lá: `id`, `link` e `title`.",
fn ->
limpando(fn ->
url = criar_url("https://phoenixframework.org", "Conferência campos")
dados = chamar(:get, "/api/urls/#{url.id}").json["data"] || %{}
case ["id", "link", "title"] -- Map.keys(dados) do
[] -> :ok
faltando -> {:erro, "Faltam as chaves #{inspect(faltando)} no JSON."}
end
end)
end}
],
eco: [
{"`POST /api/eco/name1?name=name3` com o corpo `{\"name\": \"name2\", \"extra\": true}` responde 200.",
fn ->
resposta = eco()
if resposta.status == 200 and is_map(resposta.json),
do: :ok,
else: {:erro, "A resposta veio com #{resumo(resposta)}. A rota está no router da seção anterior; a célula do `EcoController` foi avaliada?"}
end},
{"`path_params` traz só o parâmetro do caminho.",
fn -> mesmo_valor(eco().json["path_params"], %{"name" => "name1"}, "path_params", "`conn.path_params`") end},
{"`body_params` traz só o corpo.",
fn -> mesmo_valor(eco().json["body_params"], %{"name" => "name2", "extra" => true}, "body_params", "`conn.body_params`") end},
{"`query_params` traz só a query string.",
fn -> mesmo_valor(eco().json["query_params"], %{"name" => "name3"}, "query_params", "`conn.query_params`") end},
{"`params` traz tudo junto, com o nome do caminho por cima dos outros.",
fn -> mesmo_valor(eco().json["params"], %{"name" => "name1", "extra" => true}, "params", "o segundo argumento da action, `params`") end}
],
fallback: [
{"`GET /api/posts/9` responde 404 com `{\"errors\":{\"detail\":\"Not Found\"}}`.",
fn ->
erro_json(
chamar(:get, "/api/posts/9"),
404,
"Not Found",
"A cláusula de `{:error, :not_found}` já vinha pronta. Avalie a célula do `MyFallbackController` e depois a do `MyController` com `action_fallback`."
)
end},
{"`GET /api/posts/1` sem usuário responde 403 com `{\"errors\":{\"detail\":\"Forbidden\"}}`.",
fn ->
resposta = chamar(:get, "/api/posts/1")
if resposta.status == 500 do
{:erro, "A resposta veio com #{resumo(resposta)}. O fallback recebeu `{:error, :unauthorized}` e não tem cláusula para esse valor: o Elixir levantou `FunctionClauseError`. Escreva a segunda cláusula de `call/2`."}
else
erro_json(resposta, 403, "Forbidden", "Use `put_status(403)` e renderize o template `:\"403\"` com a `HelloWeb.ErrorJSON`.")
end
end},
{"`GET /api/posts/1` com o cabeçalho `x-user: ana` responde 200.",
fn ->
resposta = chamar(:get, "/api/posts/1", nil, ["x-user: ana"])
if resposta.status == 200 and resposta.json["data"]["author"] == "ana",
do: :ok,
else: {:erro, "A resposta veio com #{resumo(resposta)}. A autora do post 1 deve conseguir vê-lo."}
end}
],
link_valido: [
{"`ftp://exemplo.com` é recusado com 422 e um erro em `link`.",
fn -> limpando(fn -> link_recusado("ftp://exemplo.com") end) end},
{"`exemplo.com`, sem `http://` nem `https://`, também é recusado.",
fn -> limpando(fn -> link_recusado("exemplo.com") end) end},
{"`https://exemplo.com` continua sendo aceito, com 201.",
fn ->
limpando(fn ->
resposta = chamar(:post, "/api/urls", %{"url" => %{"link" => "https://exemplo.com", "title" => "Conferência https"}})
if resposta.status == 201,
do: :ok,
else: {:erro, "A resposta veio com #{resumo(resposta)}. A validação está recusando links válidos."}
end)
end},
{"Sem link, o erro continua sendo só `can't be blank`.",
fn ->
resposta = chamar(:post, "/api/urls", %{"url" => %{"title" => "Conferência sem link"}})
case resposta.json do
%{"errors" => %{"link" => ["can't be blank"]}} -> :ok
_ -> {:erro, "A resposta veio com #{resumo(resposta)}. Um link ausente deve dar um só erro."}
end
end}
],
show_404: [
{"`Hello.Urls.fetch_url/1` existe.", fn -> exporta(Hello.Urls, fetch_url: 1) end},
{"`fetch_url/1` devolve `{:ok, url}` para um id que existe e `{:error, :not_found}` para um que não existe.",
fn ->
limpando(fn ->
url = criar_url("https://phoenixframework.org", "Conferência fetch")
case {apply(Hello.Urls, :fetch_url, [url.id]), apply(Hello.Urls, :fetch_url, [999_999])} do
{{:ok, %{id: id}}, {:error, :not_found}} when id == url.id -> :ok
{primeiro, segundo} -> {:erro, "Veio `#{inspect(primeiro, limit: 4)}` e `#{inspect(segundo, limit: 4)}`."}
end
end)
end},
{"`GET /api/urls/999999` responde 404 em JSON: `{\"errors\":{\"detail\":\"Not Found\"}}`.",
fn ->
resposta = chamar(:get, "/api/urls/999999")
if resposta.status == 404 and resposta.json == nil do
{:erro, "A resposta veio com #{resumo(resposta)}: ainda é a página de depuração. A action `show` continua chamando `get_url!/1`, que levanta exceção. Use `with` e `fetch_url/1`."}
else
erro_json(resposta, 404, "Not Found", "Devolva o `{:error, :not_found}` para o fallback.")
end
end},
{"Uma URL que existe continua respondendo 200.",
fn ->
limpando(fn ->
url = criar_url("https://phoenixframework.org", "Conferência existe")
resposta = chamar(:get, "/api/urls/#{url.id}")
if resposta.status == 200 and resposta.json["data"]["id"] == url.id,
do: :ok,
else: {:erro, "A resposta veio com #{resumo(resposta)}."}
end)
end}
],
brecho: [
{"`Hello.Vitrine.buscar_produto/1` existe.", fn -> exporta(Hello.Vitrine, buscar_produto: 1) end},
{"As rotas `GET /api/produtos`, `GET /api/produtos/:id`, `POST /api/produtos` e `POST /api/produtos/:id/vender` apontam para o `HelloWeb.ProdutoController`.",
fn ->
esperadas = MapSet.new([{:get, "/api/produtos"}, {:get, "/api/produtos/:id"}, {:post, "/api/produtos"}, {:post, "/api/produtos/:id/vender"}])
obtidas = HelloWeb.ProdutoController |> rotas_de() |> MapSet.new(&{&1.verb, &1.path})
case MapSet.difference(esperadas, obtidas) |> MapSet.to_list() do
[] -> :ok
faltando -> {:erro, "Faltam as rotas `#{inspect(faltando)}`. Elas ficam no scope `\"/api\"` do router do desafio."}
end
end},
{"`POST /api/produtos` com dados válidos responde 201, com o cabeçalho `location` e o produto em `data`.",
fn ->
limpando(fn ->
resposta = chamar(:post, "/api/produtos", %{"produto" => %{"nome" => "Conferência criar", "preco_centavos" => 4500}})
local = cabecalho(resposta, "location")
cond do
resposta.status != 201 ->
{:erro, "A resposta veio com #{resumo(resposta)}."}
not match?(%{"data" => %{"id" => _, "nome" => "Conferência criar", "preco_centavos" => 4500, "vendido" => false}}, resposta.json) ->
{:erro, "O corpo veio `#{resposta.corpo}`; o esperado é `{\"data\": {\"id\": ..., \"nome\": ..., \"preco_centavos\": ..., \"vendido\": false}}`."}
local != "/api/produtos/#{resposta.json["data"]["id"]}" ->
{:erro, "O cabeçalho `location` veio `#{inspect(local)}`. Use `put_resp_header(\"location\", ~p\"/api/produtos/\#{produto}\")`."}
true ->
:ok
end
end)
end},
{"Dados inválidos respondem 422, com o erro do changeset em `errors`.",
fn ->
limpando(fn ->
resposta = chamar(:post, "/api/produtos", %{"produto" => %{"nome" => "Conferência inválido", "preco_centavos" => 0}})
case resposta do
%{status: 422, json: %{"errors" => %{"preco_centavos" => [_ | _]}}} -> :ok
_ -> {:erro, "A resposta veio com #{resumo(resposta)}. Devolva o `{:error, changeset}` para o fallback, que já sabe responder 422."}
end
end)
end},
{"`GET /api/produtos` lista só as peças à venda.",
fn ->
limpando(fn ->
a_venda = cadastrar("Conferência à venda", 3000)
vendida = cadastrar("Conferência vendida", 2000)
{:ok, _} = apply(Hello.Vitrine, :vender, [vendida])
nomes = (chamar(:get, "/api/produtos").json["data"] || []) |> Enum.map(& &1["nome"])
cond do
a_venda.nome not in nomes -> {:erro, "A lista não traz a peça à venda. Veio `#{inspect(nomes)}`."}
vendida.nome in nomes -> {:erro, "A lista traz uma peça vendida. Use `Vitrine.listar_disponiveis/0`."}
true -> :ok
end
end)
end},
{"`GET /api/produtos/:id` responde 200; um id que não existe responde 404 em JSON.",
fn ->
limpando(fn ->
produto = cadastrar("Conferência mostrar", 1500)
existe = chamar(:get, "/api/produtos/#{produto.id}")
nao_existe = chamar(:get, "/api/produtos/999999")
cond do
existe.status != 200 or existe.json["data"]["id"] != produto.id -> {:erro, "Para um id que existe, a resposta veio com #{resumo(existe)}."}
true -> erro_json(nao_existe, 404, "Not Found", "Use `Vitrine.buscar_produto/1` e devolva o `{:error, :not_found}` para o fallback.")
end
end)
end},
{"A primeira venda responde 200 com `vendido: true`; a segunda, 409 com `{\"errors\":{\"detail\":\"Conflict\"}}`.",
fn ->
limpando(fn ->
produto = cadastrar("Conferência venda", 5000)
primeira = chamar(:post, "/api/produtos/#{produto.id}/vender")
segunda = chamar(:post, "/api/produtos/#{produto.id}/vender")
cond do
primeira.status != 200 or primeira.json["data"]["vendido"] != true ->
{:erro, "A primeira venda veio com #{resumo(primeira)}."}
true ->
erro_json(segunda, 409, "Conflict", "O fallback precisa de uma cláusula para `{:error, :ja_vendido}`, com `put_status(:conflict)` e o template `:\"409\"`.")
end
end)
end},
{"Vender um id que não existe responde 404 em JSON.",
fn -> erro_json(chamar(:post, "/api/produtos/999999/vender"), 404, "Not Found", "Busque o produto com `Vitrine.buscar_produto/1` dentro do `with`.") end},
{"Dez vendas simultâneas da mesma peça pela API: uma responde 200 e nove respondem 409.",
fn ->
limpando(fn ->
produto = cadastrar("Conferência corrida", 9900)
respostas =
1..10
|> Task.async_stream(fn _ -> chamar(:post, "/api/produtos/#{produto.id}/vender").status end, max_concurrency: 10)
|> Enum.map(fn {:ok, status} -> status end)
|> Enum.frequencies()
if respostas == %{200 => 1, 409 => 9},
do: :ok,
else: {:erro, "Os status foram `#{inspect(respostas)}`."}
end)
end}
]
}
end
# --- ajudantes da conferência --------------------------------------------------
defp rotas_de(controller) do
@router |> apply(:__routes__, []) |> Enum.filter(&(&1.plug == controller))
end
# Uma requisição HTTP de verdade, como a do curl, com um corpo JSON opcional.
defp chamar(metodo, caminho, corpo \\ nil, cabecalhos \\ []) do
enviar_http(%{
metodo: metodo |> to_string() |> String.upcase(),
url: "http://localhost" <> caminho,
cabecalhos: if(corpo, do: ["Content-Type: application/json" | cabecalhos], else: cabecalhos),
dados: corpo && Jason.encode!(corpo),
incluir_cabecalhos: false
})
end
defp cabecalho(resposta, nome), do: resposta.cabecalhos |> List.keyfind(nome, 0, {nome, nil}) |> elem(1)
# Uma linha que descreve a resposta: o status e o JSON, ou a exceção da página de depuração.
defp resumo(resposta) do
tipo = cabecalho(resposta, "content-type") || ""
corpo =
cond do
resposta.json != nil ->
"o corpo `" <> String.slice(Jason.encode!(resposta.json), 0, 300) <> "`"
tipo =~ "text/html" ->
case Regex.run(~r{<title>(.*?)</title>}s, resposta.corpo) do
[_, titulo] -> "a página de depuração `#{String.trim(titulo)}`"
nil -> "uma página HTML"
end
tipo =~ "markdown" ->
"a página de depuração `#{resposta.corpo |> String.split("\n") |> hd() |> String.trim_leading("# ")}`"
resposta.corpo == "" ->
"o corpo vazio"
true ->
"o corpo `#{String.slice(resposta.corpo, 0, 200)}`"
end
"status `#{resposta.status}` e #{corpo}"
end
defp erro_json(resposta, status, detalhe, dica) do
if resposta.status == status and resposta.json == %{"errors" => %{"detail" => detalhe}},
do: :ok,
else: {:erro, "A resposta veio com #{resumo(resposta)}. " <> dica}
end
defp eco, do: chamar(:post, "/api/eco/name1?name=name3", %{"name" => "name2", "extra" => true})
defp mesmo_valor(obtido, esperado, chave, dica) do
if obtido == esperado,
do: :ok,
else: {:erro, "Em `#{chave}`, o esperado é `#{Jason.encode!(esperado)}`, e veio `#{inspect(obtido)}`. Use #{dica}."}
end
defp link_recusado(link) do
resposta = chamar(:post, "/api/urls", %{"url" => %{"link" => link, "title" => "Conferência link"}})
case resposta do
%{status: 422, json: %{"errors" => %{"link" => [_ | _]}}} ->
:ok
%{status: 201} ->
{:erro, "O link `#{link}` foi aceito (201). Acrescente um `validate_format/4` para `:link` no `changeset/2` e avalie a célula do schema."}
_ ->
{:erro, "A resposta veio com #{resumo(resposta)}."}
end
end
defp criar_url(link, titulo) do
{:ok, url} = apply(Hello.Urls, :create_url, [%{link: link, title: titulo}])
url
end
defp cadastrar(nome, preco) do
{:ok, produto} = apply(Hello.Vitrine, :cadastrar_produto, [%{nome: nome, preco_centavos: preco}])
produto
end
# Apaga o que a conferência criou, mesmo quando ela falha no meio.
defp limpando(teste) do
teste.()
after
if tabela_existe?("urls"), do: sql("DELETE FROM urls WHERE title LIKE 'Conferência%'")
if tabela_existe?("produtos"), do: sql("DELETE FROM produtos WHERE nome LIKE 'Conferência%'")
end
defp tabela_existe?(tabela) do
sql("SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1", [tabela]).rows != []
end
defp exporta(modulo, funcoes) do
faltando = Enum.reject(funcoes, fn {nome, aridade} -> Code.ensure_loaded?(modulo) and function_exported?(modulo, nome, aridade) end)
if faltando == [],
do: :ok,
else: {:erro, "Falta definir em `#{inspect(modulo)}`: #{Enum.map_join(faltando, ", ", fn {n, a} -> "`#{n}/#{a}`" end)}."}
end
defp explicar(%UndefinedFunctionError{module: modulo, function: funcao, arity: aridade}) do
"`#{inspect(modulo)}.#{funcao}/#{aridade}` não existe. O módulo foi definido e a célula foi avaliada?"
end
defp explicar(%Postgrex.Error{postgres: %{code: :undefined_table, message: mensagem}}) do
"O PostgreSQL respondeu `#{mensagem}`. A migration que cria a tabela já rodou?"
end
defp explicar(%MatchError{term: termo}) do
"Um casamento de padrões falhou com o valor `#{inspect(termo, limit: 8)}`."
end
defp explicar(%RuntimeError{message: mensagem}), do: mensagem
defp explicar(erro), do: "Levantou uma exceção:\n\n `" <> Exception.format_banner(:error, erro) <> "`"
end
Como usar este notebook
Este notebook abre o módulo 7 da trilha, sobre APIs. Ele acompanha o guia JSON and APIs da documentação do Phoenix 1.8, na mesma ordem e com o link de cada seção.
Até aqui, a aplicação Hello respondia a um navegador, com páginas HTML. Uma API web responde a outros programas: um app de celular, um frontend em JavaScript, um script, outro servidor. Eles mandam e recebem dados, quase sempre em JSON, e não querem páginas. O Phoenix trata os dois casos com as mesmas peças: router, pipelines, controllers e views. Muda o formato da resposta.
As convenções são as de sempre: uma célula equivale a um arquivo, as perguntas "Antes de continuar" vêm antes da explicação, e os erros fazem parte do conteúdo. A ferramenta nova é Trilha.curl/1. O guia testa a API com o curl, um programa de terminal que faz requisições HTTP. Trilha.curl/1 recebe o mesmo comando, abre uma conexão de rede de verdade com o servidor do notebook, envia a requisição e mostra a resposta como o curl mostraria: com -i, a linha de status e os cabeçalhos; sem -i, só o corpo. Entende as opções -i, -X, -H e -d, que são as que o guia usa.
Os comandos estão escritos com a porta deste notebook, http://localhost:4010 (o guia usa a 4000). Enquanto o runtime estiver ligado, a aplicação responde nessa porta para qualquer programa da sua máquina: se você tiver o curl instalado, copie os comandos para um terminal e compare. No zsh, ponha entre aspas as URLs que têm ?, porque o zsh trata o ? como curinga.
Este notebook usa o PostgreSQL configurado no notebook 07. Se outro notebook da trilha estiver com o runtime ligado, desconecte-o antes. Avalie a primeira célula, no topo, antes de tudo.
A aplicação Hello dentro do notebook
As células desta seção são a aplicação Hello no ponto em que o guia começa: um projeto novo, com Ecto, ainda sem nenhum recurso. São as mesmas peças dos notebooks 08 a 11. A API deste notebook não usa os CoreComponents nem os layouts; eles estão aqui porque todo projeto novo os tem, e a página inicial, em http://localhost:4010, os usa. O router já traz a pipeline :api, que ainda nenhum scope usa. O aviso HelloWeb.PageController.init/1 is undefined que ele mostra é esperado: o controller vem depois, por causa do ~p.
config/dev.exs: a configuração do banco no ambiente de desenvolvimento, a mesma do notebook 07. Se o seu PostgreSQL usa outra senha ou outra porta, troque aqui e avalie a célula de novo.
Application.put_env(:hello, Hello.Repo,
username: System.get_env("PGUSER", "postgres"),
password: System.get_env("PGPASSWORD", "postgres"),
hostname: System.get_env("PGHOST", "localhost"),
port: String.to_integer(System.get_env("PGPORT", "5432")),
database: "trilha_phoenix_dev",
show_sensitive_data_on_connection_error: true,
pool_size: 10,
stacktrace: true
)
lib/hello/repo.ex
defmodule Hello.Repo do
use Ecto.Repo,
otp_app: :hello,
adapter: Ecto.Adapters.Postgres
end
lib/hello_web.ex: como no projeto gerado, o bloco html_helpers importa os CoreComponents e dá o apelido JS ao Phoenix.LiveView.JS. Os templates gerados usam os dois.
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
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
unquote(html_helpers())
end
end
defp html_helpers do
quote do
import Phoenix.HTML
import HelloWeb.CoreComponents
alias Phoenix.LiveView.JS
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
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: uma diferença em relação ao projeto. Lá, o Plug.Static serve o app.js compilado, que inclui o JavaScript do phoenix_html. Aqui não há compilação de assets, então o Plug.Static serve direto o arquivo phoenix_html.js que vem no pacote. É ele que faz os links com method="delete" e method="post" funcionarem no navegador.
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.Static,
at: "/assets",
from: {:phoenix_html, "priv/static"},
only: ~w(phoenix_html.js)
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/core_components.ex: os componentes que um projeto novo traz e que os templates gerados usam: flash, button, input, header, table, list e icon. O código é o do arquivo gerado pelo Phoenix 1.8.16, com uma diferença: no projeto, as mensagens de erro passam pelo Gettext, que traduz textos; aqui, translate_error/1 só preenche os valores na mensagem.
defmodule HelloWeb.CoreComponents do
use Phoenix.Component
alias Phoenix.LiveView.JS
@doc "Renders flash notices."
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label="close">
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc "Renders a button with navigation support."
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc "Renders an input with label and error messages."
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week hidden)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<input
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label for={@id}>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc "Renders a header with title."
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc "Renders a table with generic styling."
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc "Renders a data list."
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc "Renders a [Heroicon](https://heroicons.com)."
attr :name, :string, required: true
attr :class, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Fills in the error message values. In the generated project this function
translates the message with Gettext.
"""
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
@doc "Translates the errors for a field from a keyword list of errors."
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end
lib/hello_web/components/layouts.ex: o root layout e o layout da aplicação. No projeto, o root layout carrega o app.css e o app.js compilados. Aqui ele carrega o Tailwind e o daisyUI direto de uma CDN, para que as páginas geradas tenham a mesma aparência no navegador, e o phoenix_html.js servido pelo endpoint. Sem internet, as páginas funcionam do mesmo jeito, só que sem estilo.
defmodule HelloWeb.Layouts do
use HelloWeb, :html
def root(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<title>Hello · Phoenix Framework</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">
</script>
<script defer src={~p"/assets/phoenix_html.js"}>
</script>
</head>
<body>
{@inner_content}
</body>
</html>
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map, default: nil, doc: "the current scope"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<span class="text-sm font-semibold">Hello · Phoenix v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
</div>
"""
end
end
lib/hello_web/controllers/: as páginas de erro e a página inicial.
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
Antes de começar: o PostgreSQL
Este notebook usa o mesmo PostgreSQL do notebook 07, com o banco trilha_phoenix_dev. A célula abaixo tenta uma conexão e, se falhar, traduz a mensagem em uma dica. Os comandos para instalar e configurar o PostgreSQL estão no LEIA-ME.md da trilha.
Trilha.verificar_postgres()
Crie o banco, se ainda não existir (é o mix ecto.create). {:error, :already_up} quer dizer que ele já existe, e serve do mesmo jeito.
Ecto.Adapters.Postgres.storage_up(Hello.Repo.config())
lib/hello/application.ex: a árvore de supervisão inicia o Repo, o Phoenix.PubSub e o endpoint. O PubSub é o sistema de mensagens do Phoenix; os contextos gerados com escopo o usam para avisar sobre mudanças nos dados.
{:ok, _} = Kino.start_child(Hello.Repo)
{:ok, _} = Kino.start_child({Phoenix.PubSub, name: Hello.PubSub})
{:ok, _} = Kino.start_child(HelloWeb.Endpoint)
Kino.Markdown.new("Repo conectado e aplicação no ar em <http://localhost:4010>.")
Comece do zero: a célula abaixo apaga todas as tabelas do banco trilha_phoenix_dev, para que os números de id sejam os mesmos do texto. Avalie-a sempre que reabrir o notebook. Ela só mexe no banco da trilha.
Trilha.recomecar()
A API JSON
Na documentação: The JSON API
O guia monta uma API para guardar links favoritos, com as quatro operações de um CRUD: criar, ler, atualizar e apagar. Para isso, usa o gerador mix phx.gen.json, o primo do mix phx.gen.html do notebook 08. No Phoenix 1.8.16, a saída é esta:
$ mix phx.gen.json Urls Url urls link:string title:string
* creating lib/hello_web/controllers/url_controller.ex
* creating lib/hello_web/controllers/url_json.ex
* creating lib/hello_web/controllers/changeset_json.ex
* creating test/hello_web/controllers/url_controller_test.exs
* creating lib/hello_web/controllers/fallback_controller.ex
* creating lib/hello/urls/url.ex
* creating priv/repo/migrations/20221129120234_create_urls.exs
* creating lib/hello/urls.ex
* injecting lib/hello/urls.ex
* creating test/hello/urls_test.exs
* injecting test/hello/urls_test.exs
* creating test/support/fixtures/urls_fixtures.ex
* injecting test/support/fixtures/urls_fixtures.ex
Add the resource to the "/api" scope in lib/hello_web/router.ex:
resources "/urls", UrlController, except: [:new, :edit]
Remember to update your repository by running migrations:
$ mix ecto.migrate
O número no nome da migration é a data e a hora em que o gerador rodou; aqui fica o do guia. Os argumentos são os mesmos do phx.gen.html: o contexto (Urls), o schema (Url), a tabela (urls) e os campos. O guia divide os arquivos em quatro grupos:
- os de
lib/hello_web, que renderizam o JSON; - os de
lib/hello, o contexto e o schema, que guardam os links no banco; - o de
priv/repo/migrations, que cria a tabela; - os de
test, com os testes do controller e do contexto.
O guia, e este notebook, se concentram no primeiro grupo. Os do segundo e do terceiro aparecem abaixo como células, porque a API precisa deles para funcionar, mas seguem o mesmo molde do Catalog do notebook 08. Os testes ficam para o módulo de testes da trilha.
Compare com o phx.gen.html: não há templates nem UrlHTML. No lugar deles vem url_json.ex, e aparecem dois arquivos novos, changeset_json.ex e fallback_controller.ex. São eles que transformam erros em respostas JSON.
priv/repo/migrations/20221129120234_create_urls.exs
defmodule Hello.Repo.Migrations.CreateUrls do
use Ecto.Migration
def change do
create table(:urls) do
add :link, :string
add :title, :string
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20221129120234, Hello.Repo.Migrations.CreateUrls)
A célula abaixo é o mix ecto.migrate:
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
lib/hello/urls/url.ex: o schema gerado.
defmodule Hello.Urls.Url do
use Ecto.Schema
import Ecto.Changeset
schema "urls" do
field :link, :string
field :title, :string
timestamps(type: :utc_datetime)
end
@doc false
def changeset(url, attrs) do
url
|> cast(attrs, [:link, :title])
|> validate_required([:link, :title])
end
end
lib/hello/urls.ex: o contexto gerado, com as mesmas seis funções que o notebook 08 estudou no Catalog.
defmodule Hello.Urls do
@moduledoc """
The Urls context.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Urls.Url
@doc """
Returns the list of urls.
## Examples
iex> list_urls()
[%Url{}, ...]
"""
def list_urls do
Repo.all(Url)
end
@doc """
Gets a single url.
Raises `Ecto.NoResultsError` if the Url does not exist.
## Examples
iex> get_url!(123)
%Url{}
iex> get_url!(456)
** (Ecto.NoResultsError)
"""
def get_url!(id), do: Repo.get!(Url, id)
@doc """
Creates a url.
## Examples
iex> create_url(%{field: value})
{:ok, %Url{}}
iex> create_url(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_url(attrs) do
%Url{}
|> Url.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a url.
## Examples
iex> update_url(url, %{field: new_value})
{:ok, %Url{}}
iex> update_url(url, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_url(%Url{} = url, attrs) do
url
|> Url.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a url.
## Examples
iex> delete_url(url)
{:ok, %Url{}}
iex> delete_url(url)
{:error, %Ecto.Changeset{}}
"""
def delete_url(%Url{} = url) do
Repo.delete(url)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking url changes.
## Examples
iex> change_url(url)
%Ecto.Changeset{data: %Url{}}
"""
def change_url(%Url{} = url, attrs \\ %{}) do
Url.changeset(url, attrs)
end
end
lib/hello_web/controllers/changeset_json.ex e fallback_controller.ex: os dois arquivos que transformam erros em JSON. A seção "O FallbackController e o ChangesetJSON" volta a eles; por enquanto, basta saber que o controller gerado depende deles.
defmodule HelloWeb.ChangesetJSON do
@doc """
Renders changeset errors.
"""
def error(%{changeset: changeset}) do
# When encoded, the changeset returns its errors
# as a JSON object. So we just pass it forward.
%{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
end
defp translate_error({msg, opts}) do
# You can make use of gettext to translate error messages by
# uncommenting and adjusting the following code:
# if count = opts[:count] do
# Gettext.dngettext(HelloWeb.Gettext, "errors", msg, msg, count, opts)
# else
# Gettext.dgettext(HelloWeb.Gettext, "errors", msg, opts)
# end
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
end
defmodule HelloWeb.FallbackController do
@moduledoc """
Translates controller action results into valid `Plug.Conn` responses.
See `Phoenix.Controller.action_fallback/1` for more details.
"""
use HelloWeb, :controller
# This clause handles errors returned by Ecto's insert/update/delete.
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> put_view(json: HelloWeb.ChangesetJSON)
|> render(:error, changeset: changeset)
end
# This clause is an example of how to handle resources that cannot be found.
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> put_view(html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON)
|> render(:"404")
end
end
lib/hello_web/controllers/url_json.ex: a view JSON. A seção "Renderizando JSON" a explica.
defmodule HelloWeb.UrlJSON do
alias Hello.Urls.Url
@doc """
Renders a list of urls.
"""
def index(%{urls: urls}) do
%{data: for(url <- urls, do: data(url))}
end
@doc """
Renders a single url.
"""
def show(%{url: url}) do
%{data: data(url)}
end
defp data(%Url{} = url) do
%{
id: url.id,
link: url.link,
title: url.title
}
end
end
No fim, o gerador pede uma linha no router: o recurso /urls, dentro do scope "/api", sem new e sem edit.
Trilha.pergunta(:sem_new_edit)
Sua vez: a rota da API
O router do topo do notebook já tem a pipeline :api, com plug :accepts, ["json"], mas nenhum scope a usa. A célula abaixo é o router inteiro, com o scope "/api" já aberto. Acrescente dentro dele a linha que o gerador pediu e avalie a célula. Ela substitui a do topo; no projeto, é o mesmo arquivo, lib/hello_web/router.ex.
lib/hello_web/router.ex
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
scope "/api", HelloWeb do
pipe_through :api
# Sua vez: a linha que o gerador pediu.
end
end
Os avisos redefining module HelloWeb.Router e, com a linha nova, HelloWeb.UrlController.init/1 is undefined são esperados: o controller vem logo abaixo. A célula seguinte faz o mesmo que mix phx.routes:
Phoenix.Router.ConsoleFormatter.format(HelloWeb.Router) |> IO.puts()
Com a linha certa, aparecem seis rotas para o UrlController: index, show, create, delete e duas para update (PATCH e PUT).
lib/hello_web/controllers/url_controller.ex: o controller gerado. Ele vem depois do router porque o ~p do create é conferido contra o router quando o módulo compila. Se a rota ainda não existir, esta célula mostra o aviso no route path for HelloWeb.Router matches "/api/urls/#{url}".
defmodule HelloWeb.UrlController do
use HelloWeb, :controller
alias Hello.Urls
alias Hello.Urls.Url
action_fallback HelloWeb.FallbackController
def index(conn, _params) do
urls = Urls.list_urls()
render(conn, :index, urls: urls)
end
def create(conn, %{"url" => url_params}) do
with {:ok, %Url{} = url} <- Urls.create_url(url_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/urls/#{url}")
|> render(:show, url: url)
end
end
def show(conn, %{"id" => id}) do
url = Urls.get_url!(id)
render(conn, :show, url: url)
end
def update(conn, %{"id" => id, "url" => url_params}) do
url = Urls.get_url!(id)
with {:ok, %Url{} = url} <- Urls.update_url(url, url_params) do
render(conn, :show, url: url)
end
end
def delete(conn, %{"id" => id}) do
url = Urls.get_url!(id)
with {:ok, %Url{}} <- Urls.delete_url(url) do
send_resp(conn, :no_content, "")
end
end
end
Trilha.conferir(:rota_api)
Trilha.solucao(:rota_api)
O scope "/api" usa a pipeline :api, que faz uma coisa só: plug :accepts, ["json"] diz que essas rotas respondem em JSON. Não há sessão, flash, proteção contra CSRF nem layout: nada disso faz sentido para um programa que chama a API.
Trilha.pergunta(:urls_new)
Trilha.curl(~S"""
curl -i http://localhost:4010/api/urls/new
""")
A resposta é 400 Bad Request, e o corpo é a página de depuração de Ecto.Query.CastError: a rota do show aceitou new como :id, e o Ecto não conseguiu converter "new" num inteiro. Em desenvolvimento, o Phoenix responde a exceções com essa página (é o debug_errors: true da configuração). Ela vem em HTML porque o curl manda Accept: */*, que inclui HTML; o Trilha.curl mostra só o título, porque a página tem dezenas de KB. Em produção, a resposta seria o JSON da HelloWeb.ErrorJSON: {"errors":{"detail":"Bad Request"}}.
Testando a API
Na documentação: Trying out the JSON API
O guia sobe o servidor com mix phx.server. Aqui, ele já está no ar desde a célula do application.ex. Primeiro, um teste rápido: a lista de links.
Trilha.curl(~S"""
curl -i http://localhost:4010/api/urls
""")
200 OK, com {"data":[]}: a API funciona, mas o banco ainda não tem links. Leia os cabeçalhos:
content-type: application/json; charset=utf-8: o corpo é JSON;cache-control: max-age=0, private, must-revalidate: o padrão do Phoenix, para que ninguém guarde a resposta em cache;x-request-id: um identificador único da requisição, posto peloPlug.RequestIddo endpoint; num projeto gerado, o mesmo valor aparece em cada linha de log dessa requisição, porque oconfig/config.exsincluimetadata: [:request_id]no formato do Logger;content-length,dateevarysão do servidor HTTP;connection: closeresponde aoTrilha.curl, que fecha a conexão depois de cada requisição.
O guia mostra também server: Cowboy. Ele foi escrito quando o Cowboy era o servidor padrão dos projetos novos; no 1.8, o padrão é o Bandit, que não manda esse cabeçalho. A ordem dos cabeçalhos também muda de um servidor para outro, e não tem importância.
Agora, dois links:
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api/urls \
-H 'Content-Type: application/json' \
-d '{"url": {"link":"https://phoenixframework.org", "title":"Phoenix Framework"}}'
""")
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api/urls \
-H 'Content-Type: application/json' \
-d '{"url": {"link":"https://elixir-lang.org", "title":"Elixir"}}'
""")
-X POST escolhe o método, -H acrescenta o cabeçalho que diz que o corpo é JSON, e -d é o corpo. A resposta é 201 Created, com o link criado em data. O guia não comenta, mas o controller gerado põe também o cabeçalho location: /api/urls/1, o endereço do recurso novo, como manda a convenção do HTTP para o 201.
A lista inteira e um link só:
Trilha.curl(~S"""
curl -i http://localhost:4010/api/urls
""")
Trilha.curl(~S"""
curl -i http://localhost:4010/api/urls/1
""")
Atualizar o título do segundo link:
Trilha.curl(~S"""
curl -iX PUT http://localhost:4010/api/urls/2 \
-H 'Content-Type: application/json' \
-d '{"url": {"title":"Elixir Programming Language"}}'
""")
O corpo só tem title, e o link continua o mesmo: o cast/3 do changeset só muda os campos que vieram. Por fim, apagar:
Trilha.curl(~S"""
curl -iX DELETE http://localhost:4010/api/urls/2 \
-H 'Content-Type: application/json'
""")
204 No Content: deu certo, e não há corpo. O banco confirma:
Trilha.consultar("SELECT id, link, title FROM urls ORDER BY id")
Renderizando JSON
Na documentação: Rendering JSON
A action index do UrlController é igual à de um controller HTML:
def index(conn, _params) do
urls = Urls.list_urls()
render(conn, :index, urls: urls)
end
O Phoenix usa uma view por formato. Para HTML, seria a UrlHTML; para JSON, é a UrlJSON, ao lado do controller, em lib/hello_web/controllers/url_json.ex. Quem escolhe é o próprio render/3: use HelloWeb, :controller declara os formatos [:html, :json], e a pipeline :api definiu o formato da requisição como json. O Phoenix junta o nome do controller ao formato e chama HelloWeb.UrlJSON.index/1 com os assigns.
Trilha.pergunta(:o_que_devolve)
Dá para chamar a view direto, sem requisição:
HelloWeb.UrlJSON.index(%{urls: Hello.Urls.list_urls()})
É um map comum do Elixir. Depois que a view volta, o Phoenix o codifica com a biblioteca Jason (a json_library da configuração, na primeira célula) e o envia com o content-type de JSON:
HelloWeb.UrlJSON.index(%{urls: Hello.Urls.list_urls()}) |> Jason.encode!()
É a mesma string que o curl recebeu. A função privada data/1 escolhe, campo a campo, o que sai na resposta. inserted_at e updated_at ficam de fora, por exemplo. Essa escolha explícita protege a API: um campo novo no schema, como o hash de uma senha, não aparece no JSON até alguém o pôr ali.
Sua vez: um campo calculado
A view não precisa copiar colunas: ela pode calcular. Na célula do HelloWeb.UrlJSON, lá em cima, acrescente a data/1 um campo host, com o domínio do link: para https://phoenixframework.org, "phoenixframework.org". URI.parse/1 separa as partes de uma URL. Avalie a célula; não é preciso avaliar o controller de novo, porque ele chama a view enquanto a requisição acontece.
URI.parse("https://phoenixframework.org/blog")
Trilha.curl(~S"""
curl http://localhost:4010/api/urls
""")
Trilha.conferir(:host)
Trilha.solucao(:host)
Lendo os dados da requisição
Na documentação: Reading request data
Como o notebook 01 mostrou, toda action recebe dois argumentos, conn e params. O Plug lê três fontes de dados e junta tudo em params: os parâmetros do caminho (o :id de /api/urls/:id), a query string (o que vem depois do ?) e o corpo da requisição. Quem lê o corpo é o Plug.Parsers, no endpoint: com Content-Type: application/json, ele decodifica o JSON com o Jason.
Um exemplo mínimo
O guia usa um exemplo mínimo, com três rotas no scope "/api". A célula abaixo é o router completo para o resto do notebook: as rotas do exemplo do guia (incluindo a quarta, post "/:name", que o guia acrescenta na seção seguinte) e duas rotas que este notebook usa mais adiante, a do eco e a dos posts. Ela substitui o router do exercício.
lib/hello_web/router.ex
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :home
end
scope "/api", HelloWeb do
pipe_through :api
resources "/urls", UrlController, except: [:new, :edit]
# Usadas mais adiante neste notebook:
post "/eco/:name", EcoController, :show
get "/posts/:id", MyController, :show
# O exemplo mínimo do guia:
get "/", HelloController, :show
get "/:name", HelloController, :show
post "/", HelloController, :show
post "/:name", HelloController, :show
end
end
Os avisos init/1 is undefined para EcoController, MyController e HelloController são esperados: os controllers vêm nas células seguintes.
lib/hello_web/controllers/hello_controller.ex e hello_json.ex: o controller com uma action só, e a view JSON.
defmodule HelloWeb.HelloController do
use HelloWeb, :controller
def show(conn, params) do
render(conn, :greet, params)
end
end
defmodule HelloWeb.HelloJSON do
def greet(%{"name" => name}) do
%{greeting: "Hello #{name}!"}
end
end
O controller passa o próprio params como assigns, e a view usa a chave "name". As três requisições do guia dão a mesma resposta:
Trilha.curl(~S"""
curl http://localhost:4010/api/world
""")
Trilha.curl(~S"""
curl http://localhost:4010/api?name=world
""")
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api \
-H 'Content-Type: application/json' \
-d '{"name": "world"}'
""")
O nome veio do caminho, da query string e do corpo JSON, e a action não precisou saber de onde.
Trilha.pergunta(:sem_nome)
Trilha.curl(~S"""
curl -i http://localhost:4010/api
""")
500 Internal Server Error, com a página de depuração de FunctionClauseError. O guia não mostra este caso. A rota get "/" existe, então o controller rodou com params vazio, e nenhuma cláusula de HelloJSON.greet/1 casa com %{}. Em produção, a resposta seria {"errors":{"detail":"Internal Server Error"}}. Uma API de verdade confere a entrada antes de usá-la, com um changeset ou com uma cláusula para o caso que falta.
O router testa as rotas na ordem em que foram declaradas. Neste router, o resources "/urls" vem antes de get "/:name".
Trilha.pergunta(:ordem_rotas)
Phoenix.Router.route_info/4 diz qual rota casa com um método e um caminho, sem fazer a requisição:
Phoenix.Router.route_info(HelloWeb.Router, "GET", "/api/urls", "localhost")
E num router com a ordem trocada:
defmodule Exemplo.RouterInvertido do
use Phoenix.Router, helpers: false
scope "/api", HelloWeb do
get "/:name", HelloController, :show
resources "/urls", UrlController, except: [:new, :edit]
end
end
Phoenix.Router.route_info(Exemplo.RouterInvertido, "GET", "/api/urls", "localhost")
Agora GET /api/urls cai no HelloController, com path_params igual a %{"name" => "urls"}, e a resposta seria {"greeting":"Hello urls!"}. Repare que o compilador não avisou nada: a rota do index continua no router, mas nunca vai casar. Rotas com um parâmetro que aceita qualquer segmento, como /:name, vão depois das mais específicas.
Os dados da requisição são mesclados
Na documentação: Request data is merged
Como tudo vai para o mesmo map, mandar o mesmo campo por duas fontes (no caminho e no corpo, por exemplo) faz um valor esconder o outro. O router acima já tem a rota que o guia acrescenta, post "/:name".
Trilha.pergunta(:prioridade)
As três requisições do guia:
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api/name1?name=name3 \
-H 'Content-Type: application/json' \
-d '{"name": "name2"}'
""")
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api?name=name3 \
-H 'Content-Type: application/json' \
-d '{"name": "name2"}'
""")
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api?name=name3
""")
Hello name1!, Hello name2! e Hello name3!, como o guia diz. A prioridade é: caminho, depois corpo (de qualquer tipo), depois query string. Quando a origem importa, cada fonte continua disponível em separado no conn: conn.path_params, conn.body_params e conn.query_params.
Sua vez: o eco
O router já tem a rota post "/eco/:name", EcoController, :show. Escreva a action para que ela responda com um JSON de quatro chaves: path_params, body_params e query_params, com os valores do conn, e params, com o segundo argumento da action. Para uma resposta pequena assim, não é preciso uma view: json/2, do Phoenix.Controller, codifica um map e envia a resposta direto.
lib/hello_web/controllers/eco_controller.ex
defmodule HelloWeb.EcoController do
use HelloWeb, :controller
def show(conn, params) do
# Sua vez: acrescente as outras três chaves.
json(conn, %{params: params})
end
end
Trilha.curl(~S"""
curl -iX POST 'http://localhost:4010/api/eco/name1?name=name3' \
-H 'Content-Type: application/json' \
-d '{"name": "name2", "extra": true}'
""")
Trilha.conferir(:eco)
Trilha.solucao(:eco)
Repare em params: o name é o do caminho, e o extra, que só veio no corpo, entrou também. Os valores do JSON mantêm o tipo (true continua booleano); os da query string e do caminho são sempre strings.
Action fallback
Na documentação: Action fallback
O action fallback centraliza o tratamento de erros num plug, que o Phoenix chama quando a action não devolve um %Plug.Conn{}. Esse plug recebe o conn que a action recebeu e o valor que ela devolveu.
O exemplo do guia é uma action show que usa with para buscar um post e depois verificar se o usuário atual pode vê-lo. fetch_post/1 devolve {:error, :not_found} se o post não existe, e authorize_user/3 devolve {:error, :unauthorized} se o usuário não pode vê-lo. O guia não mostra essas duas funções nem de onde vem o usuário. Para o exemplo rodar, o notebook traz um módulo de apoio, HelloWeb.Posts, com dois posts na memória: o 1, um rascunho da Ana, que só ela vê, e o 2, público.
defmodule HelloWeb.Posts do
@moduledoc "Apoio do notebook: os posts do exemplo do guia, guardados na memória."
@posts %{
"1" => %{id: 1, title: "Rascunho da Ana", author: "ana", public: false},
"2" => %{id: 2, title: "Post público", author: "bia", public: true}
}
def fetch_post(id) do
case Map.fetch(@posts, id) do
{:ok, post} -> {:ok, post}
:error -> {:error, :not_found}
end
end
def authorize_user(user, :view, post) do
if post.public or post.author == user, do: :ok, else: {:error, :unauthorized}
end
end
Duas adaptações no controller do guia:
- O usuário atual. A action do guia tem três argumentos,
show(conn, params, current_user). O Phoenix chama cada action pela funçãoaction/2do controller, que por padrão passa sóconneparams. A documentação doPhoenix.Controllermostra como sobrescreveraction/2para passar um argumento a mais; é o que a célula faz. Aqui, o usuário vem do cabeçalhox-user, no lugar da autenticação, que é assunto de outro módulo da trilha. - O
use. O guia escreveuse Phoenix.Controller. No 1.8, isso gera o avisouse HelloWeb.MyController must receive the :formats option with the formats you intend to render, e orender/3procura uma view no estilo antigo,HelloWeb.MyView, que não existe. O notebook usause HelloWeb, :controller, como os controllers gerados, e uma viewHelloWeb.MyJSON.
lib/hello_web/controllers/my_controller.ex, a primeira versão, com o else do guia:
defmodule HelloWeb.MyJSON do
def show(%{post: post}), do: %{data: post}
end
defmodule HelloWeb.MyController do
use HelloWeb, :controller
import HelloWeb.Posts
def action(conn, _) do
current_user = conn |> get_req_header("x-user") |> List.first()
apply(__MODULE__, action_name(conn), [conn, conn.params, current_user])
end
def show(conn, %{"id" => id}, current_user) do
with {:ok, post} <- fetch_post(id),
:ok <- authorize_user(current_user, :view, post) do
render(conn, :show, post: post)
else
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> put_view(html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON)
|> render(:"404")
{:error, :unauthorized} ->
conn
|> put_status(403)
|> put_view(html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON)
|> render(:"403")
end
end
end
O rascunho, sem usuário, com a Ana e um post que não existe:
Trilha.curl(~S"""
curl -i http://localhost:4010/api/posts/1
""")
Trilha.curl(~S"""
curl -i -H 'x-user: ana' http://localhost:4010/api/posts/1
""")
Trilha.curl(~S"""
curl -i http://localhost:4010/api/posts/9
""")
Os erros saem da HelloWeb.ErrorJSON do projeto, a mesma que o Phoenix usa para as páginas de erro: o template :"403" vira "Forbidden" e o :"404" vira "Not Found". Funciona, mas o else teria de se repetir em todas as actions de todos os controllers da API. O guia propõe tirar esse código daí: como controllers são plugs de módulo, o tratamento pode ficar num controller só para isso.
Trilha.pergunta(:quando_fallback)
Sua vez: o fallback
A célula abaixo é o HelloWeb.MyFallbackController, com a cláusula do {:error, :not_found} pronta. Escreva a do {:error, :unauthorized}, que responde 403 com a HelloWeb.ErrorJSON, e avalie as duas células.
lib/hello_web/controllers/my_fallback_controller.ex
defmodule HelloWeb.MyFallbackController do
use HelloWeb, :controller
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> put_view(json: HelloWeb.ErrorJSON)
|> render(:"404")
end
# Sua vez: a cláusula de {:error, :unauthorized}.
end
lib/hello_web/controllers/my_controller.ex, a versão com action_fallback e sem o else:
defmodule HelloWeb.MyController do
use HelloWeb, :controller
import HelloWeb.Posts
action_fallback HelloWeb.MyFallbackController
def action(conn, _) do
current_user = conn |> get_req_header("x-user") |> List.first()
apply(__MODULE__, action_name(conn), [conn, conn.params, current_user])
end
def show(conn, %{"id" => id}, current_user) do
with {:ok, post} <- fetch_post(id),
:ok <- authorize_user(current_user, :view, post) do
render(conn, :show, post: post)
end
end
end
Quando uma condição do with não casa, o with devolve o valor que falhou, {:error, :unauthorized} por exemplo. Isso não é um %Plug.Conn{}, e o Phoenix chama MyFallbackController.call(conn, {:error, :unauthorized}). Sem a cláusula certa, o fallback levanta FunctionClauseError, e a resposta é 500:
Trilha.curl(~S"""
curl -i http://localhost:4010/api/posts/1
""")
Trilha.conferir(:fallback)
Trilha.solucao(:fallback)
O FallbackController e o ChangesetJSON
Na documentação: FallbackController and ChangesetJSON
Com isso, o HelloWeb.FallbackController que o phx.gen.json gerou fica claro. Ele está na célula da seção "A API JSON", e tem duas cláusulas: a do changeset, usada pelo UrlController, e a do {:error, :not_found}, gerada como exemplo. Nas actions create e update, quando o contexto devolve {:error, changeset}, o with devolve esse valor, e o fallback responde com status 422 e a view HelloWeb.ChangesetJSON:
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> put_view(json: HelloWeb.ChangesetJSON)
|> render(:error, changeset: changeset)
end
A ChangesetJSON chama Ecto.Changeset.traverse_errors/2, que junta os erros de cada campo numa lista de mensagens. O arquivo gerado tem uma função privada que o trecho do guia não mostra, translate_error/1. Ela preenche os valores nas mensagens: "should be at least %{count} character(s)" vira "should be at least 3 character(s)". O comentário dentro dela mostra como traduzir as mensagens com o Gettext.
Hello.Urls.change_url(%Hello.Urls.Url{}, %{title: "Sem link"})
|> then(&HelloWeb.ChangesetJSON.error(%{changeset: &1}))
O changeset/2 do Url exige link e title. Um link sem nenhum dos dois:
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api/urls \
-H 'Content-Type: application/json' \
-d '{"url": {}}'
""")
O corpo é o do guia. A linha de status diz 422 Unprocessable Content; o nome antigo desse status era "Unprocessable Entity", e o átomo :unprocessable_entity continua valendo no put_status/2.
Trilha.pergunta(:sem_envelope)
Trilha.curl(~S"""
curl -iX PUT http://localhost:4010/api/urls/1 \
-H 'Content-Type: application/json' \
-d '{"title": "sem envelope"}'
""")
400 Bad Request, com a página de depuração de Phoenix.ActionClauseError. A API espera os campos dentro de "url", como o formulário HTML os mandaria dentro de "product" no notebook 08. Sem o envelope, nem chega a haver um changeset.
Sua vez: só links de verdade
O guia sugere mexer no changeset e ver como a API reage. Na célula do Hello.Urls.Url, na seção "A API JSON", acrescente uma validação: o link precisa começar com http:// ou https://. Use validate_format/4, com uma mensagem sua. Avalie a célula; o contexto chama Url.changeset/2 enquanto a requisição acontece, então não é preciso avaliá-lo de novo.
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api/urls \
-H 'Content-Type: application/json' \
-d '{"url": {"link":"ftp://exemplo.com", "title":"FTP"}}'
""")
Trilha.conferir(:link_valido)
Trilha.solucao(:link_valido)
A API não mudou uma linha: a regra nova está no schema, e o caminho até a resposta (contexto, with, fallback, ChangesetJSON) já existia.
Sua vez: 404 sem exceção
E um link que não existe?
Trilha.curl(~S"""
curl -i http://localhost:4010/api/urls/999
""")
O status já é 404, mas o corpo é a página de depuração. A action show chama get_url!/1, que levanta Ecto.NoResultsError. O phoenix_ecto diz ao Plug que essa exceção corresponde ao status 404, e em desenvolvimento o Phoenix mostra a página de depuração. Com Accept: application/json, ela vem em Markdown:
Trilha.curl(~S"""
curl -i -H 'Accept: application/json' http://localhost:4010/api/urls/999
""")
Em produção, sem a página de depuração, o Phoenix renderizaria a HelloWeb.ErrorJSON com o status da exceção:
status = Plug.Exception.status(%Ecto.NoResultsError{message: "expected at least one result"})
{status, HelloWeb.ErrorJSON.render("#{status}.json", %{})}
Para que a API responda o JSON também em desenvolvimento, e sem depender de exceções, o contexto pode devolver uma tupla, e o fallback gerado já sabe tratar {:error, :not_found}:
- na célula do
Hello.Urls, acrescentefetch_url/1, que devolve{:ok, url}ou{:error, :not_found}(useRepo.get/2, que devolvenilem vez de levantar exceção); - na célula do
HelloWeb.UrlController, reescrevashow/2comwithefetch_url/1.
Avalie as duas células e repita o curl acima.
Trilha.conferir(:show_404)
Trilha.solucao(:show_404)
As duas formas são comuns. get_url!/1 deixa o caso raro (alguém pediu um id que não existe) virar exceção, e o Phoenix a converte em 404; o código fica curto. fetch_url/1 trata o caso como um resultado esperado, e a action decide a resposta.
Aplicações só de API
Na documentação: API-only applications
Para uma aplicação que é só uma API, mix phx.new aceita opções --no-* que deixam de fora o que não serve.
Trilha.pergunta(:flags_api)
No Phoenix 1.8.16, mix help phx.new lista estas opções --no-*, entre outras:
* `--no-assets` - equivalent to `--no-esbuild` and `--no-tailwind`
* `--no-dashboard` - do not include Phoenix.LiveDashboard
* `--no-ecto` - do not generate Ecto files
* `--no-esbuild` - do not include esbuild dependencies and assets. We do
not recommend setting this option, unless for API only applications, as
doing so requires you to manually add and track JavaScript dependencies
* `--no-gettext` - do not generate gettext files
* `--no-html` - do not generate HTML views
* `--no-live` - comment out LiveView socket setup in your Endpoint and
assets/js/app.js. Automatically disabled if --no-html is given
* `--no-mailer` - do not generate Swoosh mailer files
* `--no-tailwind` - do not include tailwind dependencies and assets. The
generated markup will still include Tailwind CSS classes, those are left-in
as reference for the subsequent styling of your layout and components
...
* `--no-version-check` - skip the version check for the latest phx_new
version
* `--no-agents-md` - do not generate an `AGENTS.md` file
As duas últimas não estão na lista do guia e não têm a ver com APIs: uma pula a verificação de versão nova do gerador, a outra não cria o AGENTS.md, um arquivo de instruções para assistentes de programação. Para uma API chamada hello, sem frontend, banco, tradução nem e-mail:
$ mix phx.new hello --no-assets --no-dashboard --no-ecto --no-gettext --no-html --no-mailer
O projeto ainda inclui Telemetry, PubSub e DNSCluster, que dá para tirar à mão. E nada impede o meio-termo, como a aplicação deste notebook: páginas HTML em / e uma API JSON em /api, no mesmo projeto.
Sem roteiro: a API do Brechó
Agora sem passo a passo. No notebook 08, o Brechó ganhou o contexto Hello.Vitrine, com as regras de cadastrar e vender peças, e um controller HTML. A tarefa é abrir uma segunda porta para o mesmo contexto: uma API JSON, para um app de celular, por exemplo. O contexto quase não muda; o trabalho é todo na camada web.
O Brechó até aqui
As células abaixo são a Vitrine do notebook 08: a migration, o schema e o contexto. O ProdutoController HTML do 08 fica de fora, porque o router deste notebook não tem as rotas /produtos do navegador.
priv/repo/migrations/20250320000000_create_produtos.exs
defmodule Hello.Repo.Migrations.CreateProdutos do
use Ecto.Migration
def change do
create table(:produtos) do
add :nome, :string, null: false
add :preco_centavos, :integer, null: false
add :vendido, :boolean, default: false, null: false
timestamps(type: :utc_datetime)
end
end
end
Trilha.migracao(20250320000000, Hello.Repo.Migrations.CreateProdutos)
Ecto.Migrator.run(Hello.Repo, Trilha.migracoes(), :up, all: true)
lib/hello/vitrine/produto.ex e lib/hello/vitrine.ex
defmodule Hello.Vitrine.Produto do
use Ecto.Schema
import Ecto.Changeset
schema "produtos" do
field :nome, :string
field :preco_centavos, :integer
field :vendido, :boolean, default: false
timestamps(type: :utc_datetime)
end
@doc false
def changeset(produto, attrs) do
produto
|> cast(attrs, [:nome, :preco_centavos])
|> validate_required([:nome, :preco_centavos])
|> validate_number(:preco_centavos, greater_than: 0)
end
end
defmodule Hello.Vitrine do
@moduledoc """
A vitrine do Brechó: o que está à venda e a venda de cada peça.
"""
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Vitrine.Produto
def listar_disponiveis do
Repo.all(from p in Produto, where: not p.vendido, order_by: p.preco_centavos)
end
def buscar_produto!(id), do: Repo.get!(Produto, id)
def cadastrar_produto(attrs) do
%Produto{}
|> Produto.changeset(attrs)
|> Repo.insert()
end
def change_produto(%Produto{} = produto, attrs \\ %{}) do
Produto.changeset(produto, attrs)
end
@doc """
Marca a peça como vendida, se ninguém a vendeu antes.
A verificação e a mudança acontecem num só UPDATE, então duas vendas
simultâneas da mesma peça nunca dão certo ao mesmo tempo.
"""
def vender(%Produto{id: id}) do
consulta = from p in Produto, where: p.id == ^id and not p.vendido, select: p
case Repo.update_all(consulta, set: [vendido: true, updated_at: DateTime.utc_now(:second)]) do
{1, [produto]} -> {:ok, produto}
{0, []} -> {:error, :ja_vendido}
end
end
end
O que fazer
A API do Brechó, no scope "/api":
GET /api/produtos: as peças à venda, em{"data": [...]}. Cada peça sai comid,nome,preco_centavosevendido, numa viewHelloWeb.ProdutoJSON.GET /api/produtos/:id: uma peça, em{"data": {...}}. Um id que não existe responde 404 com{"errors":{"detail":"Not Found"}}, sem a página de depuração. Para isso, acrescente àVitrine, na célula acima,buscar_produto/1, que devolve{:ok, produto}ou{:error, :not_found}.POST /api/produtos: cadastra a peça que vem em"produto". Responde 201, com o cabeçalholocatione a peça emdata. Dados inválidos respondem 422 com os erros do changeset.POST /api/produtos/:id/vender: vende a peça e responde 200 com ela, já com"vendido": true. Uma peça já vendida responde 409 Conflict, com{"errors":{"detail":"Conflict"}}; um id que não existe, 404.- Os erros passam pelo fallback: o
ProdutoControllerusaaction_fallback HelloWeb.FallbackController, e oFallbackControllergerado ganha uma cláusula para{:error, :ja_vendido}. Nenhuma action temelsenemcasepara erro.
lib/hello_web/router.ex: o router da seção "Lendo os dados da requisição". Acrescente as rotas do Brechó.
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
scope "/api", HelloWeb do
pipe_through :api
resources "/urls", UrlController, except: [:new, :edit]
# Sua vez: as rotas do Brechó.
post "/eco/:name", EcoController, :show
get "/posts/:id", MyController, :show
get "/", HelloController, :show
get "/:name", HelloController, :show
post "/", HelloController, :show
post "/:name", HelloController, :show
end
end
Com as rotas novas, o router mostra o aviso HelloWeb.ProdutoController.init/1 is undefined, esperado até a célula abaixo ser avaliada. Escreva nela a view, o controller e o FallbackController com a cláusula nova (pode redefinir o módulo inteiro aqui, com as três cláusulas). A célula vem depois do router por causa do ~p do cabeçalho location.
# lib/hello_web/controllers/produto_json.ex, produto_controller.ex e fallback_controller.ex
Para testar à mão:
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api/produtos \
-H 'Content-Type: application/json' \
-d '{"produto": {"nome": "Jaqueta jeans", "preco_centavos": 8000}}'
""")
Trilha.curl(~S"""
curl -i http://localhost:4010/api/produtos
""")
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api/produtos/1/vender
""")
Trilha.curl(~S"""
curl -iX POST http://localhost:4010/api/produtos/1/vender
""")
A conferência cria e apaga as próprias peças, faz as requisições por HTTP, como o curl, e dispara dez vendas simultâneas da mesma peça.
Trilha.conferir(:brecho)
Trilha.solucao(:brecho)
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 e comandos é esta:
| Neste notebook | No projeto |
|---|---|
| As células geradas (migration, schema, contexto, web) | mix phx.gen.json Urls Url urls link:string title:string |
Ecto.Migrator.run(..., :up, all: true) |
mix ecto.migrate |
| Célula nova do router | O scope "/api" em lib/hello_web/router.ex |
Phoenix.Router.ConsoleFormatter.format/1 |
mix phx.routes |
Trilha.curl/1 na porta 4010 |
O curl no terminal, na porta 4000, com o mix phx.server rodando |
Chamar HelloWeb.UrlJSON.index/1 numa célula |
iex -S mix e a mesma chamada |
| Avaliar de novo a célula de um módulo | Salvar o arquivo (o mix phx.server recompila) ou recompile() no IEx |
Roteiro:
- Rode o
mix phx.gen.jsonacima e leia a lista de arquivos. Abraurl_json.ex,changeset_json.exefallback_controller.exe compare com as células. - Acrescente o
resourcesao scope"/api", rodemix ecto.migrateemix phx.routes, e suba o servidor commix phx.server. - Repita os comandos
curldeste notebook com a porta 4000. Compare os cabeçalhos com os do guia, e os logs do servidor (Processing with HelloWeb.UrlController.create/2,Parameters: ...) com cada requisição. - Peça um id que não existe e um JSON malformado (
-d '{"url":') e leia as páginas de depuração. O JSON quebrado é barrado antes do controller, peloPlug.Parsers, com400 Bad Request. - Faça os exercícios do notebook no projeto: o campo
host, a validação do link, ofetch_url/1. - Para o Brechó: o
phx.gen.jsonaceita--no-context(que implica--no-schema) para gerar só a parte web de um contexto que já existe, mas o controller gerado chama as funções com os nomes do gerador (list_produtos/0,get_produto!/1, ...). Para aVitrine, com nomes próprios, escreva o controller e a view à mão, como no desafio. - Rode
mix test test/hello_web/controllers/url_controller_test.exs: os testes gerados fazem as mesmas requisições, comjson_response/2. O módulo de testes da trilha volta a eles.
Revisão
Escreva em uma célula de texto, com as suas palavras: o que muda entre uma action que responde HTML e uma que responde JSON, de onde vêm os valores de params, e o que o fallback faz. Depois compare com os pontos abaixo.
mix phx.gen.jsongera o mesmo contexto e schema que ophx.gen.html; na parte web, troca os templates por uma view JSON (UrlJSON) e acrescenta oFallbackControllere aChangesetJSON.- As rotas da API ficam num scope com a pipeline
:api, que só fazplug :accepts, ["json"]. Uma API dispensaneweedit(except: [:new, :edit]), que só servem formulários HTML. - A view JSON é um módulo de funções que devolvem maps e listas. O Phoenix as codifica com o Jason. A view escolhe, campo a campo, o que sai, e pode calcular campos.
paramsjunta o caminho, o corpo e a query string, nessa ordem de prioridade. Cada fonte continua separada emconn.path_params,conn.body_paramseconn.query_params.action_fallbackregistra um plug que recebe oconne o valor que a action devolveu quando ele não é um%Plug.Conn{}. Comwith, as actions descrevem o caminho feliz e deixam os erros para um lugar só.- O fallback gerado transforma
{:error, changeset}em 422 com aChangesetJSON, e{:error, :not_found}em 404 com aErrorJSON. Exceções não passam pelo fallback: em desenvolvimento viram a página de depuração; em produção, o JSON daErrorJSON, com o status que a exceção indica (404 paraEcto.NoResultsError, 400 paraEcto.Query.CastErrorePhoenix.ActionClauseError). - O mesmo contexto atende o navegador e a API: as regras ficam na
Vitrine, e cada porta (HTML, JSON) só traduz requisições em chamadas e resultados em respostas.
Próximo módulo da trilha: autenticação, com os guias mix phx.gen.auth, Scopes e API Authentication.