vox 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vox
4
+ module HTTP
5
+ # Standard API errors for bad requests
6
+ class Error < Vox::Error
7
+ # @return [Hash<Symbol, Object>] The response object
8
+ attr_reader :data
9
+
10
+ # @return [String, nil] The trace identifier this error originated from.
11
+ attr_reader :trace
12
+
13
+ def initialize(data, req_id = nil)
14
+ @data = data
15
+ @trace = req_id
16
+ super(data[:message])
17
+ end
18
+
19
+ # Status Code 400
20
+ class BadRequest < self
21
+ end
22
+
23
+ # Status Code 401
24
+ class Unauthorized < self
25
+ end
26
+
27
+ # Status Code 403
28
+ class Forbidden < self
29
+ end
30
+
31
+ # Status Code 404
32
+ class NotFound < self
33
+ end
34
+
35
+ # Status Code 405
36
+ class MethodNotAllowed < self
37
+ end
38
+
39
+ # Status Code 429
40
+ class TooManyRequests < self
41
+ end
42
+
43
+ # Status Code 502
44
+ class GatewayUnavailable < self
45
+ end
46
+
47
+ # Status Code 5XX
48
+ class ServerError < StandardError
49
+ def initialize(req_id)
50
+ @trace = req_id
51
+ super('Internal Server Error')
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'logging'
5
+
6
+ require 'vox/http/middleware/rate_limiter'
7
+ require 'vox/http/middleware/log_formatter'
8
+
9
+ log = Logging.logger['Vox::HTTP']
10
+
11
+ log.debug { 'Registering rate_limiter middleware' }
12
+ Faraday::Middleware.register_middleware(
13
+ vox_ratelimiter: Vox::HTTP::Middleware::RateLimiter
14
+ )
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/logging/formatter'
5
+
6
+ module Vox
7
+ module HTTP
8
+ # Collection of middleware used to process requests internally.
9
+ module Middleware
10
+ # @!visibility private
11
+ class LogFormatter < Faraday::Logging::Formatter
12
+ # Request processing
13
+ def request(env)
14
+ req_id = env.request.context[:trace]
15
+
16
+ log_request_info(env, req_id)
17
+ # Debug just logs the response with the response body
18
+ log_request_debug(env, req_id) if env.request_headers['Content-Type'] == 'application/json'
19
+ debug { "{#{req_id}} [OUT] #{env.request_headers}" }
20
+ end
21
+
22
+ # Info level logging for requests. Logs the HTTP verb, the url path, query string arguments, and
23
+ # request body size.
24
+ def log_request_info(env, req_id)
25
+ query_string = "?#{env.url.query}" if env.url.query
26
+ size = env.body.respond_to?(:size) ? env.body.size : env.request_headers['Content-Length']
27
+ info { "{#{req_id}} [OUT] #{env.method} #{env.url.path}#{query_string} (#{size || 0})" }
28
+ end
29
+
30
+ # Debug level logging for requests. Displays the request body.
31
+ def log_request_debug(env, req_id)
32
+ debug { "{#{req_id}} [OUT] #{env.body}" }
33
+ end
34
+
35
+ # Response processing
36
+ def response(env)
37
+ resp = env.response
38
+ req_id = env.request.context[:trace]
39
+
40
+ log_response_error(env, resp, req_id) unless resp.success?
41
+ log_response_info(env, resp, req_id)
42
+ log_response_debug(env, resp, req_id) unless resp.body.empty?
43
+ end
44
+
45
+ # Error level logging for responses. Logs status code, url path, and response body.
46
+ def log_response_error(env, resp, req_id)
47
+ error { "{#{req_id}} [IN] #{resp.status} #{env.url.path} #{resp.body}" }
48
+ end
49
+
50
+ # Info level logging for responses. Logs status code, url path, and body size.
51
+ def log_response_info(env, resp, req_id)
52
+ info { "{#{req_id}} [IN] #{resp.status} #{env.url.path} (#{resp.body&.size || 0})" }
53
+ end
54
+
55
+ # Debug level logging for responses. Logs the response body.
56
+ def log_response_debug(_env, resp, req_id)
57
+ debug { "{#{req_id}} [IN] #{resp.body}" }
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vox
4
+ module HTTP
5
+ # @!visibility private
6
+ # A bucket used for HTTP rate limiting
7
+ class Bucket
8
+ # @!attribute [r] limit
9
+ # @return [Integer]
10
+ attr_reader :limit
11
+
12
+ # @!attribute [r] remaining
13
+ # @return [Integer]
14
+ attr_reader :remaining
15
+
16
+ # @!attribute [r] reset_time
17
+ # @return [Time]
18
+ attr_reader :reset_time
19
+
20
+ def initialize(limit, remaining, reset_time)
21
+ update(limit, remaining, reset_time)
22
+
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # @param limit [Integer]
27
+ # @param remaining [Integer]
28
+ # @param reset_time [Time]
29
+ def update(limit, remaining, reset_time)
30
+ @limit = limit
31
+ @remaining = remaining
32
+ @reset_time = reset_time
33
+ end
34
+
35
+ # @return [true, false]
36
+ def will_limit?
37
+ (@remaining - 1).negative? && Time.now <= @reset_time
38
+ end
39
+
40
+ # Lock and unlock this mutex (prevents access during reset)
41
+ def wait_until_available
42
+ return unless locked?
43
+
44
+ @mutex.synchronize {}
45
+ end
46
+
47
+ # Lock the mutex for a given duration. Used for cooldown periods
48
+ def lock_for(duration)
49
+ @mutex.synchronize { sleep duration }
50
+ end
51
+
52
+ # Lock the mutex until the bucket resets
53
+ def lock_until_reset
54
+ time_remaining = @reset_time - Time.now
55
+
56
+ raise 'Cannot sleep for negative duration.' if time_remaining.negative?
57
+
58
+ lock_for(time_remaining) unless locked?
59
+ end
60
+
61
+ # @return [true, false]
62
+ def locked?
63
+ @mutex.locked?
64
+ end
65
+ end
66
+
67
+ # @!visibility private
68
+ # A rate limiting class used for our {Client}
69
+ class LimitTable
70
+ def initialize
71
+ @bucket_key_map = {}
72
+ @bucket_id_map = {}
73
+ @key_to_id = {}
74
+ end
75
+
76
+ # Index a bucket based on the route key
77
+ def get_from_key(key)
78
+ @bucket_key_map[key]
79
+ end
80
+
81
+ # Index a bucket based on server side bucket id
82
+ def get_from_id(id)
83
+ @bucket_id_map[id]
84
+ end
85
+
86
+ # Get a bucket from a rl_key if it exists
87
+ def id_from_key(key)
88
+ @key_to_id[key]
89
+ end
90
+
91
+ # Update a rate limit bucket from response headers
92
+ def update_from_headers(key, headers, req_id = nil)
93
+ limit = headers['x-ratelimit-limit']&.to_i
94
+ remaining = headers['x-ratelimit-remaining']&.to_i
95
+ bucket_id = headers['x-ratelimit-bucket']
96
+ reset_after = headers['x-ratelimit-reset-after']&.to_f
97
+ retry_after = headers['retry-after']&.to_f
98
+
99
+ if limit && remaining && reset_after && bucket_id
100
+ reset = if retry_after
101
+ retry_after / 1000
102
+ else
103
+ reset_after
104
+ end
105
+ update(key, bucket_id, limit, remaining, Time.now + reset)
106
+ elsif retry_after
107
+ update(key, bucket_id, 0, 0, Time.now + (retry_after / 1000))
108
+ else
109
+ LOGGER.debug { "{#{req_id}} Unable to set RL for #{key}" }
110
+ end
111
+ end
112
+
113
+ # Update a rate limit bucket
114
+ def update(key, bucket_id, limit, remaining, reset_time)
115
+ bucket = @bucket_id_map[bucket_id]
116
+ if bucket
117
+ bucket.update(limit, remaining, reset_time)
118
+ @bucket_key_map[key] = bucket_id
119
+ else
120
+ bucket = Bucket.new(limit, remaining, reset_time)
121
+ @bucket_key_map[key] = bucket
122
+ if bucket_id
123
+ @bucket_id_map[bucket_id] = bucket
124
+ @key_to_id[key] = bucket_id
125
+ end
126
+ end
127
+ end
128
+
129
+ # @!visibility private
130
+ LOGGER = Logging.logger[self]
131
+ end
132
+
133
+ module Middleware
134
+ # Faraday middleware to handle ratelimiting based on
135
+ # bucket ids and the rate limit key provided by {Client#request}
136
+ # in the request context
137
+ class RateLimiter < Faraday::Middleware
138
+ def initialize(app, **_options)
139
+ super(app)
140
+ @limit_table = LimitTable.new
141
+ @mutex_table = Hash.new { |hash, key| hash[key] = Mutex.new }
142
+ end
143
+
144
+ # Request handler
145
+ def call(env)
146
+ rl_key = env.request.context[:rl_key]
147
+ req_id = env.request.context[:trace]
148
+ mutex = @mutex_table[rl_key]
149
+
150
+ mutex.synchronize do
151
+ rl_wait(rl_key, req_id)
152
+ rl_wait(:global, req_id)
153
+ @app.call(env).on_complete do |environ|
154
+ on_complete(environ, req_id)
155
+ end
156
+ end
157
+ end
158
+
159
+ # Handler for response data
160
+ def on_complete(env, req_id)
161
+ resp = env.response
162
+
163
+ if resp.status == 429 && resp.headers['x-ratelimit-global']
164
+ @limit_table.update_from_headers(:global, resp.headers, req_id)
165
+ Thread.new { @limit_table.get_from_key(:global).lock_until_reset }
166
+ LOGGER.error { "{#{req_id}}} Global ratelimit hit" }
167
+ end
168
+
169
+ update_from_headers(env)
170
+ end
171
+
172
+ private
173
+
174
+ # Lock a rate limit mutex preemptively if the next request would deplete the bucket.
175
+ def rl_wait(key, trace)
176
+ bucket_id = @limit_table.id_from_key(key)
177
+ bucket = if bucket_id
178
+ @limit_table.get_from_id(bucket_id)
179
+ else
180
+ @limit_table.get_from_key(key)
181
+ end
182
+ return if bucket.nil?
183
+
184
+ bucket.wait_until_available
185
+ return unless bucket.will_limit?
186
+
187
+ LOGGER.info do
188
+ duration = bucket.reset_time - Time.now
189
+ "{#{trace}} [RL] Locking #{key} for #{duration.truncate(3)} seconds"
190
+ end
191
+
192
+ bucket.lock_until_reset
193
+ end
194
+
195
+ def update_from_headers(env)
196
+ rl_key = env.request.context[:rl_key]
197
+ req_id = env.request.context[:trace]
198
+ @limit_table.update_from_headers(rl_key, env.response.headers, req_id)
199
+ end
200
+
201
+ # @!visibility private
202
+ LOGGER = Logging.logger[Vox::HTTP]
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vox
4
+ module HTTP
5
+ # Route that contains information about a request path, intended
6
+ # for use with {HTTP::Client#request}.
7
+ class Route
8
+ # Major parameters that are significant when forming a rate limit key.
9
+ MAJOR_PARAMS = %i[guild_id channel_id webhook_id].freeze
10
+
11
+ # @return [Symbol, String] HTTP verb to be used when accessing the API
12
+ # path.
13
+ attr_reader :verb
14
+
15
+ # @return [String] Unformatted API path, using Kernel.format
16
+ # syntax referencing keys in {params}.
17
+ attr_reader :key
18
+
19
+ # @return [String] String that defines an endpoint based on HTTP verb,
20
+ # API path, and major parameter if any.
21
+ attr_reader :rl_key
22
+
23
+ # @return [Hash] Parameters that are passed to be used when formatting
24
+ # the API path.
25
+ attr_reader :params
26
+
27
+ # @param verb [#to_sym] The HTTP verb to be used when accessing the API path.
28
+ # @param key [String] The unformatted route using Kernel.format syntax to
29
+ # incorporate the data provided in `params`.
30
+ # @param params [Hash<String, #to_s>] Parameters passed when formatting `key`.
31
+ def initialize(verb, key, **params)
32
+ @verb = verb.downcase.to_sym
33
+ @key = key
34
+ @params = params
35
+ @rl_key = "#{@verb}:#{@key}:#{major_param}"
36
+ end
37
+
38
+ # Format the route with the given params
39
+ # @return [String] Formatted API path.
40
+ def format
41
+ return @key if @params.empty?
42
+
43
+ Kernel.format(@key, @params) if @params.any?
44
+ end
45
+
46
+ # @return [String, Integer, nil] The major param value of the route key if any
47
+ def major_param
48
+ params.slice(*MAJOR_PARAMS).values.first
49
+ end
50
+
51
+ # Compare a {Route} or {Route} like object (responds to `#verb`, `#key`, and `#params`).
52
+ # @param other [Route]
53
+ # @return [true, false]
54
+ def ==(other)
55
+ @verb == other.verb && @key == other.key && @params == other.params
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'vox/http/routes/audit_log'
4
+ require 'vox/http/routes/channel'
5
+ require 'vox/http/routes/emoji'
6
+ require 'vox/http/routes/guild'
7
+ require 'vox/http/routes/invite'
8
+ require 'vox/http/routes/user'
9
+ require 'vox/http/routes/voice'
10
+ require 'vox/http/routes/webhook'
11
+
12
+ module Vox
13
+ module HTTP
14
+ # Module that contains all route containers.
15
+ module Routes
16
+ # Include all route containers if this module is included
17
+ def self.included(klass)
18
+ [AuditLog, Channel, Emoji, Guild, Invite, User, Voice, Webhook].each { |m| klass.include m }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'vox/http/route'
4
+ require 'vox/http/util'
5
+
6
+ module Vox
7
+ module HTTP
8
+ module Routes
9
+ # HTTP methods for accessing information about a {Guild}'s {AuditLog}
10
+ module AuditLog
11
+ include Util
12
+
13
+ # TODO: Is this the best place for these?
14
+ EVENTS = {
15
+ GUILD_UPDATE: 1,
16
+ CHANNEL_CREATE: 10,
17
+ CHANNEL_UPDATE: 11,
18
+ CHANNEL_DELETE: 12,
19
+ CHANNEL_OVERWRITE_CREATE: 13,
20
+ CHANNEL_OVERWRITE_UPDATE: 14,
21
+ CHANNEL_OVERWRITE_DELETE: 15,
22
+ MEMBER_KICK: 20,
23
+ MEMBER_PRUNE: 21,
24
+ MEMBER_BAN_ADD: 22,
25
+ MEMBER_BAN_REMOVE: 23,
26
+ MEMBER_UPDATE: 24,
27
+ MEMBER_ROLE_UPDATE: 25,
28
+ MEMBER_MOVE: 26,
29
+ MEMBER_DISCONNECT: 27,
30
+ BOT_ADD: 28,
31
+ ROLE_CREATE: 30,
32
+ ROLE_UPDATE: 31,
33
+ ROLE_DELETE: 32,
34
+ INVITE_CREATE: 40,
35
+ INVITE_UPDATE: 41,
36
+ INVITE_DELETE: 42,
37
+ WEBHOOK_CREATE: 50,
38
+ WEBHOOK_UPDATE: 51,
39
+ WEBHOOK_DELETE: 52,
40
+ EMOJI_CREATE: 60,
41
+ EMOJI_UPDATE: 61,
42
+ EMOJI_DELETE: 62,
43
+ MESSAGE_DELETE: 72,
44
+ MESSAGE_BULK_DELETE: 73,
45
+ MESSAGE_PIN: 74,
46
+ MESSAGE_UNPIN: 75,
47
+ INTEGRATION_CREATE: 80,
48
+ INTEGRATION_UPDATE: 81,
49
+ INTEGRATION_DELETE: 82
50
+ }.freeze
51
+
52
+ # Fetch a guild's audit log. [View on Discord's docs](https://discord.com/developers/docs/resources/audit-log#get-guild-audit-log)
53
+ # @param guild_id [String, Integer] The ID of the guild to fetch audit log entries from.
54
+ # @param user_id [String, Integer] The ID of the user to filter events for.
55
+ # @param action_type [Symbol, Integer] The name of the audit log event to filter for. Either a key from {EVENTS}
56
+ # or the corresponding integer value.
57
+ # @param before [String, Integer] The ID of the audit log entry to fetch before chronologically.
58
+ # @param limit [Integer] The maximum amount of entries to return. Defaults to 50 if no value is supplied.
59
+ # Maximum of 100, minimum of 1.
60
+ # @return [Hash<:audit_log_entries, Array<Object>>]
61
+ # @vox.permissions VIEW_AUDIT_LOG
62
+ # @vox.api_docs https://discord.com/developers/docs/resources/audit-log#get-guild-audit-log
63
+ def get_guild_audit_log(guild_id, user_id: :undef, action_type: :undef, before: :undef, limit: :undef)
64
+ route = HTTP::Route.new(:GET, '/guilds/%{guild_id}/audit-logs', guild_id: guild_id)
65
+ query_params = filter_undef({ user_id: user_id, action_type: action_type, before: before, limit: limit })
66
+ request(route, query: query_params)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end