diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:32:15 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:32:15 -0300 |
| commit | 36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c (patch) | |
| tree | bfd0a067edb5e802725bab48ebc7a0a09838feb1 | |
| download | ruby-36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c.tar.gz | |
Initial importv0.1.0
| -rw-r--r-- | .gitignore | 9 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 55 | ||||
| -rw-r--r-- | README.md | 207 | ||||
| -rw-r--r-- | cepis.gemspec | 28 | ||||
| -rw-r--r-- | lib/cepis.rb | 83 | ||||
| -rw-r--r-- | lib/cepis/client.rb | 347 | ||||
| -rw-r--r-- | lib/cepis/errors.rb | 67 | ||||
| -rw-r--r-- | lib/cepis/models.rb | 193 | ||||
| -rw-r--r-- | lib/cepis/version.rb | 6 | ||||
| -rw-r--r-- | test/cepis_test.rb | 293 | ||||
| -rw-r--r-- | test/test_helper.rb | 166 |
12 files changed, 1469 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42c09bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/pkg/ +*.gem +/doc/ +/.yardoc/ +/coverage/ +/tmp/ +Gemfile.lock +.bundle/ +vendor/bundle/ @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Murilo Ijanc <[email protected]> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1f9fc17 --- /dev/null +++ b/Makefile @@ -0,0 +1,55 @@ +# Development tasks. Everything runs through the Ruby that is on PATH — the +# library and its test suite have no dependencies beyond the standard library, +# so there is nothing to install first. +# +# make list the targets +# make test run the suite with minitest +# make lint syntax and warning checks, no files touched +# make build build the gem into pkg/ + +RUBY ?= ruby +GEM ?= gem + +SOURCES = $(shell find lib test -name '*.rb') cepis.gemspec +TESTS = $(shell find test -name '*_test.rb') +VERSION = $(shell $(RUBY) -Ilib -e 'require "cepis/version"; print Cepis::VERSION') + +.DEFAULT_GOAL := help +.PHONY: help test lint format build check clean + +help: + @echo 'Targets:' + @echo ' test run the test suite' + @echo ' lint ruby -w -c over the tree, plus rubocop when installed' + @echo ' format apply rubocop -A, when installed (writes)' + @echo ' build build the gem into pkg/' + @echo ' check lint + test + build, in that order' + @echo ' clean remove build artefacts' + +test: + @set -e; for f in $(TESTS); do $(RUBY) -Ilib -Itest $$f; done + +# Read-only by design, so it is safe in CI and in a pre-commit hook. Ruby has +# no formatter or linter in the standard library; rubocop is not a dependency +# of this gem, so it is used only when it happens to be installed. +lint: + @set -e; for f in $(SOURCES); do $(RUBY) -w -c $$f >/dev/null; done + @echo 'syntax OK: $(words $(SOURCES)) files' + $(RUBY) -w -Ilib -e 'require "cepis"' + @if command -v rubocop >/dev/null 2>&1; then rubocop; else \ + echo 'rubocop not installed: syntax and warning checks only'; fi + +format: + @if command -v rubocop >/dev/null 2>&1; then rubocop -A; else \ + echo 'rubocop not installed: nothing to format with'; fi + +build: + @mkdir -p pkg + $(GEM) build cepis.gemspec --output pkg/cepis-$(VERSION).gem + +# The order matters: lint is the fastest to fail, build the slowest, so a +# broken run reports the cheapest problem first. +check: lint test build + +clean: + rm -rf pkg doc .yardoc diff --git a/README.md b/README.md new file mode 100644 index 0000000..017fe89 --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# cepis + +Ruby client for the [public CEP API at cep.is](https://cep.is). Official +Correios data (DNE), refreshed every two weeks. No runtime dependencies: +`net/http`, `json` and `csv` come with Ruby. + +```ruby +require "cepis" + +address = Cepis.lookup("01001-000") +puts "#{address.logradouro} #{address.localidade} #{address.uf}" +# Praça da Sé São Paulo SP +``` + +## Installation + +```sh +gem install cepis +``` + +Or, in a `Gemfile`: + +```ruby +gem "cepis", git: "https://git.cep.is/ruby.git" +``` + +Requires Ruby 3.0 or newer. + +## Usage + +### A single lookup + +`lookup` accepts the CEP masked or unmasked and returns a `Cepis::Address`: + +```ruby +a = Cepis.lookup("01310-100") + +a.logradouro # "Avenida Paulista" +a.localidade # "São Paulo" +a.uf # "SP" +a.ibge # "3550308" +a.cep # "01310100" (always unmasked) +a.formatted_cep # "01310-100" +``` + +Fields the database does not have for that CEP come back as empty strings, +never `nil`. + +### Coordinates + +About 57% of CEPs have a coordinate. When they do not, `geo` is `nil`: + +```ruby +a = Cepis.lookup("01310100") + +if a.geo + puts "#{a.geo.lat} #{a.geo.lon}" # -23.567202 -46.649659 + puts a.geo.raio_m # 1084 + puts a.geo.precisao # "logradouro" +end +``` + +**Check `precisao` before computing distance.** There are two kinds of point, +and the difference between them is tens of kilometres: + +| `precisao` | typical radius | what the point is | +| --- | --- | --- | +| `logradouro` | 110–140 m | the stretch of street — good for maps and distance | +| `localidade` | 7–40 km | the municipality centroid: the CEP *is* the whole city | + +`a.geo.precise?` is the shorthand for `precisao == "logradouro"`: + +```ruby +cost = if a.geo&.precise? + shipping_from(a.geo.lat, a.geo.lon) + else + shipping_from_municipality(a.ibge) + end +``` + +### Many CEPs at once + +Do not loop over `lookup`. Use `lookup_many`, which resolves up to 500 CEPs +per request: + +```ruby +ceps = %w[01001000 99999999 20040020] + +ceps.zip(Cepis.lookup_many(ceps)).each do |requested, address| + puts address.nil? ? "#{requested}: not found" : "#{requested}: #{address.localidade}" +end +``` + +The returned array has **the same length and the same order** as the input. A +CEP that does not exist, or is malformed, becomes `nil` at its position — it +does not vanish, and it does not raise. + +Arrays longer than 500 are chunked automatically. + +> **Batches carry no coordinate.** The endpoint behind them is CSV, and the +> CSV has no `geo` column. Every `Address` from `lookup_many` has `geo` set to +> `nil`, including for CEPs that do have one. Need `geo`? Use `lookup`, one at +> a time. + +### API health + +Useful for two things: checking the API answers, and knowing **which version +of the database** is live: + +```ruby +h = Cepis.health + +h.ok? # true +h.dne # "26071" identifier of the fortnightly database +h.ceps # 1596168 +h.base_gerada_em # "2026-07-24T03:48:28Z" +``` + +An API reporting itself degraded does not raise — that arrives in `h.status`. + +## Configuration + +For anything beyond the defaults, build a `Cepis::Client` and reuse it. It +keeps no state between calls and is safe to share across threads: + +```ruby +client = Cepis::Client.new( + base_url: "https://cep.is", # point at another instance + timeout: 10.0, # seconds per socket operation + max_attempts: 3, # counting the first; 1 disables retrying + backoff_base: 0.5, # base of the exponential wait + backoff_max: 8.0, # cap on each wait + total_timeout: nil # cap on the whole call, attempts included +) + +address = client.lookup("01001000") +``` + +Retries cover 429, 5xx and network failures, with exponential backoff and +jitter; 400 and 404 are never retried. + +The module-level methods (`Cepis.lookup`, etc.) use a shared default client. +Do not mutate it: it applies process-wide, including to third-party libraries +that also use this gem. Need a different configuration? Build your own. + +### Pointing at a local instance + +`base_url` is the only parameter that usually changes between environments. +The API repo brings a local instance up with `just up`: + +```ruby +client = Cepis::Client.new(base_url: "http://localhost:8080") +``` + +## Errors + +Everything derives from `Cepis::Error`, itself a `StandardError`. Below that, +the split is by **the decision you have to make**, not by the HTTP status that +caused it: + +| error | when | does retrying help? | +| --- | --- | --- | +| `Cepis::InvalidCEP` | not 8 digits | no | +| `Cepis::CEPNotFound` | well-formed, not in the database | no | +| `Cepis::RateLimited` | 429 | yes, later | +| `Cepis::ServerError` | 5xx | yes, later | +| `Cepis::NetworkError` | DNS, connection, timeout, TLS | yes, later | +| `Cepis::InvalidResponse` | unintelligible response | probably not | + +```ruby +begin + address = Cepis.lookup(cep_from_form) +rescue Cepis::CEPNotFound + # ask the user for another CEP +rescue Cepis::Error + # transient failure: reschedule +end +``` + +`RateLimited#retry_after` carries what the server asked for, in seconds, and +`ServerError#status` the HTTP status of the last attempt. + +`InvalidResponse` almost always 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. + +## Development + +Everything runs through `make` and the Ruby on your `PATH`. There is nothing +to install: the gem and its suite use only the standard library, and the tests +bring up a real HTTP server on loopback. + +```sh +make # list the targets +make test # run the suite (minitest) +make lint # ruby -w -c over the tree, plus a warning-clean load +make build # build the gem into pkg/ +make check # lint, then test, then build +``` + +Ruby ships no linter or formatter, and this gem does not depend on one. `make +lint` is therefore a syntax and warning check; if `rubocop` happens to be +installed it is run as well, and `make format` applies it. + +## Licence + +ISC. See [LICENSE](LICENSE). diff --git a/cepis.gemspec b/cepis.gemspec new file mode 100644 index 0000000..4c2e242 --- /dev/null +++ b/cepis.gemspec @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_relative "lib/cepis/version" + +Gem::Specification.new do |spec| + spec.name = "cepis" + spec.version = Cepis::VERSION + spec.authors = ["Murilo Ijanc"] + spec.email = ["[email protected]"] + + spec.summary = "Ruby client for the public CEP API at cep.is" + spec.description = "Look up Brazilian postal codes against cep.is: official " \ + "Correios data (DNE), single lookups with coordinates, " \ + "batches of up to 500, retries with jittered backoff. " \ + "No runtime dependencies." + spec.homepage = "https://cep.is" + spec.license = "ISC" + spec.required_ruby_version = ">= 3.0" + + spec.metadata = { + "homepage_uri" => spec.homepage, + "source_code_uri" => "https://git.cep.is/ruby.git", + "rubygems_mfa_required" => "true" + } + + spec.files = Dir["lib/**/*.rb"] + %w[README.md LICENSE Makefile cepis.gemspec] + spec.require_paths = ["lib"] +end 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 diff --git a/test/cepis_test.rb b/test/cepis_test.rb new file mode 100644 index 0000000..06a6bf6 --- /dev/null +++ b/test/cepis_test.rb @@ -0,0 +1,293 @@ +# frozen_string_literal: true + +require "test_helper" + +# The suite brings up a real HTTP server on loopback and points the client at +# it. No test touches the network, and no test stubs Net::HTTP: the code path +# exercised is the one that runs in production, request construction and error +# body reading included. +# +# It doubles as the test that `base_url` works — if it did not, none of this +# would run. + +class NormalizationTest < Minitest::Test + def test_accepts_masked_and_unmasked + ["01001000", "01001-000", " 01001-000 ", "01001.000"].each do |value| + assert_equal "01001000", Cepis.normalize_cep(value) + end + end + + def test_rejects_bad_format + ["", "123", "0100100a", "010010000", "abcdefgh"].each do |value| + assert_raises(Cepis::InvalidCEP) { Cepis.normalize_cep(value) } + end + end + + def test_rejects_wrong_type + assert_raises(Cepis::InvalidCEP) { Cepis.normalize_cep(1_001_000) } + end + + def test_one_rescue_catches_everything + assert_raises(Cepis::Error) { Cepis.normalize_cep("123") } + end +end + +class LookupTest < ServerTest + def test_successful_lookup + routes.respond("/01001000", [200, JSON.generate(SE_ADDRESS), {}]) + address = @client.lookup("01001-000") + + assert_equal "São Paulo", address.localidade + assert_equal "SP", address.uf + assert_equal "01001-000", address.formatted_cep + assert_nil address.geo + end + + def test_reads_the_coordinate_when_present + body = SE_ADDRESS.merge( + "geo" => { "lat" => -23.5, "lon" => -46.6, "raio_m" => 120, "precisao" => "logradouro" } + ) + routes.respond("/01310100", [200, JSON.generate(body), {}]) + address = @client.lookup("01310100") + + assert_in_delta(-23.5, address.geo.lat) + assert_in_delta(-46.6, address.geo.lon) + assert_equal 120, address.geo.raio_m + assert address.geo.precise? + end + + def test_municipality_coordinate_is_not_precise + body = SE_ADDRESS.merge( + "geo" => { "lat" => -23.5, "lon" => -46.6, "raio_m" => 22_000, "precisao" => "localidade" } + ) + routes.respond("/01001000", [200, JSON.generate(body), {}]) + + refute @client.lookup("01001000").geo.precise? + end + + def test_malformed_geo_does_not_lose_the_address + body = SE_ADDRESS.merge("geo" => { "lat" => "not a number" }) + routes.respond("/01001000", [200, JSON.generate(body), {}]) + address = @client.lookup("01001000") + + assert_nil address.geo + assert_equal "São Paulo", address.localidade + end + + def test_new_api_field_does_not_break + body = SE_ADDRESS.merge("campo_do_futuro" => "value") + routes.respond("/01001000", [200, JSON.generate(body), {}]) + + assert_equal "value", @client.lookup("01001000").raw["campo_do_futuro"] + end + + def test_not_found + routes.respond( + "/99999999", + [404, JSON.generate({ "erro" => "cep_nao_encontrado", "mensagem" => "gone" }), {}] + ) + error = assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") } + + assert_includes error.message, "99999999" + end + + def test_not_found_is_not_retried + routes.respond("/99999999", [404, JSON.generate({ "erro" => "x" }), {}]) + assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") } + + assert_equal 1, routes.count("/99999999") + end + + def test_invalid_cep_never_leaves_the_machine + assert_raises(Cepis::InvalidCEP) { @client.lookup("123") } + + assert_empty routes.calls + end + + def test_api_400_becomes_invalid_cep + routes.respond( + "/00000000", + [400, JSON.generate({ "erro" => "cep_invalido", "mensagem" => "rejected by the API" }), {}] + ) + error = assert_raises(Cepis::InvalidCEP) { @client.lookup("00000000") } + + assert_includes error.message, "rejected by the API" + end + + def test_response_that_is_not_json + routes.respond("/01001000", [200, "<html>captive portal</html>", {}]) + + assert_raises(Cepis::InvalidResponse) { @client.lookup("01001000") } + end +end + +class RetryTest < ServerTest + def test_retries_on_500_then_succeeds + routes.respond("/01001000", [ + [500, JSON.generate({ "erro" => "interno" }), {}], + [200, JSON.generate(SE_ADDRESS), {}] + ]) + + assert_equal "São Paulo", @client.lookup("01001000").localidade + assert_equal 2, routes.count("/01001000") + end + + def test_exhausts_attempts_then_raises + routes.respond("/01001000", [503, JSON.generate({ "erro" => "interno" }), {}]) + error = assert_raises(Cepis::ServerError) { @client.lookup("01001000") } + + assert_equal 503, error.status + assert_equal 3, routes.count("/01001000") + end + + def test_max_attempts_one_disables_retry + client = Cepis::Client.new(base_url: @server.url, max_attempts: 1, timeout: 5.0) + routes.respond("/01001000", [500, "{}", {}]) + + assert_raises(Cepis::ServerError) { client.lookup("01001000") } + assert_equal 1, routes.count("/01001000") + end + + def test_429_becomes_rate_limited_with_retry_after + routes.respond("/01001000", [429, "{}", { "Retry-After" => "7" }]) + error = assert_raises(Cepis::RateLimited) { @client.lookup("01001000") } + + assert_in_delta 7.0, error.retry_after + end + + def test_retry_after_accepts_an_http_date + routes.respond( + "/01001000", + [429, "{}", { "Retry-After" => (Time.now + 4).httpdate }] + ) + error = assert_raises(Cepis::RateLimited) { @client.lookup("01001000") } + + assert_operator error.retry_after, :>, 0 + assert_operator error.retry_after, :<=, 5 + end + + def test_retry_after_honours_the_cap + routes.respond("/01001000", [429, "{}", { "Retry-After" => "3600" }]) + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + assert_raises(Cepis::RateLimited) { @client.lookup("01001000") } + + assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, :<, 5.0 + end + + def test_network_error_when_nothing_listens + client = Cepis::Client.new( + base_url: "http://127.0.0.1:1", max_attempts: 2, backoff_base: 0.001, timeout: 1.0 + ) + + assert_raises(Cepis::NetworkError) { client.lookup("01001000") } + end + + def test_total_timeout_bounds_the_whole_call + client = Cepis::Client.new( + base_url: @server.url, max_attempts: 50, backoff_base: 0.05, + backoff_max: 0.05, total_timeout: 0.3 + ) + routes.respond("/01001000", [500, "{}", {}]) + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + assert_raises(Cepis::ServerError) { client.lookup("01001000") } + assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, :<, 3.0 + assert_operator routes.count("/01001000"), :<, 50 + end +end + +class BatchTest < ServerTest + def test_preserves_order_and_marks_missing + body = csv_body([ + "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo," \ + "Sudeste,3550308,1004,11,7107", + "99999999,,,,,,,,,,,,", + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ," \ + "Rio de Janeiro,Sudeste,3304557,,21,6001" + ]) + routes.respond("/csv?ceps=01001000%2C99999999%2C20040020", [200, body, {}]) + result = @client.lookup_many(%w[01001000 99999999 20040020]) + + assert_equal 3, result.length + assert_equal "São Paulo", result[0].localidade + assert_nil result[1] + assert_equal "RJ", result[2].uf + end + + def test_batch_carries_no_coordinate + body = csv_body(["01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1"]) + routes.respond("/csv?ceps=01001000", [200, body, {}]) + + assert_nil @client.lookup_many(["01001000"]).first.geo + end + + def test_field_with_comma_is_parsed + body = csv_body(['01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1']) + routes.respond("/csv?ceps=01001000", [200, body, {}]) + + assert_equal "Rua Um, Dois", @client.lookup_many(["01001000"]).first.logradouro + end + + def test_empty_list_makes_no_request + assert_empty @client.lookup_many([]) + assert_empty routes.calls + end + + def test_chunks_above_the_api_limit + ceps = Array.new(501) { |i| format("%08d", i) } + rows = ceps.map { |cep| "#{cep},,,,,X,SP,São Paulo,Sudeste,1,,11,1" } + routes.handler = lambda do |_path, call| + [200, csv_body(call == 1 ? rows[0, 500] : rows[500..]), {}] + end + result = @client.lookup_many(ceps) + + assert_equal 2, routes.calls.length + assert_equal 501, result.length + end + + def test_truncated_response_does_not_shift + body = csv_body(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"]) + routes.respond("/csv?ceps=01001000%2C20040020", [200, body, {}]) + result = @client.lookup_many(%w[01001000 20040020]) + + assert_equal 2, result.length + assert_nil result[1] + end +end + +class HealthTest < ServerTest + def test_reads_health + body = JSON.generate( + { "status" => "ok", "dne" => "26071", "ceps" => 1_596_168, + "geo" => 894_489, "base_gerada_em" => "2026-07-24T03:48:28Z" } + ) + routes.respond("/health", [200, body, {}]) + health = @client.health + + assert health.ok? + assert_equal "26071", health.dne + assert_equal 1_596_168, health.ceps + end + + def test_degraded_does_not_raise + body = JSON.generate({ "status" => "degradado", "dne" => "", "ceps" => 0, "geo" => 0 }) + routes.respond("/health", [503, body, {}]) + + refute @client.health.ok? + end +end + +class ConfigurationTest < Minitest::Test + def test_trailing_slash_is_not_duplicated + assert_equal "http://x", Cepis::Client.new(base_url: "http://x/").base_url + end + + def test_invalid_parameters + assert_raises(ArgumentError) { Cepis::Client.new(max_attempts: 0) } + assert_raises(ArgumentError) { Cepis::Client.new(timeout: 0) } + end + + def test_user_agent_identifies_the_library + assert_includes Cepis::Client.new.user_agent, "cepis-ruby/#{Cepis::VERSION}" + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..e12bd9d --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "json" +require "minitest/autorun" +require "socket" + +require "cepis" + +# A CEP as the API returns it, used as the fixture across the suite. The field +# names stay Portuguese because they mirror the API's JSON. +SE_ADDRESS = { + "cep" => "01001000", + "logradouro" => "Praça da Sé", + "complemento" => "lado ímpar", + "unidade" => "", + "bairro" => "Sé", + "localidade" => "São Paulo", + "uf" => "SP", + "estado" => "São Paulo", + "regiao" => "Sudeste", + "ibge" => "3550308", + "gia" => "1004", + "ddd" => "11", + "siafi" => "7107" +}.freeze + +CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado," \ + "regiao,ibge,gia,ddd,siafi" + +# Per-test programmable routes, plus a request counter. +class Router + attr_reader :calls + attr_accessor :handler + + def initialize + @routes = {} + @calls = [] + @handler = nil + @lock = Mutex.new + end + + # Register the response for a path. + # + # The response may be a `[status, body, headers]` triple or an array of them + # — in which case each request consumes the next one, which is how retrying + # is tested. The last entry repeats once the list runs out. + def respond(path, response) + @lock.synchronize { @routes[path] = response } + end + + # Record the call and return the response registered for `path`. + def next_response(path) + @lock.synchronize do + @calls << path + return @handler.call(path, @calls.length) if @handler + + response = @routes[path] + return [404, JSON.generate({ "erro" => "cep_nao_encontrado" }), {}] if response.nil? + return response unless response.first.is_a?(Array) + + response.length > 1 ? response.shift : response.first + end + end + + # How many requests so far began with `prefix`. + def count(prefix) + @lock.synchronize { @calls.count { |path| path.start_with?(prefix) } } + end +end + +# A throwaway HTTP/1.1 server on a random loopback port. +# +# Deliberately built on TCPServer rather than on a web server gem: the suite +# must run against a stock Ruby, with nothing installed. +class FakeServer + REASONS = { + 200 => "OK", 400 => "Bad Request", 404 => "Not Found", + 429 => "Too Many Requests", 500 => "Internal Server Error", + 502 => "Bad Gateway", 503 => "Service Unavailable" + }.freeze + + attr_reader :router + + def initialize + @router = Router.new + @server = TCPServer.new("127.0.0.1", 0) + @thread = Thread.new { accept_loop } + end + + def url + "http://127.0.0.1:#{@server.addr[1]}" + end + + def stop + @server.close + @thread.join(5) + end + + private + + def accept_loop + loop do + socket = begin + @server.accept + rescue IOError, Errno::EBADF + break + end + Thread.new(socket) { |client| serve(client) } + end + end + + def serve(socket) + request_line = socket.gets + return if request_line.nil? + + path = request_line.split(" ")[1] + while (line = socket.gets) + break if line == "\r\n" + end + + status, body, headers = @router.next_response(path) + write_response(socket, status, body.to_s.b, headers || {}) + rescue Errno::EPIPE, Errno::ECONNRESET, IOError + nil + ensure + begin + socket.close + rescue IOError + nil + end + end + + def write_response(socket, status, body, headers) + head = +"HTTP/1.1 #{status} #{REASONS.fetch(status, 'Unknown')}\r\n" + headers.each { |key, value| head << "#{key}: #{value}\r\n" } + head << "Content-Type: text/plain; charset=utf-8\r\n" + head << "Content-Length: #{body.bytesize}\r\n" + head << "Connection: close\r\n\r\n" + socket.write(head.b + body) + end +end + +# Fixture with a fake server and a client whose backoff is near zero. +# +# The backoff is tiny because what is under test is the retry logic, not +# `sleep`'s ability to sleep. +class ServerTest < Minitest::Test + def setup + @server = FakeServer.new + @client = Cepis::Client.new( + base_url: @server.url, timeout: 5.0, backoff_base: 0.001, backoff_max: 0.01 + ) + end + + def teardown + @server.stop + end + + def routes + @server.router + end + + def csv_body(rows) + "#{CSV_HEADER}\n#{rows.join("\n")}\n" + end +end |