vehicles 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -7
- data/README.md +353 -69
- data/data/PROVENANCE-vehicles.md +2 -2
- data/data/plates/PROVENANCE.md +2 -2
- data/data/plates/_decode/at-districts.yml +2 -2
- data/data/plates/_decode/cn-issuing-offices.yml +2 -2
- data/data/plates/_decode/cn-provinces.yml +2 -2
- data/data/plates/_decode/cz-kraj.yml +1 -1
- data/data/plates/_decode/dk-serial-blocks.yml +1 -1
- data/data/plates/_decode/es-provinces.yml +1 -1
- data/data/plates/_decode/fr-departements.yml +2 -2
- data/data/plates/_decode/gb-age-identifiers.yml +1 -1
- data/data/plates/_decode/gb-memory-tags.yml +1 -1
- data/data/plates/_decode/gr-prefectures.yml +1 -1
- data/data/plates/_decode/hr-cities.yml +3 -3
- data/data/plates/_decode/ie-counties.yml +3 -3
- data/data/plates/_decode/in-state-codes.yml +2 -2
- data/data/plates/_decode/it-provinces.yml +3 -3
- data/data/plates/_decode/jp-office-marks.yml +2 -2
- data/data/plates/_decode/jp-use-marks.yml +2 -2
- data/data/plates/_decode/kr-authority-marks.yml +1 -1
- data/data/plates/_decode/mx-entity-series.yml +1 -1
- data/data/plates/_decode/my-index-marks.yml +4 -4
- data/data/plates/_decode/pt-trailer-services.yml +1 -1
- data/data/plates/_decode/ro-counties.yml +1 -1
- data/data/plates/_decode/se-diplomatic.yml +1 -1
- data/data/plates/_decode/si-regions.yml +1 -1
- data/data/plates/_decode/ua-regions.yml +1 -1
- data/data/plates/_meta/classes.yml +1 -1
- data/data/plates/_meta/separators.yml +1 -1
- data/data/plates/ad.yml +555 -52
- data/data/plates/ar.yml +8 -8
- data/data/plates/at.yml +91 -10
- data/data/plates/au-act.yml +13 -13
- data/data/plates/au-nsw.yml +9 -9
- data/data/plates/au-nt.yml +13 -14
- data/data/plates/au-qld.yml +7 -7
- data/data/plates/au-sa.yml +7 -7
- data/data/plates/au-tas.yml +10 -10
- data/data/plates/au-vic.yml +6 -6
- data/data/plates/au-wa.yml +8 -8
- data/data/plates/be.yml +11 -11
- data/data/plates/br.yml +14 -14
- data/data/plates/ca-ab.yml +10 -10
- data/data/plates/ca-bc.yml +6 -6
- data/data/plates/ca-mb.yml +4 -4
- data/data/plates/ca-nb.yml +9 -9
- data/data/plates/ca-nl.yml +11 -11
- data/data/plates/ca-ns.yml +12 -12
- data/data/plates/ca-nt.yml +7 -7
- data/data/plates/ca-nu.yml +7 -7
- data/data/plates/ca-on.yml +21 -21
- data/data/plates/ca-pe.yml +13 -13
- data/data/plates/ca-qc.yml +11 -11
- data/data/plates/ca-sk.yml +6 -6
- data/data/plates/ca-yt.yml +6 -6
- data/data/plates/ch.yml +14 -14
- data/data/plates/cn.yml +9 -9
- data/data/plates/cy.yml +6 -6
- data/data/plates/cz.yml +9 -9
- data/data/plates/de.yml +8 -8
- data/data/plates/dk.yml +12 -12
- data/data/plates/ee.yml +13 -13
- data/data/plates/eg.yml +708 -0
- data/data/plates/es.yml +8 -8
- data/data/plates/fi.yml +14 -14
- data/data/plates/fr.yml +12 -12
- data/data/plates/gb.yml +16 -16
- data/data/plates/gr.yml +14 -14
- data/data/plates/hr.yml +15 -15
- data/data/plates/hu.yml +16 -16
- data/data/plates/ie.yml +7 -7
- data/data/plates/in.yml +7 -7
- data/data/plates/is.yml +3 -3
- data/data/plates/it.yml +21 -21
- data/data/plates/jp.yml +121 -121
- data/data/plates/kr.yml +6 -6
- data/data/plates/li.yml +6 -6
- data/data/plates/lt.yml +10 -10
- data/data/plates/lu.yml +4 -4
- data/data/plates/lv.yml +9 -9
- data/data/plates/mc.yml +4 -4
- data/data/plates/mt.yml +5 -5
- data/data/plates/mx.yml +7 -7
- data/data/plates/my.yml +18 -18
- data/data/plates/ng.yml +346 -0
- data/data/plates/nl.yml +5 -5
- data/data/plates/no.yml +7 -7
- data/data/plates/nz.yml +18 -18
- data/data/plates/pl.yml +4 -4
- data/data/plates/pt.yml +13 -5
- data/data/plates/ro.yml +14 -14
- data/data/plates/se.yml +13 -13
- data/data/plates/si.yml +8 -8
- data/data/plates/sk.yml +15 -15
- data/data/plates/sm.yml +7 -7
- data/data/plates/th.yml +10 -10
- data/data/plates/tr.yml +432 -0
- data/data/plates/ua.yml +9 -9
- data/data/plates/us-ak.yml +11 -11
- data/data/plates/us-al.yml +12 -12
- data/data/plates/us-ar.yml +15 -15
- data/data/plates/us-as.yml +10 -10
- data/data/plates/us-az.yml +10 -10
- data/data/plates/us-ca.yml +13 -13
- data/data/plates/us-co.yml +14 -14
- data/data/plates/us-ct.yml +24 -24
- data/data/plates/us-dc.yml +7 -7
- data/data/plates/us-de.yml +10 -10
- data/data/plates/us-fl.yml +17 -17
- data/data/plates/us-ga.yml +10 -10
- data/data/plates/us-gu.yml +11 -11
- data/data/plates/us-hi.yml +11 -11
- data/data/plates/us-ia.yml +9 -9
- data/data/plates/us-id.yml +13 -13
- data/data/plates/us-il.yml +11 -11
- data/data/plates/us-in.yml +9 -9
- data/data/plates/us-ks.yml +8 -8
- data/data/plates/us-ky.yml +11 -11
- data/data/plates/us-la.yml +11 -11
- data/data/plates/us-ma.yml +10 -10
- data/data/plates/us-md.yml +7 -7
- data/data/plates/us-me.yml +16 -16
- data/data/plates/us-mi.yml +8 -8
- data/data/plates/us-mn.yml +13 -13
- data/data/plates/us-mo.yml +13 -13
- data/data/plates/us-mp.yml +12 -12
- data/data/plates/us-ms.yml +8 -8
- data/data/plates/us-mt.yml +12 -12
- data/data/plates/us-nc.yml +12 -12
- data/data/plates/us-nd.yml +10 -10
- data/data/plates/us-ne.yml +10 -10
- data/data/plates/us-nh.yml +8 -8
- data/data/plates/us-nj.yml +8 -8
- data/data/plates/us-nm.yml +14 -14
- data/data/plates/us-nv.yml +18 -18
- data/data/plates/us-ny.yml +17 -17
- data/data/plates/us-oh.yml +13 -13
- data/data/plates/us-ok.yml +12 -12
- data/data/plates/us-or.yml +15 -15
- data/data/plates/us-pa.yml +14 -14
- data/data/plates/us-pr.yml +15 -15
- data/data/plates/us-ri.yml +10 -10
- data/data/plates/us-sc.yml +7 -7
- data/data/plates/us-sd.yml +12 -12
- data/data/plates/us-tn.yml +8 -8
- data/data/plates/us-tx.yml +17 -17
- data/data/plates/us-ut.yml +12 -12
- data/data/plates/us-va.yml +14 -14
- data/data/plates/us-vi.yml +16 -16
- data/data/plates/us-vt.yml +9 -9
- data/data/plates/us-wa.yml +18 -18
- data/data/plates/us-wi.yml +8 -8
- data/data/plates/us-wv.yml +9 -9
- data/data/plates/us-wy.yml +10 -10
- data/data/plates/va.yml +7 -7
- data/data/plates/za.yml +11 -11
- data/data/vehicles.json +121081 -62015
- data/lib/generators/vehicles/templates/initializer.rb +21 -12
- data/lib/generators/vehicles/templates/vehicles_refresh_job.rb +8 -4
- data/lib/vehicles/color.rb +8 -1
- data/lib/vehicles/colors.rb +14 -10
- data/lib/vehicles/configuration.rb +82 -3
- data/lib/vehicles/data_source.rb +220 -0
- data/lib/vehicles/dataset.rb +38 -6
- data/lib/vehicles/dataset_resolution.rb +39 -3
- data/lib/vehicles/dataset_slice.rb +90 -0
- data/lib/vehicles/hosted_result.rb +94 -0
- data/lib/vehicles/http.rb +75 -7
- data/lib/vehicles/id_lookup.rb +37 -0
- data/lib/vehicles/kind_rule.rb +63 -0
- data/lib/vehicles/labels.rb +131 -0
- data/lib/vehicles/make.rb +45 -19
- data/lib/vehicles/model.rb +66 -19
- data/lib/vehicles/model_match.rb +16 -2
- data/lib/vehicles/model_matcher.rb +6 -7
- data/lib/vehicles/plates/series.rb +87 -8
- data/lib/vehicles/plates/template_fill.rb +2 -2
- data/lib/vehicles/plates/templates.rb +9 -5
- data/lib/vehicles/plates.rb +54 -8
- data/lib/vehicles/providers/hosted_provider.rb +145 -32
- data/lib/vehicles/providers/local_provider.rb +2 -2
- data/lib/vehicles/refresh_result.rb +74 -0
- data/lib/vehicles/refresher.rb +190 -54
- data/lib/vehicles/release_checks.rb +72 -0
- data/lib/vehicles/text_matcher.rb +19 -10
- data/lib/vehicles/validators/vehicle_make_validator.rb +18 -1
- data/lib/vehicles/validators/vehicle_model_validator.rb +17 -1
- data/lib/vehicles/version.rb +1 -1
- data/lib/vehicles.rb +119 -67
- data/rakelib/data_refresh.rake +1 -1
- data/rakelib/plates_refresh.rake +1 -1
- data/sig/vehicles.rbs +67 -13
- metadata +13 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
|
|
5
6
|
module Vehicles
|
|
6
7
|
module Providers
|
|
@@ -13,16 +14,29 @@ module Vehicles
|
|
|
13
14
|
#
|
|
14
15
|
# The images endpoint is LIVE (since 2026-07): mint a key at
|
|
15
16
|
# https://vehiclesdb.com/settings/api-keys, set `config.api_key`, and
|
|
16
|
-
# `model.image` / `model.images` answer with
|
|
17
|
+
# `model.image` / `model.images` answer with studio vehicle imagery.
|
|
18
|
+
# Since 0.9.2:
|
|
19
|
+
# - a renamed/merged id's 301 is followed (same origin only);
|
|
20
|
+
# - failures are no longer silent: `model.images_result` says why
|
|
21
|
+
# (Vehicles::HostedResult), and 401/402/403/429/5xx/timeouts go to
|
|
22
|
+
# `config.on_hosted_error` and a warn log line;
|
|
23
|
+
# - payloads are cached (`config.image_cache`), so a page view does not
|
|
24
|
+
# pay per image;
|
|
25
|
+
# - `year:` is sent, not dropped (the API answers 422
|
|
26
|
+
# `unsupported_filter` until year-accurate images ship);
|
|
27
|
+
# - `exact_color: true` refuses the API's honest color fallback.
|
|
17
28
|
#
|
|
18
29
|
# `years` and `segment` are the wired-up seam for enrichment the API does
|
|
19
|
-
# not serve yet: they answer nil WITHOUT a request
|
|
20
|
-
#
|
|
21
|
-
# to the local data. They light up here when the API ships them.
|
|
30
|
+
# not serve yet: they answer nil WITHOUT a request, so the chain falls
|
|
31
|
+
# through to the local data.
|
|
22
32
|
module HostedProvider
|
|
23
33
|
# Every VehiclesDB API endpoint lives under this path on
|
|
24
34
|
# `config.api_base_url` (https://vehiclesdb.com/api/v1/...).
|
|
25
35
|
API_PREFIX = "/api/v1"
|
|
36
|
+
CACHE_SCOPE = "vehicles/images/v1"
|
|
37
|
+
# A cached hit is dropped this long before the payload's own
|
|
38
|
+
# `expires_at`, so a served URL never dies in a visitor's browser.
|
|
39
|
+
EXPIRY_MARGIN = 3600
|
|
26
40
|
|
|
27
41
|
module_function
|
|
28
42
|
|
|
@@ -39,39 +53,138 @@ module Vehicles
|
|
|
39
53
|
end
|
|
40
54
|
|
|
41
55
|
# One variant URL — the common "just give me an <img src>" case.
|
|
42
|
-
# `size` picks from the API's
|
|
56
|
+
# `size` picks from the API's image variants (:sm 320×180, :md
|
|
43
57
|
# 640×360, :lg 1280×720; webp).
|
|
44
|
-
def image(model, year: nil, color: nil, size: :md)
|
|
45
|
-
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# The full images payload
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
def image(model, year: nil, color: nil, size: :md, exact_color: false)
|
|
59
|
+
images_result(model, year: year, color: color, exact_color: exact_color).url(size)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The full images payload (palette, every variant with dimensions,
|
|
63
|
+
# provenance, `color` served vs `requested_color` asked), or nil.
|
|
64
|
+
def images(model, color: nil, year: nil, exact_color: false)
|
|
65
|
+
result = images_result(model, color: color, year: year, exact_color: exact_color)
|
|
66
|
+
result.ok? ? result.payload : nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# GET /api/v1/vehicles/:kind/:make_slug/:model_slug/images — a
|
|
70
|
+
# Vehicles::HostedResult, never nil, never raises.
|
|
71
|
+
def images_result(model, color: nil, year: nil, exact_color: false)
|
|
72
|
+
return HostedResult.new(reason: :no_api_key) unless available?
|
|
73
|
+
|
|
74
|
+
color = color.to_s.empty? ? nil : color.to_s
|
|
75
|
+
path = "#{API_PREFIX}/vehicles/#{model.id}/images"
|
|
76
|
+
if exact_color && color && (palette = cache_read(palette_key(model))) && !palette.include?(color)
|
|
77
|
+
return HostedResult.new(reason: :color_not_available, path: path, cached: true)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
result = cached_or_fetch(model, path, color, year)
|
|
81
|
+
exact_color && color && result.ok? && result.payload["color"] != color ? color_unavailable(result) : result
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
HostedResult.new(reason: :invalid_response, error: e.class.name, path: path)
|
|
63
84
|
end
|
|
64
85
|
|
|
65
86
|
# --- internals -----------------------------------------------------------
|
|
66
87
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
88
|
+
def cached_or_fetch(model, path, color, year)
|
|
89
|
+
key = cache_key(model, color, year)
|
|
90
|
+
hit = cache_read(key)
|
|
91
|
+
return from_cache(hit, path) if hit.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
result = fetch(path, { color: color, year: year }.compact.transform_values(&:to_s))
|
|
94
|
+
remember(model, key, result)
|
|
95
|
+
notify(result)
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A keyed GET → HostedResult.
|
|
100
|
+
def fetch(path, params)
|
|
101
|
+
response = HTTP.request(HTTP.url(path, params),
|
|
102
|
+
"Authorization" => "Bearer #{Vehicles.configuration.api_key}",
|
|
103
|
+
"Accept" => "application/json")
|
|
104
|
+
return HostedResult.new(reason: response.error, path: path) if response.status.nil?
|
|
105
|
+
|
|
106
|
+
body = parse(response.body)
|
|
107
|
+
unless response.success?
|
|
108
|
+
error = body.is_a?(Hash) ? body["error"] : nil
|
|
109
|
+
return HostedResult.from_status(response.status, body_error: error, path: path)
|
|
110
|
+
end
|
|
111
|
+
return HostedResult.new(reason: :invalid_response, status: response.status, path: path) unless
|
|
112
|
+
body.is_a?(Hash) && body["variants"].is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
HostedResult.new(reason: :ok, status: response.status, payload: body, path: path)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def color_unavailable(result)
|
|
118
|
+
result.with(reason: :color_not_available)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def remember(model, key, result)
|
|
122
|
+
if result.ok?
|
|
123
|
+
cache_write(key, { "payload" => result.payload, "status" => result.status }, hit_ttl(result.payload))
|
|
124
|
+
palette = result.payload["palette"]
|
|
125
|
+
cache_write(palette_key(model), palette, hit_ttl(result.payload)) if palette.is_a?(Array)
|
|
126
|
+
elsif result.reason == :not_found
|
|
127
|
+
cache_write(key, { "status" => result.status, "error" => result.error },
|
|
128
|
+
Vehicles.configuration.image_miss_ttl)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def from_cache(entry, path)
|
|
133
|
+
if entry["payload"]
|
|
134
|
+
HostedResult.new(reason: :ok, status: entry["status"], payload: entry["payload"], path: path, cached: true)
|
|
135
|
+
else
|
|
136
|
+
HostedResult.from_status(entry["status"], body_error: entry["error"], path: path).with(cached: true)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def hit_ttl(payload)
|
|
141
|
+
ttl = Vehicles.configuration.image_cache_ttl
|
|
142
|
+
expires = payload["expires_at"] && Time.iso8601(payload["expires_at"].to_s)
|
|
143
|
+
expires ? [ttl, (expires - Time.now - EXPIRY_MARGIN).to_i].min : ttl
|
|
144
|
+
rescue ArgumentError
|
|
145
|
+
ttl
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Alerts → `config.on_hosted_error` + one warn line + an
|
|
149
|
+
# ActiveSupport notification ("request.vehicles") for every call.
|
|
150
|
+
# The path carries a model id and nothing else (no query string).
|
|
151
|
+
def notify(result)
|
|
152
|
+
Vehicles.instrument("request.vehicles", result.to_h)
|
|
153
|
+
return unless result.alert?
|
|
154
|
+
|
|
155
|
+
Vehicles.logger&.warn("[vehicles] #{result.message}")
|
|
156
|
+
Vehicles.configuration.on_hosted_error&.call(result)
|
|
157
|
+
rescue StandardError
|
|
158
|
+
nil # a broken callback must never break a page
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse(body)
|
|
162
|
+
body.to_s.empty? ? nil : JSON.parse(body)
|
|
163
|
+
rescue JSON::ParserError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The base URL is in the key so a development server's answers never
|
|
168
|
+
# serve production; the year/color complete the request identity.
|
|
169
|
+
def cache_key(model, color, year)
|
|
170
|
+
[CACHE_SCOPE, Vehicles.configuration.api_base_url, model.id, color || "-", year || "-"].join("|")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def palette_key(model)
|
|
174
|
+
[CACHE_SCOPE, Vehicles.configuration.api_base_url, model.id, "palette"].join("|")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def cache_read(key)
|
|
178
|
+
Vehicles.configuration.image_cache&.read(key)
|
|
179
|
+
rescue StandardError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def cache_write(key, value, ttl)
|
|
184
|
+
return if ttl.to_i <= 0
|
|
185
|
+
|
|
186
|
+
Vehicles.configuration.image_cache&.write(key, value, expires_in: ttl)
|
|
187
|
+
rescue StandardError
|
|
75
188
|
nil
|
|
76
189
|
end
|
|
77
190
|
end
|
|
@@ -19,8 +19,8 @@ module Vehicles
|
|
|
19
19
|
# (and ultimately yield nil) instead of raising.
|
|
20
20
|
def years(_model) = nil
|
|
21
21
|
def segment(_model) = nil
|
|
22
|
-
def image(_model, year: nil, color: nil, size: :md)
|
|
23
|
-
def images(_model, color: nil)
|
|
22
|
+
def image(_model, year: nil, color: nil, size: :md, exact_color: false) = nil
|
|
23
|
+
def images(_model, color: nil, year: nil, exact_color: false) = nil
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
# What Vehicles.refresh did, and why. Immutable; never raised.
|
|
5
|
+
#
|
|
6
|
+
# result = Vehicles.refresh
|
|
7
|
+
# result.status # => :updated | :current | :rejected | :failed
|
|
8
|
+
# result.ok? # => true for :updated and :current
|
|
9
|
+
# result.updated? # => a newer dataset is now on disk (and served)
|
|
10
|
+
# result.reason # => nil, or why not (see REASONS)
|
|
11
|
+
# result.version # => the release the refresh looked at ("2026.11.2")
|
|
12
|
+
# result.previous_version # => the newest dataset this app already had
|
|
13
|
+
# result.message # => one human sentence for a log line
|
|
14
|
+
#
|
|
15
|
+
# :current — the published release is the one you already have; nothing
|
|
16
|
+
# was downloaded (the manifest answered).
|
|
17
|
+
# :rejected — the release exists but is not safe to serve here, so it was
|
|
18
|
+
# NOT written: an unsupported schema, older than your data, a
|
|
19
|
+
# checksum or count mismatch, a payload far smaller than your
|
|
20
|
+
# data (`config.refresh_min_ratio`), or your `refresh_guard`.
|
|
21
|
+
# :failed — the network or the CDN let you down; try again later.
|
|
22
|
+
class RefreshResult
|
|
23
|
+
STATUSES = %i[updated current rejected failed].freeze
|
|
24
|
+
|
|
25
|
+
REASONS = {
|
|
26
|
+
schema_unsupported: "the release uses a dataset schema this gem version does not read",
|
|
27
|
+
older: "the release is older than the data this app already has",
|
|
28
|
+
version_mismatch: "the payload's version is not the version the manifest announced",
|
|
29
|
+
checksum_mismatch: "the payload does not match the manifest's checksum",
|
|
30
|
+
count_mismatch: "the payload's model counts do not match the manifest",
|
|
31
|
+
below_floor: "the payload has far fewer models than the data this app already has",
|
|
32
|
+
guard: "config.refresh_guard refused the release",
|
|
33
|
+
invalid_payload: "the download is not a VehiclesDB dataset",
|
|
34
|
+
manifest_unavailable: "the release manifest could not be fetched or read",
|
|
35
|
+
download_failed: "the dataset download failed",
|
|
36
|
+
write_failed: "the dataset could not be written to config.cache_path",
|
|
37
|
+
error: "an unexpected error"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
attr_reader :status, :reason, :version, :previous_version, :detail
|
|
41
|
+
|
|
42
|
+
def initialize(status:, reason: nil, version: nil, previous_version: nil, detail: nil)
|
|
43
|
+
@status = status
|
|
44
|
+
@reason = reason
|
|
45
|
+
@version = version&.to_s
|
|
46
|
+
@previous_version = previous_version&.to_s
|
|
47
|
+
@detail = detail
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ok? = %i[updated current].include?(status)
|
|
52
|
+
def updated? = status == :updated
|
|
53
|
+
def current? = status == :current
|
|
54
|
+
def rejected? = status == :rejected
|
|
55
|
+
def failed? = status == :failed
|
|
56
|
+
|
|
57
|
+
def message
|
|
58
|
+
case status
|
|
59
|
+
when :updated then "dataset #{previous_version} → #{version}"
|
|
60
|
+
when :current then "dataset #{previous_version} is current"
|
|
61
|
+
else
|
|
62
|
+
text = REASONS.fetch(reason, reason.to_s)
|
|
63
|
+
text = "#{text} (#{detail})" if detail
|
|
64
|
+
"refresh #{status} #{version}: #{text}".squeeze(" ")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_h
|
|
69
|
+
{ status: status, reason: reason, version: version, previous_version: previous_version, detail: detail }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect = %(#<Vehicles::RefreshResult #{status}#{" #{reason}" if reason} #{message.inspect}>)
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/vehicles/refresher.rb
CHANGED
|
@@ -3,33 +3,48 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module Vehicles
|
|
6
|
-
# Pulls the latest published dataset
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# Pulls the latest published dataset into `config.cache_path`, so data
|
|
7
|
+
# fixes and new makes reach an app WITHOUT a gem upgrade. Schedule
|
|
8
|
+
# `Vehicles.refresh` daily; every process then picks the new file up on its
|
|
9
|
+
# own (Vehicles::DataSource).
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
11
|
+
# Safe by construction (0.9.2) — a release is written only when ALL hold:
|
|
12
|
+
# 1. the manifest (`config.manifest_url`, read FIRST, a few KB) announces
|
|
13
|
+
# a dataset schema this gem reads and a version NEWER than the newest
|
|
14
|
+
# data the app already has (bundled or cached). Same version → done,
|
|
15
|
+
# nothing downloaded; older → refused: never a downgrade;
|
|
16
|
+
# 2. the payload is fetched from that exact release (jsDelivr `@latest`
|
|
17
|
+
# is pinned to the announced version, so the two files cannot come
|
|
18
|
+
# from different releases), parses, carries that version and schema,
|
|
19
|
+
# and matches the manifest's per-kind model counts and its
|
|
20
|
+
# `dist_files` sha256/bytes when the manifest publishes them;
|
|
21
|
+
# 3. no kind, and no (kind, country) slice, shrinks below
|
|
22
|
+
# `config.refresh_min_ratio` (default 0.9) of the data in use: a
|
|
23
|
+
# release that lost Spain's cars cannot empty a Spanish picker;
|
|
24
|
+
# 4. `config.refresh_guard`, if set, accepts it.
|
|
25
|
+
# Then it is written atomically (temp file + rename in the same
|
|
26
|
+
# directory). A failure at any step leaves the current data untouched.
|
|
27
|
+
# Never raises; returns a Vehicles::RefreshResult.
|
|
15
28
|
module Refresher
|
|
16
|
-
MAX_REDIRECTS = 3
|
|
17
|
-
|
|
18
29
|
module_function
|
|
19
30
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
def refresh
|
|
32
|
+
baseline = nil
|
|
33
|
+
outcome = begin
|
|
34
|
+
baseline = DataSource.newest_version
|
|
35
|
+
run(baseline)
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
result(:failed, :error, detail: "#{e.class}: #{e.message}")
|
|
38
|
+
end
|
|
39
|
+
final = RefreshResult.new(**outcome.to_h, previous_version: baseline)
|
|
40
|
+
report(final)
|
|
41
|
+
final
|
|
42
|
+
end
|
|
26
43
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Vehicles.logger&.warn("[vehicles] refresh failed: #{e.class}: #{e.message}")
|
|
32
|
-
false
|
|
44
|
+
# Backward compatible boolean: true when the app now has the latest
|
|
45
|
+
# release (just written, or already current).
|
|
46
|
+
def refresh! # rubocop:disable Naming/PredicateMethod -- the pre-0.9.2 boolean API, kept for callers
|
|
47
|
+
refresh.ok?
|
|
33
48
|
end
|
|
34
49
|
|
|
35
50
|
# True when a refreshed dataset is cached on disk.
|
|
@@ -39,7 +54,7 @@ module Vehicles
|
|
|
39
54
|
end
|
|
40
55
|
|
|
41
56
|
def cached_path
|
|
42
|
-
cached? ? Vehicles.configuration.cache_path : nil
|
|
57
|
+
cached? ? Vehicles.configuration.cache_path.to_s : nil
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
# Remove the cached dataset (the gem falls back to the bundled snapshot).
|
|
@@ -51,49 +66,170 @@ module Vehicles
|
|
|
51
66
|
|
|
52
67
|
# --- internals -----------------------------------------------------------
|
|
53
68
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
def run(baseline)
|
|
70
|
+
config = Vehicles.configuration
|
|
71
|
+
manifest = nil
|
|
72
|
+
if config.manifest_url
|
|
73
|
+
manifest = parse_json(fetch(config.manifest_url, config.refresh_timeout))
|
|
74
|
+
verdict = manifest_verdict(manifest, baseline)
|
|
75
|
+
return verdict if verdict
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
url = manifest ? pinned(config.data_url, manifest["version"]) : config.data_url
|
|
79
|
+
body = fetch(url, config.refresh_timeout)
|
|
80
|
+
return result(:failed, :download_failed, manifest&.dig("version")) if body.to_s.empty?
|
|
81
|
+
|
|
82
|
+
payload_verdict(body, manifest, baseline)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# nil to go on and download; otherwise the result (no manifest, an
|
|
86
|
+
# unsupported schema, or nothing newer).
|
|
87
|
+
def manifest_verdict(manifest, baseline)
|
|
88
|
+
return result(:failed, :manifest_unavailable) unless manifest.is_a?(Hash) && manifest["version"]
|
|
89
|
+
|
|
90
|
+
announced = DataSource.version(manifest["version"])
|
|
91
|
+
schema = manifest["dist_schema_version"]
|
|
92
|
+
return result(:rejected, :schema_unsupported, manifest["version"], detail: "schema #{schema}") unless
|
|
93
|
+
DataSource::SCHEMA_VERSIONS.include?(schema)
|
|
94
|
+
return result(:rejected, :invalid_payload, manifest["version"], detail: "manifest version") unless announced
|
|
95
|
+
|
|
96
|
+
compare(announced, baseline)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A RefreshResult when the parsed payload is not a dataset this gem reads.
|
|
100
|
+
def malformed(data)
|
|
101
|
+
return result(:rejected, :invalid_payload) unless data.is_a?(Hash) && data["makes"].is_a?(Array) &&
|
|
102
|
+
!data["makes"].empty?
|
|
103
|
+
return result(:rejected, :invalid_payload, data["version"], detail: "version") unless
|
|
104
|
+
DataSource.version(data["version"])
|
|
58
105
|
|
|
59
|
-
|
|
60
|
-
|
|
106
|
+
return if DataSource::SCHEMA_VERSIONS.include?(data["schema_version"])
|
|
107
|
+
|
|
108
|
+
result(:rejected, :schema_unsupported, data["version"], detail: "schema #{data["schema_version"]}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def payload_verdict(body, manifest, baseline)
|
|
112
|
+
data = parse_json(body)
|
|
113
|
+
problem = malformed(data)
|
|
114
|
+
return problem if problem
|
|
115
|
+
|
|
116
|
+
version = DataSource.version(data["version"])
|
|
117
|
+
if manifest
|
|
118
|
+
problem = ReleaseChecks.manifest_mismatch(manifest, data, body, dist_key)
|
|
119
|
+
return result(:rejected, *problem.first(1), version, detail: problem[1]) if problem
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
verdict = compare(version, baseline)
|
|
123
|
+
return verdict if verdict
|
|
124
|
+
|
|
125
|
+
problem = ReleaseChecks.below_floor(data)
|
|
126
|
+
return result(:rejected, :below_floor, version, detail: problem) if problem
|
|
127
|
+
|
|
128
|
+
candidate = materialize(data)
|
|
129
|
+
return result(:rejected, :invalid_payload, version, detail: "models") unless candidate
|
|
130
|
+
|
|
131
|
+
guard = Vehicles.configuration.refresh_guard
|
|
132
|
+
return result(:rejected, :guard, version) if guard && !guard.call(candidate, Vehicles.dataset)
|
|
133
|
+
|
|
134
|
+
write(body, version, data)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Every model built once, so a malformed record fails the refresh instead
|
|
138
|
+
# of a later request. nil when it does not build.
|
|
139
|
+
def materialize(data)
|
|
140
|
+
Dataset.new(data).tap(&:all_models)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# nil when the candidate is newer than what we have; otherwise the result.
|
|
146
|
+
def compare(candidate, baseline)
|
|
147
|
+
return nil if baseline.nil? || candidate > baseline
|
|
148
|
+
return result(:current, nil, candidate) if candidate == baseline
|
|
149
|
+
|
|
150
|
+
result(:rejected, :older, candidate)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def write(body, version, data = nil)
|
|
154
|
+
path = Vehicles.configuration.cache_path.to_s
|
|
155
|
+
return result(:failed, :write_failed, version, detail: "no cache_path") if path.empty?
|
|
156
|
+
|
|
157
|
+
promoted = with_lock(path) do
|
|
158
|
+
# Another process may have promoted a release meanwhile: re-check the
|
|
159
|
+
# version AND the shrink floor against the newest data on disk.
|
|
160
|
+
verdict = compare(version, DataSource.newest_version)
|
|
161
|
+
next verdict if verdict
|
|
162
|
+
|
|
163
|
+
problem = data && ReleaseChecks.below_floor(data, DataSource.newest_dataset(path))
|
|
164
|
+
next result(:rejected, :below_floor, version, detail: problem) if problem
|
|
165
|
+
|
|
166
|
+
write_atomic(path, body)
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
return promoted if promoted
|
|
170
|
+
|
|
171
|
+
Vehicles.reload!
|
|
172
|
+
result(:updated, nil, version)
|
|
173
|
+
rescue SystemCallError, IOError => e
|
|
174
|
+
result(:failed, :write_failed, version, detail: e.class.name)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# jsDelivr's `@latest` resolves per file and is cached per file, so the
|
|
178
|
+
# manifest and the payload could come from two releases. Ask for the
|
|
179
|
+
# release the manifest announced.
|
|
180
|
+
def pinned(url, version)
|
|
181
|
+
url.to_s.sub("@latest/", "@#{version}/")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def dist_key
|
|
185
|
+
Vehicles.configuration.data_url.to_s[%r{dist/[^/?#]+\z}] || "dist/vehicles.json"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def result(status, reason = nil, version = nil, detail: nil)
|
|
189
|
+
RefreshResult.new(status: status, reason: reason, version: version, detail: detail)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def report(result)
|
|
193
|
+
if result.ok?
|
|
194
|
+
Vehicles.logger&.info("[vehicles] #{result.message}")
|
|
195
|
+
else
|
|
196
|
+
Vehicles.logger&.warn("[vehicles] #{result.message}")
|
|
197
|
+
end
|
|
198
|
+
Vehicles.instrument("refresh.vehicles", result.to_h)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
nil # a broken logger must not turn a refresh into an exception
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def parse_json(body)
|
|
204
|
+
body.to_s.empty? ? nil : JSON.parse(body)
|
|
61
205
|
rescue JSON::ParserError
|
|
62
|
-
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
case response
|
|
77
|
-
when Net::HTTPSuccess
|
|
78
|
-
# Net::HTTP hands back an ASCII-8BIT body (e.g. after gzip decompression);
|
|
79
|
-
# our datasets are UTF-8 JSON, so label it correctly. Otherwise downstream
|
|
80
|
-
# writes/parses can transcode-raise under Encoding.default_internal = UTF-8.
|
|
81
|
-
response.body&.dup&.force_encoding(Encoding::UTF_8)
|
|
82
|
-
when Net::HTTPRedirection
|
|
83
|
-
return nil if redirects_left <= 0
|
|
84
|
-
|
|
85
|
-
fetch(response["location"], timeout, redirects_left - 1)
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The dataset download (public, no key): Vehicles::HTTP.download.
|
|
210
|
+
def fetch(url, timeout) = HTTP.download(url, timeout)
|
|
211
|
+
|
|
212
|
+
# An exclusive lock next to the cache file, so two refreshes (two
|
|
213
|
+
# workers, two containers on one volume) never interleave check + write.
|
|
214
|
+
def with_lock(path)
|
|
215
|
+
require "fileutils"
|
|
216
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
217
|
+
File.open("#{path}.lock", File::RDWR | File::CREAT, 0o644) do |lock|
|
|
218
|
+
lock.flock(File::LOCK_EX)
|
|
219
|
+
yield
|
|
86
220
|
end
|
|
87
221
|
end
|
|
88
222
|
|
|
89
223
|
def write_atomic(path, body)
|
|
90
224
|
require "fileutils"
|
|
91
225
|
FileUtils.mkdir_p(File.dirname(path))
|
|
92
|
-
tmp = "#{path}.#{Process.pid}.tmp"
|
|
226
|
+
tmp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
93
227
|
# Binary write: persist the exact bytes, never transcode. Robust regardless
|
|
94
228
|
# of the body's encoding or the app's Encoding.default_internal.
|
|
95
229
|
File.binwrite(tmp, body)
|
|
96
230
|
File.rename(tmp, path) # atomic on the same filesystem
|
|
231
|
+
ensure
|
|
232
|
+
File.delete(tmp) if tmp && File.exist?(tmp)
|
|
97
233
|
end
|
|
98
234
|
end
|
|
99
235
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Vehicles
|
|
6
|
+
# The integrity and sanity checks Refresher runs on a downloaded release
|
|
7
|
+
# before it may replace the data in use.
|
|
8
|
+
module ReleaseChecks
|
|
9
|
+
# Slices smaller than this are too small for a ratio to mean anything
|
|
10
|
+
# (a country with 12 buses can lose 2 to a merge).
|
|
11
|
+
FLOOR_MIN_BUCKET = 25
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# [reason, detail] or nil.
|
|
16
|
+
def manifest_mismatch(manifest, data, body, dist_key)
|
|
17
|
+
return [:version_mismatch, "#{data["version"]} ≠ #{manifest["version"]}"] if
|
|
18
|
+
data["version"] != manifest["version"]
|
|
19
|
+
|
|
20
|
+
file = manifest.dig("dist_files", dist_key) || {}
|
|
21
|
+
return [:checksum_mismatch, "#{dist_key} bytes"] if file["bytes"].is_a?(Integer) && body.bytesize != file["bytes"]
|
|
22
|
+
return [:checksum_mismatch, dist_key] if file["sha256"] && Digest::SHA256.hexdigest(body) != file["sha256"]
|
|
23
|
+
|
|
24
|
+
expected = manifest["kinds"]
|
|
25
|
+
return nil unless expected.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
actual = kind_counts(raw_models(data))
|
|
28
|
+
expected.each do |kind, counts|
|
|
29
|
+
want = counts.is_a?(Hash) ? counts["models"] : nil
|
|
30
|
+
next unless want.is_a?(Integer)
|
|
31
|
+
return [:count_mismatch, "#{kind}: #{actual.fetch(kind, 0)} ≠ #{want}"] if actual.fetch(kind, 0) != want
|
|
32
|
+
end
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A detail string when some kind or (kind, country) slice of the candidate
|
|
37
|
+
# is below refresh_min_ratio of the data in use; nil when all hold.
|
|
38
|
+
def below_floor(data, dataset = Vehicles.dataset)
|
|
39
|
+
ratio = Vehicles.configuration.refresh_min_ratio
|
|
40
|
+
return nil if ratio.nil?
|
|
41
|
+
|
|
42
|
+
current = dataset.all_models.map { |m| [m.kind.to_s, m.availability] }
|
|
43
|
+
before = bucket_counts(current)
|
|
44
|
+
after = bucket_counts(raw_models(data))
|
|
45
|
+
before.each do |bucket, count|
|
|
46
|
+
next if count < FLOOR_MIN_BUCKET
|
|
47
|
+
return "#{bucket.compact.join("/")}: #{after.fetch(bucket, 0)} < #{ratio} × #{count}" if
|
|
48
|
+
after.fetch(bucket, 0) < ratio * count
|
|
49
|
+
end
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# [[kind, availability], ...] from a raw payload.
|
|
54
|
+
def raw_models(data)
|
|
55
|
+
data["makes"].flat_map do |make|
|
|
56
|
+
Array(make["models"]).map { |m| [(m["kind"] || "car").to_s, Array(m["availability"])] }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def kind_counts(pairs)
|
|
61
|
+
pairs.each_with_object(Hash.new(0)) { |(kind, _), h| h[kind] += 1 }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# { [kind, nil] => n, [kind, country] => n }
|
|
65
|
+
def bucket_counts(pairs)
|
|
66
|
+
pairs.each_with_object(Hash.new(0)) do |(kind, countries), h|
|
|
67
|
+
h[[kind, nil]] += 1
|
|
68
|
+
countries.each { |c| h[[kind, c]] += 1 }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|