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 /include | |
| download | c-996c5d18fbf2ed4d3aaaf391ba4b7c2fcd2c010b.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'include')
| -rw-r--r-- | include/cepis.h | 482 |
1 files changed, 482 insertions, 0 deletions
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 |