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.
@@ -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