diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:21:46 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:21:46 -0300 |
| commit | 5598ea80735a51cb7b3690837b2b7d4240331b2d (patch) | |
| tree | 5ff0927a035ebe521c1b8d6a91fc547c81604631 /lib/cepis.mli | |
| download | ocaml-5598ea80735a51cb7b3690837b2b7d4240331b2d.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'lib/cepis.mli')
| -rw-r--r-- | lib/cepis.mli | 283 |
1 files changed, 283 insertions, 0 deletions
diff --git a/lib/cepis.mli b/lib/cepis.mli new file mode 100644 index 0000000..3deeb0b --- /dev/null +++ b/lib/cepis.mli @@ -0,0 +1,283 @@ +(** OCaml client for the public CEP API at {{:https://cep.is} cep.is}. + + Quick use — the module-level functions share one default client: + + {[ + match Cepis.lookup "01001-000" with + | Ok a -> print_endline (a.Cepis.localidade ^ " " ^ a.Cepis.uf) + | Error e -> prerr_endline (Cepis.string_of_error e) + ]} + + In an application, build a {!Client.t} once and reuse it: + + {[ + let client = Cepis.Client.create ~timeout:5.0 ~total_timeout:15.0 () + let address = Cepis.Client.lookup client "01001000" + ]} + + Expected failures come back as [('a, error) result]. Only programmer errors + — a nonsensical configuration — raise, as [Invalid_argument]. + + Note on field names: the API surface is English, but the + {b data fields keep the API's own Portuguese names} ([logradouro], [bairro], + [localidade]). What you read in the JSON is what you access on the record. +*) + +val version : string +(** Version of this library, as it appears in the default [User-Agent]. *) + +val default_url : string +(** Where the public API lives: [https://cep.is]. *) + +val max_batch : int +(** How many CEPs the API accepts in one batch: 500. {!Client.lookup_many} + chunks longer input at this size on its own. *) + +(** {1 Data} *) + +type geo = { lat : float; lon : float; raio_m : int; precisao : string } +(** The CEP's coordinate, when the database has one. + + The point is the {b 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 {i 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. *) + +val is_precise : geo -> bool +(** [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. *) + +type address = { + cep : string; + logradouro : string; + complemento : string; + unidade : string; + bairro : string; + localidade : string; + uf : string; + estado : string; + regiao : string; + ibge : string; + gia : string; + ddd : string; + siafi : string; + geo : geo option; + raw : (string * Yojson.Safe.t) list; +} +(** A CEP and everything the database knows about it. + + Fields the database does not have for that CEP come back as empty strings, + never absent — 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 [None] when there is no coordinate. There the + difference is real, because the API {b omits} the whole field in that case + (about 43% of CEPs) rather than sending null. + + [raw] holds the response's fields exactly as they arrived, uninterpreted, so + a field the API grows before this library is updated is not lost. For an + address that came from a batch it holds that CSV row's columns, every value + a string. *) + +val formatted_cep : address -> string +(** The CEP with its mask: ["01001-000"]. + + The native contract always returns it unmasked; formatting is presentation, + and it lives here so it does not become a repeated [Printf.sprintf] in every + project that uses this. *) + +type health = { + status : string; + dne : string; + ceps : int; + geo : int; + base_gerada_em : string; +} +(** The response of [GET /health]. + + Good for two things: checking that the API answers, and knowing + {b which version of the database} is live. The DNE database changes every + two weeks; [dne] identifies it. *) + +val is_ok : health -> bool +(** [true] when the API reports itself healthy. *) + +(** {1 Errors} *) + +(** Everything that can go wrong, split by + {b the decision the caller has to make} rather than by the HTTP status that + caused it. + + {!Invalid_cep} and {!Cep_not_found} are problems with the {i request}: the + same CEP will fail again. {!Network_error}, {!Server_error} and + {!Rate_limited} are problems with {i transport}: retrying may help, and the + client already retried on its own before returning any of them. *) +type error = + | Invalid_cep of string + (** The CEP is not 8 digits. Usually produced {b without} a request ever + being made — the client checks the format before going to the network. + Retrying is pointless. *) + | Cep_not_found of string + (** The CEP is well-formed but does not exist in the DNE database. + Retrying is pointless. *) + | Rate_limited of { message : string; retry_after : float option } + (** The API (or the edge in front of it) answered 429 on every attempt. + [retry_after] is what the server asked for, in seconds, or [None] if + it did not say. The client already honoured that value across the + attempts it made; it is exposed for callers who want to reschedule the + work. *) + | Server_error of { message : string; status : int } + (** The API answered 5xx on every attempt. [status] is the one seen on the + last attempt. Retrying later may help. *) + | Network_error of string + (** The request never completed: DNS, refused connection, timeout, TLS. + Returned only after every configured attempt has failed. Retrying + later may help. *) + | Invalid_response of string + (** A success status came back with a body that cannot be parsed. In + practice this 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. *) + +val error_message : error -> string +(** The human-readable message carried by an error, without its kind. *) + +val string_of_error : error -> string +(** The error rendered for a log line: kind, then message. *) + +(** {1 Validation} *) + +val normalize_cep : string -> (string, error) result +(** [normalize_cep value] returns the CEP's 8 digits, accepting the mask. + + ["01001-000"], ["01001000"] and [" 01001-000 "] all become ["01001000"]. + + Returns {!Invalid_cep} unless exactly 8 digits remain. This runs {b 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. *) + +(** {1 Client} *) + +(** The configured client. Reuse one instance: the configuration is immutable + and no function keeps state between calls. *) +module Client : sig + type t + (** A client and its configuration. *) + + val create : + ?base_url:string -> + ?timeout:float -> + ?max_attempts:int -> + ?backoff_base:float -> + ?backoff_max:float -> + ?total_timeout:float -> + ?user_agent:string -> + unit -> + t + (** Build a client. + + @param base_url + Where the API lives, default {!default_url}. 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 parameter that usually + changes between environments. Trailing slashes are stripped, so it + cannot produce doubled separators that some proxies normalise and others + 404. + @param timeout + Seconds allowed for {b one attempt}, connection included, default [10.]. + It is not a cap on the whole call — that cap is [total_timeout]. + @param max_attempts + How many attempts in total, counting the first, default [3]. [1] + disables retrying. The default covers the common case (one transient + failure) without turning a real outage into a long wait. + @param backoff_base + Base of the exponential wait in seconds, default [0.5]. The actual wait + is drawn from [[0, base * 2 ** (n-1)]] — {i 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. + @param backoff_max + Cap on each individual wait in seconds, default [8.]. It applies to a + server-sent [Retry-After] too, or a [Retry-After: 3600] from a + misconfigured proxy would park the caller for an hour. + @param total_timeout + Cap on the whole call, attempts and waits included. Absent 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. + @param user_agent + Overrides the [User-Agent]. The default identifies the library and its + version, which is what lets the server diagnose an old client + misbehaving. + @raise Invalid_argument + if [max_attempts] is below 1 or [timeout] is not positive. *) + + val base_url : t -> string + (** The base URL the client was built with, trailing slashes stripped. *) + + val user_agent : t -> string + (** The [User-Agent] this client sends. *) + + val lookup : t -> string -> (address, error) result + (** [lookup client cep] resolves a single CEP, masked or unmasked. + + A 400 from the API surfaces as {!Invalid_cep} carrying the API's own + message: the format already passed local validation, so the server knows + something this client does not. *) + + val lookup_many : t -> string list -> (address option list, error) result + (** [lookup_many client ceps] resolves many CEPs in one batch. + + The list returned has {b the same length and the same order} as the input. + A CEP that does not exist becomes [None] at its position — it does not + vanish from the list, and it does not fail the call. That is the right + difference from {!lookup}: in a batch of a thousand, one bad CEP must not + cost the other nine hundred and ninety-nine. + + A malformed CEP also becomes [None], for the same reason, rather than + returning {!Invalid_cep}. If you need to know {i which} one was malformed, + validate first with {!normalize_cep}. + + Input is chunked into blocks of {!max_batch} automatically. + + {b 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 = None], including for CEPs that do have one. Need [geo]? Use + {!lookup}, one at a time. *) + + val health : t -> (health, error) result + (** [health client] queries [GET /health]: does the API answer, and with which + database. + + Does not fail for an API reporting itself degraded — that arrives in the + [status] field, and checking health is exactly when you want the diagnosis + rather than an error. Network failures still fail. *) +end + +(** {1 Module-level conveniences} + + These share one default client, built with the defaults on first use. It + applies process-wide, including to other libraries that also use this + module. Need a different configuration? Build your own with + {!Client.create}. *) + +val lookup : string -> (address, error) result +(** Look up a CEP using the default client. See {!Client.lookup}. *) + +val lookup_many : string list -> (address option list, error) result +(** Look up many CEPs using the default client. See {!Client.lookup_many}, + especially the warning that batches carry no coordinate. *) + +val health : unit -> (health, error) result +(** Query the API's health using the default client. See {!Client.health}. *) |