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.
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