diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:28:10 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:28:10 -0300 |
| commit | b71faa6e95a0736f1ff171e006b171467b65db90 (patch) | |
| tree | 7580c3ab515e8f50726dc68dbc240995ff4a1a4c | |
| download | cpp-b71faa6e95a0736f1ff171e006b171467b65db90.tar.gz | |
Initial importv0.1.0
| -rw-r--r-- | .clang-format | 9 | ||||
| -rw-r--r-- | .clang-tidy | 20 | ||||
| -rw-r--r-- | .gitignore | 17 | ||||
| -rw-r--r-- | CMakeLists.txt | 49 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 86 | ||||
| -rw-r--r-- | README.md | 238 | ||||
| -rw-r--r-- | examples/cepis_lookup.cpp | 83 | ||||
| -rw-r--r-- | include/cepis/cepis.hpp | 47 | ||||
| -rw-r--r-- | include/cepis/client.hpp | 174 | ||||
| -rw-r--r-- | include/cepis/errors.hpp | 93 | ||||
| -rw-r--r-- | include/cepis/json.hpp | 91 | ||||
| -rw-r--r-- | include/cepis/models.hpp | 120 | ||||
| -rw-r--r-- | src/client.cpp | 408 | ||||
| -rw-r--r-- | src/csv.cpp | 72 | ||||
| -rw-r--r-- | src/csv.hpp | 21 | ||||
| -rw-r--r-- | src/http.cpp | 142 | ||||
| -rw-r--r-- | src/http.hpp | 39 | ||||
| -rw-r--r-- | src/json.cpp | 529 | ||||
| -rw-r--r-- | src/models.cpp | 137 | ||||
| -rw-r--r-- | tests/.clang-tidy | 8 | ||||
| -rw-r--r-- | tests/framework.hpp | 94 | ||||
| -rw-r--r-- | tests/test_cepis.cpp | 496 | ||||
| -rw-r--r-- | tests/test_main.cpp | 43 | ||||
| -rw-r--r-- | tests/test_server.cpp | 207 | ||||
| -rw-r--r-- | tests/test_server.hpp | 74 |
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}) @@ -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 |