summaryrefslogtreecommitdiffstats
path: root/src/models.ts
blob: 7125336ffef0f38a1df0d75f48b9fd1a2d51ab2b (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
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
/**
 * The data the API returns, as objects.
 *
 * **Data field names are the API's own, in Portuguese** — `logradouro`,
 * `bairro`, `localidade`. Everything else in this library is English; the data
 * fields are the one deliberate exception.
 *
 * The reason is fidelity: what you read in the JSON at
 * https://cep.is/01001000 is exactly what you access on the object.
 * Translating to `street`/`city` would mean maintaining a field-by-field
 * mapping and forcing anyone reading the API docs alongside this library to
 * translate back in their head. The values are Portuguese either way — they
 * come from the Correios database.
 *
 * @module
 */

/** A JSON object as it arrived from the API, before interpretation. */
export type RawObject = Record<string, unknown>;

function toNumber(value: unknown): number | null {
  if (typeof value === "number") {
    return Number.isFinite(value) ? value : null;
  }
  if (typeof value === "string" && value.trim() !== "") {
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : null;
  }
  return null;
}

function toText(value: unknown): string {
  if (value === undefined || value === null) {
    return "";
  }
  return String(value);
}

/** The four values that make up a coordinate. */
export interface GeoFields {
  lat: number;
  lon: number;
  raio_m: number;
  precisao: string;
}

/**
 * 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. `raio_m` 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. `raio_m` runs from 7 km to 40 km.
 *   Computing shipping from this is wrong by tens of kilometres.
 *
 * `raio_m` is the radius containing 90% of that CEP's addresses. It exists
 * precisely so that error cannot pass unnoticed.
 */
export class Geo implements GeoFields {
  /** Latitude, in decimal degrees. */
  readonly lat: number;
  /** Longitude, in decimal degrees. */
  readonly lon: number;
  /** Radius, in metres, containing 90% of that CEP's addresses. */
  readonly raio_m: number;
  /** Either `"logradouro"` (a street) or `"localidade"` (a municipality). */
  readonly precisao: string;

  constructor(fields: GeoFields) {
    this.lat = fields.lat;
    this.lon = fields.lon;
    this.raio_m = fields.raio_m;
    this.precisao = fields.precisao;
  }

  /**
   * `true` when the coordinate points at a street, not a municipality.
   *
   * Shorthand for `precisao === "logradouro"`, which is the check to make
   * before using the point to measure distance.
   */
  get isPrecise(): boolean {
    return this.precisao === "logradouro";
  }

  /**
   * Build from the `geo` block the API returned, or `null` when it cannot be
   * read.
   *
   * A malformed block yields `null` rather than throwing: it is not worth
   * losing the whole address over, since the rest of the response is still
   * useful.
   */
  static fromJson(data: unknown): Geo | null {
    if (typeof data !== "object" || data === null || Array.isArray(data)) {
      return null;
    }
    const block = data as RawObject;
    const lat = toNumber(block.lat);
    const lon = toNumber(block.lon);
    const raio = toNumber(block.raio_m);
    if (lat === null || lon === null || raio === null) {
      return null;
    }
    if (block.precisao === undefined || block.precisao === null) {
      return null;
    }
    return new Geo({
      lat,
      lon,
      raio_m: Math.trunc(raio),
      precisao: String(block.precisao),
    });
  }
}

/** Every field of an {@link Address}, as plain data. */
export interface AddressFields {
  cep: string;
  logradouro: string;
  complemento: string;
  unidade: string;
  bairro: string;
  localidade: string;
  uf: string;
  estado: string;
  regiao: string;
  ibge: string;
  gia: string;
  ddd: string;
  siafi: string;
  geo?: Geo | null;
  raw?: RawObject;
}

/**
 * A CEP and everything the database knows about it.
 *
 * Fields the database does not have for that CEP come back as empty strings,
 * never `null` — that is what the API returns, and changing it here would
 * create a difference between reading the JSON and reading this library.
 *
 * `geo` is the exception: it is `null` when there is no coordinate. There the
 * difference is real, because the API **omits** the whole field 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.
 */
export class Address {
  /** The 8 digits, always unmasked. */
  readonly cep: string;
  /** Street, avenue or square. Empty when the CEP covers a whole municipality. */
  readonly logradouro: string;
  /** Extra qualifier on the street, such as `"lado ímpar"`. */
  readonly complemento: string;
  /** Name of the large recipient the CEP belongs to, when it has one. */
  readonly unidade: string;
  /** Neighbourhood. */
  readonly bairro: string;
  /** Municipality. */
  readonly localidade: string;
  /** Two-letter state code, such as `"SP"`. */
  readonly uf: string;
  /** State name, spelled out. */
  readonly estado: string;
  /** Region of the country, such as `"Sudeste"`. */
  readonly regiao: string;
  /** IBGE municipality code. */
  readonly ibge: string;
  /** GIA code, for São Paulo state. */
  readonly gia: string;
  /** Telephone area code. */
  readonly ddd: string;
  /** SIAFI municipality code. */
  readonly siafi: string;
  /**
   * The coordinate, or `null` when the database has none.
   *
   * Always `null` for an address obtained through `Client.lookupMany` —
   * batches carry no coordinate.
   */
  readonly geo: Geo | null;
  /** The response as it arrived, including fields this library does not know. */
  readonly raw: RawObject;

  constructor(fields: AddressFields) {
    this.cep = fields.cep;
    this.logradouro = fields.logradouro;
    this.complemento = fields.complemento;
    this.unidade = fields.unidade;
    this.bairro = fields.bairro;
    this.localidade = fields.localidade;
    this.uf = fields.uf;
    this.estado = fields.estado;
    this.regiao = fields.regiao;
    this.ibge = fields.ibge;
    this.gia = fields.gia;
    this.ddd = fields.ddd;
    this.siafi = fields.siafi;
    this.geo = fields.geo ?? null;
    this.raw = fields.raw ?? {};
  }

  /**
   * The CEP with its mask: `01001-000`.
   *
   * The native contract always returns it unmasked; formatting is
   * presentation, and it lives here so it does not become a repeated template
   * literal in every project that uses this.
   */
  get formattedCep(): string {
    return `${this.cep.slice(0, 5)}-${this.cep.slice(5)}`;
  }

  /**
   * Build from the object the API returned.
   *
   * Unknown fields are ignored during construction but preserved in `raw`, so
   * a new field in the API cannot break this library.
   */
  static fromJson(data: RawObject): Address {
    return new Address({
      cep: toText(data.cep),
      logradouro: toText(data.logradouro),
      complemento: toText(data.complemento),
      unidade: toText(data.unidade),
      bairro: toText(data.bairro),
      localidade: toText(data.localidade),
      uf: toText(data.uf),
      estado: toText(data.estado),
      regiao: toText(data.regiao),
      ibge: toText(data.ibge),
      gia: toText(data.gia),
      ddd: toText(data.ddd),
      siafi: toText(data.siafi),
      geo: Geo.fromJson(data.geo),
      raw: { ...data },
    });
  }
}

/** Every field of a {@link Faixa}, as plain data. */
export interface FaixaFields {
  localidade: string;
  uf: string;
  estado: string;
  regiao: string;
  ibge: string;
  cep_ini: string;
  cep_fim: string;
  ceps: number;
}

/**
 * The municipality a CEP that does not exist *would* belong to.
 *
 * It comes attached to {@link CEPNotFound}, on a response that is still a
 * **404**. It is context for an error, never an address: nothing here says the
 * CEP exists, and code that treats a `faixa` as a result turns a clean
 * not-found into a wrong delivery.
 *
 * It exists because a failing CEP is rarely random — it is someone who knows
 * the city and guessed the number, and the classic guess is the `-000` that 957
 * municipalities never received.
 *
 * `cep_ini` and `cep_fim` are the smallest and the largest CEP that **actually
 * exist** in that municipality, not the range the Correios publish for it. For
 * Areal/RJ the DNE's own start is `25845000` — precisely the CEP that just
 * failed — while `cep_ini` is `25845001`.
 *
 * The range is **sparse**. Areal's 118 CEPs are scattered across the ~4,900
 * numbers between `25845001` and `25849899`, so the pair is not a list of valid
 * CEPs and iterating it produces mostly 404s. `ceps` is there to make that
 * density visible: 118 out of 4,900.
 */
export class Faixa implements FaixaFields {
  /** Municipality that owns the band the CEP falls in. */
  readonly localidade: string;
  /** Two-letter state code, such as `"RJ"`. */
  readonly uf: string;
  /** State name, spelled out. */
  readonly estado: string;
  /** Region of the country, such as `"Sudeste"`. */
  readonly regiao: string;
  /** IBGE municipality code. */
  readonly ibge: string;
  /** Smallest CEP that exists there — not the band's published start. */
  readonly cep_ini: string;
  /** Largest CEP that exists there — not the band's published end. */
  readonly cep_fim: string;
  /** How many CEPs actually exist between `cep_ini` and `cep_fim`. */
  readonly ceps: number;

  constructor(fields: FaixaFields) {
    this.localidade = fields.localidade;
    this.uf = fields.uf;
    this.estado = fields.estado;
    this.regiao = fields.regiao;
    this.ibge = fields.ibge;
    this.cep_ini = fields.cep_ini;
    this.cep_fim = fields.cep_fim;
    this.ceps = fields.ceps;
  }

  /** The municipality as `"Areal/RJ"`, for a message shown to a person. */
  get label(): string {
    return this.uf === "" ? this.localidade : `${this.localidade}/${this.uf}`;
  }

  /**
   * Build from the `faixa` block of a 404 body, or `undefined` when there is
   * none to read.
   *
   * `undefined`, not `null`, because the API **omits** the field rather than
   * sending null when the CEP falls in a band no municipality owns. Absent and
   * unreadable are the same answer here: a malformed block must never cost the
   * caller the not-found error it was already getting.
   */
  static fromJson(data: unknown): Faixa | undefined {
    if (typeof data !== "object" || data === null || Array.isArray(data)) {
      return undefined;
    }
    const block = data as RawObject;
    const localidade = toText(block.localidade).trim();
    if (localidade === "") {
      return undefined;
    }
    return new Faixa({
      localidade,
      uf: toText(block.uf),
      estado: toText(block.estado),
      regiao: toText(block.regiao),
      ibge: toText(block.ibge),
      cep_ini: toText(block.cep_ini),
      cep_fim: toText(block.cep_fim),
      ceps: toNumber(block.ceps) ?? 0,
    });
  }
}

/** Every field of a {@link Health} report, as plain data. */
export interface HealthFields {
  status: string;
  dne: string;
  ceps: number;
  geo: number;
  base_gerada_em: string;
}

/**
 * The response 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.
 */
export class Health implements HealthFields {
  /** `"ok"` when the API considers itself healthy. */
  readonly status: string;
  /** Identifier of the fortnightly DNE database currently loaded. */
  readonly dne: string;
  /** How many CEPs that database holds. */
  readonly ceps: number;
  /** How many of those carry a coordinate. */
  readonly geo: number;
  /** When the base was generated, as an ISO-8601 instant. */
  readonly base_gerada_em: string;

  constructor(fields: HealthFields) {
    this.status = fields.status;
    this.dne = fields.dne;
    this.ceps = fields.ceps;
    this.geo = fields.geo;
    this.base_gerada_em = fields.base_gerada_em;
  }

  /** `true` when the API reports itself healthy. */
  get ok(): boolean {
    return this.status === "ok";
  }

  /** Build from the object the API returned. */
  static fromJson(data: RawObject): Health {
    return new Health({
      status: toText(data.status),
      dne: toText(data.dne),
      ceps: toNumber(data.ceps) ?? 0,
      geo: toNumber(data.geo) ?? 0,
      base_gerada_em: toText(data.base_gerada_em),
    });
  }
}