···0000000000000000000000000000000000000000000000000000001## [0.2.0] - 2023-09-02
23* more consistent handling of parameters in the main methods:
···13* renamed `ident` field in the config hash to `id`
14* config is now accessed in `Requests` from the client object as a `config` property instead of `@config` ivar
15* config fields are exposed as a `user` wrapper object, e.g. `user.did` delegates to `@config['did']`
16-17## [0.1.0] - 2023-09-01
1819- extracted most code to a `Requests` module that can be included into a different client class with custom config handling
···2425## [0.0.1] - 2023-08-30
2627-Initial release - extracted from original gist:
2829- logging in and refreshing the token
30- making GET & POST requests
···1+## Unreleased
2+3+The "really niche bugfix" edition:
4+5+* 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
6+* 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)
7+* 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
8+ - note: at the moment Minisky will not catch the "token expired" error and refresh the token automatically in such scenario
9+* allow connecting to non-HTTPS servers (e.g. `http://localhost:3000`)
10+* 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
11+* 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
12+* fixed URL query params in POST requests on Ruby 2.x
13+* marked `Minisky#active_repl?` method as private
14+15+Also added YARD API documentation for most of the code.
16+17+## [0.5.0] - 2024-12-27 ๐
18+19+* `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)
20+* 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
21+* 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`)
22+* `default_progress` is set by default to show progress using dots (`.`) if Minisky is loaded inside an IRB or Pry context
23+* 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
24+* added `access_token_expired?` helper method
25+* moved `token_expiration_date` to public methods
26+* `check_access` now returns a result symbol: `:logged_in`, `:refreshed` or `:ok`
27+* fixed `method_missing` setter on `User`
28+29+## [0.4.0] - 2024-03-31 ๐ฃ
30+31+* allow passing non-JSON body to requests (e.g. when uploading blobs)
32+* allow passing custom headers to requests, including overriding `Content-Type`
33+* fixed error when the response is success but not JSON (e.g. an empty body like in deleteRecord)
34+* allow passing options to the client in the initializer
35+* aliased `default_progress` setting as `progress`
36+* 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
37+38+## [0.3.1] - 2023-10-10
39+40+* fixed Minisky not working on Ruby 2.x
41+42+## [0.3.0] - 2023-10-05
43+44+* authentication improvements & changes:
45+ - Minisky now automatically manages access tokens, calling `check_access` manually is not necessary (set `auto_manage_tokens` to `false` to disable this)
46+ - `check_access` now just checks token's expiry time instead of making a request to `getSession`
47+ - added `send_auth_headers` option โ set to `false` to not set auth header automatically, which is the default
48+ - removed default config file name โ explicit file name is now required
49+ - Minisky can now be used in unauthenticated mode โ pass `nil` as the config file name
50+ - added `reset_tokens` helper method
51+* refactored response handling โ typed errors are now raised on non-success response status
52+* `user` wrapper can also be used for writing fields to the config
53+* improved error handling
54+55## [0.2.0] - 2023-09-02
5657* more consistent handling of parameters in the main methods:
···67* renamed `ident` field in the config hash to `id`
68* config is now accessed in `Requests` from the client object as a `config` property instead of `@config` ivar
69* config fields are exposed as a `user` wrapper object, e.g. `user.did` delegates to `@config['did']`
70+71## [0.1.0] - 2023-09-01
7273- extracted most code to a `Requests` module that can be included into a different client class with custom config handling
···7879## [0.0.1] - 2023-08-30
8081+Initial release โ extracted from original gist:
8283- logging in and refreshing the token
84- making GET & POST requests
···1The zlib License
23-Copyright (c) 2023 Jakub Suder
45This software is provided 'as-is', without any express or implied
6warranty. In no event will the authors be held liable for any damages
···1The zlib License
23+Copyright (c) 2026 Jakub Suder
45This software is provided 'as-is', without any express or implied
6warranty. In no event will the authors be held liable for any damages
+78-25
README.md
···1-# Minisky
23Minisky 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.
00000456## Installation
78-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.
910To install the Minisky gem, run the command:
1112 [sudo] gem install minisky
1314-Or alternatively, add it to the `Gemfile` file for Bundler:
1516- gem 'minisky', '~> 0.2'
171819## Usage
2021-First, you need to create a `.yml` config file (by default, `bluesky.yml`) with the authentication data. It should look like this:
0000000000000000000000000000000002223```yaml
24id: my.bsky.username
···2728The `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.
2900030After 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`.
3132-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):
3334```rb
35require 'minisky'
3637-bsky = Minisky.new('bsky.social')
38-bsky.check_access
39```
4041-`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.
0004243-Now, you can make requests to the Bluesky API using `get_request` and `post_request`:
4445```rb
46-bsky.get_request('com.atproto.repo.listRecords', {
47 repo: bsky.user.did,
48 collection: 'app.bsky.feed.like'
49})
50000051bsky.post_request('com.atproto.repo.createRecord', {
52 repo: bsky.user.did,
53 collection: 'app.bsky.feed.post',
54 record: {
55 text: "Hello world!",
56- createdAt: Time.now.iso8601
057 }
58})
59```
6061-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.
6263-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:
6465```rb
66time_limit = Time.now - 86400 * 30
6768-bsky.fetch_all('com.atproto.repo.listRecords',
69 { repo: bsky.user.did, collection: 'app.bsky.feed.post' },
70 field: 'records',
71 max_pages: 10,
···75There 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:
7677```rb
78-bsky.fetch_all('com.atproto.repo.listRecords',
79 { repo: bsky.user.did, collection: 'app.bsky.feed.like' },
80 field: 'records',
81 progress: '.')
···87.................
88```
8900090## Customization
9192-The `Minisky` client currently supports one configuration option:
0000009394-- `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `nil`)
9596-When creating the `Minisky` instance, you can pass a name of the YAML config file to use instead of the default:
9798-```rb
99-bsky = Minisky.new('bsky.social', 'config/access.yml')
100-```
101102-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:
103104```rb
105class BlueskyClient
···126127```rb
128bsky = BlueskyClient.new('config/access.json')
129-bsky.check_access
130bsky.get_request(...)
131```
132···139140## Credits
141142-Copyright ยฉ 2023 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
143144The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
145
···1+# Minisky ๐ค
23Minisky 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.
4+5+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.
6+7+> [!NOTE]
8+> Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
91011## Installation
1213+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/)).
1415To install the Minisky gem, run the command:
1617 [sudo] gem install minisky
1819+Or add it to your app's `Gemfile`:
2021+ gem 'minisky', '~> 0.5'
222324## Usage
2526+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.
27+28+29+### Unauthenticated access
30+31+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.
32+33+This allows you to do things like:
34+35+- look up specific records or lists of all records of a given type in any account (in their raw form)
36+- look up profile information about any account
37+- load complete threads or users' profile feeds from the AppView
38+39+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:
40+41+```rb
42+require 'minisky'
43+44+bsky = Minisky.new('api.bsky.app', nil)
45+```
46+47+> [!NOTE]
48+> 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.
49+>
50+> 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.
51+>
52+> 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.
53+54+55+### Authenticated access
56+57+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.
58+59+First, you need to create a `.yml` config file with the authentication data, e.g. `bluesky.yml`. It should look like this:
6061```yaml
62id: my.bsky.username
···6566The `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.
6768+> [!NOTE]
69+> 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.
70+71After 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`.
7273+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:
7475```rb
76require 'minisky'
7778+bsky = Minisky.new('bsky.social', 'bluesky.yml')
079```
8081+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.
82+83+84+### Making requests
8586+With a `Minisky` client instance, you can make requests to the Bluesky API using `get_request` and `post_request`:
8788```rb
89+json = bsky.get_request('com.atproto.repo.listRecords', {
90 repo: bsky.user.did,
91 collection: 'app.bsky.feed.like'
92})
9394+json['records'].each do |r|
95+ puts r['value']['subject']['uri']
96+end
97+98bsky.post_request('com.atproto.repo.createRecord', {
99 repo: bsky.user.did,
100 collection: 'app.bsky.feed.post',
101 record: {
102 text: "Hello world!",
103+ createdAt: Time.now.iso8601,
104+ langs: ["en"]
105 }
106})
107```
108109+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.
110111+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:
112113```rb
114time_limit = Time.now - 86400 * 30
115116+posts = bsky.fetch_all('com.atproto.repo.listRecords',
117 { repo: bsky.user.did, collection: 'app.bsky.feed.post' },
118 field: 'records',
119 max_pages: 10,
···123There 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:
124125```rb
126+likes = bsky.fetch_all('com.atproto.repo.listRecords',
127 { repo: bsky.user.did, collection: 'app.bsky.feed.like' },
128 field: 'records',
129 progress: '.')
···135.................
136```
137138+You can find more examples on the [examples page](https://ruby.sdk.blue/examples/) on [ruby.sdk.blue](https://ruby.sdk.blue).
139+140+141## Customization
142143+The `Minisky` client currently supports such configuration options:
144+145+- `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `.` when in an interactive console, `nil` otherwise)
146+- `send_auth_headers` - whether auth headers should be added by default (default: `true` in authenticated mode)
147+- `auto_manage_tokens` - whether access tokens should be generated and refreshed automatically when needed (default: `true` in authenticated mode)
148+149+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.
150151+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`.
1520153154+### Using your own class
00155156+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:
157158```rb
159class BlueskyClient
···180181```rb
182bsky = BlueskyClient.new('config/access.json')
0183bsky.get_request(...)
184```
185···192193## Credits
194195+Copyright ยฉ 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
196197The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
198
+20
lib/minisky/compat.rb
···00000000000000000000
···1+require_relative 'minisky'
2+3+class Minisky
4+5+ #
6+ # Versions of {Requests#get_request} & {Requests#post_request} that work on Ruby 2.x.
7+ #
8+9+ module Ruby2Compat
10+ def get_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
11+ params ||= kwargs unless kwargs.empty?
12+ super(method, params, auth: auth, headers: headers)
13+ end
14+15+ def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil, **kwargs)
16+ data ||= kwargs unless kwargs.empty?
17+ super(method, data, auth: auth, headers: headers, params: params)
18+ end
19+ end
20+end
···1+require_relative 'minisky'
2+3+class Minisky
4+5+ #
6+ # Common base error class for Minisky errors.
7+ #
8+ class Error < StandardError
9+ end
10+11+ #
12+ # Raised when a required token or credentials are missing or invalid.
13+ #
14+ class AuthError < Error
15+ end
16+17+ #
18+ # Raised when the API returns an error status code.
19+ #
20+ class BadResponse < Error
21+22+ # @return [Integer] HTTP status code
23+ attr_reader :status
24+25+ # @return [String, Hash] response data (JSON hash or string)
26+ attr_reader :data
27+28+ # @param status [Integer] HTTP status code
29+ # @param status_message [String] HTTP status message
30+ # @param data [Hash, String] response data (JSON hash or string)
31+ #
32+ def initialize(status, status_message, data)
33+ @status = status
34+ @data = data
35+36+ message = if error_message
37+ "#{status} #{status_message}: #{error_message}"
38+ else
39+ "#{status} #{status_message}"
40+ end
41+42+ super(message)
43+ end
44+45+ # @return [String, nil] machine-readable error code from the response data
46+ def error_type
47+ @data['error'] if @data.is_a?(Hash)
48+ end
49+50+ # @return [String, nil] human-readable error message from the response data
51+ def error_message
52+ @data['message'] if @data.is_a?(Hash)
53+ end
54+ end
55+56+ #
57+ # Raised when the API returns a client error status code (4xx).
58+ #
59+ class ClientErrorResponse < BadResponse
60+ end
61+62+ #
63+ # Raised when the API returns a server error status code (5xx).
64+ #
65+ class ServerErrorResponse < BadResponse
66+ end
67+68+ #
69+ # Raised when the API returns an error indicating that the access or request
70+ # token that was passed in the header is expired.
71+ #
72+ class ExpiredTokenError < ClientErrorResponse
73+ end
74+75+ #
76+ # Raised when the API returns a redirect status code (3xx). Minisky doesn't
77+ # currently follow any redirects.
78+ #
79+ class UnexpectedRedirect < BadResponse
80+81+ # @return [String] value of the "Location" header
82+ attr_reader :location
83+84+ # @param status [Integer] HTTP status code
85+ # @param status_message [String] HTTP status message
86+ # @param location [String] value of the "Location" header
87+ #
88+ def initialize(status, status_message, location)
89+ super(status, status_message, { 'message' => "Unexpected redirect: #{location}" })
90+ @location = location
91+ end
92+ end
93+94+ #
95+ # Raised by {Requests#fetch_all} when the `field` parameter isn't set.
96+ #
97+ # The message of the exception lists the fields available in the first fetched page.
98+ #
99+ # @example Making a request in the console with empty `field`
100+ # sky = Minisky.new('public.api.bsky.app', nil)
101+ # # => #<Minisky:0x0000000120f5f6b0 @host="public.api.bsky.app", ...>
102+ #
103+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' })
104+ # # ./lib/minisky/requests.rb:270:in 'block in Minisky::Requests#fetch_all':
105+ # # Field parameter not provided; available fields: ["followers"] (Minisky::FieldNotSetError)
106+ #
107+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' }, field: 'followers')
108+ # # => .....
109+ #
110+ class FieldNotSetError < Error
111+112+ # @return [Array<String>] list of fields in the response data
113+ attr_reader :fields
114+115+ # @param fields [Array<String>] list of fields in the response data
116+ #
117+ def initialize(fields)
118+ @fields = fields
119+ super("Field parameter not provided; available fields: #{@fields.inspect}")
120+ end
121+ end
122+end
···1require 'yaml'
23+#
4+# The default API client class for making requests to AT Protocol servers. Can be used
5+# with authentication โ with the credentials stored in a YAML file โ or without it, for
6+# unauthenticated requests only (by passing `nil` as the config file name).
7+#
8+# @example Authenticated client
9+# # Expects a config.yml file like:
10+# #
11+# # id: test.example.com
12+# # pass: secret7
13+# #
14+# # "id" can be a handle or a DID.
15+#
16+# sky = Minisky.new('eurosky.social', 'config.yml')
17+#
18+# feed = sky.get_request('app.bsky.feed.getTimeline', { limit: 100 })
19+#
20+# @example Unauthenticated client
21+# sky = Minisky.new('public.api.bsky.app', nil, progress: '*')
22+#
23+# follows = sky.get_request('app.bsky.graph.getFollows',
24+# { actor: 'atproto.com', limit: 100 },
25+# field: 'follows'
26+# )
27+#
28+29class Minisky
30+31+ # @return [String] the hostname (or base URL) of the server
32+ attr_reader :host
3334+ # @return [Hash] loaded contents of the config file
35+ attr_reader :config
3637+ # Creates a new client instance.
38+ #
39+ # @param host [String] the hostname (or base URL) of the server
40+ # @param config_file [String, nil] path to the YAML config file, or `nil` for unauthenticated client
41+ # @param options [Hash] option properties to set on the new instance (see {Minisky::Requests} properties)
42+ #
43+ # @raise [AuthError] if the config file is missing an ID or password
44+ #
45+ def initialize(host, config_file, options = {})
46 @host = host
47 @config_file = config_file
48+49+ if @config_file
50+ @config = YAML.load(File.read(@config_file))
51+52+ if user.id.nil? || user.pass.nil?
53+ raise AuthError, "Missing user id or password in the config file #{@config_file}"
54+ end
55+ else
56+ @config = nil
57+ end
58+59+ if active_repl?
60+ @default_progress = '.'
61+ end
62+63+ if options
64+ options.each do |k, v|
65+ self.send("#{k}=", v)
66+ end
67+ end
68 end
6970 def save_config
71+ File.write(@config_file, YAML.dump(@config)) if @config_file
72+ end
73+74+75+ private
76+77+ def active_repl?
78+ return true if defined?(IRB) && IRB.respond_to?(:CurrentContext) && IRB.CurrentContext
79+ return true if defined?(Pry) && Pry.respond_to?(:cli) && Pry.cli
80+ false
81 end
82end
83
+437-31
lib/minisky/requests.rb
···1require_relative 'minisky'
0203require 'json'
4require 'net/http'
5-require 'open-uri'
6require 'uri'
78class Minisky
9 class User
10 def initialize(config)
11 @config = config
000012 end
1314 def logged_in?
15 !!(access_token && refresh_token)
16 end
1718- def method_missing(name)
19- @config[name.to_s]
000020 end
21 end
22000000000023 module Requests
000000024 attr_accessor :default_progress
25000000000000000000000000000000026 def base_url
27- @base_url ||= "https://#{host}/xrpc"
000028 end
2930 def user
31- @user ||= User.new(config)
32 end
3334- def get_request(method, params = nil, auth: true)
35- headers = authentication_header(auth)
36- url = URI("#{base_url}/#{method}")
00000000000000000000000000000003738 if params && !params.empty?
39 url.query = URI.encode_www_form(params)
40 end
4142- JSON.parse(URI.open(url, headers).read)
00043 end
4445- def post_request(method, params = nil, auth: true)
46- headers = authentication_header(auth).merge({ "Content-Type" => "application/json" })
47- body = params ? params.to_json : ''
000000000000000000000000000000004849- response = Net::HTTP.post(URI("#{base_url}/#{method}"), body, headers)
50- raise "Invalid response: #{response.code} #{response.body}" if response.code.to_i / 100 != 2
000000000005152- JSON.parse(response.body)
0000053 end
5455- def fetch_all(method, params = nil, field:,
56- auth: true, break_when: nil, max_pages: nil, progress: @default_progress)
000000000000000000000000000000000000000000000000000000000000000000057 data = []
58 params = {} if params.nil?
59 pages = 0
···61 loop do
62 print(progress) if progress
6364- response = get_request(method, params, auth: auth)
0000065 records = response[field]
66 cursor = response['cursor']
67···69 params[:cursor] = cursor
70 pages += 1
7172- break if !cursor || records.empty? || pages == max_pages
73 break if break_when && records.any? { |x| break_when.call(x) }
74 end
75···77 data
78 end
79000000000000000000080 def check_access
81- if !user.logged_in?
000082 log_in
00000000000083 else
84- begin
85- get_request('com.atproto.server.getSession')
86- rescue OpenURI::HTTPError
87- perform_token_refresh
88- end
89 end
90 end
91000000000000092 def log_in
000093 data = {
94 identifier: user.id,
95 password: user.pass
96 }
970000098 json = post_request('com.atproto.server.createSession', data, auth: false)
99100- config['did'] = json['did']
101- config['access_token'] = json['accessJwt']
102- config['refresh_token'] = json['refreshJwt']
103104 save_config
105 json
106 end
10700000000000108 def perform_token_refresh
0000109 json = post_request('com.atproto.server.refreshSession', auth: user.refresh_token)
110111- config['access_token'] = json['accessJwt']
112- config['refresh_token'] = json['refreshJwt']
113114 save_config
115 json
116 end
1170000000000000000000000000000000000000000000000000000000000000000000000118 private
11900000000000000000000000120 def authentication_header(auth)
121 if auth.is_a?(String)
122 { 'Authorization' => "Bearer #{auth}" }
123 elsif auth
124- { 'Authorization' => "Bearer #{user.access_token}" }
0000125 else
126 {}
0000000000000000000000000127 end
128 end
129 end
···1require_relative 'minisky'
2+require_relative 'errors'
34+require 'base64'
5require 'json'
6require 'net/http'
7+require 'time'
8require 'uri'
910class Minisky
11 class User
12 def initialize(config)
13 @config = config
14+ end
15+16+ def has_credentials?
17+ !!(id && pass)
18 end
1920 def logged_in?
21 !!(access_token && refresh_token)
22 end
2324+ def method_missing(name, *args)
25+ if name.to_s.end_with?('=')
26+ @config[name.to_s.chop] = args[0]
27+ else
28+ @config[name.to_s]
29+ end
30 end
31 end
3233+ # Regexp for NSID identifiers, used in lexicon names for record collection and API endpoints
34+ 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])?)$/
35+36+ #
37+ # This module contains most of the Minisky code for making HTTP requests and managing
38+ # authentication tokens. The module is included into the {Minisky} API client class and you'll
39+ # normally use it through that class, but you can also include it into your custom class if you
40+ # want to implement the data storage differently than using a local YAML file as {Minisky} does.
41+ #
42+43 module Requests
44+45+ # A character to print before each request in {#fetch_all} as a progress indicator.
46+ # Can also be passed explicitly instead or overridden using the `progress:` parameter.
47+ # Default is `'.'` when running inside IRB, and `nil` otherwise.
48+ #
49+ # @return [String, nil]
50+ #
51 attr_accessor :default_progress
5253+ attr_writer :send_auth_headers
54+ attr_writer :auto_manage_tokens
55+56+ # Tells whether to set authentication headers automatically (default: true if there
57+ # is a user config).
58+ #
59+ # If false, you will need to pass `auth: 'sometoken'` explicitly to requests that
60+ # require authentication.
61+ #
62+ # @return [Boolean] whether to set authentication headers in requests
63+ #
64+ def send_auth_headers
65+ instance_variable_defined?('@send_auth_headers') ? @send_auth_headers : (config != nil)
66+ end
67+68+ # Tells whether the library should manage the access & refresh tokens automatically
69+ # for you (default: true if there is a user config).
70+ #
71+ # If true, {#check_access} is called before each request to make sure that there is a
72+ # fresh access token available; if false, you will need to call {#log_in} and
73+ # {#perform_token_refresh} manually when needed.
74+ #
75+ # @return [Boolean] whether to automatically manage access tokens
76+ #
77+ def auto_manage_tokens
78+ instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens : (config != nil)
79+ end
80+81+ alias progress default_progress
82+ alias progress= default_progress=
83+84 def base_url
85+ if host.include?('://')
86+ host.chomp('/') + '/xrpc'
87+ else
88+ "https://#{host}/xrpc"
89+ end
90 end
9192 def user
93+ @user ||= config && User.new(config)
94 end
9596+ # Sends a GET request to the service's API.
97+ #
98+ # @param method [String, URI] an XRPC endpoint name or a full URL
99+ # @param params [Hash, nil] query parameters
100+ #
101+ # @param auth [Boolean, String]
102+ # boolean value which tells whether to send an auth header with the access token or not,
103+ # or an explicit bearer token to use
104+ # @param headers [Hash, nil]
105+ # additional headers to include
106+ #
107+ # @return [Hash, String] parsed JSON hash for JSON responses, or raw response body otherwise
108+ #
109+ # @raise [ArgumentError] if method name is invalid
110+ # @raise [BadResponse] if the HTTP response has an error status code
111+ # @raise [AuthError]
112+ # - if logging in is required, but login or password isn't provided
113+ # - if token refresh is needed, but refresh token is missing
114+ # - if a token has invalid format
115+ # - if required access token is missing, and {#auto_manage_tokens} is disabled
116+ #
117+ # @example Unauthenticated call
118+ # sky = Minisky.new('public.api.bsky.app', nil)
119+ # profile = sky.get_request('app.bsky.actor.getProfile', { actor: 'ec.europa.eu' })
120+ #
121+ # @example Authenticated call
122+ # sky = Minisky.new('blacksky.app', 'config.yml')
123+ # feed = sky.get_request('app.bsky.feed.getTimeline', { limit: 100 })
124+125+ def get_request(method, params = nil, auth: default_auth_mode, headers: nil)
126+ check_access if auto_manage_tokens && auth == true
127+128+ headers = authentication_header(auth).merge(headers || {})
129+ url = build_request_uri(method)
130131 if params && !params.empty?
132 url.query = URI.encode_www_form(params)
133 end
134135+ request = Net::HTTP::Get.new(url, headers)
136+137+ response = make_request(request)
138+ handle_response(response)
139 end
140141+ # Sends a POST request to the service's API.
142+ #
143+ # @param method [String, URI] an XRPC endpoint name or a full URL
144+ # @param data [Hash, String, nil] JSON or string data to send
145+ #
146+ # @param auth [Boolean, String]
147+ # boolean value which tells whether to send an auth header with the access token or not,
148+ # or an explicit bearer token to use
149+ # @param headers [Hash, nil]
150+ # additional headers to include
151+ # @param params [Hash, nil]
152+ # query parameters to append to the URL
153+ #
154+ # @return [Hash, String] parsed JSON hash for JSON responses, or raw response body otherwise
155+ #
156+ # @raise [ArgumentError] if method name is invalid
157+ # @raise [BadResponse] if the HTTP response has an error status code
158+ # @raise [AuthError]
159+ # - if logging in is required, but login or password isn't provided
160+ # - if token refresh is needed, but refresh token is missing
161+ # - if a token has invalid format
162+ # - if required access token is missing, and {#auto_manage_tokens} is disabled
163+ #
164+ # @example Making a Bluesky post
165+ # sky = Minisky.new('lab.martianbase.net', 'config.yml')
166+ #
167+ # sky.post_request('com.atproto.repo.createRecord', {
168+ # repo: sky.user.did,
169+ # collection: 'app.bsky.feed.post',
170+ # record: {
171+ # text: "Hello Bluesky!",
172+ # createdAt: Time.now.iso8601,
173+ # langs: ['en']
174+ # }
175+ # })
176177+ def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil)
178+ check_access if auto_manage_tokens && auth == true
179+180+ headers = authentication_header(auth).merge(headers || {})
181+182+ if data.is_a?(String) || data.nil?
183+ body = data.to_s
184+ else
185+ body = data.to_json
186+ headers["Content-Type"] = "application/json" unless headers.keys.any? { |k| k.to_s.downcase == 'content-type' }
187+ end
188+189+ url = build_request_uri(method)
190191+ if params && !params.empty?
192+ url.query = URI.encode_www_form(params)
193+ end
194+195+ response = Net::HTTP.post(url, body, headers)
196+ handle_response(response)
197 end
198199+ # Fetches and merges paginated responses from a service's endpoint in a loop, updating the
200+ # cursor after each page, until the cursor is nil or a break condition is met. The data is
201+ # extracted from a designated field of the response (`field`) and added to a single array,
202+ # which is returned at the end.
203+ #
204+ # A condition for when the fetching should stop can be passed as a block in `break_when`, or
205+ # alternatively, a max number of pages can be passed to `max_pages` (or both together). If
206+ # neither is set, the fetching continues until the server returns an empty cursor.
207+ #
208+ # When experimenting in the Ruby console, you can pass `nil` as `field` (or skip the parameter)
209+ # to make a single request and raise an exception, which will tell you what fields are available.
210+ #
211+ # @param method [String, URI] an XRPC endpoint name or a full URL
212+ # @param params [Hash, nil] query parameters
213+ #
214+ # @param auth [Boolean, String]
215+ # boolean value which tells whether to send an auth header with the access token or not,
216+ # or an explicit bearer token to use
217+ # @param field [String, nil]
218+ # name of the field in the responses which contains the data array
219+ # @param break_when [Proc, nil]
220+ # if passed, the fetching will stop when the block returns true for any of the
221+ # returned records, and records matching the condition will be deleted from the last page
222+ # @param max_pages [Integer, nil]
223+ # maximum number of pages to fetch
224+ # @param headers [Hash, nil]
225+ # additional headers to include
226+ # @param progress [String, nil]
227+ # a character to print before each request as a progress indicator
228+ #
229+ # @return [Array] records or objects collected from all pages
230+ #
231+ # @raise [ArgumentError] if method name is invalid
232+ # @raise [FieldNotSetError] if field parameter wasn't set (the message tells you what fields were in the response)
233+ # @raise [BadResponse] if the HTTP response has an error status code
234+ # @raise [AuthError]
235+ # - if logging in is required, but login or password isn't provided
236+ # - if token refresh is needed, but refresh token is missing
237+ # - if a token has invalid format
238+ # - if required access token is missing, and {#auto_manage_tokens} is disabled
239+ #
240+ # @example Fetching with a `break_when` block
241+ # sky = Minisky.new('public.api.bsky.app', nil)
242+ # time_limit = Time.now - 86400 * 30
243+ #
244+ # sky.fetch_all('app.bsky.feed.getAuthorFeed',
245+ # { actor: 'pfrazee.com', limit: 100 },
246+ # field: 'feed',
247+ # progress: '|',
248+ # break_when: ->(x) { Time.at(x['post']['record']['createdAt']) < time_limit }
249+ # )
250+ #
251+ # @example Fetching with `max_pages`
252+ # sky = Minisky.new('tngl.sh', 'config.yml')
253+ # sky.fetch_all('app.bsky.feed.getTimeline', { limit: 100 }, field: 'feed', max_pages: 10)
254+ #
255+ # @example Making a request in the console with empty `field`
256+ # sky = Minisky.new('public.api.bsky.app', nil)
257+ # # => #<Minisky:0x0000000120f5f6b0 @host="public.api.bsky.app", ...>
258+ #
259+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' })
260+ # # ./lib/minisky/requests.rb:270:in 'block in Minisky::Requests#fetch_all':
261+ # # Field parameter not provided; available fields: ["followers"] (Minisky::FieldNotSetError)
262+ #
263+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' }, field: 'followers')
264+ # # => .....
265+266+ def fetch_all(method, params = nil, auth: default_auth_mode,
267+ field: nil, break_when: nil, max_pages: nil, headers: nil, progress: @default_progress)
268 data = []
269 params = {} if params.nil?
270 pages = 0
···272 loop do
273 print(progress) if progress
274275+ response = get_request(method, params, auth: auth, headers: headers)
276+277+ if field.nil?
278+ raise FieldNotSetError, response.keys.select { |f| response[f].is_a?(Array) }
279+ end
280+281 records = response[field]
282 cursor = response['cursor']
283···285 params[:cursor] = cursor
286 pages += 1
287288+ break if !cursor || pages == max_pages
289 break if break_when && records.any? { |x| break_when.call(x) }
290 end
291···293 data
294 end
295296+ # Ensures that the user has a fresh access token, by checking the access token's expiry date
297+ # and performing a refresh if needed, or by logging in with a password if no tokens are present.
298+ #
299+ # If {#auto_manage_tokens} is enabled (the default setting), this method is automatically called
300+ # before {#get_request}, {#post_request} and {#fetch_all}, so you generally don't need to call it
301+ # yourself.
302+ #
303+ # @return [Symbol]
304+ # - `:logged_in` if a login using a password was performed
305+ # - `:refreshed` if the access token was expired and was refreshed
306+ # - `:ok` if no refresh was needed
307+ # - `:unknown` if the token is not a valid JWT (e.g. an opaque blob)
308+ #
309+ # @raise [BadResponse] if login or refresh returns an error status code
310+ # @raise [AuthError]
311+ # - if the client doesn't include user config at all
312+ # - if logging in is required, but login or password isn't provided
313+ # - if token refresh is needed, but refresh token is missing
314+315 def check_access
316+ if !user
317+ raise AuthError, "User config is missing"
318+ elsif !user.has_credentials?
319+ raise AuthError, "User id or password is missing"
320+ elsif !user.logged_in?
321 log_in
322+ return :logged_in
323+ end
324+325+ begin
326+ expired = access_token_expired?
327+ rescue AuthError
328+ return :unknown
329+ end
330+331+ if expired
332+ perform_token_refresh
333+ :refreshed
334 else
335+ :ok
0000336 end
337 end
338339+ # Logs in the user using an ID and password stored in the config by calling the
340+ # `createSession` endpoint, and stores the received access & refresh tokens.
341+ #
342+ # This is generally handled automatically by {#check_access}. Calling this method
343+ # repeatedly many times in a short period of time may use up your rate limit for this
344+ # endpoint (which is lower than for others) and make it inaccessible to you for some
345+ # time.
346+ #
347+ # @return [Hash] the response JSON with access tokens
348+ #
349+ # @raise [AuthError] if login or password are missing
350+ # @raise [BadResponse] if the server responds with an error status code
351+352 def log_in
353+ if user.nil? || !user.has_credentials?
354+ raise AuthError, "To log in, please provide a user id and password"
355+ end
356+357 data = {
358 identifier: user.id,
359 password: user.pass
360 }
361362+ if user.id =~ /\A[^@]+@[^@]+\z/
363+ STDERR.puts "Warning: logging in using an email address is deprecated in Minisky and will be " +
364+ "removed in a future version. Use either a handle or a DID instead."
365+ end
366+367 json = post_request('com.atproto.server.createSession', data, auth: false)
368369+ user.did = json['did']
370+ user.access_token = json['accessJwt']
371+ user.refresh_token = json['refreshJwt']
372373 save_config
374 json
375 end
376377+ # Refreshes the access token using the stored refresh token. If successful, this
378+ # invalidates *both* old tokens and replaces them with new ones from the response.
379+ #
380+ # If {#auto_manage_tokens} is enabled (the default setting), this method is automatically called
381+ # before any requests through {#check_access}, so you generally don't need to call it yourself.
382+ #
383+ # @return [Hash] the response JSON with access tokens
384+ #
385+ # @raise [AuthError] if the refresh token is missing
386+ # @raise [BadResponse] if the server responds with an error status code
387+388 def perform_token_refresh
389+ if user&.refresh_token.nil?
390+ raise AuthError, "Can't refresh access token - refresh token is missing"
391+ end
392+393 json = post_request('com.atproto.server.refreshSession', auth: user.refresh_token)
394395+ user.access_token = json['accessJwt']
396+ user.refresh_token = json['refreshJwt']
397398 save_config
399 json
400 end
401402+ # Attempts to parse a given token as JWT and extract the expiration date from the payload.
403+ # An access token technically isn't required to be a (valid) JWT, so if the parsing fails
404+ # for whatever reason, nil is returned.
405+ #
406+ # @return [Time, nil] parsed expiration time, or nil if token is not a valid JWT
407+408+ def token_expiration_date(token)
409+ return nil unless token.valid_encoding?
410+411+ parts = token.split('.')
412+ return nil unless parts.length == 3
413+414+ begin
415+ payload = JSON.parse(Base64.decode64(parts[1]))
416+ rescue JSON::ParserError
417+ return nil
418+ end
419+420+ exp = payload['exp']
421+ return nil unless exp.is_a?(Numeric) && exp > 0
422+423+ time = Time.at(exp)
424+ return nil if time.year < 2023 || time.year > 2100
425+426+ time
427+ end
428+429+ # Attempts to parse the user's access token as JWT, extract the expiration date from the
430+ # payload, and check if the token hasn't expired yet.
431+ #
432+ # @return [Boolean] true if the token's expiration time is more than a minute away
433+ # @raise [AuthError] if the token is not a valid JWT, or user is not logged in
434+435+ def access_token_expired?
436+ if user&.access_token.nil?
437+ raise AuthError, "No access token (user is not logged in)"
438+ end
439+440+ exp_date = token_expiration_date(user.access_token)
441+442+ if exp_date
443+ exp_date < Time.now + 60
444+ else
445+ raise AuthError, "Token expiration date cannot be decoded"
446+ end
447+ end
448+449+ #
450+ # Clear stored access and refresh tokens, effectively logging out the user.
451+ #
452+ # @raise [AuthError] if the client doesn't have a user config
453+ #
454+455+ def reset_tokens
456+ if !user
457+ raise AuthError, "User config is missing"
458+ end
459+460+ user.access_token = nil
461+ user.refresh_token = nil
462+ save_config
463+ nil
464+ end
465+466+ if RUBY_VERSION.to_i == 2
467+ require_relative 'compat'
468+ prepend Ruby2Compat
469+ end
470+471+472 private
473474+ def make_request(request)
475+ # this long form is needed because #get_response only supports a headers param in Ruby 3.x
476+ response = Net::HTTP.start(request.uri.hostname, request.uri.port, use_ssl: (request.uri.scheme == 'https')) do |http|
477+ http.request(request)
478+ end
479+ end
480+481+ def build_request_uri(method)
482+ if method.is_a?(URI)
483+ method
484+ elsif method.include?('://')
485+ URI(method)
486+ elsif method =~ NSID_REGEXP
487+ URI("#{base_url}/#{method}")
488+ else
489+ raise ArgumentError, "Invalid method name #{method.inspect} (should be an NSID, URL or an URI object)"
490+ end
491+ end
492+493+ def default_auth_mode
494+ !!send_auth_headers
495+ end
496+497 def authentication_header(auth)
498 if auth.is_a?(String)
499 { 'Authorization' => "Bearer #{auth}" }
500 elsif auth
501+ if user&.access_token
502+ { 'Authorization' => "Bearer #{user.access_token}" }
503+ else
504+ raise AuthError, "Can't send auth headers, access token is missing"
505+ end
506 else
507 {}
508+ end
509+ end
510+511+ def handle_response(response)
512+ status = response.code.to_i
513+ message = response.message
514+ response_body = (response.content_type == 'application/json') ? JSON.parse(response.body) : response.body
515+516+ case response
517+ when Net::HTTPSuccess
518+ response_body
519+ when Net::HTTPRedirection
520+ raise UnexpectedRedirect.new(status, message, response['location'])
521+ else
522+ error_class = if response_body.is_a?(Hash) && response_body['error'] == 'ExpiredToken'
523+ ExpiredTokenError
524+ elsif response.is_a?(Net::HTTPClientError)
525+ ClientErrorResponse
526+ elsif response.is_a?(Net::HTTPServerError)
527+ ServerErrorResponse
528+ else
529+ BadResponse
530+ end
531+532+ raise error_class.new(status, message, response_body)
533 end
534 end
535 end
+1-1
lib/minisky/version.rb
···1require_relative 'minisky'
23class Minisky
4- VERSION = "0.2.0"
5end
···1require_relative 'minisky'
23class Minisky
4+ VERSION = "0.5.0"
5end
+9-7
minisky.gemspec
···1# frozen_string_literal: true
23-require_relative "lib/minisky/version"
45Gem::Specification.new do |spec|
6 spec.name = "minisky"
7- spec.version = Minisky::VERSION
8 spec.authors = ["Kuba Suder"]
9 spec.email = ["jakub.suder@gmail.com"]
1011- spec.summary = "A minimal client of Bluesky/AtProto API"
12 spec.description = "A very simple client class that lets you log in to the Bluesky API and make any requests there."
13- spec.homepage = "https://github.com/mackuba/minisky"
1415 spec.license = "Zlib"
16 spec.required_ruby_version = ">= 2.6.0"
1718 spec.metadata = {
19- "bug_tracker_uri" => "https://github.com/mackuba/minisky/issues",
20- "changelog_uri" => "https://github.com/mackuba/minisky/blob/master/CHANGELOG.md",
21- "source_code_uri" => "https://github.com/mackuba/minisky",
22 }
2324 spec.files = Dir.chdir(__dir__) do
···26 end
2728 spec.require_paths = ["lib"]
0029end
···1# frozen_string_literal: true
23+minisky_version = File.read(File.join(__dir__, 'lib', 'minisky', 'version.rb')).match(/VERSION = "(.*)"/)[1]
45Gem::Specification.new do |spec|
6 spec.name = "minisky"
7+ spec.version = minisky_version
8 spec.authors = ["Kuba Suder"]
9 spec.email = ["jakub.suder@gmail.com"]
1011+ spec.summary = "A minimal client of Bluesky/ATProto API"
12 spec.description = "A very simple client class that lets you log in to the Bluesky API and make any requests there."
13+ spec.homepage = "https://ruby.sdk.blue"
1415 spec.license = "Zlib"
16 spec.required_ruby_version = ">= 2.6.0"
1718 spec.metadata = {
19+ "bug_tracker_uri" => "https://tangled.org/mackuba.eu/minisky/issues",
20+ "changelog_uri" => "https://tangled.org/mackuba.eu/minisky/blob/master/CHANGELOG.md",
21+ "source_code_uri" => "https://tangled.org/mackuba.eu/minisky",
22 }
2324 spec.files = Dir.chdir(__dir__) do
···26 end
2728 spec.require_paths = ["lib"]
29+30+ spec.add_dependency 'base64', '~> 0.1'
31end
+83-12
spec/custom_client_spec.rb
···1require 'json'
00023class CustomJSONClient
4 CONFIG_FILE = 'test.json'
···78 attr_reader :config
910- def initialize
11- @config = JSON.parse(File.read(CONFIG_FILE))
12 end
1314 def host
···20 end
21end
2223-describe "custom client" do
24 include FakeFS::SpecHelpers
2526- before do
27- File.write('test.json', %({
28- "id": "john.foo",
29- "pass": "hunter2",
30- "access_token": "aatoken",
31- "refresh_token": "rrtoken"
32- }))
33- end
3435 subject { CustomJSONClient.new }
3637 let(:reloaded_config) { JSON.parse(File.read('test.json')) }
3839- include_examples "Requests", 'at.x.com'
000000000000000000000000000000000000000000000000000000000000000000000040end
···1require 'json'
2+require_relative 'shared/ex_incomplete_auth'
3+require_relative 'shared/ex_requests'
4+require_relative 'shared/ex_unauthed'
56class CustomJSONClient
7 CONFIG_FILE = 'test.json'
···1011 attr_reader :config
1213+ def initialize(config_file = CONFIG_FILE)
14+ @config = config_file && JSON.parse(File.read(config_file))
15 end
1617 def host
···23 end
24end
2526+describe "in custom client" do
27 include FakeFS::SpecHelpers
2829+ let(:data) {{
30+ 'id' => 'john.foo',
31+ 'pass' => 'hunter2',
32+ 'access_token' => 'aatoken',
33+ 'refresh_token' => 'rrtoken'
34+ }}
003536 subject { CustomJSONClient.new }
3738 let(:reloaded_config) { JSON.parse(File.read('test.json')) }
3940+ context 'with correct config,' do
41+ before do
42+ File.write('test.json', JSON.generate(data))
43+ end
44+45+ it 'should send auth headers by default' do
46+ subject.send_auth_headers.should == true
47+ end
48+49+ it 'should manage tokens by default' do
50+ subject.auto_manage_tokens.should == true
51+ end
52+53+ it 'should not set default progress' do
54+ subject.progress.should be_nil
55+ end
56+57+ describe '(requests)' do
58+ include_examples "authenticated requests", 'at.x.com'
59+ end
60+ end
61+62+ context 'with no user config,' do
63+ subject { CustomJSONClient.new(nil) }
64+65+ it 'should not send auth headers' do
66+ subject.send_auth_headers.should == false
67+ end
68+69+ it 'should not manage tokens' do
70+ subject.auto_manage_tokens.should == false
71+ end
72+73+ it 'should not set default progress' do
74+ subject.progress.should be_nil
75+ end
76+77+ include_examples "unauthenticated user"
78+ end
79+80+ context 'if id field is nil,' do
81+ before do
82+ File.write('test.json', JSON.generate(id: nil, pass: 'ok'))
83+ end
84+85+ include_examples "custom client with incomplete auth"
86+ end
87+88+ context 'if id field is not included' do
89+ before do
90+ File.write('test.json', JSON.generate(pass: 'ok'))
91+ end
92+93+ include_examples "custom client with incomplete auth"
94+ end
95+96+ context 'if pass field is nil' do
97+ before do
98+ File.write('test.json', JSON.generate(id: 'id', pass: nil))
99+ end
100+101+ include_examples "custom client with incomplete auth"
102+ end
103+104+ context 'if pass field is not included' do
105+ before do
106+ File.write('test.json', JSON.generate(id: 'id'))
107+ end
108+109+ include_examples "custom client with incomplete auth"
110+ end
111end
+250-41
spec/minisky_spec.rb
···1require 'yaml'
0000000000023describe Minisky do
4 include FakeFS::SpecHelpers
56- let(:host) { 'bsky.test' }
0000000000000000000000000000078- context 'with a default config file name' do
000009 before do
10- File.write('bluesky.yml', %(
11- id: john.foo
12- pass: hunter2
13- access_token: aatoken
14- refresh_token: rrtoken
15- ))
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016 end
1718- subject { Minisky.new(host) }
00000000000001920- let(:reloaded_config) { YAML.load(File.read('bluesky.yml')) }
0002122- it 'should have a version number' do
23- Minisky::VERSION.should_not be_nil
024 end
2526- include_examples "Requests", 'bsky.test'
000027 end
2829- context 'with a custom config file name' do
0030 before do
31- File.write('myconfig.yml', %(
32- id: john.foo
33- pass: hunter2
34- access_token: aatoken
35- refresh_token: rrtoken
36- ))
00037 end
3839- subject { Minisky.new(host, 'myconfig.yml') }
04041- let(:reloaded_config) { YAML.load(File.read('myconfig.yml')) }
0004243- it 'should load config from a file' do
44- subject.user.id.should == 'john.foo'
45- subject.user.access_token.should == 'aatoken'
46- subject.user.refresh_token.should == 'rrtoken'
0047 end
4849- describe '#log_in' do
50- before do
51- stub_request(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
52- .to_return(body: %({
53- "did": "did:plc:abracadabra",
54- "accessJwt": "aaaa1234",
55- "refreshJwt": "rrrr1234"
56- }))
57 end
05859- it "should save user's DID" do
60- subject.log_in
6162- reloaded_config['did'].should == "did:plc:abracadabra"
063 end
06465- it "should update the tokens in the config file" do
66- subject.log_in
6768- reloaded_config['access_token'].should == 'aaaa1234'
69- reloaded_config['refresh_token'].should == 'rrrr1234'
70 end
71 end
72 end
73end
0000000000000000000000
···1require 'yaml'
2+require_relative 'shared/ex_requests'
3+require_relative 'shared/ex_unauthed'
4+5+data = {
6+ 'id' => 'john.foo',
7+ 'pass' => 'hunter2',
8+ 'access_token' => 'aatoken',
9+ 'refresh_token' => 'rrtoken'
10+}.freeze
11+12+host = 'bsky.test'
1314describe Minisky do
15 include FakeFS::SpecHelpers
1617+ subject { Minisky.new(host, 'myconfig.yml') }
18+19+ it 'should have a version number' do
20+ Minisky::VERSION.should_not be_nil
21+ end
22+23+ context 'if id field is nil' do
24+ before do
25+ File.write('myconfig.yml', YAML.dump(data.merge('id' => nil)))
26+ end
27+28+ it 'should raise AuthError' do
29+ expect { subject }.to raise_error(Minisky::AuthError)
30+ end
31+ end
32+33+ context 'if id field is not included' do
34+ before do
35+ File.write('myconfig.yml', YAML.dump(data.slice('pass', 'access_token', 'refresh_token')))
36+ end
37+38+ it 'should raise AuthError' do
39+ expect { subject }.to raise_error(Minisky::AuthError)
40+ end
41+ end
42+43+ context 'if pass field is nil' do
44+ before do
45+ File.write('myconfig.yml', YAML.dump(data.merge('pass' => nil)))
46+ end
4748+ it 'should raise AuthError' do
49+ expect { subject }.to raise_error(Minisky::AuthError)
50+ end
51+ end
52+53+ context 'if pass field is not included' do
54 before do
55+ File.write('myconfig.yml', YAML.dump(data.slice('id', 'access_token', 'refresh_token')))
56+ end
57+58+ it 'should raise AuthError' do
59+ expect { subject }.to raise_error(Minisky::AuthError)
60+ end
61+ end
62+63+ context 'with correct config' do
64+ before do
65+ File.write('myconfig.yml', YAML.dump(data))
66+ end
67+68+ it 'should send auth headers by default' do
69+ subject.send_auth_headers.should == true
70+ end
71+72+ it 'should manage tokens by default' do
73+ subject.auto_manage_tokens.should == true
74+ end
75+76+ it 'should set host and config properties' do
77+ subject.host.should == host
78+ subject.config.should be_a(Hash)
79+ subject.config.should == data
80+ end
81+ end
82+83+ context 'without a config' do
84+ subject { Minisky.new(host, nil) }
85+86+ it 'should not send auth headers' do
87+ subject.send_auth_headers.should == false
88+ end
89+90+ it 'should not manage tokens' do
91+ subject.auto_manage_tokens.should == false
92+ end
93+94+ it 'should set host property' do
95+ subject.host.should == host
96+ end
97+98+ it 'should set config to nil' do
99+ subject.config.should be_nil
100+ end
101+ end
102+103+ context 'if running inside IRB' do
104+ subject { Minisky.new(host, nil) }
105+106+ before do
107+ load File.join(__dir__, 'shared', 'fake_irb.rb')
108+ end
109+110+ it 'should set default_progress to "."' do
111+ subject.default_progress.should == '.'
112+ end
113+114+ after do
115+ Object.send(:remove_const, :IRB)
116+ end
117+ end
118+119+ context 'if running inside Pry' do
120+ subject { Minisky.new(host, nil) }
121+122+ before do
123+ load File.join(__dir__, 'shared', 'fake_pry.rb')
124+ end
125+126+ it 'should set default_progress to "."' do
127+ subject.default_progress.should == '.'
128+ end
129+130+ after do
131+ Object.send(:remove_const, :Pry)
132+ end
133+ end
134+135+ context 'if not running inside a REPL' do
136+ subject { Minisky.new(host, nil) }
137+138+ it 'should keep default_progress unset' do
139+ subject.default_progress.should be_nil
140+ end
141+ end
142+143+ it 'should let you pass additional options and set them' do
144+ File.write('myconfig.yml', YAML.dump(data))
145+146+ minisky = Minisky.new(host, 'myconfig.yml', auto_manage_tokens: false, progress: '*')
147+ minisky.auto_manage_tokens.should == false
148+ minisky.default_progress.should == '*'
149+ end
150+151+ describe '#token_expiration_date' do
152+ subject { Minisky.new(host, nil) }
153+154+ it 'should return nil for tokens with invalid encoding' do
155+ token = "bad\xC3".force_encoding('UTF-8')
156+ subject.token_expiration_date(token).should be_nil
157+ end
158+159+ it 'should return nil when the token does not have three parts' do
160+ subject.token_expiration_date('token').should be_nil
161+ subject.token_expiration_date('one.two').should be_nil
162+ subject.token_expiration_date('1.2.3.4').should be_nil
163+164+ token = make_token(Time.now + 3600)
165+ subject.token_expiration_date(token + '.qwe').should be_nil
166+ end
167+168+ it 'should return nil when the payload is not valid JSON' do
169+ token = ['header', Base64.strict_encode64('nope'), 'sig'].join('.')
170+ subject.token_expiration_date(token).should be_nil
171 end
172173+ it 'should return nil when exp field is missing' do
174+ token = ['header', Base64.strict_encode64(JSON.generate({ 'aud' => 'aaaa' })), 'sig'].join('.')
175+ subject.token_expiration_date(token).should be_nil
176+ end
177+178+ it 'should return nil when exp field is not a number' do
179+ token = ['header', Base64.strict_encode64(JSON.generate({ 'exp' => 'soon' })), 'sig'].join('.')
180+ subject.token_expiration_date(token).should be_nil
181+ end
182+183+ it 'should return nil when exp field is not a positive number' do
184+ token = ['header', Base64.strict_encode64(JSON.generate({ 'exp' => 0 })), 'sig'].join('.')
185+ subject.token_expiration_date(token).should be_nil
186+ end
187188+ it 'should return nil when expiration year is before 2023' do
189+ token = make_token(Time.utc(2022, 12, 24, 19, 00, 00))
190+ subject.token_expiration_date(token).should be_nil
191+ end
192193+ it 'should return nil when expiration year is after 2100' do
194+ token = make_token(Time.utc(2101, 5, 5, 0, 0, 0))
195+ subject.token_expiration_date(token).should be_nil
196 end
197198+ it 'should return the expiration time for a valid token' do
199+ time = Time.at(Time.now.to_i + 7200)
200+ token = make_token(time)
201+ subject.token_expiration_date(token).should == time
202+ end
203 end
204205+ describe '#access_token_expired?' do
206+ let(:config) { data }
207+208 before do
209+ File.write('myconfig.yml', YAML.dump(config))
210+ end
211+212+ context 'when there is no user config' do
213+ subject { Minisky.new(host, nil) }
214+215+ it 'should raise AuthError' do
216+ expect { subject.access_token_expired? }.to raise_error(Minisky::AuthError)
217+ end
218 end
219220+ context 'when access token is missing' do
221+ let(:config) { data.merge('access_token' => nil) }
222223+ it 'should raise AuthError' do
224+ expect { subject.access_token_expired? }.to raise_error(Minisky::AuthError)
225+ end
226+ end
227228+ context 'when token expiration cannot be decoded' do
229+ let(:config) { data.merge('access_token' => 'blob') }
230+231+ it 'should raise AuthError' do
232+ expect { subject.access_token_expired? }.to raise_error(Minisky::AuthError)
233+ end
234 end
235236+ context 'when token expiration date is in the past' do
237+ let(:config) { data.merge('access_token' => make_token(Time.now - 30)) }
238+239+ it 'should return true' do
240+ subject.access_token_expired?.should == true
000241 end
242+ end
243244+ context 'when token expiration date is in less than 60 seconds' do
245+ let(:config) { data.merge('access_token' => make_token(Time.now + 50)) }
246247+ it 'should return true' do
248+ subject.access_token_expired?.should == true
249 end
250+ end
251252+ context 'when token expiration date is in more than 60 seconds' do
253+ let(:config) { data.merge('access_token' => make_token(Time.now + 180)) }
254255+ it 'should return false' do
256+ subject.access_token_expired?.should == false
257 end
258 end
259 end
260end
261+262+describe 'in Minisky instance' do
263+ include FakeFS::SpecHelpers
264+265+ subject { Minisky.new(host, 'myconfig.yml') }
266+267+ let(:reloaded_config) { YAML.load(File.read('myconfig.yml')) }
268+269+ context 'with correct config,' do
270+ before do
271+ File.write('myconfig.yml', YAML.dump(data))
272+ end
273+274+ include_examples "authenticated requests", 'bsky.test'
275+ end
276+277+ context 'without a config' do
278+ subject { Minisky.new(host, nil) }
279+280+ include_examples "unauthenticated user"
281+ end
282+end
-575
spec/requests_shared.rb
···1-shared_examples "Requests" do |host|
2- let(:host) { host }
3-4- it 'should load config from a file' do
5- subject.user.id.should == 'john.foo'
6- subject.user.access_token.should == 'aatoken'
7- subject.user.refresh_token.should == 'rrtoken'
8- end
9-10- it 'should have a user object wrapping the config' do
11- subject.config['something'] = 'some value'
12-13- subject.user.something.should == 'some value'
14- end
15-16- describe '#log_in' do
17- let(:response_json) { JSON.generate(
18- "did": "did:plc:abracadabra",
19- "accessJwt": "aaaa1234",
20- "refreshJwt": "rrrr1234"
21- )}
22-23- before do
24- stub_request(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
25- .to_return(body: response_json)
26- end
27-28- it 'should make a request to com.atproto.server.createSession' do
29- subject.log_in
30-31- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
32- .once.with(body: %({"identifier":"john.foo","password":"hunter2"}))
33- end
34-35- it 'should not set authentication header' do
36- subject.log_in
37-38- WebMock.should_not have_requested(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
39- .with(headers: { 'Authorization' => /.*/ })
40- end
41-42- it "should save user's DID" do
43- subject.log_in
44-45- reloaded_config['did'].should == "did:plc:abracadabra"
46- end
47-48- it "should update the tokens in the config file" do
49- subject.log_in
50-51- reloaded_config['access_token'].should == 'aaaa1234'
52- reloaded_config['refresh_token'].should == 'rrrr1234'
53- end
54-55- it 'should return the response json' do
56- subject.log_in.should == JSON.parse(response_json)
57- end
58- end
59-60- describe '#perform_token_refresh' do
61- let(:response_json) { JSON.generate(
62- "accessJwt": "aaaa1234",
63- "refreshJwt": "rrrr1234"
64- )}
65-66- before do
67- stub_request(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
68- .to_return(body: response_json)
69- end
70-71- it 'should make a request to com.atproto.server.refreshSession' do
72- subject.perform_token_refresh
73-74- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
75- .once.with(body: '')
76- end
77-78- it 'should authenticate with the refresh token' do
79- subject.perform_token_refresh
80-81- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
82- .once.with(headers: { 'Authorization' => 'Bearer rrtoken' })
83- end
84-85- it "should update the tokens in the config file" do
86- subject.perform_token_refresh
87-88- reloaded_config['access_token'].should == 'aaaa1234'
89- reloaded_config['refresh_token'].should == 'rrrr1234'
90- end
91-92- it 'should return the response json' do
93- subject.perform_token_refresh.should == JSON.parse(response_json)
94- end
95- end
96-97- describe '#get_request' do
98- before do
99- stub_request(:get, %r(https://#{host}/xrpc/com.example.service.getStuff(\?.*)?))
100- .to_return(body: '{ "result": 123 }')
101- end
102-103- it 'should make a request to the given XRPC endpoint' do
104- subject.get_request('com.example.service.getStuff')
105-106- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
107- end
108-109- it 'should return parsed JSON' do
110- result = subject.get_request('com.example.service.getStuff')
111-112- result.should == { 'result' => 123 }
113- end
114-115- context 'with params' do
116- it 'should append params to the URL' do
117- subject.get_request('com.example.service.getStuff', { repo: 'whitehouse.gov', limit: 80 })
118-119- WebMock.should have_requested(:get,
120- "https://#{host}/xrpc/com.example.service.getStuff?repo=whitehouse.gov&limit=80").once
121- end
122- end
123-124- context 'with nil params' do
125- it 'should not append anything to the URL' do
126- subject.get_request('com.example.service.getStuff', nil)
127-128- WebMock.should have_requested(:get,
129- "https://#{host}/xrpc/com.example.service.getStuff").once
130- end
131- end
132-133- context 'with an array passed as param' do
134- it 'should append one copy of the param for each item' do
135- subject.get_request('com.example.service.getStuff', { profiles: ['john.foo', 'spam.zip'], reposts: true })
136-137- WebMock.should have_requested(:get,
138- "https://#{host}/xrpc/com.example.service.getStuff?profiles=john.foo&profiles=spam.zip&reposts=true").once
139- end
140- end
141-142- context 'with an explicit auth token' do
143- it 'should pass the token in the header' do
144- subject.get_request('com.example.service.getStuff', auth: 'token777')
145-146- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
147- .with(headers: { 'Authorization' => 'Bearer token777' })
148- end
149- end
150-151- context 'without an auth parameter' do
152- it 'should use the access token' do
153- subject.get_request('com.example.service.getStuff')
154-155- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
156- .with(headers: { 'Authorization' => 'Bearer aatoken' })
157- end
158- end
159-160- context 'with auth = false' do
161- it 'should not set the authorization header' do
162- subject.get_request('com.example.service.getStuff', auth: false)
163-164- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff")
165- .with(headers: { 'Authorization' => /.*/ })
166- end
167- end
168- end
169-170- describe '#post_request' do
171- let(:response) {{ body: '{ "result": "ok" }' }}
172-173- before do
174- stub_request(:post, "https://#{host}/xrpc/com.example.service.doStuff").to_return(response)
175- end
176-177- it 'should make a request to the given XRPC endpoint' do
178- subject.post_request('com.example.service.doStuff')
179-180- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
181- end
182-183- it 'should return parsed JSON' do
184- result = subject.post_request('com.example.service.doStuff')
185-186- result.should == { 'result' => 'ok' }
187- end
188-189- it 'should set content type to application/json' do
190- subject.post_request('com.example.service.doStuff')
191-192- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
193- .with(headers: { 'Content-Type' => 'application/json' })
194- end
195-196- context 'with an explicit auth token' do
197- it 'should pass the token in the header' do
198- subject.post_request('com.example.service.doStuff', auth: 'qwerty99')
199-200- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
201- .with(headers: { 'Authorization' => 'Bearer qwerty99' })
202- end
203- end
204-205- context 'without an auth parameter' do
206- it 'should use the access token' do
207- subject.post_request('com.example.service.doStuff')
208-209- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
210- .with(headers: { 'Authorization' => 'Bearer aatoken' })
211- end
212- end
213-214- context 'with auth = false' do
215- it 'should not set the authorization header' do
216- subject.post_request('com.example.service.doStuff', auth: false)
217-218- WebMock.should_not have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff")
219- .with(headers: { 'Authorization' => /.*/ })
220- end
221- end
222-223- context 'if params are passed' do
224- it 'should encode them as JSON in the body' do
225- data = { repo: 'kate.dev', limit: 40, fields: ['name', 'posts'] }
226- subject.post_request('com.example.service.doStuff', data)
227-228- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
229- .with(body: JSON.generate(data))
230- end
231- end
232-233- context 'if params are not passed' do
234- it 'should send an empty body' do
235- subject.post_request('com.example.service.doStuff')
236-237- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
238- .with(body: '')
239- end
240- end
241-242- context 'if params are an explicit nil' do
243- it 'should send an empty body' do
244- subject.post_request('com.example.service.doStuff', nil)
245-246- WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
247- .with(body: '')
248- end
249- end
250-251- context 'if the response has a 4xx status' do
252- let(:response) {{ body: '{ "error": "message" }', status: 403 }}
253-254- it 'should raise an error' do
255- expect { subject.post_request('com.example.service.doStuff') }.to raise_error(RuntimeError)
256- end
257- end
258- end
259-260- describe '#fetch_all' do
261- context 'when one page of items is returned' do
262- before do
263- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
264- .to_return(body: '{ "items": ["one", "two", "three"] }')
265- end
266-267- it 'should make one request to the given endpoint' do
268- subject.fetch_all('com.example.service.fetchAll', field: 'items')
269-270- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
271- end
272-273- it 'should return the parsed items' do
274- result = subject.fetch_all('com.example.service.fetchAll', field: 'items')
275- result.should == ["one", "two", "three"]
276- end
277- end
278-279- context 'when more than one page of items is returned' do
280- before do
281- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
282- .to_return(body: '{ "items": ["one", "two", "three"], "cursor": "ccc111" }')
283-284- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc111")
285- .to_return(body: '{ "items": ["four", "five"] }')
286- end
287-288- it 'should make multiple requests, passing the last cursor' do
289- subject.fetch_all('com.example.service.fetchAll', field: 'items')
290-291- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
292- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc111").once
293- end
294-295- it 'should return all the parsed items collected from the responses' do
296- result = subject.fetch_all('com.example.service.fetchAll', field: 'items')
297- result.should == ["one", "two", "three", "four", "five"]
298- end
299- end
300-301- context 'when params are passed' do
302- before do
303- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?type=post")
304- .to_return(body: '{ "items": ["one", "two", "three"], "cursor": "ccc222" }')
305-306- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?type=post&cursor=ccc222")
307- .to_return(body: '{ "items": ["four", "five"] }')
308- end
309-310- it 'should add the params to the url' do
311- subject.fetch_all('com.example.service.fetchAll', { type: 'post' }, field: 'items')
312-313- WebMock.should have_requested(:get,
314- "https://#{host}/xrpc/com.example.service.fetchAll?type=post").once
315- WebMock.should have_requested(:get,
316- "https://#{host}/xrpc/com.example.service.fetchAll?type=post&cursor=ccc222").once
317- end
318- end
319-320- context 'when params are an explicit nil' do
321- before do
322- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
323- .to_return(body: '{ "items": ["one", "two", "three"], "cursor": "ccc222" }')
324-325- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc222")
326- .to_return(body: '{ "items": ["four", "five"] }')
327- end
328-329- it 'should not add anything to the url' do
330- subject.fetch_all('com.example.service.fetchAll', nil, field: 'items')
331-332- WebMock.should have_requested(:get,
333- "https://#{host}/xrpc/com.example.service.fetchAll").once
334- WebMock.should have_requested(:get,
335- "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc222").once
336- end
337- end
338-339- describe '(auth token)' do
340- before do
341- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
342- .to_return(body: '{ "items": ["one", "two", "three"], "cursor": "ccc333" }')
343-344- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc333")
345- .to_return(body: '{ "items": ["four", "five"] }')
346- end
347-348- context 'with an explicit token' do
349- it 'should pass the token in the header' do
350- subject.fetch_all('com.example.service.fetchAll', auth: 'XXXX', field: 'items')
351-352- WebMock.should have_requested(:get,
353- "https://#{host}/xrpc/com.example.service.fetchAll").once
354- .with(headers: { 'Authorization' => 'Bearer XXXX' })
355- WebMock.should have_requested(:get,
356- "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc333").once
357- .with(headers: { 'Authorization' => 'Bearer XXXX' })
358- end
359- end
360-361- context 'without an auth parameter' do
362- it 'should use the access token' do
363- subject.fetch_all('com.example.service.fetchAll', field: 'items')
364-365- WebMock.should have_requested(:get,
366- "https://#{host}/xrpc/com.example.service.fetchAll").once
367- .with(headers: { 'Authorization' => 'Bearer aatoken' })
368- WebMock.should have_requested(:get,
369- "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc333").once
370- .with(headers: { 'Authorization' => 'Bearer aatoken' })
371- end
372- end
373-374- context 'with auth = false' do
375- it 'should not add an authentication header' do
376- subject.fetch_all('com.example.service.fetchAll', field: 'items', auth: false)
377-378- WebMock.should_not have_requested(:get, %r(https://#{host}/xrpc/com.example.service.fetchAll))
379- .with(headers: { 'Authorization' => /.*/ })
380- end
381- end
382- end
383-384- context 'when break condition is passed' do
385- before do
386- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
387- .to_return(body: '{ "items": ["one", "two", "three"], "cursor": "page1" }')
388-389- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1")
390- .to_return(body: '{ "items": ["four", "five"], "cursor": "page2" }')
391-392- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2")
393- .to_return(body: '{ "items": ["six"] }')
394- end
395-396- it 'should stop when a matching item is found' do
397- subject.fetch_all('com.example.service.fetchAll', field: 'items', break_when: ->(x) { x =~ /u/ })
398-399- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
400- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
401- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2")
402- end
403-404- it 'should filter out matching items from the response' do
405- result = subject.fetch_all('com.example.service.fetchAll', field: 'items', break_when: ->(x) { x =~ /u/ })
406- result.should == ["one", "two", "three", "five"]
407- end
408- end
409-410- context 'when max pages limit is passed' do
411- before do
412- stub_request(:get, %r(https://#{host}/xrpc/com.example.service.fetchAll(\?.*)?))
413- .to_return { |req|
414- params = req.uri.query_values || {}
415- page = params['cursor'].to_s.gsub(/page/, '').to_i
416- { body: JSON.generate({ items: ["item#{page}"], cursor: "page#{page + 1}" }) }
417- }
418- end
419-420- context 'and break_when is not passed' do
421- it 'should stop at n-th page' do
422- subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5)
423-424- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
425- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
426- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
427- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
428- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
429- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5")
430- end
431-432- it 'should collect all items' do
433- result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5)
434- result.should == ["item0", "item1", "item2", "item3", "item4"]
435- end
436- end
437-438- context 'and break_when matches earlier' do
439- it 'should stop at the page where break_when matches' do
440- subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5,
441- break_when: ->(x) { x =~ /3/ })
442-443- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
444- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
445- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
446- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
447- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4")
448- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5")
449- end
450-451- it 'should exclude items that matched break_when' do
452- result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5,
453- break_when: ->(x) { x =~ /3/ })
454-455- result.should == ["item0", "item1", "item2"]
456- end
457- end
458-459- context "and break_when doesn't match earlier" do
460- it 'should stop at the n-th page' do
461- subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
462- break_when: ->(x) { x =~ /8/ })
463-464- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
465- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
466- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
467- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
468- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
469- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5").once
470- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page6")
471- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page7")
472- end
473-474- it 'should include all items up to n-th page' do
475- result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
476- break_when: ->(x) { x =~ /8/ })
477-478- result.should == ["item0", "item1", "item2", "item3", "item4", "item5"]
479- end
480- end
481-482- context "and break_when matches on the last page" do
483- it 'should stop at the n-th page' do
484- subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
485- break_when: ->(x) { x =~ /5/ })
486-487- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
488- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
489- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
490- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
491- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
492- WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5").once
493- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page6")
494- WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page7")
495- end
496-497- it 'should exclude the items matching on the last page' do
498- result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
499- break_when: ->(x) { x =~ /5/ })
500-501- result.should == ["item0", "item1", "item2", "item3", "item4"]
502- end
503- end
504- end
505-506- describe 'progress param' do
507- before do
508- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
509- .to_return(body: '{ "items": ["one"], "cursor": "page1" }')
510-511- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1")
512- .to_return(body: '{ "items": ["two"], "cursor": "page2" }')
513-514- stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2")
515- .to_return(body: '{ "items": ["three"] }')
516- end
517-518- context 'when it is passed' do
519- it 'should print the progress character for each request' do
520- expect {
521- subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: '-=')
522- }.to output('-=-=-=').to_stdout
523- end
524- end
525-526- context 'when it is not passed' do
527- it 'should not print anything' do
528- expect {
529- subject.fetch_all('com.example.service.fetchAll', field: 'items')
530- }.to output('').to_stdout
531- end
532- end
533-534- context 'when it is passed and a default is set' do
535- it 'should use the param version' do
536- subject.default_progress = '@'
537-538- expect {
539- subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: '#')
540- }.to output('###').to_stdout
541- end
542- end
543-544- context 'when it is not passed and a default is set' do
545- it 'should use the default version' do
546- subject.default_progress = '$'
547-548- expect {
549- subject.fetch_all('com.example.service.fetchAll', field: 'items')
550- }.to output('$$$').to_stdout
551- end
552- end
553-554- context 'when default is set and nil is passed' do
555- it 'should not output anything' do
556- subject.default_progress = '$'
557-558- expect {
559- subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: nil)
560- }.to output('').to_stdout
561- end
562- end
563-564- context 'when default is set and false is passed' do
565- it 'should not output anything' do
566- subject.default_progress = '$'
567-568- expect {
569- subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: false)
570- }.to output('').to_stdout
571- end
572- end
573- end
574- end
575-end
···1+shared_examples 'authorization' do |request:, expected:|
2+ let(:request) { request }
3+ let(:expected) { expected }
4+5+ def make_request(auth:)
6+ request.call(subject, { auth: auth })
7+ end
8+9+ def make_request_without_auth
10+ request.call(subject, {})
11+ end
12+13+ def expected_calls
14+ calls = expected.call(host)
15+ calls[0].is_a?(Array) ? calls : [calls]
16+ end
17+18+ def self.with_access_token(*modes, &definitions)
19+ modes.each do |m|
20+ case m
21+ when :unchanged
22+ instance_eval(&definitions)
23+ when :nil
24+ context "when access_token is nil" do
25+ before { subject.user.access_token = nil }
26+ instance_eval(&definitions)
27+ end
28+ when :deleted
29+ context "when access_token is not provided" do
30+ before { subject.config.delete('access_token') }
31+ instance_eval(&definitions)
32+ end
33+ else
34+ raise "Unknown mode #{m}"
35+ end
36+ end
37+ end
38+39+ [true, false, nil, :undefined].each do |v|
40+ context "with send_auth_headers set to #{v.inspect}" do
41+ before do
42+ subject.send_auth_headers = v unless v == :undefined
43+ end
44+45+ context 'with an explicit auth token' do
46+ with_access_token(:unchanged, :nil, :deleted) do
47+ it 'should pass the token in the header' do
48+ make_request(auth: 'qwerty99')
49+50+ expected_calls.each do |method, url|
51+ WebMock.should have_requested(method, url).once.with(headers: { 'Authorization' => 'Bearer qwerty99' })
52+ end
53+ end
54+ end
55+ end
56+57+ context 'with auth = true' do
58+ it 'should use the access token' do
59+ make_request(auth: true)
60+61+ expected_calls.each do |method, url|
62+ WebMock.should have_requested(method, url).once.with(headers: { 'Authorization' => 'Bearer aatoken' })
63+ end
64+ end
65+66+ with_access_token(:nil, :deleted) do
67+ it 'should raise AuthError' do
68+ expect { make_request(auth: true) }.to raise_error(Minisky::AuthError)
69+70+ expected_calls.each { |method, url| WebMock.should_not have_requested(method, url) }
71+ end
72+ end
73+ end
74+75+ context 'with auth = false' do
76+ with_access_token(:unchanged, :nil, :deleted) do
77+ it 'should not set the authorization header' do
78+ make_request(auth: false)
79+80+ expected_calls.each do |method, url|
81+ WebMock.should have_requested(method, url).once
82+ WebMock.should_not have_requested(method, url).with(headers: { 'Authorization' => /.*/ })
83+ end
84+ end
85+ end
86+ end
87+88+ context 'with auth = nil' do
89+ with_access_token(:unchanged, :nil, :deleted) do
90+ it 'should not set the authorization header' do
91+ make_request(auth: nil)
92+93+ expected_calls.each do |method, url|
94+ WebMock.should have_requested(method, url).once
95+ WebMock.should_not have_requested(method, url).with(headers: { 'Authorization' => /.*/ })
96+ end
97+ end
98+ end
99+ end
100+ end
101+ end
102+103+ context 'without an auth parameter' do
104+ it 'should use the access token if send_auth_headers is true' do
105+ subject.send_auth_headers = true
106+107+ make_request_without_auth
108+109+ expected_calls.each do |method, url|
110+ WebMock.should have_requested(method, url).once.with(headers: { 'Authorization' => 'Bearer aatoken' })
111+ end
112+ end
113+114+ it 'should use the access token if send_auth_headers is not set' do
115+ make_request_without_auth
116+117+ expected_calls.each do |method, url|
118+ WebMock.should have_requested(method, url).once.with(headers: { 'Authorization' => 'Bearer aatoken' })
119+ end
120+ end
121+122+ it 'should use the access token if send_auth_headers is set to a truthy value' do
123+ subject.send_auth_headers = 'wtf'
124+125+ make_request_without_auth
126+127+ expected_calls.each do |method, url|
128+ WebMock.should have_requested(method, url).once.with(headers: { 'Authorization' => 'Bearer aatoken' })
129+ end
130+ end
131+132+ with_access_token(:nil, :deleted) do
133+ it 'should raise AuthError if send_auth_headers is true' do
134+ subject.send_auth_headers = true
135+136+ expect { make_request_without_auth }.to raise_error(Minisky::AuthError)
137+138+ expected_calls.each { |method, url| WebMock.should_not have_requested(method, url) }
139+ end
140+141+ it 'should raise AuthError if send_auth_headers is not set' do
142+ expect { make_request_without_auth }.to raise_error(Minisky::AuthError)
143+144+ expected_calls.each { |method, url| WebMock.should_not have_requested(method, url) }
145+ end
146+147+ it 'should raise AuthError if send_auth_headers is set to a truthy value' do
148+ subject.send_auth_headers = 'wtf'
149+150+ expect { make_request_without_auth }.to raise_error(Minisky::AuthError)
151+152+ expected_calls.each { |method, url| WebMock.should_not have_requested(method, url) }
153+ end
154+ end
155+156+ with_access_token(:unchanged, :nil, :deleted) do
157+ it 'should not set the authorization header if send_auth_headers is false' do
158+ subject.send_auth_headers = false
159+160+ make_request_without_auth
161+162+ expected_calls.each do |method, url|
163+ WebMock.should have_requested(method, url).once
164+ WebMock.should_not have_requested(method, url).with(headers: { 'Authorization' => /.*/ })
165+ end
166+ end
167+168+ it 'should not set the authorization header if send_auth_headers is nil' do
169+ subject.send_auth_headers = nil
170+171+ make_request_without_auth
172+173+ expected_calls.each do |method, url|
174+ WebMock.should have_requested(method, url).once
175+ WebMock.should_not have_requested(method, url).with(headers: { 'Authorization' => /.*/ })
176+ end
177+ end
178+ end
179+ end
180+end
···1+shared_examples "fetch_all" do
2+ describe '#fetch_all' do
3+ context 'when one page of items is returned' do
4+ before do
5+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
6+ { "items": ["one", "two", "three"] }
7+ ])
8+ end
9+10+ it 'should make one request to the given endpoint' do
11+ subject.fetch_all('com.example.service.fetchAll', field: 'items')
12+ verify_fetch_all
13+ end
14+15+ it 'should return the parsed items' do
16+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items')
17+ result.should == ["one", "two", "three"]
18+ end
19+ end
20+21+ context 'when more than one page of items is returned' do
22+ before do
23+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
24+ { "items": ["one", "two", "three"] },
25+ { "items": ["four", "five"] },
26+ ])
27+ end
28+29+ it 'should make multiple requests, passing the last cursor' do
30+ subject.fetch_all('com.example.service.fetchAll', field: 'items')
31+ verify_fetch_all
32+ end
33+34+ it 'should return all the parsed items collected from the responses' do
35+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items')
36+ result.should == ["one", "two", "three", "four", "five"]
37+ end
38+ end
39+40+ context 'when params are passed' do
41+ before do
42+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll?type=post", [
43+ { "items": ["one", "two", "three"] },
44+ { "items": ["four", "five"] },
45+ ])
46+ end
47+48+ it 'should add the params to the url' do
49+ subject.fetch_all('com.example.service.fetchAll', { type: 'post' }, field: 'items')
50+ verify_fetch_all
51+ end
52+ end
53+54+ context 'when params are an explicit nil' do
55+ before do
56+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
57+ { "items": ["one", "two", "three"] },
58+ { "items": ["four", "five"] },
59+ ])
60+ end
61+62+ it 'should not add anything to the url' do
63+ subject.fetch_all('com.example.service.fetchAll', nil, field: 'items')
64+ verify_fetch_all
65+ end
66+ end
67+68+ describe 'โฆ' do
69+ before do
70+ stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
71+ .to_return_json(body: { "items": ["one", "two", "three"], "cursor": "ccc333" })
72+73+ stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc333")
74+ .to_return_json(body: { "items": ["four", "five"] })
75+ end
76+77+ include_examples "authorization",
78+ request: ->(subject, params) {
79+ subject.fetch_all('com.example.service.fetchAll', field: 'items', **params)
80+ },
81+ expected: ->(host) {[
82+ [:get, "https://#{host}/xrpc/com.example.service.fetchAll"],
83+ [:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=ccc333"]
84+ ]}
85+ end
86+87+ context 'when break condition is passed' do
88+ before do
89+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
90+ { "items": ["one", "two", "three"] },
91+ { "items": ["four", "five"] },
92+ { "items": ["six"] },
93+ ])
94+ end
95+96+ it 'should stop when a matching item is found' do
97+ subject.fetch_all('com.example.service.fetchAll', field: 'items', break_when: ->(x) { x =~ /u/ })
98+99+ WebMock.should have_requested(:get, @stubbed_urls[0]).once
100+ WebMock.should have_requested(:get, @stubbed_urls[1]).once
101+ WebMock.should_not have_requested(:get, @stubbed_urls[2])
102+ end
103+104+ it 'should filter out matching items from the response' do
105+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items', break_when: ->(x) { x =~ /u/ })
106+ result.should == ["one", "two", "three", "five"]
107+ end
108+ end
109+110+ context 'when max pages limit is passed' do
111+ before do
112+ stub_request(:get, %r(https://#{host}/xrpc/com.example.service.fetchAll(\?.*)?))
113+ .to_return_json(
114+ body: ->(req) {
115+ params = req.uri.query_values || {}
116+ page = params['cursor'].to_s.gsub(/page/, '').to_i
117+ { items: ["item#{page}"], cursor: "page#{page + 1}" }
118+ }
119+ )
120+ end
121+122+ context 'and break_when is not passed' do
123+ it 'should stop at n-th page' do
124+ subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5)
125+126+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
127+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
128+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
129+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
130+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
131+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5")
132+ end
133+134+ it 'should collect all items' do
135+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5)
136+ result.should == ["item0", "item1", "item2", "item3", "item4"]
137+ end
138+ end
139+140+ context 'and break_when matches earlier' do
141+ it 'should stop at the page where break_when matches' do
142+ subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5,
143+ break_when: ->(x) { x =~ /3/ })
144+145+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
146+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
147+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
148+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
149+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4")
150+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5")
151+ end
152+153+ it 'should exclude items that matched break_when' do
154+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 5,
155+ break_when: ->(x) { x =~ /3/ })
156+157+ result.should == ["item0", "item1", "item2"]
158+ end
159+ end
160+161+ context "and break_when doesn't match earlier" do
162+ it 'should stop at the n-th page' do
163+ subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
164+ break_when: ->(x) { x =~ /8/ })
165+166+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
167+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
168+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
169+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
170+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
171+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5").once
172+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page6")
173+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page7")
174+ end
175+176+ it 'should include all items up to n-th page' do
177+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
178+ break_when: ->(x) { x =~ /8/ })
179+180+ result.should == ["item0", "item1", "item2", "item3", "item4", "item5"]
181+ end
182+ end
183+184+ context "and break_when matches on the last page" do
185+ it 'should stop at the n-th page' do
186+ subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
187+ break_when: ->(x) { x =~ /5/ })
188+189+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll").once
190+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1").once
191+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2").once
192+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page3").once
193+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page4").once
194+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page5").once
195+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page6")
196+ WebMock.should_not have_requested(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page7")
197+ end
198+199+ it 'should exclude the items matching on the last page' do
200+ result = subject.fetch_all('com.example.service.fetchAll', field: 'items', max_pages: 6,
201+ break_when: ->(x) { x =~ /5/ })
202+203+ result.should == ["item0", "item1", "item2", "item3", "item4"]
204+ end
205+ end
206+ end
207+208+ context 'when an empty page is received, but with a cursor' do
209+ before do
210+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
211+ { "feed": ["one", "two", "three"] },
212+ { "feed": [] },
213+ { "feed": ["six"] },
214+ ])
215+ end
216+217+ it 'should continue fetching until the cursor is nil' do
218+ result = subject.fetch_all('com.example.service.fetchAll', field: 'feed')
219+ result.should == ['one', 'two', 'three', 'six']
220+ end
221+ end
222+223+ context 'when field is not passed' do
224+ before do
225+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
226+ { "thingies": ["one", "two", "three"], "best": "two", "foobars": ["foo", "bar"], "total": 6 },
227+ { "items": ["four", "five"] },
228+ ])
229+ end
230+231+ it 'should make one request and raise an error with list of array fields' do
232+ expect { subject.fetch_all('com.example.service.fetchAll') }.to raise_error { |err|
233+ err.should be_a(Minisky::FieldNotSetError)
234+ err.fields.should == ['thingies', 'foobars']
235+ }
236+237+ WebMock.should have_requested(:get, @stubbed_urls[0]).once
238+ WebMock.should_not have_requested(:get, @stubbed_urls[1])
239+ end
240+ end
241+242+ context 'when field is nil' do
243+ before do
244+ stub_fetch_all("https://#{host}/xrpc/com.example.service.fetchAll", [
245+ { "thingies": ["one", "two", "three"], "best": "two", "foobars": ["foo", "bar"], "total": 6 },
246+ { "items": ["four", "five"] },
247+ ])
248+ end
249+250+ it 'should make one request and raise an error with list of array fields' do
251+ expect { subject.fetch_all('com.example.service.fetchAll', field: nil) }.to raise_error { |err|
252+ err.should be_a(Minisky::FieldNotSetError)
253+ err.fields.should == ['thingies', 'foobars']
254+ }
255+256+ WebMock.should have_requested(:get, @stubbed_urls[0]).once
257+ WebMock.should_not have_requested(:get, @stubbed_urls[1])
258+ end
259+ end
260+261+ describe 'progress param' do
262+ before do
263+ stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll")
264+ .to_return_json(body: { "items": ["one"], "cursor": "page1" })
265+266+ stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page1")
267+ .to_return_json(body: { "items": ["two"], "cursor": "page2" })
268+269+ stub_request(:get, "https://#{host}/xrpc/com.example.service.fetchAll?cursor=page2")
270+ .to_return_json(body: { "items": ["three"] })
271+ end
272+273+ context 'when it is passed' do
274+ it 'should print the progress character for each request' do
275+ expect {
276+ subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: '-=')
277+ }.to output('-=-=-=').to_stdout
278+ end
279+ end
280+281+ context 'when it is not passed' do
282+ it 'should not print anything' do
283+ expect {
284+ subject.fetch_all('com.example.service.fetchAll', field: 'items')
285+ }.to output('').to_stdout
286+ end
287+ end
288+289+ context 'when it is passed and a default is set' do
290+ it 'should use the param version' do
291+ subject.default_progress = '@'
292+293+ expect {
294+ subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: '#')
295+ }.to output('###').to_stdout
296+ end
297+ end
298+299+ context 'when it is not passed and a default is set' do
300+ it 'should use the default version' do
301+ subject.default_progress = '$'
302+303+ expect {
304+ subject.fetch_all('com.example.service.fetchAll', field: 'items')
305+ }.to output('$$$').to_stdout
306+ end
307+ end
308+309+ context 'when default is set and nil is passed' do
310+ it 'should not output anything' do
311+ subject.default_progress = '$'
312+313+ expect {
314+ subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: nil)
315+ }.to output('').to_stdout
316+ end
317+ end
318+319+ context 'when default is set and false is passed' do
320+ it 'should not output anything' do
321+ subject.default_progress = '$'
322+323+ expect {
324+ subject.fetch_all('com.example.service.fetchAll', field: 'items', progress: false)
325+ }.to output('').to_stdout
326+ end
327+ end
328+ end
329+ end
330+end
···1+require_relative 'ex_authorization'
2+require_relative 'ex_bad_response'
3+4+shared_examples "get_request" do
5+ describe '#get_request' do
6+ before do
7+ stub_request(:get, %r(https://#{host}/xrpc/com.example.service.getStuff(\?.*)?)).to_return(response)
8+ end
9+10+ let(:response) {{ body: JSON.generate({ "result": 123 }), headers: { content_type: 'application/json' }}}
11+12+ it 'should make a request to the given XRPC endpoint' do
13+ subject.get_request('com.example.service.getStuff')
14+15+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
16+ end
17+18+ it 'should return parsed JSON' do
19+ result = subject.get_request('com.example.service.getStuff')
20+21+ result.should == { 'result' => 123 }
22+ end
23+24+ context 'with params' do
25+ it 'should append params to the URL' do
26+ subject.get_request('com.example.service.getStuff', { repo: 'whitehouse.gov', limit: 80 })
27+28+ WebMock.should have_requested(:get,
29+ "https://#{host}/xrpc/com.example.service.getStuff?repo=whitehouse.gov&limit=80").once
30+ end
31+ end
32+33+ context 'with nil params' do
34+ it 'should not append anything to the URL' do
35+ subject.get_request('com.example.service.getStuff', nil)
36+37+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
38+ end
39+ end
40+41+ context 'with empty params' do
42+ it 'should not append anything to the URL' do
43+ subject.get_request('com.example.service.getStuff', {})
44+45+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff").once
46+ end
47+ end
48+49+ context 'with an array passed as param' do
50+ it 'should append one copy of the param for each item' do
51+ subject.get_request('com.example.service.getStuff', { profiles: ['john.foo', 'spam.zip'], reposts: true })
52+53+ WebMock.should have_requested(:get,
54+ "https://#{host}/xrpc/com.example.service.getStuff?profiles=john.foo&profiles=spam.zip&reposts=true").once
55+ end
56+ end
57+58+ context 'with headers' do
59+ it 'should include the custom headers' do
60+ subject.get_request('com.example.service.getStuff', { user: 'alf.gov' }, headers: { 'Food': 'cats' })
61+62+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getStuff?user=alf.gov").once
63+ .with(headers: { 'Food' => 'cats' })
64+ end
65+ end
66+67+ context 'with an invalid method name' do
68+ it 'should raise an ArgumentError' do
69+ INVALID_METHOD_NAMES.each do |m|
70+ expect { subject.get_request(m) }.to raise_error(ArgumentError)
71+ end
72+ end
73+ end
74+75+ include_examples "bad response handling", :get, 'com.example.service.getStuff'
76+77+ include_examples "authorization",
78+ request: ->(subject, params) { subject.get_request('com.example.service.getStuff', **params) },
79+ expected: ->(host) { [:get, "https://#{host}/xrpc/com.example.service.getStuff"] }
80+ end
81+end
+36
spec/shared/ex_incomplete_auth.rb
···000000000000000000000000000000000000
···1+shared_examples "custom client with incomplete auth" do
2+ it 'should have send_auth_headers enabled' do
3+ subject.send_auth_headers.should == true
4+ end
5+6+ it 'should have auto_manage_tokens enabled' do
7+ subject.auto_manage_tokens.should == true
8+ end
9+10+ it 'should fail on get_request' do
11+ expect { subject.get_request('com.example.service.getStuff') }.to raise_error(Minisky::AuthError)
12+ end
13+14+ it 'should fail on post_request' do
15+ expect { subject.post_request('com.example.service.doStuff', 'qqq') }.to raise_error(Minisky::AuthError)
16+ end
17+18+ it 'should fail on fetch_all' do
19+ expect { subject.fetch_all('com.example.service.fetchStuff', {}, field: 'feed') }.to raise_error(Minisky::AuthError)
20+ end
21+22+ it 'should fail on check_access' do
23+ expect { subject.check_access }.to raise_error(Minisky::AuthError)
24+ end
25+26+ it 'should fail on log_in' do
27+ expect { subject.log_in }.to raise_error(Minisky::AuthError)
28+ end
29+30+ it 'should fail on perform_token_refresh' do
31+ expect { subject.perform_token_refresh }.to raise_error(Minisky::AuthError)
32+ end
33+34+ # todo perform w/ access token
35+ # todo test if properties turned off
36+end
···1+require_relative 'ex_authorization'
2+require_relative 'ex_bad_response'
3+4+shared_examples "post_request" do
5+ describe '#post_request' do
6+ let(:response) {{ body: '{ "result": "ok" }', headers: { 'Content-Type': 'application/json' }}}
7+8+ before do
9+ stub_request(:post, "https://#{host}/xrpc/com.example.service.doStuff").to_return(response)
10+ end
11+12+ it 'should make a request to the given XRPC endpoint' do
13+ subject.post_request('com.example.service.doStuff')
14+15+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
16+ end
17+18+ it 'should return parsed JSON' do
19+ result = subject.post_request('com.example.service.doStuff')
20+21+ result.should == { 'result' => 'ok' }
22+ end
23+24+ context 'if data is passed as a hash' do
25+ let(:post_data) {
26+ { repo: 'kate.dev', limit: 40, fields: ['name', 'posts'] }
27+ }
28+29+ it 'should encode it as JSON in the body' do
30+ subject.post_request('com.example.service.doStuff', post_data)
31+32+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
33+ .with(body: JSON.generate(post_data))
34+ end
35+36+ it 'should set content type to application/json' do
37+ subject.post_request('com.example.service.doStuff', post_data)
38+39+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
40+ .with(headers: { 'Content-Type': 'application/json' })
41+ end
42+43+ context 'and custom content-type is set' do
44+ it 'should use that custom Content-Type' do
45+ subject.post_request('com.example.service.doStuff', post_data, headers: { 'Content-Type': 'application/graphql' })
46+47+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
48+ .with(headers: { 'Content-Type': 'application/graphql' })
49+ end
50+ end
51+52+ context 'and custom content-type in set in lowercase' do
53+ it 'should still use that custom Content-Type' do
54+ subject.post_request('com.example.service.doStuff', post_data, headers: { 'content-type': 'application/graphql' })
55+56+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
57+ .with(headers: { 'content-type': 'application/graphql' })
58+ end
59+ end
60+61+ context 'and other custom header is set' do
62+ it 'should add a json content type' do
63+ subject.post_request('com.example.service.doStuff', post_data, headers: { 'X-API-Token': '8768768768' })
64+65+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
66+ .with(headers: { 'Content-Type': 'application/json', 'X-API-Token': '8768768768' })
67+ end
68+ end
69+ end
70+71+ context 'if data is not passed' do
72+ it 'should send an empty body' do
73+ subject.post_request('com.example.service.doStuff')
74+75+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
76+ .with(body: '')
77+ end
78+79+ it 'should not set content type' do
80+ subject.post_request('com.example.service.doStuff')
81+82+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
83+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' }}
84+ end
85+86+ context 'and custom content-type is set' do
87+ it 'should include the custom Content-Type' do
88+ subject.post_request('com.example.service.doStuff', headers: { 'Content-Type': 'image/png' })
89+90+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
91+ .with(body: '', headers: { 'Content-Type': 'image/png' })
92+ end
93+ end
94+95+ context 'and custom content-type in set in lowercase' do
96+ it 'should include the custom Content-Type' do
97+ subject.post_request('com.example.service.doStuff', headers: { 'content-type': 'image/jpeg' })
98+99+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
100+ .with(body: '', headers: { 'content-type': 'image/jpeg' })
101+ end
102+ end
103+104+ context 'and other custom header is set' do
105+ it 'should not add content type' do
106+ subject.post_request('com.example.service.doStuff', headers: { 'Blob-Type': 'blobby' })
107+108+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
109+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' } && req.headers['Blob-Type'] == 'blobby' }
110+ end
111+ end
112+ end
113+114+ context 'if data is an explicit nil' do
115+ it 'should send an empty body' do
116+ subject.post_request('com.example.service.doStuff', nil)
117+118+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
119+ .with(body: '')
120+ end
121+122+ it 'should not set content type' do
123+ subject.post_request('com.example.service.doStuff', nil)
124+125+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
126+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' }}
127+ end
128+129+ context 'and custom content-type is set' do
130+ it 'should include the custom Content-Type' do
131+ subject.post_request('com.example.service.doStuff', nil, headers: { 'Content-Type': 'image/png' })
132+133+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
134+ .with(body: '', headers: { 'Content-Type': 'image/png' })
135+ end
136+ end
137+138+ context 'and custom content-type in set in lowercase' do
139+ it 'should include the custom Content-Type' do
140+ subject.post_request('com.example.service.doStuff', nil, headers: { 'content-type': 'image/jpeg' })
141+142+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
143+ .with(body: '', headers: { 'content-type': 'image/jpeg' })
144+ end
145+ end
146+147+ context 'and other custom header is set' do
148+ it 'should not add content type' do
149+ subject.post_request('com.example.service.doStuff', nil, headers: { 'Blob-Type': 'blobby' })
150+151+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
152+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' } && req.headers['Blob-Type'] == 'blobby' }
153+ end
154+ end
155+ end
156+157+ context 'if data is a string' do
158+ it 'should send that string' do
159+ subject.post_request('com.example.service.doStuff', 'hello world')
160+161+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
162+ .with(body: 'hello world')
163+ end
164+165+ it 'should not set content type' do
166+ subject.post_request('com.example.service.doStuff', 'hello world')
167+168+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
169+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' }}
170+ end
171+172+ context 'and custom content-type is set' do
173+ it 'should include the custom Content-Type' do
174+ subject.post_request('com.example.service.doStuff', 'blob', headers: { 'Content-Type': 'image/png' })
175+176+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
177+ .with(body: 'blob', headers: { 'Content-Type': 'image/png' })
178+ end
179+ end
180+181+ context 'and custom content-type in set in lowercase' do
182+ it 'should include the custom Content-Type' do
183+ subject.post_request('com.example.service.doStuff', 'blob', headers: { 'content-type': 'image/jpeg' })
184+185+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
186+ .with(body: 'blob', headers: { 'content-type': 'image/jpeg' })
187+ end
188+ end
189+190+ context 'and other custom header is set' do
191+ it 'should not add content type' do
192+ subject.post_request('com.example.service.doStuff', 'blob', headers: { 'Blob-Type': 'blobby' })
193+194+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.doStuff").once
195+ .with { |req| req.headers.all? { |k, v| k.downcase != 'content-type' } && req.headers['Blob-Type'] == 'blobby' }
196+ end
197+ end
198+ end
199+200+ context 'with both string data and query params' do
201+ it 'should add the params to the URL' do
202+ stub_request(:post, "https://#{host}/xrpc/app.bsky.video.uploadVideo?name=rickroll.mp4").to_return(response)
203+204+ subject.post_request('app.bsky.video.uploadVideo', '/\/\/\/\/\/\/', params: { name: 'rickroll.mp4' })
205+206+ WebMock.should have_requested(:post, "https://#{host}/xrpc/app.bsky.video.uploadVideo?name=rickroll.mp4").once
207+ .with(body: '/\/\/\/\/\/\/')
208+ end
209+ end
210+211+ context 'with an invalid method name' do
212+ it 'should raise an ArgumentError' do
213+ INVALID_METHOD_NAMES.each do |m|
214+ expect { subject.post_request(m) }.to raise_error(ArgumentError)
215+ end
216+ end
217+ end
218+219+ include_examples "bad response handling", :post, 'com.example.service.doStuff'
220+221+ include_examples "authorization",
222+ request: ->(subject, params) { subject.post_request('com.example.service.doStuff', **params) },
223+ expected: ->(host) { [:post, "https://#{host}/xrpc/com.example.service.doStuff"] }
224+ end
225+end
···1+require_relative 'ex_get_request'
2+require_relative 'ex_post_request'
3+require_relative 'ex_fetch_all'
4+5+shared_examples "authenticated requests" do |host|
6+ let(:host) { host }
7+8+ before do
9+ subject.auto_manage_tokens = false
10+ end
11+12+ it 'should have a user object wrapping the config' do
13+ subject.config['something'] = 'some value'
14+15+ subject.user.something.should == 'some value'
16+ end
17+18+ describe '#log_in' do
19+ let(:response_json) {{
20+ "did" => "did:plc:abracadabra",
21+ "accessJwt" => "aaaa1234",
22+ "refreshJwt" => "rrrr1234"
23+ }}
24+25+ before do
26+ stub_request(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
27+ .to_return_json(body: response_json)
28+ end
29+30+ it 'should make a request to com.atproto.server.createSession' do
31+ subject.log_in
32+33+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
34+ .once.with(body: %({"identifier":"john.foo","password":"hunter2"}))
35+ end
36+37+ [true, false, nil, :undefined, 'wtf'].each do |v|
38+ context "with send_auth_headers set to #{v.inspect}" do
39+ it 'should not set authentication header' do
40+ subject.send_auth_headers = v unless v == :undefined
41+ subject.log_in
42+43+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
44+ WebMock.should_not have_requested(:post, "https://#{host}/xrpc/com.atproto.server.createSession")
45+ .with(headers: { 'Authorization' => /.*/ })
46+ end
47+ end
48+ end
49+50+ it "should save user's DID" do
51+ subject.log_in
52+53+ reloaded_config['did'].should == "did:plc:abracadabra"
54+ end
55+56+ it "should update the tokens in the config file" do
57+ subject.log_in
58+59+ reloaded_config['access_token'].should == 'aaaa1234'
60+ reloaded_config['refresh_token'].should == 'rrrr1234'
61+ end
62+63+ it 'should return the response json' do
64+ subject.log_in.should == response_json
65+ end
66+ end
67+68+ describe '#perform_token_refresh' do
69+ let(:response_json) {{
70+ "accessJwt" => "aaaa1234",
71+ "refreshJwt" => "rrrr1234"
72+ }}
73+74+ before do
75+ stub_request(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
76+ .to_return_json(body: response_json)
77+ end
78+79+ it 'should make a request to com.atproto.server.refreshSession' do
80+ subject.perform_token_refresh
81+82+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
83+ .once.with(body: '')
84+ end
85+86+ [true, false, nil, :undefined, 'wtf'].each do |v|
87+ context "with send_auth_headers set to #{v.inspect}" do
88+ it 'should authenticate with the refresh token' do
89+ subject.send_auth_headers = v unless v == :undefined
90+ subject.perform_token_refresh
91+92+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.atproto.server.refreshSession")
93+ .once.with(headers: { 'Authorization' => 'Bearer rrtoken' })
94+ end
95+ end
96+ end
97+98+ it "should update the tokens in the config file" do
99+ subject.perform_token_refresh
100+101+ reloaded_config['access_token'].should == 'aaaa1234'
102+ reloaded_config['refresh_token'].should == 'rrrr1234'
103+ end
104+105+ it 'should return the response json' do
106+ subject.perform_token_refresh.should == response_json
107+ end
108+109+ context 'if refresh_token is nil' do
110+ before do
111+ subject.user.refresh_token = nil
112+ end
113+114+ it 'should raise AuthError' do
115+ expect { subject.perform_token_refresh }.to raise_error(Minisky::AuthError)
116+ end
117+ end
118+119+ context 'if refresh_token is not provided' do
120+ before do
121+ subject.config.delete('refresh_token')
122+ end
123+124+ it 'should raise AuthError' do
125+ expect { subject.perform_token_refresh }.to raise_error(Minisky::AuthError)
126+ end
127+ end
128+ end
129+130+ describe '#reset_tokens' do
131+ it 'should set tokens to nil' do
132+ subject.reset_tokens
133+134+ subject.user.access_token.should be_nil
135+ subject.user.refresh_token.should be_nil
136+ end
137+138+ it 'should save the config to disk' do
139+ subject.reset_tokens
140+141+ config = reloaded_config
142+143+ config['access_token'].should be_nil
144+ config['refresh_token'].should be_nil
145+ end
146+147+ context 'if tokens are already nil' do
148+ it 'should not raise error' do
149+ subject.reset_tokens
150+151+ expect { subject.reset_tokens }.not_to raise_error
152+ end
153+ end
154+ end
155+156+ include_examples "get_request"
157+ include_examples "post_request"
158+ include_examples "fetch_all"
159+end
···1+shared_examples "unauthenticated user" do
2+ let(:host) { subject.host }
3+4+ describe '#log_in' do
5+ it 'should raise AuthError' do
6+ expect { subject.log_in }.to raise_error(Minisky::AuthError)
7+ end
8+ end
9+10+ describe '#perform_token_refresh' do
11+ it 'should raise AuthError' do
12+ expect { subject.perform_token_refresh }.to raise_error(Minisky::AuthError)
13+ end
14+ end
15+16+ describe '#check_access' do
17+ it 'should raise AuthError' do
18+ expect { subject.check_access }.to raise_error(Minisky::AuthError)
19+ end
20+ end
21+22+ describe '#reset_tokens' do
23+ it 'should raise AuthError' do
24+ expect { subject.reset_tokens }.to raise_error(Minisky::AuthError)
25+ end
26+ end
27+28+ context '#user' do
29+ it 'should return nil' do
30+ subject.user.should be_nil
31+ end
32+ end
33+34+ context 'with auth headers off' do
35+ describe '#get_request' do
36+ it 'should not raise errors' do
37+ stub_request(:get, "https://#{host}/xrpc/com.example.service.getTrends").to_return_json(body: { result: 123 })
38+39+ expect { subject.get_request('com.example.service.getTrends') }.to_not raise_error
40+41+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.getTrends").once
42+ end
43+ end
44+45+ describe '#post_request' do
46+ it 'should not raise errors' do
47+ stub_request(:post, "https://#{host}/xrpc/com.example.service.createApp").to_return_json(body: { result: 123 })
48+49+ expect { subject.post_request('com.example.service.createApp') }.to_not raise_error
50+51+ WebMock.should have_requested(:post, "https://#{host}/xrpc/com.example.service.createApp").once
52+ end
53+ end
54+55+ describe '#fetch_all' do
56+ it 'should not raise errors' do
57+ stub_request(:get, "https://#{host}/xrpc/com.example.service.listRepos")
58+ .to_return_json(body: { "repos": ["aaa"], "cursor": "x123" })
59+60+ stub_request(:get, "https://#{host}/xrpc/com.example.service.listRepos?cursor=x123")
61+ .to_return_json(body: { "repos": ["bbb"] })
62+63+ expect { subject.fetch_all('com.example.service.listRepos', field: 'repos') }.to_not raise_error
64+65+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.listRepos").once
66+ WebMock.should have_requested(:get, "https://#{host}/xrpc/com.example.service.listRepos?cursor=x123").once
67+ end
68+ end
69+ end
70+71+ context 'with sending auth headers turned on' do
72+ before do
73+ subject.send_auth_headers = true
74+ end
75+76+ describe '#get_request' do
77+ it 'should raise an error' do
78+ expect { subject.get_request('com.example.service.getTrends') }.to raise_error(Minisky::AuthError)
79+ end
80+ end
81+82+ describe '#post_request' do
83+ it 'should not raise errors' do
84+ expect { subject.post_request('com.example.service.createApp') }.to raise_error(Minisky::AuthError)
85+ end
86+ end
87+88+ describe '#fetch_all' do
89+ it 'should not raise errors' do
90+ expect { subject.fetch_all('com.example.service.listRepos', field: 'repos') }.to raise_error(Minisky::AuthError)
91+ end
92+ end
93+ end
94+95+ context 'with sending auth headers & auto manage tokens turned on' do
96+ before do
97+ subject.send_auth_headers = true
98+ subject.auto_manage_tokens = true
99+ end
100+101+ describe '#get_request' do
102+ it 'should raise an error' do
103+ expect { subject.get_request('com.example.service.getTrends') }.to raise_error(Minisky::AuthError)
104+ end
105+ end
106+107+ describe '#post_request' do
108+ it 'should not raise errors' do
109+ expect { subject.post_request('com.example.service.createApp') }.to raise_error(Minisky::AuthError)
110+ end
111+ end
112+113+ describe '#fetch_all' do
114+ it 'should not raise errors' do
115+ expect { subject.fetch_all('com.example.service.listRepos', field: 'repos') }.to raise_error(Minisky::AuthError)
116+ end
117+ end
118+ end
119+end
+5
spec/shared/fake_irb.rb
···00000
···1+class IRB
2+ def self.CurrentContext
3+ {}
4+ end
5+end
···1+require 'simplecov'
2+3+SimpleCov.start do
4+ enable_coverage :branch
5+ add_filter "/spec/"
6+end
7+8+require 'minisky'
9+require 'pp' # needs to be included before fakefs
10+require 'fakefs/spec_helpers'
11+require 'webmock/rspec'
12+13+RSpec.configure do |config|
14+ # Enable flags like --only-failures and --next-failure
15+ config.example_status_persistence_file_path = ".rspec_status"
16+17+ config.expect_with :rspec do |c|
18+ c.syntax = [:should, :expect]
19+ end
20+end
21+22+module SimpleCov
23+ module Formatter
24+ class HTMLFormatter
25+ def format(result)
26+ # silence the stdout summary, just save the html files
27+ unless @inline_assets
28+ Dir[File.join(@public_assets_dir, "*")].each do |path|
29+ FileUtils.cp_r(path, asset_output_path, remove_destination: true)
30+ end
31+ end
32+33+ File.open(File.join(output_path, "index.html"), "wb") do |file|
34+ file.puts template("layout").result(binding)
35+ end
36+ end
37+ end
38+ end
39+end
+42-10
spec/spec_helper.rb
···1-require 'minisky'
2-require 'pp' # needs to be included before fakefs
034-require 'fakefs/spec_helpers'
5-require 'webmock/rspec'
000067-require 'requests_shared'
0089-RSpec.configure do |config|
10- # Enable flags like --only-failures and --next-failure
11- config.example_status_persistence_file_path = ".rspec_status"
1213- config.expect_with :rspec do |c|
14- c.syntax = [:should, :expect]
00000000015 end
000000000000000016end
···13 subject.email.should == 'admin@bsky.app'
14 end
150000016 context '#logged_in?' do
17 it 'should return false if access token is missing' do
18 subject.logged_in?.should be false
···30 subject.instance_variable_get('@config')['refresh_token'] = 'rrrr'
31 subject.instance_variable_get('@config')['access_token'] = 'aaaa'
32 subject.logged_in?.should be true
000000000000000033 end
34 end
35end
···13 subject.email.should == 'admin@bsky.app'
14 end
1516+ it 'should pass setters to the config hash' do
17+ subject.age = 33
18+ config['age'].should == 33
19+ end
20+21 context '#logged_in?' do
22 it 'should return false if access token is missing' do
23 subject.logged_in?.should be false
···35 subject.instance_variable_get('@config')['refresh_token'] = 'rrrr'
36 subject.instance_variable_get('@config')['access_token'] = 'aaaa'
37 subject.logged_in?.should be true
38+ end
39+ end
40+41+ context '#has_credentials?' do
42+ it 'should return false if id is missing' do
43+ subject.instance_variable_get('@config')['id'] = nil
44+ subject.has_credentials?.should be false
45+ end
46+47+ it 'should return false if pass is missing' do
48+ subject.instance_variable_get('@config')['pass'] = nil
49+ subject.has_credentials?.should be false
50+ end
51+52+ it 'should return true if both id and pass are set' do
53+ subject.has_credentials?.should be true
54 end
55 end
56end