diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:25:48 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:25:48 -0300 |
| commit | e2c6ae13af335b311da2429c903032124f926df2 (patch) | |
| tree | 73776fab7f3e52124a217e2768968fc9a8a76c34 | |
| download | c-0.1.0.tar.gz | |
Initial importv0.1.0
| -rw-r--r-- | .clang-format | 25 | ||||
| -rw-r--r-- | .clang-tidy | 29 | ||||
| -rw-r--r-- | .gitignore | 13 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 130 | ||||
| -rw-r--r-- | README.md | 273 | ||||
| -rw-r--r-- | include/cepis.h | 482 | ||||
| -rw-r--r-- | src/cli.c | 191 | ||||
| -rw-r--r-- | src/client.c | 822 | ||||
| -rw-r--r-- | src/csv.c | 168 | ||||
| -rw-r--r-- | src/csv.h | 45 | ||||
| -rw-r--r-- | src/internal.h | 74 | ||||
| -rw-r--r-- | src/json.c | 550 | ||||
| -rw-r--r-- | src/json.h | 82 | ||||
| -rw-r--r-- | src/model.c | 246 | ||||
| -rw-r--r-- | src/util.c | 78 | ||||
| -rw-r--r-- | tests/test_cepis.c | 935 | ||||
| -rw-r--r-- | tests/test_server.c | 417 | ||||
| -rw-r--r-- | tests/test_server.h | 69 |
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/ @@ -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 |