bearcat 1.4.13 → 1.5.0.beta1
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 +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
|