diff options
Diffstat (limited to 'include/cepis/client.hpp')
| -rw-r--r-- | include/cepis/client.hpp | 174 |
1 files changed, 174 insertions, 0 deletions
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 |