From 625cab052606cb3c2a7c4e008c43cf536b6404ce Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 12:13:27 -0300 Subject: Initial import --- .gitignore | 6 + LICENSE | 15 ++ Makefile | 50 +++++ README.md | 219 ++++++++++++++++++ cepis.go | 103 +++++++++ cepis_test.go | 694 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ client.go | 572 +++++++++++++++++++++++++++++++++++++++++++++++ errors.go | 139 ++++++++++++ go.mod | 3 + models.go | 214 ++++++++++++++++++ 10 files changed, 2015 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cepis.go create mode 100644 cepis_test.go create mode 100644 client.go create mode 100644 errors.go create mode 100644 go.mod create mode 100644 models.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9563e6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.exe +*.test +*.out +/bin/ +/dist/ +coverage.html diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5e9104d --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Murilo Ijanc + +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..ab805de --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +# Development tasks. Everything runs through the `go` tool, so there is +# nothing to install first. +# +# make list the targets +# make test run the suite +# make lint gofmt check + go vet, no files touched +# make build compile every package +GO ?= go + +.DEFAULT_GOAL := help +.PHONY: help test test-race lint format build check clean + +help: + @echo 'Targets:' + @echo ' test run the test suite' + @echo ' test-race run it under the race detector' + @echo ' lint gofmt -l + go vet (read-only)' + @echo ' format apply gofmt (writes)' + @echo ' build compile every package' + @echo ' check lint + test + build, in that order' + @echo ' clean remove build and test artefacts' + +test: + $(GO) test ./... + +test-race: + $(GO) test -race -count=1 ./... + +# Read-only by design, so it is safe in CI and in a pre-commit hook: it reports +# what `make format` would change without changing anything. +lint: + @files=$$(gofmt -l .); \ + if [ -n "$$files" ]; then \ + echo 'gofmt would rewrite:'; echo "$$files"; exit 1; \ + fi + $(GO) vet ./... + +format: + gofmt -w . + +build: + $(GO) build ./... + +# The order matters: lint is the fastest to fail, so a broken run reports the +# cheapest problem first. +check: lint test build + +clean: + $(GO) clean -testcache + rm -f coverage.out coverage.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..63f0e96 --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +# cepis + +Go client for the [public CEP API at cep.is](https://cep.is). Official Correios +data (DNE), refreshed every two weeks. No dependencies beyond the standard +library. + +```go +addr, err := cepis.Lookup(ctx, "01001-000") +fmt.Println(addr.Logradouro, addr.Localidade, addr.UF) +// Praça da Sé São Paulo SP +``` + +## Installation + +```sh +go get cep.is/go +``` + +Requires Go 1.21 or newer. + +```go +import cepis "cep.is/go" +``` + +> `cep.is/go` is a vanity import path and needs a `go-import` meta tag served by +> cep.is; that route does not exist yet, so `go get` will not resolve until it +> does. + +## Usage + +### A single lookup + +`Lookup` accepts the CEP masked or unmasked and returns an `*Address`: + +```go +a, err := cepis.Lookup(ctx, "01310-100") + +a.Logradouro // "Avenida Paulista" +a.Localidade // "São Paulo" +a.UF // "SP" +a.IBGE // "3550308" +a.CEP // "01310100" (always unmasked) +a.FormattedCEP() // "01310-100" +``` + +Fields the database does not have for that CEP come back as empty strings, +never null. `a.Field("name")` reads any field of the raw response, including +one the API grew after this release. + +### Coordinates + +About 57% of CEPs have a coordinate. When they do not, `Geo` is nil: + +```go +if a.Geo != nil { + fmt.Println(a.Geo.Lat, a.Geo.Lon) // -23.567202 -46.649659 + fmt.Println(a.Geo.RaioM) // 1084 + fmt.Println(a.Geo.Precisao) // "logradouro" +} +``` + +**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.IsPrecise()` is the shorthand for `Precisao == "logradouro"`: + +```go +if a.Geo != nil && a.Geo.IsPrecise() { + cost = shippingFrom(a.Geo.Lat, a.Geo.Lon) +} else { + cost = shippingFromMunicipality(a.IBGE) +} +``` + +### Many CEPs at once + +Do not loop over `Lookup`. Use `LookupMany`, which resolves up to 500 CEPs per +request: + +```go +ceps := []string{"01001000", "99999999", "20040020"} +result, err := cepis.LookupMany(ctx, ceps) + +for i, addr := range result { + if addr == nil { + fmt.Printf("%s: not found\n", ceps[i]) + } else { + fmt.Printf("%s: %s\n", ceps[i], addr.Localidade) + } +} +``` + +The result has **the same length and the same order** as the input. A CEP that +does not exist, or is malformed, is nil at its position — it does not vanish, +and it is not an error. + +Slices 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 `LookupMany` has a nil `Geo`, +> 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: + +```go +h, err := cepis.CheckHealth(ctx) + +h.OK() // true +h.DNE // "26071" identifier of the fortnightly database +h.CEPs // 1596168 +h.BaseGeradaEm // "2026-07-24T03:48:28Z" +``` + +An API reporting itself degraded is not an error — that arrives in `h.Status`. + +## Configuration + +For anything beyond the defaults, build a `Client` and reuse it. It keeps no +state between calls and is safe to share across goroutines: + +```go +client, err := cepis.New( + cepis.WithBaseURL("https://cep.is"), // point at another instance + cepis.WithTimeout(10*time.Second), // cap on one attempt + cepis.WithMaxAttempts(3), // counting the first; 1 disables retrying + cepis.WithBackoff(500*time.Millisecond, 8*time.Second), + cepis.WithTotalTimeout(15*time.Second), // cap on the whole call + cepis.WithUserAgent("myapp/1.0"), + cepis.WithHTTPClient(&http.Client{}), +) + +addr, err := client.Lookup(ctx, "01001000") +``` + +`WithTimeout` bounds a single attempt — connection, response and body read +together. A call may make several attempts with waits between them; the cap on +all of that is `WithTotalTimeout`. A deadline on the context passed to a call is +honoured too, and the earlier of the two wins. + +Retries cover 429, 5xx and network failures, with exponential backoff and full +jitter; 400 and 404 are never retried. See the [package +documentation](https://pkg.go.dev/cep.is/go) for each option. + +The package-level functions (`cepis.Lookup`, etc.) use a shared default client, +reachable as `cepis.Default()`. It applies process-wide, third-party libraries +included. Need a different configuration? Build your own. + +### Pointing at a local instance + +`WithBaseURL` is the only setting that usually changes between environments. The +API repo brings a local instance up with `just up`: + +```go +client, err := cepis.New(cepis.WithBaseURL("http://localhost:8080")) +``` + +## Errors + +Every error matches `cepis.Err`. 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? | +| --- | --- | --- | +| `ErrInvalidCEP` | not 8 digits | no | +| `ErrCEPNotFound` | well-formed, not in the database | no | +| `ErrRateLimited` | 429 | yes, later | +| `ErrServer` | 5xx | yes, later | +| `ErrNetwork` | DNS, connection, timeout, TLS | yes, later | +| `ErrInvalidResponse` | unintelligible response | probably not | +| `ErrInvalidConfig` | a bad option given to `New` | no | + +```go +addr, err := cepis.Lookup(ctx, cepFromForm) +switch { +case errors.Is(err, cepis.ErrCEPNotFound): + // ask the user for another CEP +case errors.Is(err, cepis.Err): + // transient failure: reschedule +} +``` + +The concrete type behind every one of them is `*cepis.Error`, which carries the +CEP, the HTTP status and the server's `Retry-After`: + +```go +var cerr *cepis.Error +if errors.As(err, &cerr) && errors.Is(cerr, cepis.ErrRateLimited) { + reschedule(cerr.RetryAfter) +} +``` + +`ErrInvalidResponse` 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 test-race # run it under the race detector +make lint # gofmt -l + go vet, touches nothing +make build # compile every package +make check # lint, then test, then build +``` + +## Licence + +ISC. See [LICENSE](LICENSE). diff --git a/cepis.go b/cepis.go new file mode 100644 index 0000000..b596c12 --- /dev/null +++ b/cepis.go @@ -0,0 +1,103 @@ +// Package cepis is a Go client for the public CEP API at https://cep.is. +// Official Correios data (DNE), refreshed every two weeks. +// +// For a script, the package-level functions are enough — they share one +// default [Client] with the default configuration: +// +// addr, err := cepis.Lookup(ctx, "01001-000") +// if err != nil { +// return err +// } +// fmt.Println(addr.Logradouro, addr.Localidade, addr.UF) +// +// In an application, build a client once and reuse it: +// +// client, err := cepis.New( +// cepis.WithTimeout(5*time.Second), +// cepis.WithTotalTimeout(15*time.Second), +// ) +// +// Pointing at a local instance (the API repo brings one up with `just up`): +// +// client, err := cepis.New(cepis.WithBaseURL("http://localhost:8080")) +// +// Errors are split by the decision the caller has to make — whether retrying +// helps or not — and all of them match [Err]: +// +// switch { +// case errors.Is(err, cepis.ErrCEPNotFound): +// // the CEP does not exist: ask the user for another +// case errors.Is(err, cepis.Err): +// // network, server, rate limit: retry later +// } +// +// 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 access on the struct. +package cepis + +import ( + "context" + "strings" +) + +var defaultClient = mustDefault() + +func mustDefault() *Client { + client, err := New() + if err != nil { + panic("cepis: default client is not constructible: " + err.Error()) + } + return client +} + +// Default returns the client behind the package-level functions. It uses the +// default configuration and is safe to share across goroutines. It cannot be +// reconfigured on purpose: it applies process-wide, third-party libraries +// included. Anyone needing different settings should build their own with +// [New]. +func Default() *Client { return defaultClient } + +// Lookup resolves a CEP using the default client. See [Client.Lookup]. +func Lookup(ctx context.Context, cep string) (*Address, error) { + return defaultClient.Lookup(ctx, cep) +} + +// LookupMany resolves many CEPs in one batch using the default client. See +// [Client.LookupMany], especially the note that batches carry no coordinate. +func LookupMany(ctx context.Context, ceps []string) ([]*Address, error) { + return defaultClient.LookupMany(ctx, ceps) +} + +// CheckHealth queries the API's health using the default client. See +// [Client.Health]. It is not called Health because that name belongs to the +// type it returns. +func CheckHealth(ctx context.Context) (*Health, error) { + return defaultClient.Health(ctx) +} + +// NormalizeCEP returns the CEP's 8 digits, accepting the mask: "01001-000", +// "01001000" and " 01001-000 " all become "01001000". +// +// It fails with [ErrInvalidCEP] unless exactly 8 digits remain. Every lookup +// runs it before making a 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. +func NormalizeCEP(value string) (string, error) { + cleaned := strings.Map(func(r rune) rune { + switch r { + case '-', '.', ' ', '\t', '\n', '\r': + return -1 + } + return r + }, value) + + if len(cleaned) != 8 { + return "", newError(ErrInvalidCEP, "CEP must be 8 digits, got %q", value) + } + for _, r := range cleaned { + if r < '0' || r > '9' { + return "", newError(ErrInvalidCEP, "CEP must be 8 digits, got %q", value) + } + } + return cleaned, nil +} diff --git a/cepis_test.go b/cepis_test.go new file mode 100644 index 0000000..888ded5 --- /dev/null +++ b/cepis_test.go @@ -0,0 +1,694 @@ +package cepis_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + cepis "cep.is/go" +) + +const seAddressJSON = `{"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"}` + +const csvHeader = "cep,logradouro,complemento,unidade,bairro,localidade,uf," + + "estado,regiao,ibge,gia,ddd,siafi" + +type stub struct { + status int + body string + header map[string]string +} + +type fakeAPI struct { + mu sync.Mutex + routes map[string][]stub + calls []string + custom func(path string, n int) stub + server *httptest.Server +} + +func newFakeAPI(t *testing.T) *fakeAPI { + t.Helper() + api := &fakeAPI{routes: map[string][]stub{}} + api.server = httptest.NewServer(http.HandlerFunc(api.serve)) + t.Cleanup(api.server.Close) + return api +} + +func (a *fakeAPI) serve(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + a.calls = append(a.calls, r.URL.RequestURI()) + count := len(a.calls) + var answer stub + if a.custom != nil { + answer = a.custom(r.URL.RequestURI(), count) + } else { + answers, ok := a.routes[r.URL.RequestURI()] + switch { + case !ok || len(answers) == 0: + answer = stub{status: 404, body: `{"erro":"cep_nao_encontrado"}`} + case len(answers) == 1: + answer = answers[0] + default: + answer = answers[0] + a.routes[r.URL.RequestURI()] = answers[1:] + } + } + a.mu.Unlock() + + for key, value := range answer.header { + w.Header().Set(key, value) + } + w.WriteHeader(answer.status) + fmt.Fprint(w, answer.body) +} + +func (a *fakeAPI) respond(path string, answers ...stub) { + a.mu.Lock() + defer a.mu.Unlock() + a.routes[path] = answers +} + +func (a *fakeAPI) countPrefix(prefix string) int { + a.mu.Lock() + defer a.mu.Unlock() + total := 0 + for _, call := range a.calls { + if strings.HasPrefix(call, prefix) { + total++ + } + } + return total +} + +func (a *fakeAPI) totalCalls() int { + a.mu.Lock() + defer a.mu.Unlock() + return len(a.calls) +} + +func newClient(t *testing.T, api *fakeAPI, opts ...cepis.Option) *cepis.Client { + t.Helper() + base := []cepis.Option{ + cepis.WithBaseURL(api.server.URL), + cepis.WithTimeout(5 * time.Second), + cepis.WithBackoff(time.Millisecond, 10*time.Millisecond), + } + client, err := cepis.New(append(base, opts...)...) + if err != nil { + t.Fatalf("New: %v", err) + } + return client +} + +func ok(status int, body string) stub { + return stub{status: status, body: body} +} + +func TestNormalizeCEPAcceptsMaskedAndUnmasked(t *testing.T) { + for _, value := range []string{"01001000", "01001-000", " 01001-000 ", "01001.000"} { + got, err := cepis.NormalizeCEP(value) + if err != nil { + t.Fatalf("NormalizeCEP(%q): %v", value, err) + } + if got != "01001000" { + t.Errorf("NormalizeCEP(%q) = %q, want 01001000", value, got) + } + } +} + +func TestNormalizeCEPRejectsBadFormat(t *testing.T) { + for _, value := range []string{"", "123", "0100100a", "010010000", "abcdefgh"} { + if _, err := cepis.NormalizeCEP(value); !errors.Is(err, cepis.ErrInvalidCEP) { + t.Errorf("NormalizeCEP(%q) error = %v, want ErrInvalidCEP", value, err) + } + } +} + +func TestErrorsShareOneRoot(t *testing.T) { + _, err := cepis.NormalizeCEP("123") + if !errors.Is(err, cepis.Err) { + t.Fatalf("error %v does not match cepis.Err", err) + } +} + +func TestLookupSuccess(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(200, seAddressJSON)) + + address, err := newClient(t, api).Lookup(context.Background(), "01001-000") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Localidade != "São Paulo" || address.UF != "SP" { + t.Errorf("got %q/%q, want São Paulo/SP", address.Localidade, address.UF) + } + if address.IBGE != "3550308" || address.DDD != "11" { + t.Errorf("got ibge %q ddd %q", address.IBGE, address.DDD) + } + if address.FormattedCEP() != "01001-000" { + t.Errorf("FormattedCEP = %q", address.FormattedCEP()) + } + if address.Geo != nil { + t.Errorf("Geo = %+v, want nil", address.Geo) + } +} + +func TestLookupSendsHeaders(t *testing.T) { + var agent, accept string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + agent, accept = r.Header.Get("User-Agent"), r.Header.Get("Accept") + fmt.Fprint(w, seAddressJSON) + })) + defer server.Close() + + client, err := cepis.New(cepis.WithBaseURL(server.URL)) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err := client.Lookup(context.Background(), "01001000"); err != nil { + t.Fatalf("Lookup: %v", err) + } + if !strings.HasPrefix(agent, "cepis-go/") { + t.Errorf("User-Agent = %q", agent) + } + if accept == "" { + t.Error("Accept header not sent") + } +} + +func TestLookupReadsTheCoordinate(t *testing.T) { + api := newFakeAPI(t) + body := withGeo(t, `{"lat":-23.5,"lon":-46.6,"raio_m":120,"precisao":"logradouro"}`) + api.respond("/01310100", ok(200, body)) + + address, err := newClient(t, api).Lookup(context.Background(), "01310100") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Geo == nil { + t.Fatal("Geo is nil") + } + if address.Geo.Lat != -23.5 || address.Geo.Lon != -46.6 || address.Geo.RaioM != 120 { + t.Errorf("Geo = %+v", *address.Geo) + } + if !address.Geo.IsPrecise() { + t.Error("IsPrecise = false, want true for precisao=logradouro") + } +} + +func TestLookupMunicipalityCoordinateIsNotPrecise(t *testing.T) { + api := newFakeAPI(t) + body := withGeo(t, `{"lat":-23.5,"lon":-46.6,"raio_m":22000,"precisao":"localidade"}`) + api.respond("/01001000", ok(200, body)) + + address, err := newClient(t, api).Lookup(context.Background(), "01001000") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Geo == nil || address.Geo.IsPrecise() { + t.Errorf("Geo = %+v, want an imprecise coordinate", address.Geo) + } +} + +func TestLookupMalformedGeoDoesNotLoseTheAddress(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(200, withGeo(t, `{"lat":"not a number"}`))) + + address, err := newClient(t, api).Lookup(context.Background(), "01001000") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Geo != nil { + t.Errorf("Geo = %+v, want nil", address.Geo) + } + if address.Localidade != "São Paulo" { + t.Errorf("Localidade = %q, the address was lost", address.Localidade) + } +} + +func TestLookupToleratesUnknownFields(t *testing.T) { + api := newFakeAPI(t) + body := strings.TrimSuffix(seAddressJSON, "}") + `,"campo_do_futuro":"value"}` + api.respond("/01001000", ok(200, body)) + + address, err := newClient(t, api).Lookup(context.Background(), "01001000") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if got := address.Field("campo_do_futuro"); got != "value" { + t.Errorf("Field(campo_do_futuro) = %q, want value", got) + } +} + +func TestLookupNotFound(t *testing.T) { + api := newFakeAPI(t) + api.respond("/99999999", ok(404, `{"erro":"cep_nao_encontrado","mensagem":"gone"}`)) + + _, err := newClient(t, api).Lookup(context.Background(), "99999999") + if !errors.Is(err, cepis.ErrCEPNotFound) { + t.Fatalf("error = %v, want ErrCEPNotFound", err) + } + var cerr *cepis.Error + if !errors.As(err, &cerr) { + t.Fatalf("error %v is not a *cepis.Error", err) + } + if cerr.CEP != "99999999" || cerr.Status != 404 { + t.Errorf("Error = %+v", cerr) + } +} + +func TestLookupNotFoundIsNotRetried(t *testing.T) { + api := newFakeAPI(t) + api.respond("/99999999", ok(404, `{"erro":"cep_nao_encontrado"}`)) + + if _, err := newClient(t, api).Lookup(context.Background(), "99999999"); err == nil { + t.Fatal("expected an error") + } + if got := api.countPrefix("/99999999"); got != 1 { + t.Errorf("%d requests, want 1: a 404 is definitive", got) + } +} + +func TestLookupInvalidCEPNeverLeavesTheMachine(t *testing.T) { + api := newFakeAPI(t) + + _, err := newClient(t, api).Lookup(context.Background(), "123") + if !errors.Is(err, cepis.ErrInvalidCEP) { + t.Fatalf("error = %v, want ErrInvalidCEP", err) + } + if got := api.totalCalls(); got != 0 { + t.Errorf("%d requests made, want 0", got) + } +} + +func TestLookupAPI400BecomesInvalidCEP(t *testing.T) { + api := newFakeAPI(t) + api.respond("/00000000", ok(400, `{"erro":"cep_invalido","mensagem":"rejected by the API"}`)) + + _, err := newClient(t, api).Lookup(context.Background(), "00000000") + if !errors.Is(err, cepis.ErrInvalidCEP) { + t.Fatalf("error = %v, want ErrInvalidCEP", err) + } + if !strings.Contains(err.Error(), "rejected by the API") { + t.Errorf("error %q does not carry the API message", err) + } + if got := api.countPrefix("/00000000"); got != 1 { + t.Errorf("%d requests, want 1: a 400 is definitive", got) + } +} + +func TestLookupResponseThatIsNotJSON(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(200, "captive portal")) + + _, err := newClient(t, api).Lookup(context.Background(), "01001000") + if !errors.Is(err, cepis.ErrInvalidResponse) { + t.Fatalf("error = %v, want ErrInvalidResponse", err) + } +} + +func TestRetriesOn500ThenSucceeds(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(500, `{"erro":"interno"}`), ok(200, seAddressJSON)) + + address, err := newClient(t, api).Lookup(context.Background(), "01001000") + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if address.Localidade != "São Paulo" { + t.Errorf("Localidade = %q", address.Localidade) + } + if got := api.countPrefix("/01001000"); got != 2 { + t.Errorf("%d requests, want 2", got) + } +} + +func TestRetryExhaustsAttempts(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(503, `{"erro":"interno"}`)) + + _, err := newClient(t, api).Lookup(context.Background(), "01001000") + if !errors.Is(err, cepis.ErrServer) { + t.Fatalf("error = %v, want ErrServer", err) + } + var cerr *cepis.Error + if errors.As(err, &cerr); cerr.Status != 503 { + t.Errorf("Status = %d, want 503", cerr.Status) + } + if got := api.countPrefix("/01001000"); got != 3 { + t.Errorf("%d requests, want the documented default of 3", got) + } +} + +func TestMaxAttemptsOneDisablesRetry(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(500, "{}")) + + client := newClient(t, api, cepis.WithMaxAttempts(1)) + if _, err := client.Lookup(context.Background(), "01001000"); !errors.Is(err, cepis.ErrServer) { + t.Fatalf("error = %v, want ErrServer", err) + } + if got := api.countPrefix("/01001000"); got != 1 { + t.Errorf("%d requests, want 1", got) + } +} + +func TestRateLimitedCarriesRetryAfter(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", stub{status: 429, body: "{}", header: map[string]string{"Retry-After": "7"}}) + + _, err := newClient(t, api).Lookup(context.Background(), "01001000") + if !errors.Is(err, cepis.ErrRateLimited) { + t.Fatalf("error = %v, want ErrRateLimited", err) + } + var cerr *cepis.Error + if errors.As(err, &cerr); cerr.RetryAfter != 7*time.Second { + t.Errorf("RetryAfter = %v, want 7s", cerr.RetryAfter) + } +} + +func TestRetryAfterHonoursTheCap(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", stub{status: 429, body: "{}", header: map[string]string{"Retry-After": "3600"}}) + + started := time.Now() + if _, err := newClient(t, api).Lookup(context.Background(), "01001000"); err == nil { + t.Fatal("expected an error") + } + if elapsed := time.Since(started); elapsed > 5*time.Second { + t.Errorf("waited %v: an absurd Retry-After must not park the caller", elapsed) + } +} + +func TestRetryAfterAcceptsAnHTTPDate(t *testing.T) { + api := newFakeAPI(t) + when := time.Now().Add(90 * time.Second).UTC().Format(http.TimeFormat) + api.respond("/01001000", stub{status: 429, body: "{}", header: map[string]string{"Retry-After": when}}) + + started := time.Now() + _, err := newClient(t, api).Lookup(context.Background(), "01001000") + if !errors.Is(err, cepis.ErrRateLimited) { + t.Fatalf("error = %v, want ErrRateLimited", err) + } + var cerr *cepis.Error + errors.As(err, &cerr) + if cerr.RetryAfter < 30*time.Second { + t.Errorf("RetryAfter = %v, the HTTP date form was not parsed", cerr.RetryAfter) + } + if elapsed := time.Since(started); elapsed > 5*time.Second { + t.Errorf("waited %v: the cap must apply to the date form too", elapsed) + } +} + +func TestNetworkErrorWhenNothingListens(t *testing.T) { + api := newFakeAPI(t) + url := api.server.URL + api.server.Close() + + client, err := cepis.New( + cepis.WithBaseURL(url), + cepis.WithMaxAttempts(2), + cepis.WithBackoff(time.Millisecond, 10*time.Millisecond), + cepis.WithTimeout(time.Second), + ) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err := client.Lookup(context.Background(), "01001000"); !errors.Is(err, cepis.ErrNetwork) { + t.Fatalf("error = %v, want ErrNetwork", err) + } +} + +func TestTotalTimeoutBoundsTheWholeCall(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(500, "{}")) + + client := newClient(t, api, + cepis.WithMaxAttempts(50), + cepis.WithBackoff(50*time.Millisecond, 50*time.Millisecond), + cepis.WithTotalTimeout(300*time.Millisecond), + ) + + started := time.Now() + if _, err := client.Lookup(context.Background(), "01001000"); err == nil { + t.Fatal("expected an error") + } + if elapsed := time.Since(started); elapsed > 3*time.Second { + t.Errorf("took %v, the deadline did not cut the run short", elapsed) + } + if got := api.countPrefix("/01001000"); got >= 50 { + t.Errorf("%d requests: the deadline did not cut the run short", got) + } +} + +func TestContextCancellationStopsTheCall(t *testing.T) { + api := newFakeAPI(t) + api.respond("/01001000", ok(500, "{}")) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + client := newClient(t, api, + cepis.WithMaxAttempts(50), + cepis.WithBackoff(30*time.Millisecond, 30*time.Millisecond), + ) + if _, err := client.Lookup(ctx, "01001000"); err == nil { + t.Fatal("expected an error") + } + if got := api.countPrefix("/01001000"); got >= 50 { + t.Errorf("%d requests: the cancelled context was ignored", got) + } +} + +func csvBody(rows ...string) string { + return csvHeader + "\n" + strings.Join(rows, "\n") + "\n" +} + +func TestBatchPreservesOrderAndMarksMissing(t *testing.T) { + api := newFakeAPI(t) + body := csvBody( + "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", + ) + api.respond("/csv?ceps=01001000%2C99999999%2C20040020", ok(200, body)) + + result, err := newClient(t, api).LookupMany( + context.Background(), + []string{"01001000", "99999999", "20040020"}, + ) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if len(result) != 3 { + t.Fatalf("len = %d, want 3", len(result)) + } + if result[0] == nil || result[0].Localidade != "São Paulo" { + t.Errorf("result[0] = %+v", result[0]) + } + if result[1] != nil { + t.Errorf("result[1] = %+v, want nil for a missing CEP", result[1]) + } + if result[2] == nil || result[2].UF != "RJ" { + t.Errorf("result[2] = %+v", result[2]) + } + if result[0].Geo != nil { + t.Error("a batch must never carry a coordinate") + } +} + +func TestBatchParsesQuotedFields(t *testing.T) { + api := newFakeAPI(t) + body := csvBody(`01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1`) + api.respond("/csv?ceps=01001000", ok(200, body)) + + result, err := newClient(t, api).LookupMany(context.Background(), []string{"01001000"}) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if result[0] == nil || result[0].Logradouro != "Rua Um, Dois" { + t.Errorf("result[0] = %+v", result[0]) + } +} + +func TestBatchEmptyListMakesNoRequest(t *testing.T) { + api := newFakeAPI(t) + + result, err := newClient(t, api).LookupMany(context.Background(), nil) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if len(result) != 0 { + t.Errorf("len = %d, want 0", len(result)) + } + if got := api.totalCalls(); got != 0 { + t.Errorf("%d requests made, want 0", got) + } +} + +func TestBatchChunksAboveTheAPILimit(t *testing.T) { + api := newFakeAPI(t) + + ceps := make([]string, 501) + for i := range ceps { + ceps[i] = fmt.Sprintf("%08d", i) + } + row := func(cep string) string { + return cep + ",,,,,X,SP,São Paulo,Sudeste,1,,11,1" + } + first := make([]string, 0, cepis.MaxBatch) + for _, cep := range ceps[:cepis.MaxBatch] { + first = append(first, row(cep)) + } + api.custom = func(_ string, n int) stub { + if n == 1 { + return ok(200, csvBody(first...)) + } + return ok(200, csvBody(row(ceps[cepis.MaxBatch]))) + } + + result, err := newClient(t, api).LookupMany(context.Background(), ceps) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if got := api.totalCalls(); got != 2 { + t.Errorf("%d requests, want 2: 501 CEPs exceed the API limit of 500", got) + } + if len(result) != 501 { + t.Errorf("len = %d, want 501", len(result)) + } + if result[500] == nil || result[500].CEP != ceps[500] { + t.Errorf("result[500] = %+v", result[500]) + } +} + +func TestBatchTruncatedResponseDoesNotShift(t *testing.T) { + api := newFakeAPI(t) + body := csvBody("01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1") + api.respond("/csv?ceps=01001000%2C20040020", ok(200, body)) + + result, err := newClient(t, api).LookupMany( + context.Background(), + []string{"01001000", "20040020"}, + ) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if len(result) != 2 { + t.Fatalf("len = %d, want 2: padding beats shifting", len(result)) + } + if result[1] != nil { + t.Errorf("result[1] = %+v, want nil", result[1]) + } +} + +func TestBatchMalformedCEPBecomesNil(t *testing.T) { + api := newFakeAPI(t) + body := csvBody( + "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", + "nope,,,,,,,,,,,,", + ) + api.respond("/csv?ceps=01001000%2Cnope", ok(200, body)) + + result, err := newClient(t, api).LookupMany(context.Background(), []string{"01001000", "nope"}) + if err != nil { + t.Fatalf("LookupMany: %v", err) + } + if result[1] != nil { + t.Errorf("result[1] = %+v, want nil", result[1]) + } +} + +func TestHealth(t *testing.T) { + api := newFakeAPI(t) + api.respond("/health", ok(200, `{"status":"ok","dne":"26071","ceps":1596168,`+ + `"geo":894489,"base_gerada_em":"2026-07-24T03:48:28Z"}`)) + + health, err := newClient(t, api).Health(context.Background()) + if err != nil { + t.Fatalf("Health: %v", err) + } + if !health.OK() { + t.Error("OK = false") + } + if health.DNE != "26071" || health.CEPs != 1596168 || health.Geo != 894489 { + t.Errorf("Health = %+v", *health) + } + if health.BaseGeradaEm != "2026-07-24T03:48:28Z" { + t.Errorf("BaseGeradaEm = %q", health.BaseGeradaEm) + } +} + +func TestHealthDegradedIsNotAnError(t *testing.T) { + api := newFakeAPI(t) + api.respond("/health", ok(503, `{"status":"degradado","dne":"","ceps":0,"geo":0}`)) + + health, err := newClient(t, api).Health(context.Background()) + if err != nil { + t.Fatalf("Health: %v", err) + } + if health.OK() { + t.Error("OK = true, want false for a degraded API") + } + if got := api.countPrefix("/health"); got != 1 { + t.Errorf("%d requests, want 1: a reported degradation is an answer, not a failure", got) + } +} + +func TestTrailingSlashIsNotDuplicated(t *testing.T) { + client, err := cepis.New(cepis.WithBaseURL("http://example.test/")) + if err != nil { + t.Fatalf("New: %v", err) + } + if client.BaseURL() != "http://example.test" { + t.Errorf("BaseURL = %q", client.BaseURL()) + } +} + +func TestInvalidConfiguration(t *testing.T) { + cases := map[string]cepis.Option{ + "zero attempts": cepis.WithMaxAttempts(0), + "zero timeout": cepis.WithTimeout(0), + "empty base": cepis.WithBaseURL(" "), + "nil http": cepis.WithHTTPClient(nil), + } + for name, opt := range cases { + if _, err := cepis.New(opt); !errors.Is(err, cepis.ErrInvalidConfig) { + t.Errorf("%s: error = %v, want ErrInvalidConfig", name, err) + } + } +} + +func TestDefaultClientIdentifiesTheLibrary(t *testing.T) { + if agent := cepis.Default().UserAgent(); !strings.HasPrefix(agent, "cepis-go/"+cepis.Version) { + t.Errorf("User-Agent = %q", agent) + } + if cepis.Default().BaseURL() != cepis.DefaultBaseURL { + t.Errorf("BaseURL = %q", cepis.Default().BaseURL()) + } +} + +func withGeo(t *testing.T, geo string) string { + t.Helper() + var payload map[string]json.RawMessage + if err := json.Unmarshal([]byte(seAddressJSON), &payload); err != nil { + t.Fatalf("fixture: %v", err) + } + payload["geo"] = json.RawMessage(geo) + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("fixture: %v", err) + } + return string(body) +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..53ada45 --- /dev/null +++ b/client.go @@ -0,0 +1,572 @@ +package cepis + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "errors" + "io" + "math/rand" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Version is the version of this library, as reported in the User-Agent. +const Version = "0.1.0" + +// DefaultBaseURL is where the public API lives. +const DefaultBaseURL = "https://cep.is" + +// MaxBatch is the API's ceiling on CEPs per batch request. [Client.LookupMany] +// splits longer input into chunks of this size on its own. +const MaxBatch = 500 + +const acceptHeader = "application/json, text/csv;q=0.9, */*;q=0.1" + +func isRetriable(status int) bool { + switch status { + case http.StatusTooManyRequests, + http.StatusInternalServerError, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout: + return true + } + return false +} + +// Client talks to the cep.is API. +// +// Build one with [New] and reuse it: the configuration is fixed after +// construction and no method keeps state between calls, so a single Client is +// safe to share across goroutines. Reuse also lets the underlying +// [net/http.Client] pool connections, which a fresh one per call cannot do. +// +// For thousands of CEPs do not write a loop over [Client.Lookup] — use +// [Client.LookupMany], which resolves up to [MaxBatch] of them per request. +type Client struct { + baseURL string + httpClient *http.Client + timeout time.Duration + maxAttempts int + backoffBase time.Duration + backoffMax time.Duration + totalTimeout time.Duration + userAgent string +} + +// Option configures a [Client]. Options are applied in order by [New], and an +// invalid value is reported as an error rather than silently ignored. +type Option func(*Client) error + +// WithBaseURL points the client at another instance of the API — a local one +// for development (the API repo brings one up on http://localhost:8080 with +// `just up`), or a test server. It is the only setting that usually changes +// between environments. A trailing slash is stripped, so it cannot produce the +// doubled separator that some proxies normalise and others answer 404 to. +func WithBaseURL(baseURL string) Option { + return func(c *Client) error { + trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if trimmed == "" { + return newError(ErrInvalidConfig, "base URL must not be empty") + } + c.baseURL = trimmed + return nil + } +} + +// WithTimeout caps a single attempt: connection, response and body read +// together. It is not a cap on the whole call, because a call may make several +// attempts with waits between them — that cap is [WithTotalTimeout]. +func WithTimeout(timeout time.Duration) Option { + return func(c *Client) error { + if timeout <= 0 { + return newError(ErrInvalidConfig, "timeout must be > 0") + } + c.timeout = timeout + return nil + } +} + +// WithMaxAttempts sets how many attempts a call makes 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. +func WithMaxAttempts(attempts int) Option { + return func(c *Client) error { + if attempts < 1 { + return newError(ErrInvalidConfig, "max attempts must be >= 1") + } + c.maxAttempts = attempts + return nil + } +} + +// WithBackoff sets the exponential wait between attempts. +// +// base is the first ceiling: attempt n waits a random duration drawn from +// [0, min(base*2^(n-1), max)] — 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. +// +// max caps each individual wait. 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. +func WithBackoff(base, max time.Duration) Option { + return func(c *Client) error { + if base < 0 || max < 0 { + return newError(ErrInvalidConfig, "backoff must not be negative") + } + c.backoffBase = base + c.backoffMax = max + return nil + } +} + +// WithTotalTimeout caps the whole call, attempts and waits included. Zero +// leaves the attempt count as the only bound. Set it in code serving a web +// request: without it the worst case is max attempts times the per-attempt +// timeout, plus the sum of the backoffs. +// +// A deadline already carried by the context passed to a call is honoured +// regardless, and the earlier of the two wins. +func WithTotalTimeout(timeout time.Duration) Option { + return func(c *Client) error { + if timeout < 0 { + return newError(ErrInvalidConfig, "total timeout must not be negative") + } + c.totalTimeout = timeout + return nil + } +} + +// WithUserAgent overrides the User-Agent header. The default identifies the +// library and its version, which is what lets the server diagnose an old +// client misbehaving. +func WithUserAgent(userAgent string) Option { + return func(c *Client) error { + if strings.TrimSpace(userAgent) == "" { + return newError(ErrInvalidConfig, "user agent must not be empty") + } + c.userAgent = userAgent + return nil + } +} + +// WithHTTPClient supplies the [net/http.Client] used for every request — for a +// custom transport, a proxy, or instrumentation. Its own Timeout field is left +// untouched but is redundant here: this library bounds each attempt through +// the request context. +func WithHTTPClient(httpClient *http.Client) Option { + return func(c *Client) error { + if httpClient == nil { + return newError(ErrInvalidConfig, "http client must not be nil") + } + c.httpClient = httpClient + return nil + } +} + +// New builds a client. With no options it targets the public API with the +// defaults: 10s per attempt, 3 attempts, 0.5s to 8s of jittered backoff and no +// deadline on the call as a whole. +func New(opts ...Option) (*Client, error) { + c := &Client{ + baseURL: DefaultBaseURL, + httpClient: &http.Client{}, + timeout: 10 * time.Second, + maxAttempts: 3, + backoffBase: 500 * time.Millisecond, + backoffMax: 8 * time.Second, + userAgent: "cepis-go/" + Version + " (+https://cep.is)", + } + for _, opt := range opts { + if opt == nil { + continue + } + if err := opt(c); err != nil { + return nil, err + } + } + return c, nil +} + +// BaseURL reports where this client sends its requests, without a trailing +// slash. +func (c *Client) BaseURL() string { return c.baseURL } + +// UserAgent reports the User-Agent this client sends. +func (c *Client) UserAgent() string { return c.userAgent } + +// Lookup resolves a single CEP, masked or unmasked. +// +// addr, err := client.Lookup(ctx, "01001-000") +// addr.Localidade // "São Paulo" +// +// A CEP that is not 8 digits fails with [ErrInvalidCEP] before any request is +// made. One that is well-formed but absent from the database fails with +// [ErrCEPNotFound]. Neither is retried: both are definitive. A 400 from the +// API also becomes [ErrInvalidCEP], carrying the API's own message — the +// format already passed local validation, so the server knows something this +// client does not. +func (c *Client) Lookup(ctx context.Context, cep string) (*Address, error) { + number, err := NormalizeCEP(cep) + if err != nil { + return nil, err + } + + resp, err := c.do(ctx, "/"+number) + if err != nil { + return nil, err + } + + switch resp.status { + case http.StatusNotFound: + return nil, &Error{ + Kind: ErrCEPNotFound, + CEP: number, + Status: resp.status, + Message: "CEP " + number + " does not exist in the DNE database", + } + case http.StatusBadRequest: + return nil, &Error{ + Kind: ErrInvalidCEP, + CEP: number, + Status: resp.status, + Message: apiMessage(resp.body, "CEP "+number+" rejected by the API"), + } + } + + var address Address + if err := json.Unmarshal(resp.body, &address); err != nil { + return nil, &Error{ + Kind: ErrInvalidResponse, + CEP: number, + Status: resp.status, + Message: "response is not valid JSON, something between you and the API is " + + "answering instead of it: " + preview(resp.body), + Cause: err, + } + } + return &address, nil +} + +// LookupMany resolves many CEPs in as few requests as possible. +// +// The result has the same length and the same order as the input. A CEP that +// does not exist is nil at its position — it does not vanish from the slice +// and it is not an error. That is the deliberate difference from +// [Client.Lookup]: in a batch of a thousand, one bad CEP must not cost the +// other nine hundred and ninety-nine. A malformed CEP is nil for the same +// reason; validate with [NormalizeCEP] first if you need to know which one it +// was. +// +// Input longer than [MaxBatch] is chunked automatically. +// +// Batches carry no coordinate. The endpoint behind them is CSV, and the CSV +// has no geo column, so every Address returned here has a nil Geo — including +// the CEPs that do have one. Need Geo? Use [Client.Lookup], one at a time. +func (c *Client) LookupMany(ctx context.Context, ceps []string) ([]*Address, error) { + if len(ceps) == 0 { + return []*Address{}, nil + } + + result := make([]*Address, 0, len(ceps)) + for start := 0; start < len(ceps); start += MaxBatch { + end := start + MaxBatch + if end > len(ceps) { + end = len(ceps) + } + chunk, err := c.lookupChunk(ctx, ceps[start:end]) + if err != nil { + return nil, err + } + result = append(result, chunk...) + } + return result, nil +} + +// Health queries 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 fail. +func (c *Client) Health(ctx context.Context) (*Health, error) { + resp, err := c.do(ctx, "/health", http.StatusOK, http.StatusServiceUnavailable) + if err != nil { + return nil, err + } + + var health Health + if err := json.Unmarshal(resp.body, &health); err != nil { + return nil, &Error{ + Kind: ErrInvalidResponse, + Status: resp.status, + Message: "health response is not valid JSON: " + preview(resp.body), + Cause: err, + } + } + return &health, nil +} + +func (c *Client) lookupChunk(ctx context.Context, ceps []string) ([]*Address, error) { + query := url.Values{} + query.Set("ceps", strings.Join(ceps, ",")) + + resp, err := c.do(ctx, "/csv?"+query.Encode()) + if err != nil { + return nil, err + } + + rows, err := parseCSV(resp.body) + if err != nil { + return nil, &Error{ + Kind: ErrInvalidResponse, + Status: resp.status, + Message: "batch response is not valid CSV: " + preview(resp.body), + Cause: err, + } + } + + if len(rows) > len(ceps) { + rows = rows[:len(ceps)] + } + for len(rows) < len(ceps) { + rows = append(rows, nil) + } + return rows, nil +} + +func parseCSV(body []byte) ([]*Address, error) { + reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(body, []byte("\xef\xbb\xbf")))) + reader.FieldsPerRecord = -1 + + records, err := reader.ReadAll() + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, errors.New("empty CSV: not even a header row") + } + + header := records[0] + rows := make([]*Address, 0, len(records)-1) + for _, record := range records[1:] { + fields := make(map[string]string, len(header)) + for i, name := range header { + if i < len(record) { + fields[name] = record[i] + } else { + fields[name] = "" + } + } + if strings.TrimSpace(fields["localidade"]) == "" { + rows = append(rows, nil) + continue + } + address, err := addressFromFields(fields) + if err != nil { + return nil, err + } + rows = append(rows, address) + } + return rows, nil +} + +func addressFromFields(fields map[string]string) (*Address, error) { + encoded, err := json.Marshal(fields) + if err != nil { + return nil, err + } + var address Address + if err := json.Unmarshal(encoded, &address); err != nil { + return nil, err + } + return &address, nil +} + +type response struct { + status int + body []byte + header http.Header +} + +func (c *Client) do(ctx context.Context, path string, accepted ...int) (*response, error) { + if ctx == nil { + ctx = context.Background() + } + if c.totalTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, c.totalTimeout) + defer cancel() + } + + target := c.baseURL + path + if _, err := url.Parse(target); err != nil { + return nil, &Error{ + Kind: ErrNetwork, + Message: "cannot build a request for " + target, + Cause: err, + } + } + + var ( + lastStatus int + lastRetryAfter time.Duration + hasRetryAfter bool + lastCause error + ) + + for attempt := 1; attempt <= c.maxAttempts; attempt++ { + if err := ctx.Err(); err != nil { + if lastStatus == 0 && lastCause == nil { + lastCause = err + } + break + } + + resp, err := c.attempt(ctx, target) + switch { + case err != nil: + lastCause, lastStatus, lastRetryAfter, hasRetryAfter = err, 0, 0, false + case isAccepted(resp.status, accepted) || !isRetriable(resp.status): + return resp, nil + default: + lastCause, lastStatus = nil, resp.status + lastRetryAfter, hasRetryAfter = retryAfter(resp.header) + } + + if attempt == c.maxAttempts { + break + } + if !sleepContext(ctx, c.backoff(attempt, lastRetryAfter, hasRetryAfter)) { + break + } + } + + attempts := " (after " + strconv.Itoa(c.maxAttempts) + " attempt(s))" + switch { + case lastStatus == http.StatusTooManyRequests: + return nil, &Error{ + Kind: ErrRateLimited, + Status: lastStatus, + RetryAfter: lastRetryAfter, + Message: "rate limit reached at " + target + attempts, + } + case lastStatus != 0: + return nil, &Error{ + Kind: ErrServer, + Status: lastStatus, + Message: "the API answered " + strconv.Itoa(lastStatus) + " at " + target + attempts, + } + default: + return nil, &Error{ + Kind: ErrNetwork, + Message: "could not reach " + target + attempts, + Cause: lastCause, + } + } +} + +func (c *Client) attempt(ctx context.Context, target string) (*response, error) { + ctx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", c.userAgent) + req.Header.Set("Accept", acceptHeader) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return &response{status: resp.StatusCode, body: body, header: resp.Header}, nil +} + +func (c *Client) backoff(attempt int, serverAsked time.Duration, hasRetryAfter bool) time.Duration { + if hasRetryAfter { + if serverAsked > c.backoffMax { + return c.backoffMax + } + return serverAsked + } + ceiling := c.backoffBase << (attempt - 1) + if ceiling > c.backoffMax || ceiling <= 0 { + ceiling = c.backoffMax + } + if ceiling <= 0 { + return 0 + } + return time.Duration(rand.Int63n(int64(ceiling) + 1)) +} + +func isAccepted(status int, accepted []int) bool { + if len(accepted) == 0 { + return status == http.StatusOK + } + for _, candidate := range accepted { + if status == candidate { + return true + } + } + return false +} + +func retryAfter(header http.Header) (time.Duration, bool) { + if header == nil { + return 0, false + } + value := strings.TrimSpace(header.Get("Retry-After")) + if value == "" { + return 0, false + } + if seconds, err := strconv.ParseFloat(value, 64); err == nil { + if seconds <= 0 { + return 0, true + } + return time.Duration(seconds * float64(time.Second)), true + } + target, err := http.ParseTime(value) + if err != nil { + return 0, false + } + wait := time.Until(target) + if wait < 0 { + return 0, true + } + return wait, true +} + +func sleepContext(ctx context.Context, wait time.Duration) bool { + if wait <= 0 { + return ctx.Err() == nil + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-timer.C: + return true + case <-ctx.Done(): + return false + } +} + +func preview(body []byte) string { + const limit = 120 + if len(body) > limit { + return strconv.Quote(string(body[:limit])) + "..." + } + return strconv.Quote(string(body)) +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..98a2385 --- /dev/null +++ b/errors.go @@ -0,0 +1,139 @@ +package cepis + +import ( + "encoding/json" + "errors" + "fmt" + "time" +) + +// Err is the root of every error this package returns. Callers who only care +// that something went wrong can match one value: +// +// if errors.Is(err, cepis.Err) { ... } +// +// Below it, the split is by the decision the caller has to make, not by the +// HTTP status that caused it. [ErrInvalidCEP] and [ErrCEPNotFound] are +// problems with the request, and retrying them is pointless — the same CEP +// fails again. [ErrNetwork], [ErrServer] and [ErrRateLimited] are problems +// with transport, so retrying may help; the client already retried on its own +// before returning any of them. +var Err = errors.New("cepis") + +// The kinds of failure, each wrapping [Err]. Match them with errors.Is: +// +// if errors.Is(err, cepis.ErrCEPNotFound) { ... } +var ( + // ErrInvalidCEP means the CEP is not 8 digits. Usually returned without + // any request being made: the client validates the format before going + // to the network. It is also what a 400 from the API becomes — the + // format passed local validation, so the server knows something this + // client does not. + ErrInvalidCEP = fmt.Errorf("%w: invalid CEP", Err) + + // ErrCEPNotFound means the CEP is well-formed but absent from the DNE + // database. Looking a CEP up is conceptually a key lookup, and this is + // the missing key. + ErrCEPNotFound = fmt.Errorf("%w: CEP not found", Err) + + // ErrRateLimited means the API (or the edge in front of it) answered 429 + // on every attempt. Error.RetryAfter carries what the server asked for. + ErrRateLimited = fmt.Errorf("%w: rate limited", Err) + + // ErrServer means the API answered 5xx on every attempt. Error.Status + // holds the status seen on the last one. + ErrServer = fmt.Errorf("%w: server error", Err) + + // ErrNetwork means the request never completed: DNS, refused connection, + // timeout, TLS. Returned only after every configured attempt failed. + ErrNetwork = fmt.Errorf("%w: network error", Err) + + // ErrInvalidConfig means an [Option] was given a value the client cannot + // work with. Only [New] returns it. + ErrInvalidConfig = fmt.Errorf("%w: invalid configuration", Err) + + // ErrInvalidResponse means a success status came back with a body that + // cannot be parsed. In practice 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. + ErrInvalidResponse = fmt.Errorf("%w: invalid response", Err) +) + +// Error is the concrete type behind every failure in this package. It carries +// the context a caller may need to act on — which CEP, which status, how long +// the server asked to be left alone — and it matches its kind through +// errors.Is: +// +// var cerr *cepis.Error +// if errors.As(err, &cerr) && errors.Is(cerr, cepis.ErrRateLimited) { +// reschedule(cerr.RetryAfter) +// } +type Error struct { + // Kind is one of the package sentinels, and what errors.Is matches. + Kind error + + // CEP is the normalised CEP the call was about, when there was one. + CEP string + + // Status is the last HTTP status seen, or 0 when no response arrived. + Status int + + // RetryAfter is what the server asked for through the Retry-After + // header, or 0 when it did not say. It is reported as received, before + // the client's own cap is applied to it. + RetryAfter time.Duration + + // Message describes the failure without the "cepis: " prefix. + Message string + + // Cause is the underlying error, when one exists — typically the + // net/http error behind [ErrNetwork]. + Cause error +} + +// Error implements the error interface. +func (e *Error) Error() string { + msg := e.Message + if msg == "" { + if e.Kind != nil { + msg = e.Kind.Error() + } else { + msg = Err.Error() + } + } else { + msg = "cepis: " + msg + } + if e.Cause != nil { + return msg + ": " + e.Cause.Error() + } + return msg +} + +// Unwrap exposes both the kind and the underlying cause, so errors.Is matches +// the package sentinels and errors.As reaches a net.Error underneath. +func (e *Error) Unwrap() []error { + switch { + case e.Kind != nil && e.Cause != nil: + return []error{e.Kind, e.Cause} + case e.Kind != nil: + return []error{e.Kind} + case e.Cause != nil: + return []error{e.Cause} + } + return nil +} + +func newError(kind error, format string, args ...any) *Error { + return &Error{Kind: kind, Message: fmt.Sprintf(format, args...)} +} + +func apiMessage(body []byte, fallback string) string { + var payload struct { + Mensagem string `json:"mensagem"` + } + if err := json.Unmarshal(body, &payload); err == nil && payload.Mensagem != "" { + return payload.Mensagem + } + return fallback +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cefbef7 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module cep.is/go + +go 1.21 diff --git a/models.go b/models.go new file mode 100644 index 0000000..3d21bf8 --- /dev/null +++ b/models.go @@ -0,0 +1,214 @@ +package cepis + +import ( + "encoding/json" + "strconv" +) + +// Geo is 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. RaioM 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. RaioM runs from 7 km to 40 km. +// Computing shipping from this is wrong by tens of kilometres. +// +// RaioM is the radius, in metres, containing 90% of that CEP's addresses. It +// exists precisely so that error cannot pass unnoticed. +type Geo struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + RaioM int `json:"raio_m"` + Precisao string `json:"precisao"` +} + +// IsPrecise reports whether the coordinate points at a street rather than at a +// whole municipality. Shorthand for Precisao == "logradouro", which is the +// check to make before using the point to measure distance. +func (g Geo) IsPrecise() bool { + return g.Precisao == "logradouro" +} + +// Address is a CEP and everything the database knows about it. +// +// The field names are the API's own, in Portuguese, with only the casing +// adapted to Go: what you read in the JSON at https://cep.is/01001000 is what +// you access here. The values are Portuguese either way — they come from the +// Correios database. +// +// Fields the database does not have for that CEP come back as empty strings, +// never null. Geo is the exception: it is nil when there is no coordinate, +// because the API omits the whole object 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 struct { + CEP string `json:"cep"` + Logradouro string `json:"logradouro"` + Complemento string `json:"complemento"` + Unidade string `json:"unidade"` + Bairro string `json:"bairro"` + Localidade string `json:"localidade"` + UF string `json:"uf"` + Estado string `json:"estado"` + Regiao string `json:"regiao"` + IBGE string `json:"ibge"` + GIA string `json:"gia"` + DDD string `json:"ddd"` + Siafi string `json:"siafi"` + Geo *Geo `json:"geo,omitempty"` + + // Raw is the response as it arrived, field by field, including fields + // this version of the library does not know about. + Raw map[string]json.RawMessage `json:"-"` +} + +// FormattedCEP returns 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 Sprintf in every project. +func (a *Address) FormattedCEP() string { + if len(a.CEP) != 8 { + return a.CEP + } + return a.CEP[:5] + "-" + a.CEP[5:] +} + +// Field returns a field of the raw response as a string, "" when the API did +// not send it. Use it to read a field the API grew after this library was +// released, without waiting for a new version. +func (a *Address) Field(name string) string { + if a.Raw == nil { + return "" + } + return rawString(a.Raw[name]) +} + +// UnmarshalJSON decodes the API's JSON leniently. +// +// Unknown fields are ignored during decoding but preserved in Raw, so a new +// field in the API cannot break this library. A malformed geo block yields a +// nil Geo rather than failing the whole document: the rest of the address is +// still useful and it is not worth losing over a coordinate. +func (a *Address) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + *a = Address{ + CEP: rawString(raw["cep"]), + Logradouro: rawString(raw["logradouro"]), + Complemento: rawString(raw["complemento"]), + Unidade: rawString(raw["unidade"]), + Bairro: rawString(raw["bairro"]), + Localidade: rawString(raw["localidade"]), + UF: rawString(raw["uf"]), + Estado: rawString(raw["estado"]), + Regiao: rawString(raw["regiao"]), + IBGE: rawString(raw["ibge"]), + GIA: rawString(raw["gia"]), + DDD: rawString(raw["ddd"]), + Siafi: rawString(raw["siafi"]), + Geo: decodeGeo(raw["geo"]), + Raw: raw, + } + return nil +} + +func decodeGeo(block json.RawMessage) *Geo { + if len(block) == 0 { + return nil + } + var probe struct { + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` + RaioM *int `json:"raio_m"` + Precisao *string `json:"precisao"` + } + if err := json.Unmarshal(block, &probe); err != nil { + return nil + } + if probe.Lat == nil || probe.Lon == nil || probe.RaioM == nil || probe.Precisao == nil { + return nil + } + return &Geo{ + Lat: *probe.Lat, + Lon: *probe.Lon, + RaioM: *probe.RaioM, + Precisao: *probe.Precisao, + } +} + +func rawString(value json.RawMessage) string { + if len(value) == 0 { + return "" + } + var text string + if err := json.Unmarshal(value, &text); err == nil { + return text + } + var number json.Number + if err := json.Unmarshal(value, &number); err == nil { + return number.String() + } + return "" +} + +// Health is the answer 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 struct { + Status string `json:"status"` + DNE string `json:"dne"` + CEPs int64 `json:"ceps"` + Geo int64 `json:"geo"` + BaseGeradaEm string `json:"base_gerada_em"` + + // Raw is the response as it arrived, field by field. + Raw map[string]json.RawMessage `json:"-"` +} + +// OK reports whether the API describes itself as healthy. +func (h *Health) OK() bool { + return h.Status == "ok" +} + +// UnmarshalJSON decodes the health payload leniently, keeping the response in +// Raw and tolerating fields this version does not know about. +func (h *Health) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *h = Health{ + Status: rawString(raw["status"]), + DNE: rawString(raw["dne"]), + CEPs: rawInt(raw["ceps"]), + Geo: rawInt(raw["geo"]), + BaseGeradaEm: rawString(raw["base_gerada_em"]), + Raw: raw, + } + return nil +} + +func rawInt(value json.RawMessage) int64 { + text := rawString(value) + if text == "" { + return 0 + } + number, err := strconv.ParseFloat(text, 64) + if err != nil { + return 0 + } + return int64(number) +} -- cgit v1.3