diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:28:10 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:28:10 -0300 |
| commit | b71faa6e95a0736f1ff171e006b171467b65db90 (patch) | |
| tree | 7580c3ab515e8f50726dc68dbc240995ff4a1a4c /include | |
| download | cpp-b71faa6e95a0736f1ff171e006b171467b65db90.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'include')
| -rw-r--r-- | include/cepis/cepis.hpp | 47 | ||||
| -rw-r--r-- | include/cepis/client.hpp | 174 | ||||
| -rw-r--r-- | include/cepis/errors.hpp | 93 | ||||
| -rw-r--r-- | include/cepis/json.hpp | 91 | ||||
| -rw-r--r-- | include/cepis/models.hpp | 120 |
5 files changed, 525 insertions, 0 deletions
diff --git a/include/cepis/cepis.hpp b/include/cepis/cepis.hpp new file mode 100644 index 0000000..4ac3f1f --- /dev/null +++ b/include/cepis/cepis.hpp @@ -0,0 +1,47 @@ +#ifndef CEPIS_CEPIS_HPP +#define CEPIS_CEPIS_HPP + +/// C++ client for the public CEP API at https://cep.is — official Correios +/// data (DNE), refreshed every two weeks. +/// +/// Quick use — for a script, the free functions are enough: +/// +/// ```cpp +/// #include <cepis/cepis.hpp> +/// +/// const cepis::Address a = cepis::lookup("01001-000"); +/// std::cout << a.localidade << ' ' << a.uf << '\n'; // São Paulo SP +/// ``` +/// +/// In an application, build a `cepis::Client` once and reuse it: +/// +/// ```cpp +/// cepis::Config config; +/// config.base_url = "http://localhost:8080"; +/// config.timeout = std::chrono::seconds{5}; +/// const cepis::Client client(config); +/// const cepis::Address a = client.lookup("01001000"); +/// ``` +/// +/// Errors are explicit and split by decision — whether retrying helps or not: +/// +/// ```cpp +/// try { +/// const cepis::Address a = cepis::lookup(cep_from_form); +/// } catch (const cepis::CepNotFound&) { +/// // the CEP does not exist: ask the user for another +/// } catch (const cepis::Error&) { +/// // network, server, rate limit: retry later +/// } +/// ``` +/// +/// Note on field names: the API surface is English, but the **data fields keep +/// the API's own Portuguese names** (`logradouro`, `bairro`, `localidade`). +/// What you read in the JSON is what you access on the object. + +#include "cepis/client.hpp" +#include "cepis/errors.hpp" +#include "cepis/json.hpp" +#include "cepis/models.hpp" + +#endif // CEPIS_CEPIS_HPP diff --git a/include/cepis/client.hpp b/include/cepis/client.hpp new file mode 100644 index 0000000..205ba85 --- /dev/null +++ b/include/cepis/client.hpp @@ -0,0 +1,174 @@ +#ifndef CEPIS_CLIENT_HPP +#define CEPIS_CLIENT_HPP + +#include <chrono> +#include <cstddef> +#include <optional> +#include <string> +#include <string_view> +#include <vector> + +#include "cepis/errors.hpp" +#include "cepis/json.hpp" +#include "cepis/models.hpp" + +namespace cepis { + +/// The library version, as it appears in the default `User-Agent`. +inline constexpr std::string_view kVersion = "0.1.0"; + +/// Where the public API lives. +inline constexpr std::string_view kDefaultBaseUrl = "https://cep.is"; + +/// The API's ceiling on one batch request. `Client::lookup_many` chunks at +/// this size on its own; you do not need to. +inline constexpr std::size_t kMaxBatch = 500; + +/// Returns the CEP's eight digits, accepting the mask. +/// +/// `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`. +/// +/// Throws `InvalidCep` unless exactly eight digits remain. This runs **before** +/// any request: a seven-digit CEP is the caller's bug, and spending a round +/// trip for the server to say so wastes both sides' time. +std::string normalize_cep(std::string_view value); + +/// The default `User-Agent`, e.g. `cepis-cpp/0.1.0 (+https://cep.is)`. +std::string default_user_agent(); + +/// How a `Client` behaves. Every field has a working default; in practice only +/// `base_url` changes between environments. +struct Config { + /// Where the API lives. Point it at a local instance (the API repo brings + /// one up on `http://localhost:8080` with `just up`) or at a test server. A + /// trailing slash is stripped, so it cannot produce doubled separators that + /// some proxies normalise and others 404. + std::string base_url{kDefaultBaseUrl}; + + /// Cap on one attempt — connection plus transfer. libcurl works on whole + /// transfers rather than per socket operation, so this is not a per-read + /// timeout; the cap on the whole call, retries and waits included, is + /// `total_timeout`. + std::chrono::milliseconds timeout{10000}; + + /// How many attempts in total, counting the first. `1` disables retrying. + /// The default of `3` covers the common case (one transient failure) + /// without turning a real outage into a long wait. + int max_attempts = 3; + + /// Base of the exponential wait. The actual wait is drawn from + /// `[0, base * 2^(n-1)]` — *full jitter*. The randomness is not decoration: + /// without it, N clients that fail together come back together and reproduce + /// the spike that took the service down. + std::chrono::milliseconds backoff_base{500}; + + /// Cap on each individual wait. It applies to a server-sent `Retry-After` + /// too, or a `Retry-After: 3600` from a misconfigured proxy would park the + /// thread for an hour. + std::chrono::milliseconds backoff_max{8000}; + + /// Cap on the whole call, attempts and waits included. Unset leaves + /// `max_attempts` as the only bound. Set it in code serving a web request: + /// without it the worst case is `max_attempts * timeout` plus the sum of the + /// backoffs. + std::optional<std::chrono::milliseconds> total_timeout; + + /// Overrides the `User-Agent`. Empty means `default_user_agent()`, which + /// identifies the library and its version — that is what lets the server + /// diagnose an old client misbehaving. + std::string user_agent; +}; + +/// Client for the cep.is API. +/// +/// Reuse one instance: the configuration is fixed at construction and no method +/// keeps state between calls, so a `const Client&` is safe to share across +/// threads. Each request owns its own connection. +/// +/// ```cpp +/// cepis::Config config; +/// config.base_url = "http://localhost:8080"; +/// const cepis::Client client(config); +/// const cepis::Address a = client.lookup("01001-000"); +/// ``` +class Client { + public: + /// Builds a client. Throws `std::invalid_argument` for a `max_attempts` + /// below 1 or a non-positive `timeout`. + explicit Client(Config config = {}); + + /// The configuration in use, with `base_url` and `user_agent` normalised. + [[nodiscard]] const Config& config() const noexcept { return config_; } + + /// Looks up a single CEP. + /// + /// Throws `InvalidCep` when the CEP is not eight digits (no request is + /// made) or when the API answers 400; `CepNotFound` when it is well-formed + /// but absent; `RateLimited` on 429 after every attempt; `ServerError` on + /// 5xx after every attempt; `NetworkError` when the request never + /// completed; `InvalidResponse` when a success status carries a body that + /// is not a JSON object. + [[nodiscard]] Address lookup(std::string_view cep) const; + + /// Looks up many CEPs in one batch. + /// + /// Returns a vector of **the same size and in the same order** as the input. + /// A CEP that does not exist is empty at its position — it does not vanish + /// from the vector, and it does not throw. That is the right difference from + /// `lookup`: in a batch of a thousand, one bad CEP must not cost the other + /// nine hundred and ninety-nine. + /// + /// A malformed CEP is likewise empty rather than throwing `InvalidCep`. If + /// you need to know *which* one was malformed, validate first with + /// `normalize_cep`. + /// + /// Input is chunked into blocks of `kMaxBatch` automatically. + /// + /// **Warning: the batch carries no coordinate.** The endpoint behind it is + /// CSV, and the CSV has no `geo` column. Every `Address` returned here has + /// an empty `geo`, including for CEPs that do have one. Need `geo`? Use + /// `lookup`, one at a time. + [[nodiscard]] std::vector<std::optional<Address>> lookup_many( + const std::vector<std::string>& ceps) const; + + /// Queries `GET /health`: does the API answer, and with which database. + /// + /// Does not throw for an API reporting itself degraded — that arrives in + /// `Health::status`, and checking health is exactly when you want the + /// diagnosis rather than an exception. Network failures still throw. + [[nodiscard]] Health health() const; + + private: + struct Response { + int status = 0; + std::string body; + std::optional<double> retry_after; + }; + + [[nodiscard]] Response request(std::string_view path, + const std::vector<int>& accepted_status) const; + [[nodiscard]] std::vector<std::optional<Address>> lookup_chunk(const std::string* first, + std::size_t count) const; + [[nodiscard]] std::chrono::milliseconds backoff(int attempt, + std::optional<double> retry_after) const; + + Config config_; +}; + +/// Looks a CEP up with the default configuration. +/// +/// The free functions share one process-wide `Client`. It is built once +/// because `Client` keeps no state between calls and is safe to share. Anyone +/// needing different settings should build their own. +Address lookup(std::string_view cep); + +/// Looks many CEPs up with the default configuration. Read `Client::lookup_many` +/// first, especially the warning that batches carry no coordinate. +std::vector<std::optional<Address>> lookup_many(const std::vector<std::string>& ceps); + +/// Queries the API's health with the default configuration. +Health health(); + +} // namespace cepis + +#endif // CEPIS_CLIENT_HPP diff --git a/include/cepis/errors.hpp b/include/cepis/errors.hpp new file mode 100644 index 0000000..0420ecd --- /dev/null +++ b/include/cepis/errors.hpp @@ -0,0 +1,93 @@ +#ifndef CEPIS_ERRORS_HPP +#define CEPIS_ERRORS_HPP + +#include <optional> +#include <stdexcept> +#include <string> + +namespace cepis { + +/// Base of every error this library throws. Catch it to handle any failure. +/// +/// It derives from `std::runtime_error`, so a `catch (const std::exception&)` +/// already in place around your I/O keeps working without knowing this library +/// exists, and `what()` carries the message. +/// +/// Below this the split is by **the decision the caller has to make**, not by +/// the HTTP status that caused it: +/// +/// - `InvalidCep` and `CepNotFound` are problems with the *request*. Retrying +/// is pointless — the same CEP fails again. +/// - `NetworkError`, `ServerError` and `RateLimited` are problems with +/// *transport*. Retrying may help, and the client already retried on its own +/// before throwing any of them. +class Error : public std::runtime_error { + public: + explicit Error(const std::string& message) : std::runtime_error(message) {} +}; + +/// The CEP is not eight digits. +/// +/// Usually thrown **without** a request ever being made: the client checks the +/// format before going to the network. It is also what a `400` from the API +/// becomes, carrying the API's own message — the format already passed local +/// validation there, so the server knows something this client does not. +class InvalidCep : public Error { + public: + using Error::Error; +}; + +/// The CEP is well-formed but does not exist in the DNE database. +class CepNotFound : public Error { + public: + using Error::Error; +}; + +/// The request never completed: DNS, refused connection, timeout, TLS. +/// +/// Thrown only after every configured attempt has failed. +class NetworkError : public Error { + public: + using Error::Error; +}; + +/// The API answered 5xx on every attempt. +class ServerError : public Error { + public: + ServerError(const std::string& message, int status) : Error(message), status_(status) {} + + /// The HTTP status seen on the last attempt. + [[nodiscard]] int status() const noexcept { return status_; } + + private: + int status_; +}; + +/// The API (or the edge in front of it) answered 429 on every attempt. +class RateLimited : public Error { + public: + RateLimited(const std::string& message, std::optional<double> retry_after) + : Error(message), retry_after_(retry_after) {} + + /// What the server asked for, in seconds, or `std::nullopt` if it did not + /// say. The client already honoured that value across the attempts it made; + /// it is exposed here for callers who want to reschedule the work. + [[nodiscard]] std::optional<double> retry_after() const noexcept { return retry_after_; } + + private: + std::optional<double> retry_after_; +}; + +/// A success status came back with a body that cannot be parsed. +/// +/// In practice this means something between you and the API is answering +/// instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error +/// page. Useful as a diagnosis: the problem is not in your code. +class InvalidResponse : public Error { + public: + using Error::Error; +}; + +} // namespace cepis + +#endif // CEPIS_ERRORS_HPP diff --git a/include/cepis/json.hpp b/include/cepis/json.hpp new file mode 100644 index 0000000..38bde79 --- /dev/null +++ b/include/cepis/json.hpp @@ -0,0 +1,91 @@ +#ifndef CEPIS_JSON_HPP +#define CEPIS_JSON_HPP + +#include <cstddef> +#include <cstdint> +#include <optional> +#include <string> +#include <string_view> +#include <utility> +#include <vector> + +namespace cepis { + +/// An immutable-by-convention JSON value, sufficient for this API's responses. +/// +/// The library ships its own parser rather than depending on a general-purpose +/// one: the payloads are flat objects of strings and numbers, and a client +/// whose only dependency is libcurl is easier to drop into an existing build. +/// +/// Objects preserve **insertion order** — the API documents a fixed field +/// order and round-tripping it through `dump()` keeps that visible. Lookup is +/// a linear scan, which is the faster choice at this size anyway. +/// +/// The parser accepts UTF-8 input, decodes `\uXXXX` escapes (surrogate pairs +/// included) into UTF-8, and rejects trailing garbage after the top-level +/// value. +class Json { + public: + /// The six JSON types. + enum class Kind : std::uint8_t { Null, Bool, Number, String, Array, Object }; + + using Array = std::vector<Json>; + using Object = std::vector<std::pair<std::string, Json>>; + + /// Constructs a null value. + Json() = default; + + /// Constructs a value of the given kind, empty for the composite kinds. + static Json boolean(bool value); + static Json number(double value); + static Json string(std::string value); + static Json array(Array value = {}); + static Json object(Object value = {}); + + /// Parses `text`, returning `std::nullopt` if it is not one well-formed + /// JSON value. Callers in this library turn that into `InvalidResponse`. + static std::optional<Json> parse(std::string_view text); + + /// Serialises back to compact JSON. Non-ASCII is emitted as raw UTF-8. + [[nodiscard]] std::string dump() const; + + [[nodiscard]] Kind kind() const noexcept { return kind_; } + [[nodiscard]] bool is_null() const noexcept { return kind_ == Kind::Null; } + [[nodiscard]] bool is_bool() const noexcept { return kind_ == Kind::Bool; } + [[nodiscard]] bool is_number() const noexcept { return kind_ == Kind::Number; } + [[nodiscard]] bool is_string() const noexcept { return kind_ == Kind::String; } + [[nodiscard]] bool is_array() const noexcept { return kind_ == Kind::Array; } + [[nodiscard]] bool is_object() const noexcept { return kind_ == Kind::Object; } + + /// Typed accessors. Each returns a harmless default for a mismatched kind + /// rather than throwing: every field of this API is optional in practice, + /// and a wrong type on one field must not cost the whole response. + [[nodiscard]] bool as_bool(bool fallback = false) const noexcept; + [[nodiscard]] double as_number(double fallback = 0.0) const noexcept; + [[nodiscard]] const std::string& as_string() const noexcept; + [[nodiscard]] const Array& as_array() const noexcept; + [[nodiscard]] const Object& as_object() const noexcept; + + /// Returns the member named `key`, or `nullptr` when this is not an object + /// or has no such member. + [[nodiscard]] const Json* find(std::string_view key) const noexcept; + + /// Appends a member. Replaces the value if `key` is already present, so the + /// original position in the order is kept. + void set(std::string key, Json value); + + /// Appends an element. Does nothing unless this is an array. + void push_back(Json value); + + private: + Kind kind_ = Kind::Null; + bool bool_ = false; + double number_ = 0.0; + std::string string_; + Array array_; + Object object_; +}; + +} // namespace cepis + +#endif // CEPIS_JSON_HPP diff --git a/include/cepis/models.hpp b/include/cepis/models.hpp new file mode 100644 index 0000000..faa2dde --- /dev/null +++ b/include/cepis/models.hpp @@ -0,0 +1,120 @@ +#ifndef CEPIS_MODELS_HPP +#define CEPIS_MODELS_HPP + +#include <cstdint> +#include <optional> +#include <string> + +#include "cepis/json.hpp" + +/// The data the API returns, as values. +/// +/// **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`, +/// `localidade`. Everything else in this library is English; the fields are the +/// one deliberate exception. +/// +/// The reason is fidelity: what you read in the JSON at https://cep.is/01001000 +/// is exactly what you access on the object. Translating to `street`/`city` +/// would mean maintaining a field-by-field mapping and forcing anyone reading +/// the API docs alongside this library to translate back in their head. The +/// values are Portuguese either way — they come from the Correios database. +namespace cepis { + +/// The CEP's coordinate, when the database has one. +/// +/// The point is the **median** of the coordinates of that CEP's addresses, not +/// the mean: one badly geocoded address 40 km away shifts a mean and does not +/// move a median. +/// +/// Always check `precisao` before using `lat`/`lon` for distance: +/// +/// - `"logradouro"`: the point is a stretch of street. `raio_m` lands around +/// 100–150 m. Good for maps and for distance. +/// - `"localidade"`: the CEP *is* the whole municipality (a city-wide general +/// CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km. +/// Computing shipping from this is wrong by tens of kilometres. +/// +/// `raio_m` is the radius containing 90% of that CEP's addresses. It exists +/// precisely so that error cannot pass unnoticed. +struct Geo { + double lat = 0.0; + double lon = 0.0; + std::int64_t raio_m = 0; + std::string precisao; + + /// `true` when the coordinate points at a street, not a municipality. + /// + /// Shorthand for `precisao == "logradouro"`, which is the check to make + /// before using the point to measure distance. + [[nodiscard]] bool is_precise() const noexcept { return precisao == "logradouro"; } +}; + +/// A CEP and everything the database knows about it. +/// +/// Fields the database does not have for that CEP come back as empty strings, +/// never absent — that is what the API returns, and changing it here would +/// create a difference between reading the JSON and reading this library. +/// +/// `geo` is the exception: it is `std::nullopt` when there is no coordinate. +/// There the difference is real, because the API **omits** the whole field in +/// that case (about 43% of CEPs) rather than sending null. +struct Address { + std::string cep; + std::string logradouro; + std::string complemento; + std::string unidade; + std::string bairro; + std::string localidade; + std::string uf; + std::string estado; + std::string regiao; + std::string ibge; + std::string gia; + std::string ddd; + std::string siafi; + std::optional<Geo> geo; + + /// The response exactly as it arrived, uninterpreted, so a field the API + /// grows before this library is updated is not lost. + Json raw; + + /// The CEP with its mask: `01001-000`. + /// + /// The native contract always returns it unmasked; formatting is + /// presentation, and it lives here so it does not become a repeated + /// concatenation in every project that uses this. + [[nodiscard]] std::string formatted_cep() const; + + /// Builds from the object the API returned. + /// + /// Unknown fields are ignored during construction but preserved in `raw`. A + /// new field in the API therefore cannot break this library. + /// + /// A malformed `geo` block yields an empty `geo` rather than propagating the + /// error: it is not worth losing the whole address over, since the rest of + /// the response is still useful. + static Address from_json(const Json& data); +}; + +/// The response of `GET /health`. +/// +/// Good for two things: checking that the API answers, and knowing **which +/// version of the database** is live. The DNE database changes every two +/// weeks; `dne` identifies it. +struct Health { + std::string status; + std::string dne; + std::int64_t ceps = 0; + std::int64_t geo = 0; + std::string base_gerada_em; + + /// `true` when the API reports itself healthy. + [[nodiscard]] bool ok() const noexcept { return status == "ok"; } + + /// Builds from the object the API returned. + static Health from_json(const Json& data); +}; + +} // namespace cepis + +#endif // CEPIS_MODELS_HPP |