huginn_record_link_agent 1.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,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: b22216e5476990eb8c5d7d172714c305eda08b05
4
+ data.tar.gz: 5e26a8864f5abd30b59a77ad9eeb64377d256de7
5
+ SHA512:
6
+ metadata.gz: 7406438bd05bc4dab39f8f29d6c1686305787974bc748403a3d5eb4157cdce4e227f5a1c2ec26a5bd3d03f80c2fbd3b432a6a34252019b48fe3286023ce63f75
7
+ data.tar.gz: 2359d01fd5b259e7178923dc8b903134b48790f456911d8f84b4345654543c2a31678314d3e5dad8b4f6ab3eada863d475067de2566785bc649de8dacbb59a65
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2019 Jacob Spizziri
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,7 @@
1
+ require 'huginn_record_link_agent/engine'
2
+ require 'huginn_agent'
3
+
4
+ module HuginnRecordLinkAgent
5
+ #HuginnAgent.load 'huginn_record_link_agent/concerns/my_agent_concern'
6
+ HuginnAgent.register 'huginn_record_link_agent/record_link_agent'
7
+ end
@@ -0,0 +1,6 @@
1
+ module HuginnRecordLinkAgent
2
+ class Engine < ::Rails::Engine
3
+ config.autoload_paths += Dir["#{config.root}/app/models/**/"]
4
+ config.autoload_paths += Dir["#{config.root}/app/utils/**/"]
5
+ end
6
+ end
@@ -0,0 +1,480 @@
1
+ module Agents
2
+ class RecordLinkAgent < Agent
3
+
4
+ can_dry_run!
5
+ no_bulk_receive!
6
+ default_schedule "never"
7
+
8
+ description do
9
+ <<-MD
10
+ This agent manages bi-directional links between systems that lack viable support for external ID fields.
11
+
12
+ ## Agent Options
13
+
14
+ - `create_link` - When `true`, this agent will create a link between a source and target record. When false, this agent will simple look for matching record links.
15
+ - `lookup_type` - One of `source`, `target`, `both`. Determines which link type to retrieve
16
+ - `emit_each` - When `true`, each linked record will be emitted as a single event
17
+ - `emit_events` - Setting this to `true` will result in the generated RecordLink being emitted as an Event which can be subsequently consumed by another agent
18
+ - `output_mode` - Setting this value to `merge` will result in the emitted Event being merged into the original contents of the received Event. Setting it to `clean` will result in no merge.
19
+
20
+ ###Create Record Link
21
+ Link creation leverages Rails `find_or_create_by` to avoid errors. This allows users to inject a link creation into their existing
22
+ flow without requiring trigger agents that check to see whether a link already exists. In most implementations, this check will have
23
+ run earlier in the flow (prior to upserting records to the external system)
24
+
25
+ **Required Options:**
26
+ - `source_system` - The source/authority for the record in question
27
+ - `source_type` - The record type in the source system
28
+ - `source_id` - The record ID (or an array of IDs) from the source system
29
+ - `target_system` - The system the source record is being sent to
30
+ - `target_type` - The record type in the target system
31
+ - `target_id` - The record ID (or array of IDs) in the target system
32
+ - `create_link` - Set to `true`
33
+
34
+ **Additional Notes:**
35
+ `target_id` and `source_id` can be set to an array in order to link multiple records together in a single execution.
36
+ In such cases, link creation will be wrapped in a transaction. The agent will only emit a successful event if all links
37
+ are created without error. If any of the links fail, a failure status will be emitted.
38
+
39
+ If _both_ `target_id` and `source_id` are arrays, then _all_ provided source records will be linked to _all_ provided targets
40
+
41
+ ###Find Target Record Links
42
+
43
+ **Required Options:**
44
+
45
+ - `source_system` - The source/authority for the record in question
46
+ - `source_type` - The record type in the source system
47
+ - `source_id` - The record ID(or array of IDs) from the source system
48
+
49
+ **Optional Filters/Settings:**
50
+ - `target_system` - Only return matches from the specified system
51
+ - `target_type` - Only return matches of the specified type (**Note:** When set, `target_system` is required)
52
+ - `create_link` - Set to `false`. This value defaults to `false` if not provided
53
+ - `require_all` - If `true`, this Agent will only emit a successful event if a matching target is found for all provided source_id values
54
+
55
+ **Additional Notes:**
56
+ If `source_id` is an array and `require_all` is `true`, this Agent will ensure that at least one matching target exists for each
57
+ provided source. If any source is missing a target, then a 404 - Links Missing event will be emitted.
58
+
59
+ ###Find Source Record Links
60
+
61
+ **Required Options:**
62
+
63
+ - `target_system` - The system the record was sent to
64
+ - `target_type` - The record type in the target system
65
+ - `target_id` - The record ID (or array of IDs) from the target system
66
+
67
+ **Optional Filters/Options:**
68
+ - `source_system` - Only return matches from the specified system
69
+ - `source_type` - Only return matches of the specified type (**Note:** When set, `target_system` is required)
70
+ - `create_link` - Set to `false`. This value defaults to `false` if not provided
71
+ - `require_all` - If `true`, this Agent will only emit a successful event if a matching source is found for all provided target_id values
72
+
73
+ **Additional Notes:**
74
+ If `target_id` is an array and `require_all` is `true`, this Agent will ensure that at least one matching source exists for each
75
+ provided target. If any source is missing a target, then a 404 - Links Missing event will be emitted.
76
+
77
+ ###Find All Record Links
78
+
79
+ **Required Options:**
80
+
81
+ - `record_system` - The system containing the record
82
+ - `record_type` - The record type in the specified system
83
+ - `record_id` - The ID of the record
84
+ - `create_link` - Set to `false`. This value defaults to `false` if not provided
85
+
86
+ **Optional Filters:**
87
+
88
+ - `system_filter` - Only return links to or from the specified system
89
+ - `type_filter` - Only return links to or from the specified type
90
+
91
+ ###Hard Errors vs Soft Errors
92
+ This agent issues two distinct error statuses: `404` and `500`.
93
+
94
+ A `404` is treated like a soft error. These errors will be reported for
95
+ debugging purposes, but they will not result in a task failure unless the
96
+ `require_all` parameter is set to true.
97
+
98
+ If `emit_each` is set to `true`, and `emit_events` is `true`, soft errors
99
+ will be emitted individually with a `link_status` of `404`. If `emit_each`
100
+ is false, a single error will be emitted with a `link_status` of `404` and
101
+ an array of each individua error.
102
+
103
+ A `500` status is considered a critical failure and is only issued when
104
+ unexpected errors are encountered during processing (usually related to
105
+ database interactions) If a critical error is encountered, this will always
106
+ result in a task failure.
107
+
108
+ When a 500 error occurs, if `emit_events` is `true`, then all errors
109
+ encountered during processing will be consolidated in to a single `link_error`
110
+ payload with a `link_status` of `500`.
111
+
112
+ **NOTE:** If `emit_each` is `true`, the option
113
+ is ignored when a 500 error occurs.
114
+
115
+ ### Recommended Usage
116
+
117
+ Traditionally bi-directional relationships work because one knows both endpoints in
118
+ the relationship, and associated tables are defined accordingly, but in this case,
119
+ the endpoints are unknwon. As a result, some sense of direction must be maintained
120
+ in order to accurately identify what the link represents.
121
+
122
+ When linking records together with this agent, it is up to the user to maintain
123
+ knowledge of that direction. As a general rule of thumb, it is recommended that the
124
+ `source` fields be defined as the authority/origin point of the data and the `target`
125
+ fields be identified as the destination of the data.
126
+
127
+ In the case of an integration between an eCommerce platform and an external product
128
+ catalog, the product catalog would be considered the `source` for product and category
129
+ information as it is likely the authority on all product/category information.
130
+
131
+ **Regarding require_all:**
132
+ When looking up source or target records, if `require_all` is set to `true`, it is recommended that `emit_each` be omitted
133
+ (or explicitly set to `false`) so that further operations can be performed on the entire batch of matched records.
134
+ MD
135
+ end
136
+
137
+ event_description <<-MD
138
+
139
+ When `emit_each` is set to `true`, events look like this:
140
+
141
+ {
142
+ record_link {
143
+ link_status: "200",
144
+ source_system: "source_system",
145
+ source_type: "source_type",
146
+ source_id: 123,
147
+ target_system: "target_system",
148
+ target_type: "target_type",
149
+ target_id: "target_id"
150
+ }
151
+ }
152
+
153
+ When `emit_each` is set to `false`, events look like this:
154
+
155
+ ```
156
+ {
157
+ "record_link": {
158
+ "link_status": "200",
159
+ "system": "system",
160
+ "type": "type",
161
+ "record_id": 123,
162
+ "targets": [
163
+ {
164
+ "target_system": "system",
165
+ "target_type": "type",
166
+ "target_id": 123
167
+ },
168
+ { ... }
169
+ ],
170
+ "sources": [
171
+ {
172
+ "source_system": "system",
173
+ "source_type": "type",
174
+ "source_id": 123
175
+ },
176
+ { ... }
177
+ ]
178
+ }
179
+ }
180
+
181
+ On error, events look like this:
182
+
183
+ {
184
+ "record_link": {
185
+ "link_status": "...",
186
+ "link_errors": [...]
187
+ }
188
+ }
189
+
190
+ ```
191
+ Original event contents will be merged when `output_mode` is set to `merge`.
192
+ MD
193
+
194
+ def default_options
195
+ {
196
+ 'expected_receive_period_in_days' => '1',
197
+ 'source_system' => 'Target System',
198
+ 'source_type' => 'Target Model',
199
+ 'source_id' => '123',
200
+ 'target_system' => 'Target System',
201
+ 'target_type' => 'Target Type',
202
+ 'target_id' => '123',
203
+ 'create_link' => 'true',
204
+ 'emit_each' => 'true',
205
+ 'emit_events' => 'true',
206
+ 'output_mode' => 'clean',
207
+ }
208
+ end
209
+
210
+ def working?
211
+ return false if recent_error_logs?
212
+
213
+ if interpolated['expected_receive_period_in_days'].present?
214
+ return false unless last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago
215
+ end
216
+
217
+ true
218
+ end
219
+
220
+ def validate_options
221
+
222
+ if options.has_key?('emit_each') && boolify(options['emit_each']).nil?
223
+ errors.add(:base, 'when provided, `emit_each` bust be either true or false')
224
+ end
225
+
226
+ if options.has_key?('emit_events') && boolify(options['emit_events']).nil?
227
+ errors.add(:base, "if provided, emit_events must be true or false")
228
+ end
229
+
230
+ if options['output_mode'].present? && !options['output_mode'].to_s.include?('{') && !%[clean merge].include?(options['output_mode'].to_s)
231
+ errors.add(:base, "if provided, output_mode must be 'clean' or 'merge'")
232
+ end
233
+
234
+ if options.has_key?('create_link') && boolify(options['create_link'])
235
+ self.validate_create_params
236
+
237
+ elsif options.has_key?('lookup_type')
238
+ if options['lookup_type'] == 'source'
239
+ self.validate_source_lookup_params
240
+
241
+ elsif options['lookup_type'] == 'target'
242
+ self.validate_target_lookup_params
243
+
244
+ elsif options['lookup_type'] == 'all'
245
+ self.validate_all_lookup_params
246
+
247
+ else
248
+ errors.add(:base, "when provided, `lookup_type` must be one of `source`, `target` or `all`")
249
+ end
250
+
251
+ else
252
+ # By default, this agent returns ALL record links
253
+ self.validate_all_lookup_params
254
+
255
+ end
256
+ end
257
+
258
+ def validate_create_params
259
+
260
+ if options.has_key?('emit_each') && !boolify(options['emit_each'])
261
+ errors.add(:base, "when `create_link` is true, `emit_each` must be true if provided")
262
+ end
263
+
264
+ unless options['source_system'].present?
265
+ errors.add(:base, "when creating record links `source_system` is a required field")
266
+ end
267
+
268
+ unless options['source_type'].present?
269
+ errors.add(:base, "when creating record links `source_type` is a required field")
270
+ end
271
+
272
+ unless options['source_id'].present?
273
+ errors.add(:base, "when creating record links `source_id` is a required field")
274
+ end
275
+
276
+ unless options['target_system'].present?
277
+ errors.add(:base, "when creating record links `target_system` is a required field")
278
+ end
279
+
280
+ unless options['target_type'].present?
281
+ errors.add(:base, "when creating record links `target_type` is a required field")
282
+ end
283
+
284
+ unless options['target_id'].present?
285
+ errors.add(:base, "when creating record links `target_id` is a required field")
286
+ end
287
+ end
288
+
289
+ def validate_target_lookup_params
290
+ unless options['source_system'].present?
291
+ errors.add(:base, "when fetching target links `source_system` is a required field")
292
+ end
293
+
294
+ unless options['source_type'].present?
295
+ errors.add(:base, "when fetching target links `source_type` is a required field")
296
+ end
297
+
298
+ unless options['source_id'].present?
299
+ errors.add(:base, "when fetching target links `source_id` is a required field")
300
+ end
301
+
302
+ if options.has_key?('target_type')
303
+ unless options.has_key?('target_system')
304
+ errors.add(:base, "when looking up linked target records, if `target_type` is provided, `target_system` is required")
305
+ end
306
+ end
307
+ end
308
+
309
+ def validate_source_lookup_params
310
+ unless options['target_system'].present?
311
+ errors.add(:base, "when fetching source links `target_system` is a required field")
312
+ end
313
+
314
+ unless options['target_type'].present?
315
+ errors.add(:base, "when fetching source links `target_type` is a required field")
316
+ end
317
+
318
+ unless options['target_id'].present?
319
+ errors.add(:base, "when fetching source links `target_id` is a required field")
320
+ end
321
+
322
+ if options.has_key?('source_type')
323
+ unless options.has_key?('source_system')
324
+ errors.add(:base, "when looking up linked source records, if `source_type` is provided, `source_system` is required")
325
+ end
326
+ end
327
+ end
328
+
329
+ def validate_all_lookup_params
330
+ unless options['record_system'].present?
331
+ errors.add(:base, "when fetching all record links `record_system` is a required field")
332
+ end
333
+
334
+ unless options['record_type'].present?
335
+ errors.add(:base, "when fetching all record links `record_type` is a required field")
336
+ end
337
+
338
+ unless options['record_id'].present?
339
+ errors.add(:base, "when fetching all record links `record_id` is a required field")
340
+ end
341
+
342
+ if options.has_key?('filter_type')
343
+ unless options.has_key?('filter_system')
344
+ errors.add(:base, "If `filter_type` is provided, `filter_system` is required")
345
+ end
346
+ end
347
+ end
348
+
349
+ def receive(incoming_events)
350
+ incoming_events.each do |event|
351
+
352
+ data = interpolated(event)
353
+
354
+ if boolify(data['create_link'])
355
+ create_record_link(event, data)
356
+
357
+ elsif (data['lookup_type'] == 'source')
358
+ find_source_links(event, data)
359
+
360
+ elsif(data['lookup_type'] == 'target')
361
+ find_target_links(event, data)
362
+
363
+ else
364
+ find_all_links(event, data)
365
+ end
366
+
367
+ end
368
+ end
369
+
370
+ def create_record_link(event, data)
371
+ source_system = data['source_system']
372
+ source_type = data['source_type']
373
+ source_id = data['source_id']
374
+
375
+ target_system = data['target_system']
376
+ target_type = data['target_type']
377
+ target_id = data['target_id']
378
+
379
+ log("Creating RecordLink from #{source_system} #{source_type} #{source_id} to #{target_system} #{target_type} #{target_id}")
380
+ results = HuginnRecordLinkAgent::RecordLinkBuilder.create_links(user, source_system, source_type, source_id, target_system, target_type, target_id)
381
+
382
+ if results[:link_status] == 200
383
+ if boolify(options['emit_each']) || results[:links].length == 1
384
+ results[:links].each do |record_link|
385
+ payload = { link_status: results[:link_status] }.merge(record_link)
386
+ emit(data, event, payload) if boolify(options['emit_events'])
387
+ end
388
+ else
389
+ # :results will be a hash object with :link_status and :links keys
390
+ payload = results
391
+ emit(data, event, payload) if boolify(options['emit_events'])
392
+ end
393
+
394
+ else
395
+ # :results will be a hash object with :link_status and :error_detail keys
396
+ payload = results
397
+ emit(data, event, payload) if boolify(options['emit_events'])
398
+ end
399
+
400
+ end
401
+
402
+ def find_source_links(event, data)
403
+ # Required Params
404
+ target_system = data['target_system']
405
+ target_type = data['target_type']
406
+ target_id = data['target_id']
407
+
408
+ # Optional Filters
409
+ source_system = data['source_system']
410
+ source_type = data['source_type']
411
+
412
+ # Ensure we have an array for iteration purposes
413
+ lookup_ids = target_id.respond_to?('each') ? target_id : [target_id]
414
+ results = HuginnRecordLinkAgent::RecordLinkLookupTool.lookup_records(user, target_system, target_type, lookup_ids, source_system, source_type, 'source')
415
+
416
+ payloads = HuginnRecordLinkAgent::RecordLinkPayloadBuilder.build_payloads(results, boolify(options['emit_each']), boolify(options['require_all']))
417
+ payloads.each do |payload|
418
+ emit(data, event, payload) if boolify(options['emit_events'])
419
+ end
420
+ end
421
+
422
+ def find_target_links(event, data)
423
+ # Required Params
424
+ source_system = data['source_system']
425
+ source_type = data['source_type']
426
+ source_id = data['source_id']
427
+ # Optional Filters
428
+ target_system = data['target_system']
429
+ target_type = data['target_type']
430
+
431
+ # Ensure we have an array for iteration purposes
432
+ lookup_ids = source_id.respond_to?('each') ? source_id : [source_id]
433
+ results = HuginnRecordLinkAgent::RecordLinkLookupTool.lookup_records(user, source_system, source_type, lookup_ids, target_system, target_type, 'target')
434
+ log(results.inspect)
435
+
436
+ payloads = HuginnRecordLinkAgent::RecordLinkPayloadBuilder.build_payloads(results, boolify(options['emit_each']), boolify(options['require_all']))
437
+ log(payloads.inspect)
438
+ payloads.each do |payload|
439
+ emit(data, event, payload) if boolify(options['emit_events'])
440
+ end
441
+ end
442
+
443
+ def find_all_links(event, data)
444
+ # Required Params
445
+ record_system = data['record_system']
446
+ record_type = data['record_type']
447
+ record_id = data['record_id']
448
+
449
+ # Optional Filters
450
+ filter_system = data['filter_system']
451
+ filter_type = data['filter_type']
452
+
453
+ # Ensure we have an array for iteration purposes
454
+ lookup_ids = record_id.respond_to?('each') ? lookup_id : [lookup_id]
455
+ results = HuginnRecordLinkAgent::RecordLinkLookupTool.lookup_records(user, record_system, record_type, record_ids, filter_system, filter_type)
456
+
457
+ payloads = HuginnRecordLinkAgent::RecordLinkPayloadBuilder.build_payloads(results, boolify(options['emit_each']), boolify(options['require_all']))
458
+ payloads.each do |payload|
459
+ emit(data, event, payload) if boolify(options['emit_events'])
460
+ end
461
+ end
462
+
463
+
464
+ #--------------- UTILITY METHODS ---------------#
465
+ def emit(data, event, payload)
466
+
467
+ if (payload[:link_status] == 200)
468
+ payload = { record_link: payload }
469
+ else
470
+ payload = { link_error: payload }
471
+ end
472
+
473
+ base_event = data['output_mode'].to_s == 'merge' ? event.payload.dup : {}
474
+ payload = base_event.merge(payload)
475
+
476
+ create_event(payload: payload)
477
+ end
478
+
479
+ end
480
+ end
@@ -0,0 +1,13 @@
1
+ require 'rails_helper'
2
+ require 'huginn_agent/spec_helper'
3
+
4
+ describe Agents::RecordLinkAgent do
5
+ before(:each) do
6
+ @valid_options = Agents::RecordLinkAgent.new.default_options
7
+ @checker = Agents::RecordLinkAgent.new(:name => "RecordLinkAgent", :options => @valid_options)
8
+ @checker.user = users(:bob)
9
+ @checker.save!
10
+ end
11
+
12
+ pending "add specs here"
13
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: huginn_record_link_agent
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mike Rogers
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-01-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.7'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: huginn_agent
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description:
56
+ email:
57
+ - mrogers@weare5stones.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE.txt
63
+ - lib/huginn_record_link_agent.rb
64
+ - lib/huginn_record_link_agent/engine.rb
65
+ - lib/huginn_record_link_agent/record_link_agent.rb
66
+ - spec/record_link_agent_spec.rb
67
+ homepage: https://github.com/5stones/huginn_http_request_agent
68
+ licenses:
69
+ - MIT
70
+ metadata: {}
71
+ post_install_message:
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubyforge_project:
87
+ rubygems_version: 2.5.2.3
88
+ signing_key:
89
+ specification_version: 4
90
+ summary: Write a short summary, because Rubygems requires one.
91
+ test_files:
92
+ - spec/record_link_agent_spec.rb