summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:28:10 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:28:10 -0300
commitb71faa6e95a0736f1ff171e006b171467b65db90 (patch)
tree7580c3ab515e8f50726dc68dbc240995ff4a1a4c /src
downloadcpp-835a20d6e0eb85c0c4dc2c429d33d19bf2ffe50c.tar.gz
Initial importv0.1.0
Diffstat (limited to 'src')
-rw-r--r--src/client.cpp408
-rw-r--r--src/csv.cpp72
-rw-r--r--src/csv.hpp21
-rw-r--r--src/http.cpp142
-rw-r--r--src/http.hpp39
-rw-r--r--src/json.cpp529
-rw-r--r--src/models.cpp137
7 files changed, 1348 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
diff --git a/src/csv.cpp b/src/csv.cpp
new file mode 100644
index 0000000..32b9591
--- /dev/null
+++ b/src/csv.cpp
@@ -0,0 +1,72 @@
+#include "csv.hpp"
+
+#include <cstddef>
+
+namespace cepis::detail {
+
+std::vector<std::vector<std::string>> parse_csv(std::string_view text) {
+ if (text.compare(0, 3, "\xEF\xBB\xBF") == 0) {
+ text.remove_prefix(3);
+ }
+
+ std::vector<std::vector<std::string>> rows;
+ std::vector<std::string> row;
+ std::string field;
+ bool in_quotes = false;
+ bool row_has_content = false;
+
+ const auto end_row = [&] {
+ if (!row_has_content) {
+ field.clear();
+ row.clear();
+ return;
+ }
+ row.push_back(std::move(field));
+ field.clear();
+ rows.push_back(std::move(row));
+ row.clear();
+ row_has_content = false;
+ };
+
+ for (std::size_t i = 0; i < text.size(); ++i) {
+ const char c = text[i];
+
+ if (in_quotes) {
+ row_has_content = true;
+ if (c != '"') {
+ field.push_back(c);
+ } else if (i + 1 < text.size() && text[i + 1] == '"') {
+ field.push_back('"');
+ ++i;
+ } else {
+ in_quotes = false;
+ }
+ continue;
+ }
+
+ switch (c) {
+ case '"':
+ row_has_content = true;
+ in_quotes = true;
+ break;
+ case ',':
+ row_has_content = true;
+ row.push_back(std::move(field));
+ field.clear();
+ break;
+ case '\r':
+ break;
+ case '\n':
+ end_row();
+ break;
+ default:
+ row_has_content = true;
+ field.push_back(c);
+ }
+ }
+
+ end_row();
+ return rows;
+}
+
+} // namespace cepis::detail
diff --git a/src/csv.hpp b/src/csv.hpp
new file mode 100644
index 0000000..485d8a5
--- /dev/null
+++ b/src/csv.hpp
@@ -0,0 +1,21 @@
+#ifndef CEPIS_SRC_CSV_HPP
+#define CEPIS_SRC_CSV_HPP
+
+#include <string>
+#include <string_view>
+#include <vector>
+
+namespace cepis::detail {
+
+/// Parses RFC 4180 CSV into rows of fields.
+///
+/// Values may contain commas, quotes and newlines when double-quoted, with
+/// `""` as the escape — splitting on `,` would corrupt exactly the street
+/// names most likely to matter. A leading UTF-8 BOM is dropped, `\r\n` and
+/// `\n` both end a record, and a trailing newline does not produce an empty
+/// final row.
+std::vector<std::vector<std::string>> parse_csv(std::string_view text);
+
+} // namespace cepis::detail
+
+#endif // CEPIS_SRC_CSV_HPP
diff --git a/src/http.cpp b/src/http.cpp
new file mode 100644
index 0000000..409fa6a
--- /dev/null
+++ b/src/http.cpp
@@ -0,0 +1,142 @@
+#include "http.hpp"
+
+#include <curl/curl.h>
+
+#include <algorithm>
+#include <cctype>
+#include <cstddef>
+#include <memory>
+#include <mutex>
+#include <string>
+
+namespace cepis::detail {
+namespace {
+
+class GlobalInit {
+ public:
+ GlobalInit() { curl_global_init(CURL_GLOBAL_DEFAULT); }
+ ~GlobalInit() { curl_global_cleanup(); }
+
+ GlobalInit(const GlobalInit&) = delete;
+ GlobalInit& operator=(const GlobalInit&) = delete;
+ GlobalInit(GlobalInit&&) = delete;
+ GlobalInit& operator=(GlobalInit&&) = delete;
+};
+
+void ensure_global_init() {
+ static const GlobalInit init;
+ (void)init;
+}
+
+struct EasyDeleter {
+ void operator()(CURL* handle) const noexcept { curl_easy_cleanup(handle); }
+};
+
+struct SlistDeleter {
+ void operator()(curl_slist* list) const noexcept { curl_slist_free_all(list); }
+};
+
+using EasyHandle = std::unique_ptr<CURL, EasyDeleter>;
+using HeaderList = std::unique_ptr<curl_slist, SlistDeleter>;
+
+std::size_t write_body(char* data, std::size_t size, std::size_t count, void* user_data) {
+ const std::size_t total = size * count;
+ static_cast<std::string*>(user_data)->append(data, total);
+ return total;
+}
+
+std::size_t read_header(char* data, std::size_t size, std::size_t count, void* user_data) {
+ const std::size_t total = size * count;
+ const std::string_view line(data, total);
+ const std::size_t colon = line.find(':');
+ if (colon != std::string_view::npos) {
+ std::string name(line.substr(0, colon));
+ std::transform(name.begin(), name.end(), name.begin(),
+ [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
+ if (name == "retry-after") {
+ std::string_view value = line.substr(colon + 1);
+ while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) {
+ value.remove_prefix(1);
+ }
+ while (!value.empty() && (value.back() == '\r' || value.back() == '\n' ||
+ value.back() == ' ' || value.back() == '\t')) {
+ value.remove_suffix(1);
+ }
+ *static_cast<std::string*>(user_data) = std::string(value);
+ }
+ }
+ return total;
+}
+
+} // namespace
+
+HttpResult perform_get(const HttpRequest& request) {
+ ensure_global_init();
+
+ HttpResult result;
+ const EasyHandle handle(curl_easy_init());
+ if (!handle) {
+ result.curl_error = "could not initialise libcurl";
+ return result;
+ }
+
+ HeaderList headers;
+ const auto add_header = [&headers](const char* value) {
+ curl_slist* owned = headers.release();
+ curl_slist* grown = curl_slist_append(owned, value);
+ headers.reset(grown != nullptr ? grown : owned);
+ };
+ add_header("Accept: application/json, text/csv;q=0.9, */*;q=0.1");
+ add_header("Accept-Encoding: identity");
+
+ char error_buffer[CURL_ERROR_SIZE] = {};
+ const long timeout_ms = static_cast<long>(request.timeout.count());
+
+ curl_easy_setopt(handle.get(), CURLOPT_URL, request.url.c_str());
+ curl_easy_setopt(handle.get(), CURLOPT_HTTPGET, 1L);
+ curl_easy_setopt(handle.get(), CURLOPT_USERAGENT, request.user_agent.c_str());
+ curl_easy_setopt(handle.get(), CURLOPT_HTTPHEADER, headers.get());
+ curl_easy_setopt(handle.get(), CURLOPT_WRITEFUNCTION, write_body);
+ curl_easy_setopt(handle.get(), CURLOPT_WRITEDATA, &result.body);
+ curl_easy_setopt(handle.get(), CURLOPT_HEADERFUNCTION, read_header);
+ curl_easy_setopt(handle.get(), CURLOPT_HEADERDATA, &result.retry_after);
+ curl_easy_setopt(handle.get(), CURLOPT_TIMEOUT_MS, timeout_ms);
+ curl_easy_setopt(handle.get(), CURLOPT_CONNECTTIMEOUT_MS, timeout_ms);
+ curl_easy_setopt(handle.get(), CURLOPT_NOSIGNAL, 1L);
+ curl_easy_setopt(handle.get(), CURLOPT_FOLLOWLOCATION, 1L);
+ curl_easy_setopt(handle.get(), CURLOPT_MAXREDIRS, 5L);
+ curl_easy_setopt(handle.get(), CURLOPT_ERRORBUFFER, error_buffer);
+
+ const CURLcode code = curl_easy_perform(handle.get());
+ if (code != CURLE_OK) {
+ result.curl_error = error_buffer[0] != '\0' ? error_buffer : curl_easy_strerror(code);
+ return result;
+ }
+
+ long status = 0;
+ curl_easy_getinfo(handle.get(), CURLINFO_RESPONSE_CODE, &status);
+ result.completed = true;
+ result.status = static_cast<int>(status);
+ return result;
+}
+
+std::string url_escape(std::string_view value) {
+ static constexpr char kHex[] = "0123456789ABCDEF";
+ std::string out;
+ out.reserve(value.size());
+ for (const char raw : value) {
+ const auto c = static_cast<unsigned char>(raw);
+ const bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
+ (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~';
+ if (unreserved) {
+ out.push_back(static_cast<char>(c));
+ } else {
+ out.push_back('%');
+ out.push_back(kHex[c >> 4]);
+ out.push_back(kHex[c & 0x0F]);
+ }
+ }
+ return out;
+}
+
+} // namespace cepis::detail
diff --git a/src/http.hpp b/src/http.hpp
new file mode 100644
index 0000000..601a2d5
--- /dev/null
+++ b/src/http.hpp
@@ -0,0 +1,39 @@
+#ifndef CEPIS_SRC_HTTP_HPP
+#define CEPIS_SRC_HTTP_HPP
+
+#include <chrono>
+#include <string>
+#include <string_view>
+
+namespace cepis::detail {
+
+/// What one attempt asked for.
+struct HttpRequest {
+ std::string url;
+ std::string user_agent;
+ std::chrono::milliseconds timeout{0};
+};
+
+/// What one attempt got back.
+///
+/// `completed` separates "the server answered, here is its status" from "the
+/// request never got there" — the two need different errors, and only the
+/// second one has a `curl_error` worth showing.
+struct HttpResult {
+ bool completed = false;
+ int status = 0;
+ std::string body;
+ std::string retry_after;
+ std::string curl_error;
+};
+
+/// Performs one GET. Never throws; failures arrive in `HttpResult`.
+HttpResult perform_get(const HttpRequest& request);
+
+/// Percent-encodes `value` for use in a query-string value, RFC 3986
+/// unreserved set. A comma becomes `%2C`.
+std::string url_escape(std::string_view value);
+
+} // namespace cepis::detail
+
+#endif // CEPIS_SRC_HTTP_HPP
diff --git a/src/json.cpp b/src/json.cpp
new file mode 100644
index 0000000..4d8bf03
--- /dev/null
+++ b/src/json.cpp
@@ -0,0 +1,529 @@
+#include "cepis/json.hpp"
+
+#include <cmath>
+#include <cstdint>
+#include <cstdio>
+#include <cstdlib>
+#include <string>
+
+namespace cepis {
+namespace {
+
+const std::string& empty_string() {
+ static const std::string value;
+ return value;
+}
+
+const Json::Array& empty_array() {
+ static const Json::Array value;
+ return value;
+}
+
+const Json::Object& empty_object() {
+ static const Json::Object value;
+ return value;
+}
+
+void append_utf8(std::string& out, std::uint32_t code_point) {
+ if (code_point <= 0x7F) {
+ out.push_back(static_cast<char>(code_point));
+ } else if (code_point <= 0x7FF) {
+ out.push_back(static_cast<char>(0xC0 | (code_point >> 6)));
+ out.push_back(static_cast<char>(0x80 | (code_point & 0x3F)));
+ } else if (code_point <= 0xFFFF) {
+ out.push_back(static_cast<char>(0xE0 | (code_point >> 12)));
+ out.push_back(static_cast<char>(0x80 | ((code_point >> 6) & 0x3F)));
+ out.push_back(static_cast<char>(0x80 | (code_point & 0x3F)));
+ } else {
+ out.push_back(static_cast<char>(0xF0 | (code_point >> 18)));
+ out.push_back(static_cast<char>(0x80 | ((code_point >> 12) & 0x3F)));
+ out.push_back(static_cast<char>(0x80 | ((code_point >> 6) & 0x3F)));
+ out.push_back(static_cast<char>(0x80 | (code_point & 0x3F)));
+ }
+}
+
+class Parser {
+ public:
+ explicit Parser(std::string_view text) : text_(text) {}
+
+ std::optional<Json> run() {
+ skip_whitespace();
+ std::optional<Json> value = parse_value(0);
+ if (!value) {
+ return std::nullopt;
+ }
+ skip_whitespace();
+ if (pos_ != text_.size()) {
+ return std::nullopt;
+ }
+ return value;
+ }
+
+ private:
+ static constexpr int kMaxDepth = 64;
+
+ [[nodiscard]] bool at_end() const { return pos_ >= text_.size(); }
+ [[nodiscard]] char peek() const { return text_[pos_]; }
+
+ void skip_whitespace() {
+ while (!at_end()) {
+ const char c = peek();
+ if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
+ ++pos_;
+ } else {
+ break;
+ }
+ }
+ }
+
+ bool consume(char expected) {
+ if (at_end() || peek() != expected) {
+ return false;
+ }
+ ++pos_;
+ return true;
+ }
+
+ bool consume(std::string_view literal) {
+ if (text_.compare(pos_, literal.size(), literal) != 0) {
+ return false;
+ }
+ pos_ += literal.size();
+ return true;
+ }
+
+ std::optional<Json> parse_value(int depth) {
+ if (depth > kMaxDepth || at_end()) {
+ return std::nullopt;
+ }
+ switch (peek()) {
+ case '{':
+ return parse_object(depth);
+ case '[':
+ return parse_array(depth);
+ case '"': {
+ std::optional<std::string> text = parse_string();
+ if (!text) {
+ return std::nullopt;
+ }
+ return Json::string(std::move(*text));
+ }
+ case 't':
+ return consume("true") ? std::optional<Json>{Json::boolean(true)} : std::nullopt;
+ case 'f':
+ return consume("false") ? std::optional<Json>{Json::boolean(false)} : std::nullopt;
+ case 'n':
+ return consume("null") ? std::optional<Json>{Json{}} : std::nullopt;
+ default:
+ return parse_number();
+ }
+ }
+
+ std::optional<Json> parse_object(int depth) {
+ ++pos_;
+ Json result = Json::object();
+ skip_whitespace();
+ if (consume('}')) {
+ return result;
+ }
+ while (true) {
+ skip_whitespace();
+ if (at_end() || peek() != '"') {
+ return std::nullopt;
+ }
+ std::optional<std::string> key = parse_string();
+ if (!key) {
+ return std::nullopt;
+ }
+ skip_whitespace();
+ if (!consume(':')) {
+ return std::nullopt;
+ }
+ skip_whitespace();
+ std::optional<Json> value = parse_value(depth + 1);
+ if (!value) {
+ return std::nullopt;
+ }
+ result.set(std::move(*key), std::move(*value));
+ skip_whitespace();
+ if (consume(',')) {
+ continue;
+ }
+ if (consume('}')) {
+ return result;
+ }
+ return std::nullopt;
+ }
+ }
+
+ std::optional<Json> parse_array(int depth) {
+ ++pos_;
+ Json result = Json::array();
+ skip_whitespace();
+ if (consume(']')) {
+ return result;
+ }
+ while (true) {
+ skip_whitespace();
+ std::optional<Json> value = parse_value(depth + 1);
+ if (!value) {
+ return std::nullopt;
+ }
+ result.push_back(std::move(*value));
+ skip_whitespace();
+ if (consume(',')) {
+ continue;
+ }
+ if (consume(']')) {
+ return result;
+ }
+ return std::nullopt;
+ }
+ }
+
+ std::optional<std::string> parse_string() {
+ ++pos_;
+ std::string out;
+ while (true) {
+ if (at_end()) {
+ return std::nullopt;
+ }
+ const char c = text_[pos_++];
+ if (c == '"') {
+ return out;
+ }
+ if (static_cast<unsigned char>(c) < 0x20) {
+ return std::nullopt;
+ }
+ if (c != '\\') {
+ out.push_back(c);
+ continue;
+ }
+ if (at_end()) {
+ return std::nullopt;
+ }
+ const char escape = text_[pos_++];
+ switch (escape) {
+ case '"':
+ out.push_back('"');
+ break;
+ case '\\':
+ out.push_back('\\');
+ break;
+ case '/':
+ out.push_back('/');
+ break;
+ case 'b':
+ out.push_back('\b');
+ break;
+ case 'f':
+ out.push_back('\f');
+ break;
+ case 'n':
+ out.push_back('\n');
+ break;
+ case 'r':
+ out.push_back('\r');
+ break;
+ case 't':
+ out.push_back('\t');
+ break;
+ case 'u': {
+ std::optional<std::uint32_t> unit = parse_hex4();
+ if (!unit) {
+ return std::nullopt;
+ }
+ std::uint32_t code_point = *unit;
+ if (code_point >= 0xD800 && code_point <= 0xDBFF) {
+ if (text_.compare(pos_, 2, "\\u") == 0) {
+ const std::size_t saved = pos_;
+ pos_ += 2;
+ std::optional<std::uint32_t> low = parse_hex4();
+ if (low && *low >= 0xDC00 && *low <= 0xDFFF) {
+ code_point = 0x10000 + ((code_point - 0xD800) << 10) + (*low - 0xDC00);
+ } else {
+ pos_ = saved;
+ code_point = 0xFFFD;
+ }
+ } else {
+ code_point = 0xFFFD;
+ }
+ } else if (code_point >= 0xDC00 && code_point <= 0xDFFF) {
+ code_point = 0xFFFD;
+ }
+ append_utf8(out, code_point);
+ break;
+ }
+ default:
+ return std::nullopt;
+ }
+ }
+ }
+
+ std::optional<std::uint32_t> parse_hex4() {
+ if (pos_ + 4 > text_.size()) {
+ return std::nullopt;
+ }
+ std::uint32_t value = 0;
+ for (int i = 0; i < 4; ++i) {
+ const char c = text_[pos_++];
+ value <<= 4;
+ if (c >= '0' && c <= '9') {
+ value |= static_cast<std::uint32_t>(c - '0');
+ } else if (c >= 'a' && c <= 'f') {
+ value |= static_cast<std::uint32_t>(c - 'a' + 10);
+ } else if (c >= 'A' && c <= 'F') {
+ value |= static_cast<std::uint32_t>(c - 'A' + 10);
+ } else {
+ return std::nullopt;
+ }
+ }
+ return value;
+ }
+
+ std::optional<Json> parse_number() {
+ const std::size_t start = pos_;
+ if (!at_end() && peek() == '-') {
+ ++pos_;
+ }
+ if (at_end() || !is_digit(peek())) {
+ return std::nullopt;
+ }
+ if (peek() == '0') {
+ ++pos_;
+ } else {
+ while (!at_end() && is_digit(peek())) {
+ ++pos_;
+ }
+ }
+ if (!at_end() && peek() == '.') {
+ ++pos_;
+ if (at_end() || !is_digit(peek())) {
+ return std::nullopt;
+ }
+ while (!at_end() && is_digit(peek())) {
+ ++pos_;
+ }
+ }
+ if (!at_end() && (peek() == 'e' || peek() == 'E')) {
+ ++pos_;
+ if (!at_end() && (peek() == '+' || peek() == '-')) {
+ ++pos_;
+ }
+ if (at_end() || !is_digit(peek())) {
+ return std::nullopt;
+ }
+ while (!at_end() && is_digit(peek())) {
+ ++pos_;
+ }
+ }
+ const std::string literal(text_.substr(start, pos_ - start));
+ return Json::number(std::strtod(literal.c_str(), nullptr));
+ }
+
+ static bool is_digit(char c) { return c >= '0' && c <= '9'; }
+
+ std::string_view text_;
+ std::size_t pos_ = 0;
+};
+
+void dump_string(const std::string& value, std::string& out) {
+ out.push_back('"');
+ for (const char c : value) {
+ switch (c) {
+ case '"':
+ out += "\\\"";
+ break;
+ case '\\':
+ out += "\\\\";
+ break;
+ case '\b':
+ out += "\\b";
+ break;
+ case '\f':
+ out += "\\f";
+ break;
+ case '\n':
+ out += "\\n";
+ break;
+ case '\r':
+ out += "\\r";
+ break;
+ case '\t':
+ out += "\\t";
+ break;
+ default:
+ if (static_cast<unsigned char>(c) < 0x20) {
+ char buffer[7];
+ std::snprintf(buffer, sizeof(buffer), "\\u%04x", static_cast<unsigned char>(c));
+ out += buffer;
+ } else {
+ out.push_back(c);
+ }
+ }
+ }
+ out.push_back('"');
+}
+
+void dump_number(double value, std::string& out) {
+ if (!std::isfinite(value)) {
+ out += "null";
+ return;
+ }
+ if (value == static_cast<double>(static_cast<std::int64_t>(value)) && std::fabs(value) < 9.0e15) {
+ out += std::to_string(static_cast<std::int64_t>(value));
+ return;
+ }
+ char buffer[40];
+ for (const int digits : {15, 16, 17}) {
+ std::snprintf(buffer, sizeof(buffer), "%.*g", digits, value);
+ if (std::strtod(buffer, nullptr) == value) {
+ break;
+ }
+ }
+ out += buffer;
+}
+
+void dump_value(const Json& value, std::string& out) {
+ switch (value.kind()) {
+ case Json::Kind::Null:
+ out += "null";
+ break;
+ case Json::Kind::Bool:
+ out += value.as_bool() ? "true" : "false";
+ break;
+ case Json::Kind::Number:
+ dump_number(value.as_number(), out);
+ break;
+ case Json::Kind::String:
+ dump_string(value.as_string(), out);
+ break;
+ case Json::Kind::Array: {
+ out.push_back('[');
+ bool first = true;
+ for (const Json& element : value.as_array()) {
+ if (!first) {
+ out.push_back(',');
+ }
+ first = false;
+ dump_value(element, out);
+ }
+ out.push_back(']');
+ break;
+ }
+ case Json::Kind::Object: {
+ out.push_back('{');
+ bool first = true;
+ for (const auto& [key, member] : value.as_object()) {
+ if (!first) {
+ out.push_back(',');
+ }
+ first = false;
+ dump_string(key, out);
+ out.push_back(':');
+ dump_value(member, out);
+ }
+ out.push_back('}');
+ break;
+ }
+ }
+}
+
+} // namespace
+
+Json Json::boolean(bool value) {
+ Json result;
+ result.kind_ = Kind::Bool;
+ result.bool_ = value;
+ return result;
+}
+
+Json Json::number(double value) {
+ Json result;
+ result.kind_ = Kind::Number;
+ result.number_ = value;
+ return result;
+}
+
+Json Json::string(std::string value) {
+ Json result;
+ result.kind_ = Kind::String;
+ result.string_ = std::move(value);
+ return result;
+}
+
+Json Json::array(Array value) {
+ Json result;
+ result.kind_ = Kind::Array;
+ result.array_ = std::move(value);
+ return result;
+}
+
+Json Json::object(Object value) {
+ Json result;
+ result.kind_ = Kind::Object;
+ result.object_ = std::move(value);
+ return result;
+}
+
+std::optional<Json> Json::parse(std::string_view text) { return Parser(text).run(); }
+
+std::string Json::dump() const {
+ std::string out;
+ dump_value(*this, out);
+ return out;
+}
+
+bool Json::as_bool(bool fallback) const noexcept { return kind_ == Kind::Bool ? bool_ : fallback; }
+
+double Json::as_number(double fallback) const noexcept {
+ return kind_ == Kind::Number ? number_ : fallback;
+}
+
+const std::string& Json::as_string() const noexcept {
+ return kind_ == Kind::String ? string_ : empty_string();
+}
+
+const Json::Array& Json::as_array() const noexcept {
+ return kind_ == Kind::Array ? array_ : empty_array();
+}
+
+const Json::Object& Json::as_object() const noexcept {
+ return kind_ == Kind::Object ? object_ : empty_object();
+}
+
+const Json* Json::find(std::string_view key) const noexcept {
+ if (kind_ != Kind::Object) {
+ return nullptr;
+ }
+ for (const auto& member : object_) {
+ if (member.first == key) {
+ return &member.second;
+ }
+ }
+ return nullptr;
+}
+
+void Json::set(std::string key, Json value) {
+ if (kind_ != Kind::Object) {
+ kind_ = Kind::Object;
+ object_.clear();
+ }
+ for (auto& member : object_) {
+ if (member.first == key) {
+ member.second = std::move(value);
+ return;
+ }
+ }
+ object_.emplace_back(std::move(key), std::move(value));
+}
+
+void Json::push_back(Json value) {
+ if (kind_ != Kind::Array) {
+ kind_ = Kind::Array;
+ array_.clear();
+ }
+ array_.push_back(std::move(value));
+}
+
+} // namespace cepis
diff --git a/src/models.cpp b/src/models.cpp
new file mode 100644
index 0000000..84e03f9
--- /dev/null
+++ b/src/models.cpp
@@ -0,0 +1,137 @@
+#include "cepis/models.hpp"
+
+#include <cerrno>
+#include <cmath>
+#include <cstdlib>
+#include <limits>
+#include <optional>
+#include <string>
+
+namespace cepis {
+namespace {
+
+std::string as_field(const Json* value) {
+ if (value == nullptr) {
+ return {};
+ }
+ switch (value->kind()) {
+ case Json::Kind::String:
+ return value->as_string();
+ case Json::Kind::Number: {
+ const double number = value->as_number();
+ if (number == std::trunc(number) && std::fabs(number) < 9.0e15) {
+ return std::to_string(static_cast<std::int64_t>(number));
+ }
+ return Json::number(number).dump();
+ }
+ case Json::Kind::Bool:
+ return value->as_bool() ? "true" : "false";
+ default:
+ return {};
+ }
+}
+
+std::optional<double> as_double(const Json* value) {
+ if (value == nullptr) {
+ return std::nullopt;
+ }
+ if (value->is_number()) {
+ return value->as_number();
+ }
+ if (!value->is_string()) {
+ return std::nullopt;
+ }
+ const std::string& text = value->as_string();
+ if (text.empty()) {
+ return std::nullopt;
+ }
+ errno = 0;
+ char* end = nullptr;
+ const double parsed = std::strtod(text.c_str(), &end);
+ if (errno != 0 || end != text.c_str() + text.size() || !std::isfinite(parsed)) {
+ return std::nullopt;
+ }
+ return parsed;
+}
+
+std::optional<std::int64_t> as_integer(const Json* value) {
+ if (value == nullptr) {
+ return std::nullopt;
+ }
+ if (value->is_number()) {
+ const double number = value->as_number();
+ if (!std::isfinite(number) || std::fabs(number) >= 9.0e18) {
+ return std::nullopt;
+ }
+ return static_cast<std::int64_t>(std::trunc(number));
+ }
+ if (!value->is_string()) {
+ return std::nullopt;
+ }
+ const std::string& text = value->as_string();
+ if (text.empty()) {
+ return std::nullopt;
+ }
+ errno = 0;
+ char* end = nullptr;
+ const long long parsed = std::strtoll(text.c_str(), &end, 10);
+ if (errno != 0 || end != text.c_str() + text.size()) {
+ return std::nullopt;
+ }
+ return static_cast<std::int64_t>(parsed);
+}
+
+std::optional<Geo> geo_from_json(const Json* block) {
+ if (block == nullptr || !block->is_object()) {
+ return std::nullopt;
+ }
+ const std::optional<double> lat = as_double(block->find("lat"));
+ const std::optional<double> lon = as_double(block->find("lon"));
+ const std::optional<std::int64_t> raio_m = as_integer(block->find("raio_m"));
+ const Json* precisao = block->find("precisao");
+ if (!lat || !lon || !raio_m || precisao == nullptr || !precisao->is_string()) {
+ return std::nullopt;
+ }
+ return Geo{*lat, *lon, *raio_m, precisao->as_string()};
+}
+
+} // namespace
+
+std::string Address::formatted_cep() const {
+ if (cep.size() != 8) {
+ return cep;
+ }
+ return cep.substr(0, 5) + "-" + cep.substr(5);
+}
+
+Address Address::from_json(const Json& data) {
+ Address address;
+ address.cep = as_field(data.find("cep"));
+ address.logradouro = as_field(data.find("logradouro"));
+ address.complemento = as_field(data.find("complemento"));
+ address.unidade = as_field(data.find("unidade"));
+ address.bairro = as_field(data.find("bairro"));
+ address.localidade = as_field(data.find("localidade"));
+ address.uf = as_field(data.find("uf"));
+ address.estado = as_field(data.find("estado"));
+ address.regiao = as_field(data.find("regiao"));
+ address.ibge = as_field(data.find("ibge"));
+ address.gia = as_field(data.find("gia"));
+ address.ddd = as_field(data.find("ddd"));
+ address.siafi = as_field(data.find("siafi"));
+ address.geo = geo_from_json(data.find("geo"));
+ address.raw = data;
+ return address;
+}
+
+Health Health::from_json(const Json& data) {
+ Health health;
+ health.status = as_field(data.find("status"));
+ health.dne = as_field(data.find("dne"));
+ health.ceps = as_integer(data.find("ceps")).value_or(0);
+ health.geo = as_integer(data.find("geo")).value_or(0);
+ health.base_gerada_em = as_field(data.find("base_gerada_em"));
+ return health;
+}
+
+} // namespace cepis