diff options
Diffstat (limited to 'src/client.cpp')
| -rw-r--r-- | src/client.cpp | 408 |
1 files changed, 408 insertions, 0 deletions
diff --git a/src/client.cpp b/src/client.cpp new file mode 100644 index 0000000..ef19da8 --- /dev/null +++ b/src/client.cpp @@ -0,0 +1,408 @@ +#include "cepis/client.hpp" + +#include <algorithm> +#include <cerrno> +#include <chrono> +#include <cmath> +#include <cstdint> +#include <cstdio> +#include <cstdlib> +#include <iterator> +#include <random> +#include <stdexcept> +#include <string> +#include <thread> + +#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<std::uint64_t>(year - (era * 400)); + const auto day_of_year = + static_cast<std::uint64_t>((((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<std::int64_t>(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<int> 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<int>(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<std::int64_t> 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<int> 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<int> year = take_int(rest, 4); + skip_spaces(rest); + const std::optional<int> hour = take_int(rest, 2); + if (!take_char(rest, ':')) { + return std::nullopt; + } + const std::optional<int> minute = take_int(rest, 2); + if (!take_char(rest, ':')) { + return std::nullopt; + } + const std::optional<int> 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<std::int64_t>(*hour) * 3600) + + (static_cast<std::int64_t>(*minute) * 60) + *second; +} + +std::optional<double> 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<std::int64_t> target = parse_http_date(value); + if (!target) { + return std::nullopt; + } + const auto now = std::chrono::duration_cast<std::chrono::seconds>( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + return std::max(0.0, static_cast<double>(*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<double>(0.0, ceiling)(engine); +} + +std::string message_from_error_body(const std::string& body, const std::string& fallback) { + const std::optional<Json> 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; +} + +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<Json> 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) { + throw CepNotFound("CEP " + number + " does not exist in the DNE database"); + } + 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<std::optional<Address>> Client::lookup_many( + const std::vector<std::string>& ceps) const { + std::vector<std::optional<Address>> 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<std::optional<Address>> 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<std::optional<Address>> 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<std::vector<std::string>> table = detail::parse_csv(response.body); + std::vector<std::optional<Address>> rows; + rows.reserve(count); + + if (!table.empty()) { + const std::vector<std::string>& 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<std::string>& 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<double> retry_after) const { + if (retry_after) { + const std::chrono::milliseconds asked{static_cast<long long>(*retry_after * 1000.0)}; + return std::min(asked, config_.backoff_max); + } + const auto base = static_cast<double>(config_.backoff_base.count()); + const double ceiling = + std::min(base * std::pow(2.0, attempt - 1), static_cast<double>(config_.backoff_max.count())); + return std::chrono::milliseconds{static_cast<long long>(jitter(ceiling))}; +} + +Client::Response Client::request(std::string_view path, + const std::vector<int>& accepted_status) const { + const std::string url = config_.base_url + std::string(path); + const std::optional<Clock::time_point> deadline = + config_.total_timeout + ? std::optional<Clock::time_point>(Clock::now() + *config_.total_timeout) + : std::nullopt; + + int last_status = 0; + std::optional<double> 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<std::chrono::milliseconds>(*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<std::optional<Address>> lookup_many(const std::vector<std::string>& ceps) { + return default_client().lookup_many(ceps); +} + +Health health() { return default_client().health(); } + +} // namespace cepis |