summaryrefslogtreecommitdiffstats
path: root/internal/lookup/lookup.go
blob: 956a7317d78d176a1e4ad74c4b180058be546e04 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
// 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
}