defmodule Mix.Tasks.Apibrasil.Codegen do use Mix.Task @shortdoc "Regenera o catálogo de endpoints da APIBrasil" @moduledoc """ Gera `lib/api_brasil/generated/catalog.ex` a partir do catálogo público do gateway APIBrasil (`GET /api/v2/documentations`). mix apibrasil.codegen mix apibrasil.codegen caminho/catalog.ex A base pode ser trocada pela variável de ambiente `APIBRASIL_BASE_URL`. A task percorre `endpoints[].url` de cada documentação, agrupa as actions por serviço, separa os serviços de consulta por crédito (`consulta/{servico}/credits`) e lê os tipos de consulta do body de exemplo (campo `tipo`, sem as chaves `tipo` e `homolog` nos campos). Tudo sai ordenado alfabeticamente: regenerar sem mudanças no gateway produz exatamente o mesmo arquivo. """ alias ApiBrasil.Platform.Catalog @requirements ["app.start"] @default_output Path.join(["lib", "api_brasil", "generated", "catalog.ex"]) @timeout 120_000 @line_length 98 @functions """ @doc "Fonte do catálogo." @spec source() :: String.t() def source, do: @source @doc "Actions conhecidas da API de WhatsApp (`POST /whatsapp/{action}`)." @spec whatsapp_actions() :: [String.t()] def whatsapp_actions, do: @whatsapp_actions @doc \"\"\" Caminhos conhecidos da Evolution API (`POST /evolution/{controller}/{action}`). \"\"\" @spec evolution_paths() :: [String.t()] def evolution_paths, do: @evolution_paths @doc "Actions conhecidas do WhatsMeow (`POST /whatsmeow/{action}`)." @spec whatsmeow_actions() :: [String.t()] def whatsmeow_actions, do: @whatsmeow_actions @doc "Serviços de consulta por crédito (`POST /consulta/{servico}/credits`)." @spec consulta_servicos() :: [String.t()] def consulta_servicos, do: @consulta_servicos @doc "Metadados por tipo de consulta (campo `tipo` do body), ordenados por tipo." @spec consulta_tipos() :: %{String.t() => %{service: String.t(), fields: [String.t()]}} def consulta_tipos, do: @consulta_tipos @doc \"\"\" Metadados de um tipo de consulta — o serviço da rota (`POST /consulta/{servico}/credits`) e os campos do body de exemplo, fora `tipo` e `homolog`. Devolve `nil` quando o tipo não está no catálogo. ApiBrasil.Generated.Catalog.consulta_tipo("acerta-essencial") #=> %{service: "cpf", fields: ["cpf"]} \"\"\" @spec consulta_tipo(String.t()) :: %{service: String.t(), fields: [String.t()]} | nil def consulta_tipo(tipo), do: Map.get(@consulta_tipos, to_string(tipo)) @doc "Actions documentadas por serviço do gateway, ordenadas por serviço." @spec service_actions() :: %{String.t() => [String.t()]} def service_actions, do: @service_actions @doc \"\"\" Actions documentadas de um serviço do gateway — lista vazia quando o serviço não está no catálogo. \"\"\" @spec service_actions(String.t()) :: [String.t()] def service_actions(service), do: Map.get(@service_actions, to_string(service), []) @doc "Informa se uma action é documentada para o serviço." @spec has_action?(String.t(), String.t()) :: boolean() def has_action?(service, action), do: to_string(action) in service_actions(service) @doc "Serviços do gateway presentes no catálogo, em ordem alfabética." @spec services() :: [String.t()] def services, do: @services end """ @impl Mix.Task @spec run([String.t()]) :: :ok def run(args) do output = output_path(args) client = ApiBrasil.new(timeout: @timeout) source = ApiBrasil.url(client, "documentations") Mix.shell().info("Baixando catálogo de #{source} ...") documentations = documentations!(client) catalog = collect(documentations) write!(output, render(source, length(documentations), catalog)) Mix.shell().info( "OK: #{output} (#{length(documentations)} docs, #{catalog.endpoints} endpoints, " <> "#{map_size(catalog.consulta_tipos)} tipos)" ) end defp output_path([output | _rest]) when is_binary(output) and output != "", do: output defp output_path(_args), do: @default_output defp documentations!(client) do case Catalog.documentations(client) do {:ok, response} -> case extract_documentations(response) do [] -> Mix.raise("O catálogo respondeu sem documentações.") documentations -> documentations end {:error, error} -> Mix.raise("Falha ao baixar o catálogo: #{Exception.message(error)}") end end # O gateway responde `{"documentations": [...]}` ou uma lista direta — que a # SDK normaliza em `{"data": [...]}`. defp extract_documentations(response) do Enum.find_value(["documentations", "data"], [], fn key -> case Map.get(response, key) do items when is_list(items) -> items _other -> nil end end) end defp collect(documentations) do initial = %{ endpoints: 0, service_actions: %{}, consulta_servicos: MapSet.new(), consulta_tipos: %{} } documentations |> Enum.flat_map(&endpoints/1) |> Enum.reduce(initial, &collect_endpoint/2) end defp endpoints(%{"endpoints" => endpoints}) when is_list(endpoints), do: endpoints defp endpoints(_documentation), do: [] defp collect_endpoint(endpoint, acc) when is_map(endpoint) do case api_path(Map.get(endpoint, "url")) do nil -> acc path -> collect_path(endpoint, path, %{acc | endpoints: acc.endpoints + 1}) end end defp collect_endpoint(_endpoint, acc), do: acc defp collect_path(endpoint, path, acc) do case split_path(path) do {"", _action} -> acc {service, action} -> acc = put_action(acc, service, action) case consulta_service(path) do nil -> acc consulta -> put_consulta(acc, endpoint, consulta) end end end defp put_action(acc, service, action) do actions = Map.get(acc.service_actions, service, MapSet.new()) actions = if action == "", do: actions, else: MapSet.put(actions, action) %{acc | service_actions: Map.put(acc.service_actions, service, actions)} end defp put_consulta(acc, endpoint, consulta) do acc = %{acc | consulta_servicos: MapSet.put(acc.consulta_servicos, consulta)} with %{} = body <- Map.get(endpoint, "body"), tipo when is_binary(tipo) and tipo != "" <- Map.get(body, "tipo") do fields = body |> Map.keys() |> Enum.reject(&(&1 in ["tipo", "homolog"])) |> Enum.sort() tipos = Map.put(acc.consulta_tipos, tipo, %{service: consulta, fields: fields}) %{acc | consulta_tipos: tipos} else _other -> acc end end # Extrai o caminho depois de `/api/v2/`. defp api_path(url) when is_binary(url) do case String.split(url, "/api/v2/", parts: 2) do [_base, path] -> case String.trim(path, "/") do "" -> nil path -> path end _other -> nil end end defp api_path(_url), do: nil defp split_path(path) do case String.split(path, "/", parts: 2) do [service, action] -> {service, action} [service] -> {service, ""} end end # Devolve o serviço de uma rota `consulta/{servico}/credits`. defp consulta_service("consulta/" <> rest) do case String.split(rest, "/") do [service, "credits"] when service != "" -> service _other -> nil end end defp consulta_service(_path), do: nil defp write!(output, content) do with :ok <- output |> Path.dirname() |> File.mkdir_p(), :ok <- File.write(output, content) do :ok else {:error, reason} -> Mix.raise("Falha ao escrever o catálogo em #{output}: #{:file.format_error(reason)}") end end defp render(source, doc_count, catalog) do service_actions = Map.new(catalog.service_actions, fn {service, actions} -> {service, Enum.sort(MapSet.to_list(actions))} end) IO.iodata_to_binary([ header(source, doc_count, catalog.endpoints, map_size(catalog.consulta_tipos)), list_attribute("whatsapp_actions", Map.get(service_actions, "whatsapp", [])), list_attribute("evolution_paths", Map.get(service_actions, "evolution", [])), list_attribute("whatsmeow_actions", Map.get(service_actions, "whatsmeow", [])), list_attribute("consulta_servicos", Enum.sort(catalog.consulta_servicos)), map_attribute("consulta_tipos", Enum.sort(catalog.consulta_tipos), &tipo_entry/1), map_attribute("service_actions", Enum.sort(service_actions), &actions_entry/1), " @services Enum.sort(Map.keys(@service_actions))\n\n", @functions ]) end defp header(source, doc_count, endpoint_count, tipo_count) do resumo = "#{doc_count} documentações, #{endpoint_count} endpoints, " <> "#{tipo_count} tipos de consulta conhecidos." """ defmodule ApiBrasil.Generated.Catalog do @moduledoc \"\"\" Catálogo de endpoints conhecidos da plataforma APIBrasil — ARQUIVO GERADO AUTOMATICAMENTE, não edite. Fonte: <#{source}> Regenerar: `mix apibrasil.codegen` #{resumo} ApiBrasil.Generated.Catalog.has_action?("whatsapp", "sendText") #=> true ApiBrasil.Generated.Catalog.service_actions("correios") #=> ["rastreio"] ApiBrasil.Generated.Catalog.consulta_tipo("acerta-essencial") #=> %{service: "cpf", fields: ["cpf"]} \"\"\" @source #{quote_string(source)} """ end defp list_attribute(name, []), do: " @#{name} []\n\n" defp list_attribute(name, values) do entries = Enum.map_join(values, ",\n", &(" " <> quote_string(&1))) " @#{name} [\n" <> entries <> "\n ]\n\n" end defp map_attribute(name, [], _entry), do: " @#{name} %{}\n\n" defp map_attribute(name, entries, entry) do " @#{name} %{\n" <> Enum.map_join(entries, ",\n", entry) <> "\n }\n\n" end defp tipo_entry({tipo, meta}) do inline = " " <> quote_string(tipo) <> " => %{service: " <> quote_string(meta.service) <> ", fields: " <> inline_list(meta.fields) <> "}" if fits_entry?(inline) do inline else " " <> quote_string(tipo) <> " => %{\n service: " <> quote_string(meta.service) <> ",\n" <> fields_block(meta.fields) <> "\n }" end end defp fields_block(fields) do inline = " fields: " <> inline_list(fields) if fits?(inline) do inline else " fields: [\n" <> Enum.map_join(fields, ",\n", &(" " <> quote_string(&1))) <> "\n ]" end end defp actions_entry({service, actions}) do inline = " " <> quote_string(service) <> " => " <> inline_list(actions) if fits_entry?(inline) do inline else " " <> quote_string(service) <> " => [\n" <> Enum.map_join(actions, ",\n", &(" " <> quote_string(&1))) <> "\n ]" end end defp inline_list(values), do: "[" <> Enum.map_join(values, ", ", "e_string/1) <> "]" # A vírgula que separa os itens entra na conta da largura da linha. defp fits_entry?(line), do: String.length(line) + 1 <= @line_length defp fits?(line), do: String.length(line) <= @line_length defp quote_string(value) do escaped = value |> String.replace("\\", "\\\\") |> String.replace("\"", "\\\"") |> String.replace("\n", "\\n") |> String.replace("\r", "\\r") |> String.replace("\t", "\\t") |> String.replace("\#{", "\\\#{") "\"" <> escaped <> "\"" end end