vox 0.2.0

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