summaryrefslogtreecommitdiffstats
path: root/lib/cepis.mli
diff options
context:
space:
mode:
Diffstat (limited to 'lib/cepis.mli')
-rw-r--r--lib/cepis.mli283
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}. *)