summaryrefslogtreecommitdiffstats
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
downloadcpp-b71faa6e95a0736f1ff171e006b171467b65db90.tar.gz
Initial importv0.1.0
-rw-r--r--.clang-format9
-rw-r--r--.clang-tidy20
-rw-r--r--.gitignore17
-rw-r--r--CMakeLists.txt49
-rw-r--r--LICENSE15
-rw-r--r--Makefile86
-rw-r--r--README.md238
-rw-r--r--examples/cepis_lookup.cpp83
-rw-r--r--include/cepis/cepis.hpp47
-rw-r--r--include/cepis/client.hpp174
-rw-r--r--include/cepis/errors.hpp93
-rw-r--r--include/cepis/json.hpp91
-rw-r--r--include/cepis/models.hpp120
-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
-rw-r--r--tests/.clang-tidy8
-rw-r--r--tests/framework.hpp94
-rw-r--r--tests/test_cepis.cpp496
-rw-r--r--tests/test_main.cpp43
-rw-r--r--tests/test_server.cpp207
-rw-r--r--tests/test_server.hpp74
26 files changed, 3312 insertions, 0 deletions
diff --git a/.clang-format b/.clang-format
new file mode 100644
index 0000000..2582ea2
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1,9 @@
+---
+BasedOnStyle: Google
+Language: Cpp
+Standard: c++17
+ColumnLimit: 100
+AccessModifierOffset: -1
+DerivePointerAlignment: false
+PointerAlignment: Left
+IncludeBlocks: Preserve
diff --git a/.clang-tidy b/.clang-tidy
new file mode 100644
index 0000000..cf5a36c
--- /dev/null
+++ b/.clang-tidy
@@ -0,0 +1,20 @@
+---
+Checks: >
+ bugprone-*,
+ cppcoreguidelines-pro-type-member-init,
+ cppcoreguidelines-slicing,
+ misc-*,
+ modernize-*,
+ performance-*,
+ readability-*,
+ -bugprone-easily-swappable-parameters,
+ -misc-include-cleaner,
+ -misc-non-private-member-variables-in-classes,
+ -modernize-avoid-c-arrays,
+ -modernize-use-trailing-return-type,
+ -readability-function-cognitive-complexity,
+ -readability-identifier-length,
+ -readability-magic-numbers,
+ -misc-no-recursion
+WarningsAsErrors: ''
+HeaderFilterRegex: '(include/cepis|src|tests)/.*\.(hpp)$'
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1d0a4d7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+build/
+cmake-build-*/
+compile_commands.json
+CMakeCache.txt
+CMakeFiles/
+CTestTestfile.cmake
+Testing/
+install_manifest.txt
+*.o
+*.a
+*.so
+*.so.*
+*.d
+*.gcda
+*.gcno
+*.gcov
+.cache/
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..7ae1fc9
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,49 @@
+cmake_minimum_required(VERSION 3.16)
+
+project(cepis VERSION 0.1.0 LANGUAGES CXX)
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_CXX_EXTENSIONS OFF)
+
+find_package(CURL REQUIRED)
+
+add_library(cepis
+ src/client.cpp
+ src/csv.cpp
+ src/http.cpp
+ src/json.cpp
+ src/models.cpp
+)
+add_library(cepis::cepis ALIAS cepis)
+
+target_include_directories(cepis
+ PUBLIC
+ $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
+ $<INSTALL_INTERFACE:include>
+ PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}/src
+)
+target_link_libraries(cepis PRIVATE CURL::libcurl)
+target_compile_options(cepis PRIVATE -Wall -Wextra)
+
+add_executable(cepis-lookup examples/cepis_lookup.cpp)
+target_link_libraries(cepis-lookup PRIVATE cepis)
+target_compile_options(cepis-lookup PRIVATE -Wall -Wextra)
+
+include(CTest)
+if(BUILD_TESTING)
+ add_executable(cepis-tests
+ tests/test_cepis.cpp
+ tests/test_main.cpp
+ tests/test_server.cpp
+ )
+ target_include_directories(cepis-tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests)
+ target_link_libraries(cepis-tests PRIVATE cepis)
+ target_compile_options(cepis-tests PRIVATE -Wall -Wextra)
+ add_test(NAME cepis-tests COMMAND cepis-tests)
+endif()
+
+include(GNUInstallDirs)
+install(TARGETS cepis ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
+install(DIRECTORY include/cepis DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ca4d3d4
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,86 @@
+# Development tasks. Plain g++/clang++ rules, no generator step: the library is
+# five translation units and one external dependency.
+#
+# make list the targets
+# make test build and run the suite
+# make lint clang-format --dry-run + clang-tidy, no files touched
+# make build libcepis.a and the example CLI into build/
+#
+# Override the compiler or the standard with e.g. `make CXX=clang++ STD=c++20`.
+
+CXX ?= g++
+AR ?= ar
+STD ?= c++17
+BUILD_DIR ?= build
+
+CURL_CFLAGS := $(shell pkg-config --cflags libcurl 2>/dev/null)
+CURL_LIBS := $(shell pkg-config --libs libcurl 2>/dev/null || echo -lcurl)
+
+WARNINGS := -Wall -Wextra
+CXXFLAGS ?= -O2 -g
+ALL_CXXFLAGS := -std=$(STD) $(WARNINGS) $(CXXFLAGS) -Iinclude -Isrc $(CURL_CFLAGS)
+LDLIBS := $(CURL_LIBS) -lpthread
+
+LIB_SOURCES := src/client.cpp src/csv.cpp src/http.cpp src/json.cpp src/models.cpp
+TEST_SOURCES := tests/test_cepis.cpp tests/test_main.cpp tests/test_server.cpp
+EXAMPLE_SOURCES := examples/cepis_lookup.cpp
+
+LIB_OBJECTS := $(LIB_SOURCES:%.cpp=$(BUILD_DIR)/%.o)
+TEST_OBJECTS := $(TEST_SOURCES:%.cpp=$(BUILD_DIR)/%.o)
+EXAMPLE_OBJECTS := $(EXAMPLE_SOURCES:%.cpp=$(BUILD_DIR)/%.o)
+
+LIBRARY := $(BUILD_DIR)/libcepis.a
+TEST_BINARY := $(BUILD_DIR)/cepis-tests
+EXAMPLE_BINARY := $(BUILD_DIR)/cepis-lookup
+
+SOURCES := $(LIB_SOURCES) $(TEST_SOURCES) $(EXAMPLE_SOURCES)
+HEADERS := $(wildcard include/cepis/*.hpp) $(wildcard src/*.hpp) $(wildcard tests/*.hpp)
+
+.DEFAULT_GOAL := help
+.PHONY: help test lint format build check clean
+
+help:
+ @echo 'Targets:'
+ @echo ' test build and run the test suite'
+ @echo ' lint clang-format --dry-run + clang-tidy (read-only)'
+ @echo ' format apply clang-format (writes)'
+ @echo ' build libcepis.a and the example CLI into $(BUILD_DIR)/'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove $(BUILD_DIR)/'
+ @echo
+ @echo 'Override the compiler or standard with e.g. make test CXX=clang++ STD=c++20'
+
+$(BUILD_DIR)/%.o: %.cpp $(HEADERS)
+ @mkdir -p $(dir $@)
+ $(CXX) $(ALL_CXXFLAGS) -c $< -o $@
+
+$(LIBRARY): $(LIB_OBJECTS)
+ @mkdir -p $(dir $@)
+ $(AR) rcs $@ $^
+
+$(TEST_BINARY): $(TEST_OBJECTS) $(LIBRARY)
+ $(CXX) $(ALL_CXXFLAGS) $(TEST_OBJECTS) $(LIBRARY) $(LDLIBS) -o $@
+
+$(EXAMPLE_BINARY): $(EXAMPLE_OBJECTS) $(LIBRARY)
+ $(CXX) $(ALL_CXXFLAGS) $(EXAMPLE_OBJECTS) $(LIBRARY) $(LDLIBS) -o $@
+
+build: $(LIBRARY) $(EXAMPLE_BINARY)
+
+test: $(TEST_BINARY)
+ $(TEST_BINARY)
+
+# Read-only by design, so it is safe in CI and in a pre-commit hook: it reports
+# what `make format` would change without changing anything.
+lint:
+ clang-format --dry-run --Werror $(SOURCES) $(HEADERS)
+ clang-tidy --quiet $(SOURCES) -- $(ALL_CXXFLAGS)
+
+format:
+ clang-format -i $(SOURCES) $(HEADERS)
+
+# The order matters: lint is the fastest to fail, build the slowest, so a broken
+# run reports the cheapest problem first.
+check: lint test build
+
+clean:
+ rm -rf $(BUILD_DIR)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0b85199
--- /dev/null
+++ b/README.md
@@ -0,0 +1,238 @@
+# cepis
+
+C++ client for the [public CEP API at cep.is](https://cep.is). Official
+Correios data (DNE), refreshed every two weeks.
+
+```cpp
+#include <cepis/cepis.hpp>
+#include <iostream>
+
+int main() {
+ const cepis::Address a = cepis::lookup("01001-000");
+ std::cout << a.logradouro << ' ' << a.localidade << ' ' << a.uf << '\n';
+ // Praça da Sé São Paulo SP
+}
+```
+
+Requires C++17 and libcurl. libcurl is the one dependency because the C++
+standard library has no HTTP client and libcurl is already present on
+essentially every system this will run on, TLS and certificate store included;
+JSON and CSV are parsed in-tree, since the payloads are flat and a
+general-purpose parser would be the larger dependency of the two.
+
+## Installation
+
+```sh
+git clone https://git.cep.is/cpp.git cepis-cpp
+cd cepis-cpp
+make build # build/libcepis.a and build/cepis-lookup
+```
+
+With CMake, either add the directory to your project or install it:
+
+```cmake
+add_subdirectory(cepis-cpp)
+target_link_libraries(your_target PRIVATE cepis::cepis)
+```
+
+```sh
+cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
+cmake --build build
+cmake --install build --prefix /usr/local
+```
+
+Link manually with `-lcepis -lcurl`.
+
+## Usage
+
+### A single lookup
+
+`lookup` accepts the CEP masked or unmasked and returns an `Address`:
+
+```cpp
+const cepis::Address a = cepis::lookup("01310-100");
+
+a.logradouro; // "Avenida Paulista"
+a.localidade; // "São Paulo"
+a.uf; // "SP"
+a.ibge; // "3550308"
+a.cep; // "01310100" (always unmasked)
+a.formatted_cep(); // "01310-100"
+```
+
+Fields the database does not have for that CEP come back as empty strings,
+never absent. `a.raw` keeps the response as it arrived, so a field the API
+grows before this library is updated is still reachable.
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `geo` is empty:
+
+```cpp
+if (a.geo) {
+ a.geo->lat; // -23.567202
+ a.geo->lon; // -46.649659
+ a.geo->raio_m; // 1084
+ a.geo->precisao; // "logradouro"
+}
+```
+
+**Check `precisao` before computing distance.** There are two kinds of point,
+and the difference between them is tens of kilometres:
+
+| `precisao` | typical radius | what the point is |
+| --- | --- | --- |
+| `logradouro` | 110–140 m | the stretch of street — good for maps and distance |
+| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city |
+
+`a.geo->is_precise()` is the shorthand for `precisao == "logradouro"`:
+
+```cpp
+const double cost = a.geo && a.geo->is_precise()
+ ? shipping_from(a.geo->lat, a.geo->lon)
+ : shipping_from_municipality(a.ibge);
+```
+
+### Many CEPs at once
+
+Do not loop over `lookup`. Use `lookup_many`, which resolves up to 500 CEPs
+per request:
+
+```cpp
+const std::vector<std::string> ceps = {"01001000", "99999999", "20040020"};
+const std::vector<std::optional<cepis::Address>> result = cepis::lookup_many(ceps);
+
+for (std::size_t i = 0; i < ceps.size(); ++i) {
+ if (result[i]) {
+ std::cout << ceps[i] << ": " << result[i]->localidade << '\n';
+ } else {
+ std::cout << ceps[i] << ": not found\n";
+ }
+}
+```
+
+The returned vector has **the same size and the same order** as the input. A
+CEP that does not exist, or is malformed, is empty at its position — it does
+not vanish, and it does not throw.
+
+Inputs longer than 500 are chunked automatically.
+
+> **Batches carry no coordinate.** The endpoint behind them is CSV, and the
+> CSV has no `geo` column. Every `Address` from `lookup_many` has an empty
+> `geo`, including for CEPs that do have one. Need `geo`? Use `lookup`, one at
+> a time.
+
+### API health
+
+Useful for two things: checking the API answers, and knowing **which version
+of the database** is live:
+
+```cpp
+const cepis::Health h = cepis::health();
+
+h.ok(); // true
+h.dne; // "26071" identifier of the fortnightly database
+h.ceps; // 1596168
+h.base_gerada_em; // "2026-07-24T03:48:28Z"
+```
+
+An API reporting itself degraded does not throw — that arrives in `h.status`.
+
+## Configuration
+
+For anything beyond the defaults, build a `Client` and reuse it. Its
+configuration is fixed at construction and it keeps no state between calls, so
+a `const Client&` is safe to share across threads:
+
+```cpp
+cepis::Config config;
+config.base_url = "https://cep.is"; // another instance
+config.timeout = std::chrono::seconds{10}; // cap on one attempt
+config.max_attempts = 3; // 1 disables retrying
+config.backoff_base = std::chrono::milliseconds{500}; // exponential base
+config.backoff_max = std::chrono::seconds{8}; // cap on each wait
+config.total_timeout = std::chrono::seconds{15}; // cap on the whole call
+
+const cepis::Client client(config);
+const cepis::Address a = client.lookup("01001000");
+```
+
+`timeout` caps one attempt — connection plus transfer — not each socket
+operation, because that is how libcurl works. `total_timeout` is the cap on
+the whole call, retries and waits included.
+
+Retries cover 429, 5xx and network failures, with exponential backoff and
+jitter; 400 and 404 are never retried. Each field is documented in
+`include/cepis/client.hpp`.
+
+The free functions (`cepis::lookup`, etc.) share one process-wide client. Need
+a different configuration? Build your own.
+
+### Pointing at a local instance
+
+`base_url` is the only parameter that usually changes between environments.
+The API repo brings a local instance up with `just up`:
+
+```cpp
+cepis::Config config;
+config.base_url = "http://localhost:8080";
+```
+
+From the command line, to confirm quickly that it answers:
+
+```sh
+build/cepis-lookup --base-url http://localhost:8080 01001000
+```
+
+## Errors
+
+Everything derives from `cepis::Error`, which derives from
+`std::runtime_error`. Below that, the split is by **the decision you have to
+make**, not by the HTTP status that caused it:
+
+| error | when | does retrying help? |
+| --- | --- | --- |
+| `InvalidCep` | not 8 digits | no |
+| `CepNotFound` | well-formed, not in the database | no |
+| `RateLimited` | 429; exposes `retry_after()` | yes, later |
+| `ServerError` | 5xx; exposes `status()` | yes, later |
+| `NetworkError` | DNS, connection, timeout, TLS | yes, later |
+| `InvalidResponse` | unintelligible response | probably not |
+
+```cpp
+try {
+ const cepis::Address a = cepis::lookup(cep_from_form);
+} catch (const cepis::CepNotFound&) {
+ // ask the user for another CEP
+} catch (const cepis::Error&) {
+ // transient failure: reschedule
+}
+```
+
+`InvalidResponse` almost always 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.
+
+`Client`'s constructor throws `std::invalid_argument` for a configuration that
+cannot work, such as `max_attempts = 0`.
+
+## Development
+
+Everything runs through `make`:
+
+```sh
+make # list the targets
+make test # build and run the suite
+make lint # clang-format --dry-run + clang-tidy, touches nothing
+make format # apply clang-format
+make build # libcepis.a and the example CLI into build/
+make check # lint, then test, then build
+```
+
+The suite stands up a real HTTP server on a loopback port and points
+`base_url` at it, so no test touches the network. Override the toolchain with
+e.g. `make test CXX=clang++ STD=c++20`.
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
diff --git a/examples/cepis_lookup.cpp b/examples/cepis_lookup.cpp
new file mode 100644
index 0000000..03a22b0
--- /dev/null
+++ b/examples/cepis_lookup.cpp
@@ -0,0 +1,83 @@
+/// Minimal CLI: `cepis-lookup 01001000`.
+///
+/// It exists for one concrete case — quickly confirming a local instance
+/// answers before pointing code at it:
+///
+/// ```sh
+/// cepis-lookup --base-url http://localhost:8080 01001000
+/// ```
+///
+/// A single CEP goes through the unit lookup, which carries `geo`; several go
+/// through the batch, which is one request instead of N and has no `geo`.
+///
+/// It is not the library's main interface; for that, include `<cepis/cepis.hpp>`.
+
+#include <chrono>
+#include <iostream>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "cepis/cepis.hpp"
+
+namespace {
+
+cepis::Json to_json(const cepis::Address& address) {
+ cepis::Json object = address.raw;
+ if (!object.is_object()) {
+ object = cepis::Json::object();
+ }
+ return object;
+}
+
+void usage() {
+ std::cerr << "usage: cepis-lookup [--base-url URL] [--timeout SECONDS] CEP [CEP...]\n";
+}
+
+} // namespace
+
+int main(int argc, char** argv) {
+ cepis::Config config;
+ std::vector<std::string> ceps;
+
+ for (int i = 1; i < argc; ++i) {
+ const std::string_view argument = argv[i];
+ if (argument == "--base-url" && i + 1 < argc) {
+ config.base_url = argv[++i];
+ } else if (argument == "--timeout" && i + 1 < argc) {
+ config.timeout =
+ std::chrono::milliseconds(static_cast<long long>(std::stod(argv[++i]) * 1000));
+ } else if (argument == "-h" || argument == "--help") {
+ usage();
+ return 0;
+ } else if (!argument.empty() && argument.front() == '-') {
+ usage();
+ return 2;
+ } else {
+ ceps.emplace_back(argument);
+ }
+ }
+
+ if (ceps.empty()) {
+ usage();
+ return 2;
+ }
+
+ try {
+ const cepis::Client client(config);
+ if (ceps.size() == 1) {
+ std::cout << to_json(client.lookup(ceps.front())).dump() << '\n';
+ return 0;
+ }
+ cepis::Json output = cepis::Json::array();
+ for (const std::optional<cepis::Address>& address : client.lookup_many(ceps)) {
+ output.push_back(address ? to_json(*address) : cepis::Json{});
+ }
+ std::cout << output.dump() << '\n';
+ return 0;
+ } catch (const cepis::Error& error) {
+ std::cerr << "error: " << error.what() << '\n';
+ return 1;
+ }
+}
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
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
diff --git a/tests/.clang-tidy b/tests/.clang-tidy
new file mode 100644
index 0000000..0b70d86
--- /dev/null
+++ b/tests/.clang-tidy
@@ -0,0 +1,8 @@
+---
+InheritParentConfig: true
+Checks: >
+ -bugprone-throwing-static-initialization,
+ -bugprone-unchecked-optional-access,
+ -misc-use-anonymous-namespace,
+ -performance-avoid-endl,
+ -readability-convert-member-functions-to-static
diff --git a/tests/framework.hpp b/tests/framework.hpp
new file mode 100644
index 0000000..e3751cf
--- /dev/null
+++ b/tests/framework.hpp
@@ -0,0 +1,94 @@
+#ifndef CEPIS_TESTS_FRAMEWORK_HPP
+#define CEPIS_TESTS_FRAMEWORK_HPP
+
+#include <cmath>
+#include <functional>
+#include <sstream>
+#include <stdexcept>
+#include <string>
+
+namespace cepis::testing {
+
+/// Registers a test case. `CEPIS_TEST` does this for you.
+void register_test(const char* name, std::function<void()> body);
+
+/// Runs every registered test. Returns the process exit code.
+int run_all();
+
+/// Thrown by the assertion helpers; caught by the runner.
+class AssertionFailure : public std::runtime_error {
+ public:
+ explicit AssertionFailure(const std::string& message) : std::runtime_error(message) {}
+};
+
+struct Registrar {
+ Registrar(const char* name, std::function<void()> body) { register_test(name, std::move(body)); }
+};
+
+[[noreturn]] inline void fail(const std::string& detail, const char* file, int line) {
+ std::ostringstream message;
+ message << file << ':' << line << ": " << detail;
+ throw AssertionFailure(message.str());
+}
+
+inline void check_true(bool value, const char* text, const char* file, int line) {
+ if (!value) {
+ fail(std::string("expected ") + text, file, line);
+ }
+}
+
+/// Comparison lives in a function rather than a macro so that the arguments are
+/// bound as parameters: a `CHECK_EQ` on a member of a temporary would otherwise
+/// compare against a reference that has already dangled.
+template <typename Actual, typename Expected>
+void check_equal(const Actual& actual, const Expected& expected, const char* text, const char* file,
+ int line) {
+ if (actual == expected) {
+ return;
+ }
+ std::ostringstream detail;
+ detail << "expected " << text << ", got [" << actual << "] vs [" << expected << ']';
+ fail(detail.str(), file, line);
+}
+
+inline void check_near(double actual, double expected, double tolerance, const char* text,
+ const char* file, int line) {
+ if (std::fabs(actual - expected) <= tolerance) {
+ return;
+ }
+ std::ostringstream detail;
+ detail << "expected " << text << ", got " << actual << " vs " << expected;
+ fail(detail.str(), file, line);
+}
+
+} // namespace cepis::testing
+
+#define CEPIS_TEST(test_name) \
+ static void test_name(); \
+ static const ::cepis::testing::Registrar registrar_##test_name(#test_name, &(test_name)); \
+ static void test_name()
+
+#define CHECK(condition) ::cepis::testing::check_true((condition), #condition, __FILE__, __LINE__)
+
+#define CHECK_EQ(actual, expected) \
+ ::cepis::testing::check_equal((actual), (expected), #actual " == " #expected, __FILE__, __LINE__)
+
+#define CHECK_NEAR(actual, expected, tolerance) \
+ ::cepis::testing::check_near((actual), (expected), (tolerance), #actual " ~= " #expected, \
+ __FILE__, __LINE__)
+
+#define CHECK_THROWS(expression, exception_type) \
+ do { \
+ bool cepis_threw = false; \
+ try { \
+ (void)(expression); \
+ } catch (const exception_type&) { \
+ cepis_threw = true; \
+ } \
+ if (!cepis_threw) { \
+ ::cepis::testing::fail("expected " #expression " to throw " #exception_type, __FILE__, \
+ __LINE__); \
+ } \
+ } while (false)
+
+#endif // CEPIS_TESTS_FRAMEWORK_HPP
diff --git a/tests/test_cepis.cpp b/tests/test_cepis.cpp
new file mode 100644
index 0000000..6b8b9bf
--- /dev/null
+++ b/tests/test_cepis.cpp
@@ -0,0 +1,496 @@
+#include <chrono>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "cepis/cepis.hpp"
+#include "framework.hpp"
+#include "test_server.hpp"
+
+namespace {
+
+using cepis::testing::Reply;
+using cepis::testing::Server;
+
+const char* const kSeAddress =
+ R"({"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar",)"
+ R"("unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP",)"
+ R"("estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004",)"
+ R"("ddd":"11","siafi":"7107"})";
+
+const char* const kCsvHeader =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi";
+
+/// The defaults every test starts from, with the backoff turned down.
+///
+/// The backoff is tiny because what is under test is the retry logic, not the
+/// ability of the standard library to sleep.
+cepis::Config fast_config(std::string base_url) {
+ cepis::Config config;
+ config.base_url = std::move(base_url);
+ config.timeout = std::chrono::milliseconds{5000};
+ config.backoff_base = std::chrono::milliseconds{1};
+ config.backoff_max = std::chrono::milliseconds{10};
+ return config;
+}
+
+/// A fake server plus a client pointed at it.
+struct Fixture {
+ Server server;
+ cepis::Client client;
+
+ Fixture() : client(fast_config(server.url())) {}
+};
+
+std::string csv(const std::vector<std::string>& rows) {
+ std::string body = std::string(kCsvHeader) + "\n";
+ for (const std::string& row : rows) {
+ body += row + "\n";
+ }
+ return body;
+}
+
+} // namespace
+
+CEPIS_TEST(normalize_accepts_masked_and_unmasked) {
+ for (const std::string value : {"01001000", "01001-000", " 01001-000 ", "01001.000"}) {
+ CHECK_EQ(cepis::normalize_cep(value), std::string("01001000"));
+ }
+}
+
+CEPIS_TEST(normalize_rejects_bad_format) {
+ for (const std::string value : {"", "123", "0100100a", "010010000", "abcdefgh"}) {
+ CHECK_THROWS(cepis::normalize_cep(value), cepis::InvalidCep);
+ }
+}
+
+CEPIS_TEST(every_error_shares_one_base) {
+ CHECK_THROWS(cepis::normalize_cep("123"), cepis::Error);
+ CHECK_THROWS(cepis::normalize_cep("123"), std::exception);
+}
+
+CEPIS_TEST(successful_lookup) {
+ Fixture fixture;
+ fixture.server.respond("/01001000", Reply{200, kSeAddress, {}});
+
+ const cepis::Address address = fixture.client.lookup("01001-000");
+ CHECK_EQ(address.localidade, std::string("São Paulo"));
+ CHECK_EQ(address.logradouro, std::string("Praça da Sé"));
+ CHECK_EQ(address.uf, std::string("SP"));
+ CHECK_EQ(address.formatted_cep(), std::string("01001-000"));
+ CHECK(!address.geo.has_value());
+}
+
+CEPIS_TEST(reads_the_coordinate_when_present) {
+ Fixture fixture;
+ fixture.server.respond(
+ "/01310100", Reply{200,
+ R"({"cep":"01310100","localidade":"São Paulo","geo":{"lat":-23.567202,)"
+ R"("lon":-46.649659,"raio_m":120,"precisao":"logradouro"}})",
+ {}});
+
+ const cepis::Address address = fixture.client.lookup("01310100");
+ CHECK(address.geo.has_value());
+ CHECK_NEAR(address.geo->lat, -23.567202, 1e-9);
+ CHECK_NEAR(address.geo->lon, -46.649659, 1e-9);
+ CHECK_EQ(address.geo->raio_m, 120);
+ CHECK(address.geo->is_precise());
+}
+
+CEPIS_TEST(municipality_coordinate_is_not_precise) {
+ Fixture fixture;
+ fixture.server.respond("/01001000",
+ Reply{200,
+ R"({"cep":"01001000","geo":{"lat":-23.5,"lon":-46.6,"raio_m":22000,)"
+ R"("precisao":"localidade"}})",
+ {}});
+
+ const cepis::Address address = fixture.client.lookup("01001000");
+ CHECK(address.geo.has_value());
+ CHECK(!address.geo->is_precise());
+ CHECK_EQ(address.geo->raio_m, 22000);
+}
+
+CEPIS_TEST(malformed_geo_does_not_lose_the_address) {
+ Fixture fixture;
+ fixture.server.respond(
+ "/01001000",
+ Reply{
+ 200, R"({"cep":"01001000","localidade":"São Paulo","geo":{"lat":"not a number"}})", {}});
+
+ const cepis::Address address = fixture.client.lookup("01001000");
+ CHECK(!address.geo.has_value());
+ CHECK_EQ(address.localidade, std::string("São Paulo"));
+}
+
+CEPIS_TEST(new_api_field_does_not_break_the_client) {
+ Fixture fixture;
+ fixture.server.respond("/01001000",
+ Reply{200, R"({"cep":"01001000","campo_do_futuro":"value"})", {}});
+
+ const cepis::Address address = fixture.client.lookup("01001000");
+ CHECK_EQ(address.cep, std::string("01001000"));
+ const cepis::Json* grown = address.raw.find("campo_do_futuro");
+ CHECK(grown != nullptr);
+ CHECK_EQ(grown->as_string(), std::string("value"));
+}
+
+CEPIS_TEST(not_found_throws_cep_not_found) {
+ Fixture fixture;
+ fixture.server.respond(
+ "/99999999",
+ Reply{
+ 404, R"({"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"})", {}});
+
+ CHECK_THROWS(fixture.client.lookup("99999999"), cepis::CepNotFound);
+}
+
+CEPIS_TEST(not_found_is_not_retried) {
+ Fixture fixture;
+ fixture.server.respond("/99999999", Reply{404, R"({"erro":"cep_nao_encontrado"})", {}});
+
+ CHECK_THROWS(fixture.client.lookup("99999999"), cepis::CepNotFound);
+ CHECK_EQ(fixture.server.count("/99999999"), 1);
+}
+
+CEPIS_TEST(invalid_cep_never_leaves_the_machine) {
+ const Fixture fixture;
+ CHECK_THROWS(fixture.client.lookup("123"), cepis::InvalidCep);
+ CHECK_EQ(fixture.server.calls().size(), std::size_t{0});
+}
+
+CEPIS_TEST(api_400_becomes_invalid_cep_with_its_message) {
+ Fixture fixture;
+ fixture.server.respond(
+ "/00000000", Reply{400, R"({"erro":"cep_invalido","mensagem":"rejected by the API"})", {}});
+
+ bool threw = false;
+ try {
+ (void)fixture.client.lookup("00000000");
+ } catch (const cepis::InvalidCep& error) {
+ threw = true;
+ CHECK_EQ(std::string(error.what()), std::string("rejected by the API"));
+ }
+ CHECK(threw);
+ CHECK_EQ(fixture.server.count("/00000000"), 1);
+}
+
+CEPIS_TEST(response_that_is_not_json_is_an_invalid_response) {
+ Fixture fixture;
+ fixture.server.respond("/01001000", Reply{200, "<html>captive portal</html>", {}});
+ CHECK_THROWS(fixture.client.lookup("01001000"), cepis::InvalidResponse);
+}
+
+CEPIS_TEST(retries_on_500_then_succeeds) {
+ Fixture fixture;
+ fixture.server.respond("/01001000", std::vector<Reply>{
+ Reply{500, R"({"erro":"interno"})", {}},
+ Reply{200, kSeAddress, {}},
+ });
+
+ const cepis::Address address = fixture.client.lookup("01001000");
+ CHECK_EQ(address.localidade, std::string("São Paulo"));
+ CHECK_EQ(fixture.server.count("/01001000"), 2);
+}
+
+CEPIS_TEST(exhausts_attempts_then_throws_server_error) {
+ Fixture fixture;
+ fixture.server.respond("/01001000", Reply{503, R"({"erro":"interno"})", {}});
+
+ bool threw = false;
+ try {
+ (void)fixture.client.lookup("01001000");
+ } catch (const cepis::ServerError& error) {
+ threw = true;
+ CHECK_EQ(error.status(), 503);
+ }
+ CHECK(threw);
+ CHECK_EQ(fixture.server.count("/01001000"), 3);
+}
+
+CEPIS_TEST(max_attempts_one_disables_retrying) {
+ Server server;
+ cepis::Config config;
+ config.base_url = server.url();
+ config.max_attempts = 1;
+ config.timeout = std::chrono::milliseconds{5000};
+ const cepis::Client client(config);
+
+ server.respond("/01001000", Reply{500, "{}", {}});
+ CHECK_THROWS(client.lookup("01001000"), cepis::ServerError);
+ CHECK_EQ(server.count("/01001000"), 1);
+}
+
+CEPIS_TEST(rate_limited_exposes_retry_after) {
+ Fixture fixture;
+ fixture.server.respond("/01001000", Reply{429, "{}", {{"Retry-After", "7"}}});
+
+ bool threw = false;
+ try {
+ (void)fixture.client.lookup("01001000");
+ } catch (const cepis::RateLimited& error) {
+ threw = true;
+ CHECK(error.retry_after().has_value());
+ CHECK_NEAR(*error.retry_after(), 7.0, 1e-9);
+ }
+ CHECK(threw);
+}
+
+CEPIS_TEST(retry_after_as_an_http_date_is_understood) {
+ Fixture fixture;
+ fixture.server.respond("/01001000",
+ Reply{429, "{}", {{"Retry-After", "Wed, 21 Oct 2099 07:28:00 GMT"}}});
+
+ bool threw = false;
+ try {
+ (void)fixture.client.lookup("01001000");
+ } catch (const cepis::RateLimited& error) {
+ threw = true;
+ CHECK(error.retry_after().has_value());
+ CHECK(*error.retry_after() > 1.0e9);
+ }
+ CHECK(threw);
+}
+
+CEPIS_TEST(retry_after_honours_the_cap) {
+ Fixture fixture;
+ fixture.server.respond("/01001000", Reply{429, "{}", {{"Retry-After", "3600"}}});
+
+ const auto started = std::chrono::steady_clock::now();
+ CHECK_THROWS(fixture.client.lookup("01001000"), cepis::RateLimited);
+ const auto elapsed = std::chrono::steady_clock::now() - started;
+ CHECK(elapsed < std::chrono::seconds{5});
+}
+
+CEPIS_TEST(network_error_when_nothing_listens) {
+ cepis::Config config;
+ config.base_url = "http://127.0.0.1:1";
+ config.max_attempts = 2;
+ config.timeout = std::chrono::milliseconds{1000};
+ config.backoff_base = std::chrono::milliseconds{1};
+ const cepis::Client client(config);
+
+ CHECK_THROWS(client.lookup("01001000"), cepis::NetworkError);
+}
+
+CEPIS_TEST(total_timeout_bounds_the_whole_call) {
+ Server server;
+ cepis::Config config;
+ config.base_url = server.url();
+ config.max_attempts = 50;
+ config.timeout = std::chrono::milliseconds{5000};
+ config.backoff_base = std::chrono::milliseconds{50};
+ config.backoff_max = std::chrono::milliseconds{50};
+ config.total_timeout = std::chrono::milliseconds{300};
+ const cepis::Client client(config);
+
+ server.respond("/01001000", Reply{500, "{}", {}});
+ const auto started = std::chrono::steady_clock::now();
+ CHECK_THROWS(client.lookup("01001000"), cepis::ServerError);
+ const auto elapsed = std::chrono::steady_clock::now() - started;
+ CHECK(elapsed < std::chrono::seconds{3});
+ CHECK(server.count("/01001000") < 50);
+}
+
+CEPIS_TEST(batch_preserves_order_and_marks_missing) {
+ Fixture fixture;
+ fixture.server.respond(
+ "/csv?ceps=01001000%2C99999999%2C20040020",
+ Reply{200,
+ csv({"01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,"
+ "1004,11,7107",
+ "99999999,,,,,,,,,,,,",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,"
+ "Sudeste,3304557,,21,6001"}),
+ {}});
+
+ const std::vector<std::optional<cepis::Address>> result =
+ fixture.client.lookup_many({"01001000", "99999999", "20040020"});
+
+ CHECK_EQ(result.size(), std::size_t{3});
+ CHECK(result[0].has_value());
+ CHECK_EQ(result[0]->localidade, std::string("São Paulo"));
+ CHECK(!result[1].has_value());
+ CHECK(result[2].has_value());
+ CHECK_EQ(result[2]->uf, std::string("RJ"));
+}
+
+CEPIS_TEST(batch_never_carries_a_coordinate) {
+ Fixture fixture;
+ fixture.server.respond(
+ "/csv?ceps=01001000",
+ Reply{200, csv({"01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1"}), {}});
+
+ const std::vector<std::optional<cepis::Address>> result =
+ fixture.client.lookup_many({"01001000"});
+ CHECK(result[0].has_value());
+ CHECK(!result[0]->geo.has_value());
+}
+
+CEPIS_TEST(batch_field_with_a_comma_is_parsed) {
+ Fixture fixture;
+ fixture.server.respond(
+ "/csv?ceps=01001000",
+ Reply{200,
+ csv({R"(01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1)"}),
+ {}});
+
+ const std::vector<std::optional<cepis::Address>> result =
+ fixture.client.lookup_many({"01001000"});
+ CHECK(result[0].has_value());
+ CHECK_EQ(result[0]->logradouro, std::string("Rua Um, Dois"));
+}
+
+CEPIS_TEST(batch_field_with_an_escaped_quote_is_parsed) {
+ Fixture fixture;
+ fixture.server.respond(
+ "/csv?ceps=01001000",
+ Reply{200,
+ csv({R"(01001000,"Rua ""Aspas""",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1)"}),
+ {}});
+
+ const std::vector<std::optional<cepis::Address>> result =
+ fixture.client.lookup_many({"01001000"});
+ CHECK(result[0].has_value());
+ CHECK_EQ(result[0]->logradouro, std::string("Rua \"Aspas\""));
+}
+
+CEPIS_TEST(batch_with_an_empty_list_makes_no_request) {
+ const Fixture fixture;
+ CHECK_EQ(fixture.client.lookup_many({}).size(), std::size_t{0});
+ CHECK_EQ(fixture.server.calls().size(), std::size_t{0});
+}
+
+CEPIS_TEST(batch_chunks_above_the_api_limit) {
+ std::vector<std::string> ceps;
+ ceps.reserve(501);
+ for (int i = 0; i < 501; ++i) {
+ std::string cep = std::to_string(i);
+ cep.insert(cep.begin(), 8 - cep.size(), '0');
+ ceps.push_back(cep);
+ }
+
+ std::vector<std::string> first_rows;
+ first_rows.reserve(500);
+ for (std::size_t i = 0; i < 500; ++i) {
+ first_rows.push_back(ceps[i] + ",,,,,X,SP,São Paulo,Sudeste,1,,11,1");
+ }
+ const std::string first = csv(first_rows);
+ const std::string second = csv({ceps[500] + ",,,,,X,SP,São Paulo,Sudeste,1,,11,1"});
+ int seen = 0;
+
+ Fixture fixture;
+ fixture.server.handle([&](const std::string&) {
+ ++seen;
+ return Reply{200, seen == 1 ? first : second, {}};
+ });
+
+ const std::vector<std::optional<cepis::Address>> result = fixture.client.lookup_many(ceps);
+ CHECK_EQ(seen, 2);
+ CHECK_EQ(result.size(), std::size_t{501});
+ CHECK(result[500].has_value());
+ CHECK_EQ(result[499]->cep, ceps[499]);
+}
+
+CEPIS_TEST(batch_truncated_by_a_proxy_pads_instead_of_shifting) {
+ Fixture fixture;
+ fixture.server.respond(
+ "/csv?ceps=01001000%2C20040020",
+ Reply{200, csv({"01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"}), {}});
+
+ const std::vector<std::optional<cepis::Address>> result =
+ fixture.client.lookup_many({"01001000", "20040020"});
+ CHECK_EQ(result.size(), std::size_t{2});
+ CHECK(result[0].has_value());
+ CHECK(!result[1].has_value());
+}
+
+CEPIS_TEST(batch_with_a_malformed_cep_yields_an_empty_slot) {
+ Fixture fixture;
+ fixture.server.respond(
+ "/csv?ceps=01001000%2C123",
+ Reply{200,
+ csv({"01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", "123,,,,,,,,,,,,"}),
+ {}});
+
+ const std::vector<std::optional<cepis::Address>> result =
+ fixture.client.lookup_many({"01001000", "123"});
+ CHECK_EQ(result.size(), std::size_t{2});
+ CHECK(result[0].has_value());
+ CHECK(!result[1].has_value());
+}
+
+CEPIS_TEST(health_reports_the_database_in_use) {
+ Fixture fixture;
+ fixture.server.respond("/health",
+ Reply{200,
+ R"({"status":"ok","dne":"26071","ceps":1596168,"geo":894489,)"
+ R"("base_gerada_em":"2026-07-24T03:48:28Z"})",
+ {}});
+
+ const cepis::Health health = fixture.client.health();
+ CHECK(health.ok());
+ CHECK_EQ(health.dne, std::string("26071"));
+ CHECK_EQ(health.ceps, std::int64_t{1596168});
+ CHECK_EQ(health.geo, std::int64_t{894489});
+ CHECK_EQ(health.base_gerada_em, std::string("2026-07-24T03:48:28Z"));
+}
+
+CEPIS_TEST(health_degraded_does_not_throw_or_retry) {
+ Fixture fixture;
+ fixture.server.respond("/health",
+ Reply{503, R"({"status":"degradado","dne":"","ceps":0,"geo":0})", {}});
+
+ const cepis::Health health = fixture.client.health();
+ CHECK(!health.ok());
+ CHECK_EQ(health.status, std::string("degradado"));
+ CHECK_EQ(fixture.server.count("/health"), 1);
+}
+
+CEPIS_TEST(trailing_slash_is_not_duplicated) {
+ cepis::Config config;
+ config.base_url = "http://x/";
+ CHECK_EQ(cepis::Client(config).config().base_url, std::string("http://x"));
+}
+
+CEPIS_TEST(invalid_configuration_is_rejected) {
+ cepis::Config attempts;
+ attempts.max_attempts = 0;
+ CHECK_THROWS(cepis::Client(attempts), std::invalid_argument);
+
+ cepis::Config timeout;
+ timeout.timeout = std::chrono::milliseconds{0};
+ CHECK_THROWS(cepis::Client(timeout), std::invalid_argument);
+}
+
+CEPIS_TEST(user_agent_identifies_the_library) {
+ CHECK(cepis::Client().config().user_agent.rfind("cepis-cpp/", 0) == 0);
+ CHECK_EQ(cepis::Client().config().base_url, std::string("https://cep.is"));
+}
+
+CEPIS_TEST(json_decodes_escapes_and_utf8) {
+ const std::optional<cepis::Json> parsed =
+ cepis::Json::parse(R"({"a":"São Paulo","b":"café","c":"😀","d":"tab\there"})");
+ CHECK(parsed.has_value());
+ CHECK_EQ(parsed->find("a")->as_string(), std::string("São Paulo"));
+ CHECK_EQ(parsed->find("b")->as_string(), std::string("café"));
+ CHECK_EQ(parsed->find("c")->as_string(), std::string("\U0001F600"));
+ CHECK_EQ(parsed->find("d")->as_string(), std::string("tab\there"));
+}
+
+CEPIS_TEST(json_rejects_malformed_input) {
+ CHECK(!cepis::Json::parse("").has_value());
+ CHECK(!cepis::Json::parse("{").has_value());
+ CHECK(!cepis::Json::parse("{\"a\":1}garbage").has_value());
+ CHECK(!cepis::Json::parse("{'a':1}").has_value());
+ CHECK(!cepis::Json::parse("[1,]").has_value());
+ CHECK(cepis::Json::parse(" {\"a\" : [1, 2.5e3, true, null] } ").has_value());
+}
+
+CEPIS_TEST(json_round_trips_utf8_and_preserves_field_order) {
+ const std::optional<cepis::Json> parsed =
+ cepis::Json::parse(R"({"cep":"01001000","logradouro":"Praça da Sé","ceps":1596168})");
+ CHECK(parsed.has_value());
+ CHECK_EQ(parsed->dump(),
+ std::string(R"({"cep":"01001000","logradouro":"Praça da Sé","ceps":1596168})"));
+}
diff --git a/tests/test_main.cpp b/tests/test_main.cpp
new file mode 100644
index 0000000..d74c473
--- /dev/null
+++ b/tests/test_main.cpp
@@ -0,0 +1,43 @@
+#include "framework.hpp"
+
+#include <exception>
+#include <iostream>
+#include <string>
+#include <utility>
+#include <vector>
+
+namespace cepis::testing {
+namespace {
+
+std::vector<std::pair<std::string, std::function<void()>>>& registry() {
+ static std::vector<std::pair<std::string, std::function<void()>>> tests;
+ return tests;
+}
+
+} // namespace
+
+void register_test(const char* name, std::function<void()> body) {
+ registry().emplace_back(name, std::move(body));
+}
+
+int run_all() {
+ int failures = 0;
+ for (const auto& [name, body] : registry()) {
+ try {
+ body();
+ std::cout << "ok " << name << '\n';
+ } catch (const std::exception& error) {
+ ++failures;
+ std::cout << "FAIL " << name << "\n " << error.what() << '\n';
+ } catch (...) {
+ ++failures;
+ std::cout << "FAIL " << name << "\n unknown exception\n";
+ }
+ }
+ std::cout << '\n' << registry().size() << " test(s), " << failures << " failure(s)" << std::endl;
+ return failures == 0 ? 0 : 1;
+}
+
+} // namespace cepis::testing
+
+int main() { return cepis::testing::run_all(); }
diff --git a/tests/test_server.cpp b/tests/test_server.cpp
new file mode 100644
index 0000000..dbb9511
--- /dev/null
+++ b/tests/test_server.cpp
@@ -0,0 +1,207 @@
+#include "test_server.hpp"
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <cerrno>
+#include <cstring>
+#include <stdexcept>
+
+namespace cepis::testing {
+namespace {
+
+std::string reason_phrase(int status) {
+ switch (status) {
+ case 200:
+ return "OK";
+ case 400:
+ return "Bad Request";
+ case 404:
+ return "Not Found";
+ case 429:
+ return "Too Many Requests";
+ case 500:
+ return "Internal Server Error";
+ case 502:
+ return "Bad Gateway";
+ case 503:
+ return "Service Unavailable";
+ case 504:
+ return "Gateway Timeout";
+ default:
+ return "Status";
+ }
+}
+
+bool write_all(int fd, const std::string& data) {
+ std::size_t sent = 0;
+ while (sent < data.size()) {
+ const ssize_t written = ::send(fd, data.data() + sent, data.size() - sent, MSG_NOSIGNAL);
+ if (written <= 0) {
+ return false;
+ }
+ sent += static_cast<std::size_t>(written);
+ }
+ return true;
+}
+
+} // namespace
+
+Server::Server() {
+ listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
+ if (listen_fd_ < 0) {
+ throw std::runtime_error("socket: " + std::string(std::strerror(errno)));
+ }
+ const int enable = 1;
+ ::setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable));
+
+ sockaddr_in address{};
+ address.sin_family = AF_INET;
+ address.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK);
+ address.sin_port = 0;
+ if (::bind(listen_fd_, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0) {
+ ::close(listen_fd_);
+ throw std::runtime_error("bind: " + std::string(std::strerror(errno)));
+ }
+ if (::listen(listen_fd_, 32) != 0) {
+ ::close(listen_fd_);
+ throw std::runtime_error("listen: " + std::string(std::strerror(errno)));
+ }
+
+ sockaddr_in bound{};
+ socklen_t length = sizeof(bound);
+ if (::getsockname(listen_fd_, reinterpret_cast<sockaddr*>(&bound), &length) != 0) {
+ ::close(listen_fd_);
+ throw std::runtime_error("getsockname: " + std::string(std::strerror(errno)));
+ }
+ port_ = ::ntohs(bound.sin_port);
+
+ thread_ = std::thread([this] { serve(); });
+}
+
+Server::~Server() {
+ stopping_.store(true);
+ if (thread_.joinable()) {
+ thread_.join();
+ }
+ if (listen_fd_ >= 0) {
+ ::close(listen_fd_);
+ }
+}
+
+std::string Server::url() const { return "http://127.0.0.1:" + std::to_string(port_); }
+
+void Server::respond(std::string path, Reply reply) {
+ respond(std::move(path), std::vector<Reply>{std::move(reply)});
+}
+
+void Server::respond(std::string path, std::vector<Reply> replies) {
+ const std::scoped_lock guard(mutex_);
+ routes_[std::move(path)] = std::move(replies);
+}
+
+void Server::handle(std::function<Reply(const std::string&)> handler) {
+ const std::scoped_lock guard(mutex_);
+ handler_ = std::move(handler);
+}
+
+int Server::count(std::string_view prefix) const {
+ const std::scoped_lock guard(mutex_);
+ int total = 0;
+ for (const std::string& call : calls_) {
+ if (call.rfind(prefix, 0) == 0) {
+ ++total;
+ }
+ }
+ return total;
+}
+
+std::vector<std::string> Server::calls() const {
+ const std::scoped_lock guard(mutex_);
+ return calls_;
+}
+
+Reply Server::next(const std::string& path) {
+ std::function<Reply(const std::string&)> handler;
+ {
+ const std::scoped_lock guard(mutex_);
+ calls_.push_back(path);
+ handler = handler_;
+ if (!handler) {
+ const auto found = routes_.find(path);
+ if (found == routes_.end()) {
+ return Reply{404, R"({"erro":"cep_nao_encontrado"})", {}};
+ }
+ std::vector<Reply>& queue = found->second;
+ if (queue.size() > 1) {
+ Reply reply = queue.front();
+ queue.erase(queue.begin());
+ return reply;
+ }
+ return queue.front();
+ }
+ }
+ return handler(path);
+}
+
+void Server::serve() {
+ while (!stopping_.load()) {
+ pollfd waiting{listen_fd_, POLLIN, 0};
+ const int ready = ::poll(&waiting, 1, 10);
+ if (ready <= 0) {
+ continue;
+ }
+
+ const int client = ::accept(listen_fd_, nullptr, nullptr);
+ if (client < 0) {
+ continue;
+ }
+
+ std::string request;
+ char buffer[4096];
+ while (request.find("\r\n\r\n") == std::string::npos) {
+ const ssize_t read_bytes = ::recv(client, buffer, sizeof(buffer), 0);
+ if (read_bytes <= 0) {
+ break;
+ }
+ request.append(buffer, static_cast<std::size_t>(read_bytes));
+ }
+
+ const std::size_t first_space = request.find(' ');
+ const std::size_t second_space =
+ first_space == std::string::npos ? std::string::npos : request.find(' ', first_space + 1);
+ if (second_space == std::string::npos) {
+ ::close(client);
+ continue;
+ }
+ const std::string path = request.substr(first_space + 1, second_space - first_space - 1);
+
+ const Reply reply = next(path);
+ std::string out = "HTTP/1.1 ";
+ out += std::to_string(reply.status);
+ out += ' ';
+ out += reason_phrase(reply.status);
+ out += "\r\n";
+ for (const auto& [name, value] : reply.headers) {
+ out += name;
+ out += ": ";
+ out += value;
+ out += "\r\n";
+ }
+ out += "Content-Length: ";
+ out += std::to_string(reply.body.size());
+ out += "\r\n";
+ out += "Connection: close\r\n\r\n";
+ out += reply.body;
+
+ write_all(client, out);
+ ::shutdown(client, SHUT_WR);
+ ::close(client);
+ }
+}
+
+} // namespace cepis::testing
diff --git a/tests/test_server.hpp b/tests/test_server.hpp
new file mode 100644
index 0000000..54c6e0c
--- /dev/null
+++ b/tests/test_server.hpp
@@ -0,0 +1,74 @@
+#ifndef CEPIS_TESTS_TEST_SERVER_HPP
+#define CEPIS_TESTS_TEST_SERVER_HPP
+
+#include <atomic>
+#include <functional>
+#include <map>
+#include <mutex>
+#include <string>
+#include <string_view>
+#include <thread>
+#include <utility>
+#include <vector>
+
+namespace cepis::testing {
+
+/// One canned HTTP response.
+struct Reply {
+ int status = 200;
+ std::string body;
+ std::vector<std::pair<std::string, std::string>> headers;
+};
+
+/// A throwaway HTTP/1.1 server on a random loopback port.
+///
+/// The tests point `Config::base_url` at it, so the code path they exercise is
+/// the one that runs in production — request construction, header reading and
+/// error-body reading included. Nothing is mocked below the socket, and it
+/// doubles as the test that `base_url` works: without it none of this runs.
+class Server {
+ public:
+ Server();
+ ~Server();
+
+ Server(const Server&) = delete;
+ Server& operator=(const Server&) = delete;
+ Server(Server&&) = delete;
+ Server& operator=(Server&&) = delete;
+
+ /// `http://127.0.0.1:<port>`, ready to hand to `Config::base_url`.
+ [[nodiscard]] std::string url() const;
+
+ /// Registers the response for a path. The last entry of a queue repeats once
+ /// the earlier ones are consumed, which is how retrying is tested.
+ void respond(std::string path, Reply reply);
+ void respond(std::string path, std::vector<Reply> replies);
+
+ /// Installs a catch-all that sees every request. Takes precedence over the
+ /// registered paths.
+ void handle(std::function<Reply(const std::string& path)> handler);
+
+ /// How many requests so far began with `prefix`.
+ [[nodiscard]] int count(std::string_view prefix) const;
+
+ /// Every path requested so far, in order.
+ [[nodiscard]] std::vector<std::string> calls() const;
+
+ private:
+ void serve();
+ Reply next(const std::string& path);
+
+ int listen_fd_ = -1;
+ int port_ = 0;
+ std::atomic<bool> stopping_{false};
+ std::thread thread_;
+
+ mutable std::mutex mutex_;
+ std::map<std::string, std::vector<Reply>> routes_;
+ std::function<Reply(const std::string&)> handler_;
+ std::vector<std::string> calls_;
+};
+
+} // namespace cepis::testing
+
+#endif // CEPIS_TESTS_TEST_SERVER_HPP