From 36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 12:32:15 -0300 Subject: Initial import --- lib/cepis/client.rb | 347 +++++++++++++++++++++++++++++++++++++++++++++++++++ lib/cepis/errors.rb | 67 ++++++++++ lib/cepis/models.rb | 193 ++++++++++++++++++++++++++++ lib/cepis/version.rb | 6 + 4 files changed, 613 insertions(+) create mode 100644 lib/cepis/client.rb create mode 100644 lib/cepis/errors.rb create mode 100644 lib/cepis/models.rb create mode 100644 lib/cepis/version.rb (limited to 'lib/cepis') diff --git a/lib/cepis/client.rb b/lib/cepis/client.rb new file mode 100644 index 0000000..f451061 --- /dev/null +++ b/lib/cepis/client.rb @@ -0,0 +1,347 @@ +# frozen_string_literal: true + +require "csv" +require "json" +require "net/http" +require "time" +require "uri" + +require_relative "errors" +require_relative "models" +require_relative "version" + +module Cepis + # Where the API lives, unless `base_url` says otherwise. + DEFAULT_URL = "https://cep.is" + + # Most CEPs the API accepts in a single batch request. + MAX_BATCH = 500 + + # Statuses worth trying again. 400 and 404 are deliberately absent: both are + # definitive answers about the request, so retrying returns the same thing + # more slowly while burning someone else's quota. + RETRIABLE_STATUS = [429, 500, 502, 503, 504].freeze + + # Failures that mean the request never completed. `Errno::*` are + # `SystemCallError`, and `Net::OpenTimeout`/`Net::ReadTimeout` are + # `Timeout::Error`, so the list is shorter than it looks. + NETWORK_ERRORS = [ + Timeout::Error, SocketError, SystemCallError, IOError, EOFError, + Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, + defined?(OpenSSL::SSL::SSLError) ? OpenSSL::SSL::SSLError : nil + ].compact.freeze + + DIGITS_ONLY = /\A\d{8}\z/.freeze + private_constant :DIGITS_ONLY + + # Return the CEP's 8 digits, accepting the mask. + # + # `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`. + # + # Raises {InvalidCEP} unless exactly 8 digits remain. This runs **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. + # + # @param value [String] + # @return [String] + def self.normalize_cep(value) + raise InvalidCEP, "CEP must be a String, got #{value.class}" unless value.is_a?(String) + + cleaned = value.strip.delete("-. ") + raise InvalidCEP, "CEP must be 8 digits, got #{value.inspect}" unless DIGITS_ONLY.match?(cleaned) + + cleaned + end + + # Client for the cep.is API. + # + # Reuse one instance: the configuration is immutable and no method keeps + # state between calls, so it is safe to share across threads. + # + # client = Cepis::Client.new(timeout: 5.0, total_timeout: 15.0) + # client.lookup("01001-000").localidade # => "São Paulo" + # + # @param base_url [String] 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 parameter that usually changes between + # environments. A trailing slash is stripped, so it cannot produce doubled + # separators that some proxies normalise and others 404. + # @param timeout [Float] seconds to wait **per socket operation** (connect, + # read, write) — not a cap on the whole call. That cap is `total_timeout`. + # @param max_attempts [Integer] 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. + # @param backoff_base [Float] 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. + # @param backoff_max [Float] 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. + # @param total_timeout [Float, nil] cap on the whole call, attempts and waits + # included. `nil` 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 [String, nil] overrides the `User-Agent`. The default + # identifies the library and its version, which is what lets the server + # diagnose an old client misbehaving. + class Client + attr_reader :base_url, :timeout, :max_attempts, :backoff_base, :backoff_max, + :total_timeout, :user_agent + + def initialize(base_url: DEFAULT_URL, timeout: 10.0, max_attempts: 3, + backoff_base: 0.5, backoff_max: 8.0, total_timeout: nil, + user_agent: nil) + raise ArgumentError, "max_attempts must be >= 1" if max_attempts < 1 + raise ArgumentError, "timeout must be > 0" if timeout <= 0 + + @base_url = base_url.to_s.sub(%r{/+\z}, "") + @timeout = timeout + @max_attempts = max_attempts + @backoff_base = backoff_base + @backoff_max = backoff_max + @total_timeout = total_timeout + @user_agent = user_agent || "cepis-ruby/#{VERSION} (+https://cep.is)" + freeze + end + + # Look up a single CEP. + # + # client.lookup("01001-000").localidade # => "São Paulo" + # + # A 400 from the API is surfaced as {InvalidCEP} carrying the API's own + # message: the format already passed local validation, so the server knows + # something this client does not. + # + # @param cep [String] masked or unmasked. + # @return [Address] + # @raise [InvalidCEP] the CEP is not 8 digits (no request is made). + # @raise [CEPNotFound] well-formed, but not in the database. + # @raise [RateLimited] 429 after every attempt. + # @raise [ServerError] 5xx after every attempt. + # @raise [NetworkError] the request never completed. + # @raise [InvalidResponse] a success status with an unreadable body. + def lookup(cep) + number = Cepis.normalize_cep(cep) + status, body = request("/#{number}") + + raise CEPNotFound, "CEP #{number} does not exist in the DNE database" if status == 404 + raise InvalidCEP, error_message(body, "CEP #{number} rejected") if status == 400 + + Address.from_json(parse_json(body)) + end + + # Look up many CEPs in one batch. + # + # Returns an array of **the same length and in the same order** as the + # input. A CEP that does not exist becomes `nil` at its position — it does + # not vanish from the array, and it does not raise. 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 `nil`, for the same reason, rather than + # raising {InvalidCEP}. To know *which* one was malformed, validate first + # with {Cepis.normalize_cep}. + # + # Input is chunked into blocks of {MAX_BATCH} (the API's limit) + # automatically. Do not loop over {#lookup}: a loop of 500 calls pays 500 + # TLS handshakes, the batch pays one. + # + # **The batch carries no coordinate.** The endpoint behind it is CSV, and + # the CSV has no `geo` column. Every {Address} returned here has `geo` set + # to `nil`, including for CEPs that do have one. Need `geo`? Use {#lookup}, + # one at a time. + # + # @param ceps [Array] + # @return [Array] + def lookup_many(ceps) + return [] if ceps.nil? || ceps.empty? + + ceps.each_slice(MAX_BATCH).flat_map { |chunk| lookup_chunk(chunk) } + end + + # Query `GET /health`: does the API answer, and with which database. + # + # Does not raise for an API reporting itself degraded — that arrives in + # {Health#status}, and checking health is exactly when you want the + # diagnosis rather than an exception. Network failures still raise. + # + # @return [Health] + def health + _, body = request("/health", accepted_status: [200, 503]) + Health.from_json(parse_json(body)) + end + + private + + # One chunk of at most {MAX_BATCH} CEPs, in a single request. + # + # A blank CSV row means "not found": the API fills only the `cep` column + # and leaves the rest empty, precisely so it does not misalign whoever is + # consuming the batch in a spreadsheet. + # + # The API guarantees one row per requested CEP. Should that ever fail to + # hold — a proxy truncating, a cut response — the result is padded to the + # input length rather than returned short, because a shifted array silently + # pairs the wrong address with the wrong CEP. + def lookup_chunk(ceps) + query = URI.encode_www_form(ceps: ceps.map(&:to_s).join(",")) + _, body = request("/csv?#{query}") + + rows = parse_csv(decode(body)).map do |row| + values = row.to_h.transform_keys(&:to_s) + next nil if values["localidade"].to_s.strip.empty? + + Address.from_json(values.transform_values { |value| value || "" }) + end + + rows.length == ceps.length ? rows : (rows + Array.new(ceps.length))[0, ceps.length] + end + + def parse_csv(text) + CSV.parse(text.delete_prefix("\uFEFF"), headers: true) + rescue CSV::MalformedCSVError => e + raise InvalidResponse, "response is not valid CSV: #{e.message}" + end + + def parse_json(body) + data = JSON.parse(decode(body)) + raise InvalidResponse, "expected a JSON object, got #{data.class}" unless data.is_a?(Hash) + + data + rescue JSON::ParserError + raise InvalidResponse, + "response is not valid JSON — something between you and the API is " \ + "answering instead of it: #{decode(body)[0, 120].inspect}" + end + + def decode(body) + body.to_s.dup.force_encoding(Encoding::UTF_8).scrub + end + + def error_message(body, fallback) + data = JSON.parse(decode(body)) + data.is_a?(Hash) && data["mensagem"] ? String(data["mensagem"]) : fallback + rescue JSON::ParserError + fallback + end + + # Parse the `Retry-After` header in both RFC 9110 forms. + # + # It may arrive as a number of seconds (`"120"`) or as an HTTP date + # (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the + # common bug, and it makes a client ignore exactly the wait request that + # matters most. + def retry_after_seconds(value) + return nil if value.nil? || value.strip.empty? + + text = value.strip + begin + return [0.0, Float(text)].max + rescue ArgumentError + nil + end + + begin + [0.0, Time.httpdate(text).to_f - Time.now.to_f].max + rescue ArgumentError + nil + end + end + + # How long to wait before the next attempt. + # + # The server's `Retry-After` wins over the local calculation when present: + # it knows when it will be back, we are guessing. It still goes through the + # `backoff_max` cap. Otherwise the wait is drawn from `[0, ceiling]` rather + # than being the ceiling — see `backoff_base` on why that matters. + def backoff(attempt, retry_after) + return [retry_after, @backoff_max].min unless retry_after.nil? + + ceiling = [@backoff_base * (2**(attempt - 1)), @backoff_max].min + rand * ceiling + end + + # Perform the GET, with retries. Returns `[status, body]`. + # + # *Definitive* error statuses (400, 404) are returned normally, for the + # caller to turn into the right error — only transient failures become + # exceptions here, and only once the attempts are exhausted. + def request(path, accepted_status: [200]) + url = "#{@base_url}#{path}" + uri = URI.parse(url) + deadline = @total_timeout && (monotonic + @total_timeout) + + last_status = nil + last_retry_after = nil + last_network_error = nil + attempt = 1 + + while attempt <= @max_attempts + break if deadline && monotonic >= deadline + + begin + response = perform(uri) + status = response.code.to_i + if accepted_status.include?(status) || !RETRIABLE_STATUS.include?(status) + return [status, response.body.to_s] + end + + last_status = status + last_retry_after = retry_after_seconds(response["Retry-After"]) + last_network_error = nil + rescue *NETWORK_ERRORS => e + last_network_error = e + last_status = nil + last_retry_after = nil + end + + break if attempt == @max_attempts + + wait = backoff(attempt, last_retry_after) + if deadline + remaining = deadline - monotonic + break if remaining <= 0 + + wait = [wait, remaining].min + end + sleep(wait) + attempt += 1 + end + + raise_transport_error(url, last_status, last_retry_after, last_network_error) + end + + def perform(uri) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = @timeout + http.read_timeout = @timeout + http.write_timeout = @timeout if http.respond_to?(:write_timeout=) + + get = Net::HTTP::Get.new(uri.request_uri, + "User-Agent" => @user_agent, + "Accept" => "application/json, text/csv;q=0.9, */*;q=0.1", + "Accept-Encoding" => "identity") + http.start { |connection| connection.request(get) } + end + + def raise_transport_error(url, status, retry_after, network_error) + attempts = "(after #{@max_attempts} attempt(s))" + if status == 429 + raise RateLimited.new("rate limit reached at #{url} #{attempts}", + retry_after: retry_after) + end + if status + raise ServerError.new("the API answered #{status} at #{url} #{attempts}", + status: status) + end + + raise NetworkError, "could not reach #{url} #{attempts}: #{network_error}" + end + + def monotonic + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + end +end diff --git a/lib/cepis/errors.rb b/lib/cepis/errors.rb new file mode 100644 index 0000000..7a15841 --- /dev/null +++ b/lib/cepis/errors.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Cepis + # Base of every error raised by this library. Rescue it to handle any + # failure with one handler. + # + # Below it, the split is by **the decision the caller has to make**, not by + # the HTTP status that caused it: + # + # - {InvalidCEP} and {CEPNotFound} are problems with the *request*. Retrying + # is pointless — the same CEP will fail again. + # - {NetworkError}, {ServerError} and {RateLimited} are problems with + # *transport*. Retrying may help, and the client already retried on its own + # before raising any of them. + # + # That split is why {CEPNotFound} is not "the HTTP 404 error": {InvalidCEP} + # is raised without any request being made at all, when the CEP is malformed + # before it ever leaves the machine. + class Error < StandardError; end + + # The CEP is not 8 digits. + # + # Usually raised **without** a request ever being made — the client checks + # the format before going to the network. It is also raised when the API + # answers 400 to a CEP that passed local validation, in which case it carries + # the API's own message. + class InvalidCEP < Error; end + + # The CEP is well-formed but does not exist in the DNE database. + class CEPNotFound < Error; end + + # The request never completed: DNS, refused connection, timeout, TLS. + # + # Raised only after every configured attempt has failed. + class NetworkError < Error; end + + # The API answered 5xx on every attempt. + class ServerError < Error + # HTTP status seen on the last attempt. + attr_reader :status + + def initialize(message, status:) + super(message) + @status = status + end + end + + # The API (or the edge in front of it) answered 429 on every attempt. + class RateLimited < Error + # What the server asked for, in seconds, or `nil` if it did not say. The + # client already honoured this across the attempts it made; it is exposed + # for callers who want to reschedule the work. + attr_reader :retry_after + + def initialize(message, retry_after: nil) + super(message) + @retry_after = retry_after + end + end + + # 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. + class InvalidResponse < Error; end +end diff --git a/lib/cepis/models.rb b/lib/cepis/models.rb new file mode 100644 index 0000000..a0181ff --- /dev/null +++ b/lib/cepis/models.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +module Cepis + # 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. + class Geo + attr_reader :lat, :lon, :raio_m, :precisao + + def initialize(lat:, lon:, raio_m:, precisao:) + @lat = lat + @lon = lon + @raio_m = raio_m + @precisao = precisao + freeze + end + + # Build from the hash the API returned, or `nil` when the block is missing + # or malformed. + # + # A malformed `geo` is not worth losing the whole address over, since the + # rest of the response is still useful. + def self.from_json(data) + return nil unless data.is_a?(Hash) + + block = data.transform_keys(&:to_s) + new( + lat: Float(block.fetch("lat")), + lon: Float(block.fetch("lon")), + raio_m: Float(block.fetch("raio_m")).to_i, + precisao: String(block.fetch("precisao")) + ) + rescue KeyError, TypeError, ArgumentError + nil + end + + # `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. + def precise? + @precisao == "logradouro" + end + + def to_h + { lat: @lat, lon: @lon, raio_m: @raio_m, precisao: @precisao } + end + + def ==(other) + other.is_a?(Geo) && to_h == other.to_h + end + alias eql? == + + def hash + to_h.hash + end + + def to_s + "#" + end + end + + # A CEP and everything the database knows about it. + # + # **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`, + # `localidade`. Everything else in this library is English; the fields are + # the one deliberate exception, so that what you read in the JSON at + # https://cep.is/01001000 is exactly what you read on the object. + # + # Fields the database does not have for that CEP come back as empty strings, + # never `nil` — that is what the API returns. + # + # {#geo} is the exception: it is `nil` 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. + # + # {#raw} holds the response exactly as it arrived, uninterpreted, so a field + # the API grows before this library is updated is not lost. + class Address + FIELDS = %i[ + cep logradouro complemento unidade bairro localidade + uf estado regiao ibge gia ddd siafi + ].freeze + + attr_reader(*FIELDS) + attr_reader :geo, :raw + + def initialize(geo: nil, raw: {}, **fields) + FIELDS.each { |name| instance_variable_set(:"@#{name}", fields[name] || "") } + @geo = geo + @raw = raw.freeze + freeze + end + + # Build from the hash the API returned. + # + # Unknown fields are ignored during construction but preserved in {#raw}. A + # new field in the API therefore cannot break this library. + def self.from_json(data) + values = data.transform_keys(&:to_s) + fields = FIELDS.each_with_object({}) do |name, acc| + acc[name] = String(values[name.to_s] || "") + end + new(geo: Geo.from_json(values["geo"]), raw: values, **fields) + end + + # The CEP with its mask: `01001-000`. + # + # The API always returns it unmasked; formatting is presentation, and it + # lives here so it does not become a repeated interpolation in every + # project that uses this. + def formatted_cep + "#{@cep[0, 5]}-#{@cep[5, 3]}" + end + + def to_h + FIELDS.each_with_object({}) { |name, acc| acc[name] = public_send(name) } + .merge(geo: @geo&.to_h) + end + + def ==(other) + other.is_a?(Address) && to_h == other.to_h + end + alias eql? == + + def hash + to_h.hash + end + + def to_s + "#" + end + end + + # 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. + class Health + attr_reader :status, :dne, :ceps, :geo, :base_gerada_em + + def initialize(status:, dne:, ceps:, geo:, base_gerada_em:) + @status = status + @dne = dne + @ceps = ceps + @geo = geo + @base_gerada_em = base_gerada_em + freeze + end + + # Build from the hash the API returned. + def self.from_json(data) + values = data.transform_keys(&:to_s) + new( + status: String(values["status"] || ""), + dne: String(values["dne"] || ""), + ceps: values["ceps"].to_i, + geo: values["geo"].to_i, + base_gerada_em: String(values["base_gerada_em"] || "") + ) + end + + # `true` when the API reports itself healthy. + def ok? + @status == "ok" + end + + def to_h + { + status: @status, dne: @dne, ceps: @ceps, geo: @geo, + base_gerada_em: @base_gerada_em + } + end + + def to_s + "#" + end + end +end diff --git a/lib/cepis/version.rb b/lib/cepis/version.rb new file mode 100644 index 0000000..cb88e06 --- /dev/null +++ b/lib/cepis/version.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Cepis + # Version of this library, as reported in the default `User-Agent`. + VERSION = "0.1.0" +end -- cgit v1.3