huginn_acumen_product_agent 1.7.3 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1025b857902f5c14f649104a892ec5d882201920933ca4f010b45cab3a25f480
4
- data.tar.gz: 999d68b437f373c47b70f69f5d884c52a30c6788f46262beb226b24c2200bba0
3
+ metadata.gz: 30c918a4763fbc8b84145f83215cf46d9b162adeb32d025d206d7f7fa51ded41
4
+ data.tar.gz: 77b5d58d8de8f15e3908b920b04882fb0706974009c9fa1ea1fe93bb66b7f1d6
5
5
  SHA512:
6
- metadata.gz: 46ea5a9bd225a32c1af84c9a14becb87b71a4c01038a85199e6dfc9bdea0f1073fef090738839f4d2cbc2a5d3b3b81e2d13da67cc9f6952dbd9542b7647a3014
7
- data.tar.gz: c08f0f45ed92defb6704b6b821053859c959c0a4061c5f58b094bac7c5bb5ca3c7e1bf0bda93ff2117d11134533945d00a6b65df1a6ed1214c2ac242a88c2782
6
+ metadata.gz: e2fe59ce492e910b0ca2c063938141a9f0ef6b64639fff28f34c3e01d577e5388d6007e1a135a51f01579677e60f2ee0e79e3aa2a449bfffba0e44242258953a
7
+ data.tar.gz: 92563dd015ec11f237a6a91d740d3ff682ddcb73e6598f1768c3dfa51c2c4e75cb8355c3ee7228a5f63a0f1470a483d21adb5bd324283f4efa45b7c77ccada50
@@ -1,6 +1,13 @@
1
1
  require 'huginn_agent'
2
2
 
3
- HuginnAgent.load 'huginn_acumen_product_agent/concerns/acumen_product_query_concern'
3
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/acumen_query_concern'
4
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/alternate_products_query_concern'
5
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/inv_product_query_concern'
6
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/prod_mkt_query_concern'
7
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/product_categories_query_concern'
8
+ HuginnAgent.load 'huginn_acumen_product_agent/concerns/product_contributors_query_concern'
9
+
4
10
  HuginnAgent.load 'huginn_acumen_product_agent/acumen_client'
11
+ HuginnAgent.load 'huginn_acumen_product_agent/acumen_agent_error'
5
12
 
6
13
  HuginnAgent.register 'huginn_acumen_product_agent/acumen_product_agent'
@@ -0,0 +1,9 @@
1
+ class AcumenAgentError < StandardError
2
+ attr_reader :thing
3
+ def initialize(scope, message, data, original_error)
4
+ @scope = scope
5
+ @data = data
6
+ @original_error = original_error
7
+ super(message)
8
+ end
9
+ end
@@ -3,7 +3,12 @@
3
3
  module Agents
4
4
  class AcumenProductAgent < Agent
5
5
  include WebRequestConcern
6
- include AcumenProductQueryConcern
6
+ include AcumenQueryConcern
7
+ include InvProductQueryConcern
8
+ include ProdMktQueryConcern
9
+ include ProductContributorsQueryConcern
10
+ include ProductCategoriesQueryConcern
11
+ include AlternateProductsQueryConcern
7
12
 
8
13
  default_schedule '12h'
9
14
 
@@ -11,27 +16,86 @@ module Agents
11
16
  default_schedule 'never'
12
17
 
13
18
  description <<-MD
14
- Huginn agent for retrieving sane ACUMEN product data.
19
+ Huginn agent for retrieving sane ACUMEN product data.
15
20
 
16
- ## Agent Options
17
- The following outlines the available options in this agent
21
+ ## Agent Options
22
+ The following outlines the available options in this agent
18
23
 
19
- ### Acumen Connection
20
- * endpoint: The root URL for the Acumen API
21
- * site_code: The site code from Acumen
22
- * password: The Acumen API password
24
+ ### Acumen Connection
25
+ * endpoint: The root URL for the Acumen API
26
+ * site_code: The site code from Acumen
27
+ * password: The Acumen API password
23
28
 
24
- ### Variant Settings
25
- * physical_formats: A list of the formats associated with a physical product
26
- * digital_formats: A list of the formats associated with a digital product
29
+ ### Format Options
30
+ * digital_formats: A list of the formats associated with a digital product
27
31
 
28
- ### Product Attributes
29
- * attribute_to_property: An optional map linking Acumen attributes to Schema.org
30
- product properties.
32
+ ### Product Attributes
33
+ * attribute_to_property: An optional map linking Acumen attributes to Schema.org
34
+ product properties.
31
35
 
32
- ### Other Options
33
- * ignore_skus: An optional array of Acumen product skus that will be intentionally
34
- excluded from any output.
36
+ ### Other Options
37
+ * ignore_skus: An optional array of Acumen product skus that will be intentionally
38
+ excluded from any output.
39
+
40
+ ### Event Output
41
+ This agent will output one of two event types during processing:
42
+
43
+ * Product bundles
44
+ * Processing Errors
45
+
46
+ The product bundle payload will be structured as:
47
+
48
+ ```
49
+ {
50
+ products: [ { ... }, { ... }, ... ],
51
+ status: 200
52
+ }
53
+ ```
54
+
55
+ The processing error payload will be structured as:
56
+
57
+ ```
58
+ {
59
+ status: 500,
60
+ scope: '[Process Name]',
61
+ message: '[Error Message]',
62
+ data: { ... },
63
+ trace: [ ... ]
64
+ }
65
+ ```
66
+
67
+ ### Payload Status
68
+
69
+ `status: 200`: Indicates a true success. The agent has output the full
70
+ range of expected data.
71
+
72
+ `status: 206`: Indicates a partial success. The products within the bundle
73
+ are vaild, but the bundle _may_ be missing products that were somehow invalid.
74
+
75
+ `status: 500`: Indicates a processing error. This may represent a complete
76
+ process failure, but may also be issued in parallel to a `202` payload.
77
+
78
+ Because this agent receives an array of Product IDs as input, errors will be issued in
79
+ such a way that product processing can recover when possible. Errors that occur within
80
+ a specific product bundle will emit an error event, but the agent will then move
81
+ forward processing the next bundle.
82
+
83
+ For example, if this agent receives two products as input (`A` and `B`), and we fail to
84
+ load the Inv_Product record for product `A`, the agent would emit an error payload of:
85
+
86
+ ```
87
+ {
88
+ status: 500,
89
+ scope: 'Fetch Inv_Product Data',
90
+ message: 'Failed to lookup Inv_Product record for Product A',
91
+ data: { product_id: 123 },
92
+ trace: [ ... ]
93
+ }
94
+ ```
95
+
96
+ The goal of this approach is to ensure the agent outputs as much data as reasonably possible
97
+ with each execution. If there is an error in the Paperback version of a title, that shouldn't
98
+ prevent this agent from returning the Hardcover version.
35
99
 
36
100
  MD
37
101
 
@@ -40,7 +104,6 @@ module Agents
40
104
  'endpoint' => 'https://example.com',
41
105
  'site_code' => '',
42
106
  'password' => '',
43
- 'physical_formats' => [],
44
107
  'digital_formats' => [],
45
108
  'attribute_to_property' => {},
46
109
  }
@@ -59,10 +122,6 @@ module Agents
59
122
  errors.add(:base, 'password is a required field')
60
123
  end
61
124
 
62
- unless options['physical_formats'].present?
63
- errors.add(:base, "physical_formats is a required field")
64
- end
65
-
66
125
  unless options['digital_formats'].present?
67
126
  errors.add(:base, "digital_formats is a required field")
68
127
  end
@@ -72,9 +131,9 @@ module Agents
72
131
  end
73
132
 
74
133
  if options['ignore_skus']
75
- unless options['ignore_skus'].is_a?(Array)
76
- errors.add(:base, "if provided, ignore_skus must be an array")
77
- end
134
+ unless options['ignore_skus'].is_a?(Array)
135
+ errors.add(:base, "if provided, ignore_skus must be an array")
136
+ end
78
137
  end
79
138
  end
80
139
 
@@ -95,13 +154,14 @@ module Agents
95
154
  private
96
155
 
97
156
  def handle(event)
157
+ # Process agent options
98
158
  endpoint = interpolated['endpoint']
99
159
  site_code = interpolated['site_code']
100
160
  password = interpolated['password']
101
- physical_formats = interpolated['physical_formats']
102
161
  digital_formats = interpolated['digital_formats']
103
- ignore_skus = interpolated['ignore_skus'] ? interpolated['ignore_skus'] : []
162
+ ignored_skus = interpolated['ignore_skus'] ? interpolated['ignore_skus'] : []
104
163
 
164
+ # Configure the Acumen Client
105
165
  auth = {
106
166
  'site_code' => site_code,
107
167
  'password' => password,
@@ -110,33 +170,62 @@ module Agents
110
170
  client = AcumenClient.new(faraday, auth)
111
171
 
112
172
  ids = event.payload['ids']
113
- products = get_products_by_ids(client, ids)
114
- products = get_product_variants(client, products, physical_formats, digital_formats)
115
- products = get_master_products_by_id(client, products)
116
- products = get_product_categories(client, products)
117
- products = get_product_contributors(client, products)
118
-
119
- # map attributes
120
- products.map do |product|
121
- map_attributes(product)
122
173
 
123
- product['model'].each do |model|
124
- map_attributes(model)
125
- end
174
+ # Load Products
175
+ fetch_product_bundles(client, ids, digital_formats, ignored_skus)
176
+ end
126
177
 
127
- product
128
- end
178
+ private
179
+
180
+ # Returns an array of Product objects for the provided product_ids.
181
+ # Each object is a merged representation of all the individual Acumen tables
182
+ # that make up a product record with fields mapped to the schema.org/Product
183
+ # object definition.
184
+ def fetch_products(acumen_client, product_ids, digital_format_list)
185
+ products = fetch_inv_product_data(acumen_client, product_ids, digital_format_list)
186
+ products = fetch_product_marketing(acumen_client, products)
187
+ products = fetch_product_contributors(acumen_client, products)
188
+ products = fetch_product_categories(acumen_client, products)
129
189
 
130
190
  products.each do |product|
131
- unless ignore_skus.include?(product['sku'])
132
- create_event payload: product
133
- end
191
+ map_attributes(product)
134
192
  end
193
+
194
+ return products
135
195
  end
136
196
 
137
- private
197
+ # Returns an array of product bundles for the provided `products` array.
198
+ # Each bundle will contain an array of all the product definitions for each
199
+ # format of a given title.
200
+ #
201
+ # NOTE: The generated bundles will contain both active and inactive products
202
+ # to facilitate product deletion in external systems.
203
+ def fetch_product_bundles(acumen_client, product_ids, digital_format_list, ignored_skus)
138
204
 
205
+ begin
206
+ alternate_ids_map = fetch_alternate_format_ids(acumen_client, product_ids)
207
+
208
+ bundles = product_ids.map do |id|
209
+ bundle_ids = alternate_ids_map[id]
210
+ bundle_ids.append(id) unless bundle_ids.include?(id)
211
+ bundle = fetch_products(acumen_client, bundle_ids.sort, digital_format_list)
212
+
213
+ # Filter out any products that are explicitly ignored by SKU
214
+ bundle.select { |p| !ignored_skus.include?(p['sku']) }
215
+
216
+ create_event payload: { products: bundle, status: 200 }
217
+ end
218
+ rescue AcumenAgentError => e
219
+ issue_error(e)
220
+ end
221
+ end
222
+
223
+ # Maps additional Acumen attributes to the `additionalProperty` array
224
+ # NOTE: Attributes mapped in this way will be _removed_ from the
225
+ # `acumenAttributes` array.
139
226
  def map_attributes(product)
227
+
228
+
140
229
  attribute_to_property = interpolated['attribute_to_property']
141
230
  attributes = product['acumenAttributes']
142
231
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module contains the baseline utility methods used in the more specific
4
+ # data concerns
5
+ module AcumenQueryConcern
6
+ extend ActiveSupport::Concern
7
+
8
+ UNIT_MAP = {
9
+ 'oz.' => 'OZ',
10
+ 'Inches (US)' => 'INH',
11
+ }
12
+
13
+ protected
14
+
15
+ # Maps Acumen XML data to a hash object as specified in the provided `field_map`
16
+ # The field map is a hash of { source_field: target_field }
17
+ def response_mapper(data, field_map)
18
+ result = {}
19
+
20
+ field_map.each do |source_field, target_field|
21
+ result[target_field] = get_field_value(data, source_field)
22
+ end
23
+
24
+ return result
25
+ end
26
+
27
+ # Utility function to retrieve a value from an XML field
28
+ def get_field_value(data, field_name)
29
+ data[field_name]['__content__'] if data[field_name]
30
+ end
31
+
32
+ # Returns a quantitative field value (e.g. weight) as a Schema.org/QuantitativeValue
33
+ # object
34
+ def get_quantitative_value(value, unit)
35
+ {
36
+ '@type' => 'QuantitativeValue',
37
+ 'value' => value,
38
+ 'unitText' => unit,
39
+ 'unitCode' => (UNIT_MAP[unit] if unit),
40
+ } if value
41
+ end
42
+
43
+ # Emits an error payload event to facilitate better debugging/logging
44
+ # NOTE: The `error` here is expected to be an instance of AcumenAgentError
45
+ def issue_error(error, status = 500)
46
+ # NOTE: Status is intentionally included on the top-level payload so that other
47
+ # agents can look for a `payload[:status]` of either 200 or 500 to distinguish
48
+ # between success and failure states
49
+ create_event payload: {
50
+ status: status,
51
+ scope: error.scope,
52
+ message: error.message,
53
+ original_error: error.original_error,
54
+ data: error.data,
55
+ trace: error.original_error.backtrace,
56
+ }
57
+ end
58
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentErrorConcern
4
+ extend ActiveSupport::Concern
5
+
6
+ def issue_error(error, status = 500)
7
+ # NOTE: Status is intentionally included on the top-level payload so that other
8
+ # agents can look for a `payload[:status]` of either 200 or 500 to distinguish
9
+ # between success and failure states
10
+ create_event payload: {
11
+ status: status,
12
+ scope: error.scope,
13
+ message: error.message,
14
+ original_error: error.original_error,
15
+ data: error.data,
16
+ trace: error.original_error.backtrace,
17
+ }
18
+ end
19
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module is responsible for reading/processing data from the Product_Link
4
+ # table. This table defines the link between product records.
5
+ #
6
+ # It's important to note that this table defines links between related products
7
+ # as well as product formats. The `Alt_Format` field determines whether the linked
8
+ # product is an alternate format for a given title.
9
+ #
10
+ # For the purposes of this concern, we only care about Product_Link records where
11
+ # the `Alt_Format` field is `true`
12
+ module AlternateProductsQueryConcern
13
+ extend AcumenQueryConcern
14
+
15
+ # Fetches the Inv_Product.ID for alternate formats of the provided product_ids
16
+ def fetch_alternate_format_ids(acumen_client, product_ids)
17
+ begin
18
+ link_data = acumen_client.get_linked_products(product_ids)
19
+
20
+ links = process_alternate_format_response(link_data)
21
+
22
+ return map_alternate_format_links(links, product_ids)
23
+ rescue => error
24
+ issue_error(AcumenAgentError.new(
25
+ 'fetch_alternate_format_ids',
26
+ 'Failed attempting to lookup alternate products',
27
+ product_ids,
28
+ error
29
+ ))
30
+ end
31
+ end
32
+
33
+ # This function parses the raw data returned from the Product_Link table
34
+ # The resulting array contains the set alternate format IDs associated with a
35
+ # single product
36
+ def process_alternate_format_response(raw_data)
37
+ results = []
38
+ raw_data.map do |link|
39
+
40
+ begin
41
+ mapped = response_mapper(link, {
42
+ 'Product_Link.Link_From_ID' => 'from_id',
43
+ 'Product_Link.Link_To_ID' => 'to_id',
44
+ 'Product_Link.Alt_Format' => 'alt_format',
45
+ })
46
+
47
+ if mapped['alt_format'].to_s != '0' && !mapped.in?(results)
48
+ results.push(mapped)
49
+ end
50
+
51
+ rescue => error
52
+ issue_error(AcumenAgentError.new(
53
+ 'process_alternate_format_response',
54
+ 'Failed while processing alternate format links',
55
+ raw_data,
56
+ error
57
+ ))
58
+ end
59
+ end
60
+
61
+ return results
62
+ end
63
+
64
+ # Returns a map that ties each provided `product_id` to an array of IDs for its
65
+ # other formats
66
+ def map_alternate_format_links(links, product_ids)
67
+ results = {}
68
+
69
+ product_ids.each do |id|
70
+
71
+ begin
72
+ alternates = links.select { |l| l['from_id'] == id }
73
+ results[id] = alternates.map { |l| l['to_id'] }
74
+
75
+ rescue => error
76
+ issue_error(AcumenAgentError.new(
77
+ 'map_alternate_format_links',
78
+ 'Failed while mapping alternate format links',
79
+ { id: id, links: links },
80
+ error
81
+ ))
82
+ end
83
+ end
84
+
85
+ return results
86
+ end
87
+ end