aspire 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|