diff options
| -rw-r--r-- | .gitignore | 8 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 63 | ||||
| -rw-r--r-- | README.md | 253 | ||||
| -rw-r--r-- | cepis-0.1.0-1.rockspec | 46 | ||||
| -rw-r--r-- | cepis.lua | 157 | ||||
| -rw-r--r-- | cepis/client.lua | 609 | ||||
| -rw-r--r-- | cepis/csv.lua | 109 | ||||
| -rw-r--r-- | cepis/errors.lua | 115 | ||||
| -rw-r--r-- | cepis/models.lua | 226 | ||||
| -rw-r--r-- | test/harness.lua | 205 | ||||
| -rw-r--r-- | test/run.lua | 506 | ||||
| -rw-r--r-- | test/server.lua | 182 |
13 files changed, 2494 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..302805c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +build/ +*.luac +*.rock +*.so +.luarocks/ +lua_modules/ +luacov.stats.out +luacov.report.out @@ -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..d3c470d --- /dev/null +++ b/Makefile @@ -0,0 +1,63 @@ +# Development tasks. +# +# make list the targets +# make test run the suite +# make lint luacheck if installed, otherwise luac -p; touches nothing +# make build byte-compile every module into build/ +# +# LUA pins the interpreter. It defaults to `lua`; override it to run the suite +# on another one, e.g. `make test LUA=lua5.4` or `make test LUA=luajit`. +LUA ?= lua +LUAC ?= luac + +SOURCES := cepis.lua $(wildcard cepis/*.lua) +ALL_LUA := $(SOURCES) $(wildcard test/*.lua) + +.DEFAULT_GOAL := help +.PHONY: help test lint format build check clean + +help: + @echo 'Targets:' + @echo ' test run the test suite (LUA=$(LUA))' + @echo ' lint luacheck when installed, else $(LUAC) -p (read-only)' + @echo ' format apply stylua when installed (writes)' + @echo ' build byte-compile every module into build/' + @echo ' check lint + test + build, in that order' + @echo ' clean remove build artefacts' + @echo + @echo 'Override the interpreter with e.g. make test LUA=luajit' + +test: + @$(LUA) test/run.lua + +# Read-only by design, so it is safe in CI and in a pre-commit hook. luacheck +# is the real linter; luac -p is the fallback that needs nothing installed and +# still catches a syntax error before the suite does. +lint: + @if command -v luacheck >/dev/null 2>&1; then \ + luacheck $(ALL_LUA); \ + else \ + echo '==> luacheck not installed, checking syntax with $(LUAC) -p'; \ + $(LUAC) -p $(ALL_LUA) && echo 'syntax ok'; \ + fi + +format: + @if command -v stylua >/dev/null 2>&1; then \ + stylua $(ALL_LUA); \ + else \ + echo '==> stylua not installed, nothing was formatted'; \ + fi + +build: + @mkdir -p build + @for source in $(SOURCES); do \ + $(LUAC) -o "build/$$(echo $$source | tr / _)c" "$$source" || exit 1; \ + done + @echo 'byte-compiled into build/' + +# The order matters: lint is the fastest to fail, so a broken run reports the +# cheapest problem first. +check: lint test build + +clean: + rm -rf build diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed4988c --- /dev/null +++ b/README.md @@ -0,0 +1,253 @@ +# cepis + +Lua client for the [public CEP API at cep.is](https://cep.is). Official +Correios data (DNE), refreshed every two weeks. + +```lua +local cepis = require("cepis") + +local address = cepis.lookup("01001-000") +print(address.logradouro, address.localidade, address.uf) +-- Praça da Sé São Paulo SP +``` + +## Installation + +```sh +luarocks install cepis +``` + +Targets **Lua 5.4**. It also runs unmodified on 5.1, 5.2, 5.3 and LuaJIT — +nothing here needs anything newer, so the compatibility was free. + +> The rockspec points at `git+https://git.cep.is/lua.git` with tag `v0.1.0`. +> That repository is not published yet, so `luarocks install cepis` will not +> resolve until it is. Until then, `luarocks make cepis-0.1.0-1.rockspec` from +> a clone works. + +Three rocks are needed, because Lua's standard library ships neither an HTTP +client nor a JSON parser: + +| rock | why | +| --- | --- | +| `luasocket` | the HTTP client, plus `gettime`/`sleep` for backoff | +| `luasec` | TLS, so the default `https://cep.is` works | +| `dkjson` | JSON, and pure Lua, so no C toolchain is needed to install it | + +CSV is parsed in-tree — a real reader, not `split(",")`, but too small to +justify a fourth dependency. + +`luasec` is loaded lazily, only when `base_url` is `https://`. Pointing at a +local instance over plain HTTP therefore needs just `luasocket` and `dkjson`. + +## Usage + +### A single lookup + +`lookup` accepts the CEP masked or unmasked: + +```lua +local address, err = cepis.lookup("01310-100") + +address.logradouro -- "Avenida Paulista" +address.localidade -- "São Paulo" +address.uf -- "SP" +address.ibge -- "3550308" +address.cep -- "01310100" (always unmasked) +address:formatted_cep() -- "01310-100" +``` + +Fields the database does not have for that CEP come back as empty strings, +never `nil`. `address.raw` holds the decoded response untouched, including a +field the API grew after this release. + +Expected failures are returned, never thrown: + +```lua +local address, err = cepis.lookup(cep_from_form) +if not address then + print(err.kind, err.message) +end +``` + +### Coordinates + +About 57% of CEPs have a coordinate. When they do not, `geo` is `nil`: + +```lua +if address.geo then + print(address.geo.lat, address.geo.lon) -- -23.567202 -46.649659 + print(address.geo.raio_m) -- 1084 + print(address.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 | + +`address.geo:is_precise()` is the shorthand for `precisao == "logradouro"`: + +```lua +local cost +if address.geo and address.geo:is_precise() then + cost = shipping_from(address.geo.lat, address.geo.lon) +else + cost = shipping_from_municipality(address.ibge) +end +``` + +### Many CEPs at once + +Do not loop over `lookup`. Use `lookup_many`, which resolves up to 500 CEPs per +request: + +```lua +local ceps = { "01001000", "99999999", "20040020" } +local result, err = cepis.lookup_many(ceps) + +for index, address in ipairs(result) do + if address then + print(ceps[index], address.localidade) + else + print(ceps[index], "not found") + end +end +``` + +The result has **the same length and the same order** as the input. + +**A CEP that does not exist, or is malformed, is `false` at its position — not +`nil`.** That is not cosmetic. A `nil` inside a Lua sequence truncates it at +the first hole, so `#result` and `ipairs` would stop at the first missing CEP +and silently drop every address after it. `false` keeps the list dense, and +`if address then` still reads as the natural test. + +Lists 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 == 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: + +```lua +local health, err = cepis.health() + +health:ok() -- true +health.dne -- "26071" identifier of the fortnightly database +health.ceps -- 1596168 +health.geo -- 894489 +health.base_gerada_em -- "2026-07-24T03:48:28Z" +``` + +An API reporting itself degraded is not a failure — that arrives in +`health.status`. + +## Configuration + +For anything beyond the defaults, build a client and reuse it. It keeps no +state between calls: + +```lua +local client = cepis.new({ + base_url = "https://cep.is", -- point at another instance + timeout = 10, -- seconds, per socket operation + max_attempts = 3, -- counting the first; 1 disables retrying + backoff_base = 0.5, + backoff_max = 8, + total_timeout = 15, -- cap on the whole call + user_agent = "myapp/1.0", +}) + +local address, err = client:lookup("01001000") +local result = client:lookup_many({ "01001000", "20040020" }) +local health = client:health() +``` + +`timeout` bounds a single socket operation — the connect, and each read — not +the whole call. A call may make several attempts with waits between them; the +cap on all of that is `total_timeout`. + +Retries cover 429, 5xx and network failures, with exponential backoff and full +jitter; 400 and 404 are never retried. + +The module-level functions (`cepis.lookup`, etc.) share a default client, +reachable as `cepis.default()`. It applies process-wide, other libraries +included. Need a different configuration? Build your own. + +### Pointing at a local instance + +`base_url` is the only setting that usually changes between environments. The +API repo brings a local instance up with `just up`: + +```lua +local client = cepis.new({ base_url = "http://localhost:8080" }) +``` + +## Errors + +Every expected failure is returned as `nil, err`. `error()` is raised only for +programmer mistakes — a bad option table, a wrong argument type. + +An error is a table carrying `kind`, `message`, and whichever of `cep`, +`status` and `retry_after` apply. `tostring(err)` yields the message. The kinds +split by **the decision you have to make**, not by the HTTP status that caused +it: + +| `err.kind` | when | does retrying help? | +| --- | --- | --- | +| `cepis.INVALID_CEP` | not 8 digits | no | +| `cepis.NOT_FOUND` | well-formed, not in the database | no | +| `cepis.RATE_LIMITED` | 429; see `err.retry_after` | yes, later | +| `cepis.SERVER_ERROR` | 5xx; see `err.status` | yes, later | +| `cepis.NETWORK_ERROR` | DNS, connection, timeout, TLS | yes, later | +| `cepis.INVALID_RESPONSE` | unintelligible response | probably not | + +```lua +local address, err = cepis.lookup(cep_from_form) +if not address then + if err.kind == cepis.NOT_FOUND then + -- ask the user for another CEP + elseif err:is_retriable() then + -- transient failure: reschedule + retry_in(err.retry_after or 60) + end +end +``` + +The kinds are plain strings, so `err.kind == "not_found"` works too. +`cepis.is_error(value)` tells one of this library's errors apart from anything +else a `nil, err` boundary might hand you. + +`INVALID_RESPONSE` 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`: + +```sh +make # list the targets +make test # run the suite +make lint # luacheck when installed, else luac -p; touches nothing +make format # stylua, when installed (writes) +make build # byte-compile every module into build/ +make check # lint, then test, then build +``` + +The suite stands up a real HTTP server on loopback and points the client at it +through `base_url`; nothing is stubbed and nothing touches the network. Run it +on another interpreter with `make test LUA=luajit`. + +## Licence + +ISC. See [LICENSE](LICENSE). diff --git a/cepis-0.1.0-1.rockspec b/cepis-0.1.0-1.rockspec new file mode 100644 index 0000000..c303f96 --- /dev/null +++ b/cepis-0.1.0-1.rockspec @@ -0,0 +1,46 @@ +rockspec_format = "3.0" +package = "cepis" +version = "0.1.0-1" + +source = { + url = "git+https://git.cep.is/lua.git", + tag = "v0.1.0", +} + +description = { + summary = "Lua client for the public CEP API at cep.is", + detailed = [[ + Looks a Brazilian CEP up against https://cep.is, which serves the official + Correios database (DNE) refreshed every two weeks, with coordinates for + about 57% of them. Single lookups, batches of up to 500 per request, and a + health endpoint. Retries 429 and 5xx with exponential backoff and full + jitter; never retries 400 or 404. + ]], + homepage = "https://cep.is", + license = "ISC", + labels = { "cep", "correios", "dne", "endereco", "brasil", "http" }, +} + +dependencies = { + "lua >= 5.1", + "luasocket >= 3.0", + "luasec >= 1.0", + "dkjson >= 2.5", +} + +build = { + type = "builtin", + modules = { + ["cepis"] = "cepis.lua", + ["cepis.client"] = "cepis/client.lua", + ["cepis.csv"] = "cepis/csv.lua", + ["cepis.errors"] = "cepis/errors.lua", + ["cepis.models"] = "cepis/models.lua", + }, + copy_directories = {}, +} + +test = { + type = "command", + command = "lua test/run.lua", +} diff --git a/cepis.lua b/cepis.lua new file mode 100644 index 0000000..e120af1 --- /dev/null +++ b/cepis.lua @@ -0,0 +1,157 @@ +--- Lua client for the public CEP API at [cep.is](https://cep.is). +-- +-- Quick use — for a script, the module-level functions are enough: +-- +-- local cepis = require("cepis") +-- +-- local address, err = cepis.lookup("01001-000") +-- print(address.localidade, address.uf) --> São Paulo SP +-- +-- In an application — build a client once and reuse it: +-- +-- local client = cepis.new({ timeout = 5, total_timeout = 15 }) +-- local address, err = client:lookup("01001000") +-- +-- Pointing at a local instance (the API repo brings one up with `just up`): +-- +-- local client = cepis.new({ base_url = "http://localhost:8080" }) +-- +-- Expected failures are returned, never thrown, and every error carries a +-- `kind` so the caller branches on one string instead of matching messages: +-- +-- local address, err = cepis.lookup(cep_from_form) +-- if not address then +-- if err.kind == cepis.NOT_FOUND then +-- -- the CEP does not exist: ask the user for another +-- elseif err:is_retriable() then +-- -- network, server, rate limit: reschedule +-- end +-- end +-- +-- `error()` is reserved for programmer mistakes — a bad option table, a wrong +-- argument type. Those are bugs to fix, not conditions to handle. +-- +-- 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 index on the table. +-- +-- The module-level functions share one default client, built on first use. +-- It applies process-wide, including to any other library that also requires +-- this module. Anyone needing different settings should build their own. +-- +-- @module cepis + +local client = require("cepis.client") +local errors = require("cepis.errors") +local models = require("cepis.models") + +local M = {} + +--- Library version. +M._VERSION = client.VERSION + +--- Where the API lives unless `base_url` says otherwise: `https://cep.is`. +M.DEFAULT_URL = client.DEFAULT_URL + +--- How many CEPs the batch endpoint accepts in one request: `500`. +M.MAX_BATCH = client.MAX_BATCH + +--- The `Client` class. `cepis.new` is the shorthand for `Client.new`. +M.Client = client.Client + +--- The `Address` metatable, for `getmetatable(x) == cepis.Address`. +M.Address = models.Address + +--- The `Geo` metatable. +M.Geo = models.Geo + +--- The `Health` metatable. +M.Health = models.Health + +--- Error kind: the CEP is not 8 digits. No request was made. +M.INVALID_CEP = errors.INVALID_CEP + +--- Error kind: well-formed, but absent from the DNE database. +M.NOT_FOUND = errors.NOT_FOUND + +--- Error kind: 429 on every attempt. `err.retry_after` may hold seconds. +M.RATE_LIMITED = errors.RATE_LIMITED + +--- Error kind: 5xx on every attempt. `err.status` holds the last one seen. +M.SERVER_ERROR = errors.SERVER_ERROR + +--- Error kind: the request never completed — DNS, connection, timeout, TLS. +M.NETWORK_ERROR = errors.NETWORK_ERROR + +--- Error kind: a success status with a body that cannot be parsed. +M.INVALID_RESPONSE = errors.INVALID_RESPONSE + +--- Every error kind, keyed by itself. +M.kinds = errors.kinds + +--- Whether a value is one of this library's errors. +-- @param value anything +-- @treturn boolean +M.is_error = errors.is + +--- Build a client. See `cepis.client.Client.new` for every option. +-- @tparam[opt] table opts +-- @treturn Client +M.new = client.Client.new + +--- Return the CEP's 8 digits, accepting the mask. +-- @tparam string value +-- @treturn[1] string +-- @treturn[2] nil +-- @treturn[2] table the error +M.normalize_cep = client.normalize_cep + +local default + +--- The shared client behind the module-level functions. +-- +-- Built on first use with every default. Do not mutate it: it applies +-- process-wide. +-- @treturn Client +function M.default() + if not default then + default = client.Client.new() + end + return default +end + +--- Look up a CEP using the default configuration. +-- +-- Shorthand for `cepis.default():lookup(cep)`. +-- @tparam string cep masked or unmasked +-- @treturn[1] Address +-- @treturn[2] nil +-- @treturn[2] table the error +function M.lookup(cep) + return M.default():lookup(cep) +end + +--- Look up many CEPs in one batch, using the default configuration. +-- +-- Shorthand for `cepis.default():lookup_many(ceps)` — read that one, in +-- particular why a missing CEP is `false` and not `nil`, and the warning that +-- batches carry no coordinate. +-- @tparam table ceps list of strings +-- @treturn[1] table list of `Address|false` +-- @treturn[2] nil +-- @treturn[2] table the error +function M.lookup_many(ceps) + return M.default():lookup_many(ceps) +end + +--- Query the API's health using the default configuration. +-- +-- Shorthand for `cepis.default():health()`. +-- @treturn[1] Health +-- @treturn[2] nil +-- @treturn[2] table the error +function M.health() + return M.default():health() +end + +return M diff --git a/cepis/client.lua b/cepis/client.lua new file mode 100644 index 0000000..2a8ae2c --- /dev/null +++ b/cepis/client.lua @@ -0,0 +1,609 @@ +--- The HTTP client, built on LuaSocket (and LuaSec for `https://`). +-- +-- Each request opens a fresh TCP+TLS connection; nothing is pooled. For a +-- one-off lookup that is irrelevant. For thousands, **do not write a loop** — +-- use `Client:lookup_many`, which resolves up to 500 CEPs per request. A loop +-- of 500 calls pays 500 TLS handshakes; the batch pays one. +-- +-- Retry policy: 429 and the transient 5xx, plus network failures, are retried +-- with exponentially backed-off, jittered waits. 400 and 404 are deliberately +-- absent from that set — both are definitive answers about the request, so +-- retrying returns the same thing more slowly while burning someone's quota. +-- +-- @module cepis.client + +local socket = require("socket") +local http = require("socket.http") +local ltn12 = require("ltn12") +local json = require("dkjson") + +local errors = require("cepis.errors") +local models = require("cepis.models") +local csv = require("cepis.csv") + +local M = {} + +--- Library version, as it appears in the default `User-Agent`. +M.VERSION = "0.1.0" + +--- Where the API lives unless `base_url` says otherwise. +M.DEFAULT_URL = "https://cep.is" + +--- How many CEPs the batch endpoint accepts in one request. +M.MAX_BATCH = 500 + +local ACCEPT = "application/json, text/csv;q=0.9, */*;q=0.1" + +local RETRIABLE_STATUS = { + [429] = true, + [500] = true, + [502] = true, + [503] = true, + [504] = true, +} + +local MONTHS = { + Jan = 1, + Feb = 2, + Mar = 3, + Apr = 4, + May = 5, + Jun = 6, + Jul = 7, + Aug = 8, + Sep = 9, + Oct = 10, + Nov = 11, + Dec = 12, +} + +local ARRAY_MT = {} + +if _VERSION < "Lua 5.4" then + math.randomseed(os.time()) +end + +local function quote(value) + local text = tostring(value) + if #text > 60 then + text = text:sub(1, 60) .. "..." + end + return "'" .. (text:gsub("%c", " ")) .. "'" +end + +local function urlescape(value) + return (value:gsub("[^%w%-%.%_%~]", function(char) + return string.format("%%%02X", string.byte(char)) + end)) +end + +local function utc_to_epoch(fields) + fields.isdst = false + local as_local = os.time(fields) + if not as_local then + return nil + end + local utc = os.date("!*t", as_local) + utc.isdst = false + return as_local + os.difftime(as_local, os.time(utc)) +end + +--- Seconds to wait, from a `Retry-After` header value. +-- +-- RFC 9110 allows two forms: a number of seconds (`"120"`) and 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. +-- +-- @tparam string|nil value the raw header +-- @treturn number|nil seconds, never negative, or `nil` if unparseable +function M.retry_after_seconds(value) + if type(value) ~= "string" then + return nil + end + + value = value:match("^%s*(.-)%s*$") + if value == "" then + return nil + end + + local seconds = tonumber(value) + if seconds then + return math.max(0, seconds) + end + + local day, month, year, hour, minute, second = + value:match("^%a+,%s+(%d+)%s+(%a+)%s+(%d+)%s+(%d+):(%d+):(%d+)%s+GMT$") + if not day then + day, month, year, hour, minute, second = + value:match("^(%d+)%s+(%a+)%s+(%d+)%s+(%d+):(%d+):(%d+)%s+GMT$") + end + if not day or not MONTHS[month] then + return nil + end + + local target = utc_to_epoch({ + year = tonumber(year), + month = MONTHS[month], + day = tonumber(day), + hour = tonumber(hour), + min = tonumber(minute), + sec = tonumber(second), + }) + if not target then + return nil + end + + return math.max(0, target - os.time()) +end + +--- Return the CEP's 8 digits, accepting the mask. +-- +-- `"01001-000"`, `"01001000"` and `" 01001-000 "` all become `"01001000"`. +-- +-- Returns `nil, err` with `err.kind == "invalid_cep"` 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. +-- +-- @tparam string value masked or unmasked +-- @treturn[1] string the 8 digits +-- @treturn[2] nil +-- @treturn[2] table the error +function M.normalize_cep(value) + if type(value) ~= "string" then + return nil, + errors.new(errors.INVALID_CEP, "CEP must be a string, got " .. type(value)) + end + + local cleaned = value:gsub("[%s%.%-]", "") + if not cleaned:match("^%d%d%d%d%d%d%d%d$") then + return nil, + errors.new( + errors.INVALID_CEP, + "CEP must be 8 digits, got " .. quote(value) + ) + end + + return cleaned +end + +local function decode_object(body) + local data = json.decode(body, 1, nil, nil, ARRAY_MT) + if type(data) ~= "table" or getmetatable(data) == ARRAY_MT then + return nil, + errors.new( + errors.INVALID_RESPONSE, + "response is not a JSON object — something between you and the API is " + .. "answering instead of it: " + .. quote(body) + ) + end + return data +end + +local function error_message(body, fallback) + local ok, data = pcall(json.decode, body or "", 1, nil) + if ok and type(data) == "table" and type(data.mensagem) == "string" then + if data.mensagem ~= "" then + return data.mensagem + end + end + return fallback +end + +local function transport(url) + if url:lower():find("^https://") then + local ok, https = pcall(require, "ssl.https") + if not ok then + return nil, + "an https:// base_url needs LuaSec (luarocks install luasec); " + .. "use an http:// base_url to avoid it" + end + return https.request + end + return http.request +end + +--- Client for the cep.is API. +-- +-- Reuse one instance: the configuration is not mutated and no method keeps +-- state between calls. +-- +-- @type Client +-- @field base_url string +-- @field timeout number +-- @field max_attempts number +-- @field backoff_base number +-- @field backoff_max number +-- @field total_timeout number|nil +-- @field user_agent string +local Client = {} +Client.__index = Client +Client.__name = "cepis.Client" + +M.Client = Client + +--- Build a client. +-- +-- Every option has a usable default, so `Client.new()` is valid. A bad option +-- raises through `error()` rather than returning one: it is a mistake in the +-- calling code, not a condition to handle at runtime. +-- +-- @tparam[opt] table opts +-- @tparam[opt="https://cep.is"] string opts.base_url +-- 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 option that usually changes between environments. Trailing +-- slashes are stripped, so it cannot produce doubled separators that some +-- proxies normalise and others 404. +-- @tparam[opt=10] number opts.timeout +-- Seconds to wait **per socket operation** (connect, and each read) — this +-- is not a cap on the whole call. That cap is `total_timeout`. +-- @tparam[opt=3] number opts.max_attempts +-- 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. +-- @tparam[opt=0.5] number opts.backoff_base +-- 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. +-- @tparam[opt=8] number opts.backoff_max +-- 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 caller for an hour. +-- @tparam[opt] number opts.total_timeout +-- 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. +-- @tparam[opt] string opts.user_agent +-- Overrides the `User-Agent`. The default identifies the library and its +-- version, which is what lets the server diagnose an old client +-- misbehaving. +-- @treturn Client +function Client.new(opts) + opts = opts or {} + + local base_url = opts.base_url or M.DEFAULT_URL + local timeout = opts.timeout or 10.0 + local max_attempts = opts.max_attempts or 3 + local backoff_base = opts.backoff_base or 0.5 + local backoff_max = opts.backoff_max or 8.0 + local total_timeout = opts.total_timeout + + if type(base_url) ~= "string" or base_url == "" then + error("base_url must be a non-empty string", 2) + end + if type(timeout) ~= "number" or timeout <= 0 then + error("timeout must be a number > 0", 2) + end + if type(max_attempts) ~= "number" or max_attempts < 1 then + error("max_attempts must be a number >= 1", 2) + end + if type(backoff_base) ~= "number" or backoff_base < 0 then + error("backoff_base must be a number >= 0", 2) + end + if type(backoff_max) ~= "number" or backoff_max < 0 then + error("backoff_max must be a number >= 0", 2) + end + if total_timeout ~= nil and (type(total_timeout) ~= "number" or total_timeout <= 0) then + error("total_timeout must be a number > 0, or nil", 2) + end + if opts.user_agent ~= nil and type(opts.user_agent) ~= "string" then + error("user_agent must be a string", 2) + end + + return setmetatable({ + base_url = (base_url:gsub("/+$", "")), + timeout = timeout, + max_attempts = math.floor(max_attempts), + backoff_base = backoff_base, + backoff_max = backoff_max, + total_timeout = total_timeout, + user_agent = opts.user_agent + or ("cepis-lua/" .. M.VERSION .. " (+https://cep.is)"), + }, Client) +end + +function Client:_perform(url, request) + local chunks = {} + local previous = http.TIMEOUT + http.TIMEOUT = self.timeout + + local called, ok, code, headers = pcall(request, { + url = url, + method = "GET", + headers = { + ["user-agent"] = self.user_agent, + ["accept"] = ACCEPT, + ["accept-encoding"] = "identity", + }, + sink = ltn12.sink.table(chunks), + redirect = false, + }) + + http.TIMEOUT = previous + + if not called then + return nil, tostring(ok) + end + if not ok or type(code) ~= "number" then + return nil, tostring(code) + end + + return code, table.concat(chunks), headers or {} +end + +function Client:_backoff(attempt, retry_after) + if retry_after then + return math.min(retry_after, self.backoff_max) + end + local ceiling = math.min(self.backoff_base * 2 ^ (attempt - 1), self.backoff_max) + return math.random() * ceiling +end + +function Client:_request(path, accepted) + accepted = accepted or { [200] = true } + + local url = self.base_url .. path + + local request, unavailable = transport(url) + if not request then + return nil, errors.new(errors.NETWORK_ERROR, "cannot reach " .. url .. ": " .. unavailable) + end + + local deadline = nil + if self.total_timeout then + deadline = socket.gettime() + self.total_timeout + end + + local last_status, last_retry_after, last_network + + for attempt = 1, self.max_attempts do + if deadline and socket.gettime() >= deadline then + break + end + + local status, body, headers = self:_perform(url, request) + + if status then + if accepted[status] or not RETRIABLE_STATUS[status] then + return status, body, headers + end + last_status = status + last_retry_after = M.retry_after_seconds(headers["retry-after"]) + last_network = nil + else + last_network = body + last_status = nil + last_retry_after = nil + end + + if attempt == self.max_attempts then + break + end + + local wait = self:_backoff(attempt, last_retry_after) + if deadline then + local remaining = deadline - socket.gettime() + if remaining <= 0 then + break + end + if wait > remaining then + wait = remaining + end + end + socket.sleep(wait) + end + + local suffix = string.format(" (after %d attempt(s))", self.max_attempts) + + if last_status == 429 then + return nil, + errors.new(errors.RATE_LIMITED, "rate limit reached at " .. url .. suffix, { + status = 429, + retry_after = last_retry_after, + }) + end + if last_status then + return nil, + errors.new( + errors.SERVER_ERROR, + string.format("the API answered %d at %s%s", last_status, url, suffix), + { status = last_status } + ) + end + return nil, + errors.new( + errors.NETWORK_ERROR, + "could not reach " .. url .. suffix .. ": " .. tostring(last_network) + ) +end + +--- Look up a single CEP. +-- +-- local address, err = client:lookup("01001-000") +-- print(address.localidade, address.uf) --> São Paulo SP +-- +-- A 400 from the API surfaces as `invalid_cep` carrying the API's own message: +-- the format already passed local validation, so the server knows something +-- this client does not. +-- +-- @tparam string cep masked or unmasked +-- @treturn[1] Address +-- @treturn[2] nil +-- @treturn[2] table the error; `kind` is one of `invalid_cep` (not 8 digits — +-- no request is made), `not_found` (well-formed, absent from the database), +-- `rate_limited`, `server_error`, `network_error`, `invalid_response` +function Client:lookup(cep) + local number, invalid = M.normalize_cep(cep) + if not number then + return nil, invalid + end + + local status, body = self:_request("/" .. number) + if not status then + return nil, body + end + + if status == 404 then + return nil, + errors.new( + errors.NOT_FOUND, + "CEP " .. number .. " does not exist in the DNE database", + { cep = number, status = 404 } + ) + end + if status == 400 then + return nil, + errors.new( + errors.INVALID_CEP, + error_message(body, "CEP " .. number .. " rejected by the API"), + { cep = number, status = 400 } + ) + end + + local data, malformed = decode_object(body) + if not data then + return nil, malformed + end + + return models.new_address(data) +end + +function Client:_lookup_chunk(ceps) + local wanted = #ceps + local query = "ceps=" .. urlescape(table.concat(ceps, ",")) + + local status, body = self:_request("/csv?" .. query) + if not status then + return nil, body + end + + local rows = csv.parse(body) + local out = {} + local count = 0 + + if #rows > 0 then + local header = rows[1] + for index = 2, #rows do + if count >= wanted then + break + end + local record = {} + for column = 1, #header do + record[header[column]] = rows[index][column] or "" + end + count = count + 1 + if record.localidade == nil or record.localidade:match("^%s*$") then + out[count] = false + else + out[count] = models.new_address(record) + end + end + end + + for index = count + 1, wanted do + out[index] = false + end + + return out +end + +--- Look up many CEPs in one batch. +-- +-- Returns a list of **the same length and in the same order** as the input. +-- +-- **A CEP that does not exist is `false` at its position, not `nil`.** This is +-- the one place where Lua forces a difference from the other clients in this +-- family, and it is not cosmetic: a `nil` inside a sequence truncates it at the +-- first hole, so `#result` and `ipairs` would silently stop at the first +-- missing CEP and drop every address after it. `false` keeps the sequence +-- dense, and `if address then` still reads as the natural test: +-- +-- local result = client:lookup_many({ "01001000", "99999999", "20040020" }) +-- for index, address in ipairs(result) do +-- if address then +-- print(ceps[index], address.localidade) +-- else +-- print(ceps[index], "not found") +-- end +-- end +-- +-- A malformed CEP is `false` too, for the same reason, rather than failing the +-- call: in a batch of a thousand, one bad CEP must not cost the other nine +-- hundred and ninety-nine. If you need to know *which* one was malformed, +-- validate first with `normalize_cep`. +-- +-- Input is chunked into blocks of 500 (the API's limit) automatically. +-- +-- **The batch carries no coordinate.** The endpoint behind it is CSV, and the +-- CSV has no `geo` column. Every `Address` returned here has `geo == nil`, +-- including for CEPs that do have one. Need `geo`? Use `lookup`, one at a +-- time. +-- +-- @tparam table ceps list of strings +-- @treturn[1] table list of `Address|false`, same length and order as `ceps` +-- @treturn[2] nil +-- @treturn[2] table the error, if the batch request itself failed +function Client:lookup_many(ceps) + if type(ceps) ~= "table" then + error("lookup_many expects a table of CEPs, got " .. type(ceps), 2) + end + + local total = #ceps + if total == 0 then + return {} + end + + local result = {} + local filled = 0 + local start = 1 + + while start <= total do + local stop = math.min(start + M.MAX_BATCH - 1, total) + local chunk = {} + for index = start, stop do + chunk[#chunk + 1] = tostring(ceps[index]) + end + + local rows, err = self:_lookup_chunk(chunk) + if not rows then + return nil, err + end + + for index = 1, #chunk do + filled = filled + 1 + result[filled] = rows[index] + end + start = stop + 1 + end + + return result +end + +--- Query `GET /health`: does the API answer, and with which database. +-- +-- An API reporting itself degraded is not an error — that arrives in +-- `health.status`, and checking health is exactly when you want the diagnosis +-- rather than a failure. Network failures still return `nil, err`. +-- +-- @treturn[1] Health +-- @treturn[2] nil +-- @treturn[2] table the error +function Client:health() + local status, body = self:_request("/health", { [200] = true, [503] = true }) + if not status then + return nil, body + end + + local data, malformed = decode_object(body) + if not data then + return nil, malformed + end + + return models.new_health(data) +end + +return M diff --git a/cepis/csv.lua b/cepis/csv.lua new file mode 100644 index 0000000..be05c9a --- /dev/null +++ b/cepis/csv.lua @@ -0,0 +1,109 @@ +--- A minimal RFC 4180 reader, for the batch endpoint. +-- +-- The values the API sends may contain commas — a `logradouro` such as +-- `Rua Um, Dois` — and are then double-quoted with `""` escaping. Splitting on +-- `,` silently misaligns every column after the first such value, which is why +-- this exists instead of a `gmatch`. +-- +-- @module cepis.csv + +local M = {} + +local BOM = "\239\187\191" + +--- Parse CSV text into a list of rows, each a list of field strings. +-- +-- Handles quoted fields, `""` escapes, embedded commas and newlines, both LF +-- and CRLF line endings, and a leading UTF-8 BOM. Blank lines yield no row. +-- Nothing is coerced: every field comes back as a string. +-- +-- Text between a closing quote and the next delimiter is appended to the field +-- rather than starting a new one: malformed input must not silently gain a +-- column and shift every value after it. +-- +-- @tparam string text the response body +-- @treturn table list of rows; each row is a list of strings +function M.parse(text) + if text:sub(1, 3) == BOM then + text = text:sub(4) + end + + local rows = {} + local row = {} + local count = 0 + local pos = 1 + local size = #text + + local function close_row() + if not (count == 1 and row[1] == "") then + rows[#rows + 1] = row + end + row = {} + count = 0 + end + + while pos <= size do + local field + if text:sub(pos, pos) == '"' then + local parts = {} + pos = pos + 1 + while true do + local quote = text:find('"', pos, true) + if not quote then + parts[#parts + 1] = text:sub(pos) + pos = size + 1 + break + end + parts[#parts + 1] = text:sub(pos, quote - 1) + if text:sub(quote + 1, quote + 1) == '"' then + parts[#parts + 1] = '"' + pos = quote + 2 + else + pos = quote + 1 + break + end + end + local trailing = text:find("[,\r\n]", pos) + if trailing ~= pos then + parts[#parts + 1] = text:sub(pos, (trailing or size + 1) - 1) + pos = trailing or size + 1 + end + field = table.concat(parts) + else + local stop = text:find("[,\r\n]", pos) + if stop then + field = text:sub(pos, stop - 1) + pos = stop + else + field = text:sub(pos) + pos = size + 1 + end + end + + count = count + 1 + row[count] = field + + local char = text:sub(pos, pos) + if char == "," then + pos = pos + 1 + if pos > size then + count = count + 1 + row[count] = "" + close_row() + end + elseif char == "\r" or char == "\n" then + if char == "\r" and text:sub(pos + 1, pos + 1) == "\n" then + pos = pos + 2 + else + pos = pos + 1 + end + close_row() + else + close_row() + end + end + + return rows +end + +return M diff --git a/cepis/errors.lua b/cepis/errors.lua new file mode 100644 index 0000000..0076b95 --- /dev/null +++ b/cepis/errors.lua @@ -0,0 +1,115 @@ +--- Error values returned by the `nil, err` convention. +-- +-- Every failure this library expects to happen is returned, never thrown: +-- `local address, err = client:lookup(cep)`. Only programmer mistakes — a bad +-- option table, a wrong argument type where no sensible value exists — raise +-- through `error()`. +-- +-- An error is a table with a `kind` field, so a caller branches on one string +-- instead of matching messages. The split is by **the decision the caller has +-- to make**, not by the HTTP status that caused it: +-- +-- - `invalid_cep` and `not_found` are problems with the *request*. Retrying is +-- pointless — the same CEP will fail again. +-- - `network_error`, `server_error` and `rate_limited` are problems with +-- *transport*. Retrying may help, and the client already retried on its own +-- before returning any of them. +-- +-- That split is why `not_found` is not "the HTTP 404 error": the client also +-- returns `invalid_cep` without making any request at all, when the CEP is +-- malformed before it ever leaves the machine. +-- +-- @module cepis.errors + +local M = {} + +--- The CEP is not 8 digits. Usually returned without any request being made. +M.INVALID_CEP = "invalid_cep" + +--- The CEP is well-formed but does not exist in the DNE database. +M.NOT_FOUND = "not_found" + +--- The API (or the edge in front of it) answered 429 on every attempt. +M.RATE_LIMITED = "rate_limited" + +--- The API answered 5xx on every attempt. +M.SERVER_ERROR = "server_error" + +--- The request never completed: DNS, refused connection, timeout, TLS. +M.NETWORK_ERROR = "network_error" + +--- 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. +M.INVALID_RESPONSE = "invalid_response" + +--- Every kind, keyed by itself, for validation and for iterating. +M.kinds = { + [M.INVALID_CEP] = true, + [M.NOT_FOUND] = true, + [M.RATE_LIMITED] = true, + [M.SERVER_ERROR] = true, + [M.NETWORK_ERROR] = true, + [M.INVALID_RESPONSE] = true, +} + +local RETRIABLE = { + [M.RATE_LIMITED] = true, + [M.SERVER_ERROR] = true, + [M.NETWORK_ERROR] = true, +} + +--- Metatable of every error this library returns. +-- @type Error +local Error = {} +Error.__index = Error +Error.__name = "cepis.Error" + +function Error.__tostring(self) + return self.message +end + +--- Whether trying the same call again later could plausibly succeed. +-- +-- True for `rate_limited`, `server_error` and `network_error`; false for +-- `invalid_cep`, `not_found` and `invalid_response`. The client has already +-- exhausted its own attempts by the time it returns any of them, so this +-- answers "should I reschedule this work?", not "should I loop right now?". +-- @treturn boolean +function Error:is_retriable() + return RETRIABLE[self.kind] == true +end + +M.Error = Error + +--- Build an error value. +-- +-- @tparam string kind one of the six constants in this module +-- @tparam string message human-readable, already complete +-- @tparam[opt] table fields extra context: `cep`, `status`, `retry_after` +-- @treturn table the error, with `tostring()` yielding `message` +function M.new(kind, message, fields) + local err = setmetatable({}, Error) + if fields then + for key, value in pairs(fields) do + err[key] = value + end + end + err.kind = kind + err.message = message + return err +end + +--- Whether a value is one of this library's errors. +-- +-- Use it to tell a `cepis` error apart from any other value a `nil, err` +-- boundary might hand you. +-- @param value anything +-- @treturn boolean +function M.is(value) + return getmetatable(value) == Error +end + +return M diff --git a/cepis/models.lua b/cepis/models.lua new file mode 100644 index 0000000..61ad678 --- /dev/null +++ b/cepis/models.lua @@ -0,0 +1,226 @@ +--- The data the API returns, as tables. +-- +-- **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. +-- +-- The reason is fidelity: what you read in the JSON at https://cep.is/01001000 +-- is exactly what you index on the table. Translating to `street`/`city` would +-- mean maintaining a field-by-field mapping and forcing anyone reading the API +-- docs alongside this library to translate back in their head. The values are +-- Portuguese either way — they come from the Correios database. +-- +-- @module cepis.models + +local M = {} + +local function text(value) + if value == nil then + return "" + end + if type(value) == "string" then + return value + end + if type(value) == "table" then + return "" + end + return tostring(value) +end + +local function integer(value) + local number = tonumber(value) + if not number then + return 0 + end + if number < 0 then + return -math.floor(-number) + end + return math.floor(number) +end + +--- 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. +-- +-- @type Geo +-- @field lat number +-- @field lon number +-- @field raio_m number +-- @field precisao string `"logradouro"` or `"localidade"` +local Geo = {} +Geo.__index = Geo +Geo.__name = "cepis.Geo" + +--- 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. +-- @treturn boolean +function Geo:is_precise() + return self.precisao == "logradouro" +end + +M.Geo = Geo + +local function build_geo(block) + if type(block) ~= "table" then + return nil + end + + local lat = tonumber(block.lat) + local lon = tonumber(block.lon) + local raio = tonumber(block.raio_m) + local precisao = block.precisao + + if not lat or not lon or not raio then + return nil + end + if precisao == nil or type(precisao) == "table" then + return nil + end + + return setmetatable({ + lat = lat + 0.0, + lon = lon + 0.0, + raio_m = integer(raio), + precisao = tostring(precisao), + }, Geo) +end + +--- A CEP and everything the database knows about it. +-- +-- Fields the database does not have for that CEP come back as empty strings, +-- never `nil` — that is what the API returns, and changing it here would +-- create a difference between reading the JSON and reading this library. +-- +-- `geo` is the exception: it is `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. +-- +-- @type Address +-- @field cep string always the 8 digits, unmasked +-- @field logradouro string +-- @field complemento string +-- @field unidade string +-- @field bairro string +-- @field localidade string +-- @field uf string +-- @field estado string +-- @field regiao string +-- @field ibge string +-- @field gia string +-- @field ddd string +-- @field siafi string +-- @field geo Geo|nil +-- @field raw table the decoded response, untouched +local Address = {} +Address.__index = Address +Address.__name = "cepis.Address" + +--- The CEP with its mask: `01001-000`. +-- +-- The native contract always returns it unmasked; formatting is presentation, +-- and it lives here so it does not become a repeated `string.format` in every +-- project that uses this. +-- @treturn string +function Address:formatted_cep() + if #self.cep ~= 8 then + return self.cep + end + return self.cep:sub(1, 5) .. "-" .. self.cep:sub(6) +end + +M.Address = Address + +local FIELDS = { + "cep", + "logradouro", + "complemento", + "unidade", + "bairro", + "localidade", + "uf", + "estado", + "regiao", + "ibge", + "gia", + "ddd", + "siafi", +} + +--- Build an `Address` from the decoded response. +-- +-- Unknown fields are ignored during construction but preserved in `raw`. A new +-- field in the API therefore cannot break this library. +-- +-- A malformed `geo` block yields `geo = nil` rather than failing the whole +-- call: it is not worth losing the address over, since the rest of the +-- response is still useful. +-- +-- @tparam table data decoded JSON object, or a CSV row keyed by column name +-- @treturn Address +function M.new_address(data) + local address = setmetatable({}, Address) + for _, name in ipairs(FIELDS) do + address[name] = text(data[name]) + end + address.geo = build_geo(data.geo) + address.raw = data + return address +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. +-- +-- @type Health +-- @field status string `"ok"` when the API reports itself healthy +-- @field dne string identifier of the fortnightly database +-- @field ceps number how many CEPs the base holds +-- @field geo number how many of them carry a coordinate +-- @field base_gerada_em string when the base was generated, RFC 3339 +local Health = {} +Health.__index = Health +Health.__name = "cepis.Health" + +--- True when the API reports itself healthy. +-- @treturn boolean +function Health:ok() + return self.status == "ok" +end + +M.Health = Health + +--- Build a `Health` from the decoded response. +-- @tparam table data decoded JSON object +-- @treturn Health +function M.new_health(data) + return setmetatable({ + status = text(data.status), + dne = text(data.dne), + ceps = integer(data.ceps), + geo = integer(data.geo), + base_gerada_em = text(data.base_gerada_em), + raw = data, + }, Health) +end + +return M diff --git a/test/harness.lua b/test/harness.lua new file mode 100644 index 0000000..b6b3a65 --- /dev/null +++ b/test/harness.lua @@ -0,0 +1,205 @@ +--- Assertions, the test registry and control of the fake server. +-- +-- No rock beyond the ones the library already needs: registering a test is +-- `test(name, fn)`, a failure is an `error()` out of an assertion, and the +-- runner exits non-zero if any of them fires. + +local http = require("socket.http") +local ltn12 = require("ltn12") +local socket = require("socket") +local json = require("dkjson") + +local M = {} + +M.tests = {} + +--- Register a test. Order of registration is order of execution. +function M.test(name, fn) + M.tests[#M.tests + 1] = { name = name, fn = fn } +end + +local function fail(message, level) + error(message, (level or 2) + 1) +end + +local function show(value) + if type(value) == "string" then + return string.format("%q", value) + end + return tostring(value) +end + +function M.ok(value, message) + if not value then + fail(message or ("expected a truthy value, got " .. show(value))) + end +end + +function M.falsy(value, message) + if value then + fail(message or ("expected a falsy value, got " .. show(value))) + end +end + +function M.equal(got, want, message) + if got ~= want then + fail( + (message and (message .. ": ") or "") + .. "expected " + .. show(want) + .. ", got " + .. show(got) + ) + end +end + +function M.near(got, want, message) + if type(got) ~= "number" or math.abs(got - want) > 1e-6 then + fail((message and (message .. ": ") or "") .. "expected ~" .. tostring(want) .. ", got " .. show(got)) + end +end + +function M.is_nil(value, message) + if value ~= nil then + fail(message or ("expected nil, got " .. show(value))) + end +end + +function M.is_false(value, message) + if value ~= false then + fail(message or ("expected false, got " .. show(value))) + end +end + +--- Assert a call failed with a given error kind, and return the error. +function M.error_kind(value, err, kind) + if value ~= nil then + fail("expected a failure, got " .. show(value)) + end + if type(err) ~= "table" or err.kind == nil then + fail("expected a cepis error, got " .. show(err)) + end + if err.kind ~= kind then + fail("expected kind " .. show(kind) .. ", got " .. show(err.kind) .. " (" .. tostring(err.message) .. ")") + end + return err +end + +--- Assert a call raised through `error()`, for programmer mistakes. +function M.raises(fn, ...) + local ok = pcall(fn, ...) + if ok then + fail("expected error() to be raised") + end +end + +local function request(url, body) + local chunks = {} + local headers = nil + local source = nil + if body then + source = ltn12.source.string(body) + headers = { + ["content-length"] = tostring(#body), + ["content-type"] = "application/json", + } + end + local ok, code = http.request({ + url = url, + method = body and "POST" or "GET", + source = source, + headers = headers, + sink = ltn12.sink.table(chunks), + }) + if not ok then + error("control request to " .. url .. " failed: " .. tostring(code)) + end + return table.concat(chunks) +end + +local Server = {} +Server.__index = Server + +--- Replace the routes and clear the call log. +function Server:program(routes) + request(self.url .. "/__program", json.encode(routes)) +end + +--- Every request target the server has seen since the last `program`. +function Server:calls() + return json.decode(request(self.url .. "/__calls")) or {} +end + +--- How many of them began with `prefix`. +function Server:count(prefix) + local total = 0 + for _, target in ipairs(self:calls()) do + if target:sub(1, #prefix) == prefix then + total = total + 1 + end + end + return total +end + +function Server:stop() + pcall(request, self.url .. "/__stop") +end + +--- Spawn the fake server and wait for it to report its port. +function M.start_server() + local directory = (arg[0] or "test/run.lua"):match("^(.*)[/\\]") or "." + local portfile = os.tmpname() + os.remove(portfile) + + local interpreter = arg[-1] or "lua" + os.execute( + string.format( + "%s %s/server.lua %s >/dev/null 2>&1 &", + interpreter, + directory, + portfile + ) + ) + + local port + local deadline = socket.gettime() + 10 + while socket.gettime() < deadline do + local handle = io.open(portfile, "r") + if handle then + local content = handle:read("*a") + handle:close() + port = tonumber(content) + if port then + break + end + end + socket.sleep(0.02) + end + + os.remove(portfile) + if not port then + error("the fake server did not start") + end + + return setmetatable({ url = "http://127.0.0.1:" .. port, port = port }, Server) +end + +--- Run every registered test, printing one line each. +function M.run() + local failures = 0 + for _, entry in ipairs(M.tests) do + local ok, err = pcall(entry.fn) + if ok then + print("ok - " .. entry.name) + else + failures = failures + 1 + print("FAIL - " .. entry.name) + print(" " .. tostring(err)) + end + end + + print(string.format("\n%d test(s), %d failure(s)", #M.tests, failures)) + return failures +end + +return M diff --git a/test/run.lua b/test/run.lua new file mode 100644 index 0000000..6220615 --- /dev/null +++ b/test/run.lua @@ -0,0 +1,506 @@ +--- Test suite. +-- +-- Every test drives a real HTTP server over a real socket, pointed at through +-- `base_url`. No test touches the network and none stubs LuaSocket: 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. +-- +-- The fixture field names stay Portuguese because they mirror the API's JSON. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local json = require("dkjson") +local harness = require("test.harness") +local cepis = require("cepis") +local csv = require("cepis.csv") + +local test = harness.test +local equal = harness.equal +local ok = harness.ok +local near = harness.near + +local 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", +} + +local CSV_HEADER = + "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi" + +local function address_json(extra) + local body = {} + for key, value in pairs(SE_ADDRESS) do + body[key] = value + end + for key, value in pairs(extra or {}) do + body[key] = value + end + return json.encode(body) +end + +local function csv_body(rows) + return CSV_HEADER .. "\n" .. table.concat(rows, "\n") .. "\n" +end + +local function once(status, body, headers) + return { responses = { { status = status, body = body, headers = headers } } } +end + +local server = harness.start_server() + +local function client(opts) + opts = opts or {} + opts.base_url = opts.base_url or server.url + if opts.timeout == nil then + opts.timeout = 5 + end + if opts.backoff_base == nil then + opts.backoff_base = 0.001 + end + if opts.backoff_max == nil then + opts.backoff_max = 0.01 + end + return cepis.new(opts) +end + +test("normalize_cep accepts the mask and stray separators", function() + for _, value in ipairs({ "01001000", "01001-000", " 01001-000 ", "01001.000" }) do + equal(cepis.normalize_cep(value), "01001000", value) + end +end) + +test("normalize_cep rejects anything that is not 8 digits", function() + for _, value in ipairs({ "", "123", "0100100a", "010010000", "abcdefgh" }) do + local got, err = cepis.normalize_cep(value) + harness.error_kind(got, err, cepis.INVALID_CEP) + end +end) + +test("normalize_cep rejects a non-string", function() + local got, err = cepis.normalize_cep(1001000) + harness.error_kind(got, err, cepis.INVALID_CEP) +end) + +test("a successful lookup reads the address", function() + server:program({ ["/01001000"] = once(200, address_json()) }) + local address, err = client():lookup("01001-000") + ok(address, tostring(err)) + equal(address.localidade, "São Paulo") + equal(address.uf, "SP") + equal(address.logradouro, "Praça da Sé") + equal(address.cep, "01001000") + equal(address:formatted_cep(), "01001-000") + harness.is_nil(address.geo) +end) + +test("a coordinate is read when present", function() + server:program({ + ["/01310100"] = once( + 200, + address_json({ + geo = { lat = -23.5, lon = -46.6, raio_m = 120, precisao = "logradouro" }, + }) + ), + }) + local address, err = client():lookup("01310100") + ok(address, tostring(err)) + ok(address.geo) + near(address.geo.lat, -23.5) + near(address.geo.lon, -46.6) + equal(address.geo.raio_m, 120) + equal(address.geo:is_precise(), true) +end) + +test("a municipality coordinate is not precise", function() + server:program({ + ["/01001000"] = once( + 200, + address_json({ + geo = { lat = -23.5, lon = -46.6, raio_m = 22000, precisao = "localidade" }, + }) + ), + }) + local address = client():lookup("01001000") + ok(address.geo) + equal(address.geo:is_precise(), false) + equal(address.geo.raio_m, 22000) +end) + +test("a malformed geo block does not lose the address", function() + server:program({ + ["/01001000"] = once(200, address_json({ geo = { lat = "not a number" } })), + }) + local address = client():lookup("01001000") + harness.is_nil(address.geo) + equal(address.localidade, "São Paulo") +end) + +test("a field the API grows is kept in raw", function() + server:program({ + ["/01001000"] = once(200, address_json({ campo_do_futuro = "value" })), + }) + local address = client():lookup("01001000") + equal(address.raw.campo_do_futuro, "value") + equal(address.localidade, "São Paulo") +end) + +test("a missing CEP is not_found", function() + server:program({ + ["/99999999"] = once(404, '{"erro":"cep_nao_encontrado","mensagem":"gone"}'), + }) + local address, err = client():lookup("99999999") + local failure = harness.error_kind(address, err, cepis.NOT_FOUND) + equal(failure.cep, "99999999") + equal(failure:is_retriable(), false) + ok(cepis.is_error(failure)) +end) + +test("404 is not retried", function() + server:program({ ["/99999999"] = once(404, '{"erro":"cep_nao_encontrado"}') }) + local address, err = client():lookup("99999999") + harness.error_kind(address, err, cepis.NOT_FOUND) + equal(server:count("/99999999"), 1) +end) + +test("an invalid CEP never leaves the machine", function() + server:program({}) + local address, err = client():lookup("123") + harness.error_kind(address, err, cepis.INVALID_CEP) + equal(#server:calls(), 0) +end) + +test("a 400 from the API becomes invalid_cep with its message", function() + server:program({ + ["/00000000"] = once(400, '{"erro":"cep_invalido","mensagem":"rejected by the API"}'), + }) + local address, err = client():lookup("00000000") + local failure = harness.error_kind(address, err, cepis.INVALID_CEP) + equal(failure.message, "rejected by the API") + equal(server:count("/00000000"), 1) +end) + +test("a body that is not JSON is invalid_response", function() + server:program({ ["/01001000"] = once(200, "<html>captive portal</html>") }) + local address, err = client():lookup("01001000") + local failure = harness.error_kind(address, err, cepis.INVALID_RESPONSE) + equal(failure:is_retriable(), false) +end) + +test("a JSON array is invalid_response, not a blank address", function() + server:program({ ["/01001000"] = once(200, "[1,2,3]") }) + local address, err = client():lookup("01001000") + harness.error_kind(address, err, cepis.INVALID_RESPONSE) +end) + +test("a 500 is retried and the next attempt succeeds", function() + server:program({ + ["/01001000"] = { + responses = { + { status = 500, body = '{"erro":"interno"}' }, + { status = 200, body = address_json() }, + }, + }, + }) + local address, err = client():lookup("01001000") + ok(address, tostring(err)) + equal(address.localidade, "São Paulo") + equal(server:count("/01001000"), 2) +end) + +test("attempts are exhausted and then it fails", function() + server:program({ ["/01001000"] = once(503, '{"erro":"interno"}') }) + local address, err = client():lookup("01001000") + local failure = harness.error_kind(address, err, cepis.SERVER_ERROR) + equal(failure.status, 503) + equal(failure:is_retriable(), true) + equal(server:count("/01001000"), 3) +end) + +test("max_attempts = 1 disables retrying", function() + server:program({ ["/01001000"] = once(500, "{}") }) + local address, err = client({ max_attempts = 1 }):lookup("01001000") + harness.error_kind(address, err, cepis.SERVER_ERROR) + equal(server:count("/01001000"), 1) +end) + +test("429 becomes rate_limited and exposes Retry-After", function() + server:program({ ["/01001000"] = once(429, "{}", { ["Retry-After"] = "7" }) }) + local address, err = client():lookup("01001000") + local failure = harness.error_kind(address, err, cepis.RATE_LIMITED) + near(failure.retry_after, 7) + equal(failure:is_retriable(), true) +end) + +test("an absurd Retry-After is capped by backoff_max", function() + server:program({ ["/01001000"] = once(429, "{}", { ["Retry-After"] = "3600" }) }) + local started = os.time() + local address, err = client():lookup("01001000") + harness.error_kind(address, err, cepis.RATE_LIMITED) + ok(os.difftime(os.time(), started) < 5, "the call parked for too long") +end) + +test("Retry-After is read in its HTTP-date form too", function() + local future = os.date("!%a, %d %b %Y %H:%M:%S GMT", os.time() + 120) + local seconds = require("cepis.client").retry_after_seconds(future) + ok(seconds, "an HTTP-date Retry-After was not parsed") + ok(seconds > 60 and seconds < 180, "expected roughly 120s, got " .. tostring(seconds)) + harness.is_nil(require("cepis.client").retry_after_seconds("not a date")) +end) + +test("nothing listening is a network_error", function() + local offline = cepis.new({ + base_url = "http://127.0.0.1:1", + max_attempts = 2, + backoff_base = 0.001, + backoff_max = 0.01, + timeout = 1, + }) + local address, err = offline:lookup("01001000") + local failure = harness.error_kind(address, err, cepis.NETWORK_ERROR) + equal(failure:is_retriable(), true) +end) + +test("total_timeout bounds the whole call", function() + server:program({ ["/01001000"] = once(500, "{}") }) + local bounded = client({ + max_attempts = 50, + backoff_base = 0.05, + backoff_max = 0.05, + total_timeout = 0.3, + }) + local started = os.time() + local address, err = bounded:lookup("01001000") + harness.error_kind(address, err, cepis.SERVER_ERROR) + ok(os.difftime(os.time(), started) < 5, "total_timeout did not cut the call short") + ok(server:count("/01001000") < 50, "every attempt ran despite total_timeout") +end) + +test("a batch keeps order and marks the missing entry", function() + server:program({ + ["/csv"] = once( + 200, + 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", + }) + ), + }) + local result, err = client():lookup_many({ "01001000", "99999999", "20040020" }) + ok(result, tostring(err)) + equal(#result, 3) + ok(result[1]) + equal(result[1].localidade, "São Paulo") + harness.is_false(result[2]) + ok(result[3]) + equal(result[3].uf, "RJ") +end) + +test("a missing CEP is false, not a hole that truncates the list", function() + server:program({ + ["/csv"] = once( + 200, + csv_body({ + "99999999,,,,,,,,,,,,", + "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107", + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001", + }) + ), + }) + local result = client():lookup_many({ "99999999", "01001000", "20040020" }) + + equal(#result, 3, "a nil at index 1 would have truncated the sequence") + + local seen = 0 + for _ in ipairs(result) do + seen = seen + 1 + end + equal(seen, 3, "ipairs stopped early, so the list had a hole") + + harness.is_false(result[1]) + ok(result[2]) + equal(result[2].localidade, "São Paulo") + ok(result[3]) +end) + +test("a batch carries no coordinate", function() + server:program({ + ["/csv"] = once( + 200, + csv_body({ + "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107", + }) + ), + }) + local result = client():lookup_many({ "01001000" }) + ok(result[1]) + harness.is_nil(result[1].geo) +end) + +test("a quoted field containing a comma is parsed", function() + server:program({ + ["/csv"] = once( + 200, + csv_body({ '01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1' }) + ), + }) + local result = client():lookup_many({ "01001000" }) + ok(result[1]) + equal(result[1].logradouro, "Rua Um, Dois") +end) + +test("an empty batch makes no request", function() + server:program({}) + local result = client():lookup_many({}) + equal(#result, 0) + equal(#server:calls(), 0) +end) + +test("a batch above 500 is chunked", function() + server:program({ ["/csv"] = { csv_from_query = true, status = 200 } }) + + local ceps = {} + for index = 0, 500 do + ceps[#ceps + 1] = string.format("%08d", index) + end + + local result, err = client():lookup_many(ceps) + ok(result, tostring(err)) + equal(#result, 501) + equal(server:count("/csv"), 2) + ok(result[1]) + ok(result[501]) +end) + +test("a truncated batch response is padded, never shifted", function() + server:program({ + ["/csv"] = once( + 200, + csv_body({ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1" }) + ), + }) + local result = client():lookup_many({ "01001000", "20040020" }) + equal(#result, 2) + ok(result[1]) + harness.is_false(result[2]) +end) + +test("a batch response longer than the request is truncated", function() + server:program({ + ["/csv"] = once( + 200, + csv_body({ + "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", + "20040020,,,,,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,1,,21,1", + }) + ), + }) + local result = client():lookup_many({ "01001000" }) + equal(#result, 1) +end) + +test("health reads the live database identifier", function() + server:program({ + ["/health"] = once( + 200, + json.encode({ + status = "ok", + dne = "26071", + ceps = 1596168, + geo = 894489, + base_gerada_em = "2026-07-24T03:48:28Z", + }) + ), + }) + local health, err = client():health() + ok(health, tostring(err)) + equal(health:ok(), true) + equal(health.dne, "26071") + equal(health.ceps, 1596168) + equal(health.geo, 894489) + equal(health.base_gerada_em, "2026-07-24T03:48:28Z") +end) + +test("a degraded API is a diagnosis, not a failure", function() + server:program({ + ["/health"] = once( + 503, + json.encode({ status = "degradado", dne = "", ceps = 0, geo = 0 }) + ), + }) + local health, err = client():health() + ok(health, tostring(err)) + equal(health:ok(), false) + equal(health.status, "degradado") + equal(server:count("/health"), 1) +end) + +test("a trailing slash on base_url is not duplicated", function() + equal(cepis.new({ base_url = "http://x/" }).base_url, "http://x") + equal(cepis.new({ base_url = "http://x///" }).base_url, "http://x") +end) + +test("a bad option is a programmer mistake and raises", function() + harness.raises(cepis.new, { max_attempts = 0 }) + harness.raises(cepis.new, { timeout = 0 }) + harness.raises(cepis.new, { timeout = -1 }) + harness.raises(cepis.new, { base_url = "" }) + harness.raises(cepis.new, { total_timeout = 0 }) + harness.raises(function() + return cepis.new():lookup_many("not a table") + end) +end) + +test("the default User-Agent identifies the library", function() + local agent = cepis.new().user_agent + ok(agent:find("cepis-lua/", 1, true), agent) + ok(agent:find(cepis._VERSION, 1, true), agent) + equal(cepis.new({ user_agent = "myapp/1.0" }).user_agent, "myapp/1.0") +end) + +test("the defaults are the documented ones", function() + local c = cepis.new() + equal(c.base_url, "https://cep.is") + equal(c.max_attempts, 3) + equal(c.backoff_base, 0.5) + equal(c.backoff_max, 8.0) + harness.is_nil(c.total_timeout) + equal(cepis.MAX_BATCH, 500) + equal(cepis._VERSION, "0.1.0") +end) + +test("the CSV reader handles quotes, escapes and CRLF", function() + local rows = csv.parse('a,b\r\n"x,1","he said ""hi""" \r\n,,\n') + equal(#rows, 3) + equal(rows[1][1], "a") + equal(rows[1][2], "b") + equal(rows[2][1], "x,1") + equal(rows[2][2], 'he said "hi" ') + equal(#rows[3], 3) + equal(rows[3][1], "") +end) + +test("the CSV reader ignores a UTF-8 BOM and blank lines", function() + local rows = csv.parse("\239\187\191a,b\n\nc,d\n") + equal(#rows, 2) + equal(rows[1][1], "a") + equal(rows[2][1], "c") +end) + +local failures = harness.run() +server:stop() +os.exit(failures == 0 and 0 or 1) diff --git a/test/server.lua b/test/server.lua new file mode 100644 index 0000000..a51b8bf --- /dev/null +++ b/test/server.lua @@ -0,0 +1,182 @@ +--- Programmable HTTP server used by the suite, run as a separate process. +-- +-- Lua has no threads, and the client blocks on its socket, so the server +-- cannot share this interpreter with the code under test. It is spawned once +-- per run, binds a free loopback port and writes it to the file named in +-- `arg[1]`, then answers until it is told to stop or nothing has connected for +-- a while. +-- +-- Three control routes drive it from the test process: +-- +-- POST /__program replace the routes and clear the call log +-- GET /__calls the request targets seen so far, as a JSON array +-- GET /__stop shut down +-- +-- A route maps a path (query string excluded) to `{ responses = { ... } }`. +-- Each request consumes the next response; the last one repeats once the list +-- runs out, which is how retrying is tested. A route may instead set +-- `csv_from_query`, and the server then generates one CSV row per CEP in the +-- query — that keeps the 501-CEP chunking test from shipping a body through +-- the control channel. + +local socket = require("socket") +local json = require("dkjson") + +local IDLE_TIMEOUT = 60 + +local REASON = { + [200] = "OK", + [400] = "Bad Request", + [404] = "Not Found", + [429] = "Too Many Requests", + [500] = "Internal Server Error", + [502] = "Bad Gateway", + [503] = "Service Unavailable", +} + +local CSV_HEADER = + "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi" + +local routes = {} +local cursor = {} +local calls = {} +local running = true + +local function unescape(value) + return (value:gsub("%%(%x%x)", function(hex) + return string.char(tonumber(hex, 16)) + end)) +end + +local function read_request(client) + local line = client:receive("*l") + if not line then + return nil + end + + local method, target = line:match("^(%u+)%s+(%S+)") + if not method then + return nil + end + + local headers = {} + while true do + local header = client:receive("*l") + if not header or header == "" then + break + end + local name, value = header:match("^([^:]+):%s*(.*)$") + if name then + headers[name:lower()] = value + end + end + + local body = "" + local length = tonumber(headers["content-length"] or "0") or 0 + if length > 0 then + body = client:receive(length) or "" + end + + return method, target, body +end + +local function send(client, status, body, headers) + local out = { + string.format("HTTP/1.1 %d %s\r\n", status, REASON[status] or "Status"), + } + for name, value in pairs(headers or {}) do + out[#out + 1] = name .. ": " .. tostring(value) .. "\r\n" + end + out[#out + 1] = "Content-Length: " .. #body .. "\r\n" + out[#out + 1] = "Connection: close\r\n\r\n" + out[#out + 1] = body + client:send(table.concat(out)) +end + +local function csv_for(target) + local raw = target:match("[?&]ceps=([^&]*)") or "" + local rows = { CSV_HEADER } + for cep in unescape(raw):gmatch("[^,]+") do + rows[#rows + 1] = cep .. ",,,,,X,SP,São Paulo,Sudeste,1,,11,1" + end + return table.concat(rows, "\n") .. "\n" +end + +local function next_response(path) + local route = routes[path] + if not route then + return nil + end + + local responses = route.responses + if not responses or #responses == 0 then + return route + end + + local index = cursor[path] or 1 + if index < #responses then + cursor[path] = index + 1 + end + return responses[index] +end + +local function serve(client, target, body) + local path = target:match("^([^?]*)") + + if path == "/__program" then + routes = json.decode(body) or {} + cursor = {} + calls = {} + send(client, 200, "{}") + return + end + + if path == "/__calls" then + send(client, 200, json.encode(calls)) + return + end + + if path == "/__stop" then + running = false + send(client, 200, "{}") + return + end + + calls[#calls + 1] = target + + local response = next_response(path) + if not response then + send(client, 404, '{"erro":"cep_nao_encontrado","mensagem":"CEP nao existe"}') + return + end + + if response.csv_from_query then + send(client, response.status or 200, csv_for(target), response.headers) + return + end + + send(client, response.status or 200, response.body or "", response.headers) +end + +local server = assert(socket.bind("127.0.0.1", 0)) +local _, port = server:getsockname() +server:settimeout(IDLE_TIMEOUT) + +local handle = assert(io.open(arg[1], "w")) +handle:write(tostring(port)) +handle:close() + +while running do + local client = server:accept() + if not client then + break + end + client:settimeout(10) + local _, target, body = read_request(client) + if target then + serve(client, target, body) + end + client:close() +end + +server:close() |