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 +7 -0
- data/CHANGELOG.md +49 -0
- data/LICENSE.txt +27 -0
- data/README.md +528 -0
- data/data/vehicles.json +3171 -0
- data/lib/generators/vehicles/install_generator.rb +40 -0
- data/lib/generators/vehicles/templates/initializer.rb +33 -0
- data/lib/generators/vehicles/templates/vehicles_refresh_job.rb +20 -0
- data/lib/vehicles/color.rb +41 -0
- data/lib/vehicles/colors.rb +41 -0
- data/lib/vehicles/configuration.rb +87 -0
- data/lib/vehicles/dataset.rb +147 -0
- data/lib/vehicles/make.rb +76 -0
- data/lib/vehicles/model.rb +98 -0
- data/lib/vehicles/providers/hosted_provider.rb +88 -0
- data/lib/vehicles/providers/local_provider.rb +25 -0
- data/lib/vehicles/railtie.rb +17 -0
- data/lib/vehicles/refresher.rb +94 -0
- data/lib/vehicles/validators/vehicle_make_validator.rb +20 -0
- data/lib/vehicles/validators/vehicle_model_validator.rb +28 -0
- data/lib/vehicles/version.rb +5 -0
- data/lib/vehicles.rb +254 -0
- data/sig/vehicles.rbs +94 -0
- metadata +72 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
module Providers
|
|
5
|
+
# The always-available provider, backed by the bundled dataset. It answers
|
|
6
|
+
# whatever the local snapshot knows and returns nil for everything it doesn't
|
|
7
|
+
# (years/segment/image today) — which is exactly what makes graceful
|
|
8
|
+
# degradation work: the hosted provider enriches, this one guarantees the
|
|
9
|
+
# gem never breaks when the hosted data is absent.
|
|
10
|
+
module LocalProvider
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def available?
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The bundled snapshot is make/model/kind/body_type only — richer fields
|
|
18
|
+
# come from the hosted API. Returning nil here lets the resolver move on
|
|
19
|
+
# (and ultimately yield nil) instead of raising.
|
|
20
|
+
def years(_model) = nil
|
|
21
|
+
def segment(_model) = nil
|
|
22
|
+
def image(_model, year:, color:) = nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Vehicles
|
|
6
|
+
# Wires the gem into Rails without forcing anything: it only registers the
|
|
7
|
+
# ActiveModel validators, and only once ActiveRecord/ActiveModel is loaded.
|
|
8
|
+
# There's no engine, no migration, no table — the data lives in the bundled
|
|
9
|
+
# JSON, so there's nothing to install.
|
|
10
|
+
class Railtie < ::Rails::Railtie
|
|
11
|
+
initializer "vehicles.validators" do
|
|
12
|
+
ActiveSupport.on_load(:active_record) { Vehicles.load_validators! }
|
|
13
|
+
# Also cover plain-ActiveModel hosts (e.g. a Tableless model) loaded early.
|
|
14
|
+
Vehicles.load_validators! if defined?(ActiveModel::EachValidator)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
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.
|
|
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.
|
|
15
|
+
module Refresher
|
|
16
|
+
MAX_REDIRECTS = 3
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
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)
|
|
26
|
+
|
|
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
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# True when a refreshed dataset is cached on disk.
|
|
36
|
+
def cached?
|
|
37
|
+
path = Vehicles.configuration.cache_path
|
|
38
|
+
!path.to_s.empty? && File.exist?(path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cached_path
|
|
42
|
+
cached? ? Vehicles.configuration.cache_path : nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Remove the cached dataset (the gem falls back to the bundled snapshot).
|
|
46
|
+
def clear_cache!
|
|
47
|
+
path = Vehicles.configuration.cache_path
|
|
48
|
+
File.delete(path) if path && File.exist?(path)
|
|
49
|
+
Vehicles.reload!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- internals -----------------------------------------------------------
|
|
53
|
+
|
|
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?
|
|
58
|
+
|
|
59
|
+
data = JSON.parse(body)
|
|
60
|
+
data.is_a?(Hash) && data["makes"].is_a?(Array) && !data["makes"].empty?
|
|
61
|
+
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
|
+
response.body
|
|
79
|
+
when Net::HTTPRedirection
|
|
80
|
+
return nil if redirects_left <= 0
|
|
81
|
+
|
|
82
|
+
fetch(response["location"], timeout, redirects_left - 1)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write_atomic(path, body)
|
|
87
|
+
require "fileutils"
|
|
88
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
89
|
+
tmp = "#{path}.#{Process.pid}.tmp"
|
|
90
|
+
File.write(tmp, body)
|
|
91
|
+
File.rename(tmp, path) # atomic on the same filesystem
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Drop-in validator: `validates :make, vehicle_make: true`.
|
|
4
|
+
#
|
|
5
|
+
# Rails resolves the `vehicle_make:` key to this top-level constant automatically
|
|
6
|
+
# (camelize + "Validator"), so no registration is needed — requiring the file is
|
|
7
|
+
# enough. Forgiving (aliases/case/slug) like every other lookup, and defensive:
|
|
8
|
+
# blank values pass (pair with `presence: true` if you want them rejected), and
|
|
9
|
+
# any internal error degrades to a generic message instead of blowing up a form.
|
|
10
|
+
class VehicleMakeValidator < ActiveModel::EachValidator
|
|
11
|
+
def validate_each(record, attribute, value)
|
|
12
|
+
return if value.blank?
|
|
13
|
+
return if Vehicles.make(value)
|
|
14
|
+
|
|
15
|
+
record.errors.add(attribute, options[:message] || "is not a recognized vehicle make")
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
Vehicles.logger&.error("[vehicles] vehicle_make validation error: #{e.message}")
|
|
18
|
+
record.errors.add(attribute, options[:message] || "is not a recognized vehicle make")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Drop-in validator: `validates :model, vehicle_model: { make: :make }`.
|
|
4
|
+
#
|
|
5
|
+
# Checks that the value is a real model OF the make held in another attribute.
|
|
6
|
+
# Pass `make:` pointing at the attribute that holds the make (defaults to :make).
|
|
7
|
+
#
|
|
8
|
+
# validates :model, vehicle_model: { make: :car_make }
|
|
9
|
+
#
|
|
10
|
+
# Defensive by design: blank model passes; an unknown/blank make can't disprove
|
|
11
|
+
# the model, so it passes (let the make's own validator flag that); errors never
|
|
12
|
+
# raise out of a form submission.
|
|
13
|
+
class VehicleModelValidator < ActiveModel::EachValidator
|
|
14
|
+
def validate_each(record, attribute, value)
|
|
15
|
+
return if value.blank?
|
|
16
|
+
|
|
17
|
+
make_attribute = options[:make] || :make
|
|
18
|
+
make_value = record.respond_to?(make_attribute) ? record.public_send(make_attribute) : nil
|
|
19
|
+
make = Vehicles.make(make_value)
|
|
20
|
+
return if make.nil? # unknown make -> defer to the make validator
|
|
21
|
+
return if make.model(value)
|
|
22
|
+
|
|
23
|
+
record.errors.add(attribute, options[:message] || "is not a recognized #{make.name} model")
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Vehicles.logger&.error("[vehicles] vehicle_model validation error: #{e.message}")
|
|
26
|
+
record.errors.add(attribute, options[:message] || "is not a recognized vehicle model")
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/vehicles.rb
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "vehicles/version"
|
|
4
|
+
require_relative "vehicles/configuration"
|
|
5
|
+
require_relative "vehicles/make"
|
|
6
|
+
require_relative "vehicles/model"
|
|
7
|
+
require_relative "vehicles/color"
|
|
8
|
+
require_relative "vehicles/dataset"
|
|
9
|
+
require_relative "vehicles/refresher"
|
|
10
|
+
require_relative "vehicles/providers/local_provider"
|
|
11
|
+
require_relative "vehicles/providers/hosted_provider"
|
|
12
|
+
|
|
13
|
+
# Car makes & models for your Rails app — dropdowns, search, validation. Bundled
|
|
14
|
+
# data, zero config, no network calls. Standalone first; an SDK for the hosted
|
|
15
|
+
# VehiclesDB API second.
|
|
16
|
+
#
|
|
17
|
+
# Vehicles.makes # => ["Alfa Romeo", "Audi", "BMW", ...]
|
|
18
|
+
# Vehicles.models("VW") # => ["Golf", "Polo", "Tiguan", ...]
|
|
19
|
+
# Vehicles.find("vw golf") # => #<Vehicles::Model "Volkswagen Golf">
|
|
20
|
+
# Vehicles.make_options # => [["Alfa Romeo", "alfa-romeo"], ...] (for select)
|
|
21
|
+
module Vehicles
|
|
22
|
+
class Error < StandardError; end
|
|
23
|
+
|
|
24
|
+
# The bundled snapshot, packaged in the gem. Overridable via `Vehicles.data_path=`.
|
|
25
|
+
DATA_PATH = File.expand_path("../data/vehicles.json", __dir__)
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# --- configuration -------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def configuration
|
|
31
|
+
@configuration ||= Configuration.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def configure
|
|
35
|
+
yield(configuration)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Reset config + caches (config, data path, dataset, providers). Primarily
|
|
39
|
+
# for the test suite — genuinely returns the gem to a pristine state.
|
|
40
|
+
def reset_configuration!
|
|
41
|
+
@configuration = Configuration.new
|
|
42
|
+
@providers = nil
|
|
43
|
+
@data_path = nil
|
|
44
|
+
Dataset.reset!
|
|
45
|
+
Providers::HostedProvider.reset!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- data access ---------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
# Path to the bundled snapshot (or an explicit override via `data_path=`).
|
|
51
|
+
def data_path
|
|
52
|
+
@data_path || DATA_PATH
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
attr_writer :data_path, :logger
|
|
56
|
+
|
|
57
|
+
# The dataset file actually in effect: an explicit override wins; otherwise a
|
|
58
|
+
# refreshed cache (if present and `use_cache`); otherwise the bundled snapshot.
|
|
59
|
+
# This is how a refresh reaches the running app — no gem upgrade needed.
|
|
60
|
+
def active_data_path
|
|
61
|
+
return @data_path if @data_path
|
|
62
|
+
return Refresher.cached_path if configuration.use_cache && Refresher.cached?
|
|
63
|
+
|
|
64
|
+
DATA_PATH
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def dataset
|
|
68
|
+
Dataset.load(active_data_path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Pull the latest published dataset into the local cache, so data fixes / new
|
|
72
|
+
# makes land WITHOUT a gem upgrade. Returns true/false; never raises. Schedule
|
|
73
|
+
# it (e.g. a daily job — `rails g vehicles:install` sets one up).
|
|
74
|
+
def refresh!
|
|
75
|
+
Refresher.refresh!
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Drop the in-memory dataset so the next access reloads from disk (after a
|
|
79
|
+
# refresh, a cache clear, or a `data_path=` change).
|
|
80
|
+
def reload!
|
|
81
|
+
Dataset.reset!
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Version of the dataset currently in effect (refreshed cache or bundled),
|
|
85
|
+
# e.g. "2026.06.0".
|
|
86
|
+
def data_version
|
|
87
|
+
dataset.version
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Region the bundled data covers, as a Symbol, e.g. :eu.
|
|
91
|
+
def region
|
|
92
|
+
dataset.region.to_s.downcase.to_sym
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- core query API ------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
# Make display names. => ["Abarth", "Alfa Romeo", ...]
|
|
98
|
+
def makes(kind: nil, region: nil)
|
|
99
|
+
dataset.makes(kind: kind, region: region || configuration.region).map(&:name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Model display names for a make. => ["Golf", "Polo", ...]. Unknown make => [].
|
|
103
|
+
def models(make, kind: nil, body_type: nil, region: nil)
|
|
104
|
+
region ||= configuration.region
|
|
105
|
+
return [] if region && !dataset.region?(region)
|
|
106
|
+
|
|
107
|
+
found = make(make)
|
|
108
|
+
found ? found.models(kind: kind, body_type: body_type).map(&:name) : []
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The rich Make object (or nil). Forgiving: name, slug, or alias.
|
|
112
|
+
def make(query)
|
|
113
|
+
dataset.find_make(query)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Resolve a free-text "make + model" string into one Model (or nil).
|
|
117
|
+
def find(query)
|
|
118
|
+
dataset.find_model(query)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Resolve a stored make + model PAIR into a Model (or nil). The structured
|
|
122
|
+
# counterpart to `find` (which parses one free-text string) — reach for this
|
|
123
|
+
# when your records keep make and model in separate columns and you want the
|
|
124
|
+
# model's metadata (kind, body_type, …) back.
|
|
125
|
+
# Vehicles.model("Audi", "A3") # => #<Vehicles::Model "Audi A3">
|
|
126
|
+
# Vehicles.model("vw", "golf") # forgiving, like every other lookup
|
|
127
|
+
def model(make_name, model_name)
|
|
128
|
+
found = make(make_name)
|
|
129
|
+
found&.model(model_name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Every model matching a query, ranked. => [Vehicles::Model, ...]
|
|
133
|
+
def search(query)
|
|
134
|
+
dataset.search(query)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# --- colors (canonical reference palette) --------------------------------
|
|
138
|
+
|
|
139
|
+
# The canonical color palette, frequency-ordered. => [Vehicles::Color, ...]
|
|
140
|
+
def colors
|
|
141
|
+
Colors::ALL
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# [[name, slug], ...] for a Rails `select`. Names are English — localize the
|
|
145
|
+
# labels in your app; the slug is the stable value you store.
|
|
146
|
+
def color_options
|
|
147
|
+
Colors::ALL.map { |c| [c.name, c.slug] }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Resolve a color by slug or name (forgiving: case, diacritics, synonyms like
|
|
151
|
+
# "gray"→grey, "navy"→blue). Returns a Vehicles::Color, or nil.
|
|
152
|
+
def color(query)
|
|
153
|
+
q = normalize(query)
|
|
154
|
+
return nil if q.empty?
|
|
155
|
+
|
|
156
|
+
Colors::BY_SLUG[q] || Colors::BY_NAME[q] || Colors::BY_SLUG[Colors::SYNONYMS[q]]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# [[label, value], ...] of makes for a Rails `select`.
|
|
160
|
+
def make_options(kind: nil, region: nil)
|
|
161
|
+
dataset.makes(kind: kind, region: region || configuration.region).map { |m| [m.name, m.slug] }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# [[label, value], ...] of a make's models for a Rails `select`. Unknown => [].
|
|
165
|
+
def model_options(make, kind: nil, body_type: nil)
|
|
166
|
+
found = make(make)
|
|
167
|
+
found ? found.model_options(kind: kind, body_type: body_type) : []
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# A { make => [model names] } map for the given filters — everything you need
|
|
171
|
+
# to build a dependent make → model picker entirely on the client: embed it
|
|
172
|
+
# once (`Vehicles.catalog(kind: :car).to_json`) and switch the model list in a
|
|
173
|
+
# few lines of JS. No endpoint, no extra request, instant. The whole car
|
|
174
|
+
# catalog is small (tens of KB), so this is the simplest path for most apps.
|
|
175
|
+
# Vehicles.catalog(kind: :car) # => { "Audi" => ["A3", "A4", ...], ... }
|
|
176
|
+
def catalog(kind: nil, region: nil)
|
|
177
|
+
makes(kind: kind, region: region).to_h do |name|
|
|
178
|
+
[name, models(name, kind: kind, region: region)]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# --- provider resolution (hosted enrichment, optional) -------------------
|
|
183
|
+
|
|
184
|
+
# Ask each available provider for an attribute, hosted first, until one gives
|
|
185
|
+
# a non-nil answer. Returns nil if none can. Never raises. Backs Model#years
|
|
186
|
+
# / #segment / #image.
|
|
187
|
+
def resolve(attribute, model, **opts)
|
|
188
|
+
providers.each do |provider|
|
|
189
|
+
next unless provider.available?
|
|
190
|
+
|
|
191
|
+
value = provider.public_send(attribute, model, **opts)
|
|
192
|
+
return value unless value.nil?
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
# A misbehaving provider must never break a model read — log and move on.
|
|
195
|
+
logger&.error("[vehicles] provider #{provider} failed on #{attribute}: #{e.message}")
|
|
196
|
+
next
|
|
197
|
+
end
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def providers
|
|
202
|
+
@providers ||= [Providers::HostedProvider, Providers::LocalProvider]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# --- helpers -------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
# Match-normalize a string: fold diacritics, downcase, collapse anything
|
|
208
|
+
# non-alphanumeric to single spaces. "Mercedes-Benz" => "mercedes benz",
|
|
209
|
+
# "Škoda" => "skoda". Used everywhere lookups need to be forgiving — so it must
|
|
210
|
+
# NEVER raise (the API contract is "garbage in => empty/nil out, not an error").
|
|
211
|
+
def normalize(str)
|
|
212
|
+
fold_diacritics(str).downcase.gsub(/[^a-z0-9]+/, " ").strip
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Slugify a display name: "Alfa Romeo" => "alfa-romeo", "Škoda" => "skoda".
|
|
216
|
+
def slugify(str)
|
|
217
|
+
fold_diacritics(str).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/(\A-|-\z)/, "")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Shared diacritic folding for normalize/slugify. NFKD splits "š" into "s" + a
|
|
221
|
+
# combining caron; we strip the combining marks (\p{Mn}) BEFORE the separator
|
|
222
|
+
# gsub, or "Škoda" would become "s koda". Defends against non-UTF-8/invalid
|
|
223
|
+
# encodings (e.g. a binary string) so callers never hit Encoding errors.
|
|
224
|
+
def fold_diacritics(str)
|
|
225
|
+
s = str.to_s
|
|
226
|
+
s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
|
|
227
|
+
s = s.scrub("") unless s.valid_encoding?
|
|
228
|
+
s.unicode_normalize(:nfkd).gsub(/\p{Mn}+/, "")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def logger
|
|
232
|
+
@logger ||= (defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Require the ActiveModel validators (idempotent; no-op without ActiveModel).
|
|
236
|
+
def load_validators!
|
|
237
|
+
return if @validators_loaded
|
|
238
|
+
return unless defined?(ActiveModel::EachValidator)
|
|
239
|
+
|
|
240
|
+
require_relative "vehicles/validators/vehicle_make_validator"
|
|
241
|
+
require_relative "vehicles/validators/vehicle_model_validator"
|
|
242
|
+
@validators_loaded = true
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Loaded after the module so its lookup tables can use Vehicles.normalize.
|
|
248
|
+
require_relative "vehicles/colors"
|
|
249
|
+
|
|
250
|
+
# Rails integration is opt-in and detected at load time — the gem works fine in
|
|
251
|
+
# plain Ruby without it.
|
|
252
|
+
require_relative "vehicles/railtie" if defined?(Rails::Railtie)
|
|
253
|
+
# If ActiveModel is already present (e.g. required directly), wire validators now.
|
|
254
|
+
Vehicles.load_validators! if defined?(ActiveModel::EachValidator)
|
data/sig/vehicles.rbs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Type signatures for the public API. (Best-effort; the README and code are the
|
|
2
|
+
# source of truth.)
|
|
3
|
+
|
|
4
|
+
module Vehicles
|
|
5
|
+
VERSION: String
|
|
6
|
+
DATA_PATH: String
|
|
7
|
+
|
|
8
|
+
def self.configuration: () -> Configuration
|
|
9
|
+
def self.configure: () { (Configuration) -> void } -> void
|
|
10
|
+
def self.reset_configuration!: () -> void
|
|
11
|
+
|
|
12
|
+
def self.data_path: () -> String
|
|
13
|
+
def self.data_version: () -> String?
|
|
14
|
+
def self.region: () -> Symbol
|
|
15
|
+
|
|
16
|
+
def self.makes: (?kind: Symbol?, ?region: Symbol?) -> Array[String]
|
|
17
|
+
def self.models: (untyped make, ?kind: Symbol?, ?body_type: Symbol?, ?region: Symbol?) -> Array[String]
|
|
18
|
+
def self.make: (untyped query) -> Make?
|
|
19
|
+
def self.find: (untyped query) -> Model?
|
|
20
|
+
def self.model: (untyped make_name, untyped model_name) -> Model?
|
|
21
|
+
def self.search: (untyped query) -> Array[Model]
|
|
22
|
+
def self.make_options: (?kind: Symbol?, ?region: Symbol?) -> Array[[String, String]]
|
|
23
|
+
def self.model_options: (untyped make, ?kind: Symbol?, ?body_type: Symbol?) -> Array[[String, String]]
|
|
24
|
+
def self.catalog: (?kind: Symbol?, ?region: Symbol?) -> Hash[String, Array[String]]
|
|
25
|
+
|
|
26
|
+
def self.colors: () -> Array[Color]
|
|
27
|
+
def self.color: (untyped query) -> Color?
|
|
28
|
+
def self.color_options: () -> Array[[String, String]]
|
|
29
|
+
|
|
30
|
+
def self.dataset: () -> Dataset
|
|
31
|
+
def self.data_path: () -> String
|
|
32
|
+
def self.data_path=: (String) -> void
|
|
33
|
+
def self.active_data_path: () -> String
|
|
34
|
+
def self.refresh!: () -> bool
|
|
35
|
+
def self.reload!: () -> void
|
|
36
|
+
def self.normalize: (untyped str) -> String
|
|
37
|
+
def self.slugify: (untyped str) -> String
|
|
38
|
+
|
|
39
|
+
class Error < StandardError
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class Configuration
|
|
43
|
+
attr_accessor region: Symbol
|
|
44
|
+
attr_accessor api_key: String?
|
|
45
|
+
attr_accessor api_base_url: String
|
|
46
|
+
attr_accessor api_timeout: Integer
|
|
47
|
+
attr_reader aliases: Hash[String, String]
|
|
48
|
+
attr_accessor data_url: String
|
|
49
|
+
attr_accessor cache_path: String
|
|
50
|
+
attr_accessor use_cache: bool
|
|
51
|
+
attr_accessor refresh_timeout: Integer
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Make
|
|
55
|
+
attr_reader name: String
|
|
56
|
+
attr_reader slug: String
|
|
57
|
+
attr_reader aliases: Array[String]
|
|
58
|
+
attr_reader kinds: Array[Symbol]
|
|
59
|
+
def models: (?kind: Symbol?, ?body_type: Symbol?) -> Array[Model]
|
|
60
|
+
def model: (untyped query) -> Model?
|
|
61
|
+
def model_names: (?kind: Symbol?, ?body_type: Symbol?) -> Array[String]
|
|
62
|
+
def model_options: (?kind: Symbol?, ?body_type: Symbol?) -> Array[[String, String]]
|
|
63
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class Color
|
|
67
|
+
attr_reader slug: String
|
|
68
|
+
attr_reader name: String
|
|
69
|
+
attr_reader hex: String
|
|
70
|
+
def to_h: () -> Hash[Symbol, String]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class Model
|
|
74
|
+
KINDS: Array[Symbol]
|
|
75
|
+
BODY_TYPES: Array[Symbol]
|
|
76
|
+
attr_reader make: String
|
|
77
|
+
attr_reader make_slug: String
|
|
78
|
+
attr_reader name: String
|
|
79
|
+
attr_reader kind: Symbol
|
|
80
|
+
attr_reader body_type: Symbol
|
|
81
|
+
def full_name: () -> String
|
|
82
|
+
def to_s: () -> String
|
|
83
|
+
def slug: () -> String
|
|
84
|
+
def model_slug: () -> String
|
|
85
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
86
|
+
# predicate sugar for each kind/body type: car?, suv?, hatchback?, coupe?, ...
|
|
87
|
+
def car?: () -> bool
|
|
88
|
+
def suv?: () -> bool
|
|
89
|
+
def hatchback?: () -> bool
|
|
90
|
+
def years: () -> Range[Integer]?
|
|
91
|
+
def segment: () -> Symbol?
|
|
92
|
+
def image: (?year: Integer?, ?color: untyped) -> String?
|
|
93
|
+
end
|
|
94
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: vehicles
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- rameerez
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-06-23 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: vehicles ships a curated, bundled dataset of car makes and models (with
|
|
13
|
+
kind and body type) and a delightful, Rails-friendly API for vehicle dropdowns,
|
|
14
|
+
search, and validation. No API keys, no network calls, no database table — the data
|
|
15
|
+
lives inside the gem and just works. It also acts as the SDK for the hosted VehiclesDB
|
|
16
|
+
API. Code is MIT; the bundled data is CC-BY 4.0 (derived from RDW Open Data).
|
|
17
|
+
email:
|
|
18
|
+
- rubygems@rameerez.com
|
|
19
|
+
executables: []
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- CHANGELOG.md
|
|
24
|
+
- LICENSE.txt
|
|
25
|
+
- README.md
|
|
26
|
+
- data/vehicles.json
|
|
27
|
+
- lib/generators/vehicles/install_generator.rb
|
|
28
|
+
- lib/generators/vehicles/templates/initializer.rb
|
|
29
|
+
- lib/generators/vehicles/templates/vehicles_refresh_job.rb
|
|
30
|
+
- lib/vehicles.rb
|
|
31
|
+
- lib/vehicles/color.rb
|
|
32
|
+
- lib/vehicles/colors.rb
|
|
33
|
+
- lib/vehicles/configuration.rb
|
|
34
|
+
- lib/vehicles/dataset.rb
|
|
35
|
+
- lib/vehicles/make.rb
|
|
36
|
+
- lib/vehicles/model.rb
|
|
37
|
+
- lib/vehicles/providers/hosted_provider.rb
|
|
38
|
+
- lib/vehicles/providers/local_provider.rb
|
|
39
|
+
- lib/vehicles/railtie.rb
|
|
40
|
+
- lib/vehicles/refresher.rb
|
|
41
|
+
- lib/vehicles/validators/vehicle_make_validator.rb
|
|
42
|
+
- lib/vehicles/validators/vehicle_model_validator.rb
|
|
43
|
+
- lib/vehicles/version.rb
|
|
44
|
+
- sig/vehicles.rbs
|
|
45
|
+
homepage: https://github.com/rameerez/vehicles
|
|
46
|
+
licenses:
|
|
47
|
+
- MIT
|
|
48
|
+
metadata:
|
|
49
|
+
allowed_push_host: https://rubygems.org
|
|
50
|
+
homepage_uri: https://github.com/rameerez/vehicles
|
|
51
|
+
source_code_uri: https://github.com/rameerez/vehicles/tree/main
|
|
52
|
+
changelog_uri: https://github.com/rameerez/vehicles/blob/main/CHANGELOG.md
|
|
53
|
+
rubygems_mfa_required: 'true'
|
|
54
|
+
rdoc_options: []
|
|
55
|
+
require_paths:
|
|
56
|
+
- lib
|
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: 3.1.0
|
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
requirements: []
|
|
68
|
+
rubygems_version: 3.6.2
|
|
69
|
+
specification_version: 4
|
|
70
|
+
summary: Car makes & models for your Rails app — dropdowns, search, validation. Zero
|
|
71
|
+
config, no API keys.
|
|
72
|
+
test_files: []
|