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.
- checksums.yaml +7 -0
- data/.github/workflows/deploy_docs.yml +39 -0
- data/.github/workflows/rspec.yml +29 -0
- data/.github/workflows/rubocop.yml +24 -0
- data/.github/workflows/yard.yml +32 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +22 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +0 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/LICENSE.md +25 -0
- data/README.md +44 -0
- data/Rakefile +14 -0
- data/lib/vox.rb +10 -0
- data/lib/vox/http/client.rb +146 -0
- data/lib/vox/http/error.rb +56 -0
- data/lib/vox/http/middleware.rb +14 -0
- data/lib/vox/http/middleware/log_formatter.rb +62 -0
- data/lib/vox/http/middleware/rate_limiter.rb +206 -0
- data/lib/vox/http/route.rb +59 -0
- data/lib/vox/http/routes.rb +22 -0
- data/lib/vox/http/routes/audit_log.rb +71 -0
- data/lib/vox/http/routes/channel.rb +425 -0
- data/lib/vox/http/routes/emoji.rb +87 -0
- data/lib/vox/http/routes/guild.rb +292 -0
- data/lib/vox/http/routes/invite.rb +37 -0
- data/lib/vox/http/routes/user.rb +114 -0
- data/lib/vox/http/routes/voice.rb +22 -0
- data/lib/vox/http/routes/webhook.rb +177 -0
- data/lib/vox/http/upload_io.rb +23 -0
- data/lib/vox/http/util.rb +33 -0
- data/lib/vox/version.rb +6 -0
- data/vox.gemspec +42 -0
- metadata +234 -0
@@ -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
|