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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/bearcat.gemspec +9 -3
  3. data/lib/bearcat/client/account_reports.rb +6 -14
  4. data/lib/bearcat/client/accounts.rb +20 -56
  5. data/lib/bearcat/client/analytics.rb +4 -7
  6. data/lib/bearcat/client/assignment_groups.rb +7 -19
  7. data/lib/bearcat/client/assignments.rb +15 -39
  8. data/lib/bearcat/client/blueprint_courses.rb +14 -20
  9. data/lib/bearcat/client/calendar_events.rb +9 -17
  10. data/lib/bearcat/client/canvas_files.rb +0 -2
  11. data/lib/bearcat/client/conferences.rb +3 -8
  12. data/lib/bearcat/client/conversations.rb +3 -8
  13. data/lib/bearcat/client/courses.rb +15 -45
  14. data/lib/bearcat/client/custom_gradebook_columns.rb +11 -25
  15. data/lib/bearcat/client/discussions.rb +11 -37
  16. data/lib/bearcat/client/enrollments.rb +9 -29
  17. data/lib/bearcat/client/external_tools.rb +9 -36
  18. data/lib/bearcat/client/file_helper.rb +2 -2
  19. data/lib/bearcat/client/files.rb +2 -4
  20. data/lib/bearcat/client/folders.rb +13 -59
  21. data/lib/bearcat/client/group_categories.rb +8 -17
  22. data/lib/bearcat/client/group_memberships.rb +6 -14
  23. data/lib/bearcat/client/groups.rb +9 -24
  24. data/lib/bearcat/client/module_items.rb +9 -17
  25. data/lib/bearcat/client/modules.rb +10 -20
  26. data/lib/bearcat/client/outcome_groups.rb +2 -4
  27. data/lib/bearcat/client/outcomes.rb +4 -7
  28. data/lib/bearcat/client/pages.rb +8 -23
  29. data/lib/bearcat/client/progresses.rb +2 -3
  30. data/lib/bearcat/client/quizzes.rb +15 -35
  31. data/lib/bearcat/client/reports.rb +8 -19
  32. data/lib/bearcat/client/roles.rb +1 -1
  33. data/lib/bearcat/client/rubric.rb +8 -20
  34. data/lib/bearcat/client/rubric_assessment.rb +5 -11
  35. data/lib/bearcat/client/rubric_association.rb +6 -12
  36. data/lib/bearcat/client/search.rb +2 -4
  37. data/lib/bearcat/client/sections.rb +11 -26
  38. data/lib/bearcat/client/submissions.rb +13 -36
  39. data/lib/bearcat/client/tabs.rb +4 -7
  40. data/lib/bearcat/client/users.rb +17 -44
  41. data/lib/bearcat/client.rb +72 -44
  42. data/lib/bearcat/client_module.rb +101 -0
  43. data/lib/bearcat/rate_limiting/redis_script.rb +164 -0
  44. data/lib/bearcat/rate_limiting.rb +70 -0
  45. data/lib/bearcat/spec_helpers.rb +62 -31
  46. data/lib/bearcat/version.rb +1 -1
  47. data/lib/bearcat.rb +8 -21
  48. data/lib/catalogcat/api_array.rb +18 -0
  49. data/lib/catalogcat/client/catalogs.rb +21 -0
  50. data/lib/catalogcat/client/certificates.rb +9 -0
  51. data/lib/catalogcat/client/courses.rb +25 -0
  52. data/lib/catalogcat/client/orders.rb +9 -0
  53. data/lib/catalogcat/client.rb +39 -0
  54. data/lib/catalogcat/version.rb +3 -0
  55. data/lib/catalogcat.rb +14 -0
  56. data/spec/bearcat/client/canvas_files_spec.rb +1 -2
  57. data/spec/bearcat/client/content_migrations_spec.rb +1 -1
  58. data/spec/bearcat/client/courses_spec.rb +2 -4
  59. data/spec/bearcat/{group_memberships_spec.rb → client/group_memberships_spec.rb} +0 -0
  60. data/spec/bearcat/client_spec.rb +1 -4
  61. data/spec/bearcat/rate_limiting_spec.rb +62 -0
  62. data/spec/bearcat/stub_bearcat_spec.rb +15 -0
  63. data/spec/helper.rb +1 -0
  64. 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
- def find_recipients(params={})
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
- def course_sections(course, params={})
6
- get("/api/v1/courses/#{course.to_s}/sections", params)
7
- end
8
-
9
- def section(section)
10
- get("/api/v1/sections/#{section.to_s}")
11
- end
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
- def crosslist_section(section_id, new_course_id)
26
- post("/api/v1/sections/#{section_id.to_s}/crosslist/#{new_course_id.to_s}")
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
- def course_submissions(course, params={})
6
- get("/api/v1/courses/#{course.to_s}/students/submissions", params)
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
- def section_submissions(section, params={})
10
- get("/api/v1/sections/#{section.to_s}/students/submissions", params)
11
- end
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
- def grade_section_submission(section, assignment, user, params)
34
- put("/api/v1/sections/#{section}/assignments/#{assignment}/submissions/#{user}", params)
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
@@ -1,15 +1,12 @@
1
1
  module Bearcat
2
2
  class Client < Footrest::Client
3
3
  module Tabs
4
+ extend ClientModule
4
5
 
5
- def get_tabs(course, params={})
6
- get("/api/v1/courses/#{course}/tabs", params)
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
@@ -1,33 +1,26 @@
1
1
  module Bearcat
2
2
  class Client < Footrest::Client
3
3
  module Users
4
+ extend ClientModule
4
5
 
5
- def list_users(account, params={})
6
- get("/api/v1/accounts/#{account.to_s}/users", params)
6
+ prefix "/api/v1/accounts/:account/users/" do
7
+ get :list_users
8
+ post :add_user
7
9
  end
8
10
 
9
- def user_avatars(user, params={})
10
- get("/api/v1/users/#{user.to_s}/avatars", params)
11
- end
12
-
13
- def add_user(account, params={})
14
- post("/api/v1/accounts/#{account.to_s}/users", params)
15
- end
16
-
17
- def user_detail(user, params={})
18
- get("/api/v1/users/#{user.to_s}", params)
19
- end
20
-
21
- def user_profile(user, params={})
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)
@@ -1,76 +1,104 @@
1
1
  require 'active_support/core_ext/hash'
2
2
  require 'footrest/client'
3
- require 'paul_walker'
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
- include "Bearcat::Client::#{mname}".constantize
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
- enforce_rate_limits
19
- response = connection.send(method, &block)
20
- apply_rate_limits(response.headers['x-rate-limit-remaining'])
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
- def enforce_rate_limits
25
- return unless limit_remaining.present?
26
- return unless limit_remaining < Bearcat.rate_limit_min
35
+ protected
27
36
 
28
- tts = ((Bearcat.rate_limit_min - limit_remaining) / 5).ceil
29
- tts = Bearcat.min_sleep_seconds if tts < Bearcat.min_sleep_seconds
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
- message = "Canvas API rate limit minimum #{Bearcat.rate_limit_min} reached. "\
34
- "Sleeping for #{tts} second(s) to catch up ~zzZZ~. "\
35
- "Limit Remaining: #{limit_remaining}"
36
- Bearcat.logger.debug(message)
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
- sleep(tts)
39
- end
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
- def apply_rate_limits(limit)
42
- return if limit.nil?
43
- self.limit_remaining = limit.to_i
44
- end
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
- def using_master_rate_limit?
47
- config[:master_rate_limit].present? || Bearcat.master_rate_limit.present?
74
+ response
48
75
  end
49
76
 
50
- def limit_remaining
51
- if using_master_rate_limit?
52
- Bearcat.master_mutex.synchronize do
53
- limit = PaulWalker::RateLimit.get(config[:token], config[:token])
54
- if limit.nil?
55
- PaulWalker::RateLimit.add(config[:token], config[:token], 0, Bearcat::rate_limit_min)
56
- limit = { current: 0 }.with_indifferent_access
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
- def limit_remaining=(value)
67
- if using_master_rate_limit?
68
- Bearcat.master_mutex.synchronize do
69
- PaulWalker::RateLimit.add(config[:token], config[:token], value, Bearcat::rate_limit_min)
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