Powered by AppSignal & Oban Pro

Trilha Phoenix · JSON e APIs

12_json_e_apis.livemd

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 pelo Plug.RequestId do endpoint; num projeto gerado, o mesmo valor aparece em cada linha de log dessa requisição, porque o config/config.exs inclui metadata: [:request_id] no formato do Logger;
  • content-length, date e vary são do servidor HTTP; connection: close responde ao Trilha.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ção action/2 do controller, que por padrão passa só conn e params. A documentação do Phoenix.Controller mostra como sobrescrever action/2 para passar um argumento a mais; é o que a célula faz. Aqui, o usuário vem do cabeçalho x-user, no lugar da autenticação, que é assunto de outro módulo da trilha.
  • O use. O guia escreve use Phoenix.Controller. No 1.8, isso gera o aviso use HelloWeb.MyController must receive the :formats option with the formats you intend to render, e o render/3 procura uma view no estilo antigo, HelloWeb.MyView, que não existe. O notebook usa use HelloWeb, :controller, como os controllers gerados, e uma view HelloWeb.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}:

  1. na célula do Hello.Urls, acrescente fetch_url/1, que devolve {:ok, url} ou {:error, :not_found} (use Repo.get/2, que devolve nil em vez de levantar exceção);
  2. na célula do HelloWeb.UrlController, reescreva show/2 com with e fetch_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":

  1. GET /api/produtos: as peças à venda, em {"data": [...]}. Cada peça sai com id, nome, preco_centavos e vendido, numa view HelloWeb.ProdutoJSON.
  2. 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}.
  3. POST /api/produtos: cadastra a peça que vem em "produto". Responde 201, com o cabeçalho location e a peça em data. Dados inválidos respondem 422 com os erros do changeset.
  4. 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.
  5. Os erros passam pelo fallback: o ProdutoController usa action_fallback HelloWeb.FallbackController, e o FallbackController gerado ganha uma cláusula para {:error, :ja_vendido}. Nenhuma action tem else nem case para 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:

  1. Rode o mix phx.gen.json acima e leia a lista de arquivos. Abra url_json.ex, changeset_json.ex e fallback_controller.ex e compare com as células.
  2. Acrescente o resources ao scope "/api", rode mix ecto.migrate e mix phx.routes, e suba o servidor com mix phx.server.
  3. Repita os comandos curl deste 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.
  4. 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, pelo Plug.Parsers, com 400 Bad Request.
  5. Faça os exercícios do notebook no projeto: o campo host, a validação do link, o fetch_url/1.
  6. Para o Brechó: o phx.gen.json aceita --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 a Vitrine, com nomes próprios, escreva o controller e a view à mão, como no desafio.
  7. Rode mix test test/hello_web/controllers/url_controller_test.exs: os testes gerados fazem as mesmas requisições, com json_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.json gera o mesmo contexto e schema que o phx.gen.html; na parte web, troca os templates por uma view JSON (UrlJSON) e acrescenta o FallbackController e a ChangesetJSON.
  • As rotas da API ficam num scope com a pipeline :api, que só faz plug :accepts, ["json"]. Uma API dispensa new e edit (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.
  • params junta o caminho, o corpo e a query string, nessa ordem de prioridade. Cada fonte continua separada em conn.path_params, conn.body_params e conn.query_params.
  • action_fallback registra um plug que recebe o conn e o valor que a action devolveu quando ele não é um %Plug.Conn{}. Com with, as actions descrevem o caminho feliz e deixam os erros para um lugar só.
  • O fallback gerado transforma {:error, changeset} em 422 com a ChangesetJSON, e {:error, :not_found} em 404 com a ErrorJSON. Exceções não passam pelo fallback: em desenvolvimento viram a página de depuração; em produção, o JSON da ErrorJSON, com o status que a exceção indica (404 para Ecto.NoResultsError, 400 para Ecto.Query.CastError e Phoenix.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.