use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use apibrasil::blocking::ApiBrasil;
use apibrasil::{Config, Error, ErrorKind, Json, Result};
use serde_json::Value;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("codegen falhou: {error}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<()> {
let output = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| ["src", "generated", "catalog.rs"].iter().collect());
let api = ApiBrasil::new(Config::new().timeout(Duration::from_secs(120)))?;
let source = format!("{}/documentations", api.http().base_url());
println!("Baixando catálogo de {source} ...");
let response = api.catalog().documentations()?;
let documentations = extract_documentations(&response);
if documentations.is_empty() {
return Err(Error::new(
ErrorKind::Api,
"O catálogo respondeu sem documentações.",
));
}
let mut service_actions: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut consulta_servicos: BTreeSet<String> = BTreeSet::new();
let mut consulta_tipos: BTreeMap<String, (String, BTreeSet<String>)> = BTreeMap::new();
let mut endpoint_count = 0usize;
for documentation in &documentations {
let Some(endpoints) = documentation.get("endpoints").and_then(Value::as_array) else {
continue;
};
for endpoint in endpoints {
let Some(path) = endpoint
.get("url")
.and_then(Value::as_str)
.and_then(api_path)
else {
continue;
};
endpoint_count += 1;
let (service, action) = match path.split_once('/') {
Some((service, action)) => (service, action),
None => (path.as_str(), ""),
};
if service.is_empty() {
continue;
}
let actions = service_actions.entry(service.to_string()).or_default();
if !action.is_empty() {
actions.insert(action.to_string());
}
let Some(consulta) = consulta_service(&path) else {
continue;
};
consulta_servicos.insert(consulta.clone());
let Some(body) = endpoint.get("body").and_then(Value::as_object) else {
continue;
};
let Some(tipo) = body.get("tipo").and_then(Value::as_str) else {
continue;
};
if tipo.is_empty() {
continue;
}
let fields = body
.keys()
.filter(|key| *key != "tipo" && *key != "homolog")
.cloned()
.collect();
consulta_tipos.insert(tipo.to_string(), (consulta, fields));
}
}
let rendered = render(
&source,
documentations.len(),
endpoint_count,
&service_actions,
&consulta_servicos,
&consulta_tipos,
);
if let Some(parent) = output.parent() {
std::fs::create_dir_all(parent).map_err(io_error)?;
}
std::fs::write(&output, rendered).map_err(io_error)?;
println!(
"OK: {} ({} docs, {} endpoints, {} tipos)",
output.display(),
documentations.len(),
endpoint_count,
consulta_tipos.len()
);
Ok(())
}
fn io_error(error: std::io::Error) -> Error {
Error::new(
ErrorKind::Api,
format!("Falha ao escrever o catálogo: {error}"),
)
.with_source(error)
}
fn extract_documentations(response: &Json) -> Vec<Json> {
for key in ["documentations", "data"] {
if let Some(items) = response.get(key).and_then(Value::as_array) {
return items.clone();
}
}
Vec::new()
}
fn api_path(url: &str) -> Option<String> {
let path = url.split_once("/api/v2/")?.1;
let path = path.trim_matches('/');
if path.is_empty() {
None
} else {
Some(path.to_string())
}
}
fn consulta_service(path: &str) -> Option<String> {
let rest = path.strip_prefix("consulta/")?;
let service = rest.strip_suffix("/credits")?;
if service.is_empty() || service.contains('/') {
None
} else {
Some(service.to_string())
}
}
fn render(
source: &str,
doc_count: usize,
endpoint_count: usize,
service_actions: &BTreeMap<String, BTreeSet<String>>,
consulta_servicos: &BTreeSet<String>,
consulta_tipos: &BTreeMap<String, (String, BTreeSet<String>)>,
) -> String {
let empty = BTreeSet::new();
let mut out = String::with_capacity(64 * 1024);
let _ = write!(
out,
r#"//! Dados do catálogo — ARQUIVO GERADO AUTOMATICAMENTE, não edite.
//!
//! Fonte: <{source}>
//! Regenerar: `cargo run --features blocking --bin codegen`
//!
//! {doc_count} documentações, {endpoint_count} endpoints, {tipo_count} tipos de
//! consulta conhecidos.
use super::ConsultaTipoMeta;
/// Fonte do catálogo.
pub const SOURCE: &str = "{source}";
"#,
tipo_count = consulta_tipos.len(),
);
write_slice(
&mut out,
"WHATSAPP_ACTIONS",
"Actions conhecidas da API de WhatsApp (`POST /whatsapp/{action}`).",
service_actions.get("whatsapp").unwrap_or(&empty),
);
write_slice(
&mut out,
"EVOLUTION_PATHS",
"Caminhos conhecidos da Evolution API\n/// (`POST /evolution/{controller}/{action}`).",
service_actions.get("evolution").unwrap_or(&empty),
);
write_slice(
&mut out,
"WHATSMEOW_ACTIONS",
"Actions conhecidas do WhatsMeow (`POST /whatsmeow/{action}`).",
service_actions.get("whatsmeow").unwrap_or(&empty),
);
write_slice(
&mut out,
"CONSULTA_SERVICOS",
"Serviços de consulta por crédito\n/// (`POST /consulta/{servico}/credits`).",
consulta_servicos,
);
out.push_str(
"\n/// Metadados por tipo de consulta (campo `tipo` do body), ordenados\n\
/// por tipo.\n\
pub static CONSULTA_TIPOS: &[(&str, ConsultaTipoMeta)] = &[\n",
);
for (tipo, (service, fields)) in consulta_tipos {
let _ = writeln!(
out,
" ({}, ConsultaTipoMeta {{ service: {}, fields: &[{}] }}),",
quote(tipo),
quote(service),
quote_join(fields)
);
}
out.push_str("];\n");
out.push_str(
"\n/// Actions documentadas por serviço do gateway, ordenadas por\n\
/// serviço.\n\
pub static SERVICE_ACTIONS: &[(&str, &[&str])] = &[\n",
);
for (service, actions) in service_actions {
let _ = writeln!(out, " ({}, &[{}]),", quote(service), quote_join(actions));
}
out.push_str("];\n");
out
}
fn write_slice(out: &mut String, name: &str, doc: &str, values: &BTreeSet<String>) {
let _ = write!(out, "\n/// {doc}\npub static {name}: &[&str] = &[\n");
for value in values {
let _ = writeln!(out, " {},", quote(value));
}
out.push_str("];\n");
}
fn quote(value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
fn quote_join(values: &BTreeSet<String>) -> String {
values
.iter()
.map(|value| quote(value))
.collect::<Vec<_>>()
.join(", ")
}