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,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Vehicles
|
|
6
|
+
module Generators
|
|
7
|
+
# `rails generate vehicles:install`
|
|
8
|
+
#
|
|
9
|
+
# Writes a configuration initializer and an optional refresh job. There is
|
|
10
|
+
# deliberately NO migration: `vehicles` ships its dataset bundled in the gem
|
|
11
|
+
# and reads it from memory, so there's no table to create and nothing to seed.
|
|
12
|
+
# The gem works the moment it's in your Gemfile; this generator gives you a
|
|
13
|
+
# place to set a region / API key, and a job to keep the data current without
|
|
14
|
+
# waiting for a gem upgrade.
|
|
15
|
+
class InstallGenerator < Rails::Generators::Base
|
|
16
|
+
source_root File.expand_path("templates", __dir__)
|
|
17
|
+
|
|
18
|
+
def create_initializer
|
|
19
|
+
template "initializer.rb", "config/initializers/vehicles.rb"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_refresh_job
|
|
23
|
+
template "vehicles_refresh_job.rb", "app/jobs/vehicles_refresh_job.rb"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def display_post_install_message
|
|
27
|
+
say "\nš vehicles installed!", :green
|
|
28
|
+
say "\nNo migration needed ā the dataset ships inside the gem and just works."
|
|
29
|
+
say "\nTry it now:"
|
|
30
|
+
say " Vehicles.makes # => [\"Alfa Romeo\", \"Audi\", ...]", :cyan
|
|
31
|
+
say " Vehicles.models(\"VW\") # => [\"Golf\", \"Polo\", ...]", :cyan
|
|
32
|
+
say " Vehicles.make_options # => [[\"Audi\", \"audi\"], ...] (for select)", :cyan
|
|
33
|
+
say "\nConfig (optional) lives in config/initializers/vehicles.rb."
|
|
34
|
+
say "Add a VehiclesDB API key there to unlock years, images, and segments."
|
|
35
|
+
say "\nStay current WITHOUT a gem upgrade: schedule VehiclesRefreshJob", :yellow
|
|
36
|
+
say "(e.g. daily in config/recurring.yml) to pull the latest published data.\n"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# vehicles configuration.
|
|
4
|
+
#
|
|
5
|
+
# Everything here is OPTIONAL ā the gem ships sensible defaults and a bundled
|
|
6
|
+
# dataset, so it works with zero configuration. Uncomment what you need.
|
|
7
|
+
|
|
8
|
+
Vehicles.configure do |config|
|
|
9
|
+
# Default region for queries. Today the bundled data covers the EU market;
|
|
10
|
+
# :us / :gb / :au / :nz / :ca packs are on the roadmap (the API is already
|
|
11
|
+
# region-aware, so adding them won't change your code).
|
|
12
|
+
# config.region = :eu
|
|
13
|
+
|
|
14
|
+
# Optional: a VehiclesDB API key unlocks hosted enrichment ā production years,
|
|
15
|
+
# model images (year- and color-accurate), and market segments ā on the same
|
|
16
|
+
# Vehicles::Model objects you already use. Without a key, everything still
|
|
17
|
+
# works on the bundled data; those richer fields just return nil.
|
|
18
|
+
# config.api_key = ENV["VEHICLESDB_API_KEY"]
|
|
19
|
+
|
|
20
|
+
# Optional: your own make aliases, merged over the built-ins. Matched
|
|
21
|
+
# forgivingly (case/diacritics-insensitive).
|
|
22
|
+
# config.aliases = { "Chevy" => "Chevrolet", "Landy" => "Land Rover" }
|
|
23
|
+
|
|
24
|
+
# --- Data refresh (optional) ----------------------------------------------
|
|
25
|
+
# The gem works offline with its bundled snapshot. To get data fixes and new
|
|
26
|
+
# makes WITHOUT upgrading the gem, schedule VehiclesRefreshJob (see app/jobs)
|
|
27
|
+
# ā it pulls the latest published dataset into a local cache, which loads
|
|
28
|
+
# prefer over the bundled copy. Defaults below are sensible; override if needed.
|
|
29
|
+
#
|
|
30
|
+
# config.data_url = "https://cdn.jsdelivr.net/gh/vehiclesdb/vehiclesdb@latest/dist/vehicles.json"
|
|
31
|
+
# config.cache_path = Rails.root.join("tmp", "cache", "vehicles", "vehicles.json")
|
|
32
|
+
# config.use_cache = true # set false to always use the bundled snapshot (fully offline/deterministic)
|
|
33
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Pulls the latest published VehiclesDB dataset into the local cache, so data
|
|
4
|
+
# fixes and new makes reach this app WITHOUT a gem upgrade. Schedule it (daily is
|
|
5
|
+
# plenty) ā e.g. with solid_queue in config/recurring.yml:
|
|
6
|
+
#
|
|
7
|
+
# vehicles_refresh:
|
|
8
|
+
# class: VehiclesRefreshJob
|
|
9
|
+
# schedule: every day at 3am
|
|
10
|
+
#
|
|
11
|
+
# It's safe to run anytime: it never raises, and a failed/partial download leaves
|
|
12
|
+
# your current data untouched (the gem keeps serving the cache, or the bundled
|
|
13
|
+
# snapshot if there's no cache yet).
|
|
14
|
+
class VehiclesRefreshJob < ApplicationJob
|
|
15
|
+
queue_as :default
|
|
16
|
+
|
|
17
|
+
def perform
|
|
18
|
+
Vehicles.refresh!
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
# A canonical vehicle color. Reference data, not RDW-derived: a small, stable
|
|
5
|
+
# palette every consumer (and, in time, the hosted VehiclesDB image API) can
|
|
6
|
+
# share, so "red" means the same thing ā and maps to the same image variant ā
|
|
7
|
+
# everywhere. Display names are English; localize in your app (the slug is the
|
|
8
|
+
# stable, locale-independent key you store). `hex` is a representative swatch
|
|
9
|
+
# for UI chips / future color-accurate imagery.
|
|
10
|
+
class Color
|
|
11
|
+
attr_reader :slug, :name, :hex
|
|
12
|
+
|
|
13
|
+
def initialize(slug, name, hex)
|
|
14
|
+
@slug = slug
|
|
15
|
+
@name = name
|
|
16
|
+
@hex = hex
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{ slug: slug, name: name, hex: hex }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_s
|
|
25
|
+
name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ==(other)
|
|
29
|
+
other.is_a?(Color) && other.slug == slug
|
|
30
|
+
end
|
|
31
|
+
alias eql? ==
|
|
32
|
+
|
|
33
|
+
def hash
|
|
34
|
+
slug.hash
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inspect
|
|
38
|
+
%(#<Vehicles::Color #{slug} "#{name}" #{hex}>)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
# The canonical color palette ā the ~handful of colors that cover virtually
|
|
5
|
+
# every car on the road, plus an explicit `other` catch-all. Ordered roughly by
|
|
6
|
+
# real-world frequency (white/black/grey lead the global fleet) so a dropdown
|
|
7
|
+
# reads sensibly. Slugs are the stable keys you store; names are English labels
|
|
8
|
+
# to localize; hexes are representative swatches.
|
|
9
|
+
module Colors
|
|
10
|
+
ALL = [
|
|
11
|
+
Color.new("white", "White", "#F4F4F4"),
|
|
12
|
+
Color.new("black", "Black", "#1B1B1B"),
|
|
13
|
+
Color.new("grey", "Grey", "#8A8D90"),
|
|
14
|
+
Color.new("silver", "Silver", "#C7CCD1"),
|
|
15
|
+
Color.new("blue", "Blue", "#27408B"),
|
|
16
|
+
Color.new("red", "Red", "#B81D24"),
|
|
17
|
+
Color.new("green", "Green", "#2E6B3E"),
|
|
18
|
+
Color.new("brown", "Brown", "#5A3A22"),
|
|
19
|
+
Color.new("beige", "Beige", "#D9C6A5"),
|
|
20
|
+
Color.new("orange", "Orange", "#E8731A"),
|
|
21
|
+
Color.new("yellow", "Yellow", "#F2C200"),
|
|
22
|
+
Color.new("gold", "Gold", "#C8A951"),
|
|
23
|
+
Color.new("purple", "Purple", "#6B3FA0"),
|
|
24
|
+
Color.new("other", "Other", "#9AA0A6")
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
# Accept a few common spellings/synonyms so lookups are forgiving like the
|
|
28
|
+
# rest of the gem (normalized keys ā canonical slug).
|
|
29
|
+
SYNONYMS = {
|
|
30
|
+
"gray" => "grey",
|
|
31
|
+
"silvery" => "silver",
|
|
32
|
+
"tan" => "beige",
|
|
33
|
+
"cream" => "beige",
|
|
34
|
+
"burgundy" => "red",
|
|
35
|
+
"navy" => "blue"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
BY_SLUG = ALL.to_h { |c| [c.slug, c] }.freeze
|
|
39
|
+
BY_NAME = ALL.to_h { |c| [Vehicles.normalize(c.name), c] }.freeze
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
# The single source of truth for every knob. Sensible defaults mean you can use
|
|
5
|
+
# the whole gem without ever touching this ā `Vehicles.configure` is opt-in.
|
|
6
|
+
#
|
|
7
|
+
# Vehicles.configure do |config|
|
|
8
|
+
# config.region = :eu
|
|
9
|
+
# config.api_key = ENV["VEHICLESDB_API_KEY"]
|
|
10
|
+
# config.aliases = { "Chevy" => "Chevrolet" }
|
|
11
|
+
# end
|
|
12
|
+
class Configuration
|
|
13
|
+
# Default region for queries. Today the bundled data ships :eu; the API is
|
|
14
|
+
# already region-aware so :us/:gb/etc. are additive, never breaking.
|
|
15
|
+
attr_accessor :region
|
|
16
|
+
|
|
17
|
+
# Optional VehiclesDB API key. When set, the hosted provider activates and
|
|
18
|
+
# enriches models with years/images/segments. When nil, everything still
|
|
19
|
+
# works on the bundled data ā the gem is standalone first, SDK second.
|
|
20
|
+
attr_accessor :api_key
|
|
21
|
+
|
|
22
|
+
# Base URL for the hosted VehiclesDB API. Overridable for self-hosting/testing.
|
|
23
|
+
attr_accessor :api_base_url
|
|
24
|
+
|
|
25
|
+
# Network timeout (seconds) for hosted API calls. Kept short so a slow/missing
|
|
26
|
+
# API never blocks a request ā hosted lookups degrade to the local data.
|
|
27
|
+
attr_accessor :api_timeout
|
|
28
|
+
|
|
29
|
+
# Extra make aliases, merged over the built-in ones. Keys are matched
|
|
30
|
+
# forgivingly (case/diacritics-insensitive); values are canonical make names.
|
|
31
|
+
attr_reader :aliases
|
|
32
|
+
|
|
33
|
+
# --- data refresh (optional) ---------------------------------------------
|
|
34
|
+
# The gem ships a bundled snapshot that works offline with zero setup. These
|
|
35
|
+
# let an app pull the latest published dataset (e.g. via a daily job) so data
|
|
36
|
+
# fixes and new makes land WITHOUT a gem upgrade.
|
|
37
|
+
|
|
38
|
+
# Where `Vehicles.refresh!` pulls the latest dataset. Defaults to the public
|
|
39
|
+
# VehiclesDB data repo via jsDelivr's CDN (always-latest release tag).
|
|
40
|
+
attr_accessor :data_url
|
|
41
|
+
|
|
42
|
+
# Where a refreshed dataset is cached on disk. Defaults to the app's cache dir
|
|
43
|
+
# (Rails) or the system temp dir. The refresh writes here; loads prefer it.
|
|
44
|
+
attr_accessor :cache_path
|
|
45
|
+
|
|
46
|
+
# Prefer a refreshed (cached) dataset over the bundled one when present.
|
|
47
|
+
# Set false to always use the bundled snapshot (fully offline/deterministic).
|
|
48
|
+
attr_accessor :use_cache
|
|
49
|
+
|
|
50
|
+
# Network timeout (seconds) for a refresh download.
|
|
51
|
+
attr_accessor :refresh_timeout
|
|
52
|
+
|
|
53
|
+
def initialize
|
|
54
|
+
@region = :eu
|
|
55
|
+
@api_key = nil
|
|
56
|
+
@api_base_url = "https://api.vehiclesdb.com"
|
|
57
|
+
@api_timeout = 2
|
|
58
|
+
@aliases = {}
|
|
59
|
+
@data_url = "https://cdn.jsdelivr.net/gh/vehiclesdb/vehiclesdb@latest/dist/vehicles.json"
|
|
60
|
+
@cache_path = default_cache_path
|
|
61
|
+
@use_cache = true
|
|
62
|
+
@refresh_timeout = 5
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Normalize alias keys at assignment time so lookups stay O(1) and forgiving.
|
|
66
|
+
def aliases=(hash)
|
|
67
|
+
@aliases = (hash || {}).each_with_object({}) do |(k, v), memo|
|
|
68
|
+
memo[Vehicles.normalize(k)] = v.to_s
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# tmp/cache/vehicles under a Rails app, else a stable spot in the system temp
|
|
75
|
+
# dir. Refreshable data, so a cache-style location is appropriate.
|
|
76
|
+
def default_cache_path
|
|
77
|
+
base =
|
|
78
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
79
|
+
Rails.root.join("tmp", "cache", "vehicles")
|
|
80
|
+
else
|
|
81
|
+
require "tmpdir"
|
|
82
|
+
File.join(Dir.tmpdir, "vehicles")
|
|
83
|
+
end
|
|
84
|
+
File.join(base.to_s, "vehicles.json")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Vehicles
|
|
6
|
+
# Loads the bundled snapshot once, builds in-memory indexes, and answers every
|
|
7
|
+
# query. No HTTP, no SQLite, no ActiveRecord on the read path ā the first call
|
|
8
|
+
# builds the index, every call after is a hash lookup.
|
|
9
|
+
#
|
|
10
|
+
# Instances are memoized per data path (see .load), so the JSON is parsed once
|
|
11
|
+
# per process.
|
|
12
|
+
class Dataset
|
|
13
|
+
# Built-in make aliases (normalized key => make slug). Common abbreviations
|
|
14
|
+
# and nicknames so whatever a user types tends to land. Diacritics and case
|
|
15
|
+
# are already handled by Vehicles.normalize, so "Ŕkoda"/"citroën" need no entry.
|
|
16
|
+
BUILTIN_ALIASES = {
|
|
17
|
+
"vw" => "volkswagen", "vdub" => "volkswagen",
|
|
18
|
+
"merc" => "mercedes-benz", "mercedes" => "mercedes-benz", "benz" => "mercedes-benz",
|
|
19
|
+
"mb" => "mercedes-benz",
|
|
20
|
+
"chevy" => "chevrolet",
|
|
21
|
+
"beemer" => "bmw", "bimmer" => "bmw",
|
|
22
|
+
"alfa" => "alfa-romeo",
|
|
23
|
+
"landrover" => "land-rover", "range rover" => "land-rover", "rangerover" => "land-rover",
|
|
24
|
+
"vauxhall" => "opel" # GB badge-engineered Opel; map to the EU make we ship
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Memoized per path so the bundled JSON is parsed only once per process.
|
|
29
|
+
def load(path = Vehicles.data_path)
|
|
30
|
+
(@instances ||= {})[path] ||= new(JSON.parse(File.read(path)))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Drop the cache (used by the test suite between runs).
|
|
34
|
+
def reset!
|
|
35
|
+
@instances = {}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :version, :schema_version, :region
|
|
40
|
+
|
|
41
|
+
def initialize(raw)
|
|
42
|
+
@version = raw["version"]
|
|
43
|
+
@schema_version = raw["schema_version"]
|
|
44
|
+
@region = raw["region"]
|
|
45
|
+
|
|
46
|
+
@makes = (raw["makes"] || []).map { |attrs| Make.new(attrs) }
|
|
47
|
+
@by_slug = {} # raw slug => Make
|
|
48
|
+
@index = {} # normalized name/slug/alias => Make
|
|
49
|
+
@makes.each do |make|
|
|
50
|
+
@by_slug[make.slug] = make
|
|
51
|
+
index(make.name, make)
|
|
52
|
+
index(make.slug, make)
|
|
53
|
+
make.aliases.each { |a| index(a, make) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# All makes, optionally filtered by kind/region. Unknown region => [] (honest:
|
|
58
|
+
# we don't ship that pack yet), so callers never get wrong-region data.
|
|
59
|
+
def makes(kind: nil, region: nil)
|
|
60
|
+
return [] if region && !region_match?(region)
|
|
61
|
+
|
|
62
|
+
list = @makes
|
|
63
|
+
list = list.select { |m| m.kinds.include?(kind.to_sym) } if kind
|
|
64
|
+
list
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Resolve a make from a String/Symbol/Make via aliases, slug, or name.
|
|
68
|
+
def find_make(query)
|
|
69
|
+
return query if query.is_a?(Make)
|
|
70
|
+
|
|
71
|
+
q = Vehicles.normalize(query)
|
|
72
|
+
return nil if q.empty?
|
|
73
|
+
|
|
74
|
+
# 1. user-supplied aliases win
|
|
75
|
+
if (canonical = Vehicles.configuration.aliases[q])
|
|
76
|
+
return @by_slug[Vehicles.slugify(canonical)] || @index[Vehicles.normalize(canonical)]
|
|
77
|
+
end
|
|
78
|
+
# 2. built-in aliases
|
|
79
|
+
if (slug = BUILTIN_ALIASES[q])
|
|
80
|
+
return @by_slug[slug]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# 3. direct slug / normalized name / make alias
|
|
84
|
+
@by_slug[q] || @index[q]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Resolve a free-text "make + model" string into one Model. Tries the longest
|
|
88
|
+
# leading make prefix first ("land rover defender"), then the remainder as the
|
|
89
|
+
# model. Returns nil if nothing matches.
|
|
90
|
+
def find_model(query)
|
|
91
|
+
q = Vehicles.normalize(query)
|
|
92
|
+
tokens = q.split
|
|
93
|
+
return nil if tokens.empty?
|
|
94
|
+
|
|
95
|
+
(tokens.length - 1).downto(1) do |i|
|
|
96
|
+
make = find_make(tokens[0, i].join(" "))
|
|
97
|
+
next unless make
|
|
98
|
+
|
|
99
|
+
model = make.model(tokens[i..].join(" "))
|
|
100
|
+
return model if model
|
|
101
|
+
end
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Every model whose name (or full name) matches the query, ranked: exact name,
|
|
106
|
+
# then prefix, then substring, then full-name substring. Shorter names first.
|
|
107
|
+
def search(query)
|
|
108
|
+
q = Vehicles.normalize(query)
|
|
109
|
+
return [] if q.empty?
|
|
110
|
+
|
|
111
|
+
scored = []
|
|
112
|
+
all_models.each do |m|
|
|
113
|
+
name_n = Vehicles.normalize(m.name)
|
|
114
|
+
score =
|
|
115
|
+
if name_n == q then 0
|
|
116
|
+
elsif name_n.start_with?(q) then 1
|
|
117
|
+
elsif name_n.include?(q) then 2
|
|
118
|
+
elsif Vehicles.normalize(m.full_name).include?(q) then 3
|
|
119
|
+
else next
|
|
120
|
+
end
|
|
121
|
+
scored << [score, m.name.length, m]
|
|
122
|
+
end
|
|
123
|
+
scored.sort_by { |score, len, _m| [score, len] }.map { |_s, _l, m| m }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Flat list of every Model (memoized + frozen ā shared, so don't let callers
|
|
127
|
+
# mutate it). Backs `search`.
|
|
128
|
+
def all_models
|
|
129
|
+
@all_models ||= @makes.flat_map(&:models).freeze
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Does this snapshot cover the given region? (Today only :eu.)
|
|
133
|
+
def region?(region)
|
|
134
|
+
region_match?(region)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def index(key, make)
|
|
140
|
+
@index[Vehicles.normalize(key)] ||= make
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def region_match?(region)
|
|
144
|
+
Vehicles.normalize(region) == Vehicles.normalize(@region)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
# A vehicle make, e.g. "Volkswagen", and the models it builds. Immutable value
|
|
5
|
+
# object. Its models are built lazily once and cached.
|
|
6
|
+
#
|
|
7
|
+
# make = Vehicles.make("Audi")
|
|
8
|
+
# make.slug # => "audi"
|
|
9
|
+
# make.models # => ["A3", "A4", "Q3", ...]
|
|
10
|
+
# make.model("a3")# => #<Vehicles::Model "Audi A3">
|
|
11
|
+
class Make
|
|
12
|
+
attr_reader :name, :slug, :aliases, :kinds
|
|
13
|
+
|
|
14
|
+
def initialize(attrs)
|
|
15
|
+
@name = attrs["name"]
|
|
16
|
+
@slug = attrs["slug"]
|
|
17
|
+
@aliases = Array(attrs["aliases"]).freeze
|
|
18
|
+
@kinds = Array(attrs["kinds"]).map(&:to_sym).freeze
|
|
19
|
+
@raw_models = attrs["models"] || []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# All models for this make, optionally filtered by kind/body_type.
|
|
23
|
+
# Returns Vehicles::Model objects, ordered by popularity (as built).
|
|
24
|
+
# The unfiltered list is memoized AND frozen ā it's shared process-wide, so a
|
|
25
|
+
# frozen array turns accidental caller mutation into a loud error instead of
|
|
26
|
+
# silently corrupting the dataset. Filtered calls return a fresh array.
|
|
27
|
+
def models(kind: nil, body_type: nil)
|
|
28
|
+
list = (@models ||= @raw_models.map { |m| Model.new(m, make: name, make_slug: slug) }.freeze)
|
|
29
|
+
list = list.select { |m| m.kind == kind.to_sym } if kind
|
|
30
|
+
list = list.select { |m| m.body_type == body_type.to_sym } if body_type
|
|
31
|
+
list
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Model display names ā what you drop into a dropdown. => ["A3", "A4", ...]
|
|
35
|
+
def model_names(**filters)
|
|
36
|
+
models(**filters).map(&:name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# [[label, value], ...] for Rails `select`. => [["A3", "a3"], ["A4", "a4"]]
|
|
40
|
+
def model_options(**filters)
|
|
41
|
+
models(**filters).map { |m| [m.name, m.model_slug] }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Find one model within this make by exact (normalized) name or slug.
|
|
45
|
+
# "a3" / "A3" / "Q 3" resolve; partial input does NOT (so `model("a")`
|
|
46
|
+
# returns nil, not "A3" ā important, since the vehicle_model validator relies
|
|
47
|
+
# on this). Fuzzy/partial matching lives in `Vehicles.search`.
|
|
48
|
+
def model(query)
|
|
49
|
+
q = Vehicles.normalize(query)
|
|
50
|
+
return nil if q.empty?
|
|
51
|
+
|
|
52
|
+
models.find { |m| Vehicles.normalize(m.name) == q || m.model_slug == q }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_h
|
|
56
|
+
{ name: name, slug: slug, kinds: kinds, models: model_names }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_s
|
|
60
|
+
name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def ==(other)
|
|
64
|
+
other.is_a?(Make) && other.slug == slug
|
|
65
|
+
end
|
|
66
|
+
alias eql? ==
|
|
67
|
+
|
|
68
|
+
def hash
|
|
69
|
+
slug.hash
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect
|
|
73
|
+
%(#<Vehicles::Make "#{name}">)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vehicles
|
|
4
|
+
# A single vehicle nameplate, e.g. "Volkswagen Golf". A lightweight, immutable
|
|
5
|
+
# value object ā no ActiveRecord, no mutation. Reads like English on purpose.
|
|
6
|
+
#
|
|
7
|
+
# car = Vehicles.find("vw golf")
|
|
8
|
+
# car.make # => "Volkswagen"
|
|
9
|
+
# car.name # => "Golf"
|
|
10
|
+
# car.full_name # => "Volkswagen Golf"
|
|
11
|
+
# car.body_type # => :hatchback
|
|
12
|
+
# car.suv? # => false
|
|
13
|
+
class Model
|
|
14
|
+
# Vehicle kinds (RDW `voertuigsoort`). Today the bundled data is all :car;
|
|
15
|
+
# the shape already supports the rest for when those packs land.
|
|
16
|
+
KINDS = %i[car motorcycle van truck pickup trailer bus moped quad trike].freeze
|
|
17
|
+
|
|
18
|
+
# Body types ā the sub-classification within a kind. For cars these are the
|
|
19
|
+
# familiar shapes; motorcycle styles (:naked, :adventure, ā¦) reuse this field.
|
|
20
|
+
BODY_TYPES = %i[
|
|
21
|
+
hatchback sedan wagon suv mpv coupe convertible roadster pickup van
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :make, :make_slug, :name, :kind, :body_type
|
|
25
|
+
|
|
26
|
+
# @param attrs [Hash] one model entry from the dataset (name/slug/kind/body_type)
|
|
27
|
+
# @param make [String] the parent make's display name
|
|
28
|
+
# @param make_slug [String] the parent make's slug (for the composite slug)
|
|
29
|
+
def initialize(attrs, make:, make_slug:)
|
|
30
|
+
@make = make
|
|
31
|
+
@make_slug = make_slug
|
|
32
|
+
@name = attrs["name"]
|
|
33
|
+
@model_slug = attrs["slug"]
|
|
34
|
+
@kind = (attrs["kind"] || "car").to_sym
|
|
35
|
+
@body_type = (attrs["body_type"] || "hatchback").to_sym
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# "Volkswagen Golf" ā the natural label for a model on its own.
|
|
40
|
+
def full_name
|
|
41
|
+
"#{make} #{name}"
|
|
42
|
+
end
|
|
43
|
+
alias to_s full_name
|
|
44
|
+
|
|
45
|
+
# Composite, stable slug: "volkswagen-golf".
|
|
46
|
+
def slug
|
|
47
|
+
"#{make_slug}-#{@model_slug}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The bare model slug ("golf"), used as the value in `model_options`.
|
|
51
|
+
attr_reader :model_slug
|
|
52
|
+
|
|
53
|
+
# Predicate sugar: `car.suv?`, `car.hatchback?`, `car.car?`, `car.motorcycle?`.
|
|
54
|
+
# `pickup?`/`van?` are true whether the term is the kind or the body type.
|
|
55
|
+
(KINDS | BODY_TYPES).each do |type|
|
|
56
|
+
define_method("#{type}?") { [@kind, @body_type].include?(type) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_h
|
|
60
|
+
{ make: make, model: name, slug: slug, kind: kind, body_type: body_type }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Value-object equality ā two models with the same slug are equal.
|
|
64
|
+
def ==(other)
|
|
65
|
+
other.is_a?(Model) && other.slug == slug
|
|
66
|
+
end
|
|
67
|
+
alias eql? ==
|
|
68
|
+
|
|
69
|
+
def hash
|
|
70
|
+
slug.hash
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def inspect
|
|
74
|
+
%(#<Vehicles::Model "#{full_name}" #{body_type}>)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# --- Hosted VehiclesDB data (optional) -----------------------------------
|
|
78
|
+
# These resolve through the provider chain: the hosted API answers when an
|
|
79
|
+
# api_key is configured, otherwise they degrade to the local data (nil today).
|
|
80
|
+
# They NEVER raise ā a missing/slow API just yields nil and your view renders
|
|
81
|
+
# a placeholder. See Vehicles::Providers.
|
|
82
|
+
|
|
83
|
+
# Production years as a Range, e.g. 1974..2024. nil without hosted data.
|
|
84
|
+
def years
|
|
85
|
+
Vehicles.resolve(:years, self)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Editorial market segment, e.g. :hot_hatch, :supercar. nil without hosted data.
|
|
89
|
+
def segment
|
|
90
|
+
Vehicles.resolve(:segment, self)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Image URL, optionally year-/color-accurate. nil without hosted data.
|
|
94
|
+
def image(year: nil, color: nil)
|
|
95
|
+
Vehicles.resolve(:image, self, year: year, color: color)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Vehicles
|
|
6
|
+
module Providers
|
|
7
|
+
# Talks to the hosted VehiclesDB API to enrich models with years, images, and
|
|
8
|
+
# segments. It is STRICTLY OPTIONAL: `available?` is true only when an api_key
|
|
9
|
+
# is configured, so a key-less install never reaches the network. Every call
|
|
10
|
+
# is error-isolated ā a slow, missing, or failing API yields nil and the
|
|
11
|
+
# resolver falls back to the local data. Tracking/enrichment must never break
|
|
12
|
+
# the host app, so this never raises out.
|
|
13
|
+
#
|
|
14
|
+
# NOTE: the public VehiclesDB API is not live yet. This is the wired-up seam:
|
|
15
|
+
# the moment the service ships (and you set `config.api_key`), these methods
|
|
16
|
+
# light up with zero code changes on the consumer's side. Until then they
|
|
17
|
+
# safely return nil. Endpoint shape is provisional ā see https://vehiclesdb.com.
|
|
18
|
+
module HostedProvider
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def available?
|
|
22
|
+
!Vehicles.configuration.api_key.to_s.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def years(model)
|
|
26
|
+
data = fetch(model)
|
|
27
|
+
return nil unless data && data["year_start"]
|
|
28
|
+
|
|
29
|
+
(data["year_start"]..data["year_end"]) # year_end may be nil => endless range
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def segment(model)
|
|
33
|
+
fetch(model)&.dig("segment")&.to_sym
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def image(model, year:, color:)
|
|
37
|
+
params = {}
|
|
38
|
+
params[:year] = year if year
|
|
39
|
+
params[:color] = color if color
|
|
40
|
+
get("/v1/models/#{model.slug}/image", params)&.dig("url")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# --- internals -----------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
# Fetch + memoize the full model payload for this process. Keyed by slug.
|
|
46
|
+
def fetch(model)
|
|
47
|
+
@cache ||= {}
|
|
48
|
+
return @cache[model.slug] if @cache.key?(model.slug)
|
|
49
|
+
|
|
50
|
+
@cache[model.slug] = get("/v1/models/#{model.slug}", {})
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reset!
|
|
54
|
+
@cache = {}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Issue a GET and parse JSON. Returns nil on ANY failure (network, timeout,
|
|
58
|
+
# non-200, bad JSON) ā callers treat nil as "fall back to local data".
|
|
59
|
+
def get(path, params)
|
|
60
|
+
# Lazy-required: the gem is standalone-first, and this whole module is
|
|
61
|
+
# inert without an api_key, so we don't pay net/http at load time.
|
|
62
|
+
require "net/http"
|
|
63
|
+
require "uri"
|
|
64
|
+
|
|
65
|
+
config = Vehicles.configuration
|
|
66
|
+
uri = URI.join(config.api_base_url, path)
|
|
67
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
68
|
+
|
|
69
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
70
|
+
http.use_ssl = uri.scheme == "https"
|
|
71
|
+
http.open_timeout = config.api_timeout
|
|
72
|
+
http.read_timeout = config.api_timeout
|
|
73
|
+
|
|
74
|
+
request = Net::HTTP::Get.new(uri)
|
|
75
|
+
request["Authorization"] = "Bearer #{config.api_key}"
|
|
76
|
+
request["Accept"] = "application/json"
|
|
77
|
+
|
|
78
|
+
response = http.request(request)
|
|
79
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
80
|
+
|
|
81
|
+
JSON.parse(response.body)
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
warn "[vehicles] hosted lookup failed (#{e.class}); using local data" if $DEBUG
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|