shep 0.1.0.pre.alpha0 → 0.2.1.pre.alpha0
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 +4 -4
- data/README.md +5 -1
- data/Rakefile +1 -0
- data/doc/Shep/Entity/Account.html +22 -37
- data/doc/Shep/Entity/Context.html +8 -9
- data/doc/Shep/Entity/CustomEmoji.html +11 -15
- data/doc/Shep/Entity/MediaAttachment.html +13 -19
- data/doc/Shep/Entity/Notification.html +11 -15
- data/doc/Shep/Entity/Status.html +34 -61
- data/doc/Shep/Entity/StatusSource.html +9 -11
- data/doc/Shep/Entity/Status_Application.html +11 -10
- data/doc/Shep/Entity/Status_Mention.html +10 -13
- data/doc/Shep/Entity/Status_Tag.html +8 -9
- data/doc/Shep/Entity.html +156 -141
- data/doc/Shep/Error/Caller.html +4 -4
- data/doc/Shep/Error/Http.html +22 -22
- data/doc/Shep/Error/RateLimit.html +176 -0
- data/doc/Shep/Error/Remote.html +3 -4
- data/doc/Shep/Error/Server.html +5 -4
- data/doc/Shep/Error/Type.html +10 -12
- data/doc/Shep/Error.html +8 -5
- data/doc/Shep/Session.html +1023 -572
- data/doc/Shep.html +20 -5
- data/doc/_index.html +10 -3
- data/doc/class_list.html +1 -1
- data/doc/file.README.html +52 -33
- data/doc/file_list.html +1 -1
- data/doc/index.html +117 -239
- data/doc/method_list.html +9 -1
- data/doc/top-level-namespace.html +2 -2
- data/lib/shep/entity_base.rb +6 -1
- data/lib/shep/exceptions.rb +8 -0
- data/lib/shep/session.rb +340 -145
- data/lib/shep/version.rb +4 -0
- data/lib/shep.rb +1 -1
- data/run_rake_test.example.sh +12 -5
- data/shep.gemspec +4 -2
- data/spec/session_reader_1_unauth_spec.rb +20 -17
- data/spec/session_reader_2_auth_spec.rb +17 -19
- data/spec/session_writer_spec.rb +4 -11
- data/spec/session_zzz_tricky_spec.rb +205 -0
- data/spec/spec_helper.rb +12 -3
- data/yard_helper.rb +11 -5
- metadata +12 -9
data/lib/shep/session.rb
CHANGED
@@ -11,8 +11,9 @@ module Shep
|
|
11
11
|
|
12
12
|
# Represents a connection to a Mastodon (or equivalent) server.
|
13
13
|
#
|
14
|
-
# @attr_reader [Logger] logger
|
15
|
-
# @attr_reader [String] host
|
14
|
+
# @attr_reader [Logger] logger The logger object
|
15
|
+
# @attr_reader [String] host The Server's hostname
|
16
|
+
# @attr_reader [String] user_agent User-Agent string; frozen
|
16
17
|
#
|
17
18
|
# ## Conventions
|
18
19
|
#
|
@@ -24,29 +25,97 @@ module Shep
|
|
24
25
|
# each item in turn and the block's result is ignored. Otherwise,
|
25
26
|
# it returns an `Enumerator` which can be used in the usual ways.
|
26
27
|
#
|
27
|
-
#
|
28
|
-
# for example,
|
28
|
+
# Some examples:
|
29
29
|
#
|
30
|
-
#
|
30
|
+
# # Evaluate a block on each status
|
31
|
+
# session.each_status(account) { |status| do_thing(status) }
|
31
32
|
#
|
32
|
-
#
|
33
|
-
#
|
34
|
-
#
|
35
|
-
# the
|
33
|
+
# # Retrieve the last 100 statuses in an array
|
34
|
+
# statuses = session.each_status(account, limit: 100).to_a
|
35
|
+
#
|
36
|
+
# # Retrieve the last 200 statuses via an enumerator and do
|
37
|
+
# # extra transformation on the result before collecting
|
38
|
+
# # them in an array.
|
39
|
+
# statuses = session.each_status(account, limit: 200)
|
40
|
+
# .select{|status| BESTIES.include? status.account.username }
|
41
|
+
# .map{|status| status.id}
|
42
|
+
# .to_a
|
43
|
+
#
|
44
|
+
# The actual web API "paginates" the output. That is, it returns
|
45
|
+
# the first 40 (or so) items and then provides a link to the next
|
46
|
+
# chunk. Shep's `each_*` methods handle this for you automatically.
|
47
|
+
# This means that unless you use `limit:`, the `each_*` methods will
|
48
|
+
# retrieve **all** available items, at least until you reach the
|
49
|
+
# rate limit (see below).
|
50
|
+
#
|
51
|
+
# Note that it is safe to leave an `each_*` methods block with
|
52
|
+
# `break`, `return`, an exception, or any other such mechanism.
|
36
53
|
#
|
37
54
|
# The remaining Mastodon API methods will in some way modify the
|
38
55
|
# state of the server and return an Entity subinstance on success.
|
39
56
|
#
|
40
57
|
# All API calls throw an exception on failure.
|
58
|
+
#
|
59
|
+
# ## Rate Limits
|
60
|
+
#
|
61
|
+
# Mastodon servers restrict the number of times you can use a
|
62
|
+
# specific endpoint within a time period as a way to prevent abuse.
|
63
|
+
# Shep provides several tools for handling these limits gracefully.
|
64
|
+
#
|
65
|
+
# 1. The method {rate_limit} will return a Struct that tells you how
|
66
|
+
# many requests you have left and when the count is reset.
|
67
|
+
#
|
68
|
+
# 2. If a rate limit is exceeded, the method will throw an
|
69
|
+
# {Error::RateLimit} exception instead of an ordinary
|
70
|
+
# {Error::Http} exception.
|
71
|
+
#
|
72
|
+
# 3. If the Session is created with argument `rate_limit_retry:` set
|
73
|
+
# to true, the Session will instead wait out the reset time and
|
74
|
+
# try again.
|
75
|
+
#
|
76
|
+
# If you enable the wait-and-retry mechanism, you can also provide a
|
77
|
+
# hook function (i.e. a thing that responds to `call`) via
|
78
|
+
# constructor argument `retry_hook:`. This is called with one
|
79
|
+
# argument, the result of {rate_limit} for the limited API endpoint,
|
80
|
+
# immediately before Shep starts waiting for the limit to reset.
|
81
|
+
#
|
82
|
+
# The built-in wait time takes the callback's execution time into
|
83
|
+
# account so it's possible to use the callback to do your own
|
84
|
+
# waiting and use that time more productively.
|
85
|
+
#
|
86
|
+
# Alternately, all of the `each_*` methods have a `limit:` parameter
|
87
|
+
# so it's easy to avoid making too many API calls and many have a
|
88
|
+
# `max_id:` parameter that allows you to continue where you left off.
|
89
|
+
#
|
41
90
|
class Session
|
42
|
-
attr_reader :logger, :host
|
91
|
+
attr_reader :logger, :host, :user_agent
|
43
92
|
|
44
93
|
# Initialize a new {Session}.
|
45
94
|
#
|
46
|
-
# @param host
|
47
|
-
# @param token
|
48
|
-
#
|
49
|
-
# @param
|
95
|
+
# @param host [String] Hostname of the server
|
96
|
+
# @param token [String] Bearer token; optional
|
97
|
+
#
|
98
|
+
# @param user_agent [String] User-Agent string to use
|
99
|
+
# @param ua_comment [String] Comment part of User-Agent string
|
100
|
+
#
|
101
|
+
# @param rate_limit_retry [Boolean] Handle request limits by waiting
|
102
|
+
# for the count to reset and trying
|
103
|
+
# again.
|
104
|
+
#
|
105
|
+
# @param retry_hook [Proc] One-argument hook function to call
|
106
|
+
# before waiting for the rate limit
|
107
|
+
# to reset
|
108
|
+
#
|
109
|
+
# @param logger [Logger] The logger or mode; optional
|
110
|
+
# @param debug_http [Boolean] Enable `Net::HTTP` debugging;
|
111
|
+
# **insecure!**
|
112
|
+
#
|
113
|
+
# By default, the User-Agent header is set to the gem's
|
114
|
+
# identifier, but may be overridden with the `user_agent`
|
115
|
+
# parameter. It is your responsibility to make sure it is
|
116
|
+
# formatted correctly. You can also append comment text to the
|
117
|
+
# given User-Agent string with `ua_comment`; this lets you add a
|
118
|
+
# comment to the default text.
|
50
119
|
#
|
51
120
|
# Parameter `logger` may be a `Logger` object, `nil`, or a
|
52
121
|
# `Symbol` whose value is the name of one of the supported log
|
@@ -56,17 +125,37 @@ module Shep
|
|
56
125
|
#
|
57
126
|
# If `debug_http` is true, compression is disabled and the
|
58
127
|
# transactions are sent to `STDERR` via
|
59
|
-
# `Net::HTTP.set_debug_output`.
|
60
|
-
#
|
128
|
+
# `Net::HTTP.set_debug_output`.
|
129
|
+
#
|
130
|
+
# **WARNING:** this opens a serious security hole and should not
|
131
|
+
# be used in production.
|
61
132
|
def initialize(host:,
|
62
|
-
token:
|
63
|
-
|
64
|
-
|
133
|
+
token: nil,
|
134
|
+
user_agent: "ShepRubyGem/#{Shep::Version}",
|
135
|
+
ua_comment: nil,
|
136
|
+
|
137
|
+
rate_limit_retry: false,
|
138
|
+
retry_hook: nil,
|
139
|
+
|
140
|
+
logger: nil,
|
141
|
+
debug_http: false)
|
65
142
|
@host = host
|
66
143
|
@token = token
|
67
144
|
@logger = init_logger(logger)
|
68
|
-
|
145
|
+
|
146
|
+
@user_agent = user_agent
|
147
|
+
@user_agent += " #{ua_comment}" if ua_comment
|
148
|
+
@user_agent.freeze
|
149
|
+
|
150
|
+
@rate_limit_retry = rate_limit_retry
|
151
|
+
@retry_hook = retry_hook
|
152
|
+
|
69
153
|
@debug_http = debug_http
|
154
|
+
|
155
|
+
@rate_limit = Struct.new(:limit, :remaining, :reset).new
|
156
|
+
|
157
|
+
raise Error::Caller.new("retry_hook: must a callable or nil") unless
|
158
|
+
@retry_hook == nil || @retry_hook.respond_to?(:call)
|
70
159
|
end
|
71
160
|
|
72
161
|
private
|
@@ -88,7 +177,7 @@ module Shep
|
|
88
177
|
|
89
178
|
public
|
90
179
|
|
91
|
-
# Return the rate limit information
|
180
|
+
# Return the rate limit information from the last REST request.
|
92
181
|
#
|
93
182
|
# The result is a Struct with the following fields:
|
94
183
|
#
|
@@ -101,6 +190,10 @@ module Shep
|
|
101
190
|
# times within 5 minutes but no more than 30 media uploads are
|
102
191
|
# allowed within a 30 minute time period.
|
103
192
|
#
|
193
|
+
# Note also that some Shep methods will perform multiple API
|
194
|
+
# requests; this is only ever the rate limit information from the
|
195
|
+
# latest of these.
|
196
|
+
#
|
104
197
|
# @see https://docs.joinmastodon.org/api/rate-limits/
|
105
198
|
#
|
106
199
|
# @return [Struct.new(:limit, :remaining, :reset)]
|
@@ -150,14 +243,19 @@ module Shep
|
|
150
243
|
#
|
151
244
|
# The username must belong to a user on the current server.
|
152
245
|
#
|
153
|
-
# @param handle [String] the account's username
|
246
|
+
# @param handle [String] the account's username with or without the
|
154
247
|
# leading '@' character (e.g. @benoitmandelbot)
|
155
248
|
#
|
156
|
-
#
|
249
|
+
#
|
250
|
+
# @return [Entity::Account, nil] The Account or nil if it can't be found.
|
157
251
|
#
|
158
252
|
# @see https://docs.joinmastodon.org/methods/accounts/#get
|
159
253
|
def fetch_account_by_username(handle)
|
160
254
|
return rest_get("accounts/lookup", Entity::Account, {acct: handle})
|
255
|
+
rescue Error::Http => oopsie
|
256
|
+
# As a special case, return nil if the lookup fails
|
257
|
+
return nil if oopsie.response.is_a?(Net::HTTPNotFound)
|
258
|
+
raise oopsie
|
161
259
|
end
|
162
260
|
|
163
261
|
# Fetch an individual notification by ID.
|
@@ -233,18 +331,18 @@ module Shep
|
|
233
331
|
|
234
332
|
if !refetch && File.exist?(outfile)
|
235
333
|
@logger.info "Found '#{outfile}'; skipping."
|
236
|
-
success = true
|
237
334
|
else
|
238
335
|
tmp = File.join(media_dir, SecureRandom.uuid + '.tmp')
|
239
|
-
|
240
|
-
|
336
|
+
begin
|
337
|
+
basic_get_binary(ma.url, tmp)
|
241
338
|
FileUtils.mv(tmp, outfile)
|
242
|
-
|
339
|
+
rescue Error::Http => e
|
243
340
|
FileUtils.rm(tmp, force: true)
|
341
|
+
raise e
|
244
342
|
end
|
245
343
|
end
|
246
344
|
|
247
|
-
media[ma.url.to_s] =
|
345
|
+
media[ma.url.to_s] = outfile
|
248
346
|
}
|
249
347
|
|
250
348
|
return [status, media]
|
@@ -300,6 +398,8 @@ module Shep
|
|
300
398
|
# @param limit [Integer] Maximum number of accounts to
|
301
399
|
# retrieve
|
302
400
|
#
|
401
|
+
# @param max_id [String] retrieve results older than this ID.
|
402
|
+
#
|
303
403
|
# @param only_media [Boolean] If true, filter for statuses
|
304
404
|
# with media
|
305
405
|
#
|
@@ -321,6 +421,7 @@ module Shep
|
|
321
421
|
# @see https://docs.joinmastodon.org/methods/accounts/#statuses
|
322
422
|
def each_status(account_id,
|
323
423
|
limit: nil,
|
424
|
+
max_id: "",
|
324
425
|
only_media: false,
|
325
426
|
exclude_replies: false,
|
326
427
|
exclude_reblogs: false,
|
@@ -337,6 +438,8 @@ module Shep
|
|
337
438
|
#
|
338
439
|
# @param limit [Integer] Max. items to retrieve.
|
339
440
|
#
|
441
|
+
# @param max_id [String] retrieve results older than this ID.
|
442
|
+
#
|
340
443
|
# @param local [Boolean] Retrieve only local statuses
|
341
444
|
#
|
342
445
|
# @param remote [Boolean] Retrieve only remote statuses
|
@@ -351,6 +454,7 @@ module Shep
|
|
351
454
|
#
|
352
455
|
# @see https://docs.joinmastodon.org/methods/timelines/#public
|
353
456
|
def each_public_status(limit: nil,
|
457
|
+
max_id: "",
|
354
458
|
local: false,
|
355
459
|
remote: false,
|
356
460
|
only_media: false,
|
@@ -373,6 +477,8 @@ module Shep
|
|
373
477
|
#
|
374
478
|
# @param limit [Integer] maximum number of items to retrieve
|
375
479
|
#
|
480
|
+
# @param max_id [String] return results older than this ID.
|
481
|
+
#
|
376
482
|
# @param local [Boolean] retrieve only local statuses
|
377
483
|
#
|
378
484
|
# @param remote [Boolean] retrieve only remote statuses
|
@@ -392,6 +498,7 @@ module Shep
|
|
392
498
|
# @see https://docs.joinmastodon.org/methods/timelines/#tag
|
393
499
|
def each_tag_status(hashtag_s,
|
394
500
|
limit: nil,
|
501
|
+
max_id: "",
|
395
502
|
local: false,
|
396
503
|
remote: false,
|
397
504
|
only_media: false,
|
@@ -422,10 +529,16 @@ module Shep
|
|
422
529
|
#
|
423
530
|
# Requires token.
|
424
531
|
#
|
425
|
-
# @param limit
|
426
|
-
#
|
427
|
-
# @param
|
428
|
-
#
|
532
|
+
# @param limit [Integer] maximum number of items to retrieve
|
533
|
+
#
|
534
|
+
# @param max_id [String] retrieve results older than this ID.
|
535
|
+
#
|
536
|
+
# @param local [Boolean] retrieve only local statuses
|
537
|
+
#
|
538
|
+
# @param remote [Boolean] retrieve only remote statuses
|
539
|
+
#
|
540
|
+
# @param only_media [Boolean] retrieve only media status
|
541
|
+
#
|
429
542
|
#
|
430
543
|
# @yield [item]
|
431
544
|
#
|
@@ -437,6 +550,7 @@ module Shep
|
|
437
550
|
# @see https://docs.joinmastodon.org/methods/timelines/#home
|
438
551
|
def each_home_status(limit: nil,
|
439
552
|
local: false,
|
553
|
+
max_id: "",
|
440
554
|
remote: false,
|
441
555
|
only_media: false,
|
442
556
|
&block)
|
@@ -488,6 +602,18 @@ module Shep
|
|
488
602
|
|
489
603
|
# Retrieve each notification.
|
490
604
|
#
|
605
|
+
# Requires a bearer token.
|
606
|
+
#
|
607
|
+
# Notification types are indicated by of the following symbols:
|
608
|
+
#
|
609
|
+
# `:mention`, `:status`, `:reblog`, `:follow`, `:follow_request`
|
610
|
+
# `:favourite`, `:poll`, `:update`, `:admin.sign_up`, or
|
611
|
+
# `:admin.report`
|
612
|
+
#
|
613
|
+
# This method will throw an `Error::Caller` exception if an
|
614
|
+
# unknown value is used.
|
615
|
+
#
|
616
|
+
#
|
491
617
|
# @param types [Array<String>] list of notifications types to
|
492
618
|
# enumerate; others are ignoredn
|
493
619
|
#
|
@@ -514,9 +640,13 @@ module Shep
|
|
514
640
|
favourite poll update admin.sign_up
|
515
641
|
admin.report}
|
516
642
|
|
517
|
-
#
|
518
|
-
types.
|
519
|
-
|
643
|
+
# Remove duplicates and convert strings to symbols
|
644
|
+
[types, exclude_types].each{|param|
|
645
|
+
param.map!{|item| item.intern}
|
646
|
+
param.uniq!
|
647
|
+
}
|
648
|
+
|
649
|
+
# Now, ensure there are no incorrect notification types.
|
520
650
|
(types + exclude_types).each{|filter|
|
521
651
|
assert("Unknown notification type: #{filter}") {
|
522
652
|
allowed_notifications.include?(filter.intern)
|
@@ -550,6 +680,8 @@ module Shep
|
|
550
680
|
#
|
551
681
|
# @param language [String] ISO language code
|
552
682
|
#
|
683
|
+
# @return [Entity::Status] The new status.
|
684
|
+
#
|
553
685
|
# @see https://docs.joinmastodon.org/methods/statuses/#create
|
554
686
|
def post_status(text,
|
555
687
|
visibility: :private,
|
@@ -620,6 +752,40 @@ module Shep
|
|
620
752
|
|
621
753
|
# Upload the media contained in the file at 'path'.
|
622
754
|
#
|
755
|
+
# Requires token with sufficient permission for the account that
|
756
|
+
# owns the status.
|
757
|
+
#
|
758
|
+
# @param path [String] Path to the media file.
|
759
|
+
#
|
760
|
+
# @param content_type [String] MIME type of the media attachment.
|
761
|
+
# The default us usually all you need.
|
762
|
+
#
|
763
|
+
# @param description [String] The image description text.
|
764
|
+
#
|
765
|
+
# @param focus_x [Float] The horizontal coordinate of
|
766
|
+
# the focus on a range of -1.0
|
767
|
+
# to 1.0. This is the point in
|
768
|
+
# the image that will be the
|
769
|
+
# center of the thumbnail. If
|
770
|
+
# set, `focus_y:` must also be
|
771
|
+
# set to a valid coordinate.
|
772
|
+
#
|
773
|
+
# @param focus_y [Float] The vertical coordinate of the focus.
|
774
|
+
#
|
775
|
+
# Note that Mastodon processes attachments asynchronously, so the
|
776
|
+
# attachment may not be available for display when this method
|
777
|
+
# returns. Posting an unprocessed status as an attachment works
|
778
|
+
# as expected but it's unclear what happens between posting and
|
779
|
+
# when the processing task completes. Usually, this shouldn't
|
780
|
+
# matter to you.
|
781
|
+
#
|
782
|
+
# If a rate limit is reached during a call to this method and
|
783
|
+
# `rate_limit_retry:` was set, the media file to upload should not
|
784
|
+
# be touched in any way until the method returns.
|
785
|
+
#
|
786
|
+
# @return [Entity::MediaAttachment] The resulting MediaAttachment
|
787
|
+
# object but without the URL.
|
788
|
+
#
|
623
789
|
# @see https://docs.joinmastodon.org/methods/media/#v2
|
624
790
|
def upload_media(path,
|
625
791
|
content_type: nil,
|
@@ -669,12 +835,14 @@ module Shep
|
|
669
835
|
return nil
|
670
836
|
end
|
671
837
|
|
672
|
-
|
838
|
+
|
673
839
|
|
674
840
|
#
|
675
|
-
# High-level REST support
|
841
|
+
# High(ish)-level REST support
|
676
842
|
#
|
677
843
|
|
844
|
+
private
|
845
|
+
|
678
846
|
# Extract all of the keyword arguments and their values from the
|
679
847
|
# caller's context. (The context is 'cbinding', which must be a
|
680
848
|
# call to method 'cmethod'.)
|
@@ -711,7 +879,7 @@ module Shep
|
|
711
879
|
|
712
880
|
def rest_delete(path, result_klass)
|
713
881
|
uri = rest_uri(path, {})
|
714
|
-
result_obj, _ = basic_rest_get_or_delete(uri,
|
882
|
+
result_obj, _ = basic_rest_get_or_delete(uri, is_delete: true)
|
715
883
|
return result_klass.from(result_obj)
|
716
884
|
end
|
717
885
|
|
@@ -766,7 +934,7 @@ module Shep
|
|
766
934
|
|
767
935
|
|
768
936
|
#
|
769
|
-
# Low-level REST support
|
937
|
+
# Low(ish)-level REST support
|
770
938
|
#
|
771
939
|
|
772
940
|
# Given a hash of query arguments, return an array of key-value
|
@@ -804,51 +972,96 @@ module Shep
|
|
804
972
|
return URI("https://#{@host}/api/#{version}/#{path}#{args}")
|
805
973
|
end
|
806
974
|
|
807
|
-
def
|
975
|
+
def parse_link_header(hdr)
|
976
|
+
result = {}
|
977
|
+
return result unless hdr # could be nil
|
978
|
+
|
979
|
+
for link in hdr.split(', ')
|
980
|
+
md = link.match(/^<([^>]+)>; rel="([^"]+)"/)
|
981
|
+
assert{md}
|
982
|
+
|
983
|
+
result[ md[2] ] = md[1]
|
984
|
+
end
|
985
|
+
|
986
|
+
return result
|
987
|
+
end
|
988
|
+
|
989
|
+
# Perform a GET or DELETE operation. (They have mostly the same
|
990
|
+
# structure, so we combine the functionality here.)
|
991
|
+
def basic_rest_get_or_delete(url, is_delete: false)
|
808
992
|
headers = headers_for(:get)
|
809
993
|
|
810
|
-
|
811
|
-
|
812
|
-
|
994
|
+
request = is_delete ?
|
995
|
+
Net::HTTP::Delete.new(url, headers) :
|
996
|
+
Net::HTTP::Get.new(url, headers)
|
813
997
|
|
814
|
-
|
815
|
-
raise Error::Http.new(response) if response.is_a? Net::HTTPClientError
|
998
|
+
response = http_operation(request)
|
816
999
|
|
817
|
-
|
1000
|
+
result = parse_json_gracefully(response.body)
|
818
1001
|
|
819
|
-
if result.is_a?(Hash) &&
|
820
|
-
raise Error::Server.new(
|
1002
|
+
if result.is_a?(Hash) && result.has_key?("error")
|
1003
|
+
raise Error::Server.new(result["error"])
|
821
1004
|
end
|
822
1005
|
|
823
1006
|
link = parse_link_header(response["link"])
|
824
1007
|
|
825
|
-
return [
|
826
|
-
rescue JSON::JSONError => e
|
827
|
-
raise Error::Remote.new("Error parsing result JSON: #{e}")
|
1008
|
+
return [result, link]
|
828
1009
|
end
|
829
1010
|
|
830
|
-
|
831
|
-
|
832
|
-
|
1011
|
+
# Retrieve the resource (assumed to be binary) at 'uri' and write
|
1012
|
+
# it to 'filename'. For now, returns false if the request
|
1013
|
+
# returned an error code and true on success.
|
1014
|
+
def basic_get_binary(url, filename)
|
1015
|
+
request = Net::HTTP::Get.new(url, headers_for(:get))
|
833
1016
|
|
834
|
-
|
835
|
-
|
836
|
-
|
837
|
-
use_ssl: uri.scheme == 'https'
|
838
|
-
) { |http|
|
839
|
-
next is_delete ?
|
840
|
-
http.delete(uri.path, headers) :
|
841
|
-
http.request_get(uri, headers)
|
1017
|
+
@logger.debug("Output file is #{filename}")
|
1018
|
+
File.open(filename, "wb") { |outfile|
|
1019
|
+
http_operation(request, output_handle: outfile)
|
842
1020
|
}
|
1021
|
+
@logger.debug("Done (#{filename})")
|
1022
|
+
end
|
1023
|
+
|
1024
|
+
def basic_rest_post_or_put(url, formdata, is_put: false)
|
1025
|
+
headers = headers_for(:post)
|
1026
|
+
|
1027
|
+
# Select request type
|
1028
|
+
request = is_put ?
|
1029
|
+
Net::HTTP::Put.new(url, headers) :
|
1030
|
+
Net::HTTP::Post.new(url, headers)
|
1031
|
+
|
1032
|
+
# Set the parameters
|
1033
|
+
enctype = 'multipart/form-data' if formdata.is_a?(Array)
|
1034
|
+
enctype = 'application/x-www-form-urlencoded' if
|
1035
|
+
formdata.is_a?(Hash)
|
1036
|
+
enctype or
|
1037
|
+
raise Error::Caller.new("Unknown formdate type: #{formdata.class}")
|
1038
|
+
request.set_form(formdata, enctype)
|
1039
|
+
|
1040
|
+
# IO-like devices need to be rewound before retrying the
|
1041
|
+
# request. Since Request doesn't let us have access to the
|
1042
|
+
# formdata objects, we pass them separately.
|
1043
|
+
rewinds = formdata.to_a
|
1044
|
+
.map{|name, value| value}
|
1045
|
+
.select{|value| value.respond_to?(:rewind)}
|
1046
|
+
|
1047
|
+
# Do the deed
|
1048
|
+
response = http_operation(request, rewinds: rewinds)
|
1049
|
+
|
1050
|
+
result = parse_json_gracefully(response.body)
|
1051
|
+
|
1052
|
+
if result.is_a?(Hash) && result.has_key?("error")
|
1053
|
+
raise Error::Server.new(result["error"])
|
1054
|
+
end
|
843
1055
|
|
844
|
-
|
845
|
-
return response
|
1056
|
+
return result
|
846
1057
|
end
|
847
1058
|
|
848
1059
|
def headers_for(method)
|
849
1060
|
headers = {}
|
850
1061
|
headers["Authorization"] = "Bearer #{@token}" if @token
|
851
1062
|
|
1063
|
+
headers['User-Agent'] = @user_agent
|
1064
|
+
|
852
1065
|
if method == :post
|
853
1066
|
extras = {
|
854
1067
|
"Indempotency-Key": SecureRandom.uuid,
|
@@ -860,111 +1073,93 @@ module Shep
|
|
860
1073
|
return headers
|
861
1074
|
end
|
862
1075
|
|
863
|
-
def
|
864
|
-
|
1076
|
+
def http_operation(request, output_handle: nil, rewinds: [])
|
1077
|
+
url = request.uri
|
865
1078
|
|
866
|
-
|
867
|
-
|
1079
|
+
while true
|
1080
|
+
http = Net::HTTP.new(url.hostname, url.port)
|
1081
|
+
http.use_ssl = (url.scheme == 'https')
|
868
1082
|
|
869
|
-
|
870
|
-
|
1083
|
+
if @debug_http
|
1084
|
+
http.set_debug_output(STDERR)
|
1085
|
+
request['Accept-Encoding'] = 'identity'
|
1086
|
+
end
|
871
1087
|
|
872
|
-
|
873
|
-
|
1088
|
+
http.start do |http|
|
1089
|
+
@logger.debug("Request #{request}; (token: #{!!@token})")
|
874
1090
|
|
875
|
-
|
876
|
-
|
877
|
-
|
878
|
-
|
879
|
-
|
1091
|
+
# We have to invoke 'request' with a block argument to get the
|
1092
|
+
# response because that's the only way we can get at it before
|
1093
|
+
# it's downloaded the entire response into RAM.
|
1094
|
+
http.request(request) do |response|
|
1095
|
+
@logger.debug("Response: #{response}")
|
880
1096
|
|
881
|
-
|
882
|
-
Net::HTTP.get_response(uri, headers) {|response|
|
883
|
-
@logger.debug("Response: #{response.code}")
|
1097
|
+
update_rate_limit(response)
|
884
1098
|
|
885
|
-
|
1099
|
+
#raise_http_exception_if_error(response)
|
886
1100
|
|
887
|
-
|
888
|
-
|
889
|
-
|
890
|
-
|
1101
|
+
if response.is_a?(Net::HTTPClientError)
|
1102
|
+
# Finish any body reading that may have been in
|
1103
|
+
# progress.
|
1104
|
+
response.read_body()
|
891
1105
|
|
892
|
-
|
893
|
-
|
894
|
-
|
895
|
-
|
896
|
-
|
897
|
-
|
898
|
-
}
|
899
|
-
@logger.debug("Done (#{filename})")
|
900
|
-
}
|
1106
|
+
# Special case: too many requests. We may throw an
|
1107
|
+
# exception or wait until it resets and try again.
|
1108
|
+
if response.is_a?(Net::HTTPTooManyRequests)
|
1109
|
+
handle_rate_limit_reached(response)
|
1110
|
+
next # if we get here, we're trying again
|
1111
|
+
end
|
901
1112
|
|
902
|
-
|
903
|
-
|
1113
|
+
raise Error::Http.new(response)
|
1114
|
+
end
|
904
1115
|
|
905
|
-
|
906
|
-
|
907
|
-
|
1116
|
+
# read_body will write the response body to output_handle if
|
1117
|
+
# it's a handle or keep it internally if it's nil.
|
1118
|
+
response.read_body(output_handle)
|
908
1119
|
|
909
|
-
|
910
|
-
|
911
|
-
assert{md}
|
1120
|
+
return response
|
1121
|
+
end
|
912
1122
|
|
913
|
-
|
1123
|
+
# If we get here, we're going to try again, so we need to
|
1124
|
+
# rewind any IO-like header value.
|
1125
|
+
rewinds.each{|io| io.rewind}
|
1126
|
+
end
|
914
1127
|
end
|
915
|
-
|
916
|
-
return result
|
917
1128
|
end
|
918
1129
|
|
1130
|
+
def handle_rate_limit_reached(response)
|
1131
|
+
raise Error::RateLimit.new(response) unless @rate_limit_retry
|
919
1132
|
|
920
|
-
|
921
|
-
|
922
|
-
|
923
|
-
# Do the thing.
|
924
|
-
@logger.debug("Posting #{uri} (token: #{!!@token})")
|
925
|
-
response = http_post_form(uri, headers, formdata, is_put)
|
926
|
-
@logger.debug("Response: #{response}")
|
927
|
-
|
928
|
-
update_rate_limit(response)
|
1133
|
+
# Call the retry hook first.
|
1134
|
+
@retry_hook.call(self.rate_limit().dup) if @retry_hook
|
929
1135
|
|
930
|
-
|
931
|
-
|
932
|
-
|
933
|
-
|
934
|
-
|
935
|
-
|
936
|
-
raise Error::Server.new(result_obj["error"])
|
1136
|
+
# Now, wait out any remaining elapsed time.
|
1137
|
+
while true
|
1138
|
+
delay = (@rate_limit.reset - Time.now) + 2
|
1139
|
+
break if delay <= 0
|
1140
|
+
@logger.info "Sleeping for #{delay.round} seconds."
|
1141
|
+
sleep delay
|
937
1142
|
end
|
938
|
-
|
939
|
-
return result_obj
|
940
|
-
rescue JSON::JSONError => e
|
941
|
-
raise Error::Remote.new("Error parsing result JSON: #{e}")
|
942
1143
|
end
|
943
1144
|
|
944
|
-
def
|
945
|
-
|
946
|
-
Net::HTTP::Put.new(url, headers) :
|
947
|
-
Net::HTTP::Post.new(url, headers)
|
948
|
-
|
949
|
-
enctype = 'multipart/form-data' if formdata.is_a?(Array)
|
950
|
-
enctype = 'application/x-www-form-urlencoded' if
|
951
|
-
formdata.is_a?(Hash)
|
952
|
-
enctype or
|
953
|
-
raise Error::Caller.new("Unknown formdate type: #{formdata.class}")
|
954
|
-
request.set_form(formdata, enctype)
|
1145
|
+
def update_rate_limit(response)
|
1146
|
+
@rate_limit.limit = @rate_limit.remaining = @rate_limit.reset = nil
|
955
1147
|
|
1148
|
+
@rate_limit.limit = response['X-RateLimit-Limit'].to_i
|
1149
|
+
@rate_limit.remaining = response['X-RateLimit-Remaining'].to_i
|
956
1150
|
|
957
|
-
|
958
|
-
|
1151
|
+
reset = response['X-RateLimit-Reset']
|
1152
|
+
@rate_limit.reset = Time.iso8601(reset) if reset
|
959
1153
|
|
960
|
-
|
961
|
-
|
962
|
-
request['Accept-Encoding'] = 'identity'
|
963
|
-
end
|
1154
|
+
@logger.debug "Rate limit: #{rate_limit_desc}"
|
1155
|
+
end
|
964
1156
|
|
965
|
-
|
966
|
-
|
967
|
-
|
1157
|
+
# Parse json_txt without throwing an exception. If the result
|
1158
|
+
# isn't valid, return a Mastodn-style error object instead.
|
1159
|
+
def parse_json_gracefully(json_txt)
|
1160
|
+
return JSON.parse(json_txt)
|
1161
|
+
rescue JSON::JSONError
|
1162
|
+
return {"error" => "Did not receive a valid JSON object."}
|
968
1163
|
end
|
969
1164
|
end
|
970
1165
|
end
|