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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -1
  3. data/Rakefile +1 -0
  4. data/doc/Shep/Entity/Account.html +22 -37
  5. data/doc/Shep/Entity/Context.html +8 -9
  6. data/doc/Shep/Entity/CustomEmoji.html +11 -15
  7. data/doc/Shep/Entity/MediaAttachment.html +13 -19
  8. data/doc/Shep/Entity/Notification.html +11 -15
  9. data/doc/Shep/Entity/Status.html +34 -61
  10. data/doc/Shep/Entity/StatusSource.html +9 -11
  11. data/doc/Shep/Entity/Status_Application.html +11 -10
  12. data/doc/Shep/Entity/Status_Mention.html +10 -13
  13. data/doc/Shep/Entity/Status_Tag.html +8 -9
  14. data/doc/Shep/Entity.html +156 -141
  15. data/doc/Shep/Error/Caller.html +4 -4
  16. data/doc/Shep/Error/Http.html +22 -22
  17. data/doc/Shep/Error/RateLimit.html +176 -0
  18. data/doc/Shep/Error/Remote.html +3 -4
  19. data/doc/Shep/Error/Server.html +5 -4
  20. data/doc/Shep/Error/Type.html +10 -12
  21. data/doc/Shep/Error.html +8 -5
  22. data/doc/Shep/Session.html +1023 -572
  23. data/doc/Shep.html +20 -5
  24. data/doc/_index.html +10 -3
  25. data/doc/class_list.html +1 -1
  26. data/doc/file.README.html +52 -33
  27. data/doc/file_list.html +1 -1
  28. data/doc/index.html +117 -239
  29. data/doc/method_list.html +9 -1
  30. data/doc/top-level-namespace.html +2 -2
  31. data/lib/shep/entity_base.rb +6 -1
  32. data/lib/shep/exceptions.rb +8 -0
  33. data/lib/shep/session.rb +340 -145
  34. data/lib/shep/version.rb +4 -0
  35. data/lib/shep.rb +1 -1
  36. data/run_rake_test.example.sh +12 -5
  37. data/shep.gemspec +4 -2
  38. data/spec/session_reader_1_unauth_spec.rb +20 -17
  39. data/spec/session_reader_2_auth_spec.rb +17 -19
  40. data/spec/session_writer_spec.rb +4 -11
  41. data/spec/session_zzz_tricky_spec.rb +205 -0
  42. data/spec/spec_helper.rb +12 -3
  43. data/yard_helper.rb +11 -5
  44. 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 The logger object
15
- # @attr_reader [String] host The Server's hostname
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
- # `each_*` will automatically paginate through the available items;
28
- # for example,
28
+ # Some examples:
29
29
  #
30
- # statuses = session.each_public_status.to_a
30
+ # # Evaluate a block on each status
31
+ # session.each_status(account) { |status| do_thing(status) }
31
32
  #
32
- # will retrieve the server's entire public timeline and put it in an
33
- # array. (Note: don't do this.) The `limit:` keyword option will
34
- # set an upper limit on the number of items retrieved (but note that
35
- # the method may retrieve more from the API endpoint).
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 [String] Hostname of the server
47
- # @param token [String] Bearer token; optional
48
- # @param logger [Logger] The logger or mode; optional
49
- # @param debug_http [Boolean] Enable `Net::HTTP` debugging; insecure!
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`. **WARNING:** this opens a serious
60
- # security hole and should not be used in production.
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: nil,
63
- logger: nil,
64
- debug_http: false)
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
- @rate_limit = Struct.new(:limit, :remaining, :reset).new
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 as of the last operation.
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 **with** the
246
+ # @param handle [String] the account's username with or without the
154
247
  # leading '@' character (e.g. @benoitmandelbot)
155
248
  #
156
- # @return [Entity::Account]
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
- success = basic_get_binary(ma.url, tmp)
240
- if success
336
+ begin
337
+ basic_get_binary(ma.url, tmp)
241
338
  FileUtils.mv(tmp, outfile)
242
- else
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] = success ? outfile : nil
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 [Integer] ,
426
- # @param local [Boolean] ,
427
- # @param remote [Boolean] ,
428
- # @param only_media [Boolean] ,
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
- # Ensure valid filter values
518
- types.uniq!
519
- exclude_types.uniq!
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
- private
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, also_delete: true)
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 basic_rest_get_or_delete(uri, also_delete: false)
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
- # Do the thing.
811
- response = http_get_or_delete(uri, headers, also_delete)
812
- update_rate_limit(response)
994
+ request = is_delete ?
995
+ Net::HTTP::Delete.new(url, headers) :
996
+ Net::HTTP::Get.new(url, headers)
813
997
 
814
- result = response.body() if response.class.body_permitted?
815
- raise Error::Http.new(response) if response.is_a? Net::HTTPClientError
998
+ response = http_operation(request)
816
999
 
817
- result_obj = JSON.parse(result)
1000
+ result = parse_json_gracefully(response.body)
818
1001
 
819
- if result.is_a?(Hash) && result_obj.has_key?("error")
820
- raise Error::Server.new(result_obj["error"])
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 [result_obj, link]
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
- def http_get_or_delete(uri, headers, is_delete)
831
- @logger.debug("#{is_delete ? "Deleting" : "Requesting"} #{uri} " +
832
- "(token: #{!!@token})")
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
- response = Net::HTTP.start(
835
- uri.hostname,
836
- uri.port,
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
- @logger.debug("Response: #{response}")
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 update_rate_limit(response)
864
- @rate_limit.limit = @rate_limit.remaining = @rate_limit.reset = nil
1076
+ def http_operation(request, output_handle: nil, rewinds: [])
1077
+ url = request.uri
865
1078
 
866
- @rate_limit.limit = response['X-RateLimit-Limit'].to_i
867
- @rate_limit.remaining = response['X-RateLimit-Remaining'].to_i
1079
+ while true
1080
+ http = Net::HTTP.new(url.hostname, url.port)
1081
+ http.use_ssl = (url.scheme == 'https')
868
1082
 
869
- reset = response['X-RateLimit-Reset']
870
- @rate_limit.reset = Time.iso8601(reset) if reset
1083
+ if @debug_http
1084
+ http.set_debug_output(STDERR)
1085
+ request['Accept-Encoding'] = 'identity'
1086
+ end
871
1087
 
872
- @logger.debug "Rate limit: #{rate_limit_desc}"
873
- end
1088
+ http.start do |http|
1089
+ @logger.debug("Request #{request}; (token: #{!!@token})")
874
1090
 
875
- # Retrieve the resource (assumed to be binary) at 'uri' and write
876
- # it to 'filename'. For now, returns false if the request
877
- # returned an error code and true on success.
878
- def basic_get_binary(uri, filename)
879
- headers = headers_for(:get)
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
- @logger.debug("Requesting #{uri} (token: #{!!@token})")
882
- Net::HTTP.get_response(uri, headers) {|response|
883
- @logger.debug("Response: #{response.code}")
1097
+ update_rate_limit(response)
884
1098
 
885
- update_rate_limit(response)
1099
+ #raise_http_exception_if_error(response)
886
1100
 
887
- if response.is_a? Net::HTTPClientError
888
- @logger.warn("Got response #{response} for #{uri}.")
889
- return false
890
- end
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
- @logger.debug("Writing body to #{filename}")
893
- File.open(filename, "wb") { |outfile|
894
- response.read_body { |chunk|
895
- @logger.debug(" Writing #{chunk.size} bytes...")
896
- outfile.write(chunk)
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
- return true
903
- end
1113
+ raise Error::Http.new(response)
1114
+ end
904
1115
 
905
- def parse_link_header(hdr)
906
- result = {}
907
- return result unless hdr # could be nil
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
- for link in hdr.split(', ')
910
- md = link.match(/^<([^>]+)>; rel="([^"]+)"/)
911
- assert{md}
1120
+ return response
1121
+ end
912
1122
 
913
- result[ md[2] ] = md[1]
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
- def basic_rest_post_or_put(uri, formdata, is_put: false)
921
- headers = headers_for(:post)
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
- result = response.body() if response.class.body_permitted?
931
- raise Error::Http.new(response) if response.is_a? Net::HTTPClientError
932
-
933
- result_obj = JSON.parse(result)
934
-
935
- if result.is_a?(Hash) && result_obj.has_key?("error")
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 http_post_form(url, headers, formdata, is_put)
945
- request = is_put ?
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
- http = Net::HTTP.new(url.hostname, url.port)
958
- http.use_ssl = (url.scheme == 'https')
1151
+ reset = response['X-RateLimit-Reset']
1152
+ @rate_limit.reset = Time.iso8601(reset) if reset
959
1153
 
960
- if @debug_http
961
- http.set_debug_output(STDERR)
962
- request['Accept-Encoding'] = 'identity'
963
- end
1154
+ @logger.debug "Rate limit: #{rate_limit_desc}"
1155
+ end
964
1156
 
965
- return http.start {|http|
966
- http.request(request)
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