# # The session class. This implements all of the API calls. # module Shep using Assert # Represents a connection to a Mastodon (or equivalent) server. # # @attr_reader [Logger] logger The logger object # @attr_reader [String] host The Server's hostname # @attr_reader [String] user_agent User-Agent string; frozen # # ## Conventions # # `fetch_*` methods retrieve a single Mastodon object, an `Entity` # subinstance. # # `each_*` methods retrieve multiple objects, also `Entity` # subinstances. If called with a block, the block is evaluated on # each item in turn and the block's result is ignored. Otherwise, # it returns an `Enumerator` which can be used in the usual ways. # # Some examples: # # # Evaluate a block on each status # session.each_status(account) { |status| do_thing(status) } # # # Retrieve the last 100 statuses in an array # statuses = session.each_status(account, limit: 100).to_a # # # Retrieve the last 200 statuses via an enumerator and do # # extra transformation on the result before collecting # # them in an array. # statuses = session.each_status(account, limit: 200) # .select{|status| BESTIES.include? status.account.username } # .map{|status| status.id} # .to_a # # The actual web API "paginates" the output. That is, it returns # the first 40 (or so) items and then provides a link to the next # chunk. Shep's `each_*` methods handle this for you automatically. # This means that unless you use `limit:`, the `each_*` methods will # retrieve **all** available items, at least until you reach the # rate limit (see below). # # Note that it is safe to leave an `each_*` methods block with # `break`, `return`, an exception, or any other such mechanism. # # The remaining Mastodon API methods will in some way modify the # state of the server and return an Entity subinstance on success. # # All API calls throw an exception on failure. # # ## Rate Limits # # Mastodon servers restrict the number of times you can use a # specific endpoint within a time period as a way to prevent abuse. # Shep provides several tools for handling these limits gracefully. # # 1. The method {rate_limit} will return a Struct that tells you how # many requests you have left and when the count is reset. # # 2. If a rate limit is exceeded, the method will throw an # {Error::RateLimit} exception instead of an ordinary # {Error::Http} exception. # # 3. If the Session is created with argument `rate_limit_retry:` set # to true, the Session will instead wait out the reset time and # try again. # # If you enable the wait-and-retry mechanism, you can also provide a # hook function (i.e. a thing that responds to `call`) via # constructor argument `retry_hook:`. This is called with one # argument, the result of {rate_limit} for the limited API endpoint, # immediately before Shep starts waiting for the limit to reset. # # The built-in wait time takes the callback's execution time into # account so it's possible to use the callback to do your own # waiting and use that time more productively. # # Alternately, all of the `each_*` methods have a `limit:` parameter # so it's easy to avoid making too many API calls and many have a # `max_id:` parameter that allows you to continue where you left off. # class Session attr_reader :logger, :host, :user_agent # Initialize a new {Session}. # # @param host [String] Hostname of the server # @param token [String] Bearer token; optional # # @param user_agent [String] User-Agent string to use # @param ua_comment [String] Comment part of User-Agent string # # @param rate_limit_retry [Boolean] Handle request limits by waiting # for the count to reset and trying # again. # # @param retry_hook [Proc] One-argument hook function to call # before waiting for the rate limit # to reset # # @param logger [Logger] The logger or mode; optional # @param debug_http [Boolean] Enable `Net::HTTP` debugging; # **insecure!** # # By default, the User-Agent header is set to the gem's # identifier, but may be overridden with the `user_agent` # parameter. It is your responsibility to make sure it is # formatted correctly. You can also append comment text to the # given User-Agent string with `ua_comment`; this lets you add a # comment to the default text. # # Parameter `logger` may be a `Logger` object, `nil`, or a # `Symbol` whose value is the name of one of the supported log # levels. In the latter case, a new Logger is created and set to # that level. If `nil` is given, a dummy `Logger` is created and # used. # # If `debug_http` is true, compression is disabled and the # transactions are sent to `STDERR` via # `Net::HTTP.set_debug_output`. # # **WARNING:** this opens a serious security hole and should not # be used in production. def initialize(host:, token: nil, user_agent: "ShepRubyGem/#{Shep::Version}", ua_comment: nil, rate_limit_retry: false, retry_hook: nil, logger: nil, debug_http: false) @host = host @token = token @logger = init_logger(logger) @user_agent = user_agent @user_agent += " #{ua_comment}" if ua_comment @user_agent.freeze @rate_limit_retry = rate_limit_retry @retry_hook = retry_hook @debug_http = debug_http @rate_limit = Struct.new(:limit, :remaining, :reset).new raise Error::Caller.new("retry_hook: must a callable or nil") unless @retry_hook == nil || @retry_hook.respond_to?(:call) end private def init_logger(logger_arg) result = nil if %i{debug error fatal info warn}.include? logger_arg result = Logger.new(STDOUT) result.send((logger_arg.to_s + '!').intern) elsif logger_arg.is_a? Logger result = logger_arg else result = Logger.new(nil) end return result end public # Return the rate limit information from the last REST request. # # The result is a Struct with the following fields: # # * limit Integer - Number of allowed requests per time period # * remaining Integer - Number of requests you have left # * reset Time - Future time when the limit resets # # Note that different types of operations have different rate # limits. For example, most endpoints can be called up to 300 # times within 5 minutes but no more than 30 media uploads are # allowed within a 30 minute time period. # # Note also that some Shep methods will perform multiple API # requests; this is only ever the rate limit information from the # latest of these. # # @see https://docs.joinmastodon.org/api/rate-limits/ # # @return [Struct.new(:limit, :remaining, :reset)] def rate_limit = @rate_limit.dup.freeze # Return a human-readable summary of the rate limit. # # @return [String] `rate_limit()`'s result as nicely-formatted # text def rate_limit_desc rem = (@rate_limit.remaining || '?').to_s lim = (@rate_limit.limit || '?').to_s reset = @rate_limit.reset ? (@rate_limit.reset - Time.now).round : '?' return "#{rem}/#{lim}, #{reset}s" end # # Account # # Return the Entity::Account object for the token we're using. # # Requires a token (obviously). # # @return [Entity::Account] # # @see https://docs.joinmastodon.org/methods/accounts/#verify_credentials def verify_credentials return rest_get('accounts/verify_credentials', Entity::Account, {}) end # Fetch user details by ID # # @param id [String] the ID code of the account # # @return [Entity::Account] # # @see https://docs.joinmastodon.org/methods/accounts/#get def fetch_account(id) return rest_get("accounts/#{id}", Entity::Account, {}) end # Fetch user details by username. # # The username must belong to a user on the current server. # # @param handle [String] the account's username with or without the # leading '@' character (e.g. @benoitmandelbot) # # # @return [Entity::Account, nil] The Account or nil if it can't be found. # # @see https://docs.joinmastodon.org/methods/accounts/#get def fetch_account_by_username(handle) return rest_get("accounts/lookup", Entity::Account, {acct: handle}) rescue Error::Http => oopsie # As a special case, return nil if the lookup fails return nil if oopsie.response.is_a?(Net::HTTPNotFound) raise oopsie end # Fetch an individual notification by ID. # # Requires a token with sufficient permissions. # # @param ntfn_id [String] the notification ID # # @return [Entity::Notification] # # @see https://docs.joinmastodon.org/methods/notifications/#get-one def fetch_notification(ntfn_id) = rest_get("notifications/#{ntfn_id}", Entity::Notification, {}) # Fetch a single status # # @return [Entity::Status] # # @see https://docs.joinmastodon.org/methods/statuses/#get def fetch_status(id) = rest_get("statuses/#{id}", Entity::Status, {}) # Fetch the context (parent and child status) of status at 'id' # # @return [Entity::Context] # # @see https://docs.joinmastodon.org/methods/statuses/#context def fetch_context(id) = rest_get("statuses/#{id}/context", Entity::Context, {}) # Fetch the editable source of status at id. # # Requires token. # # @return [Entity::StatusSource] # # @see https://docs.joinmastodon.org/methods/statuses/#source def fetch_status_src(id) = rest_get("statuses/#{id}/source", Entity::StatusSource, {}) # Fetch the given status and also any attached media. # # @param id [String] ID of the status to retrieve # # @param media_dir [String] Path to the download directory # # @param refetch [Boolean] Fetch the media even if it is already # present # # Media is downloaded into the given directory unless a file with # the expected name is already there (and `refetch` is not # `true`). # # Filenames are chosen by the function; the second return value (a # `Hash`) can be used to find them. Value order also corresponds # to the order of the returned `Status`'s `media_attachments` # field. # # Note that intermediate files unique temporary names while # downloading is in progress. This means it is safe to set # `refetch` to false even if a previous download attempt failed. # However, it is would be necessary to delete the intermediate # file, which has the suffic ".tmp". # # @return [Entity::Status, Hash] the Status and a Hash mapping the media # URL to the corresponding local file. # # @see https://docs.joinmastodon.org/methods/statuses/#get def fetch_status_with_media(id, media_dir = '.', refetch: true) status = fetch_status(id) media = {} status.media_attachments.each { |ma| outfile = File.join(media_dir, File.basename(ma.url.path)) if !refetch && File.exist?(outfile) @logger.info "Found '#{outfile}'; skipping." else tmp = File.join(media_dir, SecureRandom.uuid + '.tmp') begin basic_get_binary(ma.url, tmp) FileUtils.mv(tmp, outfile) rescue Error::Http => e FileUtils.rm(tmp, force: true) raise e end end media[ma.url.to_s] = outfile } return [status, media] end # Retrieve the follower list of an account. # # As of Mastodon 4.0, no longer requires a token. # # @param account_id [String] The account # @param limit [Integer] Maximum number of items to retrieve # # @yield [item] Optional; applied to each item # @yieldparam [Entity::Account] # # @return [Enumerator] if block is not given, otherwise self # # # @see https://docs.joinmastodon.org/methods/accounts/#followers def each_follower(account_id, limit: nil, &block) query = magically_get_caller_kwargs(binding, method(__method__)) return rest_get_seq("accounts/#{account_id}/followers", Entity::Account, query, block) end # Retrieve the list of accounts this account follows # # @param account_id [String] The account # @param limit [Integer] Maximum number of items to retrieve # # @yield [item] Optional; applied to each item # @yieldparam [Entity::Account] # # @return [Enumerator] if block is not given, otherwise self # # # @see https://docs.joinmastodon.org/methods/accounts/#following def each_following(account_id, limit: nil, &block) query = magically_get_caller_kwargs(binding, method(__method__)) return rest_get_seq("accounts/#{account_id}/following", Entity::Account, query, block) end # Retrieve the account's statuses # # @param account_id [String] The ID of the account # # @param limit [Integer] Maximum number of accounts to # retrieve # # @param max_id [String] retrieve results older than this ID. # # @param only_media [Boolean] If true, filter for statuses # with media # # @param exclude_replies [Boolean] If true, exclude replies # # @param exclude_reblogs [Boolean] If true, exclude boosts # # @param pinned [Boolean] If true, filter for pinned # statuses # # @param tagged [String] Filter for statuses containing the # given tag # # @yield [item] Optional; applied to each item # @yieldparam [Entity::Status] # # @return [Enumerator] if block is not given, otherwise self # # @see https://docs.joinmastodon.org/methods/accounts/#statuses def each_status(account_id, limit: nil, max_id: "", only_media: false, exclude_replies: false, exclude_reblogs: false, pinned: false, tagged: nil, &block) query = magically_get_caller_kwargs(binding, method(__method__)) rest_get_seq("accounts/#{account_id}/statuses", Entity::Status, query, block) end # Retrieve the instance's public timeline(s) # # May require a token depending on the instance's settings. # # @param limit [Integer] Max. items to retrieve. # # @param max_id [String] retrieve results older than this ID. # # @param local [Boolean] Retrieve only local statuses # # @param remote [Boolean] Retrieve only remote statuses # # @param only_media [Boolean] Retrieve only statuses with media # # # @yield [item] Optional; applied to each item # @yieldparam [Entity::Status] # # @return [Enumerator] if block is not given, otherwise self # # @see https://docs.joinmastodon.org/methods/timelines/#public def each_public_status(limit: nil, max_id: "", local: false, remote: false, only_media: false, &block) query = magically_get_caller_kwargs(binding, method(__method__)) rest_get_seq("timelines/public", Entity::Status, query, block) end # Retrieve a tag's timeline. # # The tag may either be a String (containing one hashtag) or an # Array containing one or more. If more than one hashtag is # given, all statuses containing **any** of the given hashtags are # retrieved. (This uses the `any[]` parameter in the API.) # # There is currently no check for contradictory tag lists. # # @param hashtag_s [String, Array] # Hashtag(s) to retrieve. # # @param limit [Integer] maximum number of items to retrieve # # @param max_id [String] return results older than this ID. # # @param local [Boolean] retrieve only local statuses # # @param remote [Boolean] retrieve only remote statuses # # @param only_media [Boolean] retrieve only media status # # @param all [Array] list of other tags that # must also be present. # # @param none [Array] list of tags that are excluded. # # @yield [item] block to apply to each Status; optional # @yieldparam [Entity::Status] # # @return [Enumerator] if block is not given, otherwise self # # @see https://docs.joinmastodon.org/methods/timelines/#tag def each_tag_status(hashtag_s, limit: nil, max_id: "", local: false, remote: false, only_media: false, all: [], none: [], &block) query = magically_get_caller_kwargs(binding, method(__method__)) any = [] if hashtag_s.is_a?(Array) hashtag_s = hashtag_s.dup hashtag = hashtag_s.shift any = hashtag_s else hashtag = hashtag_s end assert("Empty hashtag!") { hashtag && !hashtag.empty? } query[:any] = any unless any.empty? rest_get_seq("timelines/tag/#{hashtag}", Entity::Status, query, block) end # Retrieve each Entity::Status in the home timeline. # # Requires token. # # @param limit [Integer] maximum number of items to retrieve # # @param max_id [String] retrieve results older than this ID. # # @param local [Boolean] retrieve only local statuses # # @param remote [Boolean] retrieve only remote statuses # # @param only_media [Boolean] retrieve only media status # # # @yield [item] # # @yieldparam [Entity::Status] # # @return [Enumerator] if block is not given, otherwise self # # # @see https://docs.joinmastodon.org/methods/timelines/#home def each_home_status(limit: nil, local: false, max_id: "", remote: false, only_media: false, &block) query = magically_get_caller_kwargs(binding, method(__method__)) rest_get_seq("timelines/home", Entity::Status, query, block) end # Retrieve each Entity::Account that boosted the given status. # # @param limit [Integer] Maximum number of items to retrieve # # @yield [item] # # @yieldparam [Entity::Account] # # @return [Enumerator] if block is not given, otherwise self # # # @see https://docs.joinmastodon.org/methods/statuses/#reblogged_by def each_boost_acct(status_id, limit: nil, &block) query = magically_get_caller_kwargs(binding, method(__method__)) rest_get_seq("statuses/#{status_id}/reblogged_by", Entity::Account, query, block) end # Retrieve each account that favourited the given status. # # @param limit [Integer] Maximum number of items to retrieve # # @yield [item] # # @yieldparam [Entity::Account] # # @return [Enumerator] if block is not given, otherwise self # # # @see https://docs.joinmastodon.org/methods/statuses/#favourited_by def each_fave_acct(status_id, limit: nil, &block) query = magically_get_caller_kwargs(binding, method(__method__)) rest_get_seq("statuses/#{status_id}/favourited_by", Entity::Account, query, block) end # Retrieve each notification. # # Requires a bearer token. # # Notification types are indicated by of the following symbols: # # `:mention`, `:status`, `:reblog`, `:follow`, `:follow_request` # `:favourite`, `:poll`, `:update`, `:admin.sign_up`, or # `:admin.report` # # This method will throw an `Error::Caller` exception if an # unknown value is used. # # # @param types [Array] list of notifications types to # enumerate; others are ignoredn # # @param exclude_types [Array] types of notifications to exclude # # @param limit [Integer] Maximum number of items to retrieve # # @param account_id [String] Only retrieve notifications from # the account with this ID. # # @yield [item] Applied to each Notification # # @yieldparam [Entity::Notification] # # @return [Enumerator] if block is not given, otherwise self # # @see https://docs.joinmastodon.org/methods/notifications/#get def each_notification(types: [], exclude_types: [], limit: nil, account_id: nil, &block) allowed_notifications = %i{mention status reblog follow follow_request favourite poll update admin.sign_up admin.report} # Remove duplicates and convert strings to symbols [types, exclude_types].each{|param| param.map!{|item| item.intern} param.uniq! } # Now, ensure there are no incorrect notification types. (types + exclude_types).each{|filter| assert("Unknown notification type: #{filter}") { allowed_notifications.include?(filter.intern) } } query = magically_get_caller_kwargs(binding, method(__method__)) rest_get_seq("notifications", Entity::Notification, query, block) end # Post a status containing the given text at the specified # visibility with zero or more media attachments. # # visibility can be one of 'public', 'private', 'unlisted' or # 'direct'; these can be strings of symbols) # # media_ids is an array containing the ID strings of any media # that may need to be attached. # # @param visibility [Symbol] Status visibility; one of :public, # :public, :unlisted or :direct # # @param media_ids [Array] List of IDs of attached media items. # # @param spoiler_text [String] Content warning if non-empty string. # Also sets `sensitive` to true. # # @param language [String] ISO language code # # @return [Entity::Status] The new status. # # @see https://docs.joinmastodon.org/methods/statuses/#create def post_status(text, visibility: :private, media_ids: [], spoiler_text: "", language: "") raise Error::Caller.new("Invalid visibility: #{visibility}") unless %i{public unlisted private direct}.include? visibility.intern query = magically_get_caller_kwargs(binding, method(__method__)) query[:status] = text query[:sensitive] = true if spoiler_text && !spoiler_text.empty? # We need to convert to an array of keys and values rather than # a hash because passing an array argument requires duplicate # keys. This causes Net::HTTP to submit it as a multipart form, # but we can cope. formdata = formhash2array(query) return rest_post("statuses", Entity::Status, formdata) end # Update the status with the given Id. # # Requires token with sufficient permission for the account that # owns the status. # # Notionally, this method will change *all* of the affected status # parts each time it's invoked, passing the default parameter if # none is given. This is because it is unclear how the API # handles omitted fields so we just don't do that. (You can force # it to omit an argument by setting it to nil; this may or may not # work for you.) # # @param id [String] id of the status to edit # # @param status [String] new status text # # @param media_ids [Array] array of media object IDs # to attach to this status. # # @param spoiler_text [String] Sets or clears the content # warning. Non-empty value also # sets the `sensitive` field. # # @param language [String] The language of the status; # defaults to "en". (Apologies for # the anglocentrism; this should be # consistent.) # # @return [Entity::Status] the updated status # # @see https://docs.joinmastodon.org/methods/statuses/#edit def edit_status(id, status, media_ids: [], spoiler_text: "", language: "en") formhash = magically_get_caller_kwargs(binding, method(__method__), strip_ignorables: false) formhash[:status] = status formhash[:sensitive] = !!spoiler_text && !spoiler_text.empty? formdata = formhash2array(formhash) return rest_put("statuses/#{id}", Entity::Status, formdata) end # Upload the media contained in the file at 'path'. # # Requires token with sufficient permission for the account that # owns the status. # # @param path [String] Path to the media file. # # @param content_type [String] MIME type of the media attachment. # The default us usually all you need. # # @param description [String] The image description text. # # @param focus_x [Float] The horizontal coordinate of # the focus on a range of -1.0 # to 1.0. This is the point in # the image that will be the # center of the thumbnail. If # set, `focus_y:` must also be # set to a valid coordinate. # # @param focus_y [Float] The vertical coordinate of the focus. # # Note that Mastodon processes attachments asynchronously, so the # attachment may not be available for display when this method # returns. Posting an unprocessed status as an attachment works # as expected but it's unclear what happens between posting and # when the processing task completes. Usually, this shouldn't # matter to you. # # If a rate limit is reached during a call to this method and # `rate_limit_retry:` was set, the media file to upload should not # be touched in any way until the method returns. # # @return [Entity::MediaAttachment] The resulting MediaAttachment # object but without the URL. # # @see https://docs.joinmastodon.org/methods/media/#v2 def upload_media(path, content_type: nil, description: nil, focus_x: nil, focus_y: nil) formdata = [ ['filename', File.basename(path)], ['file', File.open(path, "rb"), {content_type: content_type}], ] formdata.push ['description', description] if description # Focus args are more exacting so we do some checks here. !!focus_x == !!focus_y or raise Error::Caller.new("Args 'focus_x/y' must *both* be set or unset.") if focus_x raise Error::Caller.new("focus_x/y not a float between -1 and 1") unless (focus_x.is_a?(Float) && focus_y.is_a?(Float) && focus_x >= -1.0 && focus_x <= 1.0 && focus_y >= -1.0 && focus_y <= 1.0) formdata.push ['focus', "#{focus_x},#{focus_y}"] end return rest_post("media", Entity::MediaAttachment, formdata, v2: true) end # Delete the status at ID. # # @return [Entity::Status] the deleted status # # @see https://docs.joinmastodon.org/methods/statuses/#delete def delete_status(id) = rest_delete("statuses/#{id}", Entity::Status) # Dismiss the notification with the given ID. # # Warning: due to the complexity involved in repeatably sending a # notification to an account, there is limited test coverage for # this method. # # @see https://docs.joinmastodon.org/methods/notifications/#dismiss def dismiss_notification(id) url = rest_uri("notifications/#{id}/dismiss", {}) basic_rest_post_or_put(url, {}) return nil end # # High(ish)-level REST support # private # Extract all of the keyword arguments and their values from the # caller's context. (The context is 'cbinding', which must be a # call to method 'cmethod'.) # # The results are returned as a hash mapping keyword to value. # # If 'strip_ignorables' is true, all key/value pairs whose value is # deemed to be equivalent to omitting the pair entirely are # removed. (We mostly do this so that caller's definition can # signal the expected type of the value, but it's sometimes the # wrong thing.) def magically_get_caller_kwargs(cbinding, cmethod, strip_ignorables: true) ignorables = Set.new([0, false, "", nil, []]) kwargs = {} for type, name in cmethod.parameters if type == :key || type == :keyreq arg = cbinding.local_variable_get(name) next if strip_ignorables && ignorables.include?(arg) kwargs[name] = arg end end return kwargs end def rest_get(path, result_klass, query_args) uri = rest_uri(path, query_args) result_obj, _ = basic_rest_get_or_delete(uri) return result_klass.from(result_obj) end def rest_delete(path, result_klass) uri = rest_uri(path, {}) result_obj, _ = basic_rest_get_or_delete(uri, is_delete: true) return result_klass.from(result_obj) end # We do the block+enumerator thing here as a placeholder for # pagination. def rest_get_seq(path, result_klass, query_args, block) return Enumerator.new{ |y| rest_get_seq(path, result_klass, query_args, proc{|item| y << item}) } unless block uri = rest_uri(path, query_args) limit = query_args[:limit] while true result_obj, link = basic_rest_get_or_delete(uri) assert{result_obj.is_a? Array} link_next = link["next"] if limit limit -= result_obj.size link_next = nil if limit <= 0 # set break condition if we're done # Discard extras while limit < 0 result_obj.pop limit += 1 end end result_obj .map{|obj| result_klass.from(obj)} .each(&block) break unless link_next uri = URI(link_next) end return self end def rest_post(path, result_klass, formdata, v2: false) url = rest_uri(path, {}, v2: v2) result = basic_rest_post_or_put(url, formdata) return result_klass.from(result) end def rest_put(path, result_klass, formdata, v2: false) url = rest_uri(path, {}, v2: v2) result = basic_rest_post_or_put(url, formdata, is_put: true) return result_klass.from(result) end # # Low(ish)-level REST support # # Given a hash of query arguments, return an array of key-value # pairs. # # This is slightly more complex than using 'to_a' because if the # value is an array, each array element becomes the value of a # key-value pair where the key is the array name with '[]' # appended. # # E.g. `foo : [1,2]` -> `["foo[]", "1"], ["foo[]", "2"]` # def formhash2array(query_arg_hash) result = query_arg_hash.to_a.reject{|key, value| value.is_a?(Array)} query_arg_hash .select{|key, value| value.is_a?(Array)} .each{|key, value| value.each{|v| result.push ["#{key}[]", v.to_s] } } # And ensure the keys and values are all strings result.map!{|k,v| [k.to_s, v.to_s]} return result end def rest_uri(path, query_args, v2: false) args = formhash2array(query_args) .map{|pair| pair.join("=")} .join('&') args = "?#{args}" unless args.empty? # Most of the API is v1 but a few calls have the v2 prefix. version = v2 ? "v2" : "v1" return URI("https://#{@host}/api/#{version}/#{path}#{args}") end def parse_link_header(hdr) result = {} return result unless hdr # could be nil for link in hdr.split(', ') md = link.match(/^<([^>]+)>; rel="([^"]+)"/) assert{md} result[ md[2] ] = md[1] end return result end # Perform a GET or DELETE operation. (They have mostly the same # structure, so we combine the functionality here.) def basic_rest_get_or_delete(url, is_delete: false) headers = headers_for(:get) request = is_delete ? Net::HTTP::Delete.new(url, headers) : Net::HTTP::Get.new(url, headers) response = http_operation(request) result = parse_json_gracefully(response.body) if result.is_a?(Hash) && result.has_key?("error") raise Error::Server.new(result["error"]) end link = parse_link_header(response["link"]) return [result, link] end # Retrieve the resource (assumed to be binary) at 'uri' and write # it to 'filename'. For now, returns false if the request # returned an error code and true on success. def basic_get_binary(url, filename) request = Net::HTTP::Get.new(url, headers_for(:get)) @logger.debug("Output file is #{filename}") File.open(filename, "wb") { |outfile| http_operation(request, output_handle: outfile) } @logger.debug("Done (#{filename})") end def basic_rest_post_or_put(url, formdata, is_put: false) headers = headers_for(:post) # Select request type request = is_put ? Net::HTTP::Put.new(url, headers) : Net::HTTP::Post.new(url, headers) # Set the parameters enctype = 'multipart/form-data' if formdata.is_a?(Array) enctype = 'application/x-www-form-urlencoded' if formdata.is_a?(Hash) enctype or raise Error::Caller.new("Unknown formdate type: #{formdata.class}") request.set_form(formdata, enctype) # IO-like devices need to be rewound before retrying the # request. Since Request doesn't let us have access to the # formdata objects, we pass them separately. rewinds = formdata.to_a .map{|name, value| value} .select{|value| value.respond_to?(:rewind)} # Do the deed response = http_operation(request, rewinds: rewinds) result = parse_json_gracefully(response.body) if result.is_a?(Hash) && result.has_key?("error") raise Error::Server.new(result["error"]) end return result end def headers_for(method) headers = {} headers["Authorization"] = "Bearer #{@token}" if @token headers['User-Agent'] = @user_agent if method == :post extras = { "Indempotency-Key": SecureRandom.uuid, "Content-Type": "application/json", } headers.update extras end return headers end def http_operation(request, output_handle: nil, rewinds: []) url = request.uri while true http = Net::HTTP.new(url.hostname, url.port) http.use_ssl = (url.scheme == 'https') if @debug_http http.set_debug_output(STDERR) request['Accept-Encoding'] = 'identity' end http.start do |http| @logger.debug("Request #{request}; (token: #{!!@token})") # We have to invoke 'request' with a block argument to get the # response because that's the only way we can get at it before # it's downloaded the entire response into RAM. http.request(request) do |response| @logger.debug("Response: #{response}") update_rate_limit(response) #raise_http_exception_if_error(response) if response.is_a?(Net::HTTPClientError) # Finish any body reading that may have been in # progress. response.read_body() # Special case: too many requests. We may throw an # exception or wait until it resets and try again. if response.is_a?(Net::HTTPTooManyRequests) handle_rate_limit_reached(response) next # if we get here, we're trying again end raise Error::Http.new(response) end # read_body will write the response body to output_handle if # it's a handle or keep it internally if it's nil. response.read_body(output_handle) return response end # If we get here, we're going to try again, so we need to # rewind any IO-like header value. rewinds.each{|io| io.rewind} end end end def handle_rate_limit_reached(response) raise Error::RateLimit.new(response) unless @rate_limit_retry # Call the retry hook first. @retry_hook.call(self.rate_limit().dup) if @retry_hook # Now, wait out any remaining elapsed time. while true delay = (@rate_limit.reset - Time.now) + 2 break if delay <= 0 @logger.info "Sleeping for #{delay.round} seconds." sleep delay end end def update_rate_limit(response) @rate_limit.limit = @rate_limit.remaining = @rate_limit.reset = nil @rate_limit.limit = response['X-RateLimit-Limit'].to_i @rate_limit.remaining = response['X-RateLimit-Remaining'].to_i reset = response['X-RateLimit-Reset'] @rate_limit.reset = Time.iso8601(reset) if reset @logger.debug "Rate limit: #{rate_limit_desc}" end # Parse json_txt without throwing an exception. If the result # isn't valid, return a Mastodn-style error object instead. def parse_json_gracefully(json_txt) return JSON.parse(json_txt) rescue JSON::JSONError return {"error" => "Did not receive a valid JSON object."} end end end