#include "cepis/client.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include "csv.hpp" #include "http.hpp" namespace cepis { namespace { using Clock = std::chrono::steady_clock; bool is_retriable(int status) { return status == 429 || status == 500 || status == 502 || status == 503 || status == 504; } std::string trim_ascii(std::string_view value) { const auto is_space = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; }; while (!value.empty() && is_space(value.front())) { value.remove_prefix(1); } while (!value.empty() && is_space(value.back())) { value.remove_suffix(1); } return std::string(value); } int month_from_name(std::string_view name) { static constexpr std::string_view kMonths[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; for (int i = 0; i < 12; ++i) { if (name == kMonths[i]) { return i; } } return -1; } std::int64_t days_from_civil(std::int64_t year, int month, int day) { year -= month <= 2 ? 1 : 0; const std::int64_t era = (year >= 0 ? year : year - 399) / 400; const auto year_of_era = static_cast(year - (era * 400)); const auto day_of_year = static_cast((((153 * (month + (month > 2 ? -3 : 9))) + 2) / 5) + day - 1); const std::uint64_t day_of_era = (year_of_era * 365) + (year_of_era / 4) - (year_of_era / 100) + day_of_year; return (era * 146097) + static_cast(day_of_era) - 719468; } void skip_spaces(std::string_view& text) { while (!text.empty() && (text.front() == ' ' || text.front() == '\t')) { text.remove_prefix(1); } } std::optional take_int(std::string_view& text, std::size_t max_digits) { int value = 0; std::size_t taken = 0; while (taken < max_digits && !text.empty() && text.front() >= '0' && text.front() <= '9') { value = (value * 10) + (text.front() - '0'); text.remove_prefix(1); ++taken; } return taken == 0 ? std::nullopt : std::optional(value); } bool take_char(std::string_view& text, char expected) { if (text.empty() || text.front() != expected) { return false; } text.remove_prefix(1); return true; } std::optional parse_http_date(const std::string& value) { const std::size_t comma = value.find(','); if (comma == std::string::npos) { return std::nullopt; } std::string normalised = value.substr(comma + 1); std::replace(normalised.begin(), normalised.end(), '-', ' '); std::string_view rest(normalised); skip_spaces(rest); const std::optional day = take_int(rest, 2); skip_spaces(rest); if (!day || rest.size() < 3) { return std::nullopt; } const int month = month_from_name(rest.substr(0, 3)); rest.remove_prefix(3); skip_spaces(rest); const std::optional year = take_int(rest, 4); skip_spaces(rest); const std::optional hour = take_int(rest, 2); if (!take_char(rest, ':')) { return std::nullopt; } const std::optional minute = take_int(rest, 2); if (!take_char(rest, ':')) { return std::nullopt; } const std::optional second = take_int(rest, 2); if (month < 0 || !year || !hour || !minute || !second) { return std::nullopt; } const int full_year = *year < 100 ? *year + (*year < 70 ? 2000 : 1900) : *year; if (*day < 1 || *day > 31 || *hour > 23 || *minute > 59 || *second > 60) { return std::nullopt; } const std::int64_t days = days_from_civil(full_year, month + 1, *day); return (days * 86400) + (static_cast(*hour) * 3600) + (static_cast(*minute) * 60) + *second; } std::optional retry_after_seconds(const std::string& header) { const std::string value = trim_ascii(header); if (value.empty()) { return std::nullopt; } errno = 0; char* end = nullptr; const double seconds = std::strtod(value.c_str(), &end); if (errno == 0 && end == value.c_str() + value.size()) { return std::max(0.0, seconds); } const std::optional target = parse_http_date(value); if (!target) { return std::nullopt; } const auto now = std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()) .count(); return std::max(0.0, static_cast(*target - now)); } double jitter(double ceiling) { static thread_local std::mt19937 engine(std::random_device{}()); if (ceiling <= 0.0) { return 0.0; } return std::uniform_real_distribution(0.0, ceiling)(engine); } std::string message_from_error_body(const std::string& body, const std::string& fallback) { const std::optional parsed = Json::parse(body); if (parsed) { if (const Json* message = parsed->find("mensagem"); message != nullptr && message->is_string() && !message->as_string().empty()) { return message->as_string(); } } return fallback; } /// Reads the `faixa` block a 404 may carry, never failing. /// /// A body that is not JSON, or carries no `faixa`, or carries one that cannot /// be read, all give `std::nullopt`. The 404 is the answer; the hint attached /// to it is a bonus and must never cost the diagnosis. std::optional faixa_from_error_body(const std::string& body) { const std::optional parsed = Json::parse(body); if (!parsed) { return std::nullopt; } const Json* block = parsed->find("faixa"); return block == nullptr ? std::nullopt : Faixa::from_json(*block); } std::string preview(const std::string& body) { constexpr std::size_t kLimit = 120; return body.size() <= kLimit ? body : body.substr(0, kLimit) + "..."; } Json parse_object(const std::string& body) { const std::optional parsed = Json::parse(body); if (!parsed) { throw InvalidResponse( "response is not valid JSON — something between you and the API is " "answering instead of it: " + preview(body)); } if (!parsed->is_object()) { throw InvalidResponse("expected a JSON object, got: " + preview(body)); } return *parsed; } } // namespace std::string normalize_cep(std::string_view value) { std::string cleaned; cleaned.reserve(value.size()); for (const char c : trim_ascii(value)) { if (c == '-' || c == '.' || c == ' ') { continue; } cleaned.push_back(c); } const bool digits_only = std::all_of(cleaned.begin(), cleaned.end(), [](char c) { return c >= '0' && c <= '9'; }); if (cleaned.size() != 8 || !digits_only) { throw InvalidCep("CEP must be 8 digits, got '" + std::string(value) + "'"); } return cleaned; } std::string default_user_agent() { return "cepis-cpp/" + std::string(kVersion) + " (+https://cep.is)"; } Client::Client(Config config) : config_(std::move(config)) { if (config_.max_attempts < 1) { throw std::invalid_argument("max_attempts must be >= 1"); } if (config_.timeout <= std::chrono::milliseconds::zero()) { throw std::invalid_argument("timeout must be > 0"); } while (!config_.base_url.empty() && config_.base_url.back() == '/') { config_.base_url.pop_back(); } if (config_.user_agent.empty()) { config_.user_agent = default_user_agent(); } } Address Client::lookup(std::string_view cep) const { const std::string number = normalize_cep(cep); const Response response = request("/" + number, {200}); if (response.status == 404) { std::optional faixa = faixa_from_error_body(response.body); std::string message = "CEP " + number + " does not exist in the DNE database"; if (faixa) { message += " (the range it falls in belongs to " + faixa->localidade + "/" + faixa->uf + ")"; } throw CepNotFound(message, std::move(faixa)); } if (response.status == 400) { throw InvalidCep(message_from_error_body(response.body, "CEP " + number + " rejected")); } return Address::from_json(parse_object(response.body)); } std::vector> Client::lookup_many( const std::vector& ceps) const { std::vector> result; if (ceps.empty()) { return result; } result.reserve(ceps.size()); for (std::size_t start = 0; start < ceps.size(); start += kMaxBatch) { const std::size_t count = std::min(kMaxBatch, ceps.size() - start); std::vector> chunk = lookup_chunk(ceps.data() + start, count); result.insert(result.end(), std::make_move_iterator(chunk.begin()), std::make_move_iterator(chunk.end())); } return result; } Health Client::health() const { const Response response = request("/health", {200, 503}); return Health::from_json(parse_object(response.body)); } std::vector> Client::lookup_chunk(const std::string* first, std::size_t count) const { std::string joined; for (std::size_t i = 0; i < count; ++i) { if (i != 0) { joined.push_back(','); } joined += first[i]; } const Response response = request("/csv?ceps=" + detail::url_escape(joined), {200}); const std::vector> table = detail::parse_csv(response.body); std::vector> rows; rows.reserve(count); if (!table.empty()) { const std::vector& header = table.front(); std::size_t localidade_column = std::string::npos; for (std::size_t i = 0; i < header.size(); ++i) { if (header[i] == "localidade") { localidade_column = i; } } for (std::size_t r = 1; r < table.size(); ++r) { const std::vector& row = table[r]; const bool found = localidade_column < row.size() && !trim_ascii(row[localidade_column]).empty(); if (!found) { rows.emplace_back(); continue; } Json object = Json::object(); for (std::size_t i = 0; i < header.size(); ++i) { object.set(header[i], Json::string(i < row.size() ? row[i] : std::string{})); } rows.emplace_back(Address::from_json(object)); } } rows.resize(count); return rows; } std::chrono::milliseconds Client::backoff(int attempt, std::optional retry_after) const { if (retry_after) { const std::chrono::milliseconds asked{static_cast(*retry_after * 1000.0)}; return std::min(asked, config_.backoff_max); } const auto base = static_cast(config_.backoff_base.count()); const double ceiling = std::min(base * std::pow(2.0, attempt - 1), static_cast(config_.backoff_max.count())); return std::chrono::milliseconds{static_cast(jitter(ceiling))}; } Client::Response Client::request(std::string_view path, const std::vector& accepted_status) const { const std::string url = config_.base_url + std::string(path); const std::optional deadline = config_.total_timeout ? std::optional(Clock::now() + *config_.total_timeout) : std::nullopt; int last_status = 0; std::optional last_retry_after; std::string last_network_error; for (int attempt = 1; attempt <= config_.max_attempts; ++attempt) { if (deadline && Clock::now() >= *deadline) { break; } detail::HttpRequest http_request; http_request.url = url; http_request.user_agent = config_.user_agent; http_request.timeout = config_.timeout; detail::HttpResult http_result = detail::perform_get(http_request); if (http_result.completed) { const int status = http_result.status; const bool accepted = std::find(accepted_status.begin(), accepted_status.end(), status) != accepted_status.end(); if (accepted || !is_retriable(status)) { Response response; response.status = status; response.body = std::move(http_result.body); response.retry_after = retry_after_seconds(http_result.retry_after); return response; } last_status = status; last_retry_after = retry_after_seconds(http_result.retry_after); last_network_error.clear(); } else { last_status = 0; last_retry_after.reset(); last_network_error = std::move(http_result.curl_error); } if (attempt == config_.max_attempts) { break; } std::chrono::milliseconds wait = backoff(attempt, last_retry_after); if (deadline) { const auto remaining = std::chrono::duration_cast(*deadline - Clock::now()); if (remaining <= std::chrono::milliseconds::zero()) { break; } wait = std::min(wait, remaining); } std::this_thread::sleep_for(wait); } const std::string suffix = " (after " + std::to_string(config_.max_attempts) + " attempt(s))"; if (last_status == 429) { throw RateLimited("rate limit reached at " + url + suffix, last_retry_after); } if (last_status != 0) { throw ServerError("the API answered " + std::to_string(last_status) + " at " + url + suffix, last_status); } throw NetworkError("could not reach " + url + suffix + ": " + (last_network_error.empty() ? "the call ran out of time before it started" : last_network_error)); } namespace { Client& default_client() { static Client client{}; return client; } } // namespace Address lookup(std::string_view cep) { return default_client().lookup(cep); } std::vector> lookup_many(const std::vector& ceps) { return default_client().lookup_many(ceps); } Health health() { return default_client().health(); } } // namespace cepis