summaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:21:46 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:21:46 -0300
commit5598ea80735a51cb7b3690837b2b7d4240331b2d (patch)
tree5ff0927a035ebe521c1b8d6a91fc547c81604631 /lib
downloadocaml-0.1.0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'lib')
-rw-r--r--lib/cepis.ml539
-rw-r--r--lib/cepis.mli283
-rw-r--r--lib/dune4
3 files changed, 826 insertions, 0 deletions
diff --git a/lib/cepis.ml b/lib/cepis.ml
new file mode 100644
index 0000000..a168d37
--- /dev/null
+++ b/lib/cepis.ml
@@ -0,0 +1,539 @@
+let version = "0.1.0"
+let default_url = "https://cep.is"
+let max_batch = 500
+
+type geo = { lat : float; lon : float; raio_m : int; precisao : string }
+
+let is_precise g = String.equal g.precisao "logradouro"
+
+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;
+}
+
+let formatted_cep a =
+ if String.length a.cep = 8 then
+ String.sub a.cep 0 5 ^ "-" ^ String.sub a.cep 5 3
+ else a.cep
+
+type health = {
+ status : string;
+ dne : string;
+ ceps : int;
+ geo : int;
+ base_gerada_em : string;
+}
+
+let is_ok h = String.equal h.status "ok"
+
+type error =
+ | Invalid_cep of string
+ | Cep_not_found of string
+ | Rate_limited of { message : string; retry_after : float option }
+ | Server_error of { message : string; status : int }
+ | Network_error of string
+ | Invalid_response of string
+
+let error_message = function
+ | Invalid_cep m | Cep_not_found m | Network_error m | Invalid_response m -> m
+ | Rate_limited { message; _ } | Server_error { message; _ } -> message
+
+let string_of_error = function
+ | Invalid_cep m -> "invalid CEP: " ^ m
+ | Cep_not_found m -> "CEP not found: " ^ m
+ | Rate_limited { message; retry_after = Some s } ->
+ Printf.sprintf "rate limited (retry after %gs): %s" s message
+ | Rate_limited { message; retry_after = None } -> "rate limited: " ^ message
+ | Server_error { message; status } ->
+ Printf.sprintf "server error (%d): %s" status message
+ | Network_error m -> "network error: " ^ m
+ | Invalid_response m -> "invalid response: " ^ m
+
+let is_digit c = c >= '0' && c <= '9'
+
+let normalize_cep value =
+ let buf = Buffer.create 8 in
+ String.iter
+ (fun c -> match c with '-' | '.' | ' ' -> () | c -> Buffer.add_char buf c)
+ (String.trim value);
+ let cleaned = Buffer.contents buf in
+ let ok =
+ String.length cleaned = 8 && String.for_all (fun c -> is_digit c) cleaned
+ in
+ if ok then Ok cleaned
+ else Error (Invalid_cep (Printf.sprintf "CEP must be 8 digits, got %S" value))
+
+let json_to_string = function
+ | `String s -> s
+ | `Int i -> string_of_int i
+ | `Intlit s -> s
+ | `Float f ->
+ if Float.is_integer f then Printf.sprintf "%.0f" f
+ else Printf.sprintf "%g" f
+ | `Bool b -> string_of_bool b
+ | _ -> ""
+
+let json_to_float = function
+ | `Int i -> Some (float_of_int i)
+ | `Float f -> Some f
+ | `Intlit s | `String s -> float_of_string_opt s
+ | _ -> None
+
+let field fields key =
+ match List.assoc_opt key fields with Some v -> v | None -> `Null
+
+let string_field fields key = json_to_string (field fields key)
+
+let int_field fields key =
+ match field fields key with
+ | `Int i -> i
+ | `Float f -> int_of_float f
+ | `Intlit s | `String s -> (
+ match int_of_string_opt s with Some i -> i | None -> 0)
+ | _ -> 0
+
+let geo_of_json = function
+ | `Assoc fields -> (
+ match
+ ( json_to_float (field fields "lat"),
+ json_to_float (field fields "lon"),
+ json_to_float (field fields "raio_m"),
+ field fields "precisao" )
+ with
+ | Some lat, Some lon, Some raio_m, ((`String _ | `Int _ | `Float _) as p)
+ ->
+ Some
+ {
+ lat;
+ lon;
+ raio_m = int_of_float raio_m;
+ precisao = json_to_string p;
+ }
+ | _ -> None)
+ | _ -> None
+
+let address_of_fields fields =
+ {
+ cep = string_field fields "cep";
+ logradouro = string_field fields "logradouro";
+ complemento = string_field fields "complemento";
+ unidade = string_field fields "unidade";
+ bairro = string_field fields "bairro";
+ localidade = string_field fields "localidade";
+ uf = string_field fields "uf";
+ estado = string_field fields "estado";
+ regiao = string_field fields "regiao";
+ ibge = string_field fields "ibge";
+ gia = string_field fields "gia";
+ ddd = string_field fields "ddd";
+ siafi = string_field fields "siafi";
+ geo = geo_of_json (field fields "geo");
+ raw = fields;
+ }
+
+let health_of_fields fields =
+ {
+ status = string_field fields "status";
+ dne = string_field fields "dne";
+ ceps = int_field fields "ceps";
+ geo = int_field fields "geo";
+ base_gerada_em = string_field fields "base_gerada_em";
+ }
+
+let json_kind = function
+ | `Assoc _ -> "object"
+ | `List _ -> "array"
+ | `String _ -> "string"
+ | `Int _ | `Intlit _ -> "int"
+ | `Float _ -> "float"
+ | `Bool _ -> "bool"
+ | `Null -> "null"
+ | _ -> "value"
+
+let truncate_body body =
+ if String.length body <= 120 then body else String.sub body 0 120 ^ "..."
+
+let json_object_of_body body =
+ match Yojson.Safe.from_string body with
+ | `Assoc fields -> Ok fields
+ | other ->
+ Error
+ (Invalid_response
+ (Printf.sprintf "expected a JSON object, got %s" (json_kind other)))
+ | exception _ ->
+ Error
+ (Invalid_response
+ (Printf.sprintf
+ "response is not valid JSON — something between you and the API \
+ is answering instead of it: %S"
+ (truncate_body body)))
+
+let api_message body fallback =
+ match Yojson.Safe.from_string body with
+ | `Assoc fields -> (
+ match field fields "mensagem" with
+ | `String m when m <> "" -> m
+ | _ -> fallback)
+ | _ -> fallback
+ | exception _ -> fallback
+
+let months =
+ [|
+ "jan";
+ "feb";
+ "mar";
+ "apr";
+ "may";
+ "jun";
+ "jul";
+ "aug";
+ "sep";
+ "oct";
+ "nov";
+ "dec";
+ |]
+
+let month_index name =
+ let name = String.lowercase_ascii name in
+ let name = if String.length name > 3 then String.sub name 0 3 else name in
+ let found = ref None in
+ Array.iteri
+ (fun i m -> if String.equal m name then found := Some (i + 1))
+ months;
+ !found
+
+let days_from_civil y m d =
+ let y = if m <= 2 then y - 1 else y in
+ let era = (if y >= 0 then y else y - 399) / 400 in
+ let yoe = y - (era * 400) in
+ let mp = (m + 9) mod 12 in
+ let doy = (((153 * mp) + 2) / 5) + d - 1 in
+ let doe = (yoe * 365) + (yoe / 4) - (yoe / 100) + doy in
+ (era * 146097) + doe - 719468
+
+let words s =
+ String.split_on_char ' ' (String.map (function ',' -> ' ' | c -> c) s)
+ |> List.filter (fun w -> w <> "")
+
+let parse_http_date value =
+ let tokens =
+ match words value with
+ | first :: rest when String.length first > 0 && not (is_digit first.[0]) ->
+ rest
+ | tokens -> tokens
+ in
+ match tokens with
+ | day :: month :: year :: time :: _ -> (
+ match
+ ( int_of_string_opt day,
+ month_index month,
+ int_of_string_opt year,
+ String.split_on_char ':' time )
+ with
+ | Some day, Some month, Some year, [ h; mi; s ] -> (
+ match
+ (int_of_string_opt h, int_of_string_opt mi, int_of_string_opt s)
+ with
+ | Some h, Some mi, Some s ->
+ Some
+ ((float_of_int (days_from_civil year month day) *. 86400.)
+ +. float_of_int ((h * 3600) + (mi * 60) + s))
+ | _ -> None)
+ | _ -> None)
+ | _ -> None
+
+let retry_after_seconds = function
+ | None -> None
+ | Some value -> (
+ let value = String.trim value in
+ match float_of_string_opt value with
+ | Some seconds -> Some (Float.max 0. seconds)
+ | None -> (
+ match parse_http_date value with
+ | Some target -> Some (Float.max 0. (target -. Unix.gettimeofday ()))
+ | None -> None))
+
+let header_value headers name =
+ let name = String.lowercase_ascii name in
+ List.fold_left
+ (fun acc (k, v) ->
+ if String.equal (String.lowercase_ascii (String.trim k)) name then
+ Some (String.trim v)
+ else acc)
+ None headers
+
+let retriable_status = [ 429; 500; 502; 503; 504 ]
+
+let unreserved c =
+ is_digit c
+ || (c >= 'a' && c <= 'z')
+ || (c >= 'A' && c <= 'Z')
+ || c = '-' || c = '_' || c = '.' || c = '~'
+
+let url_encode s =
+ let buf = Buffer.create (String.length s) in
+ String.iter
+ (fun c ->
+ if unreserved c then Buffer.add_char buf c
+ else Buffer.add_string buf (Printf.sprintf "%%%02X" (Char.code c)))
+ s;
+ Buffer.contents buf
+
+let strip_bom s =
+ if String.length s >= 3 && String.sub s 0 3 = "\xef\xbb\xbf" then
+ String.sub s 3 (String.length s - 3)
+ else s
+
+let csv_rows body =
+ match Csv.input_all (Csv.of_string (strip_bom body)) with
+ | [] -> []
+ | header :: rows ->
+ let width = List.length header in
+ List.filter_map
+ (fun row ->
+ if List.for_all (fun cell -> String.trim cell = "") row then None
+ else
+ let padded =
+ List.init width (fun i -> try List.nth row i with _ -> "")
+ in
+ Some (List.combine header padded))
+ rows
+
+let jitter = lazy (Random.State.make_self_init ())
+
+module Client = struct
+ type t = {
+ base_url : string;
+ timeout : float;
+ max_attempts : int;
+ backoff_base : float;
+ backoff_max : float;
+ total_timeout : float option;
+ user_agent : string;
+ }
+
+ let rec strip_trailing_slash s =
+ let n = String.length s in
+ if n > 0 && s.[n - 1] = '/' then
+ strip_trailing_slash (String.sub s 0 (n - 1))
+ else s
+
+ let create ?(base_url = default_url) ?(timeout = 10.) ?(max_attempts = 3)
+ ?(backoff_base = 0.5) ?(backoff_max = 8.) ?total_timeout ?user_agent () =
+ if max_attempts < 1 then
+ invalid_arg "Cepis.Client.create: max_attempts must be >= 1";
+ if timeout <= 0. then invalid_arg "Cepis.Client.create: timeout must be > 0";
+ {
+ base_url = strip_trailing_slash base_url;
+ timeout;
+ max_attempts;
+ backoff_base;
+ backoff_max;
+ total_timeout;
+ user_agent =
+ (match user_agent with
+ | Some ua -> ua
+ | None -> Printf.sprintf "cepis-ocaml/%s (+https://cep.is)" version);
+ }
+
+ let base_url t = t.base_url
+ let user_agent t = t.user_agent
+
+ let perform t url =
+ let config =
+ Ezcurl.Config.(
+ default |> follow_location true |> max_redirects 5 |> verbose false)
+ in
+ let set_opts curl =
+ let ms = int_of_float (t.timeout *. 1000.) in
+ Curl.set_timeoutms curl ms;
+ Curl.set_connecttimeoutms curl ms
+ in
+ match
+ Ezcurl.with_client ~set_opts (fun client ->
+ Ezcurl.get ~client ~config ~tries:1
+ ~headers:
+ [
+ ("User-Agent", t.user_agent);
+ ("Accept", "application/json, text/csv;q=0.9, */*;q=0.1");
+ ("Accept-Encoding", "identity");
+ ]
+ ~url ())
+ with
+ | Ok response ->
+ Ok (response.Ezcurl.code, response.Ezcurl.body, response.Ezcurl.headers)
+ | Error (_, message) -> Error message
+ | exception Curl.CurlException (_, _, message) -> Error message
+
+ let backoff t attempt retry_after =
+ match retry_after with
+ | Some seconds -> Float.min seconds t.backoff_max
+ | None ->
+ let ceiling =
+ Float.min
+ (t.backoff_base *. (2. ** float_of_int (attempt - 1)))
+ t.backoff_max
+ in
+ if ceiling <= 0. then 0.
+ else Random.State.float (Lazy.force jitter) ceiling
+
+ let expired = function
+ | None -> false
+ | Some deadline -> Unix.gettimeofday () >= deadline
+
+ let request t ?(accepted_status = [ 200 ]) path =
+ let url = t.base_url ^ path in
+ let deadline =
+ match t.total_timeout with
+ | None -> None
+ | Some total -> Some (Unix.gettimeofday () +. total)
+ in
+ let attempts = t.max_attempts in
+ let give_up status retry_after network =
+ match status with
+ | Some 429 ->
+ Error
+ (Rate_limited
+ {
+ message =
+ Printf.sprintf
+ "rate limit reached at %s (after %d attempt(s))" url
+ attempts;
+ retry_after;
+ })
+ | Some status ->
+ Error
+ (Server_error
+ {
+ message =
+ Printf.sprintf
+ "the API answered %d at %s (after %d attempt(s))" status
+ url attempts;
+ status;
+ })
+ | None ->
+ Error
+ (Network_error
+ (Printf.sprintf "could not reach %s (after %d attempt(s)): %s"
+ url attempts
+ (match network with
+ | Some m -> m
+ | None -> "deadline reached")))
+ in
+ let wait_then t attempt retry_after =
+ let wait = backoff t attempt retry_after in
+ match deadline with
+ | None ->
+ if wait > 0. then Unix.sleepf wait;
+ true
+ | Some deadline ->
+ let remaining = deadline -. Unix.gettimeofday () in
+ if remaining <= 0. then false
+ else begin
+ let wait = Float.min wait remaining in
+ if wait > 0. then Unix.sleepf wait;
+ true
+ end
+ in
+ let rec attempt n status retry_after network =
+ if n > attempts || expired deadline then
+ give_up status retry_after network
+ else
+ match perform t url with
+ | Ok (code, body, _)
+ when List.mem code accepted_status
+ || not (List.mem code retriable_status) ->
+ Ok (code, body)
+ | Ok (code, _, headers) ->
+ let retry_after =
+ retry_after_seconds (header_value headers "Retry-After")
+ in
+ if n >= attempts then give_up (Some code) retry_after None
+ else if wait_then t n retry_after then
+ attempt (n + 1) (Some code) retry_after None
+ else give_up (Some code) retry_after None
+ | Error message ->
+ if n >= attempts then give_up None None (Some message)
+ else if wait_then t n None then
+ attempt (n + 1) None None (Some message)
+ else give_up None None (Some message)
+ in
+ attempt 1 None None None
+
+ let ( let* ) = Result.bind
+
+ let lookup t cep =
+ let* number = normalize_cep cep in
+ let* code, body = request t ("/" ^ number) in
+ if code = 404 then
+ Error
+ (Cep_not_found
+ (Printf.sprintf "CEP %s does not exist in the DNE database" number))
+ else if code = 400 then
+ Error
+ (Invalid_cep
+ (api_message body (Printf.sprintf "CEP %s rejected" number)))
+ else
+ let* fields = json_object_of_body body in
+ Ok (address_of_fields fields)
+
+ let lookup_chunk t ceps =
+ let query = url_encode (String.concat "," ceps) in
+ let* _, body = request t ("/csv?ceps=" ^ query) in
+ let rows =
+ List.map
+ (fun row ->
+ match List.assoc_opt "localidade" row with
+ | Some place when String.trim place <> "" ->
+ Some
+ (address_of_fields
+ (List.map (fun (k, v) -> (k, `String v)) row))
+ | _ -> None)
+ (csv_rows body)
+ in
+ let wanted = List.length ceps in
+ let got = List.length rows in
+ if got = wanted then Ok rows
+ else if got > wanted then Ok (List.filteri (fun i _ -> i < wanted) rows)
+ else Ok (rows @ List.init (wanted - got) (fun _ -> None))
+
+ let rec chunks size list =
+ if list = [] then []
+ else
+ let head = List.filteri (fun i _ -> i < size) list in
+ let tail = List.filteri (fun i _ -> i >= size) list in
+ head :: chunks size tail
+
+ let lookup_many t ceps =
+ if ceps = [] then Ok []
+ else
+ List.fold_left
+ (fun acc chunk ->
+ let* acc = acc in
+ let* rows = lookup_chunk t chunk in
+ Ok (acc @ rows))
+ (Ok []) (chunks max_batch ceps)
+
+ let health t =
+ let* _, body = request t ~accepted_status:[ 200; 503 ] "/health" in
+ let* fields = json_object_of_body body in
+ Ok (health_of_fields fields)
+end
+
+let default = lazy (Client.create ())
+let lookup cep = Client.lookup (Lazy.force default) cep
+let lookup_many ceps = Client.lookup_many (Lazy.force default) ceps
+let health () = Client.health (Lazy.force default)
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}. *)
diff --git a/lib/dune b/lib/dune
new file mode 100644
index 0000000..f429f30
--- /dev/null
+++ b/lib/dune
@@ -0,0 +1,4 @@
+(library
+ (name cepis)
+ (public_name cepis)
+ (libraries unix yojson csv ezcurl))