summaryrefslogtreecommitdiffstats
path: root/src/client.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/client.cpp')
-rw-r--r--src/client.cpp408
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