bearcat 1.0.0 → 1.5.24

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 (193) hide show
  1. checksums.yaml +7 -0
  2. data/bearcat.gemspec +15 -5
  3. data/lib/badgrcat/api_array.rb +25 -0
  4. data/lib/badgrcat/client/methods.rb +54 -0
  5. data/lib/badgrcat/client.rb +53 -0
  6. data/lib/badgrcat/version.rb +3 -0
  7. data/lib/bearcat/api_array.rb +132 -65
  8. data/lib/bearcat/client/account_reports.rb +6 -14
  9. data/lib/bearcat/client/accounts.rb +18 -6
  10. data/lib/bearcat/client/analytics.rb +12 -0
  11. data/lib/bearcat/client/assignment_groups.rb +15 -0
  12. data/lib/bearcat/client/assignments.rb +17 -9
  13. data/lib/bearcat/client/blueprint_courses.rb +25 -0
  14. data/lib/bearcat/client/calendar_events.rb +9 -17
  15. data/lib/bearcat/client/canvas_files.rb +0 -2
  16. data/lib/bearcat/client/conferences.rb +3 -8
  17. data/lib/bearcat/client/content_exports.rb +39 -0
  18. data/lib/bearcat/client/content_migrations.rb +54 -0
  19. data/lib/bearcat/client/conversations.rb +3 -8
  20. data/lib/bearcat/client/courses.rb +25 -14
  21. data/lib/bearcat/client/custom_gradebook_columns.rb +21 -0
  22. data/lib/bearcat/client/discussions.rb +10 -4
  23. data/lib/bearcat/client/enrollments.rb +9 -25
  24. data/lib/bearcat/client/external_tools.rb +18 -0
  25. data/lib/bearcat/client/file_helper.rb +36 -30
  26. data/lib/bearcat/client/files.rb +9 -0
  27. data/lib/bearcat/client/folders.rb +24 -0
  28. data/lib/bearcat/client/graph_ql.rb +17 -0
  29. data/lib/bearcat/client/group_categories.rb +18 -0
  30. data/lib/bearcat/client/group_memberships.rb +14 -0
  31. data/lib/bearcat/client/groups.rb +10 -2
  32. data/lib/bearcat/client/learning_outcomes.rb +17 -0
  33. data/lib/bearcat/client/logins.rb +20 -0
  34. data/lib/bearcat/client/module_items.rb +18 -0
  35. data/lib/bearcat/client/modules.rb +12 -7
  36. data/lib/bearcat/client/o_auth2.rb +18 -9
  37. data/lib/bearcat/client/outcome_groups.rb +2 -4
  38. data/lib/bearcat/client/outcome_imports.rb +48 -0
  39. data/lib/bearcat/client/outcomes.rb +4 -7
  40. data/lib/bearcat/client/pages.rb +15 -0
  41. data/lib/bearcat/client/progresses.rb +9 -0
  42. data/lib/bearcat/client/quizzes.rb +13 -9
  43. data/lib/bearcat/client/reports.rb +37 -17
  44. data/lib/bearcat/client/roles.rb +15 -0
  45. data/lib/bearcat/client/rubric.rb +17 -0
  46. data/lib/bearcat/client/rubric_assessment.rb +13 -0
  47. data/lib/bearcat/client/rubric_association.rb +13 -0
  48. data/lib/bearcat/client/search.rb +9 -0
  49. data/lib/bearcat/client/sections.rb +10 -17
  50. data/lib/bearcat/client/sis_imports.rb +6 -12
  51. data/lib/bearcat/client/submissions.rb +53 -21
  52. data/lib/bearcat/client/tabs.rb +12 -0
  53. data/lib/bearcat/client/users.rb +32 -13
  54. data/lib/bearcat/client.rb +111 -45
  55. data/lib/bearcat/client_module.rb +103 -0
  56. data/lib/bearcat/rate_limiting/increment_bucket.lua +33 -0
  57. data/lib/bearcat/rate_limiting/redis_script.rb +164 -0
  58. data/lib/bearcat/rate_limiting.rb +69 -0
  59. data/lib/bearcat/redis_connection.rb +106 -0
  60. data/lib/bearcat/spec_helpers.rb +125 -0
  61. data/lib/bearcat/version.rb +1 -1
  62. data/lib/bearcat.rb +49 -0
  63. data/lib/catalogcat/api_array.rb +22 -0
  64. data/lib/catalogcat/client/catalogs.rb +21 -0
  65. data/lib/catalogcat/client/certificates.rb +17 -0
  66. data/lib/catalogcat/client/courses.rb +25 -0
  67. data/lib/catalogcat/client/email_domain_sets.rb +17 -0
  68. data/lib/catalogcat/client/enrollments.rb +25 -0
  69. data/lib/catalogcat/client/orders.rb +13 -0
  70. data/lib/catalogcat/client/user_registrations.rb +9 -0
  71. data/lib/catalogcat/client.rb +26 -0
  72. data/lib/catalogcat/version.rb +3 -0
  73. data/lib/catalogcat.rb +14 -0
  74. data/spec/bearcat/api_array_spec.rb +112 -0
  75. data/spec/bearcat/client/accounts_spec.rb +71 -1
  76. data/spec/bearcat/client/analytics_spec.rb +22 -0
  77. data/spec/bearcat/client/assignment_groups_spec.rb +47 -0
  78. data/spec/bearcat/client/assignments_spec.rb +43 -0
  79. data/spec/bearcat/client/blueprint_courses_spec.rb +43 -0
  80. data/spec/bearcat/client/canvas_files_spec.rb +1 -2
  81. data/spec/bearcat/client/content_exports_spec.rb +68 -0
  82. data/spec/bearcat/client/content_migrations_spec.rb +94 -0
  83. data/spec/bearcat/client/courses_spec.rb +81 -2
  84. data/spec/bearcat/client/custom_gradebook_columns_spec.rb +66 -0
  85. data/spec/bearcat/client/discussions_spec.rb +73 -0
  86. data/spec/bearcat/client/enrollments_spec.rb +10 -0
  87. data/spec/bearcat/client/external_tools_spec.rb +106 -0
  88. data/spec/bearcat/client/files_spec.rb +15 -0
  89. data/spec/bearcat/client/folders_spec.rb +18 -0
  90. data/spec/bearcat/client/graph_ql_spec.rb +35 -0
  91. data/spec/bearcat/client/group_categories_spec.rb +45 -0
  92. data/spec/bearcat/client/group_membership_spec.rb +14 -0
  93. data/spec/bearcat/client/group_memberships_spec.rb +36 -0
  94. data/spec/bearcat/client/groups_spec.rb +46 -0
  95. data/spec/bearcat/client/learning_outcomes_spec.rb +25 -0
  96. data/spec/bearcat/client/module_items_spec.rb +60 -0
  97. data/spec/bearcat/client/modules_spec.rb +38 -1
  98. data/spec/bearcat/client/o_auth2_spec.rb +3 -3
  99. data/spec/bearcat/client/pages_spec.rb +17 -0
  100. data/spec/bearcat/client/quizzes_spec.rb +41 -4
  101. data/spec/bearcat/client/reports_spec.rb +40 -1
  102. data/spec/bearcat/client/roles_spec.rb +24 -0
  103. data/spec/bearcat/client/rubric_assessment_spec.rb +47 -0
  104. data/spec/bearcat/client/rubric_association_spec.rb +39 -0
  105. data/spec/bearcat/client/rubric_spec.rb +45 -0
  106. data/spec/bearcat/client/search_spec.rb +16 -0
  107. data/spec/bearcat/client/sections_spec.rb +12 -0
  108. data/spec/bearcat/client/submissions_spec.rb +47 -2
  109. data/spec/bearcat/client/users_spec.rb +43 -0
  110. data/spec/bearcat/client_spec.rb +1 -4
  111. data/spec/bearcat/rate_limiting_spec.rb +62 -0
  112. data/spec/bearcat/stub_bearcat_spec.rb +15 -0
  113. data/spec/fixtures/access_token.json +3 -0
  114. data/spec/fixtures/account_admin_create.json +14 -0
  115. data/spec/fixtures/account_admin_delete.json +15 -0
  116. data/spec/fixtures/account_admins.json +54 -0
  117. data/spec/fixtures/account_courses.json +48 -0
  118. data/spec/fixtures/account_grading_standards.json +20 -0
  119. data/spec/fixtures/account_groups.json +42 -0
  120. data/spec/fixtures/account_role.json +34 -0
  121. data/spec/fixtures/account_roles.json +35 -0
  122. data/spec/fixtures/account_sis_imports.json +39 -0
  123. data/spec/fixtures/account_sub_accounts.json +17 -0
  124. data/spec/fixtures/accounts.json +13 -0
  125. data/spec/fixtures/assignment.json +32 -0
  126. data/spec/fixtures/assignment_group.json +7 -0
  127. data/spec/fixtures/assignment_groups.json +16 -0
  128. data/spec/fixtures/blueprint_migration.json +12 -0
  129. data/spec/fixtures/blueprint_subscriptions.json +5 -0
  130. data/spec/fixtures/blueprint_template.json +7 -0
  131. data/spec/fixtures/blueprint_update_assocations_success.json +3 -0
  132. data/spec/fixtures/communication_channels.json +10 -0
  133. data/spec/fixtures/content_export.json +9 -0
  134. data/spec/fixtures/content_migration_files/content_migration.json +13 -0
  135. data/spec/fixtures/course_copy.json +18 -0
  136. data/spec/fixtures/course_files.json +38 -0
  137. data/spec/fixtures/course_folder.json +21 -0
  138. data/spec/fixtures/course_folders.json +44 -0
  139. data/spec/fixtures/course_grading_standards.json +20 -0
  140. data/spec/fixtures/course_settings.json +33 -0
  141. data/spec/fixtures/create_course_discussion.json +44 -0
  142. data/spec/fixtures/created_group.json +37 -0
  143. data/spec/fixtures/created_group_category.json +15 -0
  144. data/spec/fixtures/created_group_membership.json +8 -0
  145. data/spec/fixtures/created_module.json +13 -0
  146. data/spec/fixtures/custom_gradebook_columns/column_data.json +4 -0
  147. data/spec/fixtures/custom_gradebook_columns/custom_gradebook_column.json +7 -0
  148. data/spec/fixtures/custom_gradebook_columns/custom_gradebook_columns.json +16 -0
  149. data/spec/fixtures/custom_gradebook_columns/gradebook_column_progress.json +14 -0
  150. data/spec/fixtures/dashboard.json +6 -0
  151. data/spec/fixtures/delete_course.json +3 -0
  152. data/spec/fixtures/delete_group_category.json +3 -0
  153. data/spec/fixtures/deleted_group.json +37 -0
  154. data/spec/fixtures/department_level_participation.json +73 -0
  155. data/spec/fixtures/department_level_statistics.json +10 -0
  156. data/spec/fixtures/discussion_entries.json +21 -0
  157. data/spec/fixtures/discussion_entry_replies.json +21 -0
  158. data/spec/fixtures/discussion_topic.json +49 -0
  159. data/spec/fixtures/discussion_topics.json +51 -0
  160. data/spec/fixtures/edited_group.json +129 -0
  161. data/spec/fixtures/edited_group_category.json +15 -0
  162. data/spec/fixtures/enrollment_terms.json +1 -1
  163. data/spec/fixtures/external_tool.json +55 -0
  164. data/spec/fixtures/external_tools.json +57 -0
  165. data/spec/fixtures/file.csv +5 -0
  166. data/spec/fixtures/gradebook_history.json +52 -0
  167. data/spec/fixtures/graph_ql_scores.json +33 -0
  168. data/spec/fixtures/group.json +15 -0
  169. data/spec/fixtures/group_categories.json +28 -0
  170. data/spec/fixtures/group_category.json +13 -0
  171. data/spec/fixtures/group_category_groups.json +20 -0
  172. data/spec/fixtures/group_membership.json +7 -0
  173. data/spec/fixtures/learning_outcome.json +32 -0
  174. data/spec/fixtures/merge_user.json +8 -0
  175. data/spec/fixtures/module.json +15 -0
  176. data/spec/fixtures/module_item.json +15 -0
  177. data/spec/fixtures/module_items.json +47 -0
  178. data/spec/fixtures/ok.json +3 -0
  179. data/spec/fixtures/outcome_result.json +13 -0
  180. data/spec/fixtures/pages.json +40 -0
  181. data/spec/fixtures/progress.json +13 -0
  182. data/spec/fixtures/quizzes/course_quiz_questions.json +59 -0
  183. data/spec/fixtures/quizzes/quiz_assignment_override.json +31 -0
  184. data/spec/fixtures/reactivate_enrollment.json +20 -0
  185. data/spec/fixtures/rubric.json +13 -0
  186. data/spec/fixtures/rubric_assessment.json +32 -0
  187. data/spec/fixtures/rubric_association.json +13 -0
  188. data/spec/fixtures/search_find_recipients.json +10 -0
  189. data/spec/fixtures/update_section.json +1 -1
  190. data/spec/fixtures/user_details.json +16 -0
  191. data/spec/fixtures/user_logins.json +9 -0
  192. data/spec/helper.rb +2 -0
  193. metadata +336 -43
@@ -0,0 +1,103 @@
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..-1] 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 = val.to_s if val.present?
96
+ val = URI.encode_www_form_component(val) if val.is_a?(String)
97
+ val
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,33 @@
1
+ local cache_key = KEYS[1]
2
+
3
+ local amount = tonumber(ARGV[1])
4
+ local current_time = tonumber(ARGV[2])
5
+ local outflow = tonumber(ARGV[3])
6
+ local maximum = tonumber(ARGV[4])
7
+
8
+ local leak = function(count, last_touched, current_time, outflow)
9
+ if count > 0 then
10
+ local timespan = current_time - last_touched
11
+ local loss = outflow * timespan
12
+ if loss > 0 then
13
+ count = count - loss
14
+ end
15
+ end
16
+ return count, last_touched
17
+ end
18
+
19
+ local count, last_touched = unpack(redis.call('HMGET', cache_key, 'count', 'timestamp'))
20
+ count = tonumber(count or 0)
21
+ last_touched = tonumber(last_touched or current_time)
22
+
23
+ count, last_touched = leak(count, last_touched, current_time, outflow)
24
+ if count < 0 then count = 0 end
25
+
26
+ count = count + amount
27
+ if count < 0 then count = 0 end
28
+ if count > maximum then count = maximum end
29
+
30
+ redis.call('HMSET', cache_key, 'count', count, 'timestamp', current_time)
31
+ redis.call('EXPIRE', cache_key, 600)
32
+
33
+ return { tostring(count), tostring(current_time) }
@@ -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
@@ -0,0 +1,69 @@
1
+ require 'digest/sha1'
2
+ require_relative 'rate_limiting/redis_script'
3
+
4
+ module Bearcat
5
+ module RateLimiting
6
+ LEAK_RATE = 10
7
+ GUESS_COST = 60
8
+ MAXIMUM = 600
9
+
10
+ class RateLimiter
11
+ attr_reader :namespace
12
+
13
+ def initialize(namespace: "bearcat", **kwargs)
14
+ @namespace = namespace
15
+ @params = kwargs
16
+ end
17
+
18
+ def apply(key, guess: GUESS_COST, on_sleep: nil, max_sleep: 15)
19
+ current = increment("#{key}", 0, guess)
20
+ cost = guess
21
+ remaining = MAXIMUM - current[:count]
22
+
23
+ if remaining < 0
24
+ tts = -remaining / LEAK_RATE
25
+ tts = [tts, max_sleep].min if max_sleep
26
+ on_sleep.call(tts) if on_sleep
27
+ sleep tts
28
+ end
29
+
30
+ cost = yield
31
+ ensure
32
+ increment(key, (cost || 0), -guess)
33
+ end
34
+ end
35
+
36
+ class RedisLimiter < RateLimiter
37
+ INCR = RedisScript.new(Pathname.new(__FILE__) + "../rate_limiting/increment_bucket.lua")
38
+
39
+ def increment(key, amount, pending_amount = 0)
40
+ ts = Time.now.to_f
41
+
42
+ redis do |r|
43
+ actual, timestamp = INCR.call(r, ["#{key}|reported"], [amount, ts, LEAK_RATE, MAXIMUM + 300])
44
+ {
45
+ count: actual.to_f,
46
+ timestamp: timestamp.to_f,
47
+ }
48
+ end
49
+ end
50
+
51
+ def checkin_known(key, remaining_time)
52
+ redis do |r|
53
+ r.mapped_hmset("#{key}|reported", {
54
+ count: MAXIMUM + 200 - remaining_time,
55
+ timestamp: Time.now.to_f,
56
+ })
57
+ end
58
+ end
59
+
60
+ def redis(&blk)
61
+ if @params[:redis]
62
+ @params[:redis].call(blk)
63
+ else
64
+ ::Bearcat.redis(&blk)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "connection_pool"
4
+ require "redis"
5
+ require "uri"
6
+
7
+ # Adapted from Sidekiq 6.x Implementation
8
+
9
+ module Bearcat
10
+ module RedisConnection
11
+ KNOWN_REDIS_URL_ENVS = %w[REDIS_URL REDISTOGO_URL REDISCLOUD_URL].freeze
12
+
13
+ class << self
14
+ def configured?(prefix, explicit: false)
15
+ determine_redis_provider(prefix: prefix, explicit: true)
16
+ end
17
+
18
+ def create(options = {})
19
+ symbolized_options = options.transform_keys(&:to_sym)
20
+
21
+ if !symbolized_options[:url] && (u = determine_redis_provider(prefix: options[:env_prefix]))
22
+ symbolized_options[:url] = u
23
+ end
24
+
25
+ size = if symbolized_options[:size]
26
+ symbolized_options[:size]
27
+ elsif symbolized_options[:env_prefix] && (v = ENV["#{symbolized_options[:env_prefix]}_REDIS_POOL_SIZE"])
28
+ Integer(v)
29
+ elsif ENV["RAILS_MAX_THREADS"]
30
+ Integer(ENV["RAILS_MAX_THREADS"])
31
+ else
32
+ 5
33
+ end
34
+
35
+ pool_timeout = symbolized_options[:pool_timeout] || 1
36
+
37
+ ConnectionPool.new(timeout: pool_timeout, size: size) do
38
+ namespace = symbolized_options[:namespace]
39
+ client = Redis.new client_opts(symbolized_options)
40
+
41
+ if namespace
42
+ begin
43
+ require "redis/namespace"
44
+ Redis::Namespace.new(namespace, redis: client)
45
+ rescue LoadError
46
+ Rails.logger.error("Your Redis configuration uses the namespace '#{namespace}' but the redis-namespace gem is not included in the Gemfile." \
47
+ "Add the gem to your Gemfile to continue using a namespace. Otherwise, remove the namespace parameter.")
48
+ exit(-127)
49
+ end
50
+ else
51
+ client
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def client_opts(options)
59
+ opts = options.dup
60
+ if opts[:namespace]
61
+ opts.delete(:namespace)
62
+ end
63
+
64
+ opts.delete(:size)
65
+ opts.delete(:env_prefix)
66
+
67
+ if opts[:network_timeout]
68
+ opts[:timeout] = opts[:network_timeout]
69
+ opts.delete(:network_timeout)
70
+ end
71
+
72
+ opts[:reconnect_attempts] ||= 1
73
+
74
+ opts
75
+ end
76
+
77
+ def determine_redis_provider(prefix: nil, explicit: false)
78
+ vars = []
79
+
80
+ if prefix.present?
81
+ if (ptr = ENV["#{prefix}_REDIS_PROVIDER"]).present?
82
+ return ENV[ptr]
83
+ else
84
+ vars.push(*KNOWN_REDIS_URL_ENVS.map { |e| ENV["#{prefix}_#{e}"] })
85
+ end
86
+ end
87
+
88
+ if !explicit || !prefix.present?
89
+ if (ptr = ENV["REDIS_PROVIDER"]).present?
90
+ vars << ptr # Intentionally not a return
91
+ else
92
+ vars.push(*KNOWN_REDIS_URL_ENVS)
93
+ end
94
+ end
95
+
96
+ vars.select!(&:present?)
97
+
98
+ vars.each do |e|
99
+ return ENV[e] if ENV[e].present?
100
+ end
101
+
102
+ nil
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,125 @@
1
+ require 'bearcat'
2
+
3
+ # Bearcat RSpec Helpers
4
+ # Can be included in your spec_helper.rb file by using:
5
+ # - `require 'bearcat/spec_helpers'`
6
+ # And
7
+ # - `config.include Bearcat::SpecHelpers`
8
+ # This helper requires `gem 'method_source'` in your test group
9
+ module Bearcat::SpecHelpers
10
+ extend ActiveSupport::Concern
11
+
12
+ SOURCE_REGEX = /(?<method>get|post|delete|put)\((?<quote>\\?('|"))(?<url>.*)\k<quote>/
13
+
14
+ # Helper method to Stub Bearcat requests.
15
+ # Automagically parses the Bearcat method source to determine the correct URL to stub.
16
+ # Accepts optional keyword parameters to interpolate specific values into the URL.
17
+ # Returns a mostly-normal Webmock stub (:to_return has been overridden to allow :body to be set to a Hash)
18
+ def stub_bearcat(endpoint, prefix: nil, method: :auto, **kwargs)
19
+ cfg = _bearcat_resolve_config(endpoint, kwargs, method: method)
20
+
21
+ url = Regexp.escape(_bearcat_resolve_prefix(prefix)) + cfg[:url]
22
+ url += "?" if url.end_with?('/')
23
+ stub = stub_request(cfg[:method], Regexp.new(url))
24
+
25
+ # Override the to_return method to accept a Hash as body:
26
+ stub.define_singleton_method(:to_return, ->(*resps, &blk) {
27
+ if blk
28
+ super do |*args|
29
+ resp = blk.call(*args)
30
+ resp[:headers] ||= {}
31
+ resp[:headers]["Content-Type"] ||= "application/json"
32
+ resp[:body] = resp[:body].to_json
33
+ resp
34
+ end
35
+ else
36
+ resps.map do |resp|
37
+ resp[:headers] ||= {}
38
+ resp[:headers]["Content-Type"] ||= "application/json"
39
+ resp[:body] = resp[:body].to_json
40
+ end
41
+ super(*resps)
42
+ end
43
+ })
44
+
45
+ stub
46
+ end
47
+
48
+ private
49
+
50
+ def _bearcat_resolve_config(endpoint, url_context, method: :auto)
51
+ case endpoint
52
+ when Symbol
53
+ if (cfg = Bearcat::Client.registered_endpoints[endpoint]).present?
54
+ bits = []
55
+ url = cfg[:url]
56
+ lend = 0
57
+ url.scan(/:(?<key>\w+)/) do |key|
58
+ m = Regexp.last_match
59
+ between = url[lend..m.begin(0)-1]
60
+ bits << between if between.present? && (m.begin(0) > lend)
61
+ lend = m.end(0)
62
+ bits << (url_context[m[:key].to_sym] || /[\w:]+/)
63
+ end
64
+ between = url[lend..-1]
65
+ bits << between if between.present?
66
+
67
+ url = bits.map do |bit|
68
+ next bit.source if bit.is_a?(Regexp)
69
+ bit = bit.canvas_id if bit.respond_to?(:canvas_id)
70
+ Regexp.escape(bit.to_s)
71
+ end.join
72
+
73
+ { method: method == :auto ? cfg[:method] : method, url: url }
74
+ else
75
+ ruby_method = Bearcat::Client.instance_method(endpoint)
76
+ match = SOURCE_REGEX.match(ruby_method.source)
77
+ bits = []
78
+ url = match[:url].gsub(/\/$/, '')
79
+ lend = 0
80
+ url.scan(/#\{(?<key>.*?)\}/) do |key|
81
+ m = Regexp.last_match
82
+ between = url[lend..m.begin(0)-1]
83
+ bits << between if between.present? && (m.begin(0) > lend)
84
+ lend = m.end(0)
85
+ bits << (url_context[m[:key].to_sym] || /[\w:]+/)
86
+ end
87
+ between = url[lend..-1]
88
+ bits << between if between.present?
89
+
90
+ url = bits.map do |bit|
91
+ next bit.source if bit.is_a?(Regexp)
92
+ bit = bit.canvas_id if bit.respond_to?(:canvas_id)
93
+ Regexp.escape(bit.to_s)
94
+ end.join
95
+
96
+ { method: method == :auto ? (match ? match[:method].to_sym : :get) : method, url: url }
97
+ end
98
+ when String
99
+ raise "Cannot use method :auto when passing string endpoint" if method == :auto
100
+ { method: method, url: Regexp.escape(endpoint) }
101
+ when Regexp
102
+ raise "Cannot use method :auto when passing regex endpoint" if method == :auto
103
+ { method: method, url: Regexp.escape(endpoint) }
104
+ end
105
+ end
106
+
107
+ def _bearcat_resolve_prefix(prefix)
108
+ if prefix == true
109
+ prefix = canvas_api_client if defined? canvas_api_client
110
+ prefix = canvas_sync_client if defined? canvas_sync_client
111
+ end
112
+ prefix = case prefix
113
+ when nil
114
+ ''
115
+ when false
116
+ ''
117
+ when true
118
+ raise "stub_bearcat() prefix: set to true, but neither canvas_(sync|api)_client are defined"
119
+ when Bearcat::Client
120
+ prefix.prefix
121
+ when String
122
+ prefix
123
+ end
124
+ end
125
+ end
@@ -1,3 +1,3 @@
1
1
  module Bearcat
2
- VERSION = '1.0.0' unless defined?(Bearcat::VERSION)
2
+ VERSION = '1.5.24' unless defined?(Bearcat::VERSION)
3
3
  end
data/lib/bearcat.rb CHANGED
@@ -1,2 +1,51 @@
1
1
  require 'bearcat/version'
2
2
  require 'bearcat/client'
3
+ require 'bearcat/redis_connection'
4
+
5
+ module Bearcat
6
+ class << self
7
+ require 'logger'
8
+ attr_accessor :master_rate_limit
9
+ attr_writer :rate_limit_min, :rate_limits, :max_sleep_seconds,
10
+ :min_sleep_seconds, :logger, :rate_limiter
11
+
12
+ def configure
13
+ yield self if block_given?
14
+ end
15
+
16
+ def rate_limiter
17
+ @rate_limiter
18
+ end
19
+
20
+ def rate_limit_min
21
+ @rate_limit_min ||= 175
22
+ end
23
+
24
+ def min_sleep_seconds
25
+ @min_sleep_seconds ||= 5
26
+ end
27
+
28
+ def max_sleep_seconds
29
+ @max_sleep_seconds ||= 60
30
+ end
31
+
32
+ def logger
33
+ return @logger if defined? @logger
34
+ @logger = Logger.new(STDOUT)
35
+ @logger.level = Logger::DEBUG
36
+ @logger
37
+ end
38
+
39
+ def redis_pool
40
+ require 'bearcat/redis_connection'
41
+ @redis_pool ||= Bearcat::RedisConnection.create(env_prefix: "BEARCAT")
42
+ end
43
+
44
+ def redis
45
+ raise ArgumentError, "requires a block" unless block_given?
46
+ redis_pool.with do |conn|
47
+ yield conn
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,22 @@
1
+ require 'bearcat/api_array'
2
+
3
+ module Catalogcat
4
+ class ApiArray < Bearcat::ApiArray
5
+ def self.array_key(response)
6
+ key = nil
7
+ if response.env[:method] == :get
8
+ path = response.env[:url].path
9
+ key = 'courses' if path =~ %r{.*/courses}
10
+ key = 'course' if path =~ %r{.*/courses/[0-9]*}
11
+ key = 'catalogs' if path =~ %r{.*/catalogs}
12
+ key = 'enrollments' if path =~ %r{.*/enrollments}
13
+ key = 'order' if path =~ %r{.*/order/[0-9]*}
14
+ key = 'orders' if path =~ %r{.*/orders}
15
+ key = 'completed_certificates' if path =~ %r{.*/completed_certificates}
16
+ key = 'user_registrations' if path =~ %r{.*/user_registrations}
17
+ key = 'email_domain_sets' if path =~ %r{.*/email_domain_sets}
18
+ end
19
+ key.present? ? key : super(response)
20
+ end
21
+ end
22
+ end