summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:25:48 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:25:48 -0300
commite2c6ae13af335b311da2429c903032124f926df2 (patch)
tree73776fab7f3e52124a217e2768968fc9a8a76c34
downloadc-e2c6ae13af335b311da2429c903032124f926df2.tar.gz
Initial importv0.1.0
-rw-r--r--.clang-format25
-rw-r--r--.clang-tidy29
-rw-r--r--.gitignore13
-rw-r--r--LICENSE15
-rw-r--r--Makefile130
-rw-r--r--README.md273
-rw-r--r--include/cepis.h482
-rw-r--r--src/cli.c191
-rw-r--r--src/client.c822
-rw-r--r--src/csv.c168
-rw-r--r--src/csv.h45
-rw-r--r--src/internal.h74
-rw-r--r--src/json.c550
-rw-r--r--src/json.h82
-rw-r--r--src/model.c246
-rw-r--r--src/util.c78
-rw-r--r--tests/test_cepis.c935
-rw-r--r--tests/test_server.c417
-rw-r--r--tests/test_server.h69
19 files changed, 4644 insertions, 0 deletions
diff --git a/.clang-format b/.clang-format
new file mode 100644
index 0000000..e5d2751
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1,25 @@
+---
+Language: Cpp
+BasedOnStyle: LLVM
+ColumnLimit: 80
+IndentWidth: 8
+TabWidth: 8
+UseTab: Never
+ContinuationIndentWidth: 8
+BreakBeforeBraces: Linux
+AllowShortFunctionsOnASingleLine: None
+AllowShortIfStatementsOnASingleLine: Never
+AllowShortLoopsOnASingleLine: false
+AllowShortCaseLabelsOnASingleLine: false
+AllowShortBlocksOnASingleLine: Never
+IndentCaseLabels: false
+PointerAlignment: Right
+DerivePointerAlignment: false
+SortIncludes: false
+SpaceAfterCStyleCast: false
+AlignEscapedNewlines: Left
+AlignAfterOpenBracket: Align
+IndentPPDirectives: None
+KeepEmptyLinesAtTheStartOfBlocks: false
+MaxEmptyLinesToKeep: 1
+ReflowComments: false
diff --git a/.clang-tidy b/.clang-tidy
new file mode 100644
index 0000000..674ea25
--- /dev/null
+++ b/.clang-tidy
@@ -0,0 +1,29 @@
+---
+Checks: >
+ clang-analyzer-*,
+ bugprone-*,
+ cert-*,
+ misc-*,
+ performance-*,
+ readability-*,
+ -bugprone-easily-swappable-parameters,
+ -bugprone-multi-level-implicit-pointer-conversion,
+ -bugprone-suspicious-realloc-usage,
+ -cert-err33-c,
+ -clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling,
+ -cert-msc30-c,
+ -cert-msc50-cpp,
+ -misc-include-cleaner,
+ -misc-no-recursion,
+ -readability-avoid-nested-conditional-operator,
+ -readability-function-cognitive-complexity,
+ -readability-function-size,
+ -readability-identifier-length,
+ -readability-implicit-bool-conversion,
+ -readability-isolate-declaration,
+ -readability-magic-numbers,
+ -readability-math-missing-parentheses,
+ -readability-braces-around-statements
+WarningsAsErrors: '*'
+HeaderFilterRegex: '(include|src|tests)/.*\.h$'
+FormatStyle: file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2b8f40e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+build/
+*.o
+*.a
+*.so
+*.so.*
+*.d
+*.gcda
+*.gcno
+*.gcov
+core
+vgcore.*
+compile_commands.json
+.cache/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..c7ff2ba
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,130 @@
+VERSION := 0.1.0
+SOVERSION := 0
+
+CC ?= cc
+AR ?= ar
+CLANG_TIDY ?= clang-tidy
+FORMAT ?= clang-format
+PREFIX ?= /usr/local
+LIBDIR ?= $(PREFIX)/lib
+INCLUDEDIR ?= $(PREFIX)/include
+BINDIR ?= $(PREFIX)/bin
+
+FEATURES := -D_POSIX_C_SOURCE=200809L -D_XOPEN_SOURCE=700 -D_DEFAULT_SOURCE
+WARNINGS := -Wall -Wextra -Wpedantic -Wshadow -Wstrict-prototypes \
+ -Wmissing-prototypes -Wold-style-definition -Wvla \
+ -Wpointer-arith -Wcast-qual -Wredundant-decls
+
+SAN := -fsanitize=address,undefined -fno-omit-frame-pointer \
+ -fno-sanitize-recover=all
+
+CPPFLAGS += -Iinclude -Isrc $(FEATURES)
+CFLAGS ?= -O2 -g
+CFLAGS += -std=c11 $(WARNINGS) $(EXTRA_CFLAGS)
+LDFLAGS += $(EXTRA_LDFLAGS)
+LDLIBS += -lcurl -lm
+
+BUILD := build
+LIB_SRC := src/client.c src/model.c src/util.c src/json.c src/csv.c
+LIB_OBJ := $(LIB_SRC:%.c=$(BUILD)/%.o)
+PIC_OBJ := $(LIB_SRC:%.c=$(BUILD)/%.pic.o)
+TEST_SRC := tests/test_cepis.c tests/test_server.c
+TEST_OBJ := $(TEST_SRC:%.c=$(BUILD)/%.o)
+ALL_SRC := $(LIB_SRC) src/cli.c $(TEST_SRC)
+ALL_HDR := include/cepis.h src/internal.h src/json.h src/csv.h \
+ tests/test_server.h
+
+STATIC := $(BUILD)/libcepis.a
+SHARED := $(BUILD)/libcepis.so.$(VERSION)
+CLI := $(BUILD)/cepis
+TESTS := $(BUILD)/test_cepis
+
+.DEFAULT_GOAL := help
+.PHONY: help build test lint format check clean memcheck sanitize install \
+ uninstall
+
+help:
+ @echo 'Targets:'
+ @echo ' build static and shared libraries, plus the cepis CLI'
+ @echo ' test build and run the suite against a local HTTP server'
+ @echo ' lint clang-format --dry-run + clang-tidy (read-only)'
+ @echo ' format apply clang-format (writes)'
+ @echo ' check lint, then test, then build'
+ @echo ' memcheck run the suite under valgrind'
+ @echo ' sanitize rebuild and run the suite under ASan + UBSan'
+ @echo ' install install headers, libraries and the CLI under PREFIX'
+ @echo ' clean remove $(BUILD)/'
+ @echo
+ @echo 'Override the compiler with e.g. make test CC=clang'
+
+build: $(STATIC) $(SHARED) $(CLI)
+
+$(BUILD)/%.o: %.c
+ @mkdir -p $(dir $@)
+ $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
+
+$(BUILD)/%.pic.o: %.c
+ @mkdir -p $(dir $@)
+ $(CC) $(CPPFLAGS) $(CFLAGS) -fPIC -c $< -o $@
+
+$(STATIC): $(LIB_OBJ)
+ @mkdir -p $(dir $@)
+ $(AR) rcs $@ $(LIB_OBJ)
+
+$(SHARED): $(PIC_OBJ)
+ @mkdir -p $(dir $@)
+ $(CC) -shared -Wl,-soname,libcepis.so.$(SOVERSION) $(PIC_OBJ) \
+ $(LDFLAGS) $(LDLIBS) -o $@
+ ln -sf libcepis.so.$(VERSION) $(BUILD)/libcepis.so.$(SOVERSION)
+ ln -sf libcepis.so.$(VERSION) $(BUILD)/libcepis.so
+
+$(CLI): $(BUILD)/src/cli.o $(STATIC)
+ $(CC) $(BUILD)/src/cli.o $(STATIC) $(LDFLAGS) $(LDLIBS) -o $@
+
+$(TESTS): $(TEST_OBJ) $(STATIC)
+ $(CC) $(TEST_OBJ) $(STATIC) $(LDFLAGS) $(LDLIBS) -lpthread -o $@
+
+test: $(TESTS)
+ $(TESTS)
+
+# 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:
+ $(FORMAT) --dry-run --Werror $(ALL_SRC) $(ALL_HDR)
+ $(CLANG_TIDY) --quiet $(ALL_SRC) -- $(CPPFLAGS) -std=c11
+
+format:
+ $(FORMAT) -i $(ALL_SRC) $(ALL_HDR)
+
+# 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
+
+memcheck: $(TESTS)
+ valgrind --error-exitcode=9 --leak-check=full --show-leak-kinds=definite,indirect \
+ --errors-for-leak-kinds=definite,indirect --track-origins=yes $(TESTS)
+
+# Rebuilt from scratch: the sanitizers change the ABI of the objects, so they
+# must not be mixed with objects from an ordinary build.
+sanitize:
+ $(MAKE) clean
+ $(MAKE) test EXTRA_CFLAGS='$(SAN)' EXTRA_LDFLAGS='$(SAN)'
+ $(MAKE) clean
+
+install: build
+ install -d $(DESTDIR)$(INCLUDEDIR) $(DESTDIR)$(LIBDIR) $(DESTDIR)$(BINDIR)
+ install -m 644 include/cepis.h $(DESTDIR)$(INCLUDEDIR)/
+ install -m 644 $(STATIC) $(DESTDIR)$(LIBDIR)/
+ install -m 755 $(SHARED) $(DESTDIR)$(LIBDIR)/
+ ln -sf libcepis.so.$(VERSION) $(DESTDIR)$(LIBDIR)/libcepis.so.$(SOVERSION)
+ ln -sf libcepis.so.$(VERSION) $(DESTDIR)$(LIBDIR)/libcepis.so
+ install -m 755 $(CLI) $(DESTDIR)$(BINDIR)/
+
+uninstall:
+ rm -f $(DESTDIR)$(INCLUDEDIR)/cepis.h
+ rm -f $(DESTDIR)$(LIBDIR)/libcepis.a
+ rm -f $(DESTDIR)$(LIBDIR)/libcepis.so*
+ rm -f $(DESTDIR)$(BINDIR)/cepis
+
+clean:
+ rm -rf $(BUILD)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2c47a6e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,273 @@
+# cepis
+
+C client for the [public CEP API at cep.is](https://cep.is). Official Correios
+data (DNE), refreshed every two weeks.
+
+```c
+#include <cepis.h>
+#include <stdio.h>
+
+int main(void)
+{
+ cepis_config_t config;
+ cepis_client_t *client;
+ cepis_address_t *address;
+ cepis_error_t err;
+
+ cepis_config_init(&config);
+ if (cepis_client_new(&config, &client, &err) != CEPIS_OK)
+ return 1;
+
+ if (cepis_lookup(client, "01001-000", &address, &err) == CEPIS_OK) {
+ printf("%s %s %s\n", address->logradouro, address->localidade,
+ address->uf);
+ cepis_address_free(address);
+ }
+
+ cepis_client_free(client);
+ return 0;
+}
+```
+
+## Installation
+
+Requires a C11 compiler and [libcurl](https://curl.se/libcurl/) with its
+headers (`libcurl-dev` / `libcurl-devel` / `curl`).
+
+```sh
+git clone https://git.cep.is/c.git cepis-c
+cd cepis-c
+make build
+sudo make install # PREFIX=/usr/local by default
+```
+
+Then compile against it:
+
+```sh
+cc myprogram.c -lcepis -lcurl -lm -o myprogram
+```
+
+Two dependencies, both deliberate: **libcurl**, because C has no standard HTTP
+client and libcurl is the one every platform already ships; and no JSON or CSV
+library at all — both parsers are hand-written here (`src/json.c`, `src/csv.c`,
+about 700 lines together) because the response shapes are small and fixed, and
+vendoring a general-purpose parser would add more code than it removes.
+
+## Usage
+
+### A single lookup
+
+`cepis_lookup` accepts the CEP masked or unmasked and fills a
+`cepis_address_t *` that you release with `cepis_address_free`:
+
+```c
+cepis_address_t *a;
+
+cepis_lookup(client, "01310-100", &a, &err);
+
+a->logradouro; /* "Avenida Paulista" */
+a->localidade; /* "São Paulo" */
+a->uf; /* "SP" */
+a->ibge; /* "3550308" */
+a->cep; /* "01310100" (always unmasked) */
+```
+
+Fields the database does not have for that CEP come back as the empty string,
+never `NULL`. For the masked form:
+
+```c
+char masked[CEPIS_CEP_LEN + 2];
+
+cepis_format_cep(a->cep, masked); /* "01310-100" */
+```
+
+### Coordinates
+
+About 57% of CEPs have a coordinate. When they do not, `geo` is `NULL`:
+
+```c
+if (a->geo != NULL) {
+ printf("%f %f\n", a->geo->lat, a->geo->lon); /* -23.567 -46.649 */
+ printf("%ld\n", a->geo->raio_m); /* 1084 */
+ printf("%s\n", 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 |
+
+`cepis_geo_is_precise()` is the shorthand for `precisao == "logradouro"`:
+
+```c
+if (cepis_geo_is_precise(a->geo))
+ cost = shipping_from(a->geo->lat, a->geo->lon);
+else
+ cost = shipping_from_municipality(a->ibge);
+```
+
+### Many CEPs at once
+
+Do not loop over `cepis_lookup`. Use `cepis_lookup_many`, which resolves up to
+500 CEPs per request:
+
+```c
+const char *ceps[] = {"01001000", "99999999", "20040020"};
+cepis_address_list_t list;
+size_t i;
+
+if (cepis_lookup_many(client, ceps, 3, &list, &err) == CEPIS_OK) {
+ for (i = 0; i < list.count; i++) {
+ if (list.items[i] == NULL)
+ printf("%s: not found\n", ceps[i]);
+ else
+ printf("%s: %s\n", ceps[i], list.items[i]->localidade);
+ }
+ cepis_address_list_free(&list);
+}
+```
+
+The list has **the same length and the same order** as the input. A CEP that
+does not exist, or is malformed, is `NULL` at its position — it does not
+vanish, and it is not an error.
+
+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 `cepis_lookup_many` has
+> `geo == NULL`, including for CEPs that do have one. Need `geo`? Use
+> `cepis_lookup`, one at a time.
+
+### API health
+
+Useful for two things: checking the API answers, and knowing **which version
+of the database** is live:
+
+```c
+cepis_health_t *h;
+
+cepis_health(client, &h, &err);
+cepis_health_ok(h); /* true */
+h->dne; /* "26071" identifier of the fortnightly database */
+h->ceps; /* 1596168 */
+h->base_gerada_em; /* "2026-07-24T03:48:28Z" */
+cepis_health_free(h);
+```
+
+An API reporting itself degraded is not an error — that arrives in `h->status`.
+
+### Memory
+
+Every function that hands back a pointer hands back ownership of it:
+
+| from | released by |
+| --- | --- |
+| `cepis_client_new` | `cepis_client_free` |
+| `cepis_lookup` | `cepis_address_free` |
+| `cepis_lookup_many` | `cepis_address_list_free` |
+| `cepis_health` | `cepis_health_free` |
+
+Every `char *` inside those structs is owned by the struct and freed with it;
+copy the bytes if you need them longer. Strings passed *into* the library are
+borrowed for the call only. A `cepis_error_t` owns nothing and can live on the
+stack.
+
+## Configuration
+
+Fill a `cepis_config_t` with `cepis_config_init` and override what you need.
+Reuse one client per thread; a client is not safe to share between threads:
+
+```c
+cepis_config_t config;
+
+cepis_config_init(&config);
+config.base_url = "https://cep.is"; /* point at another instance */
+config.timeout = 10.0; /* seconds per socket operation */
+config.max_attempts = 3; /* counting the first; 1 disables retry */
+config.backoff_base = 0.5; /* base of the exponential wait */
+config.backoff_max = 8.0; /* cap on each wait */
+config.total_timeout = 0.0; /* cap on the whole call; 0 = none */
+config.user_agent = NULL; /* NULL uses cepis-c/0.1.0 */
+
+cepis_client_new(&config, &client, &err);
+```
+
+Retries cover 429, 5xx and network failures, with exponential backoff and
+jitter; 400 and 404 are never retried. `timeout` caps the connect phase and
+aborts a transfer that stalls for that long — it is not a cap on the whole
+call, which is what `total_timeout` is for. See `include/cepis.h` for the
+details of each member.
+
+### Pointing at a local instance
+
+`base_url` is the only member that usually changes between environments. The
+API repo brings a local instance up with `just up`:
+
+```c
+config.base_url = "http://localhost:8080";
+```
+
+From the command line, to confirm quickly that it answers:
+
+```sh
+cepis --base-url http://localhost:8080 01001000
+```
+
+## Errors
+
+Every fallible call returns a `cepis_status_t` and fills an optional
+`cepis_error_t` with the detail. The split is by **the decision you have to
+make**, not by the HTTP status that caused it:
+
+| status | when | does retrying help? |
+| --- | --- | --- |
+| `CEPIS_ERR_INVALID_CEP` | not 8 digits, or a 400 from the API | no |
+| `CEPIS_ERR_NOT_FOUND` | well-formed, not in the database | no |
+| `CEPIS_ERR_RATE_LIMITED` | 429; see `err.retry_after` | yes, later |
+| `CEPIS_ERR_SERVER` | 5xx; see `err.http_status` | yes, later |
+| `CEPIS_ERR_NETWORK` | DNS, connection, timeout, TLS | yes, later |
+| `CEPIS_ERR_INVALID_RESPONSE` | unintelligible response | probably not |
+| `CEPIS_ERR_CONFIG` | a configuration value out of range | no |
+| `CEPIS_ERR_NOMEM` | allocation failed | no |
+
+```c
+switch (cepis_lookup(client, cep_from_form, &address, &err)) {
+case CEPIS_OK:
+ break;
+case CEPIS_ERR_NOT_FOUND:
+ /* ask the user for another CEP */
+ break;
+default:
+ /* transient failure: reschedule */
+ fprintf(stderr, "%s: %s\n", cepis_status_str(err.status), err.message);
+}
+```
+
+`CEPIS_ERR_INVALID_RESPONSE` 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.
+
+## Development
+
+```sh
+make # list the targets
+make build # static and shared libraries, plus the cepis CLI
+make test # run the suite against a local HTTP server
+make lint # clang-format --dry-run + clang-tidy, touches nothing
+make format # apply clang-format (writes)
+make check # lint, then test, then build
+make memcheck # run the suite under valgrind
+make sanitize # rebuild and run the suite under ASan + UBSan
+```
+
+The suite stands up a real HTTP server on a loopback port and points the
+client at it through `base_url`. Nothing is mocked, and no test touches the
+network.
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
diff --git a/include/cepis.h b/include/cepis.h
new file mode 100644
index 0000000..6b2dc9a
--- /dev/null
+++ b/include/cepis.h
@@ -0,0 +1,482 @@
+/**
+ * @file cepis.h
+ * @brief C client for the public CEP API at https://cep.is
+ *
+ * Official Correios data (DNE), refreshed every two weeks.
+ *
+ * @code
+ * cepis_config_t cfg;
+ * cepis_client_t *client = NULL;
+ * cepis_address_t *address = NULL;
+ * cepis_error_t err;
+ *
+ * cepis_config_init(&cfg);
+ * if (cepis_client_new(&cfg, &client, &err) != CEPIS_OK)
+ * return 1;
+ * if (cepis_lookup(client, "01001-000", &address, &err) == CEPIS_OK) {
+ * printf("%s %s\n", address->localidade, address->uf);
+ * cepis_address_free(address);
+ * }
+ * cepis_client_free(client);
+ * @endcode
+ *
+ * @section ownership Ownership
+ *
+ * Every function that hands back a pointer hands back ownership of it, and
+ * names the function that releases it:
+ *
+ * - ::cepis_client_t from ::cepis_client_new, released by ::cepis_client_free.
+ * - ::cepis_address_t from ::cepis_lookup, released by ::cepis_address_free.
+ * - ::cepis_address_list_t from ::cepis_lookup_many, released by
+ * ::cepis_address_list_free.
+ * - ::cepis_health_t from ::cepis_health, released by ::cepis_health_free.
+ *
+ * Every `char *` inside those structs is owned by the struct and is freed with
+ * it. Never free one on its own, and never keep a pointer to one past the
+ * lifetime of its owner — copy the bytes if you need them longer.
+ *
+ * Strings passed *into* this library (::cepis_config_t members, CEPs) are
+ * borrowed for the duration of the call only; the library copies whatever it
+ * needs to keep.
+ *
+ * @section fieldnames 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 at https://cep.is/01001000 is what you access on the struct.
+ *
+ * @section threads Threads
+ *
+ * A ::cepis_client_t owns one libcurl handle and is **not** safe to use from
+ * two threads at once. Create one client per thread; they share nothing.
+ *
+ * @copyright ISC. Copyright (c) 2026 Murilo Ijanc <[email protected]>
+ */
+#ifndef CEPIS_H
+#define CEPIS_H
+
+#include <stdbool.h>
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Library version. */
+#define CEPIS_VERSION "0.1.0"
+
+/** Default API endpoint. */
+#define CEPIS_DEFAULT_BASE_URL "https://cep.is"
+
+/** Most CEPs the API accepts in one batch request. */
+#define CEPIS_MAX_BATCH 500
+
+/** Digits in a CEP, mask excluded. */
+#define CEPIS_CEP_LEN 8
+
+/** Size of ::cepis_error_t::message, terminator included. */
+#define CEPIS_ERROR_MESSAGE_MAX 256
+
+/**
+ * @brief Result of every fallible call.
+ *
+ * The split is by **the decision the caller has to make**, not by the HTTP
+ * status that caused it. ::CEPIS_ERR_INVALID_CEP and ::CEPIS_ERR_NOT_FOUND are
+ * problems with the request: retrying returns the same answer. The transport
+ * errors below them may succeed later, and the client already retried on its
+ * own before returning any of them.
+ */
+typedef enum cepis_status {
+ /** Success. */
+ CEPIS_OK = 0,
+ /** The CEP is not 8 digits, or the API rejected it with a 400. */
+ CEPIS_ERR_INVALID_CEP,
+ /** Well-formed CEP, absent from the DNE database. */
+ CEPIS_ERR_NOT_FOUND,
+ /** The API answered 429 on every attempt. */
+ CEPIS_ERR_RATE_LIMITED,
+ /** The API answered 5xx on every attempt. */
+ CEPIS_ERR_SERVER,
+ /** The request never completed: DNS, connection, timeout, TLS. */
+ CEPIS_ERR_NETWORK,
+ /** A success status came back with a body that cannot be parsed. */
+ CEPIS_ERR_INVALID_RESPONSE,
+ /** A configuration value is out of range. */
+ CEPIS_ERR_CONFIG,
+ /** Out of memory. */
+ CEPIS_ERR_NOMEM
+} cepis_status_t;
+
+/**
+ * @brief Detail about a failure.
+ *
+ * Owns no memory: the message lives in a fixed buffer inside the struct, so an
+ * error can be declared on the stack and forgotten. Pass a pointer to one as
+ * the last argument of any fallible call, or `NULL` to discard the detail.
+ */
+typedef struct cepis_error {
+ /** Same value the call returned. */
+ cepis_status_t status;
+ /** Human-readable explanation, always NUL-terminated. */
+ char message[CEPIS_ERROR_MESSAGE_MAX];
+ /** HTTP status of the last attempt, or 0 when there was none. */
+ long http_status;
+ /**
+ * Seconds the server asked the caller to wait, from `Retry-After`, or
+ * a negative value when it did not say. Set for
+ * ::CEPIS_ERR_RATE_LIMITED. The client already honoured it across the
+ * attempts it made; it is exposed for rescheduling the work.
+ */
+ double retry_after;
+} cepis_error_t;
+
+/**
+ * @brief 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.
+ */
+typedef struct cepis_geo {
+ /** Latitude in decimal degrees. */
+ double lat;
+ /** Longitude in decimal degrees. */
+ double lon;
+ /** Radius in metres containing 90% of the CEP's addresses. */
+ long raio_m;
+ /** `"logradouro"` or `"localidade"`. Never `NULL`. */
+ char *precisao;
+} cepis_geo_t;
+
+/**
+ * @brief A CEP and everything the database knows about it.
+ *
+ * Every `char *` field is non-`NULL` and owned by the struct. Fields the
+ * database does not have for that CEP are the empty string, never `NULL` --
+ * 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 `NULL` 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.
+ */
+typedef struct cepis_address {
+ /** The 8 digits, unmasked. */
+ char *cep;
+ char *logradouro;
+ char *complemento;
+ char *unidade;
+ char *bairro;
+ char *localidade;
+ /** Two-letter state code, e.g. `"SP"`. */
+ char *uf;
+ char *estado;
+ char *regiao;
+ /** IBGE municipality code. */
+ char *ibge;
+ char *gia;
+ char *ddd;
+ char *siafi;
+ /** The coordinate, or `NULL` when the database has none. */
+ cepis_geo_t *geo;
+ /**
+ * The response body exactly as it arrived, uninterpreted, so a field
+ * the API grows before this library is updated is not lost. `NULL` for
+ * addresses that came from ::cepis_lookup_many, whose transport is CSV.
+ */
+ char *raw;
+} cepis_address_t;
+
+/**
+ * @brief Result of ::cepis_lookup_many.
+ *
+ * ::items has ::count entries, one per requested CEP, **in the requested
+ * order**. An entry is `NULL` when that CEP does not exist or was malformed.
+ */
+typedef struct cepis_address_list {
+ /** ::count entries, any of which may be `NULL`. */
+ cepis_address_t **items;
+ /** Same as the number of CEPs requested. */
+ size_t count;
+} cepis_address_list_t;
+
+/**
+ * @brief 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.
+ */
+typedef struct cepis_health {
+ /** `"ok"` when the API reports itself healthy. Never `NULL`. */
+ char *status;
+ /** Identifier of the fortnightly DNE database. Never `NULL`. */
+ char *dne;
+ /** CEPs in the database. */
+ long long ceps;
+ /** CEPs that carry a coordinate. */
+ long long geo;
+ /** ISO-8601 timestamp of the database build. Never `NULL`. */
+ char *base_gerada_em;
+} cepis_health_t;
+
+/**
+ * @brief Client configuration.
+ *
+ * Fill it with ::cepis_config_init and override what you need. The two string
+ * members are borrowed for the duration of ::cepis_client_new only -- the
+ * client copies them.
+ */
+typedef struct cepis_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. It is the only member that usually changes between
+ * environments. A trailing slash is stripped, so it cannot produce
+ * doubled separators that some proxies normalise and others 404.
+ */
+ const char *base_url;
+ /**
+ * Overrides the `User-Agent`. `NULL` uses the default, which
+ * identifies the library and its version -- that is what lets the
+ * server diagnose an old client misbehaving.
+ */
+ const char *user_agent;
+ /**
+ * Seconds to wait per socket operation: it caps the connect phase and
+ * aborts a transfer that stalls for that long. It is **not** a cap on
+ * the whole call -- that is ::total_timeout. Must be > 0.
+ */
+ double timeout;
+ /**
+ * 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. Must be
+ * >= 1.
+ */
+ int max_attempts;
+ /**
+ * Base of the exponential wait, in seconds. 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.
+ */
+ double backoff_base;
+ /**
+ * Cap on each individual wait, in seconds. 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.
+ */
+ double backoff_max;
+ /**
+ * Cap on the whole call, attempts and waits included. `0` 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.
+ */
+ double total_timeout;
+} cepis_config_t;
+
+/**
+ * @brief Opaque client.
+ *
+ * Create with ::cepis_client_new, release with ::cepis_client_free. Reuse one
+ * per thread: the configuration is immutable and no call keeps state between
+ * invocations, so consecutive requests share a connection.
+ */
+typedef struct cepis_client cepis_client_t;
+
+/**
+ * @brief Fill @p config with the defaults.
+ *
+ * `https://cep.is`, 10 s per socket operation, 3 attempts, 0.5 s backoff base,
+ * 8 s backoff cap, no total timeout, the library's own user agent.
+ */
+void cepis_config_init(cepis_config_t *config);
+
+/**
+ * @brief Create a client.
+ *
+ * @param config Configuration, or `NULL` for the defaults. Copied.
+ * @param out Receives the client on success, untouched otherwise.
+ * @param err Receives failure detail, or `NULL`.
+ * @return ::CEPIS_OK, ::CEPIS_ERR_CONFIG for a value out of range, or
+ * ::CEPIS_ERR_NOMEM.
+ *
+ * Release the client with ::cepis_client_free.
+ */
+cepis_status_t cepis_client_new(const cepis_config_t *config,
+ cepis_client_t **out, cepis_error_t *err);
+
+/** @brief Release a client. `NULL` is accepted and ignored. */
+void cepis_client_free(cepis_client_t *client);
+
+/** @brief The base URL the client was built with, trailing slash stripped. */
+const char *cepis_client_base_url(const cepis_client_t *client);
+
+/** @brief The `User-Agent` the client sends. */
+const char *cepis_client_user_agent(const cepis_client_t *client);
+
+/**
+ * @brief Look up a single CEP.
+ *
+ * @param client A client.
+ * @param cep Masked or unmasked; validated locally before any request.
+ * @param out Receives the address on success, untouched otherwise. Release
+ * it with ::cepis_address_free.
+ * @param err Receives failure detail, or `NULL`.
+ * @return ::CEPIS_OK, or one of ::CEPIS_ERR_INVALID_CEP (not 8 digits -- no
+ * request is made), ::CEPIS_ERR_NOT_FOUND, ::CEPIS_ERR_RATE_LIMITED,
+ * ::CEPIS_ERR_SERVER, ::CEPIS_ERR_NETWORK,
+ * ::CEPIS_ERR_INVALID_RESPONSE, ::CEPIS_ERR_NOMEM.
+ *
+ * A 400 from the API also becomes ::CEPIS_ERR_INVALID_CEP, carrying the API's
+ * own message: the format already passed local validation, so the server knows
+ * something this client does not.
+ *
+ * This is the only call that carries ::cepis_address_t::geo.
+ */
+cepis_status_t cepis_lookup(cepis_client_t *client, const char *cep,
+ cepis_address_t **out, cepis_error_t *err);
+
+/**
+ * @brief Look up many CEPs in one batch.
+ *
+ * @param client A client.
+ * @param ceps @p count CEPs, masked or unmasked.
+ * @param count How many. `0` succeeds without making a request.
+ * @param out Receives the list on success, untouched otherwise. Release it
+ * with ::cepis_address_list_free.
+ * @param err Receives failure detail, or `NULL`.
+ * @return ::CEPIS_OK or a transport error. Never ::CEPIS_ERR_NOT_FOUND.
+ *
+ * The list has **the same length and the same order** as the input. A CEP that
+ * does not exist becomes `NULL` at its position -- it does not vanish from the
+ * list, and it is not an error. That is the right difference from
+ * ::cepis_lookup: in a batch of a thousand, one bad CEP must not cost the other
+ * nine hundred and ninety-nine. A malformed CEP becomes `NULL` for the same
+ * reason; call ::cepis_normalize_cep first if you need to know which one it
+ * was.
+ *
+ * Input is chunked into blocks of ::CEPIS_MAX_BATCH automatically. Do not loop
+ * over ::cepis_lookup instead: a loop of 500 calls pays 500 round trips, this
+ * pays one.
+ *
+ * @warning The batch carries **no coordinate**. The endpoint behind it is CSV,
+ * and the CSV has no `geo` column. Every address returned here has
+ * `geo == NULL`, including for CEPs that do have one. Need `geo`? Use
+ * ::cepis_lookup, one at a time.
+ */
+cepis_status_t cepis_lookup_many(cepis_client_t *client,
+ const char *const *ceps, size_t count,
+ cepis_address_list_t *out, cepis_error_t *err);
+
+/**
+ * @brief Query `GET /health`: does the API answer, and with which database.
+ *
+ * @param client A client.
+ * @param out Receives the health report. Release it with
+ * ::cepis_health_free.
+ * @param err Receives failure detail, or `NULL`.
+ * @return ::CEPIS_OK or a transport error.
+ *
+ * Does not fail for an API reporting itself degraded -- that arrives in
+ * ::cepis_health_t::status, and checking health is exactly when you want the
+ * diagnosis rather than an error. Network failures still fail.
+ */
+cepis_status_t cepis_health(cepis_client_t *client, cepis_health_t **out,
+ cepis_error_t *err);
+
+/**
+ * @brief Release an address. `NULL` is accepted and ignored.
+ *
+ * Frees every string it owns, its ::cepis_address_t::geo and its
+ * ::cepis_address_t::raw.
+ */
+void cepis_address_free(cepis_address_t *address);
+
+/**
+ * @brief Release a list and every address in it.
+ *
+ * Zeroes @p list afterwards, so calling it twice is harmless. `NULL` is
+ * accepted and ignored.
+ */
+void cepis_address_list_free(cepis_address_list_t *list);
+
+/** @brief Release a health report. `NULL` is accepted and ignored. */
+void cepis_health_free(cepis_health_t *health);
+
+/**
+ * @brief Return the CEP's 8 digits, accepting the mask.
+ *
+ * `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`.
+ *
+ * @param value The CEP as the caller has it.
+ * @param out Receives the 8 digits and a terminator. Untouched on failure.
+ * @return ::CEPIS_OK, or ::CEPIS_ERR_INVALID_CEP unless exactly 8 digits
+ * remain.
+ *
+ * The client runs this **before** any request: a 7-digit CEP is the caller's
+ * bug, and spending a round trip for the server to say so wastes both sides'
+ * time.
+ */
+cepis_status_t cepis_normalize_cep(const char *value,
+ char out[CEPIS_CEP_LEN + 1]);
+
+/**
+ * @brief Write the CEP with its mask: `01001-000`.
+ *
+ * @param cep 8 digits, masked or not.
+ * @param out Receives 9 characters and a terminator. Untouched on failure.
+ * @return ::CEPIS_OK or ::CEPIS_ERR_INVALID_CEP.
+ *
+ * The native contract always returns the CEP unmasked; formatting is
+ * presentation, and it lives here so it does not become a repeated `sprintf` in
+ * every project that uses this.
+ */
+cepis_status_t cepis_format_cep(const char *cep, char out[CEPIS_CEP_LEN + 2]);
+
+/**
+ * @brief `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. `false` for `NULL`.
+ */
+bool cepis_geo_is_precise(const cepis_geo_t *geo);
+
+/** @brief `true` when the API reports itself healthy. `false` for `NULL`. */
+bool cepis_health_ok(const cepis_health_t *health);
+
+/** @brief A short, stable, English name for a status code. */
+const char *cepis_status_str(cepis_status_t status);
+
+/**
+ * @brief Initialise the process-wide state this library's HTTP layer needs.
+ *
+ * Optional: ::cepis_client_new does it too. Call it once from `main`, before
+ * any thread starts, if you create clients concurrently -- that removes any
+ * doubt about the initialisation racing.
+ *
+ * Each successful call must be balanced by one ::cepis_global_cleanup.
+ */
+cepis_status_t cepis_global_init(void);
+
+/** @brief Balance one ::cepis_global_init. */
+void cepis_global_cleanup(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/src/cli.c b/src/cli.c
new file mode 100644
index 0000000..e4c9bd4
--- /dev/null
+++ b/src/cli.c
@@ -0,0 +1,191 @@
+/**
+ * @file cli.c
+ * @brief Minimal command-line front end: `cepis 01001000`.
+ *
+ * It exists for one concrete case -- quickly confirming a local instance
+ * answers before pointing code at it:
+ *
+ * cepis --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.h`.
+ */
+#include "cepis.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static void print_escaped(const char *text)
+{
+ size_t i;
+
+ putchar('"');
+ for (i = 0; text[i] != '\0'; i++) {
+ unsigned char c = (unsigned char)text[i];
+ switch (c) {
+ case '"':
+ fputs("\\\"", stdout);
+ break;
+ case '\\':
+ fputs("\\\\", stdout);
+ break;
+ case '\n':
+ fputs("\\n", stdout);
+ break;
+ case '\r':
+ fputs("\\r", stdout);
+ break;
+ case '\t':
+ fputs("\\t", stdout);
+ break;
+ default:
+ if (c < 0x20)
+ printf("\\u%04x", c);
+ else
+ putchar((int)c);
+ }
+ }
+ putchar('"');
+}
+
+static void print_pair(const char *key, const char *value, bool last)
+{
+ printf(" \"%s\": ", key);
+ print_escaped(value);
+ printf("%s\n", last ? "" : ",");
+}
+
+static void print_address(const cepis_address_t *address)
+{
+ if (address == NULL) {
+ printf(" null");
+ return;
+ }
+ printf(" {\n");
+ print_pair("cep", address->cep, false);
+ print_pair("logradouro", address->logradouro, false);
+ print_pair("complemento", address->complemento, false);
+ print_pair("unidade", address->unidade, false);
+ print_pair("bairro", address->bairro, false);
+ print_pair("localidade", address->localidade, false);
+ print_pair("uf", address->uf, false);
+ print_pair("estado", address->estado, false);
+ print_pair("regiao", address->regiao, false);
+ print_pair("ibge", address->ibge, false);
+ print_pair("gia", address->gia, false);
+ print_pair("ddd", address->ddd, false);
+ print_pair("siafi", address->siafi, address->geo == NULL);
+ if (address->geo != NULL) {
+ printf(" \"geo\": {\"lat\": %.6f, \"lon\": %.6f, "
+ "\"raio_m\": %ld, \"precisao\": ",
+ address->geo->lat, address->geo->lon,
+ address->geo->raio_m);
+ print_escaped(address->geo->precisao);
+ printf("}\n");
+ }
+ printf(" }");
+}
+
+static int usage(int code)
+{
+ FILE *stream = code == 0 ? stdout : stderr;
+
+ fprintf(stream,
+ "usage: cepis [--base-url URL] [--timeout SECONDS] CEP...\n"
+ "\n"
+ " --base-url URL API URL (default: %s)\n"
+ " --timeout SECONDS per-socket timeout (default: 10)\n"
+ " --version print the version and exit\n",
+ CEPIS_DEFAULT_BASE_URL);
+ return code;
+}
+
+int main(int argc, char **argv)
+{
+ cepis_config_t config;
+ cepis_client_t *client = NULL;
+ cepis_error_t err;
+ const char **ceps;
+ size_t count = 0;
+ int status = 0;
+ int i;
+
+ cepis_config_init(&config);
+ ceps = calloc((size_t)argc, sizeof(*ceps));
+ if (ceps == NULL)
+ return 1;
+
+ for (i = 1; i < argc; i++) {
+ if (strcmp(argv[i], "--help") == 0) {
+ free(ceps);
+ return usage(0);
+ }
+ if (strcmp(argv[i], "--version") == 0) {
+ printf("cepis %s\n", CEPIS_VERSION);
+ free(ceps);
+ return 0;
+ }
+ if (strcmp(argv[i], "--base-url") == 0 && i + 1 < argc) {
+ config.base_url = argv[++i];
+ continue;
+ }
+ if (strcmp(argv[i], "--timeout") == 0 && i + 1 < argc) {
+ config.timeout = strtod(argv[++i], NULL);
+ continue;
+ }
+ if (argv[i][0] == '-') {
+ free(ceps);
+ return usage(2);
+ }
+ ceps[count++] = argv[i];
+ }
+
+ if (count == 0) {
+ free(ceps);
+ return usage(2);
+ }
+
+ if (cepis_client_new(&config, &client, &err) != CEPIS_OK) {
+ fprintf(stderr, "error: %s\n", err.message);
+ free(ceps);
+ return 1;
+ }
+
+ if (count == 1) {
+ cepis_address_t *address = NULL;
+ if (cepis_lookup(client, ceps[0], &address, &err) != CEPIS_OK) {
+ fprintf(stderr, "error: %s: %s\n",
+ cepis_status_str(err.status), err.message);
+ status = 1;
+ } else {
+ print_address(address);
+ printf("\n");
+ cepis_address_free(address);
+ }
+ } else {
+ cepis_address_list_t list = {NULL, 0};
+ if (cepis_lookup_many(client, ceps, count, &list, &err) !=
+ CEPIS_OK) {
+ fprintf(stderr, "error: %s: %s\n",
+ cepis_status_str(err.status), err.message);
+ status = 1;
+ } else {
+ size_t index;
+ printf("[\n");
+ for (index = 0; index < list.count; index++) {
+ print_address(list.items[index]);
+ printf("%s\n",
+ index + 1 < list.count ? "," : "");
+ }
+ printf("]\n");
+ cepis_address_list_free(&list);
+ }
+ }
+
+ cepis_client_free(client);
+ free(ceps);
+ return status;
+}
diff --git a/src/client.c b/src/client.c
new file mode 100644
index 0000000..58c55b3
--- /dev/null
+++ b/src/client.c
@@ -0,0 +1,822 @@
+#include "internal.h"
+
+#include <curl/curl.h>
+#include <errno.h>
+#include <math.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <time.h>
+
+#define CEPIS_ACCEPT_HEADER \
+ "Accept: application/json, text/csv;q=0.9, */*;q=0.1"
+
+static const long RETRIABLE_STATUS[] = {429, 500, 502, 503, 504};
+
+#define RETRIABLE_COUNT (sizeof(RETRIABLE_STATUS) / sizeof(RETRIABLE_STATUS[0]))
+
+struct cepis_client {
+ char *base_url;
+ char *user_agent;
+ double timeout;
+ double backoff_base;
+ double backoff_max;
+ double total_timeout;
+ int max_attempts;
+ uint64_t rng;
+ CURL *curl;
+ struct curl_slist *headers;
+};
+
+typedef struct {
+ long status;
+ double retry_after;
+ cepis_buf_t body;
+} cepis_response_t;
+
+const char *cepis_status_str(cepis_status_t status)
+{
+ switch (status) {
+ case CEPIS_OK:
+ return "ok";
+ case CEPIS_ERR_INVALID_CEP:
+ return "invalid CEP";
+ case CEPIS_ERR_NOT_FOUND:
+ return "CEP not found";
+ case CEPIS_ERR_RATE_LIMITED:
+ return "rate limited";
+ case CEPIS_ERR_SERVER:
+ return "server error";
+ case CEPIS_ERR_NETWORK:
+ return "network error";
+ case CEPIS_ERR_INVALID_RESPONSE:
+ return "invalid response";
+ case CEPIS_ERR_CONFIG:
+ return "invalid configuration";
+ case CEPIS_ERR_NOMEM:
+ return "out of memory";
+ }
+ return "unknown error";
+}
+
+cepis_status_t cepis_global_init(void)
+{
+ if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK)
+ return CEPIS_ERR_NETWORK;
+ return CEPIS_OK;
+}
+
+void cepis_global_cleanup(void)
+{
+ curl_global_cleanup();
+}
+
+cepis_status_t cepis_normalize_cep(const char *value,
+ char out[CEPIS_CEP_LEN + 1])
+{
+ char digits[CEPIS_CEP_LEN + 1];
+ size_t n = 0;
+ size_t i;
+
+ if (value == NULL || out == NULL)
+ return CEPIS_ERR_INVALID_CEP;
+
+ for (i = 0; value[i] != '\0'; i++) {
+ char c = value[i];
+ if (c == ' ' || c == '\t' || c == '\n' || c == '\r' ||
+ c == '-' || c == '.')
+ continue;
+ if (c < '0' || c > '9')
+ return CEPIS_ERR_INVALID_CEP;
+ if (n >= CEPIS_CEP_LEN)
+ return CEPIS_ERR_INVALID_CEP;
+ digits[n++] = c;
+ }
+ if (n != CEPIS_CEP_LEN)
+ return CEPIS_ERR_INVALID_CEP;
+
+ digits[CEPIS_CEP_LEN] = '\0';
+ memcpy(out, digits, CEPIS_CEP_LEN + 1);
+ return CEPIS_OK;
+}
+
+cepis_status_t cepis_format_cep(const char *cep, char out[CEPIS_CEP_LEN + 2])
+{
+ char digits[CEPIS_CEP_LEN + 1];
+ cepis_status_t status = cepis_normalize_cep(cep, digits);
+
+ if (status != CEPIS_OK)
+ return status;
+ memcpy(out, digits, 5);
+ out[5] = '-';
+ memcpy(out + 6, digits + 5, 3);
+ out[CEPIS_CEP_LEN + 1] = '\0';
+ return CEPIS_OK;
+}
+
+void cepis_config_init(cepis_config_t *config)
+{
+ if (config == NULL)
+ return;
+ config->base_url = CEPIS_DEFAULT_BASE_URL;
+ config->user_agent = NULL;
+ config->timeout = 10.0;
+ config->max_attempts = 3;
+ config->backoff_base = 0.5;
+ config->backoff_max = 8.0;
+ config->total_timeout = 0.0;
+}
+
+static char *trim_base_url(const char *base_url)
+{
+ size_t len = strlen(base_url);
+ char *copy;
+
+ while (len > 0 && base_url[len - 1] == '/')
+ len--;
+ copy = malloc(len + 1);
+ if (copy == NULL)
+ return NULL;
+ memcpy(copy, base_url, len);
+ copy[len] = '\0';
+ return copy;
+}
+
+cepis_status_t cepis_client_new(const cepis_config_t *config,
+ cepis_client_t **out, cepis_error_t *err)
+{
+ cepis_config_t defaults;
+ cepis_client_t *client;
+
+ if (out == NULL)
+ return cepis_set_error(err, CEPIS_ERR_CONFIG, 0, -1.0,
+ "out must not be NULL");
+ if (config == NULL) {
+ cepis_config_init(&defaults);
+ config = &defaults;
+ }
+ if (config->base_url == NULL || config->base_url[0] == '\0')
+ return cepis_set_error(err, CEPIS_ERR_CONFIG, 0, -1.0,
+ "base_url must not be empty");
+ if (config->max_attempts < 1)
+ return cepis_set_error(err, CEPIS_ERR_CONFIG, 0, -1.0,
+ "max_attempts must be >= 1");
+ if (!(config->timeout > 0.0))
+ return cepis_set_error(err, CEPIS_ERR_CONFIG, 0, -1.0,
+ "timeout must be > 0");
+ if (config->backoff_base < 0.0 || config->backoff_max < 0.0)
+ return cepis_set_error(err, CEPIS_ERR_CONFIG, 0, -1.0,
+ "backoff values must be >= 0");
+ if (config->total_timeout < 0.0)
+ return cepis_set_error(err, CEPIS_ERR_CONFIG, 0, -1.0,
+ "total_timeout must be >= 0");
+
+ if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK)
+ return cepis_set_error(err, CEPIS_ERR_NETWORK, 0, -1.0,
+ "could not initialise libcurl");
+
+ client = calloc(1, sizeof(*client));
+ if (client == NULL) {
+ curl_global_cleanup();
+ return cepis_set_error(err, CEPIS_ERR_NOMEM, 0, -1.0,
+ "out of memory");
+ }
+
+ client->timeout = config->timeout;
+ client->max_attempts = config->max_attempts;
+ client->backoff_base = config->backoff_base;
+ client->backoff_max = config->backoff_max;
+ client->total_timeout = config->total_timeout;
+ client->rng =
+ (uint64_t)time(NULL) ^ ((uint64_t)(uintptr_t)client << 16);
+ if (client->rng == 0)
+ client->rng = 0x9E3779B97F4A7C15ULL;
+
+ client->base_url = trim_base_url(config->base_url);
+ if (config->user_agent != NULL && config->user_agent[0] != '\0') {
+ client->user_agent = cepis_strdup(config->user_agent);
+ } else {
+ client->user_agent = cepis_strdup("cepis-c/" CEPIS_VERSION
+ " (+https://cep.is)");
+ }
+ client->curl = curl_easy_init();
+ client->headers = curl_slist_append(NULL, CEPIS_ACCEPT_HEADER);
+
+ if (client->base_url == NULL || client->user_agent == NULL ||
+ client->curl == NULL || client->headers == NULL) {
+ cepis_client_free(client);
+ return cepis_set_error(err, CEPIS_ERR_NOMEM, 0, -1.0,
+ "out of memory");
+ }
+
+ *out = client;
+ return CEPIS_OK;
+}
+
+void cepis_client_free(cepis_client_t *client)
+{
+ if (client == NULL)
+ return;
+ if (client->curl != NULL)
+ curl_easy_cleanup(client->curl);
+ if (client->headers != NULL)
+ curl_slist_free_all(client->headers);
+ free(client->base_url);
+ free(client->user_agent);
+ free(client);
+ curl_global_cleanup();
+}
+
+const char *cepis_client_base_url(const cepis_client_t *client)
+{
+ return client == NULL ? NULL : client->base_url;
+}
+
+const char *cepis_client_user_agent(const cepis_client_t *client)
+{
+ return client == NULL ? NULL : client->user_agent;
+}
+
+static double monotonic_now(void)
+{
+ struct timespec ts;
+
+ if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0)
+ return 0.0;
+ return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
+}
+
+static void sleep_seconds(double seconds)
+{
+ struct timespec request;
+
+ if (seconds <= 0.0)
+ return;
+ if (seconds > 3600.0)
+ seconds = 3600.0;
+ request.tv_sec = (time_t)seconds;
+ request.tv_nsec = (long)((seconds - (double)request.tv_sec) * 1e9);
+ if (request.tv_nsec < 0)
+ request.tv_nsec = 0;
+ if (request.tv_nsec > 999999999L)
+ request.tv_nsec = 999999999L;
+ while (nanosleep(&request, &request) == -1 && errno == EINTR)
+ ;
+}
+
+static double next_uniform(cepis_client_t *client)
+{
+ uint64_t x = client->rng;
+
+ x ^= x >> 12;
+ x ^= x << 25;
+ x ^= x >> 27;
+ client->rng = x;
+ x *= 0x2545F4914F6CDD1DULL;
+ return (double)(x >> 11) / 9007199254740992.0;
+}
+
+static double backoff_delay(cepis_client_t *client, int attempt,
+ double retry_after)
+{
+ double ceiling;
+
+ if (retry_after >= 0.0)
+ return retry_after < client->backoff_max ? retry_after
+ : client->backoff_max;
+ ceiling = client->backoff_base * pow(2.0, (double)(attempt - 1));
+ if (ceiling > client->backoff_max)
+ ceiling = client->backoff_max;
+ return next_uniform(client) * ceiling;
+}
+
+static double parse_retry_after(const char *value, size_t len)
+{
+ char text[128];
+ const char *formats[] = {"%a, %d %b %Y %H:%M:%S",
+ "%A, %d-%b-%y %H:%M:%S",
+ "%a %b %d %H:%M:%S %Y"};
+ char *stop = NULL;
+ double seconds;
+ size_t i;
+
+ while (len > 0 && (*value == ' ' || *value == '\t')) {
+ value++;
+ len--;
+ }
+ while (len > 0 && (value[len - 1] == ' ' || value[len - 1] == '\t' ||
+ value[len - 1] == '\r' || value[len - 1] == '\n'))
+ len--;
+ if (len == 0 || len >= sizeof(text))
+ return -1.0;
+ memcpy(text, value, len);
+ text[len] = '\0';
+
+ seconds = strtod(text, &stop);
+ if (stop != NULL && *stop == '\0' && stop != text)
+ return seconds < 0.0 ? 0.0 : seconds;
+
+ for (i = 0; i < sizeof(formats) / sizeof(formats[0]); i++) {
+ struct tm parsed;
+ time_t target;
+
+ memset(&parsed, 0, sizeof(parsed));
+ if (strptime(text, formats[i], &parsed) == NULL)
+ continue;
+ target = timegm(&parsed);
+ if (target == (time_t)-1)
+ continue;
+ seconds = difftime(target, time(NULL));
+ return seconds < 0.0 ? 0.0 : seconds;
+ }
+ return -1.0;
+}
+
+static size_t write_body(char *ptr, size_t size, size_t nmemb, void *userdata)
+{
+ cepis_buf_t *body = userdata;
+ size_t total = size * nmemb;
+
+ if (!cepis_buf_append(body, ptr, total))
+ return 0;
+ return total;
+}
+
+static size_t read_header(char *ptr, size_t size, size_t nmemb, void *userdata)
+{
+ cepis_response_t *response = userdata;
+ size_t total = size * nmemb;
+ static const char name[] = "Retry-After:";
+ size_t name_len = sizeof(name) - 1;
+
+ if (total > name_len && strncasecmp(ptr, name, name_len) == 0)
+ response->retry_after =
+ parse_retry_after(ptr + name_len, total - name_len);
+ return total;
+}
+
+static bool is_retriable(long status)
+{
+ size_t i;
+
+ for (i = 0; i < RETRIABLE_COUNT; i++)
+ if (RETRIABLE_STATUS[i] == status)
+ return true;
+ return false;
+}
+
+static bool is_accepted(long status, const long *accepted, size_t count)
+{
+ size_t i;
+
+ for (i = 0; i < count; i++)
+ if (accepted[i] == status)
+ return true;
+ return false;
+}
+
+static void apply_options(cepis_client_t *client, const char *url,
+ cepis_response_t *response, char *error_buffer,
+ double budget)
+{
+ CURL *curl = client->curl;
+ long low_speed_time = (long)ceil(client->timeout);
+
+ if (low_speed_time < 1)
+ low_speed_time = 1;
+
+ curl_easy_reset(curl);
+ curl_easy_setopt(curl, CURLOPT_URL, url);
+ curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
+ curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
+ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
+ curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
+ curl_easy_setopt(curl, CURLOPT_USERAGENT, client->user_agent);
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, client->headers);
+ curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "identity");
+ curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS,
+ (long)(client->timeout * 1000.0));
+ curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L);
+ curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, low_speed_time);
+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_body);
+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response->body);
+ curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, read_header);
+ curl_easy_setopt(curl, CURLOPT_HEADERDATA, response);
+ curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, error_buffer);
+ if (budget > 0.0) {
+ long ms = (long)(budget * 1000.0);
+ curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, ms > 1 ? ms : 1L);
+ }
+}
+
+static cepis_status_t client_request(cepis_client_t *client, const char *url,
+ const long *accepted,
+ size_t accepted_count,
+ cepis_response_t *response,
+ cepis_error_t *err)
+{
+ char error_buffer[CURL_ERROR_SIZE];
+ double deadline = client->total_timeout > 0.0
+ ? monotonic_now() + client->total_timeout
+ : 0.0;
+ long last_status = 0;
+ double last_retry_after = -1.0;
+ int attempt;
+
+ error_buffer[0] = '\0';
+ response->status = 0;
+ response->retry_after = -1.0;
+ cepis_buf_init(&response->body);
+
+ for (attempt = 1; attempt <= client->max_attempts; attempt++) {
+ double budget = 0.0;
+ CURLcode code;
+ double wait;
+
+ if (deadline > 0.0) {
+ budget = deadline - monotonic_now();
+ if (budget <= 0.0)
+ break;
+ }
+
+ response->body.len = 0;
+ if (response->body.data != NULL)
+ response->body.data[0] = '\0';
+ response->retry_after = -1.0;
+ error_buffer[0] = '\0';
+
+ apply_options(client, url, response, error_buffer, budget);
+ code = curl_easy_perform(client->curl);
+
+ if (code == CURLE_OK) {
+ long status = 0;
+ curl_easy_getinfo(client->curl, CURLINFO_RESPONSE_CODE,
+ &status);
+ if (is_accepted(status, accepted, accepted_count) ||
+ !is_retriable(status)) {
+ response->status = status;
+ return CEPIS_OK;
+ }
+ last_status = status;
+ last_retry_after = response->retry_after;
+ } else {
+ last_status = 0;
+ last_retry_after = -1.0;
+ if (error_buffer[0] == '\0')
+ snprintf(error_buffer, sizeof(error_buffer),
+ "%s", curl_easy_strerror(code));
+ }
+
+ if (attempt == client->max_attempts)
+ break;
+
+ wait = backoff_delay(client, attempt, last_retry_after);
+ if (deadline > 0.0) {
+ double remaining = deadline - monotonic_now();
+ if (remaining <= 0.0)
+ break;
+ if (wait > remaining)
+ wait = remaining;
+ }
+ sleep_seconds(wait);
+ }
+
+ cepis_buf_free(&response->body);
+
+ if (last_status == 429)
+ return cepis_set_error(err, CEPIS_ERR_RATE_LIMITED, last_status,
+ last_retry_after,
+ "rate limit reached at %s (after %d "
+ "attempt(s))",
+ url, client->max_attempts);
+ if (last_status != 0)
+ return cepis_set_error(err, CEPIS_ERR_SERVER, last_status, -1.0,
+ "the API answered %ld at %s (after %d "
+ "attempt(s))",
+ last_status, url, client->max_attempts);
+ return cepis_set_error(err, CEPIS_ERR_NETWORK, 0, -1.0,
+ "could not reach %s (after %d attempt(s)): %s",
+ url, client->max_attempts,
+ error_buffer[0] != '\0' ? error_buffer
+ : "request aborted");
+}
+
+static void api_message(const cepis_buf_t *body, char *out, size_t out_size,
+ const char *fallback)
+{
+ json_value_t *root = json_parse(body->data, body->len);
+ const char *message = json_as_string(json_object_get(root, "mensagem"));
+
+ snprintf(out, out_size, "%s", message != NULL ? message : fallback);
+ json_free(root);
+}
+
+static cepis_status_t build_url(const cepis_client_t *client, const char *path,
+ cepis_buf_t *out)
+{
+ cepis_buf_init(out);
+ if (!cepis_buf_append_str(out, client->base_url) ||
+ !cepis_buf_append_str(out, path)) {
+ cepis_buf_free(out);
+ return CEPIS_ERR_NOMEM;
+ }
+ return CEPIS_OK;
+}
+
+cepis_status_t cepis_lookup(cepis_client_t *client, const char *cep,
+ cepis_address_t **out, cepis_error_t *err)
+{
+ char number[CEPIS_CEP_LEN + 1];
+ char path[CEPIS_CEP_LEN + 2];
+ char message[CEPIS_ERROR_MESSAGE_MAX];
+ char fallback[CEPIS_ERROR_MESSAGE_MAX];
+ static const long accepted[] = {200};
+ cepis_response_t response;
+ cepis_address_t *address;
+ cepis_buf_t url;
+ json_value_t *root;
+ cepis_status_t status;
+
+ if (client == NULL || out == NULL)
+ return cepis_set_error(err, CEPIS_ERR_CONFIG, 0, -1.0,
+ "client and out must not be NULL");
+ if (cepis_normalize_cep(cep, number) != CEPIS_OK)
+ return cepis_set_error(err, CEPIS_ERR_INVALID_CEP, 0, -1.0,
+ "CEP must be 8 digits, got \"%s\"",
+ cep != NULL ? cep : "(null)");
+
+ snprintf(path, sizeof(path), "/%s", number);
+ status = build_url(client, path, &url);
+ if (status != CEPIS_OK)
+ return cepis_set_error(err, status, 0, -1.0, "out of memory");
+
+ status = client_request(client, url.data, accepted, 1, &response, err);
+ cepis_buf_free(&url);
+ if (status != CEPIS_OK)
+ return status;
+
+ if (response.status == 404) {
+ cepis_buf_free(&response.body);
+ return cepis_set_error(
+ err, CEPIS_ERR_NOT_FOUND, 404, -1.0,
+ "CEP %s does not exist in the DNE database", number);
+ }
+ if (response.status == 400) {
+ snprintf(fallback, sizeof(fallback), "CEP %s rejected", number);
+ api_message(&response.body, message, sizeof(message), fallback);
+ cepis_buf_free(&response.body);
+ return cepis_set_error(err, CEPIS_ERR_INVALID_CEP, 400, -1.0,
+ "%s", message);
+ }
+ if (response.status != 200) {
+ long seen = response.status;
+ cepis_buf_free(&response.body);
+ return cepis_set_error(err, CEPIS_ERR_INVALID_RESPONSE, seen,
+ -1.0, "the API answered %ld", seen);
+ }
+
+ root = json_parse(response.body.data, response.body.len);
+ if (root == NULL || root->type != JSON_OBJECT) {
+ json_free(root);
+ status = cepis_set_error(err, CEPIS_ERR_INVALID_RESPONSE, 200,
+ -1.0,
+ "response is not a JSON object - "
+ "something between you and "
+ "the API is answering instead of it");
+ cepis_buf_free(&response.body);
+ return status;
+ }
+
+ address = cepis_address_from_json(root, response.body.data,
+ response.body.len);
+ json_free(root);
+ cepis_buf_free(&response.body);
+ if (address == NULL)
+ return cepis_set_error(err, CEPIS_ERR_NOMEM, 0, -1.0,
+ "out of memory");
+
+ *out = address;
+ return CEPIS_OK;
+}
+
+cepis_status_t cepis_health(cepis_client_t *client, cepis_health_t **out,
+ cepis_error_t *err)
+{
+ static const long accepted[] = {200, 503};
+ cepis_response_t response;
+ cepis_health_t *health;
+ cepis_buf_t url;
+ json_value_t *root;
+ cepis_status_t status;
+
+ if (client == NULL || out == NULL)
+ return cepis_set_error(err, CEPIS_ERR_CONFIG, 0, -1.0,
+ "client and out must not be NULL");
+
+ status = build_url(client, "/health", &url);
+ if (status != CEPIS_OK)
+ return cepis_set_error(err, status, 0, -1.0, "out of memory");
+
+ status = client_request(client, url.data, accepted, 2, &response, err);
+ cepis_buf_free(&url);
+ if (status != CEPIS_OK)
+ return status;
+
+ root = json_parse(response.body.data, response.body.len);
+ health = cepis_health_from_json(root);
+ json_free(root);
+ cepis_buf_free(&response.body);
+
+ if (health == NULL)
+ return cepis_set_error(err, CEPIS_ERR_INVALID_RESPONSE,
+ response.status, -1.0,
+ "health response is not a JSON object");
+
+ *out = health;
+ return CEPIS_OK;
+}
+
+static bool blank(const char *text)
+{
+ size_t i;
+
+ if (text == NULL)
+ return true;
+ for (i = 0; text[i] != '\0'; i++)
+ if (text[i] != ' ' && text[i] != '\t' && text[i] != '\r' &&
+ text[i] != '\n')
+ return false;
+ return true;
+}
+
+static size_t column_index(const csv_row_t *header, const char *name)
+{
+ size_t i;
+
+ for (i = 0; i < header->count; i++)
+ if (strcmp(header->fields[i], name) == 0)
+ return i;
+ return (size_t)-1;
+}
+
+static cepis_status_t chunk_url(cepis_client_t *client, const char *const *ceps,
+ size_t count, cepis_buf_t *out)
+{
+ cepis_buf_t joined;
+ char *escaped;
+ size_t i;
+
+ cepis_buf_init(&joined);
+ for (i = 0; i < count; i++) {
+ const char *value = ceps[i] != NULL ? ceps[i] : "";
+ if ((i > 0 && !cepis_buf_append_str(&joined, ",")) ||
+ !cepis_buf_append_str(&joined, value)) {
+ cepis_buf_free(&joined);
+ return CEPIS_ERR_NOMEM;
+ }
+ }
+
+ escaped = curl_easy_escape(client->curl, joined.data ? joined.data : "",
+ (int)joined.len);
+ cepis_buf_free(&joined);
+ if (escaped == NULL)
+ return CEPIS_ERR_NOMEM;
+
+ cepis_buf_init(out);
+ if (!cepis_buf_append_str(out, client->base_url) ||
+ !cepis_buf_append_str(out, "/csv?ceps=") ||
+ !cepis_buf_append_str(out, escaped)) {
+ cepis_buf_free(out);
+ curl_free(escaped);
+ return CEPIS_ERR_NOMEM;
+ }
+ curl_free(escaped);
+ return CEPIS_OK;
+}
+
+static cepis_status_t fill_chunk(cepis_client_t *client,
+ const char *const *ceps, size_t count,
+ cepis_address_t **slots, cepis_error_t *err)
+{
+ static const long accepted[] = {200};
+ char message[CEPIS_ERROR_MESSAGE_MAX];
+ cepis_response_t response;
+ csv_reader_t *reader;
+ csv_row_t header;
+ cepis_buf_t url;
+ size_t localidade;
+ size_t index;
+ cepis_status_t status;
+
+ status = chunk_url(client, ceps, count, &url);
+ if (status != CEPIS_OK)
+ return cepis_set_error(err, status, 0, -1.0, "out of memory");
+
+ status = client_request(client, url.data, accepted, 1, &response, err);
+ cepis_buf_free(&url);
+ if (status != CEPIS_OK)
+ return status;
+
+ if (response.status == 400) {
+ api_message(&response.body, message, sizeof(message),
+ "the batch was rejected");
+ cepis_buf_free(&response.body);
+ return cepis_set_error(err, CEPIS_ERR_INVALID_CEP, 400, -1.0,
+ "%s", message);
+ }
+ if (response.status != 200) {
+ long seen = response.status;
+ cepis_buf_free(&response.body);
+ return cepis_set_error(err, CEPIS_ERR_INVALID_RESPONSE, seen,
+ -1.0, "the API answered %ld", seen);
+ }
+
+ reader = csv_reader_new(response.body.data ? response.body.data : "",
+ response.body.len);
+ if (reader == NULL) {
+ cepis_buf_free(&response.body);
+ return cepis_set_error(err, CEPIS_ERR_NOMEM, 0, -1.0,
+ "out of memory");
+ }
+
+ if (csv_reader_next(reader, &header) != 1) {
+ csv_reader_free(reader);
+ cepis_buf_free(&response.body);
+ return cepis_set_error(err, CEPIS_ERR_INVALID_RESPONSE, 200,
+ -1.0,
+ "the batch response has no header");
+ }
+
+ localidade = column_index(&header, "localidade");
+ status = CEPIS_OK;
+
+ for (index = 0; index < count; index++) {
+ csv_row_t record;
+ int read = csv_reader_next(reader, &record);
+
+ if (read != 1)
+ break;
+ if (localidade != (size_t)-1 && localidade < record.count &&
+ !blank(record.fields[localidade])) {
+ slots[index] = cepis_address_from_csv(&header, &record);
+ if (slots[index] == NULL) {
+ csv_row_free(&record);
+ status =
+ cepis_set_error(err, CEPIS_ERR_NOMEM, 0,
+ -1.0, "out of memory");
+ break;
+ }
+ }
+ csv_row_free(&record);
+ }
+
+ csv_row_free(&header);
+ csv_reader_free(reader);
+ cepis_buf_free(&response.body);
+ return status;
+}
+
+cepis_status_t cepis_lookup_many(cepis_client_t *client,
+ const char *const *ceps, size_t count,
+ cepis_address_list_t *out, cepis_error_t *err)
+{
+ cepis_address_list_t list = {NULL, 0};
+ size_t start;
+
+ if (client == NULL || out == NULL)
+ return cepis_set_error(err, CEPIS_ERR_CONFIG, 0, -1.0,
+ "client and out must not be NULL");
+ if (count == 0) {
+ out->items = NULL;
+ out->count = 0;
+ return CEPIS_OK;
+ }
+ if (ceps == NULL)
+ return cepis_set_error(err, CEPIS_ERR_CONFIG, 0, -1.0,
+ "ceps must not be NULL");
+
+ list.items = calloc(count, sizeof(*list.items));
+ if (list.items == NULL)
+ return cepis_set_error(err, CEPIS_ERR_NOMEM, 0, -1.0,
+ "out of memory");
+ list.count = count;
+
+ for (start = 0; start < count; start += CEPIS_MAX_BATCH) {
+ size_t size = count - start;
+ cepis_status_t status;
+
+ if (size > CEPIS_MAX_BATCH)
+ size = CEPIS_MAX_BATCH;
+ status = fill_chunk(client, ceps + start, size,
+ list.items + start, err);
+ if (status != CEPIS_OK) {
+ cepis_address_list_free(&list);
+ return status;
+ }
+ }
+
+ *out = list;
+ return CEPIS_OK;
+}
diff --git a/src/csv.c b/src/csv.c
new file mode 100644
index 0000000..ea9859b
--- /dev/null
+++ b/src/csv.c
@@ -0,0 +1,168 @@
+#include "csv.h"
+
+#include <stdbool.h>
+#include <stdlib.h>
+#include <string.h>
+
+struct csv_reader {
+ const char *p;
+ const char *end;
+};
+
+typedef struct {
+ char *data;
+ size_t len;
+ size_t cap;
+} csv_buf_t;
+
+csv_reader_t *csv_reader_new(const char *data, size_t len)
+{
+ csv_reader_t *reader;
+
+ if (data == NULL)
+ return NULL;
+ reader = calloc(1, sizeof(*reader));
+ if (reader == NULL)
+ return NULL;
+ reader->p = data;
+ reader->end = data + len;
+ if (len >= 3 && memcmp(data, "\xEF\xBB\xBF", 3) == 0)
+ reader->p += 3;
+ return reader;
+}
+
+void csv_reader_free(csv_reader_t *reader)
+{
+ free(reader);
+}
+
+void csv_row_free(csv_row_t *row)
+{
+ size_t i;
+
+ if (row == NULL)
+ return;
+ for (i = 0; i < row->count; i++)
+ free(row->fields[i]);
+ free(row->fields);
+ row->fields = NULL;
+ row->count = 0;
+}
+
+static bool buf_push(csv_buf_t *buf, char c)
+{
+ if (buf->len + 2 > buf->cap) {
+ size_t cap = buf->cap ? buf->cap * 2 : 32;
+ char *grown = realloc(buf->data, cap);
+ if (grown == NULL)
+ return false;
+ buf->data = grown;
+ buf->cap = cap;
+ }
+ buf->data[buf->len++] = c;
+ buf->data[buf->len] = '\0';
+ return true;
+}
+
+static bool row_push(csv_row_t *row, size_t *cap, char *field)
+{
+ if (row->count + 1 > *cap) {
+ size_t next = *cap ? *cap * 2 : 16;
+ char **grown = realloc(row->fields, next * sizeof(*grown));
+ if (grown == NULL)
+ return false;
+ row->fields = grown;
+ *cap = next;
+ }
+ row->fields[row->count++] = field;
+ return true;
+}
+
+static char *parse_field(csv_reader_t *reader)
+{
+ csv_buf_t field = {NULL, 0, 0};
+ bool quoted = false;
+
+ if (!buf_push(&field, '\0'))
+ return NULL;
+ field.len = 0;
+ field.data[0] = '\0';
+
+ if (reader->p < reader->end && *reader->p == '"') {
+ quoted = true;
+ reader->p++;
+ }
+
+ while (reader->p < reader->end) {
+ char c = *reader->p;
+
+ if (quoted) {
+ reader->p++;
+ if (c != '"') {
+ if (!buf_push(&field, c))
+ goto fail;
+ continue;
+ }
+ if (reader->p < reader->end && *reader->p == '"') {
+ reader->p++;
+ if (!buf_push(&field, '"'))
+ goto fail;
+ continue;
+ }
+ quoted = false;
+ continue;
+ }
+ if (c == ',' || c == '\n' || c == '\r')
+ break;
+ reader->p++;
+ if (!buf_push(&field, c))
+ goto fail;
+ }
+
+ if (quoted)
+ goto fail;
+ return field.data;
+
+fail:
+ free(field.data);
+ return NULL;
+}
+
+int csv_reader_next(csv_reader_t *reader, csv_row_t *out)
+{
+ csv_row_t row = {NULL, 0};
+ size_t cap = 0;
+
+ if (reader == NULL || out == NULL)
+ return -1;
+ if (reader->p >= reader->end)
+ return 0;
+
+ for (;;) {
+ char *field = parse_field(reader);
+
+ if (field == NULL) {
+ csv_row_free(&row);
+ return -1;
+ }
+ if (!row_push(&row, &cap, field)) {
+ free(field);
+ csv_row_free(&row);
+ return -1;
+ }
+ if (reader->p >= reader->end)
+ break;
+ if (*reader->p == ',') {
+ reader->p++;
+ continue;
+ }
+ if (*reader->p == '\r')
+ reader->p++;
+ if (reader->p < reader->end && *reader->p == '\n')
+ reader->p++;
+ break;
+ }
+
+ *out = row;
+ return 1;
+}
diff --git a/src/csv.h b/src/csv.h
new file mode 100644
index 0000000..90a9d83
--- /dev/null
+++ b/src/csv.h
@@ -0,0 +1,45 @@
+/**
+ * @file csv.h
+ * @brief Minimal RFC 4180 CSV reader, internal to this library.
+ *
+ * The batch endpoint answers `text/csv`, and its values may contain commas and
+ * are then double-quoted with `""` escaping. Splitting on commas would corrupt
+ * exactly the street names most likely to matter, so this reads the real
+ * grammar: quoted fields, embedded commas, embedded newlines, CRLF or LF line
+ * endings, and a leading UTF-8 BOM.
+ */
+#ifndef CEPIS_CSV_H
+#define CEPIS_CSV_H
+
+#include <stddef.h>
+
+/** One parsed record. Owns its fields. */
+typedef struct csv_row {
+ char **fields;
+ size_t count;
+} csv_row_t;
+
+typedef struct csv_reader csv_reader_t;
+
+/**
+ * @brief Start reading @p len bytes of CSV.
+ * @return A reader borrowing @p data, or `NULL` on allocation failure. @p data
+ * must outlive the reader.
+ */
+csv_reader_t *csv_reader_new(const char *data, size_t len);
+
+/**
+ * @brief Read the next record into @p out.
+ * @return 1 when a record was read, 0 at end of input, -1 on a malformed
+ * record or allocation failure. @p out is only valid after 1, and the
+ * caller releases it with ::csv_row_free.
+ */
+int csv_reader_next(csv_reader_t *reader, csv_row_t *out);
+
+/** @brief Release a record's fields and zero it. `NULL` is ignored. */
+void csv_row_free(csv_row_t *row);
+
+/** @brief Release a reader. `NULL` is ignored. */
+void csv_reader_free(csv_reader_t *reader);
+
+#endif
diff --git a/src/internal.h b/src/internal.h
new file mode 100644
index 0000000..57487c2
--- /dev/null
+++ b/src/internal.h
@@ -0,0 +1,74 @@
+/**
+ * @file internal.h
+ * @brief Helpers shared between the translation units of this library.
+ *
+ * Nothing here is part of the public ABI.
+ */
+#ifndef CEPIS_INTERNAL_H
+#define CEPIS_INTERNAL_H
+
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stddef.h>
+
+#include "cepis.h"
+#include "csv.h"
+#include "json.h"
+
+/** A growable, always NUL-terminated byte buffer. */
+typedef struct cepis_buf {
+ char *data;
+ size_t len;
+ size_t cap;
+} cepis_buf_t;
+
+/** @brief Put @p buf into the empty state. Allocates nothing. */
+void cepis_buf_init(cepis_buf_t *buf);
+
+/** @brief Append @p len bytes. @return `false` on allocation failure. */
+bool cepis_buf_append(cepis_buf_t *buf, const char *data, size_t len);
+
+/** @brief Append a NUL-terminated string. */
+bool cepis_buf_append_str(cepis_buf_t *buf, const char *text);
+
+/** @brief Release the buffer and return it to the empty state. */
+void cepis_buf_free(cepis_buf_t *buf);
+
+/** @brief `strdup`, tolerating a `NULL` argument by copying `""`. */
+char *cepis_strdup(const char *text);
+
+/**
+ * @brief Fill @p err, tolerating `NULL`.
+ * @return @p status, so callers can `return cepis_set_error(...)`.
+ */
+cepis_status_t cepis_set_error(cepis_error_t *err, cepis_status_t status,
+ long http_status, double retry_after,
+ const char *fmt, ...);
+
+/**
+ * @brief Build an address from a parsed JSON object.
+ * @param object The decoded body.
+ * @param raw The body as it arrived, copied into
+ * ::cepis_address_t::raw. May be `NULL`.
+ * @param raw_len Length of @p raw.
+ * @return An owned address, or `NULL` on allocation failure.
+ *
+ * Unknown members are ignored but preserved in `raw`, so a field the API grows
+ * cannot break this client. A malformed `geo` block yields `geo == NULL` rather
+ * than failing the whole address: the rest of the response is still useful.
+ */
+cepis_address_t *cepis_address_from_json(const json_value_t *object,
+ const char *raw, size_t raw_len);
+
+/**
+ * @brief Build an address from one CSV record and the header record.
+ * @return An owned address, or `NULL` on allocation failure. The result never
+ * carries a coordinate: the CSV has no `geo` column.
+ */
+cepis_address_t *cepis_address_from_csv(const csv_row_t *header,
+ const csv_row_t *record);
+
+/** @brief Build a health report from a parsed JSON object. */
+cepis_health_t *cepis_health_from_json(const json_value_t *object);
+
+#endif
diff --git a/src/json.c b/src/json.c
new file mode 100644
index 0000000..ee1382c
--- /dev/null
+++ b/src/json.c
@@ -0,0 +1,550 @@
+#include "json.h"
+
+#include <errno.h>
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct {
+ const char *p;
+ const char *end;
+ int depth;
+} json_parser_t;
+
+static json_value_t *parse_value(json_parser_t *ps);
+
+static json_value_t *value_new(json_type_t type)
+{
+ json_value_t *v = calloc(1, sizeof(*v));
+ if (v != NULL)
+ v->type = type;
+ return v;
+}
+
+void json_free(json_value_t *value)
+{
+ size_t i;
+
+ if (value == NULL)
+ return;
+ switch (value->type) {
+ case JSON_STRING:
+ free(value->as.string);
+ break;
+ case JSON_ARRAY:
+ for (i = 0; i < value->as.array.count; i++)
+ json_free(value->as.array.items[i]);
+ free(value->as.array.items);
+ break;
+ case JSON_OBJECT:
+ for (i = 0; i < value->as.object.count; i++) {
+ free(value->as.object.keys[i]);
+ json_free(value->as.object.values[i]);
+ }
+ free(value->as.object.keys);
+ free(value->as.object.values);
+ break;
+ default:
+ break;
+ }
+ free(value);
+}
+
+static void skip_ws(json_parser_t *ps)
+{
+ while (ps->p < ps->end) {
+ char c = *ps->p;
+ if (c != ' ' && c != '\t' && c != '\n' && c != '\r')
+ return;
+ ps->p++;
+ }
+}
+
+static bool at(const json_parser_t *ps, char c)
+{
+ return ps->p < ps->end && *ps->p == c;
+}
+
+static bool literal(json_parser_t *ps, const char *word)
+{
+ size_t n = strlen(word);
+
+ if ((size_t)(ps->end - ps->p) < n)
+ return false;
+ if (memcmp(ps->p, word, n) != 0)
+ return false;
+ ps->p += n;
+ return true;
+}
+
+static int hex_digit(char c)
+{
+ if (c >= '0' && c <= '9')
+ return c - '0';
+ if (c >= 'a' && c <= 'f')
+ return c - 'a' + 10;
+ if (c >= 'A' && c <= 'F')
+ return c - 'A' + 10;
+ return -1;
+}
+
+static bool read_hex4(json_parser_t *ps, unsigned *out)
+{
+ unsigned value = 0;
+ int i;
+
+ if (ps->end - ps->p < 4)
+ return false;
+ for (i = 0; i < 4; i++) {
+ int digit = hex_digit(ps->p[i]);
+ if (digit < 0)
+ return false;
+ value = (value << 4) | (unsigned)digit;
+ }
+ ps->p += 4;
+ *out = value;
+ return true;
+}
+
+static size_t utf8_encode(unsigned cp, char *out)
+{
+ if (cp < 0x80) {
+ out[0] = (char)cp;
+ return 1;
+ }
+ if (cp < 0x800) {
+ out[0] = (char)(0xC0 | (cp >> 6));
+ out[1] = (char)(0x80 | (cp & 0x3F));
+ return 2;
+ }
+ if (cp < 0x10000) {
+ out[0] = (char)(0xE0 | (cp >> 12));
+ out[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
+ out[2] = (char)(0x80 | (cp & 0x3F));
+ return 3;
+ }
+ out[0] = (char)(0xF0 | (cp >> 18));
+ out[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
+ out[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
+ out[3] = (char)(0x80 | (cp & 0x3F));
+ return 4;
+}
+
+typedef struct {
+ char *data;
+ size_t len;
+ size_t cap;
+} strbuf_t;
+
+static bool strbuf_push(strbuf_t *sb, const char *data, size_t n)
+{
+ if (sb->len + n + 1 > sb->cap) {
+ size_t cap = sb->cap ? sb->cap : 32;
+ char *grown;
+ while (sb->len + n + 1 > cap)
+ cap *= 2;
+ grown = realloc(sb->data, cap);
+ if (grown == NULL)
+ return false;
+ sb->data = grown;
+ sb->cap = cap;
+ }
+ memcpy(sb->data + sb->len, data, n);
+ sb->len += n;
+ sb->data[sb->len] = '\0';
+ return true;
+}
+
+static bool parse_escape(json_parser_t *ps, strbuf_t *sb)
+{
+ char encoded[4];
+ unsigned cp;
+ char c;
+
+ if (ps->p >= ps->end)
+ return false;
+ c = *ps->p++;
+ switch (c) {
+ case '"':
+ return strbuf_push(sb, "\"", 1);
+ case '\\':
+ return strbuf_push(sb, "\\", 1);
+ case '/':
+ return strbuf_push(sb, "/", 1);
+ case 'b':
+ return strbuf_push(sb, "\b", 1);
+ case 'f':
+ return strbuf_push(sb, "\f", 1);
+ case 'n':
+ return strbuf_push(sb, "\n", 1);
+ case 'r':
+ return strbuf_push(sb, "\r", 1);
+ case 't':
+ return strbuf_push(sb, "\t", 1);
+ case 'u':
+ break;
+ default:
+ return false;
+ }
+
+ if (!read_hex4(ps, &cp))
+ return false;
+ if (cp >= 0xD800 && cp <= 0xDBFF) {
+ unsigned low;
+ const char *save = ps->p;
+ if (ps->end - ps->p >= 2 && ps->p[0] == '\\' &&
+ ps->p[1] == 'u') {
+ ps->p += 2;
+ if (!read_hex4(ps, &low))
+ return false;
+ if (low >= 0xDC00 && low <= 0xDFFF) {
+ cp = 0x10000 + ((cp - 0xD800) << 10) +
+ (low - 0xDC00);
+ } else {
+ ps->p = save;
+ cp = 0xFFFD;
+ }
+ } else {
+ cp = 0xFFFD;
+ }
+ } else if (cp >= 0xDC00 && cp <= 0xDFFF) {
+ cp = 0xFFFD;
+ }
+ return strbuf_push(sb, encoded, utf8_encode(cp, encoded));
+}
+
+static char *parse_raw_string(json_parser_t *ps)
+{
+ strbuf_t sb = {NULL, 0, 0};
+
+ if (!at(ps, '"'))
+ return NULL;
+ ps->p++;
+ if (!strbuf_push(&sb, "", 0))
+ return NULL;
+
+ while (ps->p < ps->end) {
+ char c = *ps->p;
+ if (c == '"') {
+ ps->p++;
+ return sb.data;
+ }
+ if (c == '\\') {
+ ps->p++;
+ if (!parse_escape(ps, &sb))
+ break;
+ continue;
+ }
+ if ((unsigned char)c < 0x20)
+ break;
+ ps->p++;
+ if (!strbuf_push(&sb, &c, 1))
+ break;
+ }
+ free(sb.data);
+ return NULL;
+}
+
+static json_value_t *parse_string(json_parser_t *ps)
+{
+ char *text = parse_raw_string(ps);
+ json_value_t *v;
+
+ if (text == NULL)
+ return NULL;
+ v = value_new(JSON_STRING);
+ if (v == NULL) {
+ free(text);
+ return NULL;
+ }
+ v->as.string = text;
+ return v;
+}
+
+static json_value_t *parse_number(json_parser_t *ps)
+{
+ const char *start = ps->p;
+ json_value_t *v;
+ char *stop = NULL;
+ char token[64];
+ size_t n;
+ double number;
+
+ if (at(ps, '-'))
+ ps->p++;
+ if (ps->p >= ps->end)
+ return NULL;
+ if (*ps->p == '0')
+ ps->p++;
+ else if (*ps->p >= '1' && *ps->p <= '9')
+ while (ps->p < ps->end && *ps->p >= '0' && *ps->p <= '9')
+ ps->p++;
+ else
+ return NULL;
+ if (at(ps, '.')) {
+ ps->p++;
+ if (ps->p >= ps->end || *ps->p < '0' || *ps->p > '9')
+ return NULL;
+ while (ps->p < ps->end && *ps->p >= '0' && *ps->p <= '9')
+ ps->p++;
+ }
+ if (at(ps, 'e') || at(ps, 'E')) {
+ ps->p++;
+ if (at(ps, '+') || at(ps, '-'))
+ ps->p++;
+ if (ps->p >= ps->end || *ps->p < '0' || *ps->p > '9')
+ return NULL;
+ while (ps->p < ps->end && *ps->p >= '0' && *ps->p <= '9')
+ ps->p++;
+ }
+
+ n = (size_t)(ps->p - start);
+ if (n == 0 || n >= sizeof(token))
+ return NULL;
+ memcpy(token, start, n);
+ token[n] = '\0';
+
+ errno = 0;
+ number = strtod(token, &stop);
+ if (stop != token + n)
+ return NULL;
+
+ v = value_new(JSON_NUMBER);
+ if (v == NULL)
+ return NULL;
+ v->as.number = number;
+ return v;
+}
+
+static bool grow_slots(void ***slots, size_t count, size_t *cap, size_t size)
+{
+ void **grown;
+ size_t next;
+
+ if (count < *cap)
+ return true;
+ next = *cap ? *cap * 2 : 8;
+ grown = realloc(*slots, next * size);
+ if (grown == NULL)
+ return false;
+ memset((char *)grown + count * size, 0, (next - count) * size);
+ *slots = grown;
+ *cap = next;
+ return true;
+}
+
+static json_value_t *parse_array(json_parser_t *ps)
+{
+ json_value_t *v = value_new(JSON_ARRAY);
+ size_t cap = 0;
+
+ if (v == NULL)
+ return NULL;
+ ps->p++;
+ skip_ws(ps);
+ if (at(ps, ']')) {
+ ps->p++;
+ return v;
+ }
+ for (;;) {
+ json_value_t *item;
+ if (!grow_slots((void ***)&v->as.array.items, v->as.array.count,
+ &cap, sizeof(json_value_t *)))
+ break;
+ item = parse_value(ps);
+ if (item == NULL)
+ break;
+ v->as.array.items[v->as.array.count++] = item;
+ skip_ws(ps);
+ if (at(ps, ',')) {
+ ps->p++;
+ continue;
+ }
+ if (at(ps, ']')) {
+ ps->p++;
+ return v;
+ }
+ break;
+ }
+ json_free(v);
+ return NULL;
+}
+
+static json_value_t *parse_object(json_parser_t *ps)
+{
+ json_value_t *v = value_new(JSON_OBJECT);
+ size_t cap_keys = 0;
+ size_t cap_values = 0;
+
+ if (v == NULL)
+ return NULL;
+ ps->p++;
+ skip_ws(ps);
+ if (at(ps, '}')) {
+ ps->p++;
+ return v;
+ }
+ for (;;) {
+ char *key;
+ json_value_t *item;
+
+ skip_ws(ps);
+ if (!grow_slots((void ***)&v->as.object.keys,
+ v->as.object.count, &cap_keys, sizeof(char *)))
+ break;
+ if (!grow_slots((void ***)&v->as.object.values,
+ v->as.object.count, &cap_values,
+ sizeof(json_value_t *)))
+ break;
+ key = parse_raw_string(ps);
+ if (key == NULL)
+ break;
+ skip_ws(ps);
+ if (!at(ps, ':')) {
+ free(key);
+ break;
+ }
+ ps->p++;
+ item = parse_value(ps);
+ if (item == NULL) {
+ free(key);
+ break;
+ }
+ v->as.object.keys[v->as.object.count] = key;
+ v->as.object.values[v->as.object.count] = item;
+ v->as.object.count++;
+
+ skip_ws(ps);
+ if (at(ps, ',')) {
+ ps->p++;
+ continue;
+ }
+ if (at(ps, '}')) {
+ ps->p++;
+ return v;
+ }
+ break;
+ }
+ json_free(v);
+ return NULL;
+}
+
+static json_value_t *parse_value(json_parser_t *ps)
+{
+ json_value_t *v;
+
+ if (ps->depth >= JSON_MAX_DEPTH)
+ return NULL;
+ skip_ws(ps);
+ if (ps->p >= ps->end)
+ return NULL;
+
+ switch (*ps->p) {
+ case '"':
+ return parse_string(ps);
+ case '{':
+ ps->depth++;
+ v = parse_object(ps);
+ ps->depth--;
+ return v;
+ case '[':
+ ps->depth++;
+ v = parse_array(ps);
+ ps->depth--;
+ return v;
+ case 't':
+ if (!literal(ps, "true"))
+ return NULL;
+ v = value_new(JSON_BOOL);
+ if (v != NULL)
+ v->as.boolean = true;
+ return v;
+ case 'f':
+ if (!literal(ps, "false"))
+ return NULL;
+ v = value_new(JSON_BOOL);
+ if (v != NULL)
+ v->as.boolean = false;
+ return v;
+ case 'n':
+ if (!literal(ps, "null"))
+ return NULL;
+ return value_new(JSON_NULL);
+ default:
+ return parse_number(ps);
+ }
+}
+
+json_value_t *json_parse(const char *text, size_t len)
+{
+ json_parser_t ps;
+ json_value_t *root;
+
+ if (text == NULL)
+ return NULL;
+ ps.p = text;
+ ps.end = text + len;
+ ps.depth = 0;
+
+ if (len >= 3 && memcmp(text, "\xEF\xBB\xBF", 3) == 0)
+ ps.p += 3;
+
+ root = parse_value(&ps);
+ if (root == NULL)
+ return NULL;
+ skip_ws(&ps);
+ if (ps.p != ps.end) {
+ json_free(root);
+ return NULL;
+ }
+ return root;
+}
+
+const json_value_t *json_object_get(const json_value_t *object, const char *key)
+{
+ size_t i;
+
+ if (object == NULL || object->type != JSON_OBJECT || key == NULL)
+ return NULL;
+ for (i = 0; i < object->as.object.count; i++)
+ if (strcmp(object->as.object.keys[i], key) == 0)
+ return object->as.object.values[i];
+ return NULL;
+}
+
+const char *json_as_string(const json_value_t *value)
+{
+ if (value == NULL || value->type != JSON_STRING)
+ return NULL;
+ return value->as.string;
+}
+
+bool json_as_double(const json_value_t *value, double *out)
+{
+ char *stop = NULL;
+ double parsed;
+
+ if (value == NULL)
+ return false;
+ if (value->type == JSON_NUMBER) {
+ *out = value->as.number;
+ return true;
+ }
+ if (value->type != JSON_STRING || value->as.string[0] == '\0')
+ return false;
+ errno = 0;
+ parsed = strtod(value->as.string, &stop);
+ if (stop == NULL || *stop != '\0' || errno == ERANGE)
+ return false;
+ *out = parsed;
+ return true;
+}
+
+bool json_as_llong(const json_value_t *value, long long *out)
+{
+ double parsed;
+
+ if (!json_as_double(value, &parsed))
+ return false;
+ *out = (long long)parsed;
+ return true;
+}
diff --git a/src/json.h b/src/json.h
new file mode 100644
index 0000000..4d41714
--- /dev/null
+++ b/src/json.h
@@ -0,0 +1,82 @@
+/**
+ * @file json.h
+ * @brief Minimal JSON reader, internal to this library.
+ *
+ * Read-only, allocation-per-node, no streaming and no writer. It is here
+ * because C ships no JSON parser and the shapes this client has to read are
+ * small and fixed. It handles the whole grammar it is fed: UTF-8 throughout,
+ * `\uXXXX` escapes with surrogate pairs, and the full number syntax.
+ */
+#ifndef CEPIS_JSON_H
+#define CEPIS_JSON_H
+
+#include <stdbool.h>
+#include <stddef.h>
+
+/** Deepest nesting accepted, so a hostile body cannot exhaust the stack. */
+#define JSON_MAX_DEPTH 64
+
+/** Kind of a ::json_value_t. */
+typedef enum json_type {
+ JSON_NULL,
+ JSON_BOOL,
+ JSON_NUMBER,
+ JSON_STRING,
+ JSON_ARRAY,
+ JSON_OBJECT
+} json_type_t;
+
+typedef struct json_value json_value_t;
+
+/** A parsed JSON value. Owns everything it points at. */
+struct json_value {
+ json_type_t type;
+ union {
+ bool boolean;
+ double number;
+ /** NUL-terminated UTF-8, escapes already decoded. */
+ char *string;
+ struct {
+ json_value_t **items;
+ size_t count;
+ } array;
+ struct {
+ char **keys;
+ json_value_t **values;
+ size_t count;
+ } object;
+ } as;
+};
+
+/**
+ * @brief Parse @p len bytes of JSON.
+ * @return The root value, owned by the caller, or `NULL` when @p text is not
+ * one complete JSON value followed only by whitespace.
+ */
+json_value_t *json_parse(const char *text, size_t len);
+
+/** @brief Release a value and everything below it. `NULL` is ignored. */
+void json_free(json_value_t *value);
+
+/**
+ * @brief Look a key up in an object.
+ * @return The member, borrowed from @p object, or `NULL` when @p object is not
+ * an object or has no such key. The first match wins.
+ */
+const json_value_t *json_object_get(const json_value_t *object,
+ const char *key);
+
+/** @brief The string a value holds, or `NULL` when it is not a string. */
+const char *json_as_string(const json_value_t *value);
+
+/**
+ * @brief Read a value as a number, accepting a numeric string.
+ * @return `false` when the value is absent or cannot be read as a number, in
+ * which case @p out is untouched.
+ */
+bool json_as_double(const json_value_t *value, double *out);
+
+/** @brief Like ::json_as_double, truncating towards zero. */
+bool json_as_llong(const json_value_t *value, long long *out);
+
+#endif
diff --git a/src/model.c b/src/model.c
new file mode 100644
index 0000000..75ed0d4
--- /dev/null
+++ b/src/model.c
@@ -0,0 +1,246 @@
+#include "internal.h"
+
+#include <stddef.h>
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct {
+ const char *name;
+ size_t offset;
+} address_field_t;
+
+static const address_field_t ADDRESS_FIELDS[] = {
+ {"cep", offsetof(cepis_address_t, cep)},
+ {"logradouro", offsetof(cepis_address_t, logradouro)},
+ {"complemento", offsetof(cepis_address_t, complemento)},
+ {"unidade", offsetof(cepis_address_t, unidade)},
+ {"bairro", offsetof(cepis_address_t, bairro)},
+ {"localidade", offsetof(cepis_address_t, localidade)},
+ {"uf", offsetof(cepis_address_t, uf)},
+ {"estado", offsetof(cepis_address_t, estado)},
+ {"regiao", offsetof(cepis_address_t, regiao)},
+ {"ibge", offsetof(cepis_address_t, ibge)},
+ {"gia", offsetof(cepis_address_t, gia)},
+ {"ddd", offsetof(cepis_address_t, ddd)},
+ {"siafi", offsetof(cepis_address_t, siafi)},
+};
+
+#define ADDRESS_FIELD_COUNT (sizeof(ADDRESS_FIELDS) / sizeof(ADDRESS_FIELDS[0]))
+
+static char **field_slot(cepis_address_t *address, size_t index)
+{
+ return (char **)((char *)address + ADDRESS_FIELDS[index].offset);
+}
+
+static cepis_address_t *address_new(void)
+{
+ cepis_address_t *address = calloc(1, sizeof(*address));
+ size_t i;
+
+ if (address == NULL)
+ return NULL;
+ for (i = 0; i < ADDRESS_FIELD_COUNT; i++) {
+ char **slot = field_slot(address, i);
+ *slot = cepis_strdup("");
+ if (*slot == NULL) {
+ cepis_address_free(address);
+ return NULL;
+ }
+ }
+ return address;
+}
+
+static bool set_field(cepis_address_t *address, size_t index, const char *value)
+{
+ char **slot = field_slot(address, index);
+ char *copy = cepis_strdup(value);
+
+ if (copy == NULL)
+ return false;
+ free(*slot);
+ *slot = copy;
+ return true;
+}
+
+void cepis_address_free(cepis_address_t *address)
+{
+ size_t i;
+
+ if (address == NULL)
+ return;
+ for (i = 0; i < ADDRESS_FIELD_COUNT; i++)
+ free(*field_slot(address, i));
+ if (address->geo != NULL) {
+ free(address->geo->precisao);
+ free(address->geo);
+ }
+ free(address->raw);
+ free(address);
+}
+
+void cepis_address_list_free(cepis_address_list_t *list)
+{
+ size_t i;
+
+ if (list == NULL)
+ return;
+ for (i = 0; i < list->count; i++)
+ cepis_address_free(list->items[i]);
+ free(list->items);
+ list->items = NULL;
+ list->count = 0;
+}
+
+void cepis_health_free(cepis_health_t *health)
+{
+ if (health == NULL)
+ return;
+ free(health->status);
+ free(health->dne);
+ free(health->base_gerada_em);
+ free(health);
+}
+
+bool cepis_geo_is_precise(const cepis_geo_t *geo)
+{
+ return geo != NULL && geo->precisao != NULL &&
+ strcmp(geo->precisao, "logradouro") == 0;
+}
+
+bool cepis_health_ok(const cepis_health_t *health)
+{
+ return health != NULL && health->status != NULL &&
+ strcmp(health->status, "ok") == 0;
+}
+
+static cepis_geo_t *geo_from_json(const json_value_t *block)
+{
+ const char *precisao;
+ cepis_geo_t *geo;
+ double lat;
+ double lon;
+ long long raio;
+
+ if (block == NULL || block->type != JSON_OBJECT)
+ return NULL;
+ if (!json_as_double(json_object_get(block, "lat"), &lat))
+ return NULL;
+ if (!json_as_double(json_object_get(block, "lon"), &lon))
+ return NULL;
+ if (!json_as_llong(json_object_get(block, "raio_m"), &raio))
+ return NULL;
+ precisao = json_as_string(json_object_get(block, "precisao"));
+ if (precisao == NULL)
+ return NULL;
+
+ geo = calloc(1, sizeof(*geo));
+ if (geo == NULL)
+ return NULL;
+ geo->lat = lat;
+ geo->lon = lon;
+ geo->raio_m = (long)raio;
+ geo->precisao = cepis_strdup(precisao);
+ if (geo->precisao == NULL) {
+ free(geo);
+ return NULL;
+ }
+ return geo;
+}
+
+cepis_address_t *cepis_address_from_json(const json_value_t *object,
+ const char *raw, size_t raw_len)
+{
+ cepis_address_t *address;
+ size_t i;
+
+ if (object == NULL || object->type != JSON_OBJECT)
+ return NULL;
+ address = address_new();
+ if (address == NULL)
+ return NULL;
+
+ for (i = 0; i < ADDRESS_FIELD_COUNT; i++) {
+ const char *value = json_as_string(
+ json_object_get(object, ADDRESS_FIELDS[i].name));
+ if (value == NULL)
+ continue;
+ if (!set_field(address, i, value)) {
+ cepis_address_free(address);
+ return NULL;
+ }
+ }
+
+ address->geo = geo_from_json(json_object_get(object, "geo"));
+
+ if (raw != NULL) {
+ address->raw = malloc(raw_len + 1);
+ if (address->raw == NULL) {
+ cepis_address_free(address);
+ return NULL;
+ }
+ memcpy(address->raw, raw, raw_len);
+ address->raw[raw_len] = '\0';
+ }
+ return address;
+}
+
+cepis_address_t *cepis_address_from_csv(const csv_row_t *header,
+ const csv_row_t *record)
+{
+ cepis_address_t *address;
+ size_t column;
+
+ if (header == NULL || record == NULL)
+ return NULL;
+ address = address_new();
+ if (address == NULL)
+ return NULL;
+
+ for (column = 0; column < header->count; column++) {
+ size_t i;
+ if (column >= record->count)
+ break;
+ for (i = 0; i < ADDRESS_FIELD_COUNT; i++) {
+ if (strcmp(header->fields[column],
+ ADDRESS_FIELDS[i].name) != 0)
+ continue;
+ if (!set_field(address, i, record->fields[column])) {
+ cepis_address_free(address);
+ return NULL;
+ }
+ break;
+ }
+ }
+ return address;
+}
+
+cepis_health_t *cepis_health_from_json(const json_value_t *object)
+{
+ cepis_health_t *health;
+ const char *text;
+
+ if (object == NULL || object->type != JSON_OBJECT)
+ return NULL;
+ health = calloc(1, sizeof(*health));
+ if (health == NULL)
+ return NULL;
+
+ text = json_as_string(json_object_get(object, "status"));
+ health->status = cepis_strdup(text);
+ text = json_as_string(json_object_get(object, "dne"));
+ health->dne = cepis_strdup(text);
+ text = json_as_string(json_object_get(object, "base_gerada_em"));
+ health->base_gerada_em = cepis_strdup(text);
+
+ if (health->status == NULL || health->dne == NULL ||
+ health->base_gerada_em == NULL) {
+ cepis_health_free(health);
+ return NULL;
+ }
+
+ if (!json_as_llong(json_object_get(object, "ceps"), &health->ceps))
+ health->ceps = 0;
+ if (!json_as_llong(json_object_get(object, "geo"), &health->geo))
+ health->geo = 0;
+ return health;
+}
diff --git a/src/util.c b/src/util.c
new file mode 100644
index 0000000..a2c6c18
--- /dev/null
+++ b/src/util.c
@@ -0,0 +1,78 @@
+#include "internal.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+void cepis_buf_init(cepis_buf_t *buf)
+{
+ buf->data = NULL;
+ buf->len = 0;
+ buf->cap = 0;
+}
+
+bool cepis_buf_append(cepis_buf_t *buf, const char *data, size_t len)
+{
+ if (buf->len + len + 1 > buf->cap) {
+ size_t cap = buf->cap ? buf->cap : 256;
+ char *grown;
+ while (buf->len + len + 1 > cap) {
+ if (cap > (size_t)-1 / 2)
+ return false;
+ cap *= 2;
+ }
+ grown = realloc(buf->data, cap);
+ if (grown == NULL)
+ return false;
+ buf->data = grown;
+ buf->cap = cap;
+ }
+ if (len > 0)
+ memcpy(buf->data + buf->len, data, len);
+ buf->len += len;
+ buf->data[buf->len] = '\0';
+ return true;
+}
+
+bool cepis_buf_append_str(cepis_buf_t *buf, const char *text)
+{
+ return cepis_buf_append(buf, text, strlen(text));
+}
+
+void cepis_buf_free(cepis_buf_t *buf)
+{
+ free(buf->data);
+ cepis_buf_init(buf);
+}
+
+char *cepis_strdup(const char *text)
+{
+ size_t len;
+ char *copy;
+
+ if (text == NULL)
+ text = "";
+ len = strlen(text);
+ copy = malloc(len + 1);
+ if (copy == NULL)
+ return NULL;
+ memcpy(copy, text, len + 1);
+ return copy;
+}
+
+cepis_status_t cepis_set_error(cepis_error_t *err, cepis_status_t status,
+ long http_status, double retry_after,
+ const char *fmt, ...)
+{
+ va_list args;
+
+ if (err == NULL)
+ return status;
+ err->status = status;
+ err->http_status = http_status;
+ err->retry_after = retry_after;
+ va_start(args, fmt);
+ vsnprintf(err->message, sizeof(err->message), fmt, args);
+ va_end(args);
+ return status;
+}
diff --git a/tests/test_cepis.c b/tests/test_cepis.c
new file mode 100644
index 0000000..ee23305
--- /dev/null
+++ b/tests/test_cepis.c
@@ -0,0 +1,935 @@
+#include "cepis.h"
+#include "test_server.h"
+
+#include <math.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+static int tests_run;
+static int tests_failed;
+static const char *current_test;
+static bool current_failed;
+
+#define CHECK(expr) \
+ do { \
+ if (!(expr)) { \
+ printf(" FAIL %s:%d: %s\n", __FILE__, __LINE__, \
+ #expr); \
+ current_failed = true; \
+ } \
+ } while (0)
+
+#define CHECK_STR(actual, expected) \
+ do { \
+ const char *a_ = (actual); \
+ const char *e_ = (expected); \
+ if (a_ == NULL || strcmp(a_, e_) != 0) { \
+ printf(" FAIL %s:%d: expected \"%s\", got \"%s\"\n", \
+ __FILE__, __LINE__, e_, \
+ a_ != NULL ? a_ : "(null)"); \
+ current_failed = true; \
+ } \
+ } while (0)
+
+#define RUN(fn) \
+ do { \
+ current_test = #fn; \
+ current_failed = false; \
+ tests_run++; \
+ printf("%s\n", current_test); \
+ fn(); \
+ if (current_failed) \
+ tests_failed++; \
+ } while (0)
+
+static const char SE_ADDRESS[] =
+ "{\"cep\":\"01001000\",\"logradouro\":\"Pra\xC3\xA7\x61 da S\xC3\xA9\","
+ "\"complemento\":\"lado \xC3\xADmpar\",\"unidade\":\"\",\"bairro\":"
+ "\"S\xC3\xA9\",\"localidade\":\"S\xC3\xA3o Paulo\",\"uf\":\"SP\","
+ "\"estado\":\"S\xC3\xA3o Paulo\",\"regiao\":\"Sudeste\",\"ibge\":"
+ "\"3550308\",\"gia\":\"1004\",\"ddd\":\"11\",\"siafi\":\"7107\"}";
+
+static const char CSV_HEADER[] =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,"
+ "ibge,gia,ddd,siafi\n";
+
+typedef struct {
+ test_server_t *server;
+ cepis_client_t *client;
+} fixture_t;
+
+static void fixture_up(fixture_t *fixture)
+{
+ cepis_config_t config;
+ cepis_error_t err;
+
+ fixture->server = ts_start();
+ if (fixture->server == NULL) {
+ printf(" FAIL could not start the test server\n");
+ current_failed = true;
+ fixture->client = NULL;
+ return;
+ }
+ cepis_config_init(&config);
+ config.base_url = ts_base_url(fixture->server);
+ config.timeout = 5.0;
+ config.backoff_base = 0.001;
+ config.backoff_max = 0.01;
+ if (cepis_client_new(&config, &fixture->client, &err) != CEPIS_OK) {
+ printf(" FAIL could not build a client: %s\n", err.message);
+ current_failed = true;
+ fixture->client = NULL;
+ }
+}
+
+static void fixture_down(fixture_t *fixture)
+{
+ cepis_client_free(fixture->client);
+ ts_stop(fixture->server);
+}
+
+static double elapsed_since(double start)
+{
+ struct timespec ts;
+
+ clock_gettime(CLOCK_MONOTONIC, &ts);
+ return ((double)ts.tv_sec + (double)ts.tv_nsec / 1e9) - start;
+}
+
+static double now_seconds(void)
+{
+ return elapsed_since(0.0);
+}
+
+static void test_normalize_accepts_masked_and_unmasked(void)
+{
+ static const char *inputs[] = {"01001000", "01001-000", " 01001-000 ",
+ "01001.000"};
+ char out[CEPIS_CEP_LEN + 1];
+ size_t i;
+
+ for (i = 0; i < sizeof(inputs) / sizeof(inputs[0]); i++) {
+ CHECK(cepis_normalize_cep(inputs[i], out) == CEPIS_OK);
+ CHECK_STR(out, "01001000");
+ }
+}
+
+static void test_normalize_rejects_bad_format(void)
+{
+ static const char *inputs[] = {"", "123", "0100100a",
+ "010010000", "abcdefgh", "-1001000"};
+ char out[CEPIS_CEP_LEN + 1];
+ size_t i;
+
+ for (i = 0; i < sizeof(inputs) / sizeof(inputs[0]); i++)
+ CHECK(cepis_normalize_cep(inputs[i], out) ==
+ CEPIS_ERR_INVALID_CEP);
+ CHECK(cepis_normalize_cep(NULL, out) == CEPIS_ERR_INVALID_CEP);
+}
+
+static void test_format_cep(void)
+{
+ char out[CEPIS_CEP_LEN + 2];
+
+ CHECK(cepis_format_cep("01001000", out) == CEPIS_OK);
+ CHECK_STR(out, "01001-000");
+ CHECK(cepis_format_cep("123", out) == CEPIS_ERR_INVALID_CEP);
+}
+
+static void test_successful_lookup(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+ char masked[CEPIS_CEP_LEN + 2];
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/01001000", 200, SE_ADDRESS, NULL);
+
+ CHECK(cepis_lookup(fixture.client, "01001-000", &address, &err) ==
+ CEPIS_OK);
+ if (address != NULL) {
+ CHECK_STR(address->localidade, "S\xC3\xA3o Paulo");
+ CHECK_STR(address->logradouro, "Pra\xC3\xA7\x61 da S\xC3\xA9");
+ CHECK_STR(address->uf, "SP");
+ CHECK_STR(address->cep, "01001000");
+ CHECK(address->geo == NULL);
+ CHECK(address->raw != NULL);
+ CHECK(cepis_format_cep(address->cep, masked) == CEPIS_OK);
+ CHECK_STR(masked, "01001-000");
+ }
+ cepis_address_free(address);
+ fixture_down(&fixture);
+}
+
+static void test_escaped_and_utf8_strings_decode(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/01001000", 200,
+ "{\"cep\":\"01001000\",\"logradouro\":"
+ "\"Pra\\u00e7a da S\\u00e9\",\"localidade\":"
+ "\"S\\u00e3o Paulo\",\"bairro\":\"a\\\"b\\\\c\\td\","
+ "\"uf\":\"SP\"}",
+ NULL);
+
+ CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) ==
+ CEPIS_OK);
+ if (address != NULL) {
+ CHECK_STR(address->logradouro, "Pra\xC3\xA7\x61 da S\xC3\xA9");
+ CHECK_STR(address->localidade, "S\xC3\xA3o Paulo");
+ CHECK_STR(address->bairro, "a\"b\\c\td");
+ CHECK_STR(address->complemento, "");
+ }
+ cepis_address_free(address);
+ fixture_down(&fixture);
+}
+
+static void test_reads_the_coordinate_when_present(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/01310100", 200,
+ "{\"cep\":\"01310100\",\"localidade\":\"S\xC3\xA3o Paulo\","
+ "\"geo\":{\"lat\":-23.567202,\"lon\":-46.649659,"
+ "\"raio_m\":120,\"precisao\":\"logradouro\"}}",
+ NULL);
+
+ CHECK(cepis_lookup(fixture.client, "01310100", &address, &err) ==
+ CEPIS_OK);
+ if (address != NULL && address->geo != NULL) {
+ CHECK(fabs(address->geo->lat + 23.567202) < 1e-9);
+ CHECK(fabs(address->geo->lon + 46.649659) < 1e-9);
+ CHECK(address->geo->raio_m == 120);
+ CHECK_STR(address->geo->precisao, "logradouro");
+ CHECK(cepis_geo_is_precise(address->geo));
+ } else {
+ CHECK(address != NULL && address->geo != NULL);
+ }
+ cepis_address_free(address);
+ fixture_down(&fixture);
+}
+
+static void test_municipality_coordinate_is_not_precise(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/01001000", 200,
+ "{\"cep\":\"01001000\",\"localidade\":\"Manaus\","
+ "\"geo\":{\"lat\":-3.1,\"lon\":-60.0,\"raio_m\":22000,"
+ "\"precisao\":\"localidade\"}}",
+ NULL);
+
+ CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) ==
+ CEPIS_OK);
+ CHECK(address != NULL && address->geo != NULL);
+ if (address != NULL) {
+ CHECK(!cepis_geo_is_precise(address->geo));
+ if (address->geo != NULL)
+ CHECK(address->geo->raio_m == 22000);
+ }
+ cepis_address_free(address);
+ fixture_down(&fixture);
+}
+
+static void test_malformed_geo_does_not_lose_the_address(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/01001000", 200,
+ "{\"cep\":\"01001000\",\"localidade\":\"S\xC3\xA3o Paulo\","
+ "\"geo\":{\"lat\":\"not a number\"}}",
+ NULL);
+
+ CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) ==
+ CEPIS_OK);
+ if (address != NULL) {
+ CHECK(address->geo == NULL);
+ CHECK_STR(address->localidade, "S\xC3\xA3o Paulo");
+ }
+ cepis_address_free(address);
+ fixture_down(&fixture);
+}
+
+static void test_new_api_field_does_not_break(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/01001000", 200,
+ "{\"cep\":\"01001000\",\"localidade\":\"Santos\","
+ "\"campo_do_futuro\":\"value\"}",
+ NULL);
+
+ CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) ==
+ CEPIS_OK);
+ if (address != NULL) {
+ CHECK_STR(address->localidade, "Santos");
+ CHECK(address->raw != NULL &&
+ strstr(address->raw, "campo_do_futuro") != NULL);
+ }
+ cepis_address_free(address);
+ fixture_down(&fixture);
+}
+
+static void test_not_found_is_not_retried(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/99999999", 404,
+ "{\"erro\":\"cep_nao_encontrado\",\"mensagem\":"
+ "\"CEP n\xC3\xA3o existe na base do DNE\"}",
+ NULL);
+
+ CHECK(cepis_lookup(fixture.client, "99999999", &address, &err) ==
+ CEPIS_ERR_NOT_FOUND);
+ CHECK(address == NULL);
+ CHECK(err.http_status == 404);
+ CHECK(strstr(err.message, "99999999") != NULL);
+ CHECK(ts_count(fixture.server, "/99999999") == 1);
+ fixture_down(&fixture);
+}
+
+static void test_invalid_cep_never_leaves_the_machine(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ CHECK(cepis_lookup(fixture.client, "123", &address, &err) ==
+ CEPIS_ERR_INVALID_CEP);
+ CHECK(address == NULL);
+ CHECK(ts_total(fixture.server) == 0);
+ fixture_down(&fixture);
+}
+
+static void test_api_400_becomes_invalid_cep(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/00000000", 400,
+ "{\"erro\":\"cep_invalido\",\"mensagem\":"
+ "\"rejected by the API\"}",
+ NULL);
+
+ CHECK(cepis_lookup(fixture.client, "00000000", &address, &err) ==
+ CEPIS_ERR_INVALID_CEP);
+ CHECK(strstr(err.message, "rejected by the API") != NULL);
+ CHECK(ts_count(fixture.server, "/00000000") == 1);
+ fixture_down(&fixture);
+}
+
+static void test_response_that_is_not_json(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/01001000", 200,
+ "<html>captive portal</html>", NULL);
+
+ CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) ==
+ CEPIS_ERR_INVALID_RESPONSE);
+ CHECK(address == NULL);
+ fixture_down(&fixture);
+}
+
+static void test_retries_on_500_then_succeeds(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+ ts_reply_t replies[2];
+
+ fixture_up(&fixture);
+ replies[0].status = 500;
+ replies[0].body = "{\"erro\":\"interno\"}";
+ replies[0].headers = NULL;
+ replies[1].status = 200;
+ replies[1].body = SE_ADDRESS;
+ replies[1].headers = NULL;
+ ts_respond_seq(fixture.server, "/01001000", replies, 2);
+
+ CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) ==
+ CEPIS_OK);
+ if (address != NULL)
+ CHECK_STR(address->localidade, "S\xC3\xA3o Paulo");
+ CHECK(ts_count(fixture.server, "/01001000") == 2);
+ cepis_address_free(address);
+ fixture_down(&fixture);
+}
+
+static void test_exhausts_attempts_then_fails(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/01001000", 503, "{\"erro\":\"interno\"}",
+ NULL);
+
+ CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) ==
+ CEPIS_ERR_SERVER);
+ CHECK(err.http_status == 503);
+ CHECK(ts_count(fixture.server, "/01001000") == 3);
+ fixture_down(&fixture);
+}
+
+static void test_max_attempts_one_disables_retry(void)
+{
+ fixture_t fixture;
+ cepis_client_t *client = NULL;
+ cepis_address_t *address = NULL;
+ cepis_config_t config;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ cepis_config_init(&config);
+ config.base_url = ts_base_url(fixture.server);
+ config.timeout = 5.0;
+ config.max_attempts = 1;
+ CHECK(cepis_client_new(&config, &client, &err) == CEPIS_OK);
+ ts_respond(fixture.server, "/01001000", 500, "{}", NULL);
+
+ CHECK(cepis_lookup(client, "01001000", &address, &err) ==
+ CEPIS_ERR_SERVER);
+ CHECK(ts_count(fixture.server, "/01001000") == 1);
+ cepis_client_free(client);
+ fixture_down(&fixture);
+}
+
+static void test_429_becomes_rate_limited_with_retry_after(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/01001000", 429, "{}", "Retry-After: 7");
+
+ CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) ==
+ CEPIS_ERR_RATE_LIMITED);
+ CHECK(fabs(err.retry_after - 7.0) < 1e-9);
+ CHECK(err.http_status == 429);
+ fixture_down(&fixture);
+}
+
+static void test_retry_after_http_date_is_understood(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+ char header[128];
+ time_t target = time(NULL) + 120;
+ struct tm parts;
+
+ fixture_up(&fixture);
+ gmtime_r(&target, &parts);
+ strftime(header, sizeof(header),
+ "Retry-After: %a, %d %b %Y %H:%M:%S GMT", &parts);
+ ts_respond(fixture.server, "/01001000", 429, "{}", header);
+
+ CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) ==
+ CEPIS_ERR_RATE_LIMITED);
+ CHECK(err.retry_after > 100.0 && err.retry_after < 130.0);
+ fixture_down(&fixture);
+}
+
+static void test_retry_after_honours_the_cap(void)
+{
+ fixture_t fixture;
+ cepis_address_t *address = NULL;
+ cepis_error_t err;
+ double started;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/01001000", 429, "{}", "Retry-After: 3600");
+
+ started = now_seconds();
+ CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) ==
+ CEPIS_ERR_RATE_LIMITED);
+ CHECK(elapsed_since(started) < 5.0);
+ fixture_down(&fixture);
+}
+
+static void test_network_error_when_nothing_listens(void)
+{
+ cepis_client_t *client = NULL;
+ cepis_address_t *address = NULL;
+ cepis_config_t config;
+ cepis_error_t err;
+
+ cepis_config_init(&config);
+ config.base_url = "http://127.0.0.1:1";
+ config.timeout = 1.0;
+ config.max_attempts = 2;
+ config.backoff_base = 0.001;
+ config.backoff_max = 0.01;
+ CHECK(cepis_client_new(&config, &client, &err) == CEPIS_OK);
+
+ CHECK(cepis_lookup(client, "01001000", &address, &err) ==
+ CEPIS_ERR_NETWORK);
+ CHECK(address == NULL);
+ cepis_client_free(client);
+}
+
+static void test_total_timeout_bounds_the_whole_call(void)
+{
+ fixture_t fixture;
+ cepis_client_t *client = NULL;
+ cepis_address_t *address = NULL;
+ cepis_config_t config;
+ cepis_error_t err;
+ double started;
+
+ fixture_up(&fixture);
+ cepis_config_init(&config);
+ config.base_url = ts_base_url(fixture.server);
+ config.timeout = 5.0;
+ config.max_attempts = 50;
+ config.backoff_base = 0.05;
+ config.backoff_max = 0.05;
+ config.total_timeout = 0.3;
+ CHECK(cepis_client_new(&config, &client, &err) == CEPIS_OK);
+ ts_respond(fixture.server, "/01001000", 500, "{}", NULL);
+
+ started = now_seconds();
+ CHECK(cepis_lookup(client, "01001000", &address, &err) ==
+ CEPIS_ERR_SERVER);
+ CHECK(elapsed_since(started) < 3.0);
+ CHECK(ts_count(fixture.server, "/01001000") < 50);
+ cepis_client_free(client);
+ fixture_down(&fixture);
+}
+
+static char *csv_body(const char *const *rows, size_t count)
+{
+ size_t header_len = sizeof(CSV_HEADER) - 1;
+ size_t total = header_len;
+ size_t used;
+ char *body;
+ size_t i;
+
+ for (i = 0; i < count; i++)
+ total += strlen(rows[i]) + 1;
+ body = malloc(total + 1);
+ if (body == NULL)
+ return NULL;
+ memcpy(body, CSV_HEADER, header_len);
+ used = header_len;
+ for (i = 0; i < count; i++) {
+ size_t len = strlen(rows[i]);
+ memcpy(body + used, rows[i], len);
+ used += len;
+ body[used++] = '\n';
+ }
+ body[used] = '\0';
+ return body;
+}
+
+static void test_batch_preserves_order_and_marks_missing(void)
+{
+ static const char *rows[] = {
+ "01001000,Pra\xC3\xA7\x61 da S\xC3\xA9,lado \xC3\xADmpar,,"
+ "S\xC3\xA9,S\xC3\xA3o Paulo,SP,S\xC3\xA3o "
+ "Paulo,Sudeste,3550308,"
+ "1004,11,7107",
+ "99999999,,,,,,,,,,,,",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,"
+ "Rio de Janeiro,Sudeste,3304557,,21,6001"};
+ static const char *ceps[] = {"01001000", "99999999", "20040020"};
+ fixture_t fixture;
+ cepis_address_list_t list = {NULL, 0};
+ cepis_error_t err;
+ char *body;
+ ts_reply_t reply;
+
+ fixture_up(&fixture);
+ body = csv_body(rows, 3);
+ reply.status = 200;
+ reply.body = body;
+ reply.headers = NULL;
+ ts_respond_default(fixture.server, &reply, 1);
+
+ CHECK(cepis_lookup_many(fixture.client, ceps, 3, &list, &err) ==
+ CEPIS_OK);
+ CHECK(list.count == 3);
+ if (list.count == 3) {
+ CHECK(list.items[0] != NULL);
+ if (list.items[0] != NULL) {
+ CHECK_STR(list.items[0]->localidade,
+ "S\xC3\xA3o Paulo");
+ CHECK(list.items[0]->geo == NULL);
+ CHECK(list.items[0]->raw == NULL);
+ }
+ CHECK(list.items[1] == NULL);
+ CHECK(list.items[2] != NULL);
+ if (list.items[2] != NULL)
+ CHECK_STR(list.items[2]->uf, "RJ");
+ }
+ CHECK(strstr(ts_path(fixture.server, 0), "/csv?ceps=") ==
+ ts_path(fixture.server, 0));
+ cepis_address_list_free(&list);
+ free(body);
+ fixture_down(&fixture);
+}
+
+static void test_batch_field_with_comma_is_parsed(void)
+{
+ static const char *rows[] = {
+ "01001000,\"Rua Um, Dois\",,,Centro,S\xC3\xA3o Paulo,SP,"
+ "S\xC3\xA3o Paulo,Sudeste,1,,11,1"};
+ static const char *ceps[] = {"01001000"};
+ fixture_t fixture;
+ cepis_address_list_t list = {NULL, 0};
+ cepis_error_t err;
+ char *body;
+ ts_reply_t reply;
+
+ fixture_up(&fixture);
+ body = csv_body(rows, 1);
+ reply.status = 200;
+ reply.body = body;
+ reply.headers = NULL;
+ ts_respond_default(fixture.server, &reply, 1);
+
+ CHECK(cepis_lookup_many(fixture.client, ceps, 1, &list, &err) ==
+ CEPIS_OK);
+ CHECK(list.count == 1 && list.items[0] != NULL);
+ if (list.count == 1 && list.items[0] != NULL)
+ CHECK_STR(list.items[0]->logradouro, "Rua Um, Dois");
+ cepis_address_list_free(&list);
+ free(body);
+ fixture_down(&fixture);
+}
+
+static void test_batch_empty_list_makes_no_request(void)
+{
+ fixture_t fixture;
+ cepis_address_list_t list = {NULL, 0};
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ CHECK(cepis_lookup_many(fixture.client, NULL, 0, &list, &err) ==
+ CEPIS_OK);
+ CHECK(list.count == 0 && list.items == NULL);
+ CHECK(ts_total(fixture.server) == 0);
+ cepis_address_list_free(&list);
+ fixture_down(&fixture);
+}
+
+static void test_batch_chunks_above_the_api_limit(void)
+{
+ fixture_t fixture;
+ cepis_address_list_t list = {NULL, 0};
+ cepis_error_t err;
+ char **ceps = malloc(501 * sizeof(*ceps));
+ char **rows = malloc(501 * sizeof(*rows));
+ ts_reply_t replies[2];
+ char *first;
+ char *second;
+ size_t i;
+
+ fixture_up(&fixture);
+ for (i = 0; i < 501; i++) {
+ ceps[i] = malloc(16);
+ rows[i] = malloc(64);
+ snprintf(ceps[i], 16, "%08zu", i);
+ snprintf(rows[i], 64,
+ "%s,,,,,X,SP,S\xC3\xA3o Paulo,Sudeste,1,,11,1",
+ ceps[i]);
+ }
+ first = csv_body((const char *const *)rows, 500);
+ second = csv_body((const char *const *)rows + 500, 1);
+
+ replies[0].status = 200;
+ replies[0].body = first;
+ replies[0].headers = NULL;
+ replies[1].status = 200;
+ replies[1].body = second;
+ replies[1].headers = NULL;
+ ts_respond_default(fixture.server, replies, 2);
+
+ CHECK(cepis_lookup_many(fixture.client, (const char *const *)ceps, 501,
+ &list, &err) == CEPIS_OK);
+ CHECK(ts_total(fixture.server) == 2);
+ CHECK(list.count == 501);
+ if (list.count == 501) {
+ CHECK(list.items[0] != NULL);
+ CHECK(list.items[499] != NULL);
+ CHECK(list.items[500] != NULL);
+ }
+
+ cepis_address_list_free(&list);
+ for (i = 0; i < 501; i++) {
+ free(ceps[i]);
+ free(rows[i]);
+ }
+ free(ceps);
+ free(rows);
+ free(first);
+ free(second);
+ fixture_down(&fixture);
+}
+
+static void test_batch_truncated_response_does_not_shift(void)
+{
+ static const char *rows[] = {
+ "01001000,,,,,S\xC3\xA3o Paulo,SP,S\xC3\xA3o "
+ "Paulo,Sudeste,1,,11,1"};
+ static const char *ceps[] = {"01001000", "20040020"};
+ fixture_t fixture;
+ cepis_address_list_t list = {NULL, 0};
+ cepis_error_t err;
+ char *body;
+ ts_reply_t reply;
+
+ fixture_up(&fixture);
+ body = csv_body(rows, 1);
+ reply.status = 200;
+ reply.body = body;
+ reply.headers = NULL;
+ ts_respond_default(fixture.server, &reply, 1);
+
+ CHECK(cepis_lookup_many(fixture.client, ceps, 2, &list, &err) ==
+ CEPIS_OK);
+ CHECK(list.count == 2);
+ if (list.count == 2) {
+ CHECK(list.items[0] != NULL);
+ CHECK(list.items[1] == NULL);
+ }
+ cepis_address_list_free(&list);
+ free(body);
+ fixture_down(&fixture);
+}
+
+static void test_batch_malformed_cep_becomes_null(void)
+{
+ static const char *rows[] = {
+ "01001000,,,,,S\xC3\xA3o Paulo,SP,S\xC3\xA3o "
+ "Paulo,Sudeste,1,,11,1",
+ "123,,,,,,,,,,,,"};
+ static const char *ceps[] = {"01001000", "123"};
+ fixture_t fixture;
+ cepis_address_list_t list = {NULL, 0};
+ cepis_error_t err;
+ char *body;
+ ts_reply_t reply;
+
+ fixture_up(&fixture);
+ body = csv_body(rows, 2);
+ reply.status = 200;
+ reply.body = body;
+ reply.headers = NULL;
+ ts_respond_default(fixture.server, &reply, 1);
+
+ CHECK(cepis_lookup_many(fixture.client, ceps, 2, &list, &err) ==
+ CEPIS_OK);
+ CHECK(list.count == 2);
+ if (list.count == 2)
+ CHECK(list.items[1] == NULL);
+ cepis_address_list_free(&list);
+ free(body);
+ fixture_down(&fixture);
+}
+
+static void test_batch_retries_on_503(void)
+{
+ static const char *rows[] = {
+ "01001000,,,,,S\xC3\xA3o Paulo,SP,S\xC3\xA3o "
+ "Paulo,Sudeste,1,,11,1"};
+ static const char *ceps[] = {"01001000"};
+ fixture_t fixture;
+ cepis_address_list_t list = {NULL, 0};
+ cepis_error_t err;
+ ts_reply_t replies[2];
+ char *body;
+
+ fixture_up(&fixture);
+ body = csv_body(rows, 1);
+ replies[0].status = 503;
+ replies[0].body = "{\"erro\":\"interno\"}";
+ replies[0].headers = NULL;
+ replies[1].status = 200;
+ replies[1].body = body;
+ replies[1].headers = NULL;
+ ts_respond_default(fixture.server, replies, 2);
+
+ CHECK(cepis_lookup_many(fixture.client, ceps, 1, &list, &err) ==
+ CEPIS_OK);
+ CHECK(ts_total(fixture.server) == 2);
+ CHECK(list.count == 1 && list.items[0] != NULL);
+ cepis_address_list_free(&list);
+ free(body);
+ fixture_down(&fixture);
+}
+
+static void test_health(void)
+{
+ fixture_t fixture;
+ cepis_health_t *health = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/health", 200,
+ "{\"status\":\"ok\",\"dne\":\"26071\",\"ceps\":1596168,"
+ "\"geo\":894489,\"base_gerada_em\":"
+ "\"2026-07-24T03:48:28Z\"}",
+ NULL);
+
+ CHECK(cepis_health(fixture.client, &health, &err) == CEPIS_OK);
+ if (health != NULL) {
+ CHECK(cepis_health_ok(health));
+ CHECK_STR(health->dne, "26071");
+ CHECK(health->ceps == 1596168);
+ CHECK(health->geo == 894489);
+ CHECK_STR(health->base_gerada_em, "2026-07-24T03:48:28Z");
+ }
+ cepis_health_free(health);
+ fixture_down(&fixture);
+}
+
+static void test_health_degraded_does_not_fail(void)
+{
+ fixture_t fixture;
+ cepis_health_t *health = NULL;
+ cepis_error_t err;
+
+ fixture_up(&fixture);
+ ts_respond(fixture.server, "/health", 503,
+ "{\"status\":\"degradado\",\"dne\":\"\",\"ceps\":0,"
+ "\"geo\":0}",
+ NULL);
+
+ CHECK(cepis_health(fixture.client, &health, &err) == CEPIS_OK);
+ CHECK(health != NULL && !cepis_health_ok(health));
+ CHECK(ts_count(fixture.server, "/health") == 1);
+ cepis_health_free(health);
+ fixture_down(&fixture);
+}
+
+static void test_trailing_slash_is_not_duplicated(void)
+{
+ cepis_client_t *client = NULL;
+ cepis_config_t config;
+ cepis_error_t err;
+
+ cepis_config_init(&config);
+ config.base_url = "http://example.invalid///";
+ CHECK(cepis_client_new(&config, &client, &err) == CEPIS_OK);
+ CHECK_STR(cepis_client_base_url(client), "http://example.invalid");
+ cepis_client_free(client);
+}
+
+static void test_invalid_configuration_is_rejected(void)
+{
+ cepis_client_t *client = NULL;
+ cepis_config_t config;
+ cepis_error_t err;
+
+ cepis_config_init(&config);
+ config.max_attempts = 0;
+ CHECK(cepis_client_new(&config, &client, &err) == CEPIS_ERR_CONFIG);
+
+ cepis_config_init(&config);
+ config.timeout = 0.0;
+ CHECK(cepis_client_new(&config, &client, &err) == CEPIS_ERR_CONFIG);
+
+ cepis_config_init(&config);
+ config.base_url = "";
+ CHECK(cepis_client_new(&config, &client, &err) == CEPIS_ERR_CONFIG);
+
+ CHECK(client == NULL);
+}
+
+static void test_user_agent_identifies_the_library(void)
+{
+ cepis_client_t *client = NULL;
+ cepis_error_t err;
+
+ CHECK(cepis_client_new(NULL, &client, &err) == CEPIS_OK);
+ CHECK(strstr(cepis_client_user_agent(client), "cepis-c/") ==
+ cepis_client_user_agent(client));
+ CHECK(strstr(cepis_client_user_agent(client), CEPIS_VERSION) != NULL);
+ CHECK_STR(cepis_client_base_url(client), CEPIS_DEFAULT_BASE_URL);
+ cepis_client_free(client);
+}
+
+static void test_status_strings_exist(void)
+{
+ CHECK_STR(cepis_status_str(CEPIS_OK), "ok");
+ CHECK_STR(cepis_status_str(CEPIS_ERR_NOT_FOUND), "CEP not found");
+ CHECK_STR(cepis_status_str(CEPIS_ERR_RATE_LIMITED), "rate limited");
+ CHECK(cepis_status_str(CEPIS_ERR_NOMEM) != NULL);
+}
+
+int main(void)
+{
+ cepis_global_init();
+
+ RUN(test_normalize_accepts_masked_and_unmasked);
+ RUN(test_normalize_rejects_bad_format);
+ RUN(test_format_cep);
+ RUN(test_successful_lookup);
+ RUN(test_escaped_and_utf8_strings_decode);
+ RUN(test_reads_the_coordinate_when_present);
+ RUN(test_municipality_coordinate_is_not_precise);
+ RUN(test_malformed_geo_does_not_lose_the_address);
+ RUN(test_new_api_field_does_not_break);
+ RUN(test_not_found_is_not_retried);
+ RUN(test_invalid_cep_never_leaves_the_machine);
+ RUN(test_api_400_becomes_invalid_cep);
+ RUN(test_response_that_is_not_json);
+ RUN(test_retries_on_500_then_succeeds);
+ RUN(test_exhausts_attempts_then_fails);
+ RUN(test_max_attempts_one_disables_retry);
+ RUN(test_429_becomes_rate_limited_with_retry_after);
+ RUN(test_retry_after_http_date_is_understood);
+ RUN(test_retry_after_honours_the_cap);
+ RUN(test_network_error_when_nothing_listens);
+ RUN(test_total_timeout_bounds_the_whole_call);
+ RUN(test_batch_preserves_order_and_marks_missing);
+ RUN(test_batch_field_with_comma_is_parsed);
+ RUN(test_batch_empty_list_makes_no_request);
+ RUN(test_batch_chunks_above_the_api_limit);
+ RUN(test_batch_truncated_response_does_not_shift);
+ RUN(test_batch_malformed_cep_becomes_null);
+ RUN(test_batch_retries_on_503);
+ RUN(test_health);
+ RUN(test_health_degraded_does_not_fail);
+ RUN(test_trailing_slash_is_not_duplicated);
+ RUN(test_invalid_configuration_is_rejected);
+ RUN(test_user_agent_identifies_the_library);
+ RUN(test_status_strings_exist);
+
+ cepis_global_cleanup();
+
+ printf("\n%d test(s), %d failed\n", tests_run, tests_failed);
+ return tests_failed == 0 ? 0 : 1;
+}
diff --git a/tests/test_server.c b/tests/test_server.c
new file mode 100644
index 0000000..83cfb5a
--- /dev/null
+++ b/tests/test_server.c
@@ -0,0 +1,417 @@
+#include "test_server.h"
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <pthread.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#define TS_MAX_ROUTES 16
+#define TS_MAX_REPLIES 8
+#define TS_MAX_CALLS 2048
+#define TS_REQUEST_MAX 65536
+
+typedef struct {
+ long status;
+ char *body;
+ char *headers;
+} ts_stored_t;
+
+typedef struct {
+ char *path;
+ ts_stored_t replies[TS_MAX_REPLIES];
+ size_t count;
+ size_t served;
+} ts_route_t;
+
+struct test_server {
+ int listen_fd;
+ int port;
+ char base_url[64];
+ pthread_t thread;
+ pthread_mutex_t lock;
+ bool stopping;
+ ts_route_t routes[TS_MAX_ROUTES];
+ size_t route_count;
+ ts_route_t fallback;
+ char *calls[TS_MAX_CALLS];
+ size_t call_count;
+};
+
+static char *dup_or_empty(const char *text)
+{
+ size_t len = (text != NULL) ? strlen(text) : 0;
+ char *copy = malloc(len + 1);
+
+ if (copy == NULL)
+ return NULL;
+ if (len > 0)
+ memcpy(copy, text, len);
+ copy[len] = '\0';
+ return copy;
+}
+
+static void route_clear(ts_route_t *route)
+{
+ size_t i;
+
+ for (i = 0; i < route->count; i++) {
+ free(route->replies[i].body);
+ free(route->replies[i].headers);
+ }
+ route->count = 0;
+ route->served = 0;
+}
+
+static void route_fill(ts_route_t *route, const ts_reply_t *replies,
+ size_t count)
+{
+ size_t i;
+
+ route_clear(route);
+ if (count > TS_MAX_REPLIES)
+ count = TS_MAX_REPLIES;
+ for (i = 0; i < count; i++) {
+ route->replies[i].status = replies[i].status;
+ route->replies[i].body = dup_or_empty(replies[i].body);
+ route->replies[i].headers = dup_or_empty(replies[i].headers);
+ }
+ route->count = count;
+}
+
+static ts_route_t *route_for(test_server_t *server, const char *path,
+ bool create)
+{
+ size_t i;
+
+ for (i = 0; i < server->route_count; i++)
+ if (strcmp(server->routes[i].path, path) == 0)
+ return &server->routes[i];
+ if (!create || server->route_count >= TS_MAX_ROUTES)
+ return NULL;
+ server->routes[server->route_count].path = dup_or_empty(path);
+ return &server->routes[server->route_count++];
+}
+
+static const char *reason_phrase(long 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";
+ }
+}
+
+static bool read_request_path(int fd, char *out, size_t out_size)
+{
+ char buffer[TS_REQUEST_MAX];
+ size_t used = 0;
+ const char *start;
+ const char *space;
+ size_t len;
+
+ for (;;) {
+ ssize_t got;
+ struct pollfd pfd;
+
+ pfd.fd = fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ if (poll(&pfd, 1, 5000) <= 0)
+ return false;
+ got = read(fd, buffer + used, sizeof(buffer) - used - 1);
+ if (got <= 0)
+ return false;
+ used += (size_t)got;
+ buffer[used] = '\0';
+ if (strstr(buffer, "\r\n\r\n") != NULL)
+ break;
+ if (used + 1 >= sizeof(buffer))
+ return false;
+ }
+
+ start = strchr(buffer, ' ');
+ if (start == NULL)
+ return false;
+ start++;
+ space = strchr(start, ' ');
+ if (space == NULL)
+ return false;
+ len = (size_t)(space - start);
+ if (len + 1 > out_size)
+ return false;
+ memcpy(out, start, len);
+ out[len] = '\0';
+ return true;
+}
+
+static void write_all(int fd, const char *data, size_t len)
+{
+ size_t sent = 0;
+
+ while (sent < len) {
+ ssize_t wrote = write(fd, data + sent, len - sent);
+ if (wrote <= 0) {
+ if (wrote < 0 && errno == EINTR)
+ continue;
+ return;
+ }
+ sent += (size_t)wrote;
+ }
+}
+
+static void serve(test_server_t *server, int fd)
+{
+ char path[TS_REQUEST_MAX];
+ char head[1024];
+ ts_stored_t reply;
+ ts_route_t *route;
+ size_t index;
+ int head_len;
+
+ if (!read_request_path(fd, path, sizeof(path)))
+ return;
+
+ pthread_mutex_lock(&server->lock);
+ if (server->call_count < TS_MAX_CALLS)
+ server->calls[server->call_count++] = dup_or_empty(path);
+
+ route = route_for(server, path, false);
+ if (route == NULL || route->count == 0)
+ route = &server->fallback;
+
+ if (route->count == 0) {
+ head_len = snprintf(head, sizeof(head),
+ "HTTP/1.1 404 Not Found\r\n"
+ "Content-Length: 0\r\n"
+ "Connection: close\r\n\r\n");
+ pthread_mutex_unlock(&server->lock);
+ write_all(fd, head, (size_t)head_len);
+ return;
+ }
+
+ index = route->served;
+ if (index >= route->count)
+ index = route->count - 1;
+ route->served++;
+
+ reply.status = route->replies[index].status;
+ reply.body = dup_or_empty(route->replies[index].body);
+ reply.headers = dup_or_empty(route->replies[index].headers);
+ pthread_mutex_unlock(&server->lock);
+
+ head_len = snprintf(head, sizeof(head),
+ "HTTP/1.1 %ld %s\r\n"
+ "Content-Type: application/octet-stream\r\n"
+ "Content-Length: %zu\r\n"
+ "Connection: close\r\n"
+ "%s%s"
+ "\r\n",
+ reply.status, reason_phrase(reply.status),
+ reply.body != NULL ? strlen(reply.body) : 0,
+ (reply.headers != NULL && reply.headers[0] != '\0')
+ ? reply.headers
+ : "",
+ (reply.headers != NULL && reply.headers[0] != '\0')
+ ? "\r\n"
+ : "");
+ if (head_len > 0)
+ write_all(fd, head, (size_t)head_len);
+ if (reply.body != NULL)
+ write_all(fd, reply.body, strlen(reply.body));
+ free(reply.body);
+ free(reply.headers);
+}
+
+static void *accept_loop(void *argument)
+{
+ test_server_t *server = argument;
+
+ for (;;) {
+ struct pollfd pfd;
+ int fd;
+
+ pthread_mutex_lock(&server->lock);
+ if (server->stopping) {
+ pthread_mutex_unlock(&server->lock);
+ return NULL;
+ }
+ pthread_mutex_unlock(&server->lock);
+
+ pfd.fd = server->listen_fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ if (poll(&pfd, 1, 20) <= 0)
+ continue;
+
+ fd = accept(server->listen_fd, NULL, NULL);
+ if (fd < 0)
+ continue;
+ serve(server, fd);
+ shutdown(fd, SHUT_WR);
+ close(fd);
+ }
+}
+
+test_server_t *ts_start(void)
+{
+ struct sockaddr_in address;
+ socklen_t length = sizeof(address);
+ test_server_t *server = calloc(1, sizeof(*server));
+ int reuse = 1;
+
+ if (server == NULL)
+ return NULL;
+ pthread_mutex_init(&server->lock, NULL);
+
+ server->listen_fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (server->listen_fd < 0) {
+ free(server);
+ return NULL;
+ }
+ setsockopt(server->listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse,
+ sizeof(reuse));
+
+ memset(&address, 0, sizeof(address));
+ address.sin_family = AF_INET;
+ address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ address.sin_port = 0;
+
+ if (bind(server->listen_fd, (struct sockaddr *)&address,
+ sizeof(address)) != 0 ||
+ listen(server->listen_fd, 32) != 0 ||
+ getsockname(server->listen_fd, (struct sockaddr *)&address,
+ &length) != 0) {
+ close(server->listen_fd);
+ free(server);
+ return NULL;
+ }
+
+ server->port = ntohs(address.sin_port);
+ snprintf(server->base_url, sizeof(server->base_url),
+ "http://127.0.0.1:%d", server->port);
+
+ if (pthread_create(&server->thread, NULL, accept_loop, server) != 0) {
+ close(server->listen_fd);
+ free(server);
+ return NULL;
+ }
+ return server;
+}
+
+void ts_stop(test_server_t *server)
+{
+ size_t i;
+
+ if (server == NULL)
+ return;
+ pthread_mutex_lock(&server->lock);
+ server->stopping = true;
+ pthread_mutex_unlock(&server->lock);
+ pthread_join(server->thread, NULL);
+ close(server->listen_fd);
+
+ for (i = 0; i < server->route_count; i++) {
+ route_clear(&server->routes[i]);
+ free(server->routes[i].path);
+ }
+ route_clear(&server->fallback);
+ for (i = 0; i < server->call_count; i++)
+ free(server->calls[i]);
+ pthread_mutex_destroy(&server->lock);
+ free(server);
+}
+
+const char *ts_base_url(const test_server_t *server)
+{
+ return server->base_url;
+}
+
+void ts_respond(test_server_t *server, const char *path, long status,
+ const char *body, const char *headers)
+{
+ ts_reply_t reply;
+
+ reply.status = status;
+ reply.body = body;
+ reply.headers = headers;
+ ts_respond_seq(server, path, &reply, 1);
+}
+
+void ts_respond_seq(test_server_t *server, const char *path,
+ const ts_reply_t *replies, size_t count)
+{
+ ts_route_t *route;
+
+ pthread_mutex_lock(&server->lock);
+ route = route_for(server, path, true);
+ if (route != NULL)
+ route_fill(route, replies, count);
+ pthread_mutex_unlock(&server->lock);
+}
+
+void ts_respond_default(test_server_t *server, const ts_reply_t *replies,
+ size_t count)
+{
+ pthread_mutex_lock(&server->lock);
+ route_fill(&server->fallback, replies, count);
+ pthread_mutex_unlock(&server->lock);
+}
+
+size_t ts_count(test_server_t *server, const char *prefix)
+{
+ size_t total = 0;
+ size_t len = strlen(prefix);
+ size_t i;
+
+ pthread_mutex_lock(&server->lock);
+ for (i = 0; i < server->call_count; i++)
+ if (server->calls[i] != NULL &&
+ strncmp(server->calls[i], prefix, len) == 0)
+ total++;
+ pthread_mutex_unlock(&server->lock);
+ return total;
+}
+
+size_t ts_total(test_server_t *server)
+{
+ size_t total;
+
+ pthread_mutex_lock(&server->lock);
+ total = server->call_count;
+ pthread_mutex_unlock(&server->lock);
+ return total;
+}
+
+const char *ts_path(test_server_t *server, size_t index)
+{
+ const char *path = NULL;
+
+ pthread_mutex_lock(&server->lock);
+ if (index < server->call_count)
+ path = server->calls[index];
+ pthread_mutex_unlock(&server->lock);
+ return path;
+}
diff --git a/tests/test_server.h b/tests/test_server.h
new file mode 100644
index 0000000..cbcd4e0
--- /dev/null
+++ b/tests/test_server.h
@@ -0,0 +1,69 @@
+/**
+ * @file test_server.h
+ * @brief A throwaway HTTP/1.1 server on a random loopback port.
+ *
+ * The suite points the client at one of these through `base_url`. Nothing is
+ * mocked: the code path under test is the one that runs in production, libcurl
+ * and response-body reading included. It doubles as the test that `base_url`
+ * works, since without it none of the suite would run.
+ */
+#ifndef CEPIS_TEST_SERVER_H
+#define CEPIS_TEST_SERVER_H
+
+#include <stddef.h>
+
+typedef struct test_server test_server_t;
+
+/** One canned reply. */
+typedef struct {
+ long status;
+ /** Body, copied. `NULL` means empty. */
+ const char *body;
+ /** Extra header lines without the trailing CRLF, or `NULL`. */
+ const char *headers;
+} ts_reply_t;
+
+/** @brief Start a server. @return `NULL` if it could not bind or spawn. */
+test_server_t *ts_start(void);
+
+/** @brief Stop and release a server. `NULL` is ignored. */
+void ts_stop(test_server_t *server);
+
+/** @brief `http://127.0.0.1:PORT`, owned by the server. */
+const char *ts_base_url(const test_server_t *server);
+
+/** @brief Register one reply for @p path, replacing any previous route. */
+void ts_respond(test_server_t *server, const char *path, long status,
+ const char *body, const char *headers);
+
+/**
+ * @brief Register a sequence of replies for @p path.
+ *
+ * Each request consumes the next entry; the last one repeats once the sequence
+ * runs out. This is how retrying is tested.
+ */
+void ts_respond_seq(test_server_t *server, const char *path,
+ const ts_reply_t *replies, size_t count);
+
+/**
+ * @brief Register the sequence used for any path with no explicit route.
+ *
+ * Used by the chunking test, where the request paths are thousands of
+ * characters long and matching them exactly buys nothing.
+ */
+void ts_respond_default(test_server_t *server, const ts_reply_t *replies,
+ size_t count);
+
+/** @brief How many requests so far had a path starting with @p prefix. */
+size_t ts_count(test_server_t *server, const char *prefix);
+
+/** @brief How many requests the server has served in total. */
+size_t ts_total(test_server_t *server);
+
+/**
+ * @brief The path of request @p index, or `NULL` when there is no such
+ * request. Owned by the server.
+ */
+const char *ts_path(test_server_t *server, size_t index);
+
+#endif