diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-27 20:37:05 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-27 20:44:20 -0300 |
| commit | eb15ec9b2715783fcd2bf4f834595d1bd549c09d (patch) | |
| tree | cd9c6e80892b409db346fde3fe8334f3c37a1130 | |
| parent | e2c6ae13af335b311da2429c903032124f926df2 (diff) | |
| download | c-eb15ec9b2715783fcd2bf4f834595d1bd549c09d.tar.gz | |
A CEP that fails is rarely a random number. It is usually someone who
knows the city and guessed the last digits, and the classic guess is the
-000 that 957 municipalities never received. The API now answers those
404s with a faixa block naming the municipality the CEP would have
belonged to, and telling a caller "that CEP does not exist, did you mean
somewhere in Areal/RJ?" is worth far more than telling them "no".
It rides on cepis_error_t rather than on cepis_address_t because it is
context for a failure, not an address: the lookup still returns
CEPIS_ERR_NOT_FOUND and still hands back no address. Embedded by value
with a has_faixa flag, so the error keeps owning nothing and keeps
living on the stack with nothing to free; cepis_error_faixa gives the
absent case the same NULL-or-not shape as cepis_address_t::geo, which is
what the API's omit-never-null encoding calls for.
Parsing is total on purpose. A body that is not JSON, a faixa that is
null, a string, an array, or an object with no localidade all mean "the
API named no municipality". A 404 is already the answer, and failing to
read the garnish must never turn it into a different one.
The bounds need reading with care, hence the length of the comments:
cep_ini and cep_fim are the smallest and largest CEP that actually
exist, not the range the Correios publish -- the DNE's own start for
Areal is 25845000, exactly the CEP that just failed -- and the range is
sparse, 118 CEPs scattered over the ~4900 numbers up to 25849899. That
is what ceps is for.
| -rw-r--r-- | Makefile | 4 | ||||
| -rw-r--r-- | README.md | 62 | ||||
| -rw-r--r-- | include/cepis.h | 105 | ||||
| -rw-r--r-- | src/cli.c | 8 | ||||
| -rw-r--r-- | src/client.c | 29 | ||||
| -rw-r--r-- | src/internal.h | 15 | ||||
| -rw-r--r-- | src/model.c | 54 | ||||
| -rw-r--r-- | src/util.c | 6 | ||||
| -rw-r--r-- | tests/test_cepis.c | 124 |
9 files changed, 399 insertions, 8 deletions
@@ -1,5 +1,5 @@ -VERSION := 0.1.0 -SOVERSION := 0 +VERSION := 0.2.0 +SOVERSION := 1 CC ?= cc AR ?= ar @@ -41,6 +41,13 @@ make build sudo make install # PREFIX=/usr/local by default ``` +> **0.2.0 changed `sizeof(cepis_error_t)`** — it grew a `faixa` member. The +> source API is unchanged and existing code compiles untouched, but a program +> built against 0.1.0 must be **recompiled**, not just relinked: it declares +> the struct on its own stack at the old size, and the new library would write +> past the end of it. The soname went from `libcepis.so.0` to `libcepis.so.1` +> so the loader refuses that pairing instead of corrupting it silently. + Then compile against it: ```sh @@ -191,7 +198,7 @@ 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 */ +config.user_agent = NULL; /* NULL uses cepis-c/0.2.0 */ cepis_client_new(&config, &client, &err); ``` @@ -251,6 +258,59 @@ default: 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. +### A CEP that does not exist + +A `CEPIS_ERR_NOT_FOUND` often knows more than "no". A CEP that fails is rarely +a random number — it is usually someone who knows the city and guessed the +last digits, and the classic guess is the `-000` that 957 municipalities never +received. So the error can name the municipality the CEP *would* have belonged +to: + +```c +const cepis_faixa_t *faixa; + +/* Still CEPIS_ERR_NOT_FOUND, still a 404. */ +cepis_lookup(client, "25845-000", &address, &err); + +faixa = cepis_error_faixa(&err); +if (faixa != NULL) { + faixa->localidade; /* "Areal" */ + faixa->uf; /* "RJ" */ + faixa->estado; /* "Rio de Janeiro" */ + faixa->regiao; /* "Sudeste" */ + faixa->ibge; /* "3300225" */ + faixa->cep_ini; /* "25845001" */ + faixa->cep_fim; /* "25849899" */ + faixa->ceps; /* 118 */ +} +``` + +Three things this is not: + +- **Not an address.** The CEP asked for still does not exist and the call + still failed. Never promote `faixa` to a delivery address, and never let it + turn a not-found into a success. Use it to say "that CEP does not exist — + did you mean somewhere in Areal/RJ?". +- **Not the range the Correios publish.** `cep_ini` and `cep_fim` are the + smallest and largest CEP that *actually exist* there. The DNE's own start + for Areal is `25845000` — exactly the CEP that just failed. `cep_ini` is + `25845001`, the first one really in the database. +- **Not a list.** The range is sparse: Areal's 118 CEPs are scattered across + the ~4900 numbers between `25845001` and `25849899`. Iterating the interval + mostly produces CEPs that do not exist either. That is what `ceps` is for — + say "118 CEPs in Areal", never "4899". + +`faixa` is **omitted, never null**, when the CEP falls in a band no +municipality owns, so `cepis_error_faixa` returning `NULL` — equivalently +`err.has_faixa == false` — is how you tell absent from present. A missing, +malformed or empty block leaves it absent and never changes the status: a 404 +is already the answer, and failing to read the garnish must not turn it into a +different one. + +`cepis_error_t` still owns nothing and still lives on the stack: `faixa` is +embedded by value, so there is nothing extra to free. The pointer +`cepis_error_faixa` returns is borrowed from the error and dies with it. + ## Development ```sh diff --git a/include/cepis.h b/include/cepis.h index 6b2dc9a..d639d62 100644 --- a/include/cepis.h +++ b/include/cepis.h @@ -39,6 +39,10 @@ * borrowed for the duration of the call only; the library copies whatever it * needs to keep. * + * A ::cepis_error_t is the exception in the other direction: it owns nothing, + * lives happily on the stack and is never freed. ::cepis_error_faixa therefore + * returns a pointer *into* it, borrowed for as long as the error itself lives. + * * @section fieldnames Field names * * The API surface is English, but the **data fields keep the API's own @@ -63,7 +67,7 @@ extern "C" { #endif /** Library version. */ -#define CEPIS_VERSION "0.1.0" +#define CEPIS_VERSION "0.2.0" /** Default API endpoint. */ #define CEPIS_DEFAULT_BASE_URL "https://cep.is" @@ -77,6 +81,12 @@ extern "C" { /** Size of ::cepis_error_t::message, terminator included. */ #define CEPIS_ERROR_MESSAGE_MAX 256 +/** Size of the longer ::cepis_faixa_t members, terminator included. */ +#define CEPIS_FAIXA_NAME_MAX 80 + +/** Size of the short coded ::cepis_faixa_t members, terminator included. */ +#define CEPIS_FAIXA_CODE_MAX 16 + /** * @brief Result of every fallible call. * @@ -108,11 +118,58 @@ typedef enum cepis_status { } cepis_status_t; /** + * @brief The municipality a CEP that does not exist would have belonged to. + * + * Context for a ::CEPIS_ERR_NOT_FOUND, **not an address**. The CEP asked for + * still does not exist and the lookup still failed with a 404; this only names + * the band it fell inside. Never promote it to a delivery address, and never + * let it turn a not-found into a success. + * + * It exists because a CEP that fails is rarely a random number: it is usually + * someone who knows the city and guessed the last digits, and the classic guess + * is the `-000` that 957 municipalities never received. + * + * @section faixabounds What the bounds are, and are not + * + * ::cep_ini and ::cep_fim are the smallest and the largest CEP that **actually + * exist** in that municipality -- not the range the Correios publish for it. + * For Areal/RJ the DNE's own start is `25845000`, which is exactly the CEP that + * just failed; ::cep_ini is `25845001`, the first one really there. + * + * The range is **sparse**. Areal's ::ceps is 118, spread over the ~4900 numbers + * between `25845001` and `25849899`. Iterating the interval, or offering the + * pair as a list of valid CEPs, produces mostly CEPs that do not exist either. + * ::ceps is there precisely so that cannot pass unnoticed: use it to say "118 + * CEPs in Areal", never "4899 CEPs". + */ +typedef struct cepis_faixa { + /** Municipality name. Never `NULL`, never empty when present. */ + char localidade[CEPIS_FAIXA_NAME_MAX]; + /** Two-letter state code, e.g. `"RJ"`. */ + char uf[CEPIS_FAIXA_CODE_MAX]; + char estado[CEPIS_FAIXA_NAME_MAX]; + char regiao[CEPIS_FAIXA_CODE_MAX]; + /** IBGE municipality code. */ + char ibge[CEPIS_FAIXA_CODE_MAX]; + /** Smallest CEP that exists there, unmasked. */ + char cep_ini[CEPIS_CEP_LEN + 1]; + /** Largest CEP that exists there, unmasked. */ + char cep_fim[CEPIS_CEP_LEN + 1]; + /** + * How many CEPs exist between ::cep_ini and ::cep_fim. Far smaller + * than the numeric distance between them. `0` when the API did not + * say. + */ + long long ceps; +} cepis_faixa_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. + * Owns no memory: the message and ::faixa live in fixed buffers 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. */ @@ -128,6 +185,19 @@ typedef struct cepis_error { * attempts it made; it is exposed for rescheduling the work. */ double retry_after; + /** + * `true` when ::faixa was filled. Only ::CEPIS_ERR_NOT_FOUND from + * ::cepis_lookup ever sets it, and only when the CEP fell inside a + * band some municipality owns -- the API **omits** the block, rather + * than sending null, for a band nobody owns. Always test this before + * reading ::faixa, or use ::cepis_error_faixa. + */ + bool has_faixa; + /** + * The municipality the missing CEP would have belonged to. Meaningful + * only while ::has_faixa is `true`; untouched otherwise. + */ + cepis_faixa_t faixa; } cepis_error_t; /** @@ -345,6 +415,11 @@ const char *cepis_client_user_agent(const cepis_client_t *client); * own message: the format already passed local validation, so the server knows * something this client does not. * + * On ::CEPIS_ERR_NOT_FOUND the API often names the municipality the CEP would + * have belonged to; it arrives in ::cepis_error_t::faixa, guarded by + * ::cepis_error_t::has_faixa. It is context for the failure and never an + * address -- see ::cepis_faixa_t. + * * This is the only call that carries ::cepis_address_t::geo. */ cepis_status_t cepis_lookup(cepis_client_t *client, const char *cep, @@ -458,6 +533,28 @@ 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 The municipality named by a not-found error, or `NULL`. + * + * @return A pointer borrowed from @p err, valid as long as @p err is, or + * `NULL` when the API named no municipality -- for `NULL`, for any + * status other than ::CEPIS_ERR_NOT_FOUND, and for a CEP that fell in + * a band nobody owns. + * + * Reads better than testing ::cepis_error_t::has_faixa by hand and gives the + * absent case the same shape as ::cepis_address_t::geo: + * + * @code + * const cepis_faixa_t *faixa = cepis_error_faixa(&err); + * + * if (faixa != NULL) + * printf("no such CEP; %s/%s has %lld, from %s to %s\n", + * faixa->localidade, faixa->uf, faixa->ceps, faixa->cep_ini, + * faixa->cep_fim); + * @endcode + */ +const cepis_faixa_t *cepis_error_faixa(const cepis_error_t *err); + /** @brief A short, stable, English name for a status code. */ const char *cepis_status_str(cepis_status_t status); @@ -157,8 +157,16 @@ int main(int argc, char **argv) if (count == 1) { cepis_address_t *address = NULL; if (cepis_lookup(client, ceps[0], &address, &err) != CEPIS_OK) { + const cepis_faixa_t *faixa = cepis_error_faixa(&err); fprintf(stderr, "error: %s: %s\n", cepis_status_str(err.status), err.message); + if (faixa != NULL) + fprintf(stderr, + "note: it would be in %s/%s, where " + "%lld CEP(s) exist between %s and %s\n", + faixa->localidade, faixa->uf, + faixa->ceps, faixa->cep_ini, + faixa->cep_fim); status = 1; } else { print_address(address); diff --git a/src/client.c b/src/client.c index 58c55b3..27812f2 100644 --- a/src/client.c +++ b/src/client.c @@ -513,6 +513,20 @@ static void api_message(const cepis_buf_t *body, char *out, size_t out_size, json_free(root); } +/* + * A 404 body may carry a `faixa` block naming the municipality the CEP would + * have belonged to. Nothing here can fail: an unparseable body just means the + * API named no municipality, which is also what an absent block means. + */ +static bool faixa_from_body(const cepis_buf_t *body, cepis_faixa_t *out) +{ + json_value_t *root = json_parse(body->data, body->len); + bool named = cepis_faixa_from_json(root, out); + + json_free(root); + return named; +} + static cepis_status_t build_url(const cepis_client_t *client, const char *path, cepis_buf_t *out) { @@ -558,10 +572,23 @@ cepis_status_t cepis_lookup(cepis_client_t *client, const char *cep, return status; if (response.status == 404) { + cepis_faixa_t faixa; + bool named = faixa_from_body(&response.body, &faixa); + cepis_buf_free(&response.body); - return cepis_set_error( + status = cepis_set_error( err, CEPIS_ERR_NOT_FOUND, 404, -1.0, "CEP %s does not exist in the DNE database", number); + /* + * After cepis_set_error, which clears has_faixa: the block is + * extra detail about the failure, never a reason to report a + * different one. + */ + if (named && err != NULL) { + err->faixa = faixa; + err->has_faixa = true; + } + return status; } if (response.status == 400) { snprintf(fallback, sizeof(fallback), "CEP %s rejected", number); diff --git a/src/internal.h b/src/internal.h index 57487c2..700b1ee 100644 --- a/src/internal.h +++ b/src/internal.h @@ -71,4 +71,19 @@ cepis_address_t *cepis_address_from_csv(const csv_row_t *header, /** @brief Build a health report from a parsed JSON object. */ cepis_health_t *cepis_health_from_json(const json_value_t *object); +/** + * @brief Read the `faixa` block of a 404 body into @p out. + * @param object The decoded body, which may be `NULL`. + * @param out Filled and returning `true`, or untouched and returning + * `false`. + * @return `true` only when the block is present, is an object and names a + * municipality. + * + * Deliberately total: a body that is not JSON, a `faixa` that is null, a + * string, an array or an object without `localidade` all mean "the API named + * no municipality". A 404 is already the answer, and failing to read the + * garnish must never turn it into a different one. + */ +bool cepis_faixa_from_json(const json_value_t *object, cepis_faixa_t *out); + #endif diff --git a/src/model.c b/src/model.c index 75ed0d4..2358b11 100644 --- a/src/model.c +++ b/src/model.c @@ -1,6 +1,7 @@ #include "internal.h" #include <stddef.h> +#include <stdio.h> #include <stdlib.h> #include <string.h> @@ -113,6 +114,59 @@ bool cepis_health_ok(const cepis_health_t *health) strcmp(health->status, "ok") == 0; } +const cepis_faixa_t *cepis_error_faixa(const cepis_error_t *err) +{ + if (err == NULL || !err->has_faixa) + return NULL; + return &err->faixa; +} + +/* + * Copies at most `size - 1` bytes and always terminates. A municipality name + * longer than the buffer is truncated rather than rejected: the point of the + * block is to tell the caller which city they meant, and a truncated name + * still does that. + */ +static void copy_member(char *out, size_t size, const json_value_t *block, + const char *name) +{ + const char *text = json_as_string(json_object_get(block, name)); + + snprintf(out, size, "%s", text != NULL ? text : ""); +} + +bool cepis_faixa_from_json(const json_value_t *object, cepis_faixa_t *out) +{ + const json_value_t *block; + const char *localidade; + + if (out == NULL) + return false; + block = json_object_get(object, "faixa"); + if (block == NULL || block->type != JSON_OBJECT) + return false; + /* + * The municipality is the whole reason the block exists. Without it + * there is nothing to report, so an empty or non-string `localidade` + * counts as no block at all rather than as a nameless one. + */ + localidade = json_as_string(json_object_get(block, "localidade")); + if (localidade == NULL || localidade[0] == '\0') + return false; + + memset(out, 0, sizeof(*out)); + snprintf(out->localidade, sizeof(out->localidade), "%s", localidade); + copy_member(out->uf, sizeof(out->uf), block, "uf"); + copy_member(out->estado, sizeof(out->estado), block, "estado"); + copy_member(out->regiao, sizeof(out->regiao), block, "regiao"); + copy_member(out->ibge, sizeof(out->ibge), block, "ibge"); + copy_member(out->cep_ini, sizeof(out->cep_ini), block, "cep_ini"); + copy_member(out->cep_fim, sizeof(out->cep_fim), block, "cep_fim"); + if (!json_as_llong(json_object_get(block, "ceps"), &out->ceps)) + out->ceps = 0; + return true; +} + static cepis_geo_t *geo_from_json(const json_value_t *block) { const char *precisao; @@ -71,6 +71,12 @@ cepis_status_t cepis_set_error(cepis_error_t *err, cepis_status_t status, err->status = status; err->http_status = http_status; err->retry_after = retry_after; + /* + * Cleared here, not by the caller: an error is declared uninitialised + * on the stack and reused across calls, so anything left set would be + * read as a municipality that this failure never named. + */ + err->has_faixa = false; va_start(args, fmt); vsnprintf(err->message, sizeof(err->message), fmt, args); va_end(args); diff --git a/tests/test_cepis.c b/tests/test_cepis.c index ee23305..b17ab67 100644 --- a/tests/test_cepis.c +++ b/tests/test_cepis.c @@ -310,6 +310,126 @@ static void test_not_found_is_not_retried(void) fixture_down(&fixture); } +static void test_not_found_names_the_municipality(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + const cepis_faixa_t *faixa; + + fixture_up(&fixture); + ts_respond(fixture.server, "/25845000", 404, + "{\"erro\":\"cep_nao_encontrado\",\"mensagem\":" + "\"CEP n\xC3\xA3o existe na base do DNE\",\"faixa\":" + "{\"localidade\":\"Areal\",\"uf\":\"RJ\",\"estado\":" + "\"Rio de Janeiro\",\"regiao\":\"Sudeste\",\"ibge\":" + "\"3300225\",\"cep_ini\":\"25845001\",\"cep_fim\":" + "\"25849899\",\"ceps\":118}}", + NULL); + + /* Still a 404: the block is context for the failure, not an address. */ + CHECK(cepis_lookup(fixture.client, "25845-000", &address, &err) == + CEPIS_ERR_NOT_FOUND); + CHECK(address == NULL); + CHECK(err.http_status == 404); + + CHECK(err.has_faixa); + faixa = cepis_error_faixa(&err); + CHECK(faixa == &err.faixa); + if (faixa != NULL) { + CHECK_STR(faixa->localidade, "Areal"); + CHECK_STR(faixa->uf, "RJ"); + CHECK_STR(faixa->estado, "Rio de Janeiro"); + CHECK_STR(faixa->regiao, "Sudeste"); + CHECK_STR(faixa->ibge, "3300225"); + /* + * Not the DNE's own start, which is 25845000 -- the CEP that + * just failed. These are the smallest and largest that really + * exist, and the 118 are sparse across the ~4900 between them. + */ + CHECK_STR(faixa->cep_ini, "25845001"); + CHECK_STR(faixa->cep_fim, "25849899"); + CHECK(faixa->ceps == 118); + } + fixture_down(&fixture); +} + +static void test_not_found_without_faixa_leaves_it_absent(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); + + /* A band no municipality owns: the API omits the block, never nulls + * it, and the caller must be able to tell absent from present. */ + CHECK(cepis_lookup(fixture.client, "99999999", &address, &err) == + CEPIS_ERR_NOT_FOUND); + CHECK(!err.has_faixa); + CHECK(cepis_error_faixa(&err) == NULL); + fixture_down(&fixture); +} + +static void test_malformed_faixa_is_still_a_clean_not_found(void) +{ + static const char *bodies[] = { + "{\"erro\":\"x\",\"faixa\":null}", + "{\"erro\":\"x\",\"faixa\":\"Areal\"}", + "{\"erro\":\"x\",\"faixa\":[]}", + "{\"erro\":\"x\",\"faixa\":{}}", + "{\"erro\":\"x\",\"faixa\":{\"localidade\":\"\"}}", + "{\"erro\":\"x\",\"faixa\":{\"uf\":\"RJ\",\"ceps\":118}}", + "{\"erro\":\"x\",\"faixa\":{\"localidade\":42}}", + "not json at all", + ""}; + fixture_t fixture; + cepis_error_t err; + size_t i; + + fixture_up(&fixture); + for (i = 0; i < sizeof(bodies) / sizeof(bodies[0]); i++) { + cepis_address_t *address = NULL; + + ts_respond(fixture.server, "/99999999", 404, bodies[i], NULL); + CHECK(cepis_lookup(fixture.client, "99999999", &address, + &err) == CEPIS_ERR_NOT_FOUND); + CHECK(address == NULL); + CHECK(!err.has_faixa); + CHECK(cepis_error_faixa(&err) == NULL); + } + fixture_down(&fixture); +} + +static void test_faixa_does_not_survive_the_next_error(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/25845000", 404, + "{\"erro\":\"cep_nao_encontrado\",\"faixa\":" + "{\"localidade\":\"Areal\",\"uf\":\"RJ\",\"cep_ini\":" + "\"25845001\",\"cep_fim\":\"25849899\",\"ceps\":118}}", + NULL); + CHECK(cepis_lookup(fixture.client, "25845000", &address, &err) == + CEPIS_ERR_NOT_FOUND); + CHECK(err.has_faixa); + + /* Same error struct reused: a stale block would be read as a + * municipality this second failure never named. */ + CHECK(cepis_lookup(fixture.client, "123", &address, &err) == + CEPIS_ERR_INVALID_CEP); + CHECK(!err.has_faixa); + CHECK(cepis_error_faixa(&err) == NULL); + fixture_down(&fixture); +} + static void test_invalid_cep_never_leaves_the_machine(void) { fixture_t fixture; @@ -903,6 +1023,10 @@ int main(void) 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_not_found_names_the_municipality); + RUN(test_not_found_without_faixa_leaves_it_absent); + RUN(test_malformed_faixa_is_still_a_clean_not_found); + RUN(test_faixa_does_not_survive_the_next_error); RUN(test_invalid_cep_never_leaves_the_machine); RUN(test_api_400_becomes_invalid_cep); RUN(test_response_that_is_not_json); |