vehicles 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 02e8cd34e8d4383e6c5859d20ca3aa735d55f6829ff062a7bd48b482b417f4b2
4
+ data.tar.gz: 26c5f4d78dd4e01c38d9d5b2a85319da52b910091deffc4d54ec4f29e510cafd
5
+ SHA512:
6
+ metadata.gz: 86fb03f91835b6449bd8becd1719fb01c84c51ec7822e9d567660f28ef35ccf867cffb688e7200dc50d7702cdeb401e0cc3550ca12059b6815ceded826fd339b
7
+ data.tar.gz: 4b732abbcb7e038457996a1e0633200c5ec57be5f1486d40a01f48f01f5b93ad3712d075e9d7c4b56e5cd19567f76313939a70630e0fbeeaa400718849dbbbe4
data/CHANGELOG.md ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-06-23
11
+
12
+ Initial release.
13
+
14
+ ### Added
15
+ - Bundled, zero-config dataset of **EU car make/model nameplates** (~47 makes,
16
+ ~456 models, dataset version `2026.06.0`), derived from RDW Open Data (CC-BY 4.0).
17
+ - Each model carries a `kind` (`:car` today) and a curated `body_type`
18
+ (`:hatchback`, `:sedan`, `:suv`, `:mpv`, `:coupe`, `:wagon`, `:convertible`,
19
+ `:roadster`, `:van`).
20
+ - Core query API: `Vehicles.makes`, `Vehicles.models`, `Vehicles.make`,
21
+ `Vehicles.find`, `Vehicles.model(make, model)`, `Vehicles.search`, with
22
+ `kind:` / `body_type:` / `region:` filters.
23
+ - `Vehicles.catalog(kind:, region:)` — a `{ make => [model names] }` map, so a
24
+ dependent make→model picker can be built fully client-side (embed once, no
25
+ route/controller/fetch). The recommended dropdown recipe in the README.
26
+ - **Data refresh** (optional): `Vehicles.refresh!` pulls the latest published
27
+ dataset (from the VehiclesDB data repo via CDN) into a local file cache; loads
28
+ prefer the cache over the bundled snapshot, so data fixes reach an app **without
29
+ a gem upgrade**. Bundled data remains the offline, zero-config floor. Config:
30
+ `data_url` / `cache_path` / `use_cache`. The install generator drops a
31
+ schedulable `VehiclesRefreshJob`. Error-isolated (never raises; a bad download
32
+ never clobbers good data).
33
+ - Canonical color palette: `Vehicles.colors`, `Vehicles.color(query)` (forgiving,
34
+ with synonyms), `Vehicles.color_options`, and the `Vehicles::Color` value object
35
+ (slug/name/hex) — shared vocabulary for color dropdowns and future image variants.
36
+ - Rails dropdown helpers: `Vehicles.make_options`, `Vehicles.model_options`.
37
+ - Forgiving lookups: case-, diacritic-, slug-, and alias-insensitive
38
+ (`"VW"`, `"merc"`, `"Vauxhall"`, `"Škoda"` all resolve).
39
+ - `Vehicles::Make` and `Vehicles::Model` value objects with predicate sugar
40
+ (`car.suv?`, `car.hatchback?`) and `to_h`.
41
+ - Drop-in ActiveModel validators: `vehicle_make` and `vehicle_model`.
42
+ - Provider seam (`LocalProvider` + `HostedProvider`) for optional hosted
43
+ VehiclesDB enrichment (`model.years` / `#segment` / `#image`), gated on an
44
+ API key and degrading gracefully to local data.
45
+ - Install generator that writes a configuration initializer (no migration —
46
+ the gem has no database table).
47
+
48
+ [Unreleased]: https://github.com/rameerez/vehicles/compare/v0.1.0...HEAD
49
+ [0.1.0]: https://github.com/rameerez/vehicles/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,27 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Javi R
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
22
+
23
+ ---
24
+
25
+ NOTE ON DATA LICENSING: The bundled dataset in `data/vehicles.json` is NOT
26
+ covered by the MIT license above. It is licensed under CC-BY 4.0 and is derived
27
+ from RDW Open Data (CC0). See the README for attribution requirements.
data/README.md ADDED
@@ -0,0 +1,528 @@
1
+ # 🚗 `vehicles` – Car makes & models for your Rails app
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/vehicles.svg)](https://badge.fury.io/rb/vehicles) [![Build Status](https://github.com/rameerez/vehicles/workflows/Tests/badge.svg)](https://github.com/rameerez/vehicles/actions)
4
+
5
+ > [!TIP]
6
+ > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=vehicles)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=vehicles)!
7
+
8
+ `vehicles` gives your Rails app a clean, curated list of car makes and models — ready for dropdowns, search, and validation. No API keys, no database table, no migration — it works **fully offline** the second you `bundle install`, because the data ships inside the gem. (Optionally, it can [refresh](#staying-current-optional) the data without a gem upgrade.)
9
+
10
+ ✨ Perfect for marketplaces, carpooling & rideshare apps, fleet tools, parking & EV-charging apps, insurance and booking forms — anywhere a user has to pick their vehicle.
11
+
12
+ Check out my other 💎 Ruby gems: [`usage_credits`](https://github.com/rameerez/usage_credits) · [`profitable`](https://github.com/rameerez/profitable) · [`api_keys`](https://github.com/rameerez/api_keys) · [`nondisposable`](https://github.com/rameerez/nondisposable) · [`trackdown`](https://github.com/rameerez/trackdown)
13
+
14
+ ## 👨‍💻 Example
15
+
16
+ ```ruby
17
+ Vehicles.makes
18
+ # => ["Alfa Romeo", "Audi", "BMW", "BYD", "Citroën", "Cupra", "Dacia", "Fiat", ...]
19
+
20
+ Vehicles.models("VW") # alias-aware — "VW" just works
21
+ # => ["Golf", "Polo", "Tiguan", "Passat", "T-Roc", "ID.3", "ID.4", "T-Cross", ...]
22
+
23
+ car = Vehicles.find("vw golf") # one fuzzy lookup, make + model in a single string
24
+ car.make # => "Volkswagen"
25
+ car.name # => "Golf"
26
+ car.full_name # => "Volkswagen Golf"
27
+ car.kind # => :car
28
+ car.body_type # => :hatchback
29
+ car.hatchback? # => true
30
+ ```
31
+
32
+ …and the whole reason this gem exists — a make → model picker in two lines:
33
+
34
+ ```erb
35
+ <%= form.select :make, Vehicles.make_options, prompt: "Make" %>
36
+ <%= form.select :model, Vehicles.model_options(@car.make), prompt: "Model" %>
37
+ ```
38
+
39
+ That's it. A working dropdown, with zero setup and zero network calls. You can finally delete that hand-maintained `MAKES = [...]` constant.
40
+
41
+ ## Installation
42
+
43
+ Add it to your Gemfile:
44
+
45
+ ```ruby
46
+ gem "vehicles"
47
+ ```
48
+
49
+ And run `bundle install`. **That's the whole setup.** No generator, no migration, no API key, no seed task — the dataset is bundled and loaded lazily into memory on first use.
50
+
51
+ > [!NOTE]
52
+ > `vehicles` works in **any Ruby project**, not just Rails. The Rails bits (form option helpers, validators) light up automatically when Rails is present, and stay out of your way when it isn't.
53
+
54
+ ## The basics
55
+
56
+ Two methods cover 90% of what you need:
57
+
58
+ ```ruby
59
+ Vehicles.makes
60
+ # => ["Alfa Romeo", "Audi", "BMW", ...] (alphabetical, ~47 makes)
61
+
62
+ Vehicles.models("Toyota")
63
+ # => ["Yaris", "Corolla", "Aygo", "RAV4", "C-HR", "Prius", ...] (most common first)
64
+ ```
65
+
66
+ Make lookups are **forgiving by default** — case-insensitive, slug-friendly, and alias-aware, so whatever your users type tends to land:
67
+
68
+ ```ruby
69
+ Vehicles.models("toyota") # case-insensitive
70
+ Vehicles.models("VW") # => Volkswagen (common abbreviation)
71
+ Vehicles.models("merc") # => Mercedes-Benz
72
+ Vehicles.models("alfa-romeo") # slug form
73
+ ```
74
+
75
+ Unknown make? You get an empty array, never an exception:
76
+
77
+ ```ruby
78
+ Vehicles.models("DeLorean") # => []
79
+ ```
80
+
81
+ ## Smart lookup & search
82
+
83
+ When you want the rich object instead of a plain list, ask for it:
84
+
85
+ ```ruby
86
+ make = Vehicles.make("Audi") # => #<Vehicles::Make "Audi">
87
+ make.name # => "Audi"
88
+ make.slug # => "audi"
89
+ make.kinds # => [:car]
90
+ make.models # => ["A3", "A4", "Q3", "Q5", ...]
91
+ make.model("a3") # => #<Vehicles::Model "Audi A3">
92
+ ```
93
+
94
+ `find` resolves a free-text "make + model" string into a single model — great for normalizing messy input:
95
+
96
+ ```ruby
97
+ Vehicles.find("vw golf") # => #<Vehicles::Model "Volkswagen Golf">
98
+ Vehicles.find("Mercedes C Class") # => #<Vehicles::Model "Mercedes-Benz C-Class">
99
+ Vehicles.find("nope nope") # => nil
100
+ ```
101
+
102
+ `search` returns every model that matches, ranked — perfect for an autocomplete endpoint:
103
+
104
+ ```ruby
105
+ Vehicles.search("golf")
106
+ # => [#<Model "Volkswagen Golf">]
107
+
108
+ Vehicles.search("3")
109
+ # => [#<Model "BMW 3 Series">, #<Model "Mazda 3">, #<Model "Citroën C3">, ...]
110
+ ```
111
+
112
+ Every `Vehicles::Model` reads like English and serializes cleanly:
113
+
114
+ ```ruby
115
+ car = Vehicles.find("seat leon")
116
+ car.make # => "SEAT"
117
+ car.name # => "Leon"
118
+ car.full_name # => "SEAT Leon"
119
+ car.slug # => "seat-leon"
120
+ car.to_h # => { make: "SEAT", model: "Leon", slug: "seat-leon",
121
+ # kind: :car, body_type: :hatchback }
122
+ ```
123
+
124
+ ## Kinds & body types
125
+
126
+ Every vehicle is classified on two axes, so you can filter, group, and label without maintaining your own taxonomy.
127
+
128
+ **`kind`** — what *sort* of vehicle it is. Sourced straight from official registration data:
129
+
130
+ ```ruby
131
+ :car · :motorcycle · :van · :truck · :pickup · :trailer · :bus · :moped · :quad
132
+ ```
133
+
134
+ **`body_type`** — the sub-classification *within* a kind:
135
+
136
+ ```ruby
137
+ # cars :hatchback :sedan :wagon :suv :mpv :coupe :convertible :roadster :pickup :van
138
+ # motorcycles :naked :sport :adventure :trail :enduro :motocross :scooter :cruiser :touring
139
+ ```
140
+
141
+ ```ruby
142
+ Vehicles.find("vw tiguan").kind # => :car
143
+ Vehicles.find("vw tiguan").body_type # => :suv
144
+ Vehicles.find("vw tiguan").suv? # => true ← predicate sugar for every body_type
145
+ ```
146
+
147
+ Filter any list by either axis:
148
+
149
+ ```ruby
150
+ Vehicles.models("Toyota", body_type: :suv) # => ["RAV4", "C-HR", "Yaris Cross", ...]
151
+ Vehicles.makes(kind: :car) # => makes that build cars (the default)
152
+ Vehicles.makes(kind: :motorcycle) # => makes that build bikes
153
+ Vehicles.search("golf").select(&:hatchback?)
154
+ ```
155
+
156
+ > [!NOTE]
157
+ > **Today the bundled data is cars** (`kind: :car`), each tagged with a `body_type` from the source registration data. `kind` and `body_type` are first-class on every record, so when motorcycle, van, and trailer packs land, the API — and your code — doesn't change. Market *segments* (supercar, sports car, city car, …) are an editorial layer that arrives with [VehiclesDB](#-more-with-vehiclesdb).
158
+
159
+ ## Colors
160
+
161
+ A small **canonical color palette** ships with the gem — the handful of colors
162
+ that cover virtually every car, plus an explicit `other`. It's the shared
163
+ vocabulary for color dropdowns today and for color-accurate imagery from the
164
+ hosted API later, so "red" means the same thing everywhere.
165
+
166
+ ```ruby
167
+ Vehicles.colors
168
+ # => [#<Color white "White" #F4F4F4>, #<Color black "Black" #1B1B1B>, ...]
169
+
170
+ Vehicles.color("navy") # forgiving: synonyms, case, diacritics
171
+ # => #<Vehicles::Color blue "Blue" #27408B>
172
+ Vehicles.color("navy").slug # => "blue" ← the stable value you store
173
+ Vehicles.color("navy").hex # => "#27408B" (representative swatch)
174
+
175
+ Vehicles.color_options # => [["White", "white"], ["Black", "black"], ...]
176
+ ```
177
+
178
+ Names are English — store the **slug** and localize the label in your app (the
179
+ slug is also what maps to image color-variants down the line).
180
+
181
+ ## Rails dropdowns
182
+
183
+ The `*_options` helpers return `[[label, value], ...]` pairs, exactly what Rails' `select` wants:
184
+
185
+ ```ruby
186
+ Vehicles.make_options
187
+ # => [["Alfa Romeo", "alfa-romeo"], ["Audi", "audi"], ["BMW", "bmw"], ...]
188
+
189
+ Vehicles.model_options("audi")
190
+ # => [["A3", "a3"], ["A4", "a4"], ["Q3", "q3"], ...]
191
+ ```
192
+
193
+ ### Dependent make → model picker
194
+
195
+ The whole dataset is small, so the simplest, snappiest way to wire a dependent
196
+ picker is **client-side**: embed [`Vehicles.catalog`](#the-full-ruby-api) once and
197
+ switch the model list with no request at all — **no route, no controller, no
198
+ fetch.**
199
+
200
+ ```erb
201
+ <div data-controller="vehicle-picker"
202
+ data-vehicle-picker-catalog-value="<%= Vehicles.catalog.to_json %>">
203
+ <%= form.select :make, Vehicles.makes, { include_blank: "Make" },
204
+ data: { vehicle_picker_target: "make", action: "change->vehicle-picker#makeChanged" } %>
205
+ <%= form.select :model, Vehicles.models(@car&.make), { include_blank: "Model" },
206
+ data: { vehicle_picker_target: "model" } %>
207
+ </div>
208
+ ```
209
+
210
+ ```js
211
+ // app/javascript/controllers/vehicle_picker_controller.js
212
+ import { Controller } from "@hotwired/stimulus"
213
+
214
+ export default class extends Controller {
215
+ static targets = ["make", "model"]
216
+ static values = { catalog: Object } // { "Audi": ["A3", ...], ... }
217
+
218
+ makeChanged() {
219
+ const models = this.catalogValue[this.makeTarget.value] || []
220
+ const previous = this.modelTarget.value
221
+ this.modelTarget.replaceChildren(new Option("Model", ""))
222
+ for (const name of models) {
223
+ this.modelTarget.add(new Option(name, name, false, name === previous))
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ That's the whole picker — the gem ships the data, you keep your markup and ~12
230
+ lines of JS. (Storing slugs instead of names? Swap the selects for
231
+ `make_options` / `model_options` and key the catalog by slug.)
232
+
233
+ > [!TIP]
234
+ > Prefer a server round-trip (huge/remote dataset, or you'd rather not embed it)?
235
+ > It's a three-line endpoint — `render json: Vehicles.models(params[:make])` — and
236
+ > the controller fetches that on `makeChanged` instead of reading the embedded
237
+ > catalog. Same gem API, your call.
238
+
239
+ ## Validations
240
+
241
+ Drop-in ActiveModel validators, so bad data never reaches your database:
242
+
243
+ ```ruby
244
+ class Car < ApplicationRecord
245
+ validates :make, vehicle_make: true # must be a real make
246
+ validates :model, vehicle_model: { make: :make } # must be a real model of that make
247
+ end
248
+ ```
249
+
250
+ ```ruby
251
+ Car.new(make: "Volkswagen", model: "Golf").valid? # => true
252
+ Car.new(make: "Volkswagen", model: "Mustang").valid? # => false
253
+ Car.new(make: "Tesler", model: "Model 3").valid? # => false
254
+ ```
255
+
256
+ They're forgiving the same way the lookups are (aliases, case, slugs), and they never raise on blank/garbage input — they just add an error.
257
+
258
+ ## Recommended integration
259
+
260
+ Here's the pattern that works cleanly end to end — and a reference schema for the
261
+ columns a vehicle record actually needs.
262
+
263
+ **A record stores its vehicle's *identity*, not the reference data.** Make,
264
+ model, year, and color identify the car; `kind` and `body_type` (and, later,
265
+ specs/images) are *properties of that make+model* owned by the dataset — look
266
+ them up via the gem, don't duplicate them per row (they'd just drift). So the
267
+ schema is small and stable:
268
+
269
+ | Column | Type | Store | Example |
270
+ |----------|-----------|-----------------------------------------|---------------|
271
+ | `make` | `string` | display name | `"Volkswagen"`|
272
+ | `model` | `string` | display name | `"Golf"` |
273
+ | `year` | `integer` | the year | `2022` |
274
+ | `color` | `string` | a canonical color **slug** | `"blue"` |
275
+
276
+ Everything else (`kind`, `body_type`, production years, images, …) is derived:
277
+ `Vehicles.model(record.make, record.model)`. No `kind`/`body_type` columns, no
278
+ migration to add new metadata later — it lands in the dataset, not your schema.
279
+
280
+ **Store the display name.** The simplest, most readable thing to persist is the
281
+ name itself (`"Volkswagen"`, `"Golf"`). Populate the selects with the plain
282
+ **name lists**, where the option value *is* the name:
283
+
284
+ ```erb
285
+ <%= form.select :make, Vehicles.makes, { include_blank: "Make" } %>
286
+ <%= form.select :model, Vehicles.models(@car.make), { include_blank: "Model" } %>
287
+ <%= form.select :year, (Date.current.year + 1).downto(1990).to_a, { include_blank: "Year" } %>
288
+ <%= form.select :color, Vehicles.color_options, { include_blank: "Color" } %>
289
+ ```
290
+
291
+ > [!TIP]
292
+ > Use `Vehicles.makes` / `Vehicles.models` (name = value) when your column stores
293
+ > the **display name**. Use `Vehicles.make_options` / `Vehicles.model_options`
294
+ > (which pair `[name, slug]`) only when you store the **slug** — then read the
295
+ > name back with `Vehicles.make(slug).name`. Pick one and stay consistent.
296
+
297
+ **Validate what you store** — the dropdowns already constrain input, but the
298
+ validators guard every other write path (imports, console, your own API):
299
+
300
+ ```ruby
301
+ validates :make, vehicle_make: true, allow_blank: true
302
+ validates :model, vehicle_model: { make: :make }, allow_blank: true
303
+ validates :color, inclusion: { in: Vehicles.colors.map(&:slug) }, allow_blank: true
304
+ ```
305
+
306
+ **Read the metadata back** from a stored pair whenever you need it — kind,
307
+ body type, and (with the hosted API) years/segment/image:
308
+
309
+ ```ruby
310
+ car = Vehicles.model(record.make, record.model) # => Vehicles::Model | nil
311
+ car&.body_type # => :hatchback
312
+ car&.suv? # => false
313
+ ```
314
+
315
+ **Only accept the kinds you support.** Filter every list/lookup by `kind:` so
316
+ the picker only ever offers vehicles your app handles — and widening later (when
317
+ new kind packs ship, or when you decide to accept them) is a one-word change, not
318
+ a migration:
319
+
320
+ ```ruby
321
+ Vehicles.makes(kind: :car) # makes that build cars
322
+ Vehicles.models("Toyota", kind: :car) # ...their cars only
323
+ ```
324
+
325
+ No migration, no seed task, no API key — the data is bundled and read from
326
+ memory. The whole integration is the markup above plus a 3-line endpoint for the
327
+ dependent model list (see [Rails dropdowns](#rails-dropdowns)).
328
+
329
+ ## Country & region awareness
330
+
331
+ Vehicle availability is regional — a Vauxhall in the UK is an Opel on the continent, a Holden only ever shipped in Australia. `vehicles` is built around this from day one:
332
+
333
+ ```ruby
334
+ Vehicles.makes(region: :eu) # makes sold in the EU (the default today)
335
+ Vehicles.models("Toyota", region: :eu)
336
+ ```
337
+
338
+ > [!NOTE]
339
+ > **Today the bundled data covers the EU market** (~47 makes, ~460 model nameplates, sourced from the Dutch national vehicle register — see [Where the data comes from](#where-the-data-comes-from)). `:us`, `:gb`, `:au`, `:nz`, and `:ca` packs are on the [roadmap](#roadmap). The region API is already in place, so adding them is additive, never a breaking change.
340
+
341
+ Set your app's default once:
342
+
343
+ ```ruby
344
+ Vehicles.configure do |config|
345
+ config.region = :eu
346
+ end
347
+ ```
348
+
349
+ ## 🔓 More with VehiclesDB
350
+
351
+ `vehicles` is the free, open-source SDK for [**VehiclesDB**](https://vehiclesdb.com) — a hosted API for richer vehicle data. The gem is **fully standalone and always will be**; pointing it at VehiclesDB is purely additive. Drop in an API key and the same objects you already use light up with more:
352
+
353
+ ```ruby
354
+ Vehicles.configure do |config|
355
+ config.api_key = ENV["VEHICLESDB_API_KEY"] # optional — unlocks hosted data
356
+ end
357
+
358
+ car = Vehicles.find("vw golf")
359
+ car.years # => 1974..2024 production years
360
+ car.segment # => :hatchback / :hot_hatch editorial segment
361
+ car.image # => "https://cdn.vehiclesdb.com/volkswagen/golf.webp"
362
+ car.image(year: 2020) # year-accurate photo
363
+ car.image(year: 2020, color: :silver)
364
+ ```
365
+
366
+ Under the hood this is a simple **provider** model: a `LocalProvider` (the bundled data) is always available, and a `HostedProvider` activates only when an API key is configured. Calls prefer the hosted data when it's there and **fall back to the local data otherwise — never raising, never blocking** your request:
367
+
368
+ ```ruby
369
+ car.image # no API key configured? => nil (your views just render a placeholder)
370
+ car.years # not in the local pack yet? => nil, until you add a key or we ship it locally
371
+ ```
372
+
373
+ So you can build your UI against the full API today, ship on the free local data, and flip on richer data later without touching your code.
374
+
375
+ ## Configuration
376
+
377
+ Everything has a sensible default; you can run the gem without configuring anything. When you do want to tune it:
378
+
379
+ ```ruby
380
+ # config/initializers/vehicles.rb
381
+ Vehicles.configure do |config|
382
+ config.region = :eu # default region for queries
383
+ config.api_key = ENV["VEHICLESDB_API_KEY"] # optional hosted VehiclesDB data
384
+ config.aliases = { "Chevy" => "Chevrolet" } # add your own make aliases
385
+ config.use_cache = true # prefer a refreshed dataset over the bundled one
386
+ end
387
+ ```
388
+
389
+ ## Staying current (optional)
390
+
391
+ The bundled snapshot works offline forever — but vehicle data changes (new
392
+ makes, fixes), and you shouldn't have to ship a gem upgrade to every app just to
393
+ get them. So `vehicles` can **refresh** from the published
394
+ [dataset](https://github.com/vehiclesdb/vehiclesdb) into a local file cache;
395
+ loads prefer the cache over the bundled copy. Data fixes reach your app **without
396
+ a gem bump.**
397
+
398
+ `rails g vehicles:install` drops a `VehiclesRefreshJob` — schedule it (daily is
399
+ plenty), e.g. with solid_queue:
400
+
401
+ ```yaml
402
+ # config/recurring.yml
403
+ vehicles_refresh:
404
+ class: VehiclesRefreshJob
405
+ schedule: every day at 3am
406
+ ```
407
+
408
+ Or refresh manually:
409
+
410
+ ```ruby
411
+ Vehicles.refresh! # pull latest -> cache; true/false, never raises
412
+ Vehicles.data_version # => the version now in effect (cached, or bundled)
413
+ ```
414
+
415
+ It's safe by design: a failed/partial download never replaces good data, and the
416
+ gem keeps serving the cache (or the bundled snapshot) no matter what. Want it
417
+ fully offline/deterministic? `config.use_cache = false`.
418
+
419
+ ## The full Ruby API
420
+
421
+ ```ruby
422
+ # Lists (strings — drop straight into a form)
423
+ Vehicles.makes # => [String]
424
+ Vehicles.makes(kind: :car, region: :eu)
425
+ Vehicles.models("Audi") # => [String]
426
+ Vehicles.models("Audi", body_type: :suv)
427
+
428
+ # Option pairs (for Rails `select`)
429
+ Vehicles.make_options # => [[label, value]]
430
+ Vehicles.model_options("Audi") # => [[label, value]]
431
+
432
+ # Whole make => [models] map (embed once for a client-side dependent picker)
433
+ Vehicles.catalog(kind: :car) # => { "Audi" => ["A3", ...], ... }
434
+
435
+ # Objects
436
+ Vehicles.make("Audi") # => Vehicles::Make | nil
437
+ Vehicles.find("audi a3") # => Vehicles::Model | nil (one free-text string)
438
+ Vehicles.model("Audi", "A3") # => Vehicles::Model | nil (a stored make+model pair)
439
+ Vehicles.search("a3") # => [Vehicles::Model]
440
+
441
+ # Vehicles::Make
442
+ make.name make.slug make.aliases make.kinds
443
+ make.models make.model("a3") make.to_h
444
+
445
+ # Vehicles::Model
446
+ model.make model.name model.full_name model.slug model.to_h
447
+ model.kind model.body_type model.suv? model.coupe? # …predicates
448
+ model.years model.segment model.image(year:, color:) # ← hosted VehiclesDB API
449
+
450
+ # Colors (canonical palette)
451
+ Vehicles.colors # => [Vehicles::Color]
452
+ Vehicles.color("navy") # => Vehicles::Color | nil (forgiving)
453
+ Vehicles.color_options # => [[name, slug]] (for select)
454
+ color.slug color.name color.hex
455
+
456
+ # Meta
457
+ Vehicles.data_version # => "2026.06.0" (version in effect: cached or bundled)
458
+ Vehicles.region # => :eu
459
+
460
+ # Refresh (optional — keep data current without a gem upgrade)
461
+ Vehicles.refresh! # pull latest published data -> cache; true/false, never raises
462
+ Vehicles.reload! # drop the in-memory dataset (reload from disk)
463
+ ```
464
+
465
+ ## Where the data comes from
466
+
467
+ The bundled dataset is built from [**RDW Open Data**](https://opendata.rdw.nl/) — the Dutch national vehicle register, effectively a census of every vehicle on EU roads — aggregated to clean **nameplates** (trims and generations collapsed: one "Golf", one "3 Series"), ranked by how many are actually registered, with the long tail of kit cars and gray imports filtered out.
468
+
469
+ Every record is shaped like this:
470
+
471
+ ```json
472
+ {
473
+ "name": "Volkswagen", "slug": "volkswagen", "kinds": ["car"],
474
+ "models": [
475
+ { "name": "Golf", "slug": "golf", "kind": "car", "body_type": "hatchback" },
476
+ { "name": "Tiguan", "slug": "tiguan", "kind": "car", "body_type": "suv" },
477
+ { "name": "Touran", "slug": "touran", "kind": "car", "body_type": "mpv" }
478
+ ]
479
+ }
480
+ ```
481
+
482
+ - **`kind` is sourced, not guessed** — straight from RDW's `voertuigsoort`, the same classification the government uses. **`body_type` is curated** on top of RDW's `inrichting` (which is too coarse to use raw — it lumps wagons, SUVs and crossovers together), mapped to a clean canonical vocabulary.
483
+ - **Nameplate-level, on purpose.** "Golf" covers the GTI, the Variant, the R. "3 Series" covers every 3-series trim. That's what a dropdown wants. Per-trim and per-generation detail is a VehiclesDB API concern.
484
+ - **Source data:** RDW Open Data, licensed **CC0**.
485
+ - **This dataset** (`data/vehicles.json`): **CC-BY 4.0**. Attribution: *"Vehicle data from VehiclesDB, derived from RDW Open Data."*
486
+ - **The gem code:** MIT.
487
+
488
+ The data is versioned (`Vehicles.data_version`). Each gem release bundles a known snapshot — the offline, deterministic floor. To get newer data, either upgrade the gem or enable [refresh](#staying-current-optional) (which pulls published releases without a gem bump). Either way it's explicit and versioned — no silent mutations.
489
+
490
+ ## How it works
491
+
492
+ No magic, just good defaults:
493
+
494
+ - **Bundled, not fetched.** `data/vehicles.json` is packaged in the gem and loaded into a frozen, memoized in-memory index on first access. First call builds the index; every call after is a hash lookup. No HTTP, no SQLite, no ActiveRecord on the read path.
495
+ - **Zero-config.** No initializer required. No migration. Nothing to schedule.
496
+ - **Standalone first, SDK second.** The hosted VehiclesDB layer is strictly optional and detected at runtime; the gem never hard-depends on it.
497
+ - **Rails-aware, not Rails-bound.** Form helpers and validators register through a lightweight Railtie only when Rails is loaded.
498
+
499
+ ## Roadmap
500
+
501
+ This is a young gem. What's bundled today is EU car make/model data (with `kind` + `body_type`) and the API above. On the way:
502
+
503
+ - 🏍️ More **kinds**: motorcycles, vans, trucks, trailers — the shape already supports them
504
+ - 🌍 More **regions**: `:us` (NHTSA vPIC), `:gb` (DVSA), `:au`, `:nz`, `:ca`
505
+ - 📅 Production **years** in the local data
506
+ - 🖼️ Model **images**, year-accurate and color variants (via VehiclesDB)
507
+ - 🏷️ **Segments** (supercar, sports car, city car, hot hatch) and richer metadata
508
+ - 🔎 A mountable **autocomplete endpoint** so the dependent-dropdown recipe becomes one line
509
+
510
+ Want one of these sooner? Open an issue.
511
+
512
+ ## Testing
513
+
514
+ Run the test suite with `bundle exec rake test`. The gem is tested against multiple Rails versions with Appraisal: `bundle exec appraisal rails-8.0 rake test`.
515
+
516
+ ## Development
517
+
518
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
519
+
520
+ To install this gem onto your local machine, run `bundle exec rake install`.
521
+
522
+ ## Contributing
523
+
524
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/vehicles. Our code of conduct is: just be nice and make your mom proud of what you do and post online.
525
+
526
+ ## License
527
+
528
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). The bundled dataset is licensed CC-BY 4.0 (see [Where the data comes from](#where-the-data-comes-from)).