summaryrefslogtreecommitdiffstats
path: root/internal/lookup
diff options
context:
space:
mode:
Diffstat (limited to 'internal/lookup')
-rw-r--r--internal/lookup/lookup.go248
-rw-r--r--internal/lookup/lookup_test.go279
2 files changed, 0 insertions, 527 deletions
diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go
deleted file mode 100644
index 956a731..0000000
--- a/internal/lookup/lookup.go
+++ /dev/null
@@ -1,248 +0,0 @@
-// Package lookup resolves a CEP through the cep.is API.
-//
-// It is a thin layer over the official client, and it exists for one thing the
-// client does not do: a 404 from GET /{cep} may carry a `faixa` block naming
-// the municipality that CEP would belong to, and cep.is/go v0.1.0 turns the
-// response into [cepis.ErrCEPNotFound] without keeping anything from the body.
-// This package puts that block back, as [NotFoundError.Faixa].
-//
-// Everything about it is best-effort. The municipality is context on top of an
-// error that is already complete without it, so every failure along the way —
-// no body, malformed JSON, a slow second request — ends as a plain
-// ErrCEPNotFound, exactly as if the API had never sent the block.
-package lookup
-
-import (
- "context"
- "encoding/json"
- "errors"
- "io"
- "net/http"
- "strings"
- "time"
-
- cepis "cep.is/go"
-)
-
-// defaultFaixaTimeout caps the extra request. It is deliberately short and
-// deliberately not the timeout of the lookup itself: the CEP is already known
-// not to exist, the answer is already writable, and nobody should wait longer
-// for the footnote than for the answer.
-const defaultFaixaTimeout = 2 * time.Second
-
-// maxFaixaBody is how much of the 404 body is read. The real one is around 200
-// bytes; anything past this is not the API answering.
-const maxFaixaBody = 64 << 10
-
-// Faixa is the municipality a CEP that does not exist would fall in, together
-// with the range of CEPs that municipality actually has.
-//
-// A CEP that fails is rarely a random number: it is usually someone who knows
-// the city and guessed the rest, and the classic guess is the "-000" that 957
-// municipalities never received. Areal/RJ answers from 25845-001 up and never
-// at 25845-000, so the 404 for 25845000 names Areal.
-//
-// CEPIni and CEPFim are the smallest and the largest CEP that EXIST there, not
-// the bounds the Correios publish — the DNE's own start for Areal is the
-// 25845000 that just failed. The range is sparse, which is what CEPs is for:
-// Areal's 118 CEPs are scattered over nearly five thousand numbers. Never
-// present the pair as a list of valid CEPs.
-type Faixa struct {
- Localidade string `json:"localidade"`
- UF string `json:"uf"`
- Estado string `json:"estado"`
- Regiao string `json:"regiao"`
- IBGE string `json:"ibge"`
- CEPIni string `json:"cep_ini"`
- CEPFim string `json:"cep_fim"`
-
- // CEPs is how many CEPs the municipality has inside the range.
- CEPs int `json:"ceps"`
-}
-
-// Place is the municipality as it is written to a user: "Areal/RJ", or just
-// "Areal" when the API sent no UF.
-func (f *Faixa) Place() string {
- if f == nil {
- return ""
- }
- if f.UF == "" {
- return f.Localidade
- }
- return f.Localidade + "/" + f.UF
-}
-
-// NotFoundError is [cepis.ErrCEPNotFound] with the municipality attached. It
-// matches the sentinel through errors.Is, so a caller that does not know about
-// this type keeps behaving exactly as before:
-//
-// if errors.Is(err, cepis.ErrCEPNotFound) { ... }
-type NotFoundError struct {
- // Faixa is the municipality the CEP would belong to. It is never nil in
- // an error this package builds: without a faixa there is nothing to add
- // and the underlying error is returned untouched.
- Faixa *Faixa
-
- // Err is the error from the client library, wrapped unchanged.
- Err error
-}
-
-// Error implements the error interface, reporting the wrapped failure verbatim:
-// the faixa is for the user, not for the log line.
-func (e *NotFoundError) Error() string {
- if e.Err != nil {
- return e.Err.Error()
- }
- return cepis.ErrCEPNotFound.Error()
-}
-
-// Unwrap keeps errors.Is(err, cepis.ErrCEPNotFound) true.
-func (e *NotFoundError) Unwrap() error {
- if e.Err != nil {
- return e.Err
- }
- return cepis.ErrCEPNotFound
-}
-
-// FaixaOf returns the municipality carried by err, or nil when there is none.
-// Any error is safe to pass, including nil.
-func FaixaOf(err error) *Faixa {
- var notFound *NotFoundError
- if errors.As(err, &notFound) {
- return notFound.Faixa
- }
- return nil
-}
-
-// Client resolves CEPs. It is safe for concurrent use, as the client it wraps
-// is.
-type Client struct {
- api *cepis.Client
- httpClient *http.Client
- timeout time.Duration
-}
-
-// Option configures a [Client].
-type Option func(*Client)
-
-// WithTimeout caps the extra request that reads the faixa off a 404.
-func WithTimeout(timeout time.Duration) Option {
- return func(c *Client) {
- if timeout > 0 {
- c.timeout = timeout
- }
- }
-}
-
-// WithHTTPClient supplies the [net/http.Client] used for that request.
-func WithHTTPClient(httpClient *http.Client) Option {
- return func(c *Client) {
- if httpClient != nil {
- c.httpClient = httpClient
- }
- }
-}
-
-// New wraps an already configured cep.is client.
-func New(api *cepis.Client, opts ...Option) *Client {
- c := &Client{
- api: api,
- httpClient: &http.Client{},
- timeout: defaultFaixaTimeout,
- }
- for _, opt := range opts {
- if opt != nil {
- opt(c)
- }
- }
- return c
-}
-
-// Lookup resolves a single CEP.
-//
-// It behaves exactly like [cepis.Client.Lookup], except that a CEP absent from
-// the DNE may come back as a [*NotFoundError] naming the municipality. That is
-// still a failure and still matches cepis.ErrCEPNotFound; nothing about it says
-// an address was found.
-func (c *Client) Lookup(ctx context.Context, cep string) (*cepis.Address, error) {
- address, err := c.api.Lookup(ctx, cep)
- if err == nil || !errors.Is(err, cepis.ErrCEPNotFound) {
- return address, err
- }
-
- faixa := c.faixa(ctx, cep)
- if faixa == nil {
- return nil, err
- }
- return nil, &NotFoundError{Faixa: faixa, Err: err}
-}
-
-// faixa asks for the CEP once more, only to read the block the library dropped.
-//
-// One extra GET is the price of not forking the client. It is bounded to the
-// 404 path, which is definitive and never retried, so the request cannot land
-// in a retry loop; and it returns nil for every problem it meets, because the
-// caller already has a complete answer to send without it.
-//
-// Delete this the day cep.is/go exposes the faixa itself.
-func (c *Client) faixa(ctx context.Context, cep string) *Faixa {
- number, err := cepis.NormalizeCEP(cep)
- if err != nil {
- return nil
- }
-
- ctx, cancel := context.WithTimeout(ctx, c.timeout)
- defer cancel()
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.api.BaseURL()+"/"+number, nil)
- if err != nil {
- return nil
- }
- req.Header.Set("User-Agent", c.api.UserAgent())
- req.Header.Set("Accept", "application/json")
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil
- }
- defer resp.Body.Close()
-
- // Anything but a second 404 means the answer changed under us — a CEP
- // that now exists, an outage, a captive portal. None of those is a
- // municipality.
- if resp.StatusCode != http.StatusNotFound {
- return nil
- }
-
- body, err := io.ReadAll(io.LimitReader(resp.Body, maxFaixaBody))
- if err != nil {
- return nil
- }
- return decodeFaixa(body)
-}
-
-// decodeFaixa reads the `faixa` block of a 404 body. Every failure yields nil:
-// an absent block (the API omits it, never sends null), a body that is not
-// JSON, a block that names no municipality. A field the API grows later is
-// ignored rather than fatal, which is what encoding/json does by default.
-func decodeFaixa(body []byte) *Faixa {
- if len(body) == 0 {
- return nil
- }
-
- var envelope struct {
- Faixa *Faixa `json:"faixa"`
- }
- if err := json.Unmarshal(body, &envelope); err != nil {
- return nil
- }
-
- faixa := envelope.Faixa
- if faixa == nil || strings.TrimSpace(faixa.Localidade) == "" {
- return nil
- }
- if faixa.CEPs < 0 {
- faixa.CEPs = 0
- }
- return faixa
-}
diff --git a/internal/lookup/lookup_test.go b/internal/lookup/lookup_test.go
deleted file mode 100644
index 6eb7b4e..0000000
--- a/internal/lookup/lookup_test.go
+++ /dev/null
@@ -1,279 +0,0 @@
-package lookup
-
-import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
- "time"
-
- cepis "cep.is/go"
-)
-
-const areal404 = `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE",
- "faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro","regiao":"Sudeste",
- "ibge":"3300225","cep_ini":"25845001","cep_fim":"25849899","ceps":118}}`
-
-const plain404 = `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}`
-
-const address200 = `{"cep":"01001000","logradouro":"Praça da Sé","bairro":"Sé",
- "localidade":"São Paulo","uf":"SP","estado":"São Paulo","regiao":"Sudeste","ddd":"11"}`
-
-// fakeAPI answers every request with the same status and body, and counts how
-// many arrived: the count is what proves the extra request happens only on the
-// 404 path.
-type fakeAPI struct {
- server *httptest.Server
- requests atomic.Int64
-
- // delay is applied from the delayFrom-th request on, which is how the
- // second request — the one that reads the faixa — is made slow without
- // slowing the lookup that precedes it.
- delay time.Duration
- delayFrom int64
-}
-
-func newFakeAPI(t *testing.T, status int, body string) *fakeAPI {
- t.Helper()
- f := &fakeAPI{delayFrom: 1}
- f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- n := f.requests.Add(1)
- if f.delay > 0 && n >= f.delayFrom {
- select {
- case <-time.After(f.delay):
- case <-r.Context().Done():
- return
- }
- }
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- w.WriteHeader(status)
- w.Write([]byte(body))
- }))
- t.Cleanup(f.server.Close)
- return f
-}
-
-func newClient(t *testing.T, f *fakeAPI, opts ...Option) *Client {
- t.Helper()
- api, err := cepis.New(
- cepis.WithBaseURL(f.server.URL),
- cepis.WithMaxAttempts(1),
- cepis.WithTimeout(2*time.Second),
- )
- if err != nil {
- t.Fatalf("cepis.New: %v", err)
- }
- return New(api, opts...)
-}
-
-func TestNotFoundCarriesTheMunicipality(t *testing.T) {
- f := newFakeAPI(t, http.StatusNotFound, areal404)
- _, err := newClient(t, f).Lookup(context.Background(), "25845-000")
-
- if !errors.Is(err, cepis.ErrCEPNotFound) {
- t.Fatalf("err = %v, want a not-found error", err)
- }
- faixa := FaixaOf(err)
- if faixa == nil {
- t.Fatal("the faixa on the 404 was dropped")
- }
- if faixa.Place() != "Areal/RJ" {
- t.Fatalf("place = %q, want Areal/RJ", faixa.Place())
- }
- if faixa.CEPs != 118 {
- t.Fatalf("ceps = %d, want 118", faixa.CEPs)
- }
- if faixa.CEPIni != "25845001" || faixa.CEPFim != "25849899" {
- t.Fatalf("range = %s..%s, want 25845001..25849899", faixa.CEPIni, faixa.CEPFim)
- }
- if faixa.IBGE != "3300225" || faixa.Estado != "Rio de Janeiro" || faixa.Regiao != "Sudeste" {
- t.Fatalf("faixa lost fields: %+v", faixa)
- }
-}
-
-func TestNotFoundWithoutFaixaIsUntouched(t *testing.T) {
- f := newFakeAPI(t, http.StatusNotFound, plain404)
- _, err := newClient(t, f).Lookup(context.Background(), "99999999")
-
- if !errors.Is(err, cepis.ErrCEPNotFound) {
- t.Fatalf("err = %v, want a not-found error", err)
- }
- if faixa := FaixaOf(err); faixa != nil {
- t.Fatalf("invented a municipality: %+v", faixa)
- }
- var notFound *NotFoundError
- if errors.As(err, &notFound) {
- t.Fatal("a 404 without a faixa should come back as the library's own error")
- }
-}
-
-// A faixa the bot cannot trust must cost nothing: the reply falls back to the
-// plain one, and never to a panic or an unanswered message.
-func TestMalformedFaixaFallsBack(t *testing.T) {
- bodies := map[string]string{
- "empty body": ``,
- "not JSON": `<html>captive portal</html>`,
- "truncated": `{"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal"`,
- "faixa is null": `{"erro":"cep_nao_encontrado","faixa":null}`,
- "faixa is a string": `{"erro":"cep_nao_encontrado","faixa":"Areal/RJ"}`,
- "faixa is an array": `{"erro":"cep_nao_encontrado","faixa":[{"localidade":"Areal"}]}`,
- "no localidade": `{"faixa":{"uf":"RJ","cep_ini":"25845001","cep_fim":"25849899"}}`,
- "blank localidade": `{"faixa":{"localidade":" ","uf":"RJ"}}`,
- "ceps is a string": `{"faixa":{"localidade":"Areal","uf":"RJ","ceps":"118"}}`,
- "whole body is null": `null`,
- "whole body is array": `[]`,
- }
-
- for name, body := range bodies {
- t.Run(name, func(t *testing.T) {
- f := newFakeAPI(t, http.StatusNotFound, body)
- _, err := newClient(t, f).Lookup(context.Background(), "25845000")
- if !errors.Is(err, cepis.ErrCEPNotFound) {
- t.Fatalf("err = %v, want a not-found error", err)
- }
- if faixa := FaixaOf(err); faixa != nil {
- t.Fatalf("trusted a broken faixa: %+v", faixa)
- }
- })
- }
-}
-
-// A negative count is nonsense the formatter would print verbatim, so it is
-// flattened to "unknown" while the municipality itself is kept.
-func TestNegativeCountIsDropped(t *testing.T) {
- f := newFakeAPI(t, http.StatusNotFound, `{"faixa":{"localidade":"Areal","uf":"RJ","ceps":-3}}`)
- _, err := newClient(t, f).Lookup(context.Background(), "25845000")
-
- faixa := FaixaOf(err)
- if faixa == nil {
- t.Fatal("the municipality should have survived a bad count")
- }
- if faixa.CEPs != 0 {
- t.Fatalf("ceps = %d, want 0", faixa.CEPs)
- }
-}
-
-func TestSuccessCostsOneRequest(t *testing.T) {
- f := newFakeAPI(t, http.StatusOK, address200)
- address, err := newClient(t, f).Lookup(context.Background(), "01001000")
-
- if err != nil {
- t.Fatalf("Lookup: %v", err)
- }
- if address.Localidade != "São Paulo" {
- t.Fatalf("localidade = %q", address.Localidade)
- }
- if got := f.requests.Load(); got != 1 {
- t.Fatalf("the API saw %d requests for a hit, want 1", got)
- }
-}
-
-func TestOtherFailuresAskOnlyOnce(t *testing.T) {
- f := newFakeAPI(t, http.StatusInternalServerError, `{"erro":"interno"}`)
- _, err := newClient(t, f).Lookup(context.Background(), "50000000")
-
- if !errors.Is(err, cepis.ErrServer) {
- t.Fatalf("err = %v, want a server error", err)
- }
- if got := f.requests.Load(); got != 1 {
- t.Fatalf("the API saw %d requests for a 500, want 1", got)
- }
-}
-
-// The faixa is worth one short wait and no more. A slow API still gets an
-// answer out to the user, without the municipality.
-func TestSlowFaixaIsAbandoned(t *testing.T) {
- f := newFakeAPI(t, http.StatusNotFound, areal404)
- client := newClient(t, f, WithTimeout(20*time.Millisecond))
- f.delay, f.delayFrom = 500*time.Millisecond, 2
-
- started := time.Now()
- _, err := client.Lookup(context.Background(), "25845000")
- elapsed := time.Since(started)
-
- if !errors.Is(err, cepis.ErrCEPNotFound) {
- t.Fatalf("err = %v, want a not-found error", err)
- }
- if faixa := FaixaOf(err); faixa != nil {
- t.Fatalf("used a faixa that arrived too late: %+v", faixa)
- }
- if elapsed > 400*time.Millisecond {
- t.Fatalf("waited %v for the faixa, want it abandoned quickly", elapsed)
- }
-}
-
-// A CEP that stopped being a 404 between the two requests is not a
-// municipality: the second answer is ignored rather than mixed into the first.
-func TestFaixaIgnoresANonNotFoundSecondAnswer(t *testing.T) {
- var count atomic.Int64
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json; charset=utf-8")
- if count.Add(1) == 1 {
- w.WriteHeader(http.StatusNotFound)
- w.Write([]byte(plain404))
- return
- }
- w.WriteHeader(http.StatusOK)
- w.Write([]byte(address200))
- }))
- t.Cleanup(server.Close)
-
- api, err := cepis.New(cepis.WithBaseURL(server.URL), cepis.WithMaxAttempts(1))
- if err != nil {
- t.Fatalf("cepis.New: %v", err)
- }
-
- _, err = New(api).Lookup(context.Background(), "25845000")
- if !errors.Is(err, cepis.ErrCEPNotFound) {
- t.Fatalf("err = %v, want a not-found error", err)
- }
- if faixa := FaixaOf(err); faixa != nil {
- t.Fatalf("read a municipality out of a 200: %+v", faixa)
- }
-}
-
-func TestFaixaOfAcceptsAnything(t *testing.T) {
- for name, err := range map[string]error{
- "nil": nil,
- "unrelated": errors.New("boom"),
- "not found": cepis.ErrCEPNotFound,
- "no faixa": &NotFoundError{Err: cepis.ErrCEPNotFound},
- "wrapped": &NotFoundError{Faixa: &Faixa{Localidade: "Areal", UF: "RJ"}, Err: cepis.ErrCEPNotFound},
- "empty type": &NotFoundError{},
- } {
- if name == "wrapped" {
- if got := FaixaOf(err); got == nil || got.Place() != "Areal/RJ" {
- t.Fatalf("%s: FaixaOf = %+v", name, got)
- }
- continue
- }
- if got := FaixaOf(err); got != nil {
- t.Fatalf("%s: FaixaOf = %+v, want nil", name, got)
- }
- }
-}
-
-// The zero value still matches the sentinel, so no construction of this type
-// can ever produce an error the bot fails to recognise as a not-found.
-func TestEmptyNotFoundErrorStillMatches(t *testing.T) {
- var err error = &NotFoundError{}
- if !errors.Is(err, cepis.ErrCEPNotFound) {
- t.Fatal("an empty NotFoundError should still be a not-found")
- }
- if err.Error() == "" {
- t.Fatal("an empty NotFoundError should still say something")
- }
-}
-
-func TestPlaceWithoutUF(t *testing.T) {
- faixa := &Faixa{Localidade: "Areal"}
- if faixa.Place() != "Areal" {
- t.Fatalf("place = %q, want Areal", faixa.Place())
- }
- var missing *Faixa
- if missing.Place() != "" {
- t.Fatalf("place = %q, want empty", missing.Place())
- }
-}