···11+## Unreleased
22+33+The "really niche bugfix" edition:
44+55+* don't stop fetching in `fetch_all` if an empty page is returned but the cursor is not nil; it's technically allowed for the server to return an empty page but still have more data to send
66+* in `post_request`, don't set Content-Type to "application/json" if the data sent is a string or nil (it might cause an error in some cases, like when uploading some binary content)
77+* handle the (somewhat theoretical but possible) case where an access token is not a JWT but just some opaque blob โ in that case, Minisky will now not throw an error trying to parse it, but just treat it as "unknown" and will not try to refresh it
88+ - note: at the moment Minisky will not catch the "token expired" error and refresh the token automatically in such scenario
99+* allow connecting to non-HTTPS servers (e.g. `http://localhost:3000`)
1010+* allow making unauthenticated clients with custom classes by returning `nil` from `#config`; custom clients with a config that's missing an `id` or `pass` are treated as an error
1111+* deprecate logging in using an email address in the `id` field โ `createSession` accepts such identifier, but unlike with handle or DID, there's no way to use it to look up the DID document and PDS location if we wanted to
1212+* fixed URL query params in POST requests on Ruby 2.x
1313+* marked `Minisky#active_repl?` method as private
1414+1515+Also added YARD API documentation for most of the code.
1616+1717+## [0.5.0] - 2024-12-27 ๐
1818+1919+* `host` param in the initializer can be passed with a `https://` prefix (useful if you're passing it directly from a DID document, e.g. using DIDKit)
2020+* added validation of the `method` parameter in request calls: it needs to be either a proper NSID, or a full URL as a string or a URI object
2121+* added new optional `params` keyword argument in `post_request`, which lets you append query parameters to the URL if a POST endpoint requires passing them this way (e.g. `uploadVideo`)
2222+* `default_progress` is set by default to show progress using dots (`.`) if Minisky is loaded inside an IRB or Pry context
2323+* when experimenting with Minisky in the console, you can now skip the `field:` parameter to `fetch_all` if you don't remember the expected key name in the response, and the method will make a request and return an error which tells you the list of available keys
2424+* added `access_token_expired?` helper method
2525+* moved `token_expiration_date` to public methods
2626+* `check_access` now returns a result symbol: `:logged_in`, `:refreshed` or `:ok`
2727+* fixed `method_missing` setter on `User`
2828+2929+## [0.4.0] - 2024-03-31 ๐ฃ
3030+3131+* allow passing non-JSON body to requests (e.g. when uploading blobs)
3232+* allow passing custom headers to requests, including overriding `Content-Type`
3333+* fixed error when the response is success but not JSON (e.g. an empty body like in deleteRecord)
3434+* allow passing options to the client in the initializer
3535+* aliased `default_progress` setting as `progress`
3636+* added `base64` dependency explicitly to the gemspec โ fixes a warning in Ruby 3.3, since it will be extracted as an optional gem in 3.4
3737+3838+## [0.3.1] - 2023-10-10
3939+4040+* fixed Minisky not working on Ruby 2.x
4141+4242+## [0.3.0] - 2023-10-05
4343+4444+* authentication improvements & changes:
4545+ - Minisky now automatically manages access tokens, calling `check_access` manually is not necessary (set `auto_manage_tokens` to `false` to disable this)
4646+ - `check_access` now just checks token's expiry time instead of making a request to `getSession`
4747+ - added `send_auth_headers` option โ set to `false` to not set auth header automatically, which is the default
4848+ - removed default config file name โ explicit file name is now required
4949+ - Minisky can now be used in unauthenticated mode โ pass `nil` as the config file name
5050+ - added `reset_tokens` helper method
5151+* refactored response handling โ typed errors are now raised on non-success response status
5252+* `user` wrapper can also be used for writing fields to the config
5353+* improved error handling
5454+155## [0.2.0] - 2023-09-02
256357* more consistent handling of parameters in the main methods:
···1367* renamed `ident` field in the config hash to `id`
1468* config is now accessed in `Requests` from the client object as a `config` property instead of `@config` ivar
1569* config fields are exposed as a `user` wrapper object, e.g. `user.did` delegates to `@config['did']`
1616-7070+1771## [0.1.0] - 2023-09-01
18721973- extracted most code to a `Requests` module that can be included into a different client class with custom config handling
···24782579## [0.0.1] - 2023-08-30
26802727-Initial release - extracted from original gist:
8181+Initial release โ extracted from original gist:
28822983- logging in and refreshing the token
3084- making GET & POST requests
···11The zlib License
2233-Copyright (c) 2023 Jakub Suder
33+Copyright (c) 2026 Jakub Suder
4455This software is provided 'as-is', without any express or implied
66warranty. In no event will the authors be held liable for any damages
+78-25
README.md
···11-# Minisky
11+# Minisky ๐ค
2233Minisky is a minimal client of the Bluesky (ATProto) API. It provides a simple API client class that you can use to log in to the Bluesky API and make any GET and POST requests there. It's meant to be an easy way to start playing and experimenting with the AT Protocol API.
44+55+This is designed as a low-level XRPC client library - it purposefully does not include any convenience methods like "get posts" or "get profile" etc., it only provides base components that you could use to build a higher level API.
66+77+> [!NOTE]
88+> Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
49510611## Installation
71288-To use Minisky, you need a reasonably new version of Ruby (2.6+). Such version should be preinstalled on macOS Big Sur and above and some Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux.
1313+To use Minisky, you need a reasonably new version of Ruby โ it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
9141015To install the Minisky gem, run the command:
11161217 [sudo] gem install minisky
13181414-Or alternatively, add it to the `Gemfile` file for Bundler:
1919+Or add it to your app's `Gemfile`:
15201616- gem 'minisky', '~> 0.2'
2121+ gem 'minisky', '~> 0.5'
172218231924## Usage
20252121-First, you need to create a `.yml` config file (by default, `bluesky.yml`) with the authentication data. It should look like this:
2626+All calls to the XRPC API are made through an instance of the `Minisky` class. There are two ways to use the library: with or without authentication.
2727+2828+2929+### Unauthenticated access
3030+3131+You can access parts of the API anonymously without any authentication. This currently includes: read-only `com.atproto.*` routes on the PDS (user's data server) and most read-only `app.bsky.*` routes on the AppView server.
3232+3333+This allows you to do things like:
3434+3535+- look up specific records or lists of all records of a given type in any account (in their raw form)
3636+- look up profile information about any account
3737+- load complete threads or users' profile feeds from the AppView
3838+3939+To use Minisky this way, create a `Minisky` instance, passing the API hostname string and `nil` as the configuration in the arguments. Use the hostname `api.bsky.app` or `public.api.bsky.app` for the AppView, or a PDS hostname for the `com.atproto.*` raw data endpoints:
4040+4141+```rb
4242+require 'minisky'
4343+4444+bsky = Minisky.new('api.bsky.app', nil)
4545+```
4646+4747+> [!NOTE]
4848+> To call PDS endpoints like `getRecord` or `listRecords`, you need to connect to the PDS of the user whose data you're loading, not to yours (unless it's the same one). Alternatively, you can use the `bsky.social` "entryway" PDS hostname for any Bluesky-hosted accounts, but this will not work for self-hosted accounts.
4949+>
5050+> To look up the PDS hostname of a user given their handle or DID, you can use the [didkit](https://tangled.org/mackuba.eu/didkit) library.
5151+>
5252+> For the AppView, `api.bsky.app` connects directly to Bluesky's AppView, and `public.api.bsky.app` to a version with extra caching that will usually be faster.
5353+5454+5555+### Authenticated access
5656+5757+To use the complete API including posting or reading your home feed, you need to log in using your account info and get an access token which will be added as an authentication header to all requests.
5858+5959+First, you need to create a `.yml` config file with the authentication data, e.g. `bluesky.yml`. It should look like this:
22602361```yaml
2462id: my.bsky.username
···27652866The `id` can be either your handle, or your DID, or the email you've used to sign up. It's recommended that you use the "app password" that you can create in the settings instead of your main account password.
29676868+> [!NOTE]
6969+> Bluesky has recently implemented OAuth, but Minisky doesn't support it yet - it will be added in a future version. App passwords should still be supported for a fairly long time.
7070+3071After you log in, this file will also be used to store your access & request tokens and DID. The data in the config file can be accessed through a `user` wrapper property that exposes them as methods, e.g. the password is available as `user.pass` and the DID as `user.did`.
31723232-Next, create the Minisky client instance, passing the server name (at the moment there is only one server at `bsky.social`, but there will be more once federation support goes live):
7373+Next, create the Minisky client instance, passing your PDS hostname (for Bluesky-hosted PDSes, you can use either `bsky.social` or your specific PDS like `amanita.us-east.host.bsky.network`) and the name of the config file:
33743475```rb
3576require 'minisky'
36773737-bsky = Minisky.new('bsky.social')
3838-bsky.check_access
7878+bsky = Minisky.new('bsky.social', 'bluesky.yml')
3979```
40804141-`check_access` will check if an access token is saved, if not - it will log you in using the login & password, otherwise it will check if the token is still valid and refresh it if needed.
8181+Minisky automatically manages your access and refresh tokens - it will first log you in using the login & password, and then use the refresh token to update the access token before the request when it expires.
8282+8383+8484+### Making requests
42854343-Now, you can make requests to the Bluesky API using `get_request` and `post_request`:
8686+With a `Minisky` client instance, you can make requests to the Bluesky API using `get_request` and `post_request`:
44874588```rb
4646-bsky.get_request('com.atproto.repo.listRecords', {
8989+json = bsky.get_request('com.atproto.repo.listRecords', {
4790 repo: bsky.user.did,
4891 collection: 'app.bsky.feed.like'
4992})
50939494+json['records'].each do |r|
9595+ puts r['value']['subject']['uri']
9696+end
9797+5198bsky.post_request('com.atproto.repo.createRecord', {
5299 repo: bsky.user.did,
53100 collection: 'app.bsky.feed.post',
54101 record: {
55102 text: "Hello world!",
5656- createdAt: Time.now.iso8601
103103+ createdAt: Time.now.iso8601,
104104+ langs: ["en"]
57105 }
58106})
59107```
601086161-The requests use the saved access token for authentication automatically. You can also pass `auth: false` or `auth: nil` to not send any authentication headers, or `auth: sometoken` to use a specific other token.
109109+In authenticated mode, the requests use the saved access token for auth headers automatically. You can also pass `auth: false` or `auth: nil` to not send any authentication headers for a given request, or `auth: sometoken` to use a specific other token. In unauthenticated mode, sending of auth headers is disabled.
621106363-The third useful method you can use is `#fetch_all`, which loads multiple paginated responses and collects all returned items on a single list (you need to pass the name of the field that contains the items in the response). Optionally, you can also specify a limit of pages to load as `max_pages: n`, or a break condition `break_when` to stop fetching when any item matches it. You can use it to e.g. to fetch all of your posts from the last 30 days, but not earlier:
111111+The third useful method you can use is `#fetch_all`, which loads multiple paginated responses and collects all returned items on a single list (you need to pass the name of the field that contains the items in the response). Optionally, you can also specify a limit of pages to load as `max_pages: n`, or a break condition `break_when` to stop fetching when any item matches it. You can use it to e.g. to fetch all of your posts from the last 30 days but not earlier:
6411265113```rb
66114time_limit = Time.now - 86400 * 30
671156868-bsky.fetch_all('com.atproto.repo.listRecords',
116116+posts = bsky.fetch_all('com.atproto.repo.listRecords',
69117 { repo: bsky.user.did, collection: 'app.bsky.feed.post' },
70118 field: 'records',
71119 max_pages: 10,
···75123There is also a `progress` option you can use to print some kind of character for every page load. E.g. pass `progress: '.'` to print dots as the pages are loading:
7612477125```rb
7878-bsky.fetch_all('com.atproto.repo.listRecords',
126126+likes = bsky.fetch_all('com.atproto.repo.listRecords',
79127 { repo: bsky.user.did, collection: 'app.bsky.feed.like' },
80128 field: 'records',
81129 progress: '.')
···87135.................
88136```
89137138138+You can find more examples on the [examples page](https://ruby.sdk.blue/examples/) on [ruby.sdk.blue](https://ruby.sdk.blue).
139139+140140+90141## Customization
911429292-The `Minisky` client currently supports one configuration option:
143143+The `Minisky` client currently supports such configuration options:
144144+145145+- `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `.` when in an interactive console, `nil` otherwise)
146146+- `send_auth_headers` - whether auth headers should be added by default (default: `true` in authenticated mode)
147147+- `auto_manage_tokens` - whether access tokens should be generated and refreshed automatically when needed (default: `true` in authenticated mode)
148148+149149+In authenticated mode, you can disable the `send_auth_headers` option and then explicitly add `auth: true` to specific requests to include a header there.
931509494-- `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `nil`)
151151+You can also disable the `auto_manage_tokens` option - in this case you will need to call the `#check_access` method before a request to refresh a token if needed, or alternatively, call either `#login` or `#perform_token_refresh`.
951529696-When creating the `Minisky` instance, you can pass a name of the YAML config file to use instead of the default:
971539898-```rb
9999-bsky = Minisky.new('bsky.social', 'config/access.yml')
100100-```
154154+### Using your own class
101155102102-Alternatively, instead of using the `Minisky` class, you can make your own class that includes the `Minisky::Requests` module and provides a different way to load & save the config, e.g. from a JSON file:
156156+Instead of using the `Minisky` class, you can also make your own class that includes the `Minisky::Requests` module and provides a different way to load & save the config, e.g. from a JSON file:
103157104158```rb
105159class BlueskyClient
···126180127181```rb
128182bsky = BlueskyClient.new('config/access.json')
129129-bsky.check_access
130183bsky.get_request(...)
131184```
132185···139192140193## Credits
141194142142-Copyright ยฉ 2023 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
195195+Copyright ยฉ 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
143196144197The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
145198
+20
lib/minisky/compat.rb
···11+require_relative 'minisky'
22+33+class Minisky
44+55+ #
66+ # Versions of {Requests#get_request} & {Requests#post_request} that work on Ruby 2.x.
77+ #
88+99+ module Ruby2Compat
1010+ def get_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
1111+ params ||= kwargs unless kwargs.empty?
1212+ super(method, params, auth: auth, headers: headers)
1313+ end
1414+1515+ def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil, **kwargs)
1616+ data ||= kwargs unless kwargs.empty?
1717+ super(method, data, auth: auth, headers: headers, params: params)
1818+ end
1919+ end
2020+end
+122
lib/minisky/errors.rb
···11+require_relative 'minisky'
22+33+class Minisky
44+55+ #
66+ # Common base error class for Minisky errors.
77+ #
88+ class Error < StandardError
99+ end
1010+1111+ #
1212+ # Raised when a required token or credentials are missing or invalid.
1313+ #
1414+ class AuthError < Error
1515+ end
1616+1717+ #
1818+ # Raised when the API returns an error status code.
1919+ #
2020+ class BadResponse < Error
2121+2222+ # @return [Integer] HTTP status code
2323+ attr_reader :status
2424+2525+ # @return [String, Hash] response data (JSON hash or string)
2626+ attr_reader :data
2727+2828+ # @param status [Integer] HTTP status code
2929+ # @param status_message [String] HTTP status message
3030+ # @param data [Hash, String] response data (JSON hash or string)
3131+ #
3232+ def initialize(status, status_message, data)
3333+ @status = status
3434+ @data = data
3535+3636+ message = if error_message
3737+ "#{status} #{status_message}: #{error_message}"
3838+ else
3939+ "#{status} #{status_message}"
4040+ end
4141+4242+ super(message)
4343+ end
4444+4545+ # @return [String, nil] machine-readable error code from the response data
4646+ def error_type
4747+ @data['error'] if @data.is_a?(Hash)
4848+ end
4949+5050+ # @return [String, nil] human-readable error message from the response data
5151+ def error_message
5252+ @data['message'] if @data.is_a?(Hash)
5353+ end
5454+ end
5555+5656+ #
5757+ # Raised when the API returns a client error status code (4xx).
5858+ #
5959+ class ClientErrorResponse < BadResponse
6060+ end
6161+6262+ #
6363+ # Raised when the API returns a server error status code (5xx).
6464+ #
6565+ class ServerErrorResponse < BadResponse
6666+ end
6767+6868+ #
6969+ # Raised when the API returns an error indicating that the access or request
7070+ # token that was passed in the header is expired.
7171+ #
7272+ class ExpiredTokenError < ClientErrorResponse
7373+ end
7474+7575+ #
7676+ # Raised when the API returns a redirect status code (3xx). Minisky doesn't
7777+ # currently follow any redirects.
7878+ #
7979+ class UnexpectedRedirect < BadResponse
8080+8181+ # @return [String] value of the "Location" header
8282+ attr_reader :location
8383+8484+ # @param status [Integer] HTTP status code
8585+ # @param status_message [String] HTTP status message
8686+ # @param location [String] value of the "Location" header
8787+ #
8888+ def initialize(status, status_message, location)
8989+ super(status, status_message, { 'message' => "Unexpected redirect: #{location}" })
9090+ @location = location
9191+ end
9292+ end
9393+9494+ #
9595+ # Raised by {Requests#fetch_all} when the `field` parameter isn't set.
9696+ #
9797+ # The message of the exception lists the fields available in the first fetched page.
9898+ #
9999+ # @example Making a request in the console with empty `field`
100100+ # sky = Minisky.new('public.api.bsky.app', nil)
101101+ # # => #<Minisky:0x0000000120f5f6b0 @host="public.api.bsky.app", ...>
102102+ #
103103+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' })
104104+ # # ./lib/minisky/requests.rb:270:in 'block in Minisky::Requests#fetch_all':
105105+ # # Field parameter not provided; available fields: ["followers"] (Minisky::FieldNotSetError)
106106+ #
107107+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' }, field: 'followers')
108108+ # # => .....
109109+ #
110110+ class FieldNotSetError < Error
111111+112112+ # @return [Array<String>] list of fields in the response data
113113+ attr_reader :fields
114114+115115+ # @param fields [Array<String>] list of fields in the response data
116116+ #
117117+ def initialize(fields)
118118+ @fields = fields
119119+ super("Field parameter not provided; available fields: #{@fields.inspect}")
120120+ end
121121+ end
122122+end
+70-5
lib/minisky/minisky.rb
···11require 'yaml'
2233+#
44+# The default API client class for making requests to AT Protocol servers. Can be used
55+# with authentication โ with the credentials stored in a YAML file โ or without it, for
66+# unauthenticated requests only (by passing `nil` as the config file name).
77+#
88+# @example Authenticated client
99+# # Expects a config.yml file like:
1010+# #
1111+# # id: test.example.com
1212+# # pass: secret7
1313+# #
1414+# # "id" can be a handle or a DID.
1515+#
1616+# sky = Minisky.new('eurosky.social', 'config.yml')
1717+#
1818+# feed = sky.get_request('app.bsky.feed.getTimeline', { limit: 100 })
1919+#
2020+# @example Unauthenticated client
2121+# sky = Minisky.new('public.api.bsky.app', nil, progress: '*')
2222+#
2323+# follows = sky.get_request('app.bsky.graph.getFollows',
2424+# { actor: 'atproto.com', limit: 100 },
2525+# field: 'follows'
2626+# )
2727+#
2828+329class Minisky
44- DEFAULT_CONFIG_FILE = 'bluesky.yml'
3030+3131+ # @return [String] the hostname (or base URL) of the server
3232+ attr_reader :host
53366- attr_reader :host, :config
3434+ # @return [Hash] loaded contents of the config file
3535+ attr_reader :config
73688- def initialize(host, config_file = DEFAULT_CONFIG_FILE)
3737+ # Creates a new client instance.
3838+ #
3939+ # @param host [String] the hostname (or base URL) of the server
4040+ # @param config_file [String, nil] path to the YAML config file, or `nil` for unauthenticated client
4141+ # @param options [Hash] option properties to set on the new instance (see {Minisky::Requests} properties)
4242+ #
4343+ # @raise [AuthError] if the config file is missing an ID or password
4444+ #
4545+ def initialize(host, config_file, options = {})
946 @host = host
1047 @config_file = config_file
1111- @config = YAML.load(File.read(@config_file))
4848+4949+ if @config_file
5050+ @config = YAML.load(File.read(@config_file))
5151+5252+ if user.id.nil? || user.pass.nil?
5353+ raise AuthError, "Missing user id or password in the config file #{@config_file}"
5454+ end
5555+ else
5656+ @config = nil
5757+ end
5858+5959+ if active_repl?
6060+ @default_progress = '.'
6161+ end
6262+6363+ if options
6464+ options.each do |k, v|
6565+ self.send("#{k}=", v)
6666+ end
6767+ end
1268 end
13691470 def save_config
1515- File.write(@config_file, YAML.dump(@config))
7171+ File.write(@config_file, YAML.dump(@config)) if @config_file
7272+ end
7373+7474+7575+ private
7676+7777+ def active_repl?
7878+ return true if defined?(IRB) && IRB.respond_to?(:CurrentContext) && IRB.CurrentContext
7979+ return true if defined?(Pry) && Pry.respond_to?(:cli) && Pry.cli
8080+ false
1681 end
1782end
1883
+437-31
lib/minisky/requests.rb
···11require_relative 'minisky'
22+require_relative 'errors'
2344+require 'base64'
35require 'json'
46require 'net/http'
55-require 'open-uri'
77+require 'time'
68require 'uri'
79810class Minisky
911 class User
1012 def initialize(config)
1113 @config = config
1414+ end
1515+1616+ def has_credentials?
1717+ !!(id && pass)
1218 end
13191420 def logged_in?
1521 !!(access_token && refresh_token)
1622 end
17231818- def method_missing(name)
1919- @config[name.to_s]
2424+ def method_missing(name, *args)
2525+ if name.to_s.end_with?('=')
2626+ @config[name.to_s.chop] = args[0]
2727+ else
2828+ @config[name.to_s]
2929+ end
2030 end
2131 end
22323333+ # Regexp for NSID identifiers, used in lexicon names for record collection and API endpoints
3434+ NSID_REGEXP = /^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z]{0,61}[a-zA-Z])?)$/
3535+3636+ #
3737+ # This module contains most of the Minisky code for making HTTP requests and managing
3838+ # authentication tokens. The module is included into the {Minisky} API client class and you'll
3939+ # normally use it through that class, but you can also include it into your custom class if you
4040+ # want to implement the data storage differently than using a local YAML file as {Minisky} does.
4141+ #
4242+2343 module Requests
4444+4545+ # A character to print before each request in {#fetch_all} as a progress indicator.
4646+ # Can also be passed explicitly instead or overridden using the `progress:` parameter.
4747+ # Default is `'.'` when running inside IRB, and `nil` otherwise.
4848+ #
4949+ # @return [String, nil]
5050+ #
2451 attr_accessor :default_progress
25525353+ attr_writer :send_auth_headers
5454+ attr_writer :auto_manage_tokens
5555+5656+ # Tells whether to set authentication headers automatically (default: true if there
5757+ # is a user config).
5858+ #
5959+ # If false, you will need to pass `auth: 'sometoken'` explicitly to requests that
6060+ # require authentication.
6161+ #
6262+ # @return [Boolean] whether to set authentication headers in requests
6363+ #
6464+ def send_auth_headers
6565+ instance_variable_defined?('@send_auth_headers') ? @send_auth_headers : (config != nil)
6666+ end
6767+6868+ # Tells whether the library should manage the access & refresh tokens automatically
6969+ # for you (default: true if there is a user config).
7070+ #
7171+ # If true, {#check_access} is called before each request to make sure that there is a
7272+ # fresh access token available; if false, you will need to call {#log_in} and
7373+ # {#perform_token_refresh} manually when needed.
7474+ #
7575+ # @return [Boolean] whether to automatically manage access tokens
7676+ #
7777+ def auto_manage_tokens
7878+ instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens : (config != nil)
7979+ end
8080+8181+ alias progress default_progress
8282+ alias progress= default_progress=
8383+2684 def base_url
2727- @base_url ||= "https://#{host}/xrpc"
8585+ if host.include?('://')
8686+ host.chomp('/') + '/xrpc'
8787+ else
8888+ "https://#{host}/xrpc"
8989+ end
2890 end
29913092 def user
3131- @user ||= User.new(config)
9393+ @user ||= config && User.new(config)
3294 end
33953434- def get_request(method, params = nil, auth: true)
3535- headers = authentication_header(auth)
3636- url = URI("#{base_url}/#{method}")
9696+ # Sends a GET request to the service's API.
9797+ #
9898+ # @param method [String, URI] an XRPC endpoint name or a full URL
9999+ # @param params [Hash, nil] query parameters
100100+ #
101101+ # @param auth [Boolean, String]
102102+ # boolean value which tells whether to send an auth header with the access token or not,
103103+ # or an explicit bearer token to use
104104+ # @param headers [Hash, nil]
105105+ # additional headers to include
106106+ #
107107+ # @return [Hash, String] parsed JSON hash for JSON responses, or raw response body otherwise
108108+ #
109109+ # @raise [ArgumentError] if method name is invalid
110110+ # @raise [BadResponse] if the HTTP response has an error status code
111111+ # @raise [AuthError]
112112+ # - if logging in is required, but login or password isn't provided
113113+ # - if token refresh is needed, but refresh token is missing
114114+ # - if a token has invalid format
115115+ # - if required access token is missing, and {#auto_manage_tokens} is disabled
116116+ #
117117+ # @example Unauthenticated call
118118+ # sky = Minisky.new('public.api.bsky.app', nil)
119119+ # profile = sky.get_request('app.bsky.actor.getProfile', { actor: 'ec.europa.eu' })
120120+ #
121121+ # @example Authenticated call
122122+ # sky = Minisky.new('blacksky.app', 'config.yml')
123123+ # feed = sky.get_request('app.bsky.feed.getTimeline', { limit: 100 })
124124+125125+ def get_request(method, params = nil, auth: default_auth_mode, headers: nil)
126126+ check_access if auto_manage_tokens && auth == true
127127+128128+ headers = authentication_header(auth).merge(headers || {})
129129+ url = build_request_uri(method)
3713038131 if params && !params.empty?
39132 url.query = URI.encode_www_form(params)
40133 end
411344242- JSON.parse(URI.open(url, headers).read)
135135+ request = Net::HTTP::Get.new(url, headers)
136136+137137+ response = make_request(request)
138138+ handle_response(response)
43139 end
441404545- def post_request(method, params = nil, auth: true)
4646- headers = authentication_header(auth).merge({ "Content-Type" => "application/json" })
4747- body = params ? params.to_json : ''
141141+ # Sends a POST request to the service's API.
142142+ #
143143+ # @param method [String, URI] an XRPC endpoint name or a full URL
144144+ # @param data [Hash, String, nil] JSON or string data to send
145145+ #
146146+ # @param auth [Boolean, String]
147147+ # boolean value which tells whether to send an auth header with the access token or not,
148148+ # or an explicit bearer token to use
149149+ # @param headers [Hash, nil]
150150+ # additional headers to include
151151+ # @param params [Hash, nil]
152152+ # query parameters to append to the URL
153153+ #
154154+ # @return [Hash, String] parsed JSON hash for JSON responses, or raw response body otherwise
155155+ #
156156+ # @raise [ArgumentError] if method name is invalid
157157+ # @raise [BadResponse] if the HTTP response has an error status code
158158+ # @raise [AuthError]
159159+ # - if logging in is required, but login or password isn't provided
160160+ # - if token refresh is needed, but refresh token is missing
161161+ # - if a token has invalid format
162162+ # - if required access token is missing, and {#auto_manage_tokens} is disabled
163163+ #
164164+ # @example Making a Bluesky post
165165+ # sky = Minisky.new('lab.martianbase.net', 'config.yml')
166166+ #
167167+ # sky.post_request('com.atproto.repo.createRecord', {
168168+ # repo: sky.user.did,
169169+ # collection: 'app.bsky.feed.post',
170170+ # record: {
171171+ # text: "Hello Bluesky!",
172172+ # createdAt: Time.now.iso8601,
173173+ # langs: ['en']
174174+ # }
175175+ # })
481764949- response = Net::HTTP.post(URI("#{base_url}/#{method}"), body, headers)
5050- raise "Invalid response: #{response.code} #{response.body}" if response.code.to_i / 100 != 2
177177+ def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil)
178178+ check_access if auto_manage_tokens && auth == true
179179+180180+ headers = authentication_header(auth).merge(headers || {})
181181+182182+ if data.is_a?(String) || data.nil?
183183+ body = data.to_s
184184+ else
185185+ body = data.to_json
186186+ headers["Content-Type"] = "application/json" unless headers.keys.any? { |k| k.to_s.downcase == 'content-type' }
187187+ end
188188+189189+ url = build_request_uri(method)
511905252- JSON.parse(response.body)
191191+ if params && !params.empty?
192192+ url.query = URI.encode_www_form(params)
193193+ end
194194+195195+ response = Net::HTTP.post(url, body, headers)
196196+ handle_response(response)
53197 end
541985555- def fetch_all(method, params = nil, field:,
5656- auth: true, break_when: nil, max_pages: nil, progress: @default_progress)
199199+ # Fetches and merges paginated responses from a service's endpoint in a loop, updating the
200200+ # cursor after each page, until the cursor is nil or a break condition is met. The data is
201201+ # extracted from a designated field of the response (`field`) and added to a single array,
202202+ # which is returned at the end.
203203+ #
204204+ # A condition for when the fetching should stop can be passed as a block in `break_when`, or
205205+ # alternatively, a max number of pages can be passed to `max_pages` (or both together). If
206206+ # neither is set, the fetching continues until the server returns an empty cursor.
207207+ #
208208+ # When experimenting in the Ruby console, you can pass `nil` as `field` (or skip the parameter)
209209+ # to make a single request and raise an exception, which will tell you what fields are available.
210210+ #
211211+ # @param method [String, URI] an XRPC endpoint name or a full URL
212212+ # @param params [Hash, nil] query parameters
213213+ #
214214+ # @param auth [Boolean, String]
215215+ # boolean value which tells whether to send an auth header with the access token or not,
216216+ # or an explicit bearer token to use
217217+ # @param field [String, nil]
218218+ # name of the field in the responses which contains the data array
219219+ # @param break_when [Proc, nil]
220220+ # if passed, the fetching will stop when the block returns true for any of the
221221+ # returned records, and records matching the condition will be deleted from the last page
222222+ # @param max_pages [Integer, nil]
223223+ # maximum number of pages to fetch
224224+ # @param headers [Hash, nil]
225225+ # additional headers to include
226226+ # @param progress [String, nil]
227227+ # a character to print before each request as a progress indicator
228228+ #
229229+ # @return [Array] records or objects collected from all pages
230230+ #
231231+ # @raise [ArgumentError] if method name is invalid
232232+ # @raise [FieldNotSetError] if field parameter wasn't set (the message tells you what fields were in the response)
233233+ # @raise [BadResponse] if the HTTP response has an error status code
234234+ # @raise [AuthError]
235235+ # - if logging in is required, but login or password isn't provided
236236+ # - if token refresh is needed, but refresh token is missing
237237+ # - if a token has invalid format
238238+ # - if required access token is missing, and {#auto_manage_tokens} is disabled
239239+ #
240240+ # @example Fetching with a `break_when` block
241241+ # sky = Minisky.new('public.api.bsky.app', nil)
242242+ # time_limit = Time.now - 86400 * 30
243243+ #
244244+ # sky.fetch_all('app.bsky.feed.getAuthorFeed',
245245+ # { actor: 'pfrazee.com', limit: 100 },
246246+ # field: 'feed',
247247+ # progress: '|',
248248+ # break_when: ->(x) { Time.at(x['post']['record']['createdAt']) < time_limit }
249249+ # )
250250+ #
251251+ # @example Fetching with `max_pages`
252252+ # sky = Minisky.new('tngl.sh', 'config.yml')
253253+ # sky.fetch_all('app.bsky.feed.getTimeline', { limit: 100 }, field: 'feed', max_pages: 10)
254254+ #
255255+ # @example Making a request in the console with empty `field`
256256+ # sky = Minisky.new('public.api.bsky.app', nil)
257257+ # # => #<Minisky:0x0000000120f5f6b0 @host="public.api.bsky.app", ...>
258258+ #
259259+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' })
260260+ # # ./lib/minisky/requests.rb:270:in 'block in Minisky::Requests#fetch_all':
261261+ # # Field parameter not provided; available fields: ["followers"] (Minisky::FieldNotSetError)
262262+ #
263263+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' }, field: 'followers')
264264+ # # => .....
265265+266266+ def fetch_all(method, params = nil, auth: default_auth_mode,
267267+ field: nil, break_when: nil, max_pages: nil, headers: nil, progress: @default_progress)
57268 data = []
58269 params = {} if params.nil?
59270 pages = 0
···61272 loop do
62273 print(progress) if progress
632746464- response = get_request(method, params, auth: auth)
275275+ response = get_request(method, params, auth: auth, headers: headers)
276276+277277+ if field.nil?
278278+ raise FieldNotSetError, response.keys.select { |f| response[f].is_a?(Array) }
279279+ end
280280+65281 records = response[field]
66282 cursor = response['cursor']
67283···69285 params[:cursor] = cursor
70286 pages += 1
712877272- break if !cursor || records.empty? || pages == max_pages
288288+ break if !cursor || pages == max_pages
73289 break if break_when && records.any? { |x| break_when.call(x) }
74290 end
75291···77293 data
78294 end
79295296296+ # Ensures that the user has a fresh access token, by checking the access token's expiry date
297297+ # and performing a refresh if needed, or by logging in with a password if no tokens are present.
298298+ #
299299+ # If {#auto_manage_tokens} is enabled (the default setting), this method is automatically called
300300+ # before {#get_request}, {#post_request} and {#fetch_all}, so you generally don't need to call it
301301+ # yourself.
302302+ #
303303+ # @return [Symbol]
304304+ # - `:logged_in` if a login using a password was performed
305305+ # - `:refreshed` if the access token was expired and was refreshed
306306+ # - `:ok` if no refresh was needed
307307+ # - `:unknown` if the token is not a valid JWT (e.g. an opaque blob)
308308+ #
309309+ # @raise [BadResponse] if login or refresh returns an error status code
310310+ # @raise [AuthError]
311311+ # - if the client doesn't include user config at all
312312+ # - if logging in is required, but login or password isn't provided
313313+ # - if token refresh is needed, but refresh token is missing
314314+80315 def check_access
8181- if !user.logged_in?
316316+ if !user
317317+ raise AuthError, "User config is missing"
318318+ elsif !user.has_credentials?
319319+ raise AuthError, "User id or password is missing"
320320+ elsif !user.logged_in?
82321 log_in
322322+ return :logged_in
323323+ end
324324+325325+ begin
326326+ expired = access_token_expired?
327327+ rescue AuthError
328328+ return :unknown
329329+ end
330330+331331+ if expired
332332+ perform_token_refresh
333333+ :refreshed
83334 else
8484- begin
8585- get_request('com.atproto.server.getSession')
8686- rescue OpenURI::HTTPError
8787- perform_token_refresh
8888- end
335335+ :ok
89336 end
90337 end
91338339339+ # Logs in the user using an ID and password stored in the config by calling the
340340+ # `createSession` endpoint, and stores the received access & refresh tokens.
341341+ #
342342+ # This is generally handled automatically by {#check_access}. Calling this method
343343+ # repeatedly many times in a short period of time may use up your rate limit for this
344344+ # endpoint (which is lower than for others) and make it inaccessible to you for some
345345+ # time.
346346+ #
347347+ # @return [Hash] the response JSON with access tokens
348348+ #
349349+ # @raise [AuthError] if login or password are missing
350350+ # @raise [BadResponse] if the server responds with an error status code
351351+92352 def log_in
353353+ if user.nil? || !user.has_credentials?
354354+ raise AuthError, "To log in, please provide a user id and password"
355355+ end
356356+93357 data = {
94358 identifier: user.id,
95359 password: user.pass
96360 }
97361362362+ if user.id =~ /\A[^@]+@[^@]+\z/
363363+ STDERR.puts "Warning: logging in using an email address is deprecated in Minisky and will be " +
364364+ "removed in a future version. Use either a handle or a DID instead."
365365+ end
366366+98367 json = post_request('com.atproto.server.createSession', data, auth: false)
99368100100- config['did'] = json['did']
101101- config['access_token'] = json['accessJwt']
102102- config['refresh_token'] = json['refreshJwt']
369369+ user.did = json['did']
370370+ user.access_token = json['accessJwt']
371371+ user.refresh_token = json['refreshJwt']
103372104373 save_config
105374 json
106375 end
107376377377+ # Refreshes the access token using the stored refresh token. If successful, this
378378+ # invalidates *both* old tokens and replaces them with new ones from the response.
379379+ #
380380+ # If {#auto_manage_tokens} is enabled (the default setting), this method is automatically called
381381+ # before any requests through {#check_access}, so you generally don't need to call it yourself.
382382+ #
383383+ # @return [Hash] the response JSON with access tokens
384384+ #
385385+ # @raise [AuthError] if the refresh token is missing
386386+ # @raise [BadResponse] if the server responds with an error status code
387387+108388 def perform_token_refresh
389389+ if user&.refresh_token.nil?
390390+ raise AuthError, "Can't refresh access token - refresh token is missing"
391391+ end
392392+109393 json = post_request('com.atproto.server.refreshSession', auth: user.refresh_token)
110394111111- config['access_token'] = json['accessJwt']
112112- config['refresh_token'] = json['refreshJwt']
395395+ user.access_token = json['accessJwt']
396396+ user.refresh_token = json['refreshJwt']
113397114398 save_config
115399 json
116400 end
117401402402+ # Attempts to parse a given token as JWT and extract the expiration date from the payload.
403403+ # An access token technically isn't required to be a (valid) JWT, so if the parsing fails
404404+ # for whatever reason, nil is returned.
405405+ #
406406+ # @return [Time, nil] parsed expiration time, or nil if token is not a valid JWT
407407+408408+ def token_expiration_date(token)
409409+ return nil unless token.valid_encoding?
410410+411411+ parts = token.split('.')
412412+ return nil unless parts.length == 3
413413+414414+ begin
415415+ payload = JSON.parse(Base64.decode64(parts[1]))
416416+ rescue JSON::ParserError
417417+ return nil
418418+ end
419419+420420+ exp = payload['exp']
421421+ return nil unless exp.is_a?(Numeric) && exp > 0
422422+423423+ time = Time.at(exp)
424424+ return nil if time.year < 2023 || time.year > 2100
425425+426426+ time
427427+ end
428428+429429+ # Attempts to parse the user's access token as JWT, extract the expiration date from the
430430+ # payload, and check if the token hasn't expired yet.
431431+ #
432432+ # @return [Boolean] true if the token's expiration time is more than a minute away
433433+ # @raise [AuthError] if the token is not a valid JWT, or user is not logged in
434434+435435+ def access_token_expired?
436436+ if user&.access_token.nil?
437437+ raise AuthError, "No access token (user is not logged in)"
438438+ end
439439+440440+ exp_date = token_expiration_date(user.access_token)
441441+442442+ if exp_date
443443+ exp_date < Time.now + 60
444444+ else
445445+ raise AuthError, "Token expiration date cannot be decoded"
446446+ end
447447+ end
448448+449449+ #
450450+ # Clear stored access and refresh tokens, effectively logging out the user.
451451+ #
452452+ # @raise [AuthError] if the client doesn't have a user config
453453+ #
454454+455455+ def reset_tokens
456456+ if !user
457457+ raise AuthError, "User config is missing"
458458+ end
459459+460460+ user.access_token = nil
461461+ user.refresh_token = nil
462462+ save_config
463463+ nil
464464+ end
465465+466466+ if RUBY_VERSION.to_i == 2
467467+ require_relative 'compat'
468468+ prepend Ruby2Compat
469469+ end
470470+471471+118472 private
119473474474+ def make_request(request)
475475+ # this long form is needed because #get_response only supports a headers param in Ruby 3.x
476476+ response = Net::HTTP.start(request.uri.hostname, request.uri.port, use_ssl: (request.uri.scheme == 'https')) do |http|
477477+ http.request(request)
478478+ end
479479+ end
480480+481481+ def build_request_uri(method)
482482+ if method.is_a?(URI)
483483+ method
484484+ elsif method.include?('://')
485485+ URI(method)
486486+ elsif method =~ NSID_REGEXP
487487+ URI("#{base_url}/#{method}")
488488+ else
489489+ raise ArgumentError, "Invalid method name #{method.inspect} (should be an NSID, URL or an URI object)"
490490+ end
491491+ end
492492+493493+ def default_auth_mode
494494+ !!send_auth_headers
495495+ end
496496+120497 def authentication_header(auth)
121498 if auth.is_a?(String)
122499 { 'Authorization' => "Bearer #{auth}" }
123500 elsif auth
124124- { 'Authorization' => "Bearer #{user.access_token}" }
501501+ if user&.access_token
502502+ { 'Authorization' => "Bearer #{user.access_token}" }
503503+ else
504504+ raise AuthError, "Can't send auth headers, access token is missing"
505505+ end
125506 else
126507 {}
508508+ end
509509+ end
510510+511511+ def handle_response(response)
512512+ status = response.code.to_i
513513+ message = response.message
514514+ response_body = (response.content_type == 'application/json') ? JSON.parse(response.body) : response.body
515515+516516+ case response
517517+ when Net::HTTPSuccess
518518+ response_body
519519+ when Net::HTTPRedirection
520520+ raise UnexpectedRedirect.new(status, message, response['location'])
521521+ else
522522+ error_class = if response_body.is_a?(Hash) && response_body['error'] == 'ExpiredToken'
523523+ ExpiredTokenError
524524+ elsif response.is_a?(Net::HTTPClientError)
525525+ ClientErrorResponse
526526+ elsif response.is_a?(Net::HTTPServerError)
527527+ ServerErrorResponse
528528+ else
529529+ BadResponse
530530+ end
531531+532532+ raise error_class.new(status, message, response_body)
127533 end
128534 end
129535 end
+1-1
lib/minisky/version.rb
···11require_relative 'minisky'
2233class Minisky
44- VERSION = "0.2.0"
44+ VERSION = "0.5.0"
55end
+9-7
minisky.gemspec
···11# frozen_string_literal: true
2233-require_relative "lib/minisky/version"
33+minisky_version = File.read(File.join(__dir__, 'lib', 'minisky', 'version.rb')).match(/VERSION = "(.*)"/)[1]
4455Gem::Specification.new do |spec|
66 spec.name = "minisky"
77- spec.version = Minisky::VERSION
77+ spec.version = minisky_version
88 spec.authors = ["Kuba Suder"]
99 spec.email = ["jakub.suder@gmail.com"]
10101111- spec.summary = "A minimal client of Bluesky/AtProto API"
1111+ spec.summary = "A minimal client of Bluesky/ATProto API"
1212 spec.description = "A very simple client class that lets you log in to the Bluesky API and make any requests there."
1313- spec.homepage = "https://github.com/mackuba/minisky"
1313+ spec.homepage = "https://ruby.sdk.blue"
14141515 spec.license = "Zlib"
1616 spec.required_ruby_version = ">= 2.6.0"
17171818 spec.metadata = {
1919- "bug_tracker_uri" => "https://github.com/mackuba/minisky/issues",
2020- "changelog_uri" => "https://github.com/mackuba/minisky/blob/master/CHANGELOG.md",
2121- "source_code_uri" => "https://github.com/mackuba/minisky",
1919+ "bug_tracker_uri" => "https://tangled.org/mackuba.eu/minisky/issues",
2020+ "changelog_uri" => "https://tangled.org/mackuba.eu/minisky/blob/master/CHANGELOG.md",
2121+ "source_code_uri" => "https://tangled.org/mackuba.eu/minisky",
2222 }
23232424 spec.files = Dir.chdir(__dir__) do
···2626 end
27272828 spec.require_paths = ["lib"]
2929+3030+ spec.add_dependency 'base64', '~> 0.1'
2931end
+83-12
spec/custom_client_spec.rb
···11require 'json'
22+require_relative 'shared/ex_incomplete_auth'
33+require_relative 'shared/ex_requests'
44+require_relative 'shared/ex_unauthed'
2536class CustomJSONClient
47 CONFIG_FILE = 'test.json'
···710811 attr_reader :config
9121010- def initialize
1111- @config = JSON.parse(File.read(CONFIG_FILE))
1313+ def initialize(config_file = CONFIG_FILE)
1414+ @config = config_file && JSON.parse(File.read(config_file))
1215 end
13161417 def host
···2023 end
2124end
22252323-describe "custom client" do
2626+describe "in custom client" do
2427 include FakeFS::SpecHelpers
25282626- before do
2727- File.write('test.json', %({
2828- "id": "john.foo",
2929- "pass": "hunter2",
3030- "access_token": "aatoken",
3131- "refresh_token": "rrtoken"
3232- }))
3333- end
2929+ let(:data) {{
3030+ 'id' => 'john.foo',
3131+ 'pass' => 'hunter2',
3232+ 'access_token' => 'aatoken',
3333+ 'refresh_token' => 'rrtoken'
3434+ }}
34353536 subject { CustomJSONClient.new }
36373738 let(:reloaded_config) { JSON.parse(File.read('test.json')) }
38393939- include_examples "Requests", 'at.x.com'
4040+ context 'with correct config,' do
4141+ before do
4242+ File.write('test.json', JSON.generate(data))
4343+ end
4444+4545+ it 'should send auth headers by default' do
4646+ subject.send_auth_headers.should == true
4747+ end
4848+4949+ it 'should manage tokens by default' do
5050+ subject.auto_manage_tokens.should == true
5151+ end
5252+5353+ it 'should not set default progress' do
5454+ subject.progress.should be_nil
5555+ end
5656+5757+ describe '(requests)' do
5858+ include_examples "authenticated requests", 'at.x.com'
5959+ end
6060+ end
6161+6262+ context 'with no user config,' do
6363+ subject { CustomJSONClient.new(nil) }
6464+6565+ it 'should not send auth headers' do
6666+ subject.send_auth_headers.should == false
6767+ end
6868+6969+ it 'should not manage tokens' do
7070+ subject.auto_manage_tokens.should == false
7171+ end
7272+7373+ it 'should not set default progress' do
7474+ subject.progress.should be_nil
7575+ end
7676+7777+ include_examples "unauthenticated user"
7878+ end
7979+8080+ context 'if id field is nil,' do
8181+ before do
8282+ File.write('test.json', JSON.generate(id: nil, pass: 'ok'))
8383+ end
8484+8585+ include_examples "custom client with incomplete auth"
8686+ end
8787+8888+ context 'if id field is not included' do
8989+ before do
9090+ File.write('test.json', JSON.generate(pass: 'ok'))
9191+ end
9292+9393+ include_examples "custom client with incomplete auth"
9494+ end
9595+9696+ context 'if pass field is nil' do
9797+ before do
9898+ File.write('test.json', JSON.generate(id: 'id', pass: nil))
9999+ end
100100+101101+ include_examples "custom client with incomplete auth"
102102+ end
103103+104104+ context 'if pass field is not included' do
105105+ before do
106106+ File.write('test.json', JSON.generate(id: 'id'))
107107+ end
108108+109109+ include_examples "custom client with incomplete auth"
110110+ end
40111end
+250-41
spec/minisky_spec.rb
···11require 'yaml'
22+require_relative 'shared/ex_requests'
33+require_relative 'shared/ex_unauthed'
44+55+data = {
66+ 'id' => 'john.foo',
77+ 'pass' => 'hunter2',
88+ 'access_token' => 'aatoken',
99+ 'refresh_token' => 'rrtoken'
1010+}.freeze
1111+1212+host = 'bsky.test'
213314describe Minisky do
415 include FakeFS::SpecHelpers
51666- let(:host) { 'bsky.test' }
1717+ subject { Minisky.new(host, 'myconfig.yml') }
1818+1919+ it 'should have a version number' do
2020+ Minisky::VERSION.should_not be_nil
2121+ end
2222+2323+ context 'if id field is nil' do
2424+ before do
2525+ File.write('myconfig.yml', YAML.dump(data.merge('id' => nil)))
2626+ end
2727+2828+ it 'should raise AuthError' do
2929+ expect { subject }.to raise_error(Minisky::AuthError)
3030+ end
3131+ end
3232+3333+ context 'if id field is not included' do
3434+ before do
3535+ File.write('myconfig.yml', YAML.dump(data.slice('pass', 'access_token', 'refresh_token')))
3636+ end
3737+3838+ it 'should raise AuthError' do
3939+ expect { subject }.to raise_error(Minisky::AuthError)
4040+ end
4141+ end
4242+4343+ context 'if pass field is nil' do
4444+ before do
4545+ File.write('myconfig.yml', YAML.dump(data.merge('pass' => nil)))
4646+ end
74788- context 'with a default config file name' do
4848+ it 'should raise AuthError' do
4949+ expect { subject }.to raise_error(Minisky::AuthError)
5050+ end
5151+ end
5252+5353+ context 'if pass field is not included' do
954 before do
1010- File.write('bluesky.yml', %(
1111- id: john.foo
1212- pass: hunter2
1313- access_token: aatoken
1414- refresh_token: rrtoken
1515- ))
5555+ File.write('myconfig.yml', YAML.dump(data.slice('id', 'access_token', 'refresh_token')))
5656+ end
5757+5858+ it 'should raise AuthError' do
5959+ expect { subject }.to raise_error(Minisky::AuthError)
6060+ end
6161+ end
6262+6363+ context 'with correct config' do
6464+ before do
6565+ File.write('myconfig.yml', YAML.dump(data))
6666+ end
6767+6868+ it 'should send auth headers by default' do
6969+ subject.send_auth_headers.should == true
7070+ end
7171+7272+ it 'should manage tokens by default' do
7373+ subject.auto_manage_tokens.should == true
7474+ end
7575+7676+ it 'should set host and config properties' do
7777+ subject.host.should == host
7878+ subject.config.should be_a(Hash)
7979+ subject.config.should == data
8080+ end
8181+ end
8282+8383+ context 'without a config' do
8484+ subject { Minisky.new(host, nil) }
8585+8686+ it 'should not send auth headers' do
8787+ subject.send_auth_headers.should == false
8888+ end
8989+9090+ it 'should not manage tokens' do
9191+ subject.auto_manage_tokens.should == false
9292+ end
9393+9494+ it 'should set host property' do
9595+ subject.host.should == host
9696+ end
9797+9898+ it 'should set config to nil' do
9999+ subject.config.should be_nil
100100+ end
101101+ end
102102+103103+ context 'if running inside IRB' do
104104+ subject { Minisky.new(host, nil) }
105105+106106+ before do
107107+ load File.join(__dir__, 'shared', 'fake_irb.rb')
108108+ end
109109+110110+ it 'should set default_progress to "."' do
111111+ subject.default_progress.should == '.'
112112+ end
113113+114114+ after do
115115+ Object.send(:remove_const, :IRB)
116116+ end
117117+ end
118118+119119+ context 'if running inside Pry' do
120120+ subject { Minisky.new(host, nil) }
121121+122122+ before do
123123+ load File.join(__dir__, 'shared', 'fake_pry.rb')
124124+ end
125125+126126+ it 'should set default_progress to "."' do
127127+ subject.default_progress.should == '.'
128128+ end
129129+130130+ after do
131131+ Object.send(:remove_const, :Pry)
132132+ end
133133+ end
134134+135135+ context 'if not running inside a REPL' do
136136+ subject { Minisky.new(host, nil) }
137137+138138+ it 'should keep default_progress unset' do
139139+ subject.default_progress.should be_nil
140140+ end
141141+ end
142142+143143+ it 'should let you pass additional options and set them' do
144144+ File.write('myconfig.yml', YAML.dump(data))
145145+146146+ minisky = Minisky.new(host, 'myconfig.yml', auto_manage_tokens: false, progress: '*')
147147+ minisky.auto_manage_tokens.should == false
148148+ minisky.default_progress.should == '*'
149149+ end
150150+151151+ describe '#token_expiration_date' do
152152+ subject { Minisky.new(host, nil) }
153153+154154+ it 'should return nil for tokens with invalid encoding' do
155155+ token = "bad\xC3".force_encoding('UTF-8')
156156+ subject.token_expiration_date(token).should be_nil
157157+ end
158158+159159+ it 'should return nil when the token does not have three parts' do
160160+ subject.token_expiration_date('token').should be_nil
161161+ subject.token_expiration_date('one.two').should be_nil
162162+ subject.token_expiration_date('1.2.3.4').should be_nil
163163+164164+ token = make_token(Time.now + 3600)
165165+ subject.token_expiration_date(token + '.qwe').should be_nil
166166+ end
167167+168168+ it 'should return nil when the payload is not valid JSON' do
169169+ token = ['header', Base64.strict_encode64('nope'), 'sig'].join('.')
170170+ subject.token_expiration_date(token).should be_nil
16171 end
171721818- subject { Minisky.new(host) }
173173+ it 'should return nil when exp field is missing' do
174174+ token = ['header', Base64.strict_encode64(JSON.generate({ 'aud' => 'aaaa' })), 'sig'].join('.')
175175+ subject.token_expiration_date(token).should be_nil
176176+ end
177177+178178+ it 'should return nil when exp field is not a number' do
179179+ token = ['header', Base64.strict_encode64(JSON.generate({ 'exp' => 'soon' })), 'sig'].join('.')
180180+ subject.token_expiration_date(token).should be_nil
181181+ end
182182+183183+ it 'should return nil when exp field is not a positive number' do
184184+ token = ['header', Base64.strict_encode64(JSON.generate({ 'exp' => 0 })), 'sig'].join('.')
185185+ subject.token_expiration_date(token).should be_nil
186186+ end
191872020- let(:reloaded_config) { YAML.load(File.read('bluesky.yml')) }
188188+ it 'should return nil when expiration year is before 2023' do
189189+ token = make_token(Time.utc(2022, 12, 24, 19, 00, 00))
190190+ subject.token_expiration_date(token).should be_nil
191191+ end
211922222- it 'should have a version number' do
2323- Minisky::VERSION.should_not be_nil
193193+ it 'should return nil when expiration year is after 2100' do
194194+ token = make_token(Time.utc(2101, 5, 5, 0, 0, 0))
195195+ subject.token_expiration_date(token).should be_nil
24196 end
251972626- include_examples "Requests", 'bsky.test'
198198+ it 'should return the expiration time for a valid token' do
199199+ time = Time.at(Time.now.to_i + 7200)
200200+ token = make_token(time)
201201+ subject.token_expiration_date(token).should == time
202202+ end
27203 end
282042929- context 'with a custom config file name' do
205205+ describe '#access_token_expired?' do
206206+ let(:config) { data }
207207+30208 before do
3131- File.write('myconfig.yml', %(
3232- id: john.foo
3333- pass: hunter2
3434- access_token: aatoken
3535- refresh_token: rrtoken
3636- ))
209209+ File.write('myconfig.yml', YAML.dump(config))
210210+ end
211211+212212+ context 'when there is no user config' do
213213+ subject { Minisky.new(host, nil) }
214214+215215+ it 'should raise AuthError' do
216216+ expect { subject.access_token_expired? }.to raise_error(Minisky::AuthError)
217217+ end
37218 end
382193939- subject { Minisky.new(host, 'myconfig.yml') }
220220+ context 'when access token is missing' do
221221+ let(:config) { data.merge('access_token' => nil) }
402224141- let(:reloaded_config) { YAML.load(File.read('myconfig.yml')) }
223223+ it 'should raise AuthError' do
224224+ expect { subject.access_token_expired? }.to raise_error(Minisky::AuthError)
225225+ end
226226+ end
422274343- it 'should load config from a file' do
4444- subject.user.id.should == 'john.foo'
4545- subject.user.access_token.should == 'aatoken'
4646- subject.user.refresh_token.should == 'rrtoken'
228228+ context 'when token expiration cannot be decoded' do
229229+ let(:config) { data.merge('access_token' => 'blob') }
230230+231231+ it 'should raise AuthError' do
232232+ expect { subject.access_token_expired? }.to raise_error(Minisky::AuthError)
233233+ end
47234 end
482354949- describe '#log_in' do
5050- before do
5151- stub_request(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
5252- .to_return(body: %({
5353- "did": "did:plc:abracadabra",
5454- "accessJwt": "aaaa1234",
5555- "refreshJwt": "rrrr1234"
5656- }))
236236+ context 'when token expiration date is in the past' do
237237+ let(:config) { data.merge('access_token' => make_token(Time.now - 30)) }
238238+239239+ it 'should return true' do
240240+ subject.access_token_expired?.should == true
57241 end
242242+ end
582435959- it "should save user's DID" do
6060- subject.log_in
244244+ context 'when token expiration date is in less than 60 seconds' do
245245+ let(:config) { data.merge('access_token' => make_token(Time.now + 50)) }
612466262- reloaded_config['did'].should == "did:plc:abracadabra"
247247+ it 'should return true' do
248248+ subject.access_token_expired?.should == true
63249 end
250250+ end
642516565- it "should update the tokens in the config file" do
6666- subject.log_in
252252+ context 'when token expiration date is in more than 60 seconds' do
253253+ let(:config) { data.merge('access_token' => make_token(Time.now + 180)) }
672546868- reloaded_config['access_token'].should == 'aaaa1234'
6969- reloaded_config['refresh_token'].should == 'rrrr1234'
255255+ it 'should return false' do
256256+ subject.access_token_expired?.should == false
70257 end
71258 end
72259 end
73260end
261261+262262+describe 'in Minisky instance' do
263263+ include FakeFS::SpecHelpers
264264+265265+ subject { Minisky.new(host, 'myconfig.yml') }
266266+267267+ let(:reloaded_config) { YAML.load(File.read('myconfig.yml')) }
268268+269269+ context 'with correct config,' do
270270+ before do
271271+ File.write('myconfig.yml', YAML.dump(data))
272272+ end
273273+274274+ include_examples "authenticated requests", 'bsky.test'
275275+ end
276276+277277+ context 'without a config' do
278278+ subject { Minisky.new(host, nil) }
279279+280280+ include_examples "unauthenticated user"
281281+ end
282282+end
-575
spec/requests_shared.rb
···11-shared_examples "Requests" do |host|
22- let(:host) { host }
33-44- it 'should load config from a file' do
55- subject.user.id.should == 'john.foo'
66- subject.user.access_token.should == 'aatoken'
77- subject.user.refresh_token.should == 'rrtoken'
88- end
99-1010- it 'should have a user object wrapping the config' do
1111- subject.config['something'] = 'some value'
1212-1313- subject.user.something.should == 'some value'
1414- end
1515-1616- describe '#log_in' do
1717- let(:response_json) { JSON.generate(
1818- "did": "did:plc:abracadabra",
1919- "accessJwt": "aaaa1234",
2020- "refreshJwt": "rrrr1234"
2121- )}
2222-2323- before do
2424- stub_request(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
2525- .to_return(body: response_json)
2626- end
2727-2828- it 'should make a request to com.atproto.server.createSession' do
2929- subject.log_in
3030-3131- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
3232- .once.with(body: %({"identifier":"john.foo","password":"hunter2"}))
3333- end
3434-3535- it 'should not set authentication header' do
3636- subject.log_in
3737-3838- WebMock.should_not have_requested(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
3939- .with(headers: { 'Authorization' => /.*/ })
4040- end
4141-4242- it "should save user's DID" do
4343- subject.log_in
4444-4545- reloaded_config['did'].should == "did:plc:abracadabra"
4646- end
4747-4848- it "should update the tokens in the config file" do
4949- subject.log_in
5050-5151- reloaded_config['access_token'].should == 'aaaa1234'
5252- reloaded_config['refresh_token'].should == 'rrrr1234'
5353- end
5454-5555- it 'should return the response json' do
5656- subject.log_in.should == JSON.parse(response_json)
5757- end
5858- end
5959-6060- describe '#perform_token_refresh' do
6161- let(:response_json) { JSON.generate(
6262- "accessJwt": "aaaa1234",
6363- "refreshJwt": "rrrr1234"
6464- )}
6565-6666- before do
6767- stub_request(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
6868- .to_return(body: response_json)
6969- end
7070-7171- it 'should make a request to com.atproto.server.refreshSession' do
7272- subject.perform_token_refresh
7373-7474- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
7575- .once.with(body: '')
7676- end
7777-7878- it 'should authenticate with the refresh token' do
7979- subject.perform_token_refresh
8080-8181- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
8282- .once.with(headers: { 'Authorization' => 'Bearer rrtoken' })
8383- end
8484-8585- it "should update the tokens in the config file" do
8686- subject.perform_token_refresh
8787-8888- reloaded_config['access_token'].should == 'aaaa1234'
8989- reloaded_config['refresh_token'].should == 'rrrr1234'
9090- end
9191-9292- it 'should return the response json' do
9393- subject.perform_token_refresh.should == JSON.parse(response_json)
9494- end
9595- end
9696-9797- describe '#get_request' do
9898- before do
9999- stub_request(:get, %r(https://#{host}/xrpc/com.example.service.getStuff(\?.*)?))
100100- .to_return(body: '{ "result": 123 }')
101101- end
102102-103103- it 'should make a request to the given XRPC endpoint' do
104104- subject.get_request('com.example.service.getStuff')
105105-106106- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
107107- end
108108-109109- it 'should return parsed JSON' do
110110- result = subject.get_request('com.example.service.getStuff')
111111-112112- result.should == { 'result' => 123 }
113113- end
114114-115115- context 'with params' do
116116- it 'should append params to the URL' do
117117- subject.get_request('com.example.service.getStuff', { repo: 'whitehouse.gov', limit: 80 })
118118-119119- WebMock.should have_requested(:get,
120120- "https://#{host}/xrpc/com.example.service.getStuff?repo=whitehouse.gov&limit=80").once
121121- end
122122- end
123123-124124- context 'with nil params' do
125125- it 'should not append anything to the URL' do
126126- subject.get_request('com.example.service.getStuff', nil)
127127-128128- WebMock.should have_requested(:get,
129129- "https://#{host}/xrpc/com.example.service.getStuff").once
130130- end
131131- end
132132-133133- context 'with an array passed as param' do
134134- it 'should append one copy of the param for each item' do
135135- subject.get_request('com.example.service.getStuff', { profiles: ['john.foo', 'spam.zip'], reposts: true })
136136-137137- WebMock.should have_requested(:get,
138138- "https://#{host}/xrpc/com.example.service.getStuff?profiles=john.foo&profiles=spam.zip&reposts=true").once
139139- end
140140- end
141141-142142- context 'with an explicit auth token' do
143143- it 'should pass the token in the header' do
144144- subject.get_request('com.example.service.getStuff', auth: 'token777')
145145-146146- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
147147- .with(headers: { 'Authorization' => 'Bearer token777' })
148148- end
149149- end
150150-151151- context 'without an auth parameter' do
152152- it 'should use the access token' do
153153- subject.get_request('com.example.service.getStuff')
154154-155155- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
156156- .with(headers: { 'Authorization' => 'Bearer aatoken' })
157157- end
158158- end
159159-160160- context 'with auth = false' do
161161- it 'should not set the authorization header' do
162162- subject.get_request('com.example.service.getStuff', auth: false)
163163-164164- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff")
165165- .with(headers: { 'Authorization' => /.*/ })
166166- end
167167- end
168168- end
169169-170170- describe '#post_request' do
171171- let(:response) {{ body: '{ "result": "ok" }' }}
172172-173173- before do
174174- stub_request(:post, "https://#{host}/xrpc/com.example.service.doStuff").to_return(response)
175175- end
176176-177177- it 'should make a request to the given XRPC endpoint' do
178178- subject.post_request('com.example.service.doStuff')
179179-180180- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
181181- end
182182-183183- it 'should return parsed JSON' do
184184- result = subject.post_request('com.example.service.doStuff')
185185-186186- result.should == { 'result' => 'ok' }
187187- end
188188-189189- it 'should set content type to application/json' do
190190- subject.post_request('com.example.service.doStuff')
191191-192192- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
193193- .with(headers: { 'Content-Type' => 'application/json' })
194194- end
195195-196196- context 'with an explicit auth token' do
197197- it 'should pass the token in the header' do
198198- subject.post_request('com.example.service.doStuff', auth: 'qwerty99')
199199-200200- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
201201- .with(headers: { 'Authorization' => 'Bearer qwerty99' })
202202- end
203203- end
204204-205205- context 'without an auth parameter' do
206206- it 'should use the access token' do
207207- subject.post_request('com.example.service.doStuff')
208208-209209- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
210210- .with(headers: { 'Authorization' => 'Bearer aatoken' })
211211- end
212212- end
213213-214214- context 'with auth = false' do
215215- it 'should not set the authorization header' do
216216- subject.post_request('com.example.service.doStuff', auth: false)
217217-218218- WebMock.should_not have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff")
219219- .with(headers: { 'Authorization' => /.*/ })
220220- end
221221- end
222222-223223- context 'if params are passed' do
224224- it 'should encode them as JSON in the body' do
225225- data = { repo: 'kate.dev', limit: 40, fields: ['name', 'posts'] }
226226- subject.post_request('com.example.service.doStuff', data)
227227-228228- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
229229- .with(body: JSON.generate(data))
230230- end
231231- end
232232-233233- context 'if params are not passed' do
234234- it 'should send an empty body' do
235235- subject.post_request('com.example.service.doStuff')
236236-237237- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
238238- .with(body: '')
239239- end
240240- end
241241-242242- context 'if params are an explicit nil' do
243243- it 'should send an empty body' do
244244- subject.post_request('com.example.service.doStuff', nil)
245245-246246- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
247247- .with(body: '')
248248- end
249249- end
250250-251251- context 'if the response has a 4xx status' do
252252- let(:response) {{ body: '{ "error": "message" }', status: 403 }}
253253-254254- it 'should raise an error' do
255255- expect { subject.post_request('com.example.service.doStuff') }.to raise_error(RuntimeError)
256256- end
257257- end
258258- end
259259-260260- describe '#fetch_all' do
261261- context 'when one page of items is returned' do
262262- before do
263263- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
264264- .to_return(body: '{ "items": ["one", "two", "three"] }')
265265- end
266266-267267- it 'should make one request to the given endpoint' do
268268- subject.fetch_all('com.example.service.fetchAll', field: 'items')
269269-270270- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
271271- end
272272-273273- it 'should return the parsed items' do
274274- result = subject.fetch_all('com.example.service.fetchAll', field: 'items')
275275- result.should == ["one", "two", "three"]
276276- end
277277- end
278278-279279- context 'when more than one page of items is returned' do
280280- before do
281281- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
282282- .to_return(body: '{ "items": ["one", "two", "three"], "cursor": "ccc111" }')
283283-284284- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc111")
285285- .to_return(body: '{ "items": ["four", "five"] }')
286286- end
287287-288288- it 'should make multiple requests, passing the last cursor' do
289289- subject.fetch_all('com.example.service.fetchAll', field: 'items')
290290-291291- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
292292- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc111").once
293293- end
294294-295295- it 'should return all the parsed items collected from the responses' do
296296- result = subject.fetch_all('com.example.service.fetchAll', field: 'items')
297297- result.should == ["one", "two", "three", "four", "five"]
298298- end
299299- end
300300-301301- context 'when params are passed' do
302302- before do
303303- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?type=post")
304304- .to_return(body: '{ "items": ["one", "two", "three"], "cursor": "ccc222" }')
305305-306306- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?type=post&cursor=ccc222")
307307- .to_return(body: '{ "items": ["four", "five"] }')
308308- end
309309-310310- it 'should add the params to the url' do
311311- subject.fetch_all('com.example.service.fetchAll', { type: 'post' }, field: 'items')
312312-313313- WebMock.should have_requested(:get,
314314- "https://#{host}/xrpc/com.example.service.fetchAll?type=post").once
315315- WebMock.should have_requested(:get,
316316- "https://#{host}/xrpc/com.example.service.fetchAll?type=post&cursor=ccc222").once
317317- end
318318- end
319319-320320- context 'when params are an explicit nil' do
321321- before do
322322- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
323323- .to_return(body: '{ "items": ["one", "two", "three"], "cursor": "ccc222" }')
324324-325325- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc222")
326326- .to_return(body: '{ "items": ["four", "five"] }')
327327- end
328328-329329- it 'should not add anything to the url' do
330330- subject.fetch_all('com.example.service.fetchAll', nil, field: 'items')
331331-332332- WebMock.should have_requested(:get,
333333- "https://#{host}/xrpc/com.example.service.fetchAll").once
334334- WebMock.should have_requested(:get,
335335- "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc222").once
336336- end
337337- end
338338-339339- describe '(auth token)' do
340340- before do
341341- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
342342- .to_return(body: '{ "items": ["one", "two", "three"], "cursor": "ccc333" }')
343343-344344- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc333")
345345- .to_return(body: '{ "items": ["four", "five"] }')
346346- end
347347-348348- context 'with an explicit token' do
349349- it 'should pass the token in the header' do
350350- subject.fetch_all('com.example.service.fetchAll', auth: 'XXXX', field: 'items')
351351-352352- WebMock.should have_requested(:get,
353353- "https://#{host}/xrpc/com.example.service.fetchAll").once
354354- .with(headers: { 'Authorization' => 'Bearer XXXX' })
355355- WebMock.should have_requested(:get,
356356- "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc333").once
357357- .with(headers: { 'Authorization' => 'Bearer XXXX' })
358358- end
359359- end
360360-361361- context 'without an auth parameter' do
362362- it 'should use the access token' do
363363- subject.fetch_all('com.example.service.fetchAll', field: 'items')
364364-365365- WebMock.should have_requested(:get,
366366- "https://#{host}/xrpc/com.example.service.fetchAll").once
367367- .with(headers: { 'Authorization' => 'Bearer aatoken' })
368368- WebMock.should have_requested(:get,
369369- "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc333").once
370370- .with(headers: { 'Authorization' => 'Bearer aatoken' })
371371- end
372372- end
373373-374374- context 'with auth = false' do
375375- it 'should not add an authentication header' do
376376- subject.fetch_all('com.example.service.fetchAll', field: 'items', auth: false)
377377-378378- WebMock.should_not have_requested(:get, %r(https://#{host}/xrpc/com.example.service.fetchAll))
379379- .with(headers: { 'Authorization' => /.*/ })
380380- end
381381- end
382382- end
383383-384384- context 'when break condition is passed' do
385385- before do
386386- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
387387- .to_return(body: '{ "items": ["one", "two", "three"], "cursor": "page1" }')
388388-389389- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1")
390390- .to_return(body: '{ "items": ["four", "five"], "cursor": "page2" }')
391391-392392- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2")
393393- .to_return(body: '{ "items": ["six"] }')
394394- end
395395-396396- it 'should stop when a matching item is found' do
397397- subject.fetch_all('com.example.service.fetchAll', field: 'items', break_when: ->(x) { x =~ /u/ })
398398-399399- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
400400- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
401401- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2")
402402- end
403403-404404- it 'should filter out matching items from the response' do
405405- result = subject.fetch_all('com.example.service.fetchAll', field: 'items', break_when: ->(x) { x =~ /u/ })
406406- result.should == ["one", "two", "three", "five"]
407407- end
408408- end
409409-410410- context 'when max pages limit is passed' do
411411- before do
412412- stub_request(:get, %r(https://#{host}/xrpc/com.example.service.fetchAll(\?.*)?))
413413- .to_return { |req|
414414- params = req.uri.query_values || {}
415415- page = params['cursor'].to_s.gsub(/page/, '').to_i
416416- { body: JSON.generate({ items: ["item#{page}"], cursor: "page#{page + 1}" }) }
417417- }
418418- end
419419-420420- context 'and break_when is not passed' do
421421- it 'should stop at n-th page' do
422422- subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5)
423423-424424- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
425425- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
426426- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
427427- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
428428- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
429429- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5")
430430- end
431431-432432- it 'should collect all items' do
433433- result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5)
434434- result.should == ["item0", "item1", "item2", "item3", "item4"]
435435- end
436436- end
437437-438438- context 'and break_when matches earlier' do
439439- it 'should stop at the page where break_when matches' do
440440- subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5,
441441- break_when: ->(x) { x =~ /3/ })
442442-443443- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
444444- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
445445- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
446446- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
447447- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4")
448448- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5")
449449- end
450450-451451- it 'should exclude items that matched break_when' do
452452- result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5,
453453- break_when: ->(x) { x =~ /3/ })
454454-455455- result.should == ["item0", "item1", "item2"]
456456- end
457457- end
458458-459459- context "and break_when doesn't match earlier" do
460460- it 'should stop at the n-th page' do
461461- subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
462462- break_when: ->(x) { x =~ /8/ })
463463-464464- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
465465- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
466466- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
467467- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
468468- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
469469- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5").once
470470- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page6")
471471- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page7")
472472- end
473473-474474- it 'should include all items up to n-th page' do
475475- result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
476476- break_when: ->(x) { x =~ /8/ })
477477-478478- result.should == ["item0", "item1", "item2", "item3", "item4", "item5"]
479479- end
480480- end
481481-482482- context "and break_when matches on the last page" do
483483- it 'should stop at the n-th page' do
484484- subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
485485- break_when: ->(x) { x =~ /5/ })
486486-487487- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
488488- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
489489- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
490490- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
491491- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
492492- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5").once
493493- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page6")
494494- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page7")
495495- end
496496-497497- it 'should exclude the items matching on the last page' do
498498- result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
499499- break_when: ->(x) { x =~ /5/ })
500500-501501- result.should == ["item0", "item1", "item2", "item3", "item4"]
502502- end
503503- end
504504- end
505505-506506- describe 'progress param' do
507507- before do
508508- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
509509- .to_return(body: '{ "items": ["one"], "cursor": "page1" }')
510510-511511- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1")
512512- .to_return(body: '{ "items": ["two"], "cursor": "page2" }')
513513-514514- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2")
515515- .to_return(body: '{ "items": ["three"] }')
516516- end
517517-518518- context 'when it is passed' do
519519- it 'should print the progress character for each request' do
520520- expect {
521521- subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: '-=')
522522- }.to output('-=-=-=').to_stdout
523523- end
524524- end
525525-526526- context 'when it is not passed' do
527527- it 'should not print anything' do
528528- expect {
529529- subject.fetch_all('com.example.service.fetchAll', field: 'items')
530530- }.to output('').to_stdout
531531- end
532532- end
533533-534534- context 'when it is passed and a default is set' do
535535- it 'should use the param version' do
536536- subject.default_progress = '@'
537537-538538- expect {
539539- subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: '#')
540540- }.to output('###').to_stdout
541541- end
542542- end
543543-544544- context 'when it is not passed and a default is set' do
545545- it 'should use the default version' do
546546- subject.default_progress = '$'
547547-548548- expect {
549549- subject.fetch_all('com.example.service.fetchAll', field: 'items')
550550- }.to output('$$$').to_stdout
551551- end
552552- end
553553-554554- context 'when default is set and nil is passed' do
555555- it 'should not output anything' do
556556- subject.default_progress = '$'
557557-558558- expect {
559559- subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: nil)
560560- }.to output('').to_stdout
561561- end
562562- end
563563-564564- context 'when default is set and false is passed' do
565565- it 'should not output anything' do
566566- subject.default_progress = '$'
567567-568568- expect {
569569- subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: false)
570570- }.to output('').to_stdout
571571- end
572572- end
573573- end
574574- end
575575-end
+180
spec/shared/ex_authorization.rb
···11+shared_examples 'authorization' do |request:, expected:|
22+ let(:request) { request }
33+ let(:expected) { expected }
44+55+ def make_request(auth:)
66+ request.call(subject, { auth: auth })
77+ end
88+99+ def make_request_without_auth
1010+ request.call(subject, {})
1111+ end
1212+1313+ def expected_calls
1414+ calls = expected.call(host)
1515+ calls[0].is_a?(Array) ? calls : [calls]
1616+ end
1717+1818+ def self.with_access_token(*modes, &definitions)
1919+ modes.each do |m|
2020+ case m
2121+ when :unchanged
2222+ instance_eval(&definitions)
2323+ when :nil
2424+ context "when access_token is nil" do
2525+ before { subject.user.access_token = nil }
2626+ instance_eval(&definitions)
2727+ end
2828+ when :deleted
2929+ context "when access_token is not provided" do
3030+ before { subject.config.delete('access_token') }
3131+ instance_eval(&definitions)
3232+ end
3333+ else
3434+ raise "Unknown mode #{m}"
3535+ end
3636+ end
3737+ end
3838+3939+ [true, false, nil, :undefined].each do |v|
4040+ context "with send_auth_headers set to #{v.inspect}" do
4141+ before do
4242+ subject.send_auth_headers = v unless v == :undefined
4343+ end
4444+4545+ context 'with an explicit auth token' do
4646+ with_access_token(:unchanged, :nil, :deleted) do
4747+ it 'should pass the token in the header' do
4848+ make_request(auth: 'qwerty99')
4949+5050+ expected_calls.each do |method, url|
5151+ WebMock.should have_requested(method, url).once.with(headers: { 'Authorization' => 'Bearer qwerty99' })
5252+ end
5353+ end
5454+ end
5555+ end
5656+5757+ context 'with auth = true' do
5858+ it 'should use the access token' do
5959+ make_request(auth: true)
6060+6161+ expected_calls.each do |method, url|
6262+ WebMock.should have_requested(method, url).once.with(headers: { 'Authorization' => 'Bearer aatoken' })
6363+ end
6464+ end
6565+6666+ with_access_token(:nil, :deleted) do
6767+ it 'should raise AuthError' do
6868+ expect { make_request(auth: true) }.to raise_error(Minisky::AuthError)
6969+7070+ expected_calls.each { |method, url| WebMock.should_not have_requested(method, url) }
7171+ end
7272+ end
7373+ end
7474+7575+ context 'with auth = false' do
7676+ with_access_token(:unchanged, :nil, :deleted) do
7777+ it 'should not set the authorization header' do
7878+ make_request(auth: false)
7979+8080+ expected_calls.each do |method, url|
8181+ WebMock.should have_requested(method, url).once
8282+ WebMock.should_not have_requested(method, url).with(headers: { 'Authorization' => /.*/ })
8383+ end
8484+ end
8585+ end
8686+ end
8787+8888+ context 'with auth = nil' do
8989+ with_access_token(:unchanged, :nil, :deleted) do
9090+ it 'should not set the authorization header' do
9191+ make_request(auth: nil)
9292+9393+ expected_calls.each do |method, url|
9494+ WebMock.should have_requested(method, url).once
9595+ WebMock.should_not have_requested(method, url).with(headers: { 'Authorization' => /.*/ })
9696+ end
9797+ end
9898+ end
9999+ end
100100+ end
101101+ end
102102+103103+ context 'without an auth parameter' do
104104+ it 'should use the access token if send_auth_headers is true' do
105105+ subject.send_auth_headers = true
106106+107107+ make_request_without_auth
108108+109109+ expected_calls.each do |method, url|
110110+ WebMock.should have_requested(method, url).once.with(headers: { 'Authorization' => 'Bearer aatoken' })
111111+ end
112112+ end
113113+114114+ it 'should use the access token if send_auth_headers is not set' do
115115+ make_request_without_auth
116116+117117+ expected_calls.each do |method, url|
118118+ WebMock.should have_requested(method, url).once.with(headers: { 'Authorization' => 'Bearer aatoken' })
119119+ end
120120+ end
121121+122122+ it 'should use the access token if send_auth_headers is set to a truthy value' do
123123+ subject.send_auth_headers = 'wtf'
124124+125125+ make_request_without_auth
126126+127127+ expected_calls.each do |method, url|
128128+ WebMock.should have_requested(method, url).once.with(headers: { 'Authorization' => 'Bearer aatoken' })
129129+ end
130130+ end
131131+132132+ with_access_token(:nil, :deleted) do
133133+ it 'should raise AuthError if send_auth_headers is true' do
134134+ subject.send_auth_headers = true
135135+136136+ expect { make_request_without_auth }.to raise_error(Minisky::AuthError)
137137+138138+ expected_calls.each { |method, url| WebMock.should_not have_requested(method, url) }
139139+ end
140140+141141+ it 'should raise AuthError if send_auth_headers is not set' do
142142+ expect { make_request_without_auth }.to raise_error(Minisky::AuthError)
143143+144144+ expected_calls.each { |method, url| WebMock.should_not have_requested(method, url) }
145145+ end
146146+147147+ it 'should raise AuthError if send_auth_headers is set to a truthy value' do
148148+ subject.send_auth_headers = 'wtf'
149149+150150+ expect { make_request_without_auth }.to raise_error(Minisky::AuthError)
151151+152152+ expected_calls.each { |method, url| WebMock.should_not have_requested(method, url) }
153153+ end
154154+ end
155155+156156+ with_access_token(:unchanged, :nil, :deleted) do
157157+ it 'should not set the authorization header if send_auth_headers is false' do
158158+ subject.send_auth_headers = false
159159+160160+ make_request_without_auth
161161+162162+ expected_calls.each do |method, url|
163163+ WebMock.should have_requested(method, url).once
164164+ WebMock.should_not have_requested(method, url).with(headers: { 'Authorization' => /.*/ })
165165+ end
166166+ end
167167+168168+ it 'should not set the authorization header if send_auth_headers is nil' do
169169+ subject.send_auth_headers = nil
170170+171171+ make_request_without_auth
172172+173173+ expected_calls.each do |method, url|
174174+ WebMock.should have_requested(method, url).once
175175+ WebMock.should_not have_requested(method, url).with(headers: { 'Authorization' => /.*/ })
176176+ end
177177+ end
178178+ end
179179+ end
180180+end
+107
spec/shared/ex_bad_response.rb
···11+shared_examples "bad response handling" do |method, endpoint|
22+ context 'with a bad response' do
33+ let(:method) { method }
44+ let(:endpoint) { endpoint }
55+66+ def make_request(**kwargs)
77+ subject.send("#{method}_request", endpoint, **kwargs)
88+ end
99+1010+ context 'if the response has a 4xx status' do
1111+ let(:response) {{
1212+ body: JSON.generate(error: 'BadReq', message: 'This request was bad'),
1313+ status: 403,
1414+ headers: { 'Content-Type': 'application/json' }
1515+ }}
1616+1717+ it 'should raise a ClientErrorResponse error' do
1818+ expect { make_request }.to raise_error { |err|
1919+ err.should be_a(Minisky::ClientErrorResponse)
2020+ err.status.should == 403
2121+ err.data.should be_a(Hash)
2222+ err.error_type.should == 'BadReq'
2323+ err.error_message.should == 'This request was bad'
2424+ }
2525+ end
2626+ end
2727+2828+ context 'if the response has a 5xx status' do
2929+ let(:response) {{
3030+ body: JSON.generate(error: 'Boom', message: 'Server exploded'),
3131+ status: 500,
3232+ headers: { 'Content-Type': 'application/json' }
3333+ }}
3434+3535+ it 'should raise a ServerErrorResponse error' do
3636+ expect { make_request }.to raise_error { |err|
3737+ err.should be_a(Minisky::ServerErrorResponse)
3838+ err.status.should == 500
3939+ err.data.should be_a(Hash)
4040+ err.error_type.should == 'Boom'
4141+ err.error_message.should == 'Server exploded'
4242+ }
4343+ end
4444+ end
4545+4646+ context 'if the response is a redirect' do
4747+ let(:response) {{ status: 302, headers: { 'Location': 'https://google.com' }}}
4848+4949+ it 'should raise an UnexpectedRedirect error' do
5050+ expect { make_request }.to raise_error { |err|
5151+ err.should be_a(Minisky::UnexpectedRedirect)
5252+ err.status.should == 302
5353+ err.data.should be_a(Hash)
5454+ err.location.should == 'https://google.com'
5555+ }
5656+ end
5757+ end
5858+5959+ context 'if the response is an ExpiredToken error' do
6060+ let(:response) {{
6161+ body: JSON.generate(error: 'ExpiredToken', message: 'Your token has expired'),
6262+ status: 401,
6363+ headers: { 'Content-Type': 'application/json' }
6464+ }}
6565+6666+ it 'should raise an ExpiredTokenError error' do
6767+ expect { make_request }.to raise_error { |err|
6868+ err.should be_a(Minisky::ExpiredTokenError)
6969+ err.status.should == 401
7070+ err.data.should be_a(Hash)
7171+ err.error_type.should == 'ExpiredToken'
7272+ err.error_message.should == 'Your token has expired'
7373+ }
7474+ end
7575+ end
7676+7777+ context 'if the bad response is not json' do
7878+ let(:response) {{
7979+ body: '<html>wtf</html>',
8080+ status: 503
8181+ }}
8282+8383+ it 'should raise an error with the response body' do
8484+ expect { make_request }.to raise_error { |err|
8585+ err.should be_a(Minisky::BadResponse)
8686+ err.status.should == 503
8787+ err.data.should == '<html>wtf</html>'
8888+ err.error_type.should be_nil
8989+ err.error_message.should be_nil
9090+ }
9191+ end
9292+ end
9393+9494+ context 'if the response is not json, but has a 2xx status' do
9595+ let(:response) {{ body: 'ok', status: 201, headers: { 'Content-Type': 'text/plain' }}}
9696+9797+ it 'should not raise an error' do
9898+ expect { make_request }.to_not raise_error
9999+ end
100100+101101+ it 'should return the body as a string' do
102102+ result = make_request
103103+ result.should == 'ok'
104104+ end
105105+ end
106106+ end
107107+end
+330
spec/shared/ex_fetch_all.rb
···11+shared_examples "fetch_all" do
22+ describe '#fetch_all' do
33+ context 'when one page of items is returned' do
44+ before do
55+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
66+ { "items": ["one", "two", "three"] }
77+ ])
88+ end
99+1010+ it 'should make one request to the given endpoint' do
1111+ subject.fetch_all('com.example.service.fetchAll', field: 'items')
1212+ verify_fetch_all
1313+ end
1414+1515+ it 'should return the parsed items' do
1616+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items')
1717+ result.should == ["one", "two", "three"]
1818+ end
1919+ end
2020+2121+ context 'when more than one page of items is returned' do
2222+ before do
2323+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
2424+ { "items": ["one", "two", "three"] },
2525+ { "items": ["four", "five"] },
2626+ ])
2727+ end
2828+2929+ it 'should make multiple requests, passing the last cursor' do
3030+ subject.fetch_all('com.example.service.fetchAll', field: 'items')
3131+ verify_fetch_all
3232+ end
3333+3434+ it 'should return all the parsed items collected from the responses' do
3535+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items')
3636+ result.should == ["one", "two", "three", "four", "five"]
3737+ end
3838+ end
3939+4040+ context 'when params are passed' do
4141+ before do
4242+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll?type=post", [
4343+ { "items": ["one", "two", "three"] },
4444+ { "items": ["four", "five"] },
4545+ ])
4646+ end
4747+4848+ it 'should add the params to the url' do
4949+ subject.fetch_all('com.example.service.fetchAll', { type: 'post' }, field: 'items')
5050+ verify_fetch_all
5151+ end
5252+ end
5353+5454+ context 'when params are an explicit nil' do
5555+ before do
5656+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
5757+ { "items": ["one", "two", "three"] },
5858+ { "items": ["four", "five"] },
5959+ ])
6060+ end
6161+6262+ it 'should not add anything to the url' do
6363+ subject.fetch_all('com.example.service.fetchAll', nil, field: 'items')
6464+ verify_fetch_all
6565+ end
6666+ end
6767+6868+ describe 'โฆ' do
6969+ before do
7070+ stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
7171+ .to_return_json(body: { "items": ["one", "two", "three"], "cursor": "ccc333" })
7272+7373+ stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc333")
7474+ .to_return_json(body: { "items": ["four", "five"] })
7575+ end
7676+7777+ include_examples "authorization",
7878+ request: ->(subject, params) {
7979+ subject.fetch_all('com.example.service.fetchAll', field: 'items', **params)
8080+ },
8181+ expected: ->(host) {[
8282+ [:get, "https://#{host}/xrpc/com.example.service.fetchAll"],
8383+ [:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc333"]
8484+ ]}
8585+ end
8686+8787+ context 'when break condition is passed' do
8888+ before do
8989+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
9090+ { "items": ["one", "two", "three"] },
9191+ { "items": ["four", "five"] },
9292+ { "items": ["six"] },
9393+ ])
9494+ end
9595+9696+ it 'should stop when a matching item is found' do
9797+ subject.fetch_all('com.example.service.fetchAll', field: 'items', break_when: ->(x) { x =~ /u/ })
9898+9999+ WebMock.should have_requested(:get, @stubbed_urls[0]).once
100100+ WebMock.should have_requested(:get, @stubbed_urls[1]).once
101101+ WebMock.should_not have_requested(:get, @stubbed_urls[2])
102102+ end
103103+104104+ it 'should filter out matching items from the response' do
105105+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items', break_when: ->(x) { x =~ /u/ })
106106+ result.should == ["one", "two", "three", "five"]
107107+ end
108108+ end
109109+110110+ context 'when max pages limit is passed' do
111111+ before do
112112+ stub_request(:get, %r(https://#{host}/xrpc/com.example.service.fetchAll(\?.*)?))
113113+ .to_return_json(
114114+ body: ->(req) {
115115+ params = req.uri.query_values || {}
116116+ page = params['cursor'].to_s.gsub(/page/, '').to_i
117117+ { items: ["item#{page}"], cursor: "page#{page + 1}" }
118118+ }
119119+ )
120120+ end
121121+122122+ context 'and break_when is not passed' do
123123+ it 'should stop at n-th page' do
124124+ subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5)
125125+126126+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
127127+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
128128+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
129129+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
130130+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
131131+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5")
132132+ end
133133+134134+ it 'should collect all items' do
135135+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5)
136136+ result.should == ["item0", "item1", "item2", "item3", "item4"]
137137+ end
138138+ end
139139+140140+ context 'and break_when matches earlier' do
141141+ it 'should stop at the page where break_when matches' do
142142+ subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5,
143143+ break_when: ->(x) { x =~ /3/ })
144144+145145+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
146146+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
147147+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
148148+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
149149+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4")
150150+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5")
151151+ end
152152+153153+ it 'should exclude items that matched break_when' do
154154+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5,
155155+ break_when: ->(x) { x =~ /3/ })
156156+157157+ result.should == ["item0", "item1", "item2"]
158158+ end
159159+ end
160160+161161+ context "and break_when doesn't match earlier" do
162162+ it 'should stop at the n-th page' do
163163+ subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
164164+ break_when: ->(x) { x =~ /8/ })
165165+166166+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
167167+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
168168+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
169169+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
170170+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
171171+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5").once
172172+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page6")
173173+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page7")
174174+ end
175175+176176+ it 'should include all items up to n-th page' do
177177+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
178178+ break_when: ->(x) { x =~ /8/ })
179179+180180+ result.should == ["item0", "item1", "item2", "item3", "item4", "item5"]
181181+ end
182182+ end
183183+184184+ context "and break_when matches on the last page" do
185185+ it 'should stop at the n-th page' do
186186+ subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
187187+ break_when: ->(x) { x =~ /5/ })
188188+189189+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
190190+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
191191+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
192192+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
193193+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
194194+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5").once
195195+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page6")
196196+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page7")
197197+ end
198198+199199+ it 'should exclude the items matching on the last page' do
200200+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
201201+ break_when: ->(x) { x =~ /5/ })
202202+203203+ result.should == ["item0", "item1", "item2", "item3", "item4"]
204204+ end
205205+ end
206206+ end
207207+208208+ context 'when an empty page is received, but with a cursor' do
209209+ before do
210210+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
211211+ { "feed": ["one", "two", "three"] },
212212+ { "feed": [] },
213213+ { "feed": ["six"] },
214214+ ])
215215+ end
216216+217217+ it 'should continue fetching until the cursor is nil' do
218218+ result = subject.fetch_all('com.example.service.fetchAll', field: 'feed')
219219+ result.should == ['one', 'two', 'three', 'six']
220220+ end
221221+ end
222222+223223+ context 'when field is not passed' do
224224+ before do
225225+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
226226+ { "thingies": ["one", "two", "three"], "best": "two", "foobars": ["foo", "bar"], "total": 6 },
227227+ { "items": ["four", "five"] },
228228+ ])
229229+ end
230230+231231+ it 'should make one request and raise an error with list of array fields' do
232232+ expect { subject.fetch_all('com.example.service.fetchAll') }.to raise_error { |err|
233233+ err.should be_a(Minisky::FieldNotSetError)
234234+ err.fields.should == ['thingies', 'foobars']
235235+ }
236236+237237+ WebMock.should have_requested(:get, @stubbed_urls[0]).once
238238+ WebMock.should_not have_requested(:get, @stubbed_urls[1])
239239+ end
240240+ end
241241+242242+ context 'when field is nil' do
243243+ before do
244244+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
245245+ { "thingies": ["one", "two", "three"], "best": "two", "foobars": ["foo", "bar"], "total": 6 },
246246+ { "items": ["four", "five"] },
247247+ ])
248248+ end
249249+250250+ it 'should make one request and raise an error with list of array fields' do
251251+ expect { subject.fetch_all('com.example.service.fetchAll', field: nil) }.to raise_error { |err|
252252+ err.should be_a(Minisky::FieldNotSetError)
253253+ err.fields.should == ['thingies', 'foobars']
254254+ }
255255+256256+ WebMock.should have_requested(:get, @stubbed_urls[0]).once
257257+ WebMock.should_not have_requested(:get, @stubbed_urls[1])
258258+ end
259259+ end
260260+261261+ describe 'progress param' do
262262+ before do
263263+ stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
264264+ .to_return_json(body: { "items": ["one"], "cursor": "page1" })
265265+266266+ stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1")
267267+ .to_return_json(body: { "items": ["two"], "cursor": "page2" })
268268+269269+ stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2")
270270+ .to_return_json(body: { "items": ["three"] })
271271+ end
272272+273273+ context 'when it is passed' do
274274+ it 'should print the progress character for each request' do
275275+ expect {
276276+ subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: '-=')
277277+ }.to output('-=-=-=').to_stdout
278278+ end
279279+ end
280280+281281+ context 'when it is not passed' do
282282+ it 'should not print anything' do
283283+ expect {
284284+ subject.fetch_all('com.example.service.fetchAll', field: 'items')
285285+ }.to output('').to_stdout
286286+ end
287287+ end
288288+289289+ context 'when it is passed and a default is set' do
290290+ it 'should use the param version' do
291291+ subject.default_progress = '@'
292292+293293+ expect {
294294+ subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: '#')
295295+ }.to output('###').to_stdout
296296+ end
297297+ end
298298+299299+ context 'when it is not passed and a default is set' do
300300+ it 'should use the default version' do
301301+ subject.default_progress = '$'
302302+303303+ expect {
304304+ subject.fetch_all('com.example.service.fetchAll', field: 'items')
305305+ }.to output('$$$').to_stdout
306306+ end
307307+ end
308308+309309+ context 'when default is set and nil is passed' do
310310+ it 'should not output anything' do
311311+ subject.default_progress = '$'
312312+313313+ expect {
314314+ subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: nil)
315315+ }.to output('').to_stdout
316316+ end
317317+ end
318318+319319+ context 'when default is set and false is passed' do
320320+ it 'should not output anything' do
321321+ subject.default_progress = '$'
322322+323323+ expect {
324324+ subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: false)
325325+ }.to output('').to_stdout
326326+ end
327327+ end
328328+ end
329329+ end
330330+end
+81
spec/shared/ex_get_request.rb
···11+require_relative 'ex_authorization'
22+require_relative 'ex_bad_response'
33+44+shared_examples "get_request" do
55+ describe '#get_request' do
66+ before do
77+ stub_request(:get, %r(https://#{host}/xrpc/com.example.service.getStuff(\?.*)?)).to_return(response)
88+ end
99+1010+ let(:response) {{ body: JSON.generate({ "result": 123 }), headers: { content_type: 'application/json' }}}
1111+1212+ it 'should make a request to the given XRPC endpoint' do
1313+ subject.get_request('com.example.service.getStuff')
1414+1515+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
1616+ end
1717+1818+ it 'should return parsed JSON' do
1919+ result = subject.get_request('com.example.service.getStuff')
2020+2121+ result.should == { 'result' => 123 }
2222+ end
2323+2424+ context 'with params' do
2525+ it 'should append params to the URL' do
2626+ subject.get_request('com.example.service.getStuff', { repo: 'whitehouse.gov', limit: 80 })
2727+2828+ WebMock.should have_requested(:get,
2929+ "https://#{host}/xrpc/com.example.service.getStuff?repo=whitehouse.gov&limit=80").once
3030+ end
3131+ end
3232+3333+ context 'with nil params' do
3434+ it 'should not append anything to the URL' do
3535+ subject.get_request('com.example.service.getStuff', nil)
3636+3737+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
3838+ end
3939+ end
4040+4141+ context 'with empty params' do
4242+ it 'should not append anything to the URL' do
4343+ subject.get_request('com.example.service.getStuff', {})
4444+4545+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
4646+ end
4747+ end
4848+4949+ context 'with an array passed as param' do
5050+ it 'should append one copy of the param for each item' do
5151+ subject.get_request('com.example.service.getStuff', { profiles: ['john.foo', 'spam.zip'], reposts: true })
5252+5353+ WebMock.should have_requested(:get,
5454+ "https://#{host}/xrpc/com.example.service.getStuff?profiles=john.foo&profiles=spam.zip&reposts=true").once
5555+ end
5656+ end
5757+5858+ context 'with headers' do
5959+ it 'should include the custom headers' do
6060+ subject.get_request('com.example.service.getStuff', { user: 'alf.gov' }, headers: { 'Food': 'cats' })
6161+6262+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff?user=alf.gov").once
6363+ .with(headers: { 'Food' => 'cats' })
6464+ end
6565+ end
6666+6767+ context 'with an invalid method name' do
6868+ it 'should raise an ArgumentError' do
6969+ INVALID_METHOD_NAMES.each do |m|
7070+ expect { subject.get_request(m) }.to raise_error(ArgumentError)
7171+ end
7272+ end
7373+ end
7474+7575+ include_examples "bad response handling", :get, 'com.example.service.getStuff'
7676+7777+ include_examples "authorization",
7878+ request: ->(subject, params) { subject.get_request('com.example.service.getStuff', **params) },
7979+ expected: ->(host) { [:get, "https://#{host}/xrpc/com.example.service.getStuff"] }
8080+ end
8181+end
+36
spec/shared/ex_incomplete_auth.rb
···11+shared_examples "custom client with incomplete auth" do
22+ it 'should have send_auth_headers enabled' do
33+ subject.send_auth_headers.should == true
44+ end
55+66+ it 'should have auto_manage_tokens enabled' do
77+ subject.auto_manage_tokens.should == true
88+ end
99+1010+ it 'should fail on get_request' do
1111+ expect { subject.get_request('com.example.service.getStuff') }.to raise_error(Minisky::AuthError)
1212+ end
1313+1414+ it 'should fail on post_request' do
1515+ expect { subject.post_request('com.example.service.doStuff', 'qqq') }.to raise_error(Minisky::AuthError)
1616+ end
1717+1818+ it 'should fail on fetch_all' do
1919+ expect { subject.fetch_all('com.example.service.fetchStuff', {}, field: 'feed') }.to raise_error(Minisky::AuthError)
2020+ end
2121+2222+ it 'should fail on check_access' do
2323+ expect { subject.check_access }.to raise_error(Minisky::AuthError)
2424+ end
2525+2626+ it 'should fail on log_in' do
2727+ expect { subject.log_in }.to raise_error(Minisky::AuthError)
2828+ end
2929+3030+ it 'should fail on perform_token_refresh' do
3131+ expect { subject.perform_token_refresh }.to raise_error(Minisky::AuthError)
3232+ end
3333+3434+ # todo perform w/ access token
3535+ # todo test if properties turned off
3636+end
+225
spec/shared/ex_post_request.rb
···11+require_relative 'ex_authorization'
22+require_relative 'ex_bad_response'
33+44+shared_examples "post_request" do
55+ describe '#post_request' do
66+ let(:response) {{ body: '{ "result": "ok" }', headers: { 'Content-Type': 'application/json' }}}
77+88+ before do
99+ stub_request(:post, "https://#{host}/xrpc/com.example.service.doStuff").to_return(response)
1010+ end
1111+1212+ it 'should make a request to the given XRPC endpoint' do
1313+ subject.post_request('com.example.service.doStuff')
1414+1515+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
1616+ end
1717+1818+ it 'should return parsed JSON' do
1919+ result = subject.post_request('com.example.service.doStuff')
2020+2121+ result.should == { 'result' => 'ok' }
2222+ end
2323+2424+ context 'if data is passed as a hash' do
2525+ let(:post_data) {
2626+ { repo: 'kate.dev', limit: 40, fields: ['name', 'posts'] }
2727+ }
2828+2929+ it 'should encode it as JSON in the body' do
3030+ subject.post_request('com.example.service.doStuff', post_data)
3131+3232+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
3333+ .with(body: JSON.generate(post_data))
3434+ end
3535+3636+ it 'should set content type to application/json' do
3737+ subject.post_request('com.example.service.doStuff', post_data)
3838+3939+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
4040+ .with(headers: { 'Content-Type': 'application/json' })
4141+ end
4242+4343+ context 'and custom content-type is set' do
4444+ it 'should use that custom Content-Type' do
4545+ subject.post_request('com.example.service.doStuff', post_data, headers: { 'Content-Type': 'application/graphql' })
4646+4747+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
4848+ .with(headers: { 'Content-Type': 'application/graphql' })
4949+ end
5050+ end
5151+5252+ context 'and custom content-type in set in lowercase' do
5353+ it 'should still use that custom Content-Type' do
5454+ subject.post_request('com.example.service.doStuff', post_data, headers: { 'content-type': 'application/graphql' })
5555+5656+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
5757+ .with(headers: { 'content-type': 'application/graphql' })
5858+ end
5959+ end
6060+6161+ context 'and other custom header is set' do
6262+ it 'should add a json content type' do
6363+ subject.post_request('com.example.service.doStuff', post_data, headers: { 'X-API-Token': '8768768768' })
6464+6565+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
6666+ .with(headers: { 'Content-Type': 'application/json', 'X-API-Token': '8768768768' })
6767+ end
6868+ end
6969+ end
7070+7171+ context 'if data is not passed' do
7272+ it 'should send an empty body' do
7373+ subject.post_request('com.example.service.doStuff')
7474+7575+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
7676+ .with(body: '')
7777+ end
7878+7979+ it 'should not set content type' do
8080+ subject.post_request('com.example.service.doStuff')
8181+8282+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
8383+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' }}
8484+ end
8585+8686+ context 'and custom content-type is set' do
8787+ it 'should include the custom Content-Type' do
8888+ subject.post_request('com.example.service.doStuff', headers: { 'Content-Type': 'image/png' })
8989+9090+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
9191+ .with(body: '', headers: { 'Content-Type': 'image/png' })
9292+ end
9393+ end
9494+9595+ context 'and custom content-type in set in lowercase' do
9696+ it 'should include the custom Content-Type' do
9797+ subject.post_request('com.example.service.doStuff', headers: { 'content-type': 'image/jpeg' })
9898+9999+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
100100+ .with(body: '', headers: { 'content-type': 'image/jpeg' })
101101+ end
102102+ end
103103+104104+ context 'and other custom header is set' do
105105+ it 'should not add content type' do
106106+ subject.post_request('com.example.service.doStuff', headers: { 'Blob-Type': 'blobby' })
107107+108108+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
109109+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' } && req.headers['Blob-Type'] == 'blobby' }
110110+ end
111111+ end
112112+ end
113113+114114+ context 'if data is an explicit nil' do
115115+ it 'should send an empty body' do
116116+ subject.post_request('com.example.service.doStuff', nil)
117117+118118+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
119119+ .with(body: '')
120120+ end
121121+122122+ it 'should not set content type' do
123123+ subject.post_request('com.example.service.doStuff', nil)
124124+125125+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
126126+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' }}
127127+ end
128128+129129+ context 'and custom content-type is set' do
130130+ it 'should include the custom Content-Type' do
131131+ subject.post_request('com.example.service.doStuff', nil, headers: { 'Content-Type': 'image/png' })
132132+133133+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
134134+ .with(body: '', headers: { 'Content-Type': 'image/png' })
135135+ end
136136+ end
137137+138138+ context 'and custom content-type in set in lowercase' do
139139+ it 'should include the custom Content-Type' do
140140+ subject.post_request('com.example.service.doStuff', nil, headers: { 'content-type': 'image/jpeg' })
141141+142142+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
143143+ .with(body: '', headers: { 'content-type': 'image/jpeg' })
144144+ end
145145+ end
146146+147147+ context 'and other custom header is set' do
148148+ it 'should not add content type' do
149149+ subject.post_request('com.example.service.doStuff', nil, headers: { 'Blob-Type': 'blobby' })
150150+151151+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
152152+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' } && req.headers['Blob-Type'] == 'blobby' }
153153+ end
154154+ end
155155+ end
156156+157157+ context 'if data is a string' do
158158+ it 'should send that string' do
159159+ subject.post_request('com.example.service.doStuff', 'hello world')
160160+161161+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
162162+ .with(body: 'hello world')
163163+ end
164164+165165+ it 'should not set content type' do
166166+ subject.post_request('com.example.service.doStuff', 'hello world')
167167+168168+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
169169+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' }}
170170+ end
171171+172172+ context 'and custom content-type is set' do
173173+ it 'should include the custom Content-Type' do
174174+ subject.post_request('com.example.service.doStuff', 'blob', headers: { 'Content-Type': 'image/png' })
175175+176176+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
177177+ .with(body: 'blob', headers: { 'Content-Type': 'image/png' })
178178+ end
179179+ end
180180+181181+ context 'and custom content-type in set in lowercase' do
182182+ it 'should include the custom Content-Type' do
183183+ subject.post_request('com.example.service.doStuff', 'blob', headers: { 'content-type': 'image/jpeg' })
184184+185185+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
186186+ .with(body: 'blob', headers: { 'content-type': 'image/jpeg' })
187187+ end
188188+ end
189189+190190+ context 'and other custom header is set' do
191191+ it 'should not add content type' do
192192+ subject.post_request('com.example.service.doStuff', 'blob', headers: { 'Blob-Type': 'blobby' })
193193+194194+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
195195+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' } && req.headers['Blob-Type'] == 'blobby' }
196196+ end
197197+ end
198198+ end
199199+200200+ context 'with both string data and query params' do
201201+ it 'should add the params to the URL' do
202202+ stub_request(:post, "https://#{host}/xrpc/app.bsky.video.uploadVideo?name=rickroll.mp4").to_return(response)
203203+204204+ subject.post_request('app.bsky.video.uploadVideo', '/\/\/\/\/\/\/', params: { name: 'rickroll.mp4' })
205205+206206+ WebMock.should have_requested(:post, "https://#{host}/xrpc/app.bsky.video.uploadVideo?name=rickroll.mp4").once
207207+ .with(body: '/\/\/\/\/\/\/')
208208+ end
209209+ end
210210+211211+ context 'with an invalid method name' do
212212+ it 'should raise an ArgumentError' do
213213+ INVALID_METHOD_NAMES.each do |m|
214214+ expect { subject.post_request(m) }.to raise_error(ArgumentError)
215215+ end
216216+ end
217217+ end
218218+219219+ include_examples "bad response handling", :post, 'com.example.service.doStuff'
220220+221221+ include_examples "authorization",
222222+ request: ->(subject, params) { subject.post_request('com.example.service.doStuff', **params) },
223223+ expected: ->(host) { [:post, "https://#{host}/xrpc/com.example.service.doStuff"] }
224224+ end
225225+end
+159
spec/shared/ex_requests.rb
···11+require_relative 'ex_get_request'
22+require_relative 'ex_post_request'
33+require_relative 'ex_fetch_all'
44+55+shared_examples "authenticated requests" do |host|
66+ let(:host) { host }
77+88+ before do
99+ subject.auto_manage_tokens = false
1010+ end
1111+1212+ it 'should have a user object wrapping the config' do
1313+ subject.config['something'] = 'some value'
1414+1515+ subject.user.something.should == 'some value'
1616+ end
1717+1818+ describe '#log_in' do
1919+ let(:response_json) {{
2020+ "did" => "did:plc:abracadabra",
2121+ "accessJwt" => "aaaa1234",
2222+ "refreshJwt" => "rrrr1234"
2323+ }}
2424+2525+ before do
2626+ stub_request(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
2727+ .to_return_json(body: response_json)
2828+ end
2929+3030+ it 'should make a request to com.atproto.server.createSession' do
3131+ subject.log_in
3232+3333+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
3434+ .once.with(body: %({"identifier":"john.foo","password":"hunter2"}))
3535+ end
3636+3737+ [true, false, nil, :undefined, 'wtf'].each do |v|
3838+ context "with send_auth_headers set to #{v.inspect}" do
3939+ it 'should not set authentication header' do
4040+ subject.send_auth_headers = v unless v == :undefined
4141+ subject.log_in
4242+4343+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
4444+ WebMock.should_not have_requested(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
4545+ .with(headers: { 'Authorization' => /.*/ })
4646+ end
4747+ end
4848+ end
4949+5050+ it "should save user's DID" do
5151+ subject.log_in
5252+5353+ reloaded_config['did'].should == "did:plc:abracadabra"
5454+ end
5555+5656+ it "should update the tokens in the config file" do
5757+ subject.log_in
5858+5959+ reloaded_config['access_token'].should == 'aaaa1234'
6060+ reloaded_config['refresh_token'].should == 'rrrr1234'
6161+ end
6262+6363+ it 'should return the response json' do
6464+ subject.log_in.should == response_json
6565+ end
6666+ end
6767+6868+ describe '#perform_token_refresh' do
6969+ let(:response_json) {{
7070+ "accessJwt" => "aaaa1234",
7171+ "refreshJwt" => "rrrr1234"
7272+ }}
7373+7474+ before do
7575+ stub_request(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
7676+ .to_return_json(body: response_json)
7777+ end
7878+7979+ it 'should make a request to com.atproto.server.refreshSession' do
8080+ subject.perform_token_refresh
8181+8282+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
8383+ .once.with(body: '')
8484+ end
8585+8686+ [true, false, nil, :undefined, 'wtf'].each do |v|
8787+ context "with send_auth_headers set to #{v.inspect}" do
8888+ it 'should authenticate with the refresh token' do
8989+ subject.send_auth_headers = v unless v == :undefined
9090+ subject.perform_token_refresh
9191+9292+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
9393+ .once.with(headers: { 'Authorization' => 'Bearer rrtoken' })
9494+ end
9595+ end
9696+ end
9797+9898+ it "should update the tokens in the config file" do
9999+ subject.perform_token_refresh
100100+101101+ reloaded_config['access_token'].should == 'aaaa1234'
102102+ reloaded_config['refresh_token'].should == 'rrrr1234'
103103+ end
104104+105105+ it 'should return the response json' do
106106+ subject.perform_token_refresh.should == response_json
107107+ end
108108+109109+ context 'if refresh_token is nil' do
110110+ before do
111111+ subject.user.refresh_token = nil
112112+ end
113113+114114+ it 'should raise AuthError' do
115115+ expect { subject.perform_token_refresh }.to raise_error(Minisky::AuthError)
116116+ end
117117+ end
118118+119119+ context 'if refresh_token is not provided' do
120120+ before do
121121+ subject.config.delete('refresh_token')
122122+ end
123123+124124+ it 'should raise AuthError' do
125125+ expect { subject.perform_token_refresh }.to raise_error(Minisky::AuthError)
126126+ end
127127+ end
128128+ end
129129+130130+ describe '#reset_tokens' do
131131+ it 'should set tokens to nil' do
132132+ subject.reset_tokens
133133+134134+ subject.user.access_token.should be_nil
135135+ subject.user.refresh_token.should be_nil
136136+ end
137137+138138+ it 'should save the config to disk' do
139139+ subject.reset_tokens
140140+141141+ config = reloaded_config
142142+143143+ config['access_token'].should be_nil
144144+ config['refresh_token'].should be_nil
145145+ end
146146+147147+ context 'if tokens are already nil' do
148148+ it 'should not raise error' do
149149+ subject.reset_tokens
150150+151151+ expect { subject.reset_tokens }.not_to raise_error
152152+ end
153153+ end
154154+ end
155155+156156+ include_examples "get_request"
157157+ include_examples "post_request"
158158+ include_examples "fetch_all"
159159+end
+119
spec/shared/ex_unauthed.rb
···11+shared_examples "unauthenticated user" do
22+ let(:host) { subject.host }
33+44+ describe '#log_in' do
55+ it 'should raise AuthError' do
66+ expect { subject.log_in }.to raise_error(Minisky::AuthError)
77+ end
88+ end
99+1010+ describe '#perform_token_refresh' do
1111+ it 'should raise AuthError' do
1212+ expect { subject.perform_token_refresh }.to raise_error(Minisky::AuthError)
1313+ end
1414+ end
1515+1616+ describe '#check_access' do
1717+ it 'should raise AuthError' do
1818+ expect { subject.check_access }.to raise_error(Minisky::AuthError)
1919+ end
2020+ end
2121+2222+ describe '#reset_tokens' do
2323+ it 'should raise AuthError' do
2424+ expect { subject.reset_tokens }.to raise_error(Minisky::AuthError)
2525+ end
2626+ end
2727+2828+ context '#user' do
2929+ it 'should return nil' do
3030+ subject.user.should be_nil
3131+ end
3232+ end
3333+3434+ context 'with auth headers off' do
3535+ describe '#get_request' do
3636+ it 'should not raise errors' do
3737+ stub_request(:get, "https://#{host}/xrpc/com.example.service.getTrends").to_return_json(body: { result: 123 })
3838+3939+ expect { subject.get_request('com.example.service.getTrends') }.to_not raise_error
4040+4141+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getTrends").once
4242+ end
4343+ end
4444+4545+ describe '#post_request' do
4646+ it 'should not raise errors' do
4747+ stub_request(:post, "https://#{host}/xrpc/com.example.service.createApp").to_return_json(body: { result: 123 })
4848+4949+ expect { subject.post_request('com.example.service.createApp') }.to_not raise_error
5050+5151+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.createApp").once
5252+ end
5353+ end
5454+5555+ describe '#fetch_all' do
5656+ it 'should not raise errors' do
5757+ stub_request(:get, "https://#{host}/xrpc/com.example.service.listRepos")
5858+ .to_return_json(body: { "repos": ["aaa"], "cursor": "x123" })
5959+6060+ stub_request(:get, "https://#{host}/xrpc/com.example.service.listRepos?cursor=x123")
6161+ .to_return_json(body: { "repos": ["bbb"] })
6262+6363+ expect { subject.fetch_all('com.example.service.listRepos', field: 'repos') }.to_not raise_error
6464+6565+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.listRepos").once
6666+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.listRepos?cursor=x123").once
6767+ end
6868+ end
6969+ end
7070+7171+ context 'with sending auth headers turned on' do
7272+ before do
7373+ subject.send_auth_headers = true
7474+ end
7575+7676+ describe '#get_request' do
7777+ it 'should raise an error' do
7878+ expect { subject.get_request('com.example.service.getTrends') }.to raise_error(Minisky::AuthError)
7979+ end
8080+ end
8181+8282+ describe '#post_request' do
8383+ it 'should not raise errors' do
8484+ expect { subject.post_request('com.example.service.createApp') }.to raise_error(Minisky::AuthError)
8585+ end
8686+ end
8787+8888+ describe '#fetch_all' do
8989+ it 'should not raise errors' do
9090+ expect { subject.fetch_all('com.example.service.listRepos', field: 'repos') }.to raise_error(Minisky::AuthError)
9191+ end
9292+ end
9393+ end
9494+9595+ context 'with sending auth headers & auto manage tokens turned on' do
9696+ before do
9797+ subject.send_auth_headers = true
9898+ subject.auto_manage_tokens = true
9999+ end
100100+101101+ describe '#get_request' do
102102+ it 'should raise an error' do
103103+ expect { subject.get_request('com.example.service.getTrends') }.to raise_error(Minisky::AuthError)
104104+ end
105105+ end
106106+107107+ describe '#post_request' do
108108+ it 'should not raise errors' do
109109+ expect { subject.post_request('com.example.service.createApp') }.to raise_error(Minisky::AuthError)
110110+ end
111111+ end
112112+113113+ describe '#fetch_all' do
114114+ it 'should not raise errors' do
115115+ expect { subject.fetch_all('com.example.service.listRepos', field: 'repos') }.to raise_error(Minisky::AuthError)
116116+ end
117117+ end
118118+ end
119119+end
+5
spec/shared/fake_irb.rb
···11+class IRB
22+ def self.CurrentContext
33+ {}
44+ end
55+end
+5
spec/shared/fake_pry.rb
···11+class Pry
22+ def self.cli
33+ {}
44+ end
55+end
+39
spec/spec_config.rb
···11+require 'simplecov'
22+33+SimpleCov.start do
44+ enable_coverage :branch
55+ add_filter "/spec/"
66+end
77+88+require 'minisky'
99+require 'pp' # needs to be included before fakefs
1010+require 'fakefs/spec_helpers'
1111+require 'webmock/rspec'
1212+1313+RSpec.configure do |config|
1414+ # Enable flags like --only-failures and --next-failure
1515+ config.example_status_persistence_file_path = ".rspec_status"
1616+1717+ config.expect_with :rspec do |c|
1818+ c.syntax = [:should, :expect]
1919+ end
2020+end
2121+2222+module SimpleCov
2323+ module Formatter
2424+ class HTMLFormatter
2525+ def format(result)
2626+ # silence the stdout summary, just save the html files
2727+ unless @inline_assets
2828+ Dir[File.join(@public_assets_dir, "*")].each do |path|
2929+ FileUtils.cp_r(path, asset_output_path, remove_destination: true)
3030+ end
3131+ end
3232+3333+ File.open(File.join(output_path, "index.html"), "wb") do |file|
3434+ file.puts template("layout").result(binding)
3535+ end
3636+ end
3737+ end
3838+ end
3939+end
+42-10
spec/spec_helper.rb
···11-require 'minisky'
22-require 'pp' # needs to be included before fakefs
11+require_relative 'spec_config'
22+require 'base64'
33+require 'json'
3444-require 'fakefs/spec_helpers'
55-require 'webmock/rspec'
55+INVALID_METHOD_NAMES = [
66+ 'getUsers',
77+ '127.0.0.1',
88+ '/xrpc/com.atproto.repo.getRecords',
99+ 'app.bsky.feed.under_score'
1010+]
61177-require 'requests_shared'
1212+def stub_fetch_all(base_url, responses)
1313+ cursor = nil
1414+ urls = []
81599-RSpec.configure do |config|
1010- # Enable flags like --only-failures and --next-failure
1111- config.example_status_persistence_file_path = ".rspec_status"
1616+ responses.each_with_index do |r, i|
1717+ url = base_url
1818+ body = r
12191313- config.expect_with :rspec do |c|
1414- c.syntax = [:should, :expect]
2020+ if cursor
2121+ url += (url.include?('?') ? '&' : '?') + "cursor=#{cursor}"
2222+ end
2323+2424+ if i < responses.length - 1
2525+ cursor = rand.to_s
2626+ body = body.merge("cursor" => cursor)
2727+ end
2828+2929+ stub_request(:get, url).to_return_json(body: body)
3030+ urls << url
1531 end
3232+3333+ @stubbed_urls = urls
3434+end
3535+3636+def verify_fetch_all
3737+ @stubbed_urls.each do |url|
3838+ WebMock.should have_requested(:get, url).once
3939+ end
4040+end
4141+4242+def make_token(exp_time)
4343+ header = { alg: 'HS256', typ: 'JWT' }
4444+ payload = { exp: exp_time.to_i }
4545+ signature = 'signature'
4646+4747+ [header, payload, signature].map { |part| Base64.strict_encode64(JSON.generate(part)) }.join('.')
1648end
+21
spec/user_spec.rb
···1313 subject.email.should == 'admin@bsky.app'
1414 end
15151616+ it 'should pass setters to the config hash' do
1717+ subject.age = 33
1818+ config['age'].should == 33
1919+ end
2020+1621 context '#logged_in?' do
1722 it 'should return false if access token is missing' do
1823 subject.logged_in?.should be false
···3035 subject.instance_variable_get('@config')['refresh_token'] = 'rrrr'
3136 subject.instance_variable_get('@config')['access_token'] = 'aaaa'
3237 subject.logged_in?.should be true
3838+ end
3939+ end
4040+4141+ context '#has_credentials?' do
4242+ it 'should return false if id is missing' do
4343+ subject.instance_variable_get('@config')['id'] = nil
4444+ subject.has_credentials?.should be false
4545+ end
4646+4747+ it 'should return false if pass is missing' do
4848+ subject.instance_variable_get('@config')['pass'] = nil
4949+ subject.has_credentials?.should be false
5050+ end
5151+5252+ it 'should return true if both id and pass are set' do
5353+ subject.has_credentials?.should be true
3354 end
3455 end
3556end