diff options
Diffstat (limited to 'internal/lookup/lookup.go')
| -rw-r--r-- | internal/lookup/lookup.go | 248 |
1 files changed, 0 insertions, 248 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, ¬Found) { - 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 -} |