rockauto_api 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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Endpoints
5
+ module PartSearch
6
+ def get_manufacturers
7
+ cache.fetch("rockauto:manufacturers", ttl: 86400) do
8
+ html = get("/en/partsearch/")
9
+ opts = Parsers::HtmlHelpers.select_options(html, "manufacturer_partsearch_007")
10
+ manufacturers = opts.map { |o| Models::PartSearchOption.new(value: o[:value], text: o[:text]) }
11
+ Models::ManufacturerOptions.new(
12
+ manufacturers: manufacturers,
13
+ count: manufacturers.size
14
+ )
15
+ end
16
+ end
17
+
18
+ def get_part_groups
19
+ cache.fetch("rockauto:part_groups", ttl: 86400) do
20
+ html = get("/en/partsearch/")
21
+ opts = Parsers::HtmlHelpers.select_options(html, "partgroup_partsearch_007")
22
+ groups = opts.map { |o| Models::PartSearchOption.new(value: o[:value], text: o[:text]) }
23
+ Models::PartGroupOptions.new(
24
+ part_groups: groups,
25
+ count: groups.size
26
+ )
27
+ end
28
+ end
29
+
30
+ def get_part_types
31
+ cache.fetch("rockauto:part_types", ttl: 86400) do
32
+ html = get("/en/partsearch/")
33
+ opts = Parsers::HtmlHelpers.select_options(html, "parttype_partsearch_007")
34
+ types = opts.map { |o| Models::PartSearchOption.new(value: o[:value], text: o[:text]) }
35
+ Models::PartTypeOptions.new(
36
+ part_types: types,
37
+ count: types.size
38
+ )
39
+ end
40
+ end
41
+
42
+ def search_parts_by_number(part_number, manufacturer: nil, part_group: nil, part_type: nil, part_name: nil, include_fitments: false)
43
+ man_value = ""
44
+ group_value = ""
45
+ type_value = ""
46
+
47
+ if manufacturer
48
+ mans = get_manufacturers
49
+ match = mans.lookup(manufacturer)
50
+ man_value = match&.value || ""
51
+ end
52
+
53
+ if part_group
54
+ groups = get_part_groups
55
+ match = groups.lookup(part_group)
56
+ group_value = match&.value || ""
57
+ end
58
+
59
+ if part_type
60
+ types = get_part_types
61
+ match = types.lookup(part_type)
62
+ type_value = match&.value || ""
63
+ end
64
+
65
+ form_data = {
66
+ "dopartsearch" => "1",
67
+ "partsearch[partnum][partsearch_007]" => part_number,
68
+ "partsearch[manufacturer][partsearch_007]" => man_value,
69
+ "partsearch[partgroup][partsearch_007]" => group_value,
70
+ "partsearch[parttype][partsearch_007]" => type_value,
71
+ "partsearch[partname][partsearch_007]" => part_name || "",
72
+ "partsearch[do][partsearch_007]" => "Search"
73
+ }
74
+
75
+ html = post_with_csrf("/en/partsearch/", form_data)
76
+ doc = Nokogiri::HTML(html)
77
+
78
+ parts = parse_part_search_results(doc)
79
+ parts = parts.map do |p|
80
+ attrs = p.to_h
81
+ if include_fitments && attrs[:listing_data]
82
+ fitment_result = get_fitment_for_part(attrs[:listing_data])
83
+ end
84
+ Models::PartInfo.new(**attrs)
85
+ end
86
+
87
+ Models::PartSearchResult.new(
88
+ parts: parts,
89
+ count: parts.size,
90
+ search_term: part_number,
91
+ manufacturer: manufacturer || "All",
92
+ part_group: part_group || "All"
93
+ )
94
+ end
95
+
96
+ def what_is_part_called(search_query)
97
+ html = post("/en/partsearch/", {
98
+ "topsearchinput[input]" => search_query,
99
+ "topsearchinput[submit]" => "Search"
100
+ })
101
+ doc = Nokogiri::HTML(html)
102
+ results = parse_what_is_called_results(doc)
103
+
104
+ Models::WhatIsPartCalledResults.new(
105
+ results: results,
106
+ count: results.size,
107
+ search_term: search_query
108
+ )
109
+ end
110
+
111
+ private
112
+
113
+ def parse_part_search_results(doc)
114
+ doc.css("table tr").map { |row|
115
+ cells = row.css("td")
116
+ next nil if cells.size < 3
117
+
118
+ part = Parsers::PartExtractor.extract_from_row(row)
119
+ next nil unless part
120
+
121
+ listing_data = extract_listing_data_from_row(row)
122
+
123
+ attrs = part.to_h
124
+ attrs[:listing_data] = listing_data if listing_data
125
+ Models::PartInfo.new(**attrs)
126
+ }.compact
127
+ end
128
+
129
+ def extract_listing_data_from_row(row)
130
+ data = {}
131
+
132
+ row.css("[data-groupindex], [data-carcode], [data-parttype], [data-partkey], [data-partnumber], [data-catalogname], [data-optkey]").each do |el|
133
+ data["groupindex"] = el["data-groupindex"] if el["data-groupindex"]
134
+ data["car"] = { "carcode" => el["data-carcode"], "parttype" => el["data-parttype"], "partkey" => el["data-partkey"] }
135
+ data["supplemental"] = { "partnumber" => el["data-partnumber"], "catalogname" => el["data-catalogname"] }
136
+ data["optkey"] = el["data-optkey"] if el["data-optkey"]
137
+ end
138
+
139
+ row.css("a").each do |a|
140
+ onclick = a["onclick"]
141
+ next unless onclick
142
+ match = onclick.match(/getbuyersguide\('([^']+)'/)
143
+ data["buyersguide_key"] = match[1] if match
144
+ end
145
+
146
+ data.empty? ? nil : data
147
+ end
148
+
149
+ def parse_what_is_called_results(doc)
150
+ doc.css("a").map { |a|
151
+ text = a.text.strip
152
+ next nil if text.empty?
153
+ path_parts = text.split(">").map(&:strip)
154
+ main_category = path_parts[0]
155
+ subcategory = path_parts[1]
156
+ Models::WhatIsPartCalledResult.new(
157
+ main_category: main_category || "",
158
+ subcategory: subcategory || "",
159
+ full_path: text
160
+ )
161
+ }.compact
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Endpoints
5
+ module Tools
6
+ def get_tool_categories(path = "/en/tools/")
7
+ html = get(path)
8
+ doc = Nokogiri::HTML(html)
9
+
10
+ categories = doc.css("a").map { |a|
11
+ text = a.text.strip
12
+ href = a["href"]
13
+ next nil if text.empty? || href.nil? || !href.include?("tools")
14
+ Models::ToolCategory.new(
15
+ name: text,
16
+ group_name: text,
17
+ href: Parsers::HtmlHelpers.make_absolute_url(href),
18
+ level: path.scan("/").size - 1
19
+ )
20
+ }.compact
21
+
22
+ Models::ToolCategories.new(
23
+ categories: categories,
24
+ count: categories.size,
25
+ level: path.scan("/").size - 1,
26
+ parent_path: path
27
+ )
28
+ end
29
+
30
+ def get_tools_by_category(category_path)
31
+ html = get(category_path)
32
+ doc = Nokogiri::HTML(html)
33
+ tools = parse_tools_from_table(doc)
34
+ category_name = category_path.split("/").last
35
+
36
+ Models::ToolsResult.new(
37
+ tools: tools,
38
+ count: tools.size,
39
+ category: category_name,
40
+ category_path: category_path
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ def parse_tools_from_table(doc)
47
+ doc.css("table tr").map { |row|
48
+ cells = row.css("td")
49
+ next nil if cells.size < 3
50
+
51
+ texts = cells.map { |c| c.text.strip }
52
+
53
+ link = row.at_css("a")
54
+ img = row.at_css("img")
55
+
56
+ Models::ToolInfo.new(
57
+ name: texts[1] || texts.first || "",
58
+ part_number: texts[0] || "",
59
+ brand: texts[2],
60
+ description: texts[3],
61
+ url: link ? Parsers::HtmlHelpers.make_absolute_url(link["href"]) : nil,
62
+ image_url: img ? Parsers::HtmlHelpers.make_absolute_url(img["src"]) : nil,
63
+ info_url: nil,
64
+ video_url: nil,
65
+ specifications: nil,
66
+ category: nil,
67
+ features: nil,
68
+ warranty_info: nil
69
+ )
70
+ }.compact
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Endpoints
5
+ module Vehicles
6
+ BASE_CATALOG_URL = "https://www.rockauto.com/en/catalog/"
7
+
8
+ def get_makes
9
+ html = get("/en/catalog/")
10
+ results = Models::VehicleMakes.from_html(html)
11
+ results
12
+ end
13
+
14
+ def get_years_for_make(make)
15
+ simulate_navigation_context(make: make)
16
+
17
+ path = "/en/catalog/#{make}"
18
+ html = get(path)
19
+ doc = Nokogiri::HTML(html)
20
+
21
+ years = doc.css('a[href*="catalog"]').map { |a|
22
+ href = a["href"]
23
+ next nil if href.nil?
24
+ match = href.match(/#{Regexp.escape(make)},(\d{4})/i)
25
+ match ? match[1].to_i : nil
26
+ }.compact.uniq.sort
27
+
28
+ Models::VehicleYears.new(
29
+ make: make,
30
+ years: years,
31
+ count: years.size
32
+ )
33
+ end
34
+
35
+ def get_models_for_make_year(make, year)
36
+ simulate_navigation_context(make: make, year: year.to_s)
37
+
38
+ path = "/en/catalog/#{make},#{year}"
39
+ html = get(path)
40
+ doc = Nokogiri::HTML(html)
41
+
42
+ models = doc.css('a[href*="catalog"]').map { |a|
43
+ href = a["href"]
44
+ next nil if href.nil?
45
+ match = href.match(/#{Regexp.escape(make)},#{year},(.+)/i)
46
+ next nil unless match
47
+ model = match[1].split(",").first
48
+ model&.strip
49
+ }.compact.uniq
50
+
51
+ Models::VehicleModels.new(
52
+ make: make,
53
+ year: year,
54
+ models: models,
55
+ count: models.size
56
+ )
57
+ end
58
+
59
+ def get_engines_for_vehicle(make, year, model)
60
+ path = "/en/catalog/#{make},#{year},#{model}"
61
+ html = get(path)
62
+ doc = Nokogiri::HTML(html)
63
+
64
+ engines = doc.css('a[href*="carcode"]').map { |a|
65
+ href = a["href"]
66
+ next nil unless href
67
+ match = href.match(/carcode=(\d+)/)
68
+ next nil unless match
69
+ Models::Engine.new(
70
+ description: a.text.strip,
71
+ carcode: match[1],
72
+ href: Parsers::HtmlHelpers.make_absolute_url(href)
73
+ )
74
+ }.compact
75
+
76
+ Models::VehicleEngines.new(
77
+ make: make,
78
+ year: year,
79
+ model: model,
80
+ engines: engines,
81
+ count: engines.size
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ class Error < StandardError; end
5
+ class AuthenticationError < Error; end
6
+ class CaptchaError < Error; end
7
+ class NetworkError < Error; end
8
+ class ParseError < Error; end
9
+ class NotFoundError < Error; end
10
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Models
5
+ class SavedAddress < Dry::Struct
6
+ attribute? :name, Types::String.optional
7
+ attribute? :full_name, Types::String.optional
8
+ attribute? :address_line1, Types::String.optional
9
+ attribute? :address_line2, Types::String.optional
10
+ attribute? :city, Types::String.optional
11
+ attribute? :state, Types::String.optional
12
+ attribute? :postal_code, Types::String.optional
13
+ attribute? :country, Types::String.optional
14
+ attribute? :phone, Types::String.optional
15
+ attribute :is_default, Types::Bool.default(false)
16
+ attribute? :address_id, Types::String.optional
17
+ end
18
+
19
+ class SavedAddressesResult < Dry::Struct
20
+ attribute :addresses, Types::Array.of(SavedAddress)
21
+ attribute :count, Types::Integer
22
+ attribute :has_default, Types::Bool.default(false)
23
+ end
24
+
25
+ class SavedVehicle < Dry::Struct
26
+ attribute :year, Types::Coercible::Integer
27
+ attribute :make, Types::String
28
+ attribute :model, Types::String
29
+ attribute? :engine, Types::String.optional
30
+ attribute? :carcode, Types::String.optional
31
+ attribute? :display_name, Types::String.optional
32
+ attribute? :catalog_url, Types::String.optional
33
+ attribute? :vehicle_id, Types::String.optional
34
+ end
35
+
36
+ class SavedVehiclesResult < Dry::Struct
37
+ attribute :vehicles, Types::Array.of(SavedVehicle)
38
+ attribute :count, Types::Integer
39
+ end
40
+
41
+ class OrderHistoryItem < Dry::Struct
42
+ attribute :order_number, Types::String
43
+ attribute? :date, Types::String.optional
44
+ attribute? :status, Types::String.optional
45
+ attribute? :total, Types::String.optional
46
+ attribute? :vehicle, Types::String.optional
47
+ attribute? :order_url, Types::String.optional
48
+ end
49
+
50
+ class OrderHistoryResult < Dry::Struct
51
+ attribute :orders, Types::Array.of(OrderHistoryItem)
52
+ attribute :count, Types::Integer
53
+ attribute? :filter_applied, Types::String.optional
54
+ attribute? :search_time, Types::String.optional
55
+ end
56
+
57
+ class AccountActivityResult < Dry::Struct
58
+ attribute :order_history, OrderHistoryResult.optional
59
+ attribute :saved_addresses, SavedAddressesResult.optional
60
+ attribute :saved_vehicles, SavedVehiclesResult.optional
61
+ attribute :has_discount_codes, Types::Bool.default(false)
62
+ attribute :has_store_credit, Types::Bool.default(false)
63
+ attribute :has_alerts, Types::Bool.default(false)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Models
5
+ class FitmentInfo < Dry::Struct
6
+ attribute :year, Types::Integer
7
+ attribute :make, Types::String
8
+ attribute :model, Types::String
9
+ attribute? :engine, Types::String.optional
10
+ attribute? :transmission, Types::String.optional
11
+ attribute? :drivetrain, Types::String.optional
12
+ attribute? :notes, Types::String.optional
13
+ end
14
+
15
+ class BuyersGuideResult < Dry::Struct
16
+ attribute :part_number, Types::String
17
+ attribute :brand, Types::String
18
+ attribute :fitments, Types::Array.of(FitmentInfo)
19
+ attribute :count, Types::Integer
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Models
5
+ class OrderItem < Dry::Struct
6
+ attribute :part_number, Types::String
7
+ attribute :description, Types::String
8
+ attribute? :brand, Types::String.optional
9
+ attribute? :quantity, Types::String.optional
10
+ attribute? :unit_price, Types::String.optional
11
+ attribute? :total_price, Types::String.optional
12
+ attribute? :status, Types::String.optional
13
+ attribute? :tracking_number, Types::String.optional
14
+ end
15
+
16
+ class BillingInfo < Dry::Struct
17
+ attribute? :subtotal, Types::String.optional
18
+ attribute? :shipping_cost, Types::String.optional
19
+ attribute? :tax, Types::String.optional
20
+ attribute? :total, Types::String.optional
21
+ attribute? :payment_method, Types::String.optional
22
+ attribute? :payment_status, Types::String.optional
23
+ end
24
+
25
+ class ShippingInfo < Dry::Struct
26
+ attribute? :method, Types::String.optional
27
+ attribute? :cost, Types::String.optional
28
+ attribute? :carrier, Types::String.optional
29
+ attribute? :tracking_number, Types::String.optional
30
+ attribute? :estimated_delivery, Types::String.optional
31
+ attribute? :actual_delivery, Types::String.optional
32
+ end
33
+
34
+ class OrderStatus < Dry::Struct
35
+ attribute :order_number, Types::String
36
+ attribute? :order_date, Types::String.optional
37
+ attribute? :status, Types::String.optional
38
+ attribute? :customer_email, Types::String.optional
39
+ attribute? :customer_phone, Types::String.optional
40
+ attribute :items, Types::Array.of(OrderItem).default([].freeze)
41
+ attribute :billing, BillingInfo.optional
42
+ attribute :shipping, ShippingInfo.optional
43
+ attribute? :notes, Types::String.optional
44
+ attribute? :return_eligibility, Types::String.optional
45
+ end
46
+
47
+ class OrderStatusError < Dry::Struct
48
+ attribute :error_type, Types::String
49
+ attribute :message, Types::String
50
+ attribute :order_number, Types::String
51
+ attribute :suggestions, Types::Array.of(Types::String).default([].freeze)
52
+ end
53
+
54
+ class OrderStatusResult < Dry::Struct
55
+ attribute :success, Types::Bool
56
+ attribute :order, OrderStatus.optional
57
+ attribute :error, OrderStatusError.optional
58
+ attribute? :lookup_time, Types::String.optional
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Models
5
+ class PartInfo < Dry::Struct
6
+ attribute :name, Types::String
7
+ attribute :part_number, Types::String
8
+ attribute? :brand, Types::String.optional
9
+ attribute? :price, Types::String.optional
10
+ attribute? :url, Types::String.optional
11
+ attribute? :image_url, Types::String.optional
12
+ attribute? :info_url, Types::String.optional
13
+ attribute? :video_url, Types::String.optional
14
+ attribute? :category, Types::String.optional
15
+ attribute? :specifications, Types::String.optional
16
+ attribute? :compatibility_notes, Types::String.optional
17
+ attribute? :listing_data, Types::Hash
18
+ end
19
+
20
+ class PartSearchResult < Dry::Struct
21
+ attribute :parts, Types::Array.of(PartInfo)
22
+ attribute :count, Types::Integer
23
+ attribute :search_term, Types::String
24
+ attribute :manufacturer, Types::String.default("All")
25
+ attribute :part_group, Types::String.default("All")
26
+ end
27
+
28
+ class PartSearchOption < Dry::Struct
29
+ attribute :value, Types::String
30
+ attribute :text, Types::String
31
+ end
32
+
33
+ class ManufacturerOptions < Dry::Struct
34
+ attribute :manufacturers, Types::Array.of(PartSearchOption)
35
+ attribute :count, Types::Integer
36
+ attribute? :last_updated, Types::String.optional
37
+
38
+ def lookup(name)
39
+ manufacturers.find { |m| m.text.casecmp(name).zero? }
40
+ end
41
+ end
42
+
43
+ class PartGroupOptions < Dry::Struct
44
+ attribute :part_groups, Types::Array.of(PartSearchOption)
45
+ attribute :count, Types::Integer
46
+ attribute? :last_updated, Types::String.optional
47
+
48
+ def lookup(name)
49
+ part_groups.find { |g| g.text.casecmp(name).zero? }
50
+ end
51
+ end
52
+
53
+ class PartTypeOptions < Dry::Struct
54
+ attribute :part_types, Types::Array.of(PartSearchOption)
55
+ attribute :count, Types::Integer
56
+ attribute? :last_updated, Types::String.optional
57
+
58
+ def lookup(name)
59
+ part_types.find { |t| t.text.casecmp(name).zero? }
60
+ end
61
+ end
62
+
63
+ class WhatIsPartCalledResult < Dry::Struct
64
+ attribute :main_category, Types::String
65
+ attribute :subcategory, Types::String
66
+ attribute :full_path, Types::String
67
+ end
68
+
69
+ class WhatIsPartCalledResults < Dry::Struct
70
+ attribute :results, Types::Array.of(WhatIsPartCalledResult)
71
+ attribute :count, Types::Integer
72
+ attribute :search_term, Types::String
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Models
5
+ class ToolCategory < Dry::Struct
6
+ attribute :name, Types::String
7
+ attribute :group_name, Types::String
8
+ attribute? :href, Types::String.optional
9
+ attribute :level, Types::Integer.default(0)
10
+ end
11
+
12
+ class ToolCategories < Dry::Struct
13
+ attribute :categories, Types::Array.of(ToolCategory)
14
+ attribute :count, Types::Integer
15
+ attribute :level, Types::Integer.default(0)
16
+ attribute? :parent_path, Types::String.optional
17
+ end
18
+
19
+ class ToolInfo < Dry::Struct
20
+ attribute :name, Types::String
21
+ attribute :part_number, Types::String
22
+ attribute? :brand, Types::String.optional
23
+ attribute? :description, Types::String.optional
24
+ attribute? :url, Types::String.optional
25
+ attribute? :image_url, Types::String.optional
26
+ attribute? :info_url, Types::String.optional
27
+ attribute? :video_url, Types::String.optional
28
+ attribute? :specifications, Types::String.optional
29
+ attribute? :category, Types::String.optional
30
+ attribute? :features, Types::String.optional
31
+ attribute? :warranty_info, Types::String.optional
32
+ end
33
+
34
+ class ToolsResult < Dry::Struct
35
+ attribute :tools, Types::Array.of(ToolInfo)
36
+ attribute :count, Types::Integer
37
+ attribute :category, Types::String
38
+ attribute? :category_path, Types::String.optional
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RockautoApi
4
+ module Types
5
+ include Dry.Types()
6
+ end
7
+
8
+ module Models
9
+ class VehicleMakes < Dry::Struct
10
+ attribute :makes, Types::Array.of(Types::String)
11
+ attribute :count, Types::Integer
12
+
13
+ def self.from_html(html)
14
+ doc = Nokogiri::HTML(html)
15
+ makes = doc.css('a[href*="/en/catalog/"]').map { |a|
16
+ href = a["href"]
17
+ next nil if href.nil? || href == "/en/catalog/" || href.include?(",")
18
+ a.text.strip
19
+ }.compact.uniq
20
+ new(makes: makes, count: makes.size)
21
+ end
22
+ end
23
+
24
+ class VehicleYears < Dry::Struct
25
+ attribute :make, Types::String
26
+ attribute :years, Types::Array.of(Types::Integer)
27
+ attribute :count, Types::Integer
28
+ end
29
+
30
+ class VehicleModels < Dry::Struct
31
+ attribute :make, Types::String
32
+ attribute :year, Types::Integer
33
+ attribute :models, Types::Array.of(Types::String)
34
+ attribute :count, Types::Integer
35
+ end
36
+
37
+ class Engine < Dry::Struct
38
+ attribute :description, Types::String
39
+ attribute :carcode, Types::String
40
+ attribute? :href, Types::String.optional
41
+ end
42
+
43
+ class VehicleEngines < Dry::Struct
44
+ attribute :make, Types::String
45
+ attribute :year, Types::Integer
46
+ attribute :model, Types::String
47
+ attribute :engines, Types::Array.of(Engine)
48
+ attribute :count, Types::Integer
49
+ end
50
+
51
+ class PartCategory < Dry::Struct
52
+ attribute :name, Types::String
53
+ attribute :group_name, Types::String
54
+ attribute? :href, Types::String.optional
55
+ end
56
+
57
+ class VehiclePartCategories < Dry::Struct
58
+ attribute :make, Types::String
59
+ attribute :year, Types::Integer
60
+ attribute :model, Types::String
61
+ attribute :carcode, Types::String
62
+ attribute :categories, Types::Array.of(PartCategory)
63
+ attribute :count, Types::Integer
64
+ end
65
+
66
+ class VehiclePartsResult < Dry::Struct
67
+ attribute :make, Types::String
68
+ attribute :year, Types::Integer
69
+ attribute :model, Types::String
70
+ attribute :carcode, Types::String
71
+ attribute :category, Types::String
72
+ attribute :parts, Types::Array
73
+ attribute :count, Types::Integer
74
+ end
75
+ end
76
+ end