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.
Files changed (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -7
  3. data/README.md +353 -69
  4. data/data/PROVENANCE-vehicles.md +2 -2
  5. data/data/plates/PROVENANCE.md +2 -2
  6. data/data/plates/_decode/at-districts.yml +2 -2
  7. data/data/plates/_decode/cn-issuing-offices.yml +2 -2
  8. data/data/plates/_decode/cn-provinces.yml +2 -2
  9. data/data/plates/_decode/cz-kraj.yml +1 -1
  10. data/data/plates/_decode/dk-serial-blocks.yml +1 -1
  11. data/data/plates/_decode/es-provinces.yml +1 -1
  12. data/data/plates/_decode/fr-departements.yml +2 -2
  13. data/data/plates/_decode/gb-age-identifiers.yml +1 -1
  14. data/data/plates/_decode/gb-memory-tags.yml +1 -1
  15. data/data/plates/_decode/gr-prefectures.yml +1 -1
  16. data/data/plates/_decode/hr-cities.yml +3 -3
  17. data/data/plates/_decode/ie-counties.yml +3 -3
  18. data/data/plates/_decode/in-state-codes.yml +2 -2
  19. data/data/plates/_decode/it-provinces.yml +3 -3
  20. data/data/plates/_decode/jp-office-marks.yml +2 -2
  21. data/data/plates/_decode/jp-use-marks.yml +2 -2
  22. data/data/plates/_decode/kr-authority-marks.yml +1 -1
  23. data/data/plates/_decode/mx-entity-series.yml +1 -1
  24. data/data/plates/_decode/my-index-marks.yml +4 -4
  25. data/data/plates/_decode/pt-trailer-services.yml +1 -1
  26. data/data/plates/_decode/ro-counties.yml +1 -1
  27. data/data/plates/_decode/se-diplomatic.yml +1 -1
  28. data/data/plates/_decode/si-regions.yml +1 -1
  29. data/data/plates/_decode/ua-regions.yml +1 -1
  30. data/data/plates/_meta/classes.yml +1 -1
  31. data/data/plates/_meta/separators.yml +1 -1
  32. data/data/plates/ad.yml +555 -52
  33. data/data/plates/ar.yml +8 -8
  34. data/data/plates/at.yml +91 -10
  35. data/data/plates/au-act.yml +13 -13
  36. data/data/plates/au-nsw.yml +9 -9
  37. data/data/plates/au-nt.yml +13 -14
  38. data/data/plates/au-qld.yml +7 -7
  39. data/data/plates/au-sa.yml +7 -7
  40. data/data/plates/au-tas.yml +10 -10
  41. data/data/plates/au-vic.yml +6 -6
  42. data/data/plates/au-wa.yml +8 -8
  43. data/data/plates/be.yml +11 -11
  44. data/data/plates/br.yml +14 -14
  45. data/data/plates/ca-ab.yml +10 -10
  46. data/data/plates/ca-bc.yml +6 -6
  47. data/data/plates/ca-mb.yml +4 -4
  48. data/data/plates/ca-nb.yml +9 -9
  49. data/data/plates/ca-nl.yml +11 -11
  50. data/data/plates/ca-ns.yml +12 -12
  51. data/data/plates/ca-nt.yml +7 -7
  52. data/data/plates/ca-nu.yml +7 -7
  53. data/data/plates/ca-on.yml +21 -21
  54. data/data/plates/ca-pe.yml +13 -13
  55. data/data/plates/ca-qc.yml +11 -11
  56. data/data/plates/ca-sk.yml +6 -6
  57. data/data/plates/ca-yt.yml +6 -6
  58. data/data/plates/ch.yml +14 -14
  59. data/data/plates/cn.yml +9 -9
  60. data/data/plates/cy.yml +6 -6
  61. data/data/plates/cz.yml +9 -9
  62. data/data/plates/de.yml +8 -8
  63. data/data/plates/dk.yml +12 -12
  64. data/data/plates/ee.yml +13 -13
  65. data/data/plates/eg.yml +708 -0
  66. data/data/plates/es.yml +8 -8
  67. data/data/plates/fi.yml +14 -14
  68. data/data/plates/fr.yml +12 -12
  69. data/data/plates/gb.yml +16 -16
  70. data/data/plates/gr.yml +14 -14
  71. data/data/plates/hr.yml +15 -15
  72. data/data/plates/hu.yml +16 -16
  73. data/data/plates/ie.yml +7 -7
  74. data/data/plates/in.yml +7 -7
  75. data/data/plates/is.yml +3 -3
  76. data/data/plates/it.yml +21 -21
  77. data/data/plates/jp.yml +121 -121
  78. data/data/plates/kr.yml +6 -6
  79. data/data/plates/li.yml +6 -6
  80. data/data/plates/lt.yml +10 -10
  81. data/data/plates/lu.yml +4 -4
  82. data/data/plates/lv.yml +9 -9
  83. data/data/plates/mc.yml +4 -4
  84. data/data/plates/mt.yml +5 -5
  85. data/data/plates/mx.yml +7 -7
  86. data/data/plates/my.yml +18 -18
  87. data/data/plates/ng.yml +346 -0
  88. data/data/plates/nl.yml +5 -5
  89. data/data/plates/no.yml +7 -7
  90. data/data/plates/nz.yml +18 -18
  91. data/data/plates/pl.yml +4 -4
  92. data/data/plates/pt.yml +13 -5
  93. data/data/plates/ro.yml +14 -14
  94. data/data/plates/se.yml +13 -13
  95. data/data/plates/si.yml +8 -8
  96. data/data/plates/sk.yml +15 -15
  97. data/data/plates/sm.yml +7 -7
  98. data/data/plates/th.yml +10 -10
  99. data/data/plates/tr.yml +432 -0
  100. data/data/plates/ua.yml +9 -9
  101. data/data/plates/us-ak.yml +11 -11
  102. data/data/plates/us-al.yml +12 -12
  103. data/data/plates/us-ar.yml +15 -15
  104. data/data/plates/us-as.yml +10 -10
  105. data/data/plates/us-az.yml +10 -10
  106. data/data/plates/us-ca.yml +13 -13
  107. data/data/plates/us-co.yml +14 -14
  108. data/data/plates/us-ct.yml +24 -24
  109. data/data/plates/us-dc.yml +7 -7
  110. data/data/plates/us-de.yml +10 -10
  111. data/data/plates/us-fl.yml +17 -17
  112. data/data/plates/us-ga.yml +10 -10
  113. data/data/plates/us-gu.yml +11 -11
  114. data/data/plates/us-hi.yml +11 -11
  115. data/data/plates/us-ia.yml +9 -9
  116. data/data/plates/us-id.yml +13 -13
  117. data/data/plates/us-il.yml +11 -11
  118. data/data/plates/us-in.yml +9 -9
  119. data/data/plates/us-ks.yml +8 -8
  120. data/data/plates/us-ky.yml +11 -11
  121. data/data/plates/us-la.yml +11 -11
  122. data/data/plates/us-ma.yml +10 -10
  123. data/data/plates/us-md.yml +7 -7
  124. data/data/plates/us-me.yml +16 -16
  125. data/data/plates/us-mi.yml +8 -8
  126. data/data/plates/us-mn.yml +13 -13
  127. data/data/plates/us-mo.yml +13 -13
  128. data/data/plates/us-mp.yml +12 -12
  129. data/data/plates/us-ms.yml +8 -8
  130. data/data/plates/us-mt.yml +12 -12
  131. data/data/plates/us-nc.yml +12 -12
  132. data/data/plates/us-nd.yml +10 -10
  133. data/data/plates/us-ne.yml +10 -10
  134. data/data/plates/us-nh.yml +8 -8
  135. data/data/plates/us-nj.yml +8 -8
  136. data/data/plates/us-nm.yml +14 -14
  137. data/data/plates/us-nv.yml +18 -18
  138. data/data/plates/us-ny.yml +17 -17
  139. data/data/plates/us-oh.yml +13 -13
  140. data/data/plates/us-ok.yml +12 -12
  141. data/data/plates/us-or.yml +15 -15
  142. data/data/plates/us-pa.yml +14 -14
  143. data/data/plates/us-pr.yml +15 -15
  144. data/data/plates/us-ri.yml +10 -10
  145. data/data/plates/us-sc.yml +7 -7
  146. data/data/plates/us-sd.yml +12 -12
  147. data/data/plates/us-tn.yml +8 -8
  148. data/data/plates/us-tx.yml +17 -17
  149. data/data/plates/us-ut.yml +12 -12
  150. data/data/plates/us-va.yml +14 -14
  151. data/data/plates/us-vi.yml +16 -16
  152. data/data/plates/us-vt.yml +9 -9
  153. data/data/plates/us-wa.yml +18 -18
  154. data/data/plates/us-wi.yml +8 -8
  155. data/data/plates/us-wv.yml +9 -9
  156. data/data/plates/us-wy.yml +10 -10
  157. data/data/plates/va.yml +7 -7
  158. data/data/plates/za.yml +11 -11
  159. data/data/vehicles.json +121081 -62015
  160. data/lib/generators/vehicles/templates/initializer.rb +21 -12
  161. data/lib/generators/vehicles/templates/vehicles_refresh_job.rb +8 -4
  162. data/lib/vehicles/color.rb +8 -1
  163. data/lib/vehicles/colors.rb +14 -10
  164. data/lib/vehicles/configuration.rb +82 -3
  165. data/lib/vehicles/data_source.rb +220 -0
  166. data/lib/vehicles/dataset.rb +38 -6
  167. data/lib/vehicles/dataset_resolution.rb +39 -3
  168. data/lib/vehicles/dataset_slice.rb +90 -0
  169. data/lib/vehicles/hosted_result.rb +94 -0
  170. data/lib/vehicles/http.rb +75 -7
  171. data/lib/vehicles/id_lookup.rb +37 -0
  172. data/lib/vehicles/kind_rule.rb +63 -0
  173. data/lib/vehicles/labels.rb +131 -0
  174. data/lib/vehicles/make.rb +45 -19
  175. data/lib/vehicles/model.rb +66 -19
  176. data/lib/vehicles/model_match.rb +16 -2
  177. data/lib/vehicles/model_matcher.rb +6 -7
  178. data/lib/vehicles/plates/series.rb +87 -8
  179. data/lib/vehicles/plates/template_fill.rb +2 -2
  180. data/lib/vehicles/plates/templates.rb +9 -5
  181. data/lib/vehicles/plates.rb +54 -8
  182. data/lib/vehicles/providers/hosted_provider.rb +145 -32
  183. data/lib/vehicles/providers/local_provider.rb +2 -2
  184. data/lib/vehicles/refresh_result.rb +74 -0
  185. data/lib/vehicles/refresher.rb +190 -54
  186. data/lib/vehicles/release_checks.rb +72 -0
  187. data/lib/vehicles/text_matcher.rb +19 -10
  188. data/lib/vehicles/validators/vehicle_make_validator.rb +18 -1
  189. data/lib/vehicles/validators/vehicle_model_validator.rb +17 -1
  190. data/lib/vehicles/version.rb +1 -1
  191. data/lib/vehicles.rb +119 -67
  192. data/rakelib/data_refresh.rake +1 -1
  193. data/rakelib/plates_refresh.rake +1 -1
  194. data/sig/vehicles.rbs +67 -13
  195. 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 rendered vehicle imagery.
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 (a call to an endpoint
20
- # that doesn't exist would only cost latency), so the chain falls through
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 rendered variants (:sm 320×180, :md
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
- images(model, color: color)&.dig("variants", size.to_s, "url")
46
- end
47
-
48
- # The full images payload for a model — palette, every variant with
49
- # dimensions, provenance, and the honest color fallback: `color` is what
50
- # the API actually served, `requested_color` what you asked for (a color
51
- # that isn't rendered yet falls back rather than 404ing).
52
- #
53
- # GET /api/v1/vehicles/:kind/:make_slug/:model_slug/images?color=<slug>
54
- #
55
- # `year`/`trim` filters are RESERVED server-side today (the API 422s on
56
- # them by contract, so callers can't silently build on unimplemented
57
- # semantics) — which is why `image` accepts `year:` but never sends it:
58
- # serving the current rendering beats an error until the filter ships.
59
- def images(model, color: nil)
60
- params = {}
61
- params[:color] = color.to_s unless color.to_s.empty?
62
- get("#{API_PREFIX}/vehicles/#{model.kind}/#{model.make_slug}/#{model.model_slug}/images", params)
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
- # Issue a keyed GET and parse JSON. Returns nil on ANY failure (network,
68
- # timeout, non-200, bad JSON) — callers treat nil as "fall back to local
69
- # data". The wire rules (timeout, no retry) are Vehicles::HTTP's.
70
- def get(path, params)
71
- body = HTTP.get(HTTP.url(path, params),
72
- "Authorization" => "Bearer #{Vehicles.configuration.api_key}", "Accept" => "application/json")
73
- body && JSON.parse(body)
74
- rescue StandardError # bad JSON, a malformed api_base_url
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) = nil
23
- def images(_model, color: nil) = 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
@@ -3,33 +3,48 @@
3
3
  require "json"
4
4
 
5
5
  module Vehicles
6
- # Pulls the latest published dataset from the VehiclesDB data repo (config.data_url)
7
- # into a local file cache (config.cache_path). This is how data fixes and new
8
- # makes reach an app WITHOUT a gem upgrade — schedule `Vehicles.refresh!` (e.g.
9
- # a daily job) and loads will prefer the cached data over the bundled snapshot.
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
- # Error-isolated by design: a failed/partial download never corrupts the cache
12
- # and never raises — the gem just keeps serving whatever it already had
13
- # (cached, or the bundled snapshot). Atomic write so a reader never sees a
14
- # half-written file.
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
- # Download + cache the latest dataset. Returns true on success, false on any
21
- # failure (network, non-200, unparseable/empty payload). Never raises.
22
- def refresh!
23
- config = Vehicles.configuration
24
- body = fetch(config.data_url, config.refresh_timeout)
25
- return false unless valid_payload?(body)
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
- write_atomic(config.cache_path, body)
28
- Vehicles.reload! # drop the in-memory dataset so the next access uses fresh data
29
- true
30
- rescue StandardError => e
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
- # A payload is only accepted if it parses and looks like our dataset, so a
55
- # CDN error page / truncated download can never replace good data.
56
- def valid_payload?(body)
57
- return false if body.to_s.empty?
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
- data = JSON.parse(body)
60
- data.is_a?(Hash) && data["makes"].is_a?(Array) && !data["makes"].empty?
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
- false
63
- end
64
-
65
- def fetch(url, timeout, redirects_left = MAX_REDIRECTS)
66
- require "net/http"
67
- require "uri"
68
-
69
- uri = URI(url)
70
- http = Net::HTTP.new(uri.host, uri.port)
71
- http.use_ssl = uri.scheme == "https"
72
- http.open_timeout = timeout
73
- http.read_timeout = timeout
74
-
75
- response = http.get(uri.request_uri, "Accept" => "application/json")
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