summaryrefslogtreecommitdiffstats
path: root/lib/cepis/models.rb
blob: a80fe3727aabe1e75a65b3990eb0fd72fa78dc2a (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
# frozen_string_literal: true

module Cepis
  # 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.
  class Geo
    attr_reader :lat, :lon, :raio_m, :precisao

    def initialize(lat:, lon:, raio_m:, precisao:)
      @lat = lat
      @lon = lon
      @raio_m = raio_m
      @precisao = precisao
      freeze
    end

    # Build from the hash the API returned, or `nil` when the block is missing
    # or malformed.
    #
    # A malformed `geo` is not worth losing the whole address over, since the
    # rest of the response is still useful.
    def self.from_json(data)
      return nil unless data.is_a?(Hash)

      block = data.transform_keys(&:to_s)
      new(
        lat: Float(block.fetch("lat")),
        lon: Float(block.fetch("lon")),
        raio_m: Float(block.fetch("raio_m")).to_i,
        precisao: String(block.fetch("precisao"))
      )
    rescue KeyError, TypeError, ArgumentError
      nil
    end

    # `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.
    def precise?
      @precisao == "logradouro"
    end

    def to_h
      { lat: @lat, lon: @lon, raio_m: @raio_m, precisao: @precisao }
    end

    def ==(other)
      other.is_a?(Geo) && to_h == other.to_h
    end
    alias eql? ==

    def hash
      to_h.hash
    end

    def to_s
      "#<Cepis::Geo #{@lat},#{@lon} ±#{@raio_m}m #{@precisao}>"
    end
  end

  # A CEP and everything the database knows about it.
  #
  # **Field names are the API's own, in Portuguese** — `logradouro`, `bairro`,
  # `localidade`. Everything else in this library is English; the fields are
  # the one deliberate exception, so that what you read in the JSON at
  # https://cep.is/01001000 is exactly what you read on the object.
  #
  # Fields the database does not have for that CEP come back as empty strings,
  # never `nil` — that is what the API returns.
  #
  # {#geo} is the exception: it is `nil` 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.
  class Address
    FIELDS = %i[
      cep logradouro complemento unidade bairro localidade
      uf estado regiao ibge gia ddd siafi
    ].freeze

    attr_reader(*FIELDS)
    attr_reader :geo, :raw

    def initialize(geo: nil, raw: {}, **fields)
      FIELDS.each { |name| instance_variable_set(:"@#{name}", fields[name] || "") }
      @geo = geo
      @raw = raw.freeze
      freeze
    end

    # Build from the hash the API returned.
    #
    # Unknown fields are ignored during construction but preserved in {#raw}. A
    # new field in the API therefore cannot break this library.
    def self.from_json(data)
      values = data.transform_keys(&:to_s)
      fields = FIELDS.each_with_object({}) do |name, acc|
        acc[name] = String(values[name.to_s] || "")
      end
      new(geo: Geo.from_json(values["geo"]), raw: values, **fields)
    end

    # The CEP with its mask: `01001-000`.
    #
    # The API always returns it unmasked; formatting is presentation, and it
    # lives here so it does not become a repeated interpolation in every
    # project that uses this.
    def formatted_cep
      "#{@cep[0, 5]}-#{@cep[5, 3]}"
    end

    def to_h
      FIELDS.each_with_object({}) { |name, acc| acc[name] = public_send(name) }
            .merge(geo: @geo&.to_h)
    end

    def ==(other)
      other.is_a?(Address) && to_h == other.to_h
    end
    alias eql? ==

    def hash
      to_h.hash
    end

    def to_s
      "#<Cepis::Address #{formatted_cep} #{@logradouro} #{@localidade}/#{@uf}>"
    end
  end

  # The municipality that owns the band a **nonexistent** CEP falls into.
  #
  # It is context for an error, never an address: it arrives on the 404 of
  # {Client#lookup}, hanging off {CEPNotFound#faixa}, and says "this CEP does
  # not exist, but the numbers around it belong to Areal/RJ". The lookup still
  # failed — nothing here turns it into a result.
  #
  # It is worth having because a CEP that fails is rarely a random number: it
  # is usually someone who knows the city and guessed the digits. The classic
  # guess is the `-000`, which 957 municipalities never received.
  #
  # **{#cep_ini} and {#cep_fim} are the smallest and the largest CEP that
  # actually exist in the municipality, not the bounds the Correios publish.**
  # The DNE's own start for Areal is `25845000` — exactly the CEP that just
  # failed, which is why the published bounds would be useless here.
  #
  # **The band is sparse.** Areal's 118 CEPs are scattered across the ~4,900
  # numbers between `25845001` and `25849899`. {#ceps} is there to say so. Do
  # not present the pair as a range of valid CEPs, and do not iterate it: all
  # but a fiftieth of it does not exist.
  class Faixa
    attr_reader :localidade, :uf, :estado, :regiao, :ibge, :cep_ini, :cep_fim, :ceps

    def initialize(localidade:, uf:, estado:, regiao:, ibge:, cep_ini:, cep_fim:, ceps:)
      @localidade = localidade
      @uf = uf
      @estado = estado
      @regiao = regiao
      @ibge = ibge
      @cep_ini = cep_ini
      @cep_fim = cep_fim
      @ceps = ceps
      freeze
    end

    # Build from the hash the API returned, or `nil` when the block is
    # missing, empty or malformed.
    #
    # The API **omits** `faixa` — it never sends null — for a CEP falling in a
    # band no municipality owns, so absence is the ordinary case and not an
    # anomaly. Anything unreadable is treated as absence too: this is a bonus
    # on top of a failure that has already happened, and it must never be the
    # reason a clean not-found becomes something else.
    def self.from_json(data)
      return nil unless data.is_a?(Hash)

      block = data.transform_keys(&:to_s)
      localidade = String(block["localidade"] || "").strip
      return nil if localidade.empty?

      new(
        localidade: localidade,
        uf: String(block["uf"] || ""),
        estado: String(block["estado"] || ""),
        regiao: String(block["regiao"] || ""),
        ibge: String(block["ibge"] || ""),
        cep_ini: String(block["cep_ini"] || ""),
        cep_fim: String(block["cep_fim"] || ""),
        ceps: Integer(block["ceps"] || 0, exception: false) || 0
      )
    rescue TypeError, ArgumentError
      nil
    end

    def to_h
      {
        localidade: @localidade, uf: @uf, estado: @estado, regiao: @regiao,
        ibge: @ibge, cep_ini: @cep_ini, cep_fim: @cep_fim, ceps: @ceps
      }
    end

    def ==(other)
      other.is_a?(Faixa) && to_h == other.to_h
    end
    alias eql? ==

    def hash
      to_h.hash
    end

    def to_s
      "#<Cepis::Faixa #{@localidade}/#{@uf} #{@cep_ini}-#{@cep_fim} #{@ceps} CEPs>"
    end
  end

  # 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.
  class Health
    attr_reader :status, :dne, :ceps, :geo, :base_gerada_em

    def initialize(status:, dne:, ceps:, geo:, base_gerada_em:)
      @status = status
      @dne = dne
      @ceps = ceps
      @geo = geo
      @base_gerada_em = base_gerada_em
      freeze
    end

    # Build from the hash the API returned.
    def self.from_json(data)
      values = data.transform_keys(&:to_s)
      new(
        status: String(values["status"] || ""),
        dne: String(values["dne"] || ""),
        ceps: values["ceps"].to_i,
        geo: values["geo"].to_i,
        base_gerada_em: String(values["base_gerada_em"] || "")
      )
    end

    # `true` when the API reports itself healthy.
    def ok?
      @status == "ok"
    end

    def to_h
      {
        status: @status, dne: @dne, ceps: @ceps, geo: @geo,
        base_gerada_em: @base_gerada_em
      }
    end

    def to_s
      "#<Cepis::Health #{@status} dne=#{@dne} ceps=#{@ceps}>"
    end
  end
end