aspire 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/.gitignore +59 -0
- data/.rbenv-gemsets +1 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Dockerfile +20 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +851 -0
- data/Rakefile +10 -0
- data/aspire.gemspec +40 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/entrypoint.sh +11 -0
- data/exe/build-cache +13 -0
- data/lib/aspire.rb +11 -0
- data/lib/aspire/api.rb +2 -0
- data/lib/aspire/api/base.rb +198 -0
- data/lib/aspire/api/json.rb +195 -0
- data/lib/aspire/api/linked_data.rb +214 -0
- data/lib/aspire/caching.rb +4 -0
- data/lib/aspire/caching/builder.rb +356 -0
- data/lib/aspire/caching/cache.rb +365 -0
- data/lib/aspire/caching/cache_entry.rb +296 -0
- data/lib/aspire/caching/cache_logger.rb +63 -0
- data/lib/aspire/caching/util.rb +210 -0
- data/lib/aspire/cli/cache_builder.rb +123 -0
- data/lib/aspire/cli/command.rb +20 -0
- data/lib/aspire/enumerator/base.rb +29 -0
- data/lib/aspire/enumerator/json_enumerator.rb +130 -0
- data/lib/aspire/enumerator/linked_data_uri_enumerator.rb +32 -0
- data/lib/aspire/enumerator/report_enumerator.rb +64 -0
- data/lib/aspire/exceptions.rb +36 -0
- data/lib/aspire/object.rb +7 -0
- data/lib/aspire/object/base.rb +155 -0
- data/lib/aspire/object/digitisation.rb +43 -0
- data/lib/aspire/object/factory.rb +87 -0
- data/lib/aspire/object/list.rb +590 -0
- data/lib/aspire/object/module.rb +36 -0
- data/lib/aspire/object/resource.rb +371 -0
- data/lib/aspire/object/time_period.rb +47 -0
- data/lib/aspire/object/user.rb +46 -0
- data/lib/aspire/properties.rb +20 -0
- data/lib/aspire/user_lookup.rb +103 -0
- data/lib/aspire/util.rb +185 -0
- data/lib/aspire/version.rb +3 -0
- data/lib/retry.rb +197 -0
- metadata +274 -0
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'aspire/object/base'
|
2
|
+
|
3
|
+
module Aspire
|
4
|
+
module Object
|
5
|
+
# Represents a user profile in the Aspire API
|
6
|
+
class User < Base
|
7
|
+
# @!attribute [rw] email
|
8
|
+
# @return [Array<String>] the list of email addresses for the user
|
9
|
+
attr_accessor :email
|
10
|
+
|
11
|
+
# @!attribute [rw] first_name
|
12
|
+
# @return [String] the user's first name
|
13
|
+
attr_accessor :first_name
|
14
|
+
|
15
|
+
# @!attribute [rw] role
|
16
|
+
# @return [Array<String>] the Aspire roles associated with the user
|
17
|
+
attr_accessor :role
|
18
|
+
|
19
|
+
# @!attribute [rw] surname
|
20
|
+
# @return [String] the user's last name
|
21
|
+
attr_accessor :surname
|
22
|
+
|
23
|
+
# Initialises a new User instance
|
24
|
+
# @param uri [String] the URI of the user profile
|
25
|
+
# @param factory [Aspire::Object::Factory] the data object factory
|
26
|
+
# @param json [Hash] the user profile data from the Aspire JSON API
|
27
|
+
# @param ld [Hash] the user profile data from the Aspire linked data API
|
28
|
+
# @return [void]
|
29
|
+
def initialize(uri, factory, json: nil, ld: nil)
|
30
|
+
super(uri, factory)
|
31
|
+
json ||= {}
|
32
|
+
self.email = json['email']
|
33
|
+
self.first_name = json['firstName']
|
34
|
+
self.role = json['role']
|
35
|
+
self.surname = json['surname']
|
36
|
+
end
|
37
|
+
|
38
|
+
# Returns a string representation of the user profile (name and emails)
|
39
|
+
# @return [String] the string representation of the user profile
|
40
|
+
def to_s
|
41
|
+
emails = email.nil? || email.empty? ? '' : " <#{email.join('; ')}>"
|
42
|
+
"#{first_name} #{surname}#{emails}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Aspire
|
2
|
+
# Aspire linked data API property names
|
3
|
+
module Properties
|
4
|
+
AIISO_CODE = 'http://purl.org/vocab/aiiso/schema#code'.freeze
|
5
|
+
AIISO_NAME = 'http://putl.org/vocab/aiiso/schema#name'.freeze
|
6
|
+
CREATED = 'http://purl.org/vocab/resourcelist/schema#created'.freeze
|
7
|
+
DESCRIPTION = 'http://purl.org/vocab/resourcelist/schema#description'
|
8
|
+
.freeze
|
9
|
+
HAS_CREATOR = 'http://rdfs.org/sioc/spec/has_creator'.freeze
|
10
|
+
HAS_OWNER = 'http://purl.org/vocab/resourcelist/schema#hasOwner'.freeze
|
11
|
+
LAST_PUBLISHED = 'http://purl.org/vocab/resourcelist/schema#lastPublished'
|
12
|
+
.freeze
|
13
|
+
LAST_UPDATED = 'http://purl.org/vocab/resourcelist/schema#lastUpdated'
|
14
|
+
.freeze
|
15
|
+
NAME = 'http://rdfs.org/sioc/spec/name'.freeze
|
16
|
+
PUBLISHED_BY = 'http://purl.org/vocab/resourcelist/schema#publishedBy'
|
17
|
+
.freeze
|
18
|
+
USED_BY = 'http://purl.org/vocab/resourcelist/schema#usedBy'.freeze
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
require 'csv'
|
2
|
+
|
3
|
+
require 'aspire/enumerator/report_enumerator'
|
4
|
+
require 'aspire/object/user'
|
5
|
+
|
6
|
+
module Aspire
|
7
|
+
# Implements a hash of User instances indexed by URI
|
8
|
+
# The hash can be populated from an Aspire All User Profiles report CSV file
|
9
|
+
class UserLookup
|
10
|
+
# @!attribute [rw] store
|
11
|
+
# @return [Object] a hash-like object mapping user URIs to their JSON data
|
12
|
+
attr_accessor :store
|
13
|
+
|
14
|
+
# Initialises a new UserLookup instance
|
15
|
+
# @see (Hash#initialize)
|
16
|
+
# @param filename [String] the filename of the CSV file to populate the hash
|
17
|
+
# @return [void]
|
18
|
+
def initialize(filename: nil, store: nil)
|
19
|
+
self.store = store || {}
|
20
|
+
load(filename) if filename
|
21
|
+
end
|
22
|
+
|
23
|
+
# Returns an Aspire::Object::User instance for a URI
|
24
|
+
# @param uri [String] the URI of the user
|
25
|
+
# @param factory [Aspire::Object::Factory] the data object factory
|
26
|
+
# @return [Aspire::Object::User] the user
|
27
|
+
def [](uri, factory = nil)
|
28
|
+
data = store[uri]
|
29
|
+
data.nil? ? nil : Aspire::Object::User.new(uri, factory, json: data)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Populates the store from an All User Profiles report CSV file
|
33
|
+
# @param filename [String] the filename of the CSV file
|
34
|
+
# @return [void]
|
35
|
+
def load(filename = nil)
|
36
|
+
delim = /\s*;\s*/ # The delimiter for email and role lists
|
37
|
+
enum = Aspire::Enumerator::ReportEnumerator.new(filename).enumerator
|
38
|
+
enum.each do |row|
|
39
|
+
# Construct a JSON data structure for the user
|
40
|
+
uri = row[3]
|
41
|
+
data = csv_to_json_api(row, email_delim: delim, role_delim: delim)
|
42
|
+
csv_to_json_other(row, data)
|
43
|
+
# Store the JSON data in the lookup table
|
44
|
+
store[uri] = data
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Proxies missing methods to the store
|
49
|
+
# @param method [Symbol] the method name
|
50
|
+
# @param args [Array] the method arguments
|
51
|
+
# @param block [Proc] the code block
|
52
|
+
# @return [Object] the store method result
|
53
|
+
def method_missing(method, *args, &block)
|
54
|
+
super unless store.respond_to?(method)
|
55
|
+
store.public_send(method, *args, &block)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Proxies missing method respond_to? to the store
|
59
|
+
# @param method [Symbol] the method name
|
60
|
+
# @param include_private [Boolean] if true, include private methods,
|
61
|
+
# otherwise include only public methods
|
62
|
+
# @return [Boolean] true if the store supports the method, false otherwise
|
63
|
+
def respond_to_missing?(method, include_private = false)
|
64
|
+
store.respond_to?(method, include_private)
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def csv_to_json(row)
|
70
|
+
# Recreate the Aspire user profile JSON API response from the CSV record
|
71
|
+
data = csv_to_json_api(row)
|
72
|
+
# Add other report fields which aren't part of the JSON API response
|
73
|
+
csv_to_json_other(row, data)
|
74
|
+
end
|
75
|
+
|
76
|
+
# Adds CSV fields which mirror the Aspire user profile JSON API fields
|
77
|
+
# @param row [Array] the fields from the All User Profiles report CSV
|
78
|
+
# @param data [Hash] the JSON representation of the user profile
|
79
|
+
# @return [Hash] the JSON data hash
|
80
|
+
def csv_to_json_api(row, data = {}, email_delim: nil, role_delim: nil)
|
81
|
+
data['email'] = (row[4] || '').split(email_delim)
|
82
|
+
data['firstName'] = row[0]
|
83
|
+
data['role'] = (row[7] || '').split(role_delim)
|
84
|
+
data['surname'] = row[1]
|
85
|
+
data['uri'] = row[3]
|
86
|
+
data
|
87
|
+
end
|
88
|
+
|
89
|
+
# Adds CSV fields which aren't part of the Aspire user profile JSON API
|
90
|
+
# @param row [Array] the fields from the All User Profiles report CSV
|
91
|
+
# @param data [Hash] the JSON representation of the user profile
|
92
|
+
# @return [Hash] the JSON data hash
|
93
|
+
def csv_to_json_other(row, data = {})
|
94
|
+
# The following fields are not present in the JSON API response but are in
|
95
|
+
# the All User Profiles report - they are included for completeness.
|
96
|
+
data['jobRole'] = row[5] || ''
|
97
|
+
data['lastLogin'] = row[8]
|
98
|
+
data['name'] = row[2] || ''
|
99
|
+
data['visibility'] = row[6] || ''
|
100
|
+
data
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
data/lib/aspire/util.rb
ADDED
@@ -0,0 +1,185 @@
|
|
1
|
+
module Aspire
|
2
|
+
# Utility methods mixin
|
3
|
+
module Util
|
4
|
+
# Regular expression to parse a Linked Data API URI
|
5
|
+
LD_API_URI = Regexp.new('https?://(?<tenancy_host>[^/]*)/' \
|
6
|
+
'(?<type>[^/]*)/' \
|
7
|
+
'(?<id>[^/\.]*)' \
|
8
|
+
'(\.(?<format>[^/]*))?' \
|
9
|
+
'(/' \
|
10
|
+
'(?<child_type>[^/.]*)' \
|
11
|
+
'(/(?<child_id>[^/\.]*))?' \
|
12
|
+
'(\.(?<child_format>[^/]*))?' \
|
13
|
+
')?(?<rest>.*)').freeze
|
14
|
+
|
15
|
+
# Returns true if the first URL is the child of the second URL
|
16
|
+
# @param url1 [Aspire::Caching::CacheEntry, String] the first URL
|
17
|
+
# @param url2 [Aspire::Caching::CacheEntry, String] the second URL
|
18
|
+
# @param api [Aspire::API::LinkedData] the API for generating canonical URLs
|
19
|
+
# @param strict [Boolean] if true, the URL must be a parent of this entry,
|
20
|
+
# otherwise the URL must be a parent or the same as this entry
|
21
|
+
# @return [Boolean] true if the URL is a child of the cache entry, false
|
22
|
+
# otherwise
|
23
|
+
def child_url?(url1, url2, api = nil, strict: false)
|
24
|
+
parent_url?(url2, url1, api, strict: strict)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Returns a HH:MM:SS string given a Benchmark time
|
28
|
+
# @param benchmark_time [Benchmark:Tms] the Benchmark time object
|
29
|
+
# @return [String] the HH:HM:SS string
|
30
|
+
def duration(benchmark_time)
|
31
|
+
secs = benchmark_time.real
|
32
|
+
hours = secs / 3600
|
33
|
+
format('%2.2d:%2.2d:%2.2d', hours, hours % 60, secs % 60)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Returns the ID of an object from its URL
|
37
|
+
# @param u [String] the URL of the API object
|
38
|
+
# @return [String] the object ID
|
39
|
+
def id_from_uri(u, parsed: nil)
|
40
|
+
parsed ||= parse_url(u)
|
41
|
+
parsed[:id]
|
42
|
+
end
|
43
|
+
|
44
|
+
# Returns true if URI is a list item
|
45
|
+
# @param uri [String] the URI
|
46
|
+
# @return [Boolean] true if the URI is a list item, otherwise false
|
47
|
+
def item?(uri)
|
48
|
+
uri.include?('/items/')
|
49
|
+
end
|
50
|
+
|
51
|
+
# Returns the data for a URI from a parsed linked data API response
|
52
|
+
# which may contain multiple objects
|
53
|
+
# @param uri [String] the URI of the object
|
54
|
+
# @param ld [Hash] the parsed JSON data from the Aspire linked data API
|
55
|
+
# @return [Hash] the parsed JSON data for the URI
|
56
|
+
def linked_data(uri, ld)
|
57
|
+
uri = linked_data_path(uri)
|
58
|
+
return nil unless uri && ld
|
59
|
+
# The URI used to retrieve the data may be the canonical URI or a
|
60
|
+
# tenancy aliases. We ignore the host part of the URIs and match just
|
61
|
+
# the path
|
62
|
+
ld.each { |u, data| return data if uri == linked_data_path(u) }
|
63
|
+
# No match was found
|
64
|
+
nil
|
65
|
+
end
|
66
|
+
|
67
|
+
# Returns the path of a URI
|
68
|
+
# @param uri [String] the URI
|
69
|
+
# @return [String, nil] the URI path or nil if invalid
|
70
|
+
def linked_data_path(uri)
|
71
|
+
URI.parse(uri).path
|
72
|
+
rescue URI::InvalidComponentError, URI::InvalidURIError
|
73
|
+
nil
|
74
|
+
end
|
75
|
+
|
76
|
+
# Returns true if URI is a list
|
77
|
+
# @param uri [String] the URI
|
78
|
+
# @return [Boolean] true if the URI is a list, otherwise false
|
79
|
+
def list?(uri)
|
80
|
+
uri.include?('/lists/')
|
81
|
+
end
|
82
|
+
|
83
|
+
# Returns true if a URL is a list URL, false otherwise
|
84
|
+
# @param u [String] the URL of the API object
|
85
|
+
# @return [Boolean] true if the URL is a list URL, false otherwise
|
86
|
+
def list_url?(u = nil, parsed: nil)
|
87
|
+
return false if (u.nil? || u.empty?) && parsed.nil?
|
88
|
+
parsed ||= parse_url(u)
|
89
|
+
child_type = parsed[:child_type]
|
90
|
+
parsed[:type] == 'lists' && (child_type.nil? || child_type.empty?)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Returns true if URI is a module
|
94
|
+
# @param uri [String] the URI
|
95
|
+
# @return [Boolean] true if the URI is a module, otherwise false
|
96
|
+
def module?(uri)
|
97
|
+
uri.include?('/modules/')
|
98
|
+
end
|
99
|
+
|
100
|
+
# Returns true if the first URL is the parent of the second URL
|
101
|
+
# @param url1 [Aspire::Caching::CacheEntry, String] the first URL
|
102
|
+
# @param url2 [Aspire::Caching::CacheEntry, String] the second URL
|
103
|
+
# @param api [Aspire::API::LinkedData] the API for generating canonical URLs
|
104
|
+
# @param strict [Boolean] if true, the first URL must be a parent of the
|
105
|
+
# second URL, otherwise the first URL must be a parent or the same as the
|
106
|
+
# second.
|
107
|
+
# @return [Boolean] true if the URL has the same parent as this entry
|
108
|
+
def parent_url?(url1, url2, api = nil, strict: false)
|
109
|
+
u1 = url_for_comparison(url1, api, parsed: true)
|
110
|
+
u2 = url_for_comparison(url2, api, parsed: true)
|
111
|
+
# Both URLs must have the same parent
|
112
|
+
return false unless u1[:type] == u2[:type] && u1[:id] == u2[:id]
|
113
|
+
# Non-strict comparison requires only the same parent object
|
114
|
+
return true unless strict
|
115
|
+
# Strict comparison requires that this entry is a child of the URL
|
116
|
+
u1[:child_type].nil? && !u2[:child_type].nil? ? true : false
|
117
|
+
end
|
118
|
+
|
119
|
+
# Returns the components of an object URL
|
120
|
+
# @param url [String] the object URL
|
121
|
+
# @return [MatchData, nil] the URI components:
|
122
|
+
# {
|
123
|
+
# tenancy_host: tenancy root (server name),
|
124
|
+
# type: type of primary object,
|
125
|
+
# id: ID of primary object,
|
126
|
+
# child_type: type of child object,
|
127
|
+
# child_id: ID of child object
|
128
|
+
# }
|
129
|
+
def parse_url(url)
|
130
|
+
url ? LD_API_URI.match(url) : nil
|
131
|
+
end
|
132
|
+
|
133
|
+
# Returns true if URI is a resource
|
134
|
+
# @param uri [String] the URI
|
135
|
+
# @return [Boolean] true if the URI is a resource, otherwise false
|
136
|
+
def resource?(uri)
|
137
|
+
uri.include?('/resources/')
|
138
|
+
end
|
139
|
+
|
140
|
+
# Returns true if URI is a section
|
141
|
+
# @param uri [String] the URI
|
142
|
+
# @return [Boolean] true if the URI is a section, otherwise false
|
143
|
+
def section?(uri)
|
144
|
+
uri.include?('/sections/')
|
145
|
+
end
|
146
|
+
|
147
|
+
# Returns a parsed or unparsed URL for comparison
|
148
|
+
# @param url [Aspire::Caching::CacheEntry, String] the URL
|
149
|
+
# @param api [Aspire::API::LinkedData] the API for generating canonical URLs
|
150
|
+
# @param parsed [Boolean] if true, return a parsed URL, otherwise return
|
151
|
+
# an unparsed URL string
|
152
|
+
# @return [Aspire::Caching::CacheEntry, String] the URL for comparison
|
153
|
+
def url_for_comparison(url, api = nil, parsed: false)
|
154
|
+
if url.is_a?(MatchData) && parsed
|
155
|
+
url
|
156
|
+
elsif parsed && url.respond_to?(:parsed_url)
|
157
|
+
url.parsed_url
|
158
|
+
elsif !parsed && url.respond_to?(url)
|
159
|
+
url.url
|
160
|
+
else
|
161
|
+
result = api.nil? ? url.to_s : api.canonical_url(url.to_s)
|
162
|
+
parsed ? parse_url(result) : result
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# Returns the path from the URL as a relative filename
|
167
|
+
def url_path
|
168
|
+
# Get the path component of the URL as a relative path
|
169
|
+
filename = URI.parse(url).path
|
170
|
+
filename.slice!(0) # Remove the leading /
|
171
|
+
# Return the path with '.json' extension if not already present
|
172
|
+
filename.end_with?('.json') ? filename : "#{filename}.json"
|
173
|
+
rescue URI::InvalidComponentError, URI::InvalidURIError
|
174
|
+
# Return nil if the URL is invalid
|
175
|
+
nil
|
176
|
+
end
|
177
|
+
|
178
|
+
# Returns true if URI is a section
|
179
|
+
# @param uri [String] the URI
|
180
|
+
# @return [Boolean] true if the URI is a section, otherwise false
|
181
|
+
def user?(uri)
|
182
|
+
uri.include?('/users/')
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
data/lib/retry.rb
ADDED
@@ -0,0 +1,197 @@
|
|
1
|
+
# Support for performing retriable operations
|
2
|
+
module Retry
|
3
|
+
# Common exceptions suitable for retrying
|
4
|
+
module Exceptions
|
5
|
+
SOCKET_EXCEPTIONS = [
|
6
|
+
Errno::ECONNREFUSED,
|
7
|
+
Errno::ECONNRESET,
|
8
|
+
Errno::EINTR,
|
9
|
+
Errno::EHOSTUNREACH,
|
10
|
+
Errno::ENETDOWN,
|
11
|
+
Errno::ENETUNREACH,
|
12
|
+
Errno::ENOBUFS,
|
13
|
+
Errno::ENOSR,
|
14
|
+
Errno::ETIMEDOUT,
|
15
|
+
IO::WaitReadable
|
16
|
+
].freeze
|
17
|
+
end
|
18
|
+
|
19
|
+
# Retry handlers should raise this exception to stop retry processing and
|
20
|
+
# return the return value from the Retry.do method
|
21
|
+
class StopRetry < RuntimeError
|
22
|
+
attr_accessor :value
|
23
|
+
def initialize(value = nil)
|
24
|
+
self.value = value
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Support for repeatedly calling retriable operations
|
29
|
+
class Engine
|
30
|
+
attr_accessor :delay
|
31
|
+
attr_accessor :exceptions
|
32
|
+
attr_accessor :handlers
|
33
|
+
attr_accessor :tries
|
34
|
+
|
35
|
+
# Initialises a new Engine instance
|
36
|
+
# @param delay [Float] the default delay before retrying
|
37
|
+
# @param exceptions [Hash<Exception, Boolean>] the default retriable
|
38
|
+
# exceptions
|
39
|
+
# @param handlers [Hash<Exception|Symbol, Proc>] the default exception
|
40
|
+
# handlers
|
41
|
+
# @param tries [Integer, Proc] the default maximum number of tries or
|
42
|
+
# a proc which accepts an Exception and returns true if a retry is allowed
|
43
|
+
# or false if not
|
44
|
+
# @return [void]
|
45
|
+
def initialize(delay: nil, exceptions: nil, handlers: nil, tries: nil)
|
46
|
+
self.delay = delay.to_f
|
47
|
+
self.exceptions = exceptions || {}
|
48
|
+
self.handlers = handlers || {}
|
49
|
+
self.tries = tries
|
50
|
+
end
|
51
|
+
|
52
|
+
# Executes the class method do using instance default values
|
53
|
+
def do(delay: nil, exceptions: nil, handlers: nil, tries: nil, &block)
|
54
|
+
Retry.do(delay: delay || self.delay,
|
55
|
+
exceptions: exceptions || self.exceptions,
|
56
|
+
handlers: handlers || self.handlers,
|
57
|
+
tries: tries || self.tries,
|
58
|
+
&block)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Executes the code block until it returns successfully, throws a
|
63
|
+
# non-retriable exception or some termination condition is met.
|
64
|
+
# @param delay [Float] the number of seconds to wait before retrying.
|
65
|
+
# Positive values specify an exact delay, negative values specify a
|
66
|
+
# random delay no longer than this value.
|
67
|
+
# @param exceptions [Hash<Exception, Boolean>] the hash of retriable
|
68
|
+
# exceptions
|
69
|
+
# @param handlers [Hash<Exception|Symbol, Proc>] handlers to be invoked
|
70
|
+
# when specific exceptions occur. A handler should accept the exception
|
71
|
+
# and the number of tries remaining as arguments. It does not need to
|
72
|
+
# re-raise its exception argument, but it may throw another exception
|
73
|
+
# to prevent a retry.
|
74
|
+
# @param tries [Integer] the maximum number of tries
|
75
|
+
# @return [Object] the return value of the block
|
76
|
+
def self.do(delay: nil, exceptions: nil, handlers: nil, tries: nil)
|
77
|
+
yield if block_given?
|
78
|
+
rescue StandardError => exception
|
79
|
+
# Decrement the tries-remaining count if appropriate
|
80
|
+
tries -= 1 if tries.is_a?(Numeric)
|
81
|
+
# Handlers may raise StopRetry to force a return value from the method
|
82
|
+
# Check if the exception is retriable
|
83
|
+
retriable = retry?(exception, exceptions, tries)
|
84
|
+
begin
|
85
|
+
# Run the exception handler
|
86
|
+
# - this will re-raise the exception if it is not retriable
|
87
|
+
handle_exception(exception, handlers, tries, retriable)
|
88
|
+
# Run the retry handler and retry
|
89
|
+
handle_retry(exception, handlers, tries, retriable, delay)
|
90
|
+
retry
|
91
|
+
rescue StopRetry => exception
|
92
|
+
# Force a return value from the handler
|
93
|
+
exception.value
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Executes a handler for an exception
|
98
|
+
# @param exception [Exception] the exception
|
99
|
+
# @param handlers [Hash<Exception|Symbol, Proc>] the exception handlers
|
100
|
+
# @param tries [Integer] the number of tries remaining
|
101
|
+
# @param retriable [Boolean] true if the exception is retriable, false if not
|
102
|
+
# @return [Object] the return value of the handler, or nil if no handler
|
103
|
+
# was executed
|
104
|
+
def self.handle_exception(exception, handlers, tries, retriable)
|
105
|
+
# Execute the general exception handler
|
106
|
+
handler(exception, handlers, tries, retriable, :all)
|
107
|
+
# Execute the exception-specific handler
|
108
|
+
handler(exception, handlers, tries, retriable)
|
109
|
+
# Re-raise the exception if not retriable
|
110
|
+
raise exception unless retriable
|
111
|
+
end
|
112
|
+
|
113
|
+
# Executes the retry handler
|
114
|
+
# @param exception [Exception] the exception
|
115
|
+
# @param handlers [Hash<Exception|Symbol, Proc>] the exception handlers
|
116
|
+
# @param tries [Integer] the number of tries remaining
|
117
|
+
# @param retriable [Boolean] true if the exception is retriable, false if not
|
118
|
+
# @param delay [Float] the number of seconds to wait before retrying
|
119
|
+
def self.handle_retry(exception, handlers, tries, retriable, delay)
|
120
|
+
# Wait for the specified delay
|
121
|
+
wait(delay) unless delay.zero?
|
122
|
+
# Return the result of the retry handler
|
123
|
+
handler(exception, handlers, tries, retriable, :retry)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Executes the specified handler
|
127
|
+
# @param exception [Exception] the exception
|
128
|
+
# @param handlers [Hash<Exception|Symbol, Proc>] the exception handlers
|
129
|
+
# @param tries [Integer] the number of tries remaining
|
130
|
+
# @param retriable [Boolean] true if the exception is retriable, false if not
|
131
|
+
# @param name [Symbol] the handler name, defaults to the exception class
|
132
|
+
# @return [Object] the return value of the handler, or nil if no handler
|
133
|
+
# was executed
|
134
|
+
def self.handler(exception, handlers, tries, retriable, name = nil)
|
135
|
+
handler = nil
|
136
|
+
if name.nil?
|
137
|
+
# Find the handler for the exception class
|
138
|
+
handlers.each do |e, h|
|
139
|
+
next unless e.is_a?(Class) && exception.is_a?(e)
|
140
|
+
handler = h
|
141
|
+
break
|
142
|
+
end
|
143
|
+
# Use the default handler if no match was found
|
144
|
+
handler ||= handlers[:default]
|
145
|
+
else
|
146
|
+
# Use the named handler, do not use the default if not found
|
147
|
+
handler = handlers[name]
|
148
|
+
end
|
149
|
+
handler ? handler.call(exception, tries, retriable) : nil
|
150
|
+
end
|
151
|
+
|
152
|
+
# Returns true if the exception instance is retriable, false if not
|
153
|
+
# @param exception [Exception] the exception instance
|
154
|
+
# @param tries [Integer, Proc] the number of tries remaining or a proc
|
155
|
+
# determining whether tries remain
|
156
|
+
# @return [Boolean] true if the exception is retriable, false if not
|
157
|
+
def self.retry?(exception, exceptions, tries)
|
158
|
+
# Return false if there are no more tries remaining
|
159
|
+
return false unless tries_remain?(exception, tries)
|
160
|
+
# Return true if the exception matches a retriable exception class
|
161
|
+
exceptions.each { |e| return true if exception.is_a?(e) }
|
162
|
+
# The exception didn't match any retriable classes
|
163
|
+
false
|
164
|
+
end
|
165
|
+
|
166
|
+
# Returns true if there are tries remaining
|
167
|
+
# @param exception [Exception] the exception instance
|
168
|
+
# @param tries [Integer, Proc] the number of tries remaining or a proc
|
169
|
+
# determining whether tries remain
|
170
|
+
def self.tries_remain?(exception, tries)
|
171
|
+
# If tries is numeric, this is the number of tries remaining
|
172
|
+
return false if tries.is_a?(Numeric) && tries.zero?
|
173
|
+
# If tries has a #call method, this should return true to allow a retry or
|
174
|
+
# false to raise the exception
|
175
|
+
return false if tries.respond_to?(:call) && !tries.call(exception)
|
176
|
+
# Otherwise allow a retry
|
177
|
+
true
|
178
|
+
end
|
179
|
+
|
180
|
+
# Waits for the specified number of seconds. If delay is positive, sleep
|
181
|
+
# for that period. If delay is negative, sleep for a random time up to
|
182
|
+
# that duration.
|
183
|
+
# @param delay [Float] the number of seconds to wait before retrying
|
184
|
+
# @return [void]
|
185
|
+
def self.wait(delay)
|
186
|
+
sleep(delay > 0 ? delay : Random.rand(-delay))
|
187
|
+
end
|
188
|
+
|
189
|
+
class << self
|
190
|
+
private :handle_exception
|
191
|
+
private :handle_retry
|
192
|
+
private :handler
|
193
|
+
private :retry?
|
194
|
+
private :tries_remain?
|
195
|
+
private :wait
|
196
|
+
end
|
197
|
+
end
|