bearcat 1.4.13 → 1.5.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/bearcat.gemspec +9 -3
- data/lib/bearcat/client/account_reports.rb +6 -14
- data/lib/bearcat/client/accounts.rb +20 -56
- data/lib/bearcat/client/analytics.rb +4 -7
- data/lib/bearcat/client/assignment_groups.rb +7 -19
- data/lib/bearcat/client/assignments.rb +15 -39
- data/lib/bearcat/client/blueprint_courses.rb +14 -20
- data/lib/bearcat/client/calendar_events.rb +9 -17
- data/lib/bearcat/client/canvas_files.rb +0 -2
- data/lib/bearcat/client/conferences.rb +3 -8
- data/lib/bearcat/client/conversations.rb +3 -8
- data/lib/bearcat/client/courses.rb +15 -45
- data/lib/bearcat/client/custom_gradebook_columns.rb +11 -25
- data/lib/bearcat/client/discussions.rb +11 -37
- data/lib/bearcat/client/enrollments.rb +9 -29
- data/lib/bearcat/client/external_tools.rb +9 -36
- data/lib/bearcat/client/file_helper.rb +2 -2
- data/lib/bearcat/client/files.rb +2 -4
- data/lib/bearcat/client/folders.rb +13 -59
- data/lib/bearcat/client/group_categories.rb +8 -17
- data/lib/bearcat/client/group_memberships.rb +6 -14
- data/lib/bearcat/client/groups.rb +9 -24
- data/lib/bearcat/client/module_items.rb +9 -17
- data/lib/bearcat/client/modules.rb +10 -20
- data/lib/bearcat/client/outcome_groups.rb +2 -4
- data/lib/bearcat/client/outcomes.rb +4 -7
- data/lib/bearcat/client/pages.rb +8 -23
- data/lib/bearcat/client/progresses.rb +2 -3
- data/lib/bearcat/client/quizzes.rb +15 -35
- data/lib/bearcat/client/reports.rb +8 -19
- data/lib/bearcat/client/roles.rb +1 -1
- data/lib/bearcat/client/rubric.rb +8 -20
- data/lib/bearcat/client/rubric_assessment.rb +5 -11
- data/lib/bearcat/client/rubric_association.rb +6 -12
- data/lib/bearcat/client/search.rb +2 -4
- data/lib/bearcat/client/sections.rb +11 -26
- data/lib/bearcat/client/submissions.rb +13 -36
- data/lib/bearcat/client/tabs.rb +4 -7
- data/lib/bearcat/client/users.rb +17 -44
- data/lib/bearcat/client.rb +72 -44
- data/lib/bearcat/client_module.rb +101 -0
- data/lib/bearcat/rate_limiting/redis_script.rb +164 -0
- data/lib/bearcat/rate_limiting.rb +70 -0
- data/lib/bearcat/spec_helpers.rb +62 -31
- data/lib/bearcat/version.rb +1 -1
- data/lib/bearcat.rb +8 -21
- data/lib/catalogcat/api_array.rb +18 -0
- data/lib/catalogcat/client/catalogs.rb +21 -0
- data/lib/catalogcat/client/certificates.rb +9 -0
- data/lib/catalogcat/client/courses.rb +25 -0
- data/lib/catalogcat/client/orders.rb +9 -0
- data/lib/catalogcat/client.rb +39 -0
- data/lib/catalogcat/version.rb +3 -0
- data/lib/catalogcat.rb +14 -0
- data/spec/bearcat/client/canvas_files_spec.rb +1 -2
- data/spec/bearcat/client/content_migrations_spec.rb +1 -1
- data/spec/bearcat/client/courses_spec.rb +2 -4
- data/spec/bearcat/{group_memberships_spec.rb → client/group_memberships_spec.rb} +0 -0
- data/spec/bearcat/client_spec.rb +1 -4
- data/spec/bearcat/rate_limiting_spec.rb +62 -0
- data/spec/bearcat/stub_bearcat_spec.rb +15 -0
- data/spec/helper.rb +1 -0
- metadata +195 -180
@@ -1,11 +1,9 @@
|
|
1
1
|
module Bearcat
|
2
2
|
class Client < Footrest::Client
|
3
3
|
module Search
|
4
|
+
extend ClientModule
|
4
5
|
|
5
|
-
|
6
|
-
get('/api/v1/conversations/find_recipients', params)
|
7
|
-
end
|
8
|
-
|
6
|
+
get :find_recipients, '/api/v1/conversations/find_recipients'
|
9
7
|
end
|
10
8
|
end
|
11
9
|
end
|
@@ -1,35 +1,20 @@
|
|
1
1
|
module Bearcat
|
2
2
|
class Client < Footrest::Client
|
3
3
|
module Sections
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
def create_section(course, params)
|
14
|
-
post("/api/v1/courses/#{course.to_s}/sections", params)
|
15
|
-
end
|
16
|
-
|
17
|
-
def update_section(section, params)
|
18
|
-
put("/api/v1/sections/#{section.to_s}", params)
|
19
|
-
end
|
20
|
-
|
21
|
-
def delete_section(section)
|
22
|
-
delete("/api/v1/sections/#{section.to_s}")
|
4
|
+
extend ClientModule
|
5
|
+
|
6
|
+
prefix "/api/v1/sections/:section/" do
|
7
|
+
get :section
|
8
|
+
put :update_section
|
9
|
+
delete :delete_section
|
10
|
+
post :crosslist_section, "crosslist/:new_course_id"
|
11
|
+
delete :decrosslist_section, "crosslist"
|
23
12
|
end
|
24
13
|
|
25
|
-
|
26
|
-
|
14
|
+
prefix "/api/v1/courses/:course/sections/" do
|
15
|
+
get :course_sections
|
16
|
+
post :create_section
|
27
17
|
end
|
28
|
-
|
29
|
-
def decrosslist_section(section_id)
|
30
|
-
delete("/api/v1/sections/#{section_id.to_s}/crosslist")
|
31
|
-
end
|
32
|
-
|
33
18
|
end
|
34
19
|
end
|
35
20
|
end
|
@@ -1,37 +1,23 @@
|
|
1
1
|
module Bearcat
|
2
2
|
class Client < Footrest::Client
|
3
3
|
module Submissions
|
4
|
+
extend ClientModule
|
4
5
|
|
5
|
-
|
6
|
-
get
|
6
|
+
prefix "/api/v1/courses/:course/assignments/:assignment/submissions/" do
|
7
|
+
get :get_course_submissions
|
8
|
+
get :user_course_assignment_submission, ":user"
|
7
9
|
end
|
8
10
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
def get_course_submissions(course, assignment, params={})
|
14
|
-
get("/api/v1/courses/#{course.to_s}/assignments/#{assignment}/submissions", params)
|
15
|
-
end
|
16
|
-
|
17
|
-
def user_course_assignment_submission(course, assignment, user, params={})
|
18
|
-
get("/api/v1/courses/#{course.to_s}/assignments/#{assignment.to_s}/submissions/#{user.to_s}", params)
|
19
|
-
end
|
20
|
-
|
21
|
-
def course_submission(course, assignment, params)
|
22
|
-
post("/api/v1/courses/#{course}/assignments/#{assignment}/submissions", params)
|
23
|
-
end
|
24
|
-
|
25
|
-
def section_submission(section, assignment, params)
|
26
|
-
post("/api/v1/sections/#{section}/assignments/#{assignment}/submissions", params)
|
27
|
-
end
|
28
|
-
|
29
|
-
def grade_course_submission(course, assignment, user, params)
|
30
|
-
put("/api/v1/courses/#{course}/assignments/#{assignment}/submissions/#{user}", params)
|
31
|
-
end
|
11
|
+
context_types %i[course section] do |ct|
|
12
|
+
prefix "/api/v1/#{ct}s/:#{ct}/" do
|
13
|
+
get :"#{ct}_submissions", "students/submissions"
|
32
14
|
|
33
|
-
|
34
|
-
|
15
|
+
prefix "assignments/:assignment/submissions/" do
|
16
|
+
post :"#{ct}_submission"
|
17
|
+
put :"grade_#{ct}_submission", ":user"
|
18
|
+
post :"#{ct}_update_grades", "update_grades"
|
19
|
+
end
|
20
|
+
end
|
35
21
|
end
|
36
22
|
|
37
23
|
def course_file_upload_submission(course, assignment, user, file_data, params={})
|
@@ -42,14 +28,6 @@ module Bearcat
|
|
42
28
|
file_upload_submission(section, assignment, user, file_data, params, type: :section)
|
43
29
|
end
|
44
30
|
|
45
|
-
def course_update_grades(course, assignment, params={})
|
46
|
-
post("/api/v1/courses/#{course}/assignments/#{assignment}/submissions/update_grades", params)
|
47
|
-
end
|
48
|
-
|
49
|
-
def section_update_grades(section, assignment, params={})
|
50
|
-
post("/api/v1/sections/#{section}/assignments/#{assignment}/submissions/update_grades", params)
|
51
|
-
end
|
52
|
-
|
53
31
|
protected
|
54
32
|
|
55
33
|
# @param file_data One of an array of file_path strings or an array of Hashes, each being upload file params plus the file's path
|
@@ -86,7 +64,6 @@ module Bearcat
|
|
86
64
|
params[:submission] = sub_params
|
87
65
|
send("#{type}_submission".to_sym, type_id, assignment, params)
|
88
66
|
end
|
89
|
-
|
90
67
|
end
|
91
68
|
end
|
92
69
|
end
|
data/lib/bearcat/client/tabs.rb
CHANGED
@@ -1,15 +1,12 @@
|
|
1
1
|
module Bearcat
|
2
2
|
class Client < Footrest::Client
|
3
3
|
module Tabs
|
4
|
+
extend ClientModule
|
4
5
|
|
5
|
-
|
6
|
-
get
|
6
|
+
prefix "/api/v1/courses/:course/tabs/" do
|
7
|
+
get :get_tabs
|
8
|
+
put :update_tab, ":tab"
|
7
9
|
end
|
8
|
-
|
9
|
-
def update_tab(id, course, params={})
|
10
|
-
put("/api/v1/courses/#{course}/tabs/#{id}", params)
|
11
|
-
end
|
12
|
-
|
13
10
|
end
|
14
11
|
end
|
15
12
|
end
|
data/lib/bearcat/client/users.rb
CHANGED
@@ -1,33 +1,26 @@
|
|
1
1
|
module Bearcat
|
2
2
|
class Client < Footrest::Client
|
3
3
|
module Users
|
4
|
+
extend ClientModule
|
4
5
|
|
5
|
-
|
6
|
-
get
|
6
|
+
prefix "/api/v1/accounts/:account/users/" do
|
7
|
+
get :list_users
|
8
|
+
post :add_user
|
7
9
|
end
|
8
10
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
get("/api/v1/users/#{user.to_s}/profile", params)
|
23
|
-
end
|
24
|
-
|
25
|
-
def user_logins(user, params={})
|
26
|
-
get("/api/v1/users/#{user.to_s}/logins", params)
|
27
|
-
end
|
28
|
-
|
29
|
-
def communication_channels(user, params={})
|
30
|
-
get("/api/v1/users/#{user.to_s}/communication_channels", params)
|
11
|
+
prefix "/api/v1/users/" do
|
12
|
+
prefix ":user/" do
|
13
|
+
get :user_detail
|
14
|
+
get :user_avatars, "avatars"
|
15
|
+
get :user_profile, "profile"
|
16
|
+
get :user_logins, "logins"
|
17
|
+
get :communication_channels, "communication_channels"
|
18
|
+
get :page_views, "page_views"
|
19
|
+
put :user_merge, "merge_into/:merge_into_user"
|
20
|
+
get :user_assignments, "courses/:course/assignments"
|
21
|
+
get :dashboard_positions, "dashboard_positions"
|
22
|
+
put :update_dashboard_positions, "dashboard_positions"
|
23
|
+
end
|
31
24
|
end
|
32
25
|
|
33
26
|
# scope: food
|
@@ -52,26 +45,6 @@ module Bearcat
|
|
52
45
|
delete("/api/v1/users/#{user}/custom_data/#{scope}", params)
|
53
46
|
end
|
54
47
|
|
55
|
-
def page_views(user, params = {})
|
56
|
-
get("/api/v1/users/#{user}/page_views", params)
|
57
|
-
end
|
58
|
-
|
59
|
-
def user_merge(user, merge_into_user)
|
60
|
-
put("/api/v1/users/#{user}/merge_into/#{merge_into_user}")
|
61
|
-
end
|
62
|
-
|
63
|
-
def user_assignments(user, course, params = {})
|
64
|
-
get("/api/v1/users/#{user}/courses/#{course}/assignments", params)
|
65
|
-
end
|
66
|
-
|
67
|
-
def dashboard_positions(user, params = {})
|
68
|
-
get("/api/v1/users/#{user}/dashboard_positions", params)
|
69
|
-
end
|
70
|
-
|
71
|
-
def update_dashboard_positions(user, params = {})
|
72
|
-
put("/api/v1/users/#{user}/dashboard_positions", params)
|
73
|
-
end
|
74
|
-
|
75
48
|
def favorite_courses(user, params = {})
|
76
49
|
params.merge!({as_user_id: user})
|
77
50
|
get("/api/v1/users/self/favorites/courses", params)
|
data/lib/bearcat/client.rb
CHANGED
@@ -1,76 +1,104 @@
|
|
1
1
|
require 'active_support/core_ext/hash'
|
2
2
|
require 'footrest/client'
|
3
|
-
|
3
|
+
require_relative 'rate_limiting'
|
4
|
+
require_relative 'client_module'
|
4
5
|
|
5
6
|
module Bearcat
|
6
7
|
class Client < Footrest::Client
|
7
8
|
require 'bearcat/api_array'
|
8
9
|
|
10
|
+
@added_modules = []
|
11
|
+
|
9
12
|
Dir[File.join(__dir__, 'client', '*.rb')].each do |mod|
|
10
13
|
mname = File.basename(mod, '.*').camelize
|
11
14
|
mname = 'GraphQL' if mname == 'GraphQl'
|
12
15
|
require mod
|
13
|
-
|
16
|
+
lmod = "Bearcat::Client::#{mname}".constantize
|
17
|
+
include lmod
|
18
|
+
@added_modules << lmod
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.registered_endpoints
|
22
|
+
@registered_endpoints ||= @added_modules.reduce({}) do |h, m|
|
23
|
+
h.merge!(m._registered_endpoints) rescue h
|
24
|
+
end
|
25
|
+
@registered_endpoints
|
14
26
|
end
|
15
27
|
|
16
|
-
# Override Footrest request for ApiArray support
|
17
28
|
def request(method, &block)
|
18
|
-
|
19
|
-
|
20
|
-
|
29
|
+
response = rate_limited_request do
|
30
|
+
connection.send(method, &block)
|
31
|
+
end
|
21
32
|
ApiArray.process_response(response, self)
|
22
33
|
end
|
23
34
|
|
24
|
-
|
25
|
-
return unless limit_remaining.present?
|
26
|
-
return unless limit_remaining < Bearcat.rate_limit_min
|
35
|
+
protected
|
27
36
|
|
28
|
-
|
29
|
-
|
30
|
-
tts = Bearcat.max_sleep_seconds if tts > Bearcat.max_sleep_seconds
|
37
|
+
def rate_limited_request
|
38
|
+
return yield unless rate_limiter
|
31
39
|
|
40
|
+
canvas_rate_limits= 0
|
41
|
+
response = nil
|
32
42
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
43
|
+
begin
|
44
|
+
rate_limiter.apply(
|
45
|
+
rate_limit_key,
|
46
|
+
max_sleep: Bearcat.max_sleep_seconds,
|
47
|
+
on_sleep: ->(tts) {
|
48
|
+
message = "Canvas API rate limit reached; sleeping for #{tts.to_i} second(s) to catch up."
|
49
|
+
Bearcat.logger.debug(message)
|
50
|
+
},
|
51
|
+
) do
|
52
|
+
response = yield
|
53
|
+
0
|
54
|
+
end
|
55
|
+
rescue Footrest::HttpError::Forbidden => err
|
56
|
+
# Somehow our rate-limiting didn't limit enough and Canvas stopped us.
|
57
|
+
response = err.response
|
58
|
+
if canvas_rate_limits < 2 && err.message.include?("(Rate Limit Exceeded)")
|
59
|
+
canvas_rate_limits += 1
|
60
|
+
rate_limiter.checkin_known(rate_limit_key, 0)
|
37
61
|
|
38
|
-
|
39
|
-
|
62
|
+
message = "Canvas API applied rate limit; upticking Bearcat rate-limit avoidance and retrying (Retry #{canvas_rate_limits})."
|
63
|
+
Bearcat.logger.debug(message)
|
40
64
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
65
|
+
retry
|
66
|
+
end
|
67
|
+
raise
|
68
|
+
ensure
|
69
|
+
headers = response.try(:response_headers) || response.try(:headers) || {}
|
70
|
+
# -50 to provide a little extra leeway and hopefully be more proactive, making sure we don't even get close to Canvas throwing a 403, even if an out-of-band process is involved
|
71
|
+
rate_limiter.checkin_known(rate_limit_key, headers['x-rate-limit-remaining'].to_f - 100) if response
|
72
|
+
end
|
45
73
|
|
46
|
-
|
47
|
-
config[:master_rate_limit].present? || Bearcat.master_rate_limit.present?
|
74
|
+
response
|
48
75
|
end
|
49
76
|
|
50
|
-
def
|
51
|
-
|
52
|
-
Bearcat.
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
end
|
58
|
-
Bearcat.logger.debug limit['current'].to_s
|
59
|
-
limit['current']
|
77
|
+
def rate_limiter
|
78
|
+
@rate_limiter ||= begin
|
79
|
+
rl = config[:rate_limiter] || Bearcat.rate_limiter
|
80
|
+
master_rate_limit = config[:master_rate_limit].present? ? config[:master_rate_limit] : Bearcat.master_rate_limit
|
81
|
+
|
82
|
+
if rl.nil? && master_rate_limit.nil? && defined?(Rails) && Rails.env.production?
|
83
|
+
master_rate_limit = true if defined?(::Sidekiq)
|
60
84
|
end
|
61
|
-
else
|
62
|
-
Bearcat.rate_limits[config[:token]]
|
63
|
-
end
|
64
|
-
end
|
65
85
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
86
|
+
if rl.nil? && master_rate_limit
|
87
|
+
rl = RateLimiting::RedisLimiter
|
88
|
+
end
|
89
|
+
|
90
|
+
if rl.is_a?(Class)
|
91
|
+
rl.new()
|
92
|
+
elsif rl.present?
|
93
|
+
rl
|
70
94
|
end
|
71
|
-
else
|
72
|
-
Bearcat.rate_limits[config[:token]] = value
|
73
95
|
end
|
74
96
|
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def rate_limit_key
|
101
|
+
Digest::SHA1.hexdigest(config[:token])
|
102
|
+
end
|
75
103
|
end
|
76
104
|
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
module Bearcat
|
2
|
+
class Client < Footrest::Client
|
3
|
+
module ClientModule
|
4
|
+
ARG_REGEX = /:(\w+)/
|
5
|
+
attr_reader :_registered_endpoints
|
6
|
+
|
7
|
+
def prefix(prefix)
|
8
|
+
past_prefix = @current_prefix
|
9
|
+
@current_prefix = (past_prefix || '') + prefix
|
10
|
+
yield
|
11
|
+
ensure
|
12
|
+
@current_prefix = past_prefix
|
13
|
+
end
|
14
|
+
|
15
|
+
def endpoint(method, identifier, url = "", defaults: {}, &blk)
|
16
|
+
if @current_prefix && !url.start_with?('/')
|
17
|
+
url = url[2..] if url.start_with?('./')
|
18
|
+
url = @current_prefix + url
|
19
|
+
end
|
20
|
+
|
21
|
+
args = url.to_enum(:scan, ARG_REGEX).map { Regexp.last_match }
|
22
|
+
arg_names = args.map{|m| m[1]}
|
23
|
+
|
24
|
+
@_registered_endpoints ||= {}
|
25
|
+
@_registered_endpoints[identifier] = { symbol: identifier, method: method, url: url }
|
26
|
+
|
27
|
+
# TODO: Consider generating the method using class_eval and a template - this will improve runtime performance
|
28
|
+
# signature_bits = []
|
29
|
+
# logical_bits = []
|
30
|
+
# args.each do |m|
|
31
|
+
# name = [1]
|
32
|
+
# end
|
33
|
+
|
34
|
+
# interpolated_url = url.gsub(ARG_REGEX) do |m|
|
35
|
+
# '#{' + m[1] + '}'
|
36
|
+
# end
|
37
|
+
|
38
|
+
# class_eval <<~RUBY
|
39
|
+
# def #{identifier}(*args, **kwargs)
|
40
|
+
# parameters = {
|
41
|
+
|
42
|
+
# }
|
43
|
+
|
44
|
+
# #{method}(#{interpolated_url})
|
45
|
+
# end
|
46
|
+
# RUBY
|
47
|
+
|
48
|
+
define_method(identifier) do |*args, **kwargs|
|
49
|
+
url_arguments = {}
|
50
|
+
parameters = { }.with_indifferent_access
|
51
|
+
parameters.merge!(defaults)
|
52
|
+
|
53
|
+
args.each_with_index do |v, i|
|
54
|
+
if arg_names[i]
|
55
|
+
url_arguments[arg_names[i]] = v
|
56
|
+
elsif i == arg_names.count && v.is_a?(Hash)
|
57
|
+
parameters.merge!(v)
|
58
|
+
else
|
59
|
+
raise ArgumentError, "Too many arguments passed to #{identifier}" unless arg_names[i]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
parameters.merge!(kwargs)
|
64
|
+
|
65
|
+
preq = BearcatRequest.new(url, url_arguments, parameters)
|
66
|
+
yield preq if block_given?
|
67
|
+
|
68
|
+
arg_names.each do |an|
|
69
|
+
preq.arguments[an] = preq.parameters.delete(an) unless preq.arguments.key?(an)
|
70
|
+
raise ArgumentError, "Missing argument #{an}" unless preq.arguments.key?(an)
|
71
|
+
end
|
72
|
+
|
73
|
+
send(method, preq.interpoated_url, preq.parameters)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def context_types(types, &blk)
|
78
|
+
types.each(&blk)
|
79
|
+
end
|
80
|
+
|
81
|
+
%i[get post delete put patch head].each do |mthd|
|
82
|
+
define_method(mthd) do |*args, **kwargs, &blk|
|
83
|
+
endpoint(mthd, *args, **kwargs, &blk)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
BearcatRequest = Struct.new(:url, :arguments, :parameters) do
|
88
|
+
def interpoated_url
|
89
|
+
url.gsub(ARG_REGEX) do |_|
|
90
|
+
m = Regexp.last_match
|
91
|
+
val = arguments[m[1]]
|
92
|
+
val = val.canvas_id if val.respond_to?(:canvas_id)
|
93
|
+
val = val.id if val.respond_to?(:id)
|
94
|
+
val = val['id'].presence || val[:id].presence || val if val.is_a?(Hash)
|
95
|
+
val
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,164 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'digest/sha1'
|
3
|
+
require 'erb'
|
4
|
+
|
5
|
+
# Modified from https://github.com/Shopify/wolverine/blob/master/lib/wolverine/script.rb
|
6
|
+
|
7
|
+
module Bearcat
|
8
|
+
module RateLimiting
|
9
|
+
# {RedisScript} represents a lua script in the filesystem. It loads the script
|
10
|
+
# from disk and handles talking to redis to execute it. Error handling
|
11
|
+
# is handled by {LuaError}.
|
12
|
+
class RedisScript
|
13
|
+
|
14
|
+
# Loads the script file from disk and calculates its +SHA1+ sum.
|
15
|
+
#
|
16
|
+
# @param file [Pathname] the full path to the indicated file
|
17
|
+
def initialize(file)
|
18
|
+
@file = Pathname.new(file)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Passes the script and supplied arguments to redis for evaulation.
|
22
|
+
# It first attempts to use a script redis has already cached by using
|
23
|
+
# the +EVALSHA+ command, but falls back to providing the full script
|
24
|
+
# text via +EVAL+ if redis has not seen this script before. Future
|
25
|
+
# invocations will then use +EVALSHA+ without erroring.
|
26
|
+
#
|
27
|
+
# @param redis [Redis] the redis connection to run against
|
28
|
+
# @param args [*Objects] the arguments to the script
|
29
|
+
# @return [Object] the value passed back by redis after script execution
|
30
|
+
# @raise [LuaError] if the script failed to compile of encountered a
|
31
|
+
# runtime error
|
32
|
+
def call(redis, *args)
|
33
|
+
t = Time.now
|
34
|
+
begin
|
35
|
+
redis.evalsha(digest, *args)
|
36
|
+
rescue => e
|
37
|
+
e.message =~ /NOSCRIPT/ ? redis.eval(content, *args) : raise
|
38
|
+
end
|
39
|
+
rescue => e
|
40
|
+
if LuaError.intercepts?(e)
|
41
|
+
raise LuaError.new(e, @file, content)
|
42
|
+
else
|
43
|
+
raise
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def content
|
48
|
+
@content ||= load_lua(@file)
|
49
|
+
end
|
50
|
+
|
51
|
+
def digest
|
52
|
+
@digest ||= Digest::SHA1.hexdigest content
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def script_path
|
58
|
+
@file.basename
|
59
|
+
# Rails.root + 'app/redis_lua'
|
60
|
+
end
|
61
|
+
|
62
|
+
def relative_path
|
63
|
+
@path ||= @file.relative_path_from(script_path)
|
64
|
+
end
|
65
|
+
|
66
|
+
def load_lua(file)
|
67
|
+
TemplateContext.new(script_path).template(script_path + file)
|
68
|
+
end
|
69
|
+
|
70
|
+
class TemplateContext
|
71
|
+
def initialize(script_path)
|
72
|
+
@script_path = script_path
|
73
|
+
end
|
74
|
+
|
75
|
+
def template(pathname)
|
76
|
+
@partial_templates ||= {}
|
77
|
+
ERB.new(File.read(pathname)).result binding
|
78
|
+
end
|
79
|
+
|
80
|
+
# helper method to include a lua partial within another lua script
|
81
|
+
#
|
82
|
+
# @param relative_path [String] the relative path to the script from
|
83
|
+
# `script_path`
|
84
|
+
def include_partial(relative_path)
|
85
|
+
unless @partial_templates.has_key? relative_path
|
86
|
+
@partial_templates[relative_path] = nil
|
87
|
+
template( Pathname.new("#{@script_path}/#{relative_path}") )
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Reformats errors raised by redis representing failures while executing
|
93
|
+
# a lua script. The default errors have confusing messages and backtraces,
|
94
|
+
# and a type of +RuntimeError+. This class improves the message and
|
95
|
+
# modifies the backtrace to include the lua script itself in a reasonable
|
96
|
+
# way.
|
97
|
+
class LuaError < StandardError
|
98
|
+
PATTERN = /ERR Error (compiling|running) script \(.*?\): .*?:(\d+): (.*)/
|
99
|
+
WOLVERINE_LIB_PATH = File.expand_path('../../', __FILE__)
|
100
|
+
CONTEXT_LINE_NUMBER = 2
|
101
|
+
|
102
|
+
attr_reader :error, :file, :content
|
103
|
+
|
104
|
+
# Is this error one that should be reformatted?
|
105
|
+
#
|
106
|
+
# @param error [StandardError] the original error raised by redis
|
107
|
+
# @return [Boolean] is this an error that should be reformatted?
|
108
|
+
def self.intercepts? error
|
109
|
+
error.message =~ PATTERN
|
110
|
+
end
|
111
|
+
|
112
|
+
# Initialize a new {LuaError} from an existing redis error, adjusting
|
113
|
+
# the message and backtrace in the process.
|
114
|
+
#
|
115
|
+
# @param error [StandardError] the original error raised by redis
|
116
|
+
# @param file [Pathname] full path to the lua file the error ocurred in
|
117
|
+
# @param content [String] lua file content the error ocurred in
|
118
|
+
def initialize error, file, content
|
119
|
+
@error = error
|
120
|
+
@file = file
|
121
|
+
@content = content
|
122
|
+
|
123
|
+
@error.message =~ PATTERN
|
124
|
+
_stage, line_number, message = $1, $2, $3
|
125
|
+
error_context = generate_error_context(content, line_number.to_i)
|
126
|
+
|
127
|
+
super "#{message}\n\n#{error_context}\n\n"
|
128
|
+
set_backtrace generate_backtrace file, line_number
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
|
133
|
+
def generate_error_context(content, line_number)
|
134
|
+
lines = content.lines.to_a
|
135
|
+
beginning_line_number = [1, line_number - CONTEXT_LINE_NUMBER].max
|
136
|
+
ending_line_number = [lines.count, line_number + CONTEXT_LINE_NUMBER].min
|
137
|
+
line_number_width = ending_line_number.to_s.length
|
138
|
+
|
139
|
+
(beginning_line_number..ending_line_number).map do |number|
|
140
|
+
indicator = number == line_number ? '=>' : ' '
|
141
|
+
formatted_number = "%#{line_number_width}d" % number
|
142
|
+
" #{indicator} #{formatted_number}: #{lines[number - 1]}"
|
143
|
+
end.join.chomp
|
144
|
+
end
|
145
|
+
|
146
|
+
def generate_backtrace(file, line_number)
|
147
|
+
pre_wolverine = backtrace_before_entering_wolverine(@error.backtrace)
|
148
|
+
index_of_first_wolverine_line = (@error.backtrace.size - pre_wolverine.size - 1)
|
149
|
+
pre_wolverine.unshift(@error.backtrace[index_of_first_wolverine_line])
|
150
|
+
pre_wolverine.unshift("#{file}:#{line_number}")
|
151
|
+
pre_wolverine
|
152
|
+
end
|
153
|
+
|
154
|
+
def backtrace_before_entering_wolverine(backtrace)
|
155
|
+
backtrace.reverse.take_while { |line| ! line_from_wolverine(line) }.reverse
|
156
|
+
end
|
157
|
+
|
158
|
+
def line_from_wolverine(line)
|
159
|
+
line.split(':').first.include?(WOLVERINE_LIB_PATH)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|