A minimal Ruby client of Bluesky/ATProto API

Compare changes

Choose any two refs to compare.

+2500 -713
+8 -1
.github/workflows/main.yml
··· 14 strategy: 15 matrix: 16 ruby: 17 - - '3.2.2' 18 19 steps: 20 - uses: actions/checkout@v3
··· 14 strategy: 15 matrix: 16 ruby: 17 + - '2.6' 18 + - '2.7' 19 + - '3.0' 20 + - '3.1' 21 + - '3.2' 22 + - '3.3' 23 + - '3.4' 24 + - '4.0' 25 26 steps: 27 - uses: actions/checkout@v3
+4
.gitignore
··· 1 .bundle 2 .rspec_status 3 *.yml 4 Gemfile.lock
··· 1 .bundle 2 .rspec_status 3 + .yardoc 4 *.yml 5 + coverage 6 + doc 7 + example/*.json 8 Gemfile.lock
+4
.yardopts
···
··· 1 + --protected 2 + --no-private 3 + --default-return nil 4 + --markup markdown
+56 -2
CHANGELOG.md
··· 1 ## [0.2.0] - 2023-09-02 2 3 * 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 18 19 - extracted most code to a `Requests` module that can be included into a different client class with custom config handling ··· 24 25 ## [0.0.1] - 2023-08-30 26 27 - Initial release - extracted from original gist: 28 29 - 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 56 57 * 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 72 73 - extracted most code to a `Requests` module that can be included into a different client class with custom config handling ··· 78 79 ## [0.0.1] - 2023-08-30 80 81 + Initial release โ€“ extracted from original gist: 82 83 - logging in and refreshing the token 84 - making GET & POST requests
+8 -2
Gemfile
··· 5 # Specify your gem's dependencies in minisky.gemspec 6 gemspec 7 8 - gem 'rake', '~> 13.0' 9 gem 'rspec', '~> 3.12' 10 - gem 'fakefs', '~> 2.5' 11 gem 'webmock', '~> 3.19'
··· 5 # Specify your gem's dependencies in minisky.gemspec 6 gemspec 7 8 + gem 'rake' 9 + gem 'irb' 10 + 11 + gem 'rdoc' 12 + gem 'yard' 13 + 14 gem 'rspec', '~> 3.12' 15 + gem 'fakefs', '~> 1.8' 16 gem 'webmock', '~> 3.19' 17 + gem 'simplecov'
+1 -1
LICENSE.txt
··· 1 The zlib License 2 3 - Copyright (c) 2023 Jakub Suder 4 5 This software is provided 'as-is', without any express or implied 6 warranty. In no event will the authors be held liable for any damages
··· 1 The zlib License 2 3 + Copyright (c) 2026 Jakub Suder 4 5 This software is provided 'as-is', without any express or implied 6 warranty. In no event will the authors be held liable for any damages
+78 -25
README.md
··· 1 - # Minisky 2 3 Minisky 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 6 ## Installation 7 8 - 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. 9 10 To install the Minisky gem, run the command: 11 12 [sudo] gem install minisky 13 14 - Or alternatively, add it to the `Gemfile` file for Bundler: 15 16 - gem 'minisky', '~> 0.2' 17 18 19 ## Usage 20 21 - First, you need to create a `.yml` config file (by default, `bluesky.yml`) with the authentication data. It should look like this: 22 23 ```yaml 24 id: my.bsky.username ··· 27 28 The `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. 29 30 After 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`. 31 32 - 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): 33 34 ```rb 35 require 'minisky' 36 37 - bsky = Minisky.new('bsky.social') 38 - bsky.check_access 39 ``` 40 41 - `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. 42 43 - Now, you can make requests to the Bluesky API using `get_request` and `post_request`: 44 45 ```rb 46 - bsky.get_request('com.atproto.repo.listRecords', { 47 repo: bsky.user.did, 48 collection: 'app.bsky.feed.like' 49 }) 50 51 bsky.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 57 } 58 }) 59 ``` 60 61 - 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. 62 63 - 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: 64 65 ```rb 66 time_limit = Time.now - 86400 * 30 67 68 - bsky.fetch_all('com.atproto.repo.listRecords', 69 { repo: bsky.user.did, collection: 'app.bsky.feed.post' }, 70 field: 'records', 71 max_pages: 10, ··· 75 There 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: 76 77 ```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 ``` 89 90 ## Customization 91 92 - The `Minisky` client currently supports one configuration option: 93 94 - - `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `nil`) 95 96 - When creating the `Minisky` instance, you can pass a name of the YAML config file to use instead of the default: 97 98 - ```rb 99 - bsky = Minisky.new('bsky.social', 'config/access.yml') 100 - ``` 101 102 - 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: 103 104 ```rb 105 class BlueskyClient ··· 126 127 ```rb 128 bsky = BlueskyClient.new('config/access.json') 129 - bsky.check_access 130 bsky.get_request(...) 131 ``` 132 ··· 139 140 ## Credits 141 142 - Copyright ยฉ 2023 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)). 143 144 The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT). 145
··· 1 + # Minisky ๐ŸŒค 2 3 Minisky 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) 9 10 11 ## Installation 12 13 + 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/)). 14 15 To install the Minisky gem, run the command: 16 17 [sudo] gem install minisky 18 19 + Or add it to your app's `Gemfile`: 20 21 + gem 'minisky', '~> 0.5' 22 23 24 ## Usage 25 26 + 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: 60 61 ```yaml 62 id: my.bsky.username ··· 65 66 The `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. 67 68 + > [!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 + 71 After 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`. 72 73 + 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: 74 75 ```rb 76 require 'minisky' 77 78 + bsky = Minisky.new('bsky.social', 'bluesky.yml') 79 ``` 80 81 + 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 85 86 + With a `Minisky` client instance, you can make requests to the Bluesky API using `get_request` and `post_request`: 87 88 ```rb 89 + json = bsky.get_request('com.atproto.repo.listRecords', { 90 repo: bsky.user.did, 91 collection: 'app.bsky.feed.like' 92 }) 93 94 + json['records'].each do |r| 95 + puts r['value']['subject']['uri'] 96 + end 97 + 98 bsky.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 ``` 108 109 + 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. 110 111 + 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: 112 113 ```rb 114 time_limit = Time.now - 86400 * 30 115 116 + 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, ··· 123 There 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: 124 125 ```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 ``` 137 138 + 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 142 143 + 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. 150 151 + 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`. 152 153 154 + ### Using your own class 155 156 + 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: 157 158 ```rb 159 class BlueskyClient ··· 180 181 ```rb 182 bsky = BlueskyClient.new('config/access.json') 183 bsky.get_request(...) 184 ``` 185 ··· 192 193 ## Credits 194 195 + Copyright ยฉ 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)). 196 197 The 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
···
··· 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
+122
lib/minisky/errors.rb
···
··· 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
+70 -5
lib/minisky/minisky.rb
··· 1 require 'yaml' 2 3 class Minisky 4 - DEFAULT_CONFIG_FILE = 'bluesky.yml' 5 6 - attr_reader :host, :config 7 8 - def initialize(host, config_file = DEFAULT_CONFIG_FILE) 9 @host = host 10 @config_file = config_file 11 - @config = YAML.load(File.read(@config_file)) 12 end 13 14 def save_config 15 - File.write(@config_file, YAML.dump(@config)) 16 end 17 end 18
··· 1 require 'yaml' 2 3 + # 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 + 29 class Minisky 30 + 31 + # @return [String] the hostname (or base URL) of the server 32 + attr_reader :host 33 34 + # @return [Hash] loaded contents of the config file 35 + attr_reader :config 36 37 + # 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 69 70 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 82 end 83
+437 -31
lib/minisky/requests.rb
··· 1 require_relative 'minisky' 2 3 require 'json' 4 require 'net/http' 5 - require 'open-uri' 6 require 'uri' 7 8 class Minisky 9 class User 10 def initialize(config) 11 @config = config 12 end 13 14 def logged_in? 15 !!(access_token && refresh_token) 16 end 17 18 - def method_missing(name) 19 - @config[name.to_s] 20 end 21 end 22 23 module Requests 24 attr_accessor :default_progress 25 26 def base_url 27 - @base_url ||= "https://#{host}/xrpc" 28 end 29 30 def user 31 - @user ||= User.new(config) 32 end 33 34 - def get_request(method, params = nil, auth: true) 35 - headers = authentication_header(auth) 36 - url = URI("#{base_url}/#{method}") 37 38 if params && !params.empty? 39 url.query = URI.encode_www_form(params) 40 end 41 42 - JSON.parse(URI.open(url, headers).read) 43 end 44 45 - def post_request(method, params = nil, auth: true) 46 - headers = authentication_header(auth).merge({ "Content-Type" => "application/json" }) 47 - body = params ? params.to_json : '' 48 49 - 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 51 52 - JSON.parse(response.body) 53 end 54 55 - def fetch_all(method, params = nil, field:, 56 - auth: true, break_when: nil, max_pages: nil, progress: @default_progress) 57 data = [] 58 params = {} if params.nil? 59 pages = 0 ··· 61 loop do 62 print(progress) if progress 63 64 - response = get_request(method, params, auth: auth) 65 records = response[field] 66 cursor = response['cursor'] 67 ··· 69 params[:cursor] = cursor 70 pages += 1 71 72 - 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 79 80 def check_access 81 - if !user.logged_in? 82 log_in 83 else 84 - begin 85 - get_request('com.atproto.server.getSession') 86 - rescue OpenURI::HTTPError 87 - perform_token_refresh 88 - end 89 end 90 end 91 92 def log_in 93 data = { 94 identifier: user.id, 95 password: user.pass 96 } 97 98 json = post_request('com.atproto.server.createSession', data, auth: false) 99 100 - config['did'] = json['did'] 101 - config['access_token'] = json['accessJwt'] 102 - config['refresh_token'] = json['refreshJwt'] 103 104 save_config 105 json 106 end 107 108 def perform_token_refresh 109 json = post_request('com.atproto.server.refreshSession', auth: user.refresh_token) 110 111 - config['access_token'] = json['accessJwt'] 112 - config['refresh_token'] = json['refreshJwt'] 113 114 save_config 115 json 116 end 117 118 private 119 120 def authentication_header(auth) 121 if auth.is_a?(String) 122 { 'Authorization' => "Bearer #{auth}" } 123 elsif auth 124 - { 'Authorization' => "Bearer #{user.access_token}" } 125 else 126 {} 127 end 128 end 129 end
··· 1 require_relative 'minisky' 2 + require_relative 'errors' 3 4 + require 'base64' 5 require 'json' 6 require 'net/http' 7 + require 'time' 8 require 'uri' 9 10 class Minisky 11 class User 12 def initialize(config) 13 @config = config 14 + end 15 + 16 + def has_credentials? 17 + !!(id && pass) 18 end 19 20 def logged_in? 21 !!(access_token && refresh_token) 22 end 23 24 + 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 32 33 + # 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 52 53 + 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 91 92 def user 93 + @user ||= config && User.new(config) 94 end 95 96 + # 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) 130 131 if params && !params.empty? 132 url.query = URI.encode_www_form(params) 133 end 134 135 + request = Net::HTTP::Get.new(url, headers) 136 + 137 + response = make_request(request) 138 + handle_response(response) 139 end 140 141 + # 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 + # }) 176 177 + 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) 190 191 + 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 198 199 + # 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 274 275 + 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 287 288 + 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 295 296 + # 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 336 end 337 end 338 339 + # 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 } 361 362 + 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) 368 369 + user.did = json['did'] 370 + user.access_token = json['accessJwt'] 371 + user.refresh_token = json['refreshJwt'] 372 373 save_config 374 json 375 end 376 377 + # 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) 394 395 + user.access_token = json['accessJwt'] 396 + user.refresh_token = json['refreshJwt'] 397 398 save_config 399 json 400 end 401 402 + # 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 473 474 + 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
··· 1 require_relative 'minisky' 2 3 class Minisky 4 - VERSION = "0.2.0" 5 end
··· 1 require_relative 'minisky' 2 3 class Minisky 4 + VERSION = "0.5.0" 5 end
+9 -7
minisky.gemspec
··· 1 # frozen_string_literal: true 2 3 - require_relative "lib/minisky/version" 4 5 Gem::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"] 10 11 - 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" 14 15 spec.license = "Zlib" 16 spec.required_ruby_version = ">= 2.6.0" 17 18 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 } 23 24 spec.files = Dir.chdir(__dir__) do ··· 26 end 27 28 spec.require_paths = ["lib"] 29 end
··· 1 # frozen_string_literal: true 2 3 + minisky_version = File.read(File.join(__dir__, 'lib', 'minisky', 'version.rb')).match(/VERSION = "(.*)"/)[1] 4 5 Gem::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"] 10 11 + 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" 14 15 spec.license = "Zlib" 16 spec.required_ruby_version = ">= 2.6.0" 17 18 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 } 23 24 spec.files = Dir.chdir(__dir__) do ··· 26 end 27 28 spec.require_paths = ["lib"] 29 + 30 + spec.add_dependency 'base64', '~> 0.1' 31 end
+83 -12
spec/custom_client_spec.rb
··· 1 require 'json' 2 3 class CustomJSONClient 4 CONFIG_FILE = 'test.json' ··· 7 8 attr_reader :config 9 10 - def initialize 11 - @config = JSON.parse(File.read(CONFIG_FILE)) 12 end 13 14 def host ··· 20 end 21 end 22 23 - describe "custom client" do 24 include FakeFS::SpecHelpers 25 26 - 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 34 35 subject { CustomJSONClient.new } 36 37 let(:reloaded_config) { JSON.parse(File.read('test.json')) } 38 39 - include_examples "Requests", 'at.x.com' 40 end
··· 1 require 'json' 2 + require_relative 'shared/ex_incomplete_auth' 3 + require_relative 'shared/ex_requests' 4 + require_relative 'shared/ex_unauthed' 5 6 class CustomJSONClient 7 CONFIG_FILE = 'test.json' ··· 10 11 attr_reader :config 12 13 + def initialize(config_file = CONFIG_FILE) 14 + @config = config_file && JSON.parse(File.read(config_file)) 15 end 16 17 def host ··· 23 end 24 end 25 26 + describe "in custom client" do 27 include FakeFS::SpecHelpers 28 29 + let(:data) {{ 30 + 'id' => 'john.foo', 31 + 'pass' => 'hunter2', 32 + 'access_token' => 'aatoken', 33 + 'refresh_token' => 'rrtoken' 34 + }} 35 36 subject { CustomJSONClient.new } 37 38 let(:reloaded_config) { JSON.parse(File.read('test.json')) } 39 40 + 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 111 end
+250 -41
spec/minisky_spec.rb
··· 1 require 'yaml' 2 3 describe Minisky do 4 include FakeFS::SpecHelpers 5 6 - let(:host) { 'bsky.test' } 7 8 - context 'with a default config file name' do 9 before do 10 - File.write('bluesky.yml', %( 11 - id: john.foo 12 - pass: hunter2 13 - access_token: aatoken 14 - refresh_token: rrtoken 15 - )) 16 end 17 18 - subject { Minisky.new(host) } 19 20 - let(:reloaded_config) { YAML.load(File.read('bluesky.yml')) } 21 22 - it 'should have a version number' do 23 - Minisky::VERSION.should_not be_nil 24 end 25 26 - include_examples "Requests", 'bsky.test' 27 end 28 29 - context 'with a custom config file name' do 30 before do 31 - File.write('myconfig.yml', %( 32 - id: john.foo 33 - pass: hunter2 34 - access_token: aatoken 35 - refresh_token: rrtoken 36 - )) 37 end 38 39 - subject { Minisky.new(host, 'myconfig.yml') } 40 41 - let(:reloaded_config) { YAML.load(File.read('myconfig.yml')) } 42 43 - 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' 47 end 48 49 - 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 58 59 - it "should save user's DID" do 60 - subject.log_in 61 62 - reloaded_config['did'].should == "did:plc:abracadabra" 63 end 64 65 - it "should update the tokens in the config file" do 66 - subject.log_in 67 68 - reloaded_config['access_token'].should == 'aaaa1234' 69 - reloaded_config['refresh_token'].should == 'rrrr1234' 70 end 71 end 72 end 73 end
··· 1 require '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' 13 14 describe Minisky do 15 include FakeFS::SpecHelpers 16 17 + 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 47 48 + 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 172 173 + 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 187 188 + 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 192 193 + 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 197 198 + 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 204 205 + 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 219 220 + context 'when access token is missing' do 221 + let(:config) { data.merge('access_token' => nil) } 222 223 + it 'should raise AuthError' do 224 + expect { subject.access_token_expired? }.to raise_error(Minisky::AuthError) 225 + end 226 + end 227 228 + 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 235 236 + 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 241 end 242 + end 243 244 + context 'when token expiration date is in less than 60 seconds' do 245 + let(:config) { data.merge('access_token' => make_token(Time.now + 50)) } 246 247 + it 'should return true' do 248 + subject.access_token_expired?.should == true 249 end 250 + end 251 252 + context 'when token expiration date is in more than 60 seconds' do 253 + let(:config) { data.merge('access_token' => make_token(Time.now + 180)) } 254 255 + it 'should return false' do 256 + subject.access_token_expired?.should == false 257 end 258 end 259 end 260 end 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
···
+180
spec/shared/ex_authorization.rb
···
··· 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
+107
spec/shared/ex_bad_response.rb
···
··· 1 + shared_examples "bad response handling" do |method, endpoint| 2 + context 'with a bad response' do 3 + let(:method) { method } 4 + let(:endpoint) { endpoint } 5 + 6 + def make_request(**kwargs) 7 + subject.send("#{method}_request", endpoint, **kwargs) 8 + end 9 + 10 + context 'if the response has a 4xx status' do 11 + let(:response) {{ 12 + body: JSON.generate(error: 'BadReq', message: 'This request was bad'), 13 + status: 403, 14 + headers: { 'Content-Type': 'application/json' } 15 + }} 16 + 17 + it 'should raise a ClientErrorResponse error' do 18 + expect { make_request }.to raise_error { |err| 19 + err.should be_a(Minisky::ClientErrorResponse) 20 + err.status.should == 403 21 + err.data.should be_a(Hash) 22 + err.error_type.should == 'BadReq' 23 + err.error_message.should == 'This request was bad' 24 + } 25 + end 26 + end 27 + 28 + context 'if the response has a 5xx status' do 29 + let(:response) {{ 30 + body: JSON.generate(error: 'Boom', message: 'Server exploded'), 31 + status: 500, 32 + headers: { 'Content-Type': 'application/json' } 33 + }} 34 + 35 + it 'should raise a ServerErrorResponse error' do 36 + expect { make_request }.to raise_error { |err| 37 + err.should be_a(Minisky::ServerErrorResponse) 38 + err.status.should == 500 39 + err.data.should be_a(Hash) 40 + err.error_type.should == 'Boom' 41 + err.error_message.should == 'Server exploded' 42 + } 43 + end 44 + end 45 + 46 + context 'if the response is a redirect' do 47 + let(:response) {{ status: 302, headers: { 'Location': 'https://google.com' }}} 48 + 49 + it 'should raise an UnexpectedRedirect error' do 50 + expect { make_request }.to raise_error { |err| 51 + err.should be_a(Minisky::UnexpectedRedirect) 52 + err.status.should == 302 53 + err.data.should be_a(Hash) 54 + err.location.should == 'https://google.com' 55 + } 56 + end 57 + end 58 + 59 + context 'if the response is an ExpiredToken error' do 60 + let(:response) {{ 61 + body: JSON.generate(error: 'ExpiredToken', message: 'Your token has expired'), 62 + status: 401, 63 + headers: { 'Content-Type': 'application/json' } 64 + }} 65 + 66 + it 'should raise an ExpiredTokenError error' do 67 + expect { make_request }.to raise_error { |err| 68 + err.should be_a(Minisky::ExpiredTokenError) 69 + err.status.should == 401 70 + err.data.should be_a(Hash) 71 + err.error_type.should == 'ExpiredToken' 72 + err.error_message.should == 'Your token has expired' 73 + } 74 + end 75 + end 76 + 77 + context 'if the bad response is not json' do 78 + let(:response) {{ 79 + body: '<html>wtf</html>', 80 + status: 503 81 + }} 82 + 83 + it 'should raise an error with the response body' do 84 + expect { make_request }.to raise_error { |err| 85 + err.should be_a(Minisky::BadResponse) 86 + err.status.should == 503 87 + err.data.should == '<html>wtf</html>' 88 + err.error_type.should be_nil 89 + err.error_message.should be_nil 90 + } 91 + end 92 + end 93 + 94 + context 'if the response is not json, but has a 2xx status' do 95 + let(:response) {{ body: 'ok', status: 201, headers: { 'Content-Type': 'text/plain' }}} 96 + 97 + it 'should not raise an error' do 98 + expect { make_request }.to_not raise_error 99 + end 100 + 101 + it 'should return the body as a string' do 102 + result = make_request 103 + result.should == 'ok' 104 + end 105 + end 106 + end 107 + end
+330
spec/shared/ex_fetch_all.rb
···
··· 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
+81
spec/shared/ex_get_request.rb
···
··· 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
···
··· 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
+225
spec/shared/ex_post_request.rb
···
··· 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
+159
spec/shared/ex_requests.rb
···
··· 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
+119
spec/shared/ex_unauthed.rb
···
··· 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
···
··· 1 + class IRB 2 + def self.CurrentContext 3 + {} 4 + end 5 + end
+5
spec/shared/fake_pry.rb
···
··· 1 + class Pry 2 + def self.cli 3 + {} 4 + end 5 + end
+39
spec/spec_config.rb
···
··· 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 3 4 - require 'fakefs/spec_helpers' 5 - require 'webmock/rspec' 6 7 - require 'requests_shared' 8 9 - RSpec.configure do |config| 10 - # Enable flags like --only-failures and --next-failure 11 - config.example_status_persistence_file_path = ".rspec_status" 12 13 - config.expect_with :rspec do |c| 14 - c.syntax = [:should, :expect] 15 end 16 end
··· 1 + require_relative 'spec_config' 2 + require 'base64' 3 + require 'json' 4 5 + INVALID_METHOD_NAMES = [ 6 + 'getUsers', 7 + '127.0.0.1', 8 + '/xrpc/com.atproto.repo.getRecords', 9 + 'app.bsky.feed.under_score' 10 + ] 11 12 + def stub_fetch_all(base_url, responses) 13 + cursor = nil 14 + urls = [] 15 16 + responses.each_with_index do |r, i| 17 + url = base_url 18 + body = r 19 20 + if cursor 21 + url += (url.include?('?') ? '&' : '?') + "cursor=#{cursor}" 22 + end 23 + 24 + if i < responses.length - 1 25 + cursor = rand.to_s 26 + body = body.merge("cursor" => cursor) 27 + end 28 + 29 + stub_request(:get, url).to_return_json(body: body) 30 + urls << url 31 end 32 + 33 + @stubbed_urls = urls 34 + end 35 + 36 + def verify_fetch_all 37 + @stubbed_urls.each do |url| 38 + WebMock.should have_requested(:get, url).once 39 + end 40 + end 41 + 42 + def make_token(exp_time) 43 + header = { alg: 'HS256', typ: 'JWT' } 44 + payload = { exp: exp_time.to_i } 45 + signature = 'signature' 46 + 47 + [header, payload, signature].map { |part| Base64.strict_encode64(JSON.generate(part)) }.join('.') 48 end
+21
spec/user_spec.rb
··· 13 subject.email.should == 'admin@bsky.app' 14 end 15 16 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 33 end 34 end 35 end
··· 13 subject.email.should == 'admin@bsky.app' 14 end 15 16 + 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 56 end