summaryrefslogtreecommitdiffstats
path: root/include
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:25:48 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:25:48 -0300
commite2c6ae13af335b311da2429c903032124f926df2 (patch)
tree73776fab7f3e52124a217e2768968fc9a8a76c34 /include
downloadc-e2c6ae13af335b311da2429c903032124f926df2.tar.gz
Initial importv0.1.0
Diffstat (limited to 'include')
-rw-r--r--include/cepis.h482
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