summaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:32:15 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:32:15 -0300
commit36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c (patch)
treebfd0a067edb5e802725bab48ebc7a0a09838feb1 /lib
downloadruby-0.1.0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'lib')
-rw-r--r--lib/cepis.rb83
-rw-r--r--lib/cepis/client.rb347
-rw-r--r--lib/cepis/errors.rb67
-rw-r--r--lib/cepis/models.rb193
-rw-r--r--lib/cepis/version.rb6
5 files changed, 696 insertions, 0 deletions
diff --git a/lib/cepis.rb b/lib/cepis.rb
new file mode 100644
index 0000000..7b2cc5a
--- /dev/null
+++ b/lib/cepis.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require_relative "cepis/version"
+require_relative "cepis/errors"
+require_relative "cepis/models"
+require_relative "cepis/client"
+
+# Ruby client for the public CEP API at https://cep.is.
+#
+# For a script, the module-level methods are enough:
+#
+# require "cepis"
+#
+# address = Cepis.lookup("01001-000")
+# puts "#{address.localidade} #{address.uf}" # São Paulo SP
+#
+# In an application, build a {Cepis::Client} once and reuse it:
+#
+# client = Cepis::Client.new(timeout: 5.0, total_timeout: 15.0)
+# address = client.lookup("01001000")
+#
+# Pointing at a local instance (the API repo brings one up with `just up`):
+#
+# client = Cepis::Client.new(base_url: "http://localhost:8080")
+#
+# Errors are explicit and split by decision — whether retrying helps or not:
+#
+# begin
+# address = Cepis.lookup(cep_from_form)
+# rescue Cepis::CEPNotFound
+# # the CEP does not exist: ask the user for another
+# rescue Cepis::Error
+# # network, server, rate limit: retry later
+# end
+#
+# Note on field names: the API surface is English, but the **data fields keep
+# the API's own Portuguese names** (`logradouro`, `bairro`, `localidade`). What
+# you read in the JSON is what you read on the object.
+module Cepis
+ class << self
+ # The client behind the module-level methods.
+ #
+ # It is built once because {Client} keeps no state between calls and is
+ # safe to share across threads. It applies process-wide, including to
+ # third-party libraries that also use this module — anyone needing
+ # different settings should build their own {Client}.
+ #
+ # @return [Client]
+ def default_client
+ @default_client ||= Client.new
+ end
+
+ # Look up a CEP using the default configuration.
+ #
+ # Shorthand for `Cepis.default_client.lookup(cep)`. See {Client#lookup}.
+ #
+ # @param cep [String]
+ # @return [Address]
+ def lookup(cep)
+ default_client.lookup(cep)
+ end
+
+ # Look up many CEPs in one batch, using the default configuration.
+ #
+ # Shorthand for {Client#lookup_many} — read that one, especially the
+ # warning that batches carry no coordinate.
+ #
+ # @param ceps [Array<String>]
+ # @return [Array<Address, nil>]
+ def lookup_many(ceps)
+ default_client.lookup_many(ceps)
+ end
+
+ # Query the API's health using the default configuration.
+ #
+ # Shorthand for {Client#health}.
+ #
+ # @return [Health]
+ def health
+ default_client.health
+ end
+ end
+end
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<String>]
+ # @return [Array<Address, nil>]
+ 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
+ "#<Cepis::Geo #{@lat},#{@lon} ±#{@raio_m}m #{@precisao}>"
+ 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
+ "#<Cepis::Address #{formatted_cep} #{@logradouro} #{@localidade}/#{@uf}>"
+ 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
+ "#<Cepis::Health #{@status} dne=#{@dne} ceps=#{@ceps}>"
+ 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