// 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 }