(** 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}. *)