├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop ├── rspec.yml └── strict.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── gemfiles └── rubocop.gemfile ├── graphql-anycable.gemspec ├── lib ├── Rakefile ├── graphql-anycable.rb └── graphql │ ├── anycable.rb │ ├── anycable │ ├── cleaner.rb │ ├── config.rb │ ├── errors.rb │ ├── railtie.rb │ ├── stats.rb │ ├── tasks │ │ └── clean_expired_subscriptions.rake │ └── version.rb │ └── subscriptions │ └── anycable_subscriptions.rb └── spec ├── graphql ├── anycable_spec.rb ├── broadcast_spec.rb └── stats_spec.rb ├── integration_helper.rb ├── integrations ├── broadcastable_subscriptions_spec.rb ├── per_client_subscriptions_spec.rb └── rails_spec.rb ├── redis_helper.rb ├── spec_helper.rb └── support └── graphql_schema.rb /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve graphql-anycable 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Versions** 14 | ruby: 15 | rails (or other framework): 16 | graphql: 17 | graphql-anycable: 18 | anycable: 19 | 20 | **GraphQL schema** 21 | Provide relevant details. Are you using [subscription classes](https://graphql-ruby.org/subscriptions/subscription_classes.html) or not (graphql-ruby behavior differs there)? 22 | 23 | ```ruby 24 | class Product < GraphQL::Schema::Object 25 | field :id, ID, null: false, hash_key: :id 26 | field :title, String, null: true, hash_key: :title 27 | end 28 | 29 | class SubscriptionType < GraphQL::Schema::Object 30 | field :product_created, Product, null: false 31 | field :product_updated, Product, null: false 32 | 33 | def product_created; end 34 | def product_updated; end 35 | end 36 | 37 | class ApplicationSchema < GraphQL::Schema 38 | subscription SubscriptionType 39 | end 40 | ``` 41 | 42 | **GraphQL query** 43 | 44 | How do you subscribe to subscriptions? 45 | 46 | ```graphql 47 | subscription { 48 | productCreated { id title } 49 | productUpdated { id } 50 | } 51 | ``` 52 | 53 | **Steps to reproduce** 54 | Steps to reproduce the behavior 55 | 56 | **Expected behavior** 57 | A clear and concise description of what you expected to happen. 58 | 59 | **Actual behavior** 60 | What specifically went wrong? 61 | 62 | **Additional context** 63 | Add any other context about the problem here. Tracebacks, your thoughts. anything that may be useful. 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "gemfiles/*" 9 | - "Gemfile" 10 | - "**/*.rb" 11 | - "**/*.gemspec" 12 | - ".github/workflows/lint.yml" 13 | pull_request: 14 | paths: 15 | - "gemfiles/*" 16 | - "Gemfile" 17 | - "**/*.rb" 18 | - "**/*.gemspec" 19 | - ".github/workflows/lint.yml" 20 | 21 | jobs: 22 | rubocop: 23 | runs-on: ubuntu-latest 24 | env: 25 | BUNDLE_GEMFILE: "gemfiles/rubocop.gemfile" 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: 3.1 31 | bundler-cache: true 32 | - name: Lint Ruby code with RuboCop 33 | run: | 34 | bundle exec rubocop 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and release gem 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | id-token: write 14 | packages: write 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 # Fetch current tag as annotated. See https://github.com/actions/checkout/issues/290 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: "3.3" 22 | - name: "Extract data from tag: version, message, body" 23 | id: tag 24 | run: | 25 | git fetch --tags --force # Really fetch annotated tag. See https://github.com/actions/checkout/issues/290#issuecomment-680260080 26 | echo ::set-output name=version::${GITHUB_REF#refs/tags/v} 27 | echo ::set-output name=subject::$(git for-each-ref $GITHUB_REF --format='%(contents:subject)') 28 | BODY="$(git for-each-ref $GITHUB_REF --format='%(contents:body)')" 29 | # Extract changelog entries between this and previous version headers 30 | escaped_version=$(echo ${GITHUB_REF#refs/tags/v} | sed -e 's/[]\/$*.^[]/\\&/g') 31 | changelog=$(awk "BEGIN{inrelease=0} /## ${escaped_version}/{inrelease=1;next} /## [0-9]+\.[0-9]+\.[0-9]+/{inrelease=0;exit} {if (inrelease) print}" CHANGELOG.md) 32 | # Multiline body for release. See https://github.community/t/set-output-truncates-multiline-strings/16852/5 33 | BODY="${BODY}"$'\n'"${changelog}" 34 | BODY="${BODY//'%'/'%25'}" 35 | BODY="${BODY//$'\n'/'%0A'}" 36 | BODY="${BODY//$'\r'/'%0D'}" 37 | echo "::set-output name=body::$BODY" 38 | # Add pre-release option if tag name has any suffix after vMAJOR.MINOR.PATCH 39 | if [[ ${GITHUB_REF#refs/tags/} =~ ^v[0-9]+\.[0-9]+\.[0-9]+.+ ]]; then 40 | echo ::set-output name=prerelease::true 41 | fi 42 | - name: Build gem 43 | run: gem build 44 | - name: Calculate checksums 45 | run: sha256sum graphql-anycable-${{ steps.tag.outputs.version }}.gem > SHA256SUM 46 | - name: Check version 47 | run: ls -l graphql-anycable-${{ steps.tag.outputs.version }}.gem 48 | - name: Create Release 49 | id: create_release 50 | uses: actions/create-release@v1 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | with: 54 | tag_name: ${{ github.ref }} 55 | release_name: ${{ steps.tag.outputs.subject }} 56 | body: ${{ steps.tag.outputs.body }} 57 | draft: false 58 | prerelease: ${{ steps.tag.outputs.prerelease }} 59 | - name: Upload built gem as release asset 60 | uses: actions/upload-release-asset@v1 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | with: 64 | upload_url: ${{ steps.create_release.outputs.upload_url }} 65 | asset_path: graphql-anycable-${{ steps.tag.outputs.version }}.gem 66 | asset_name: graphql-anycable-${{ steps.tag.outputs.version }}.gem 67 | asset_content_type: application/x-tar 68 | - name: Upload checksums as release asset 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.create_release.outputs.upload_url }} 74 | asset_path: SHA256SUM 75 | asset_name: SHA256SUM 76 | asset_content_type: text/plain 77 | - name: Publish to GitHub packages 78 | env: 79 | GEM_HOST_API_KEY: Bearer ${{ secrets.GITHUB_TOKEN }} 80 | run: | 81 | gem push graphql-anycable-${{ steps.tag.outputs.version }}.gem --host https://rubygems.pkg.github.com/${{ github.repository_owner }} 82 | - name: Configure RubyGems Credentials 83 | uses: rubygems/configure-rubygems-credentials@main 84 | - name: Publish to RubyGems 85 | run: | 86 | gem push graphql-anycable-${{ steps.tag.outputs.version }}.gem 87 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - '**' 8 | tags-ignore: 9 | - 'v*' 10 | 11 | jobs: 12 | test: 13 | name: "GraphQL-Ruby ${{ matrix.graphql }} AnyCable ${{ matrix.anycable }} on Ruby ${{ matrix.ruby }} Redis ${{ matrix.redis_version }}" 14 | # Skip running tests for local pull requests (use push event instead), run only for foreign ones 15 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.owner.login != github.event.pull_request.base.repo.owner.login 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | include: 21 | - ruby: "3.3" 22 | graphql: '~> 2.3' 23 | anycable: '~> 1.5' 24 | redis_version: latest 25 | - ruby: "3.2" 26 | graphql: '~> 2.2.0' 27 | anycable: '~> 1.5.0' 28 | redis_version: '7.2' 29 | - ruby: "3.1" 30 | graphql: '~> 2.1.0' 31 | anycable: '~> 1.4.0' 32 | redis_version: '6.2' 33 | - ruby: "3.0" 34 | graphql: '~> 2.0.0' 35 | anycable: '~> 1.4.0' 36 | redis_version: '6.2' 37 | env: 38 | CI: true 39 | GRAPHQL_RUBY_VERSION: ${{ matrix.graphql }} 40 | ANYCABLE_VERSION: ${{ matrix.anycable }} 41 | ANYCABLE_RAILS_VERSION: ${{ matrix.anycable }} 42 | GRAPHQL_ANYCABLE_USE_CLIENT_PROVIDED_UNIQ_ID: ${{ matrix.client_id }} 43 | REDIS_URL: redis://localhost:6379 44 | services: 45 | redis: 46 | image: redis:${{ matrix.redis_version }} 47 | options: >- 48 | --health-cmd "redis-cli ping" 49 | --health-interval 10s 50 | --health-timeout 5s 51 | --health-retries 5 52 | ports: 53 | - 6379:6379 54 | steps: 55 | - uses: actions/checkout@v4 56 | - uses: ruby/setup-ruby@v1 57 | with: 58 | ruby-version: ${{ matrix.ruby }} 59 | bundler-cache: true 60 | - name: Run RSpec 61 | run: bundle exec rspec 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock 10 | .rspec_status 11 | /gemfiles/*.lock 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_mode: 2 | merge: 3 | - Exclude 4 | 5 | require: 6 | - standard 7 | - standard-custom 8 | - standard-performance 9 | - rubocop-performance 10 | 11 | inherit_gem: 12 | standard: config/base.yml 13 | standard-performance: config/base.yml 14 | standard-custom: config/base.yml 15 | 16 | inherit_from: 17 | - .rubocop/rspec.yml 18 | - .rubocop/strict.yml 19 | 20 | AllCops: 21 | NewCops: disable 22 | SuggestExtensions: false 23 | TargetRubyVersion: 3.2 24 | 25 | Style/ArgumentsForwarding: 26 | Enabled: false 27 | 28 | Style/GlobalVars: 29 | Exclude: 30 | - "spec/**/*.rb" 31 | -------------------------------------------------------------------------------- /.rubocop/rspec.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | 4 | # Disable all cops by default, 5 | # only enable those defined explcitly in this configuration file 6 | RSpec: 7 | Enabled: false 8 | 9 | RSpec/Focus: 10 | Enabled: true 11 | 12 | RSpec/EmptyExampleGroup: 13 | Enabled: true 14 | 15 | RSpec/EmptyLineAfterExampleGroup: 16 | Enabled: true 17 | 18 | RSpec/EmptyLineAfterFinalLet: 19 | Enabled: true 20 | 21 | RSpec/EmptyLineAfterHook: 22 | Enabled: true 23 | 24 | RSpec/EmptyLineAfterSubject: 25 | Enabled: true 26 | 27 | RSpec/HookArgument: 28 | Enabled: true 29 | 30 | RSpec/HooksBeforeExamples: 31 | Enabled: true 32 | 33 | RSpec/ImplicitExpect: 34 | Enabled: true 35 | 36 | RSpec/IteratedExpectation: 37 | Enabled: true 38 | 39 | RSpec/LetBeforeExamples: 40 | Enabled: true 41 | 42 | RSpec/MissingExampleGroupArgument: 43 | Enabled: true 44 | 45 | RSpec/ReceiveCounts: 46 | Enabled: true 47 | -------------------------------------------------------------------------------- /.rubocop/strict.yml: -------------------------------------------------------------------------------- 1 | Lint/Debugger: # don't leave binding.pry 2 | Enabled: true 3 | Exclude: [] 4 | 5 | RSpec/Focus: # run ALL tests on CI 6 | Enabled: true 7 | Exclude: [] 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | ## 1.3.1 - 2025-03-29 11 | 12 | ### Fixed 13 | 14 | - Remove `clean:events` from `clean_expired_subscriptions.rake`. [@Geesu] ([#46](https://github.com/anycable/graphql-anycable/pull/46)) 15 | 16 | ## 1.3.0 - 2024-08-13 17 | 18 | ### Changed 19 | 20 | - Redis subscriptions store configuration has been decoupled from AnyCable, so you can use any broadcasting adapter and configure Redis as you like. [@palkan] ([#44](https://github.com/anycable/graphql-anycable/pull/44)) 21 | 22 | ## 1.2.0 - 2024-05-07 23 | 24 | ### Added 25 | 26 | - Stats collection about subscriptions, channels, etc via `GraphQL::AnyCable.stats`. [@prog-supdex] ([#37](https://github.com/anycable/graphql-anycable/pull/37)) 27 | 28 | See [Stats](https://github.com/anycable/graphql-anycable?tab=readme-ov-file#stats) section in README for details. 29 | 30 | - Configuration option `redis_prefix` for namespacing Redis keys. [@prog-supdex] ([#36](https://github.com/anycable/graphql-anycable/pull/36)) 31 | 32 | ### Changed 33 | 34 | - Depend on `anycable-core` gem instead of `anycable`. 35 | 36 | This allows to avoid installing `grpc` gem when using alternate AnyCable broadcasting adapters (like HTTP). 37 | 38 | See https://github.com/anycable/graphql-anycable/issues/43 for details. 39 | 40 | ### Removed 41 | 42 | - Handling of client-provided channel identifiers. **BREAKING CHANGE** 43 | 44 | Please make sure that you have changed your channel `disconnected` method to pass channel instance to GraphQL-AnyCable's `delete_channel_subscriptions` method. 45 | See [release notes for version 1.1.0](https://github.com/anycable/graphql-anycable/releases/tag/v1.1.0) for details. 46 | 47 | - Handling of pre-1.0 subscriptions data. 48 | 49 | If you're still using version 0.5 or below, please upgrade to 1.0 or 1.1 first with `handle_legacy_subscriptions` setting enabled. 50 | See [release notes for version 1.0.0](https://github.com/anycable/graphql-anycable/releases/tag/v1.0.0) for details. 51 | 52 | ## 1.1.6 - 2023-08-03 53 | 54 | ### Fixed 55 | 56 | - Fix empty operation name handling when using redis-client or redis.rb v5. [@ilyasgaraev] ([#34](https://github.com/anycable/graphql-anycable/pull/34)) 57 | - Fix deprecation warnings for redis.rb v4.8+ and support for redis.rb v5. [@smasry] ([#29](https://github.com/anycable/graphql-anycable/pull/29)) 58 | 59 | ## 1.1.5 - 2022-10-26 60 | 61 | - Fix that deprecation warning about `config.use_client_provided_uniq_id` again, so it can be issued outside of Rails. [@gsamokovarov] ([#27](https://github.com/anycable/graphql-anycable/pull/27)) 62 | 63 | ## 1.1.4 - 2022-07-28 64 | 65 | - Fix deprecation warning about using client-side channel ids shown even if deprecated functionality was disabled in application code (not via config file or environment variable). [@gsamokovarov] ([#26](https://github.com/anycable/graphql-anycable/pull/26)) 66 | 67 | However, now deprecation warning won't be shown if graphql-anycable is used not in Ruby on Rails application. 68 | 69 | ## 1.1.3 - 2022-03-11 70 | 71 | ### Changed 72 | 73 | - Allow using graphql-anycable with GraphQL-Ruby 2.x (it seem to be already compatible). [@Envek] 74 | 75 | ## 1.1.2 - 2022-03-11 76 | 77 | ### Fixed 78 | 79 | - AnyCable 1.3.0 compatibility. [@palkan] [#21](https://github.com/anycable/graphql-anycable/pull/21) 80 | - Redis.rb 5.0 compatibility. [@palkan] [#21](https://github.com/anycable/graphql-anycable/pull/21) 81 | 82 | ## 1.1.1 - 2021-12-06 83 | 84 | ### Fixed 85 | 86 | - Handling of buggy istate values on unsubscribe (when `use_client_provided_uniq_id: false`). [@palkan] [#20](https://github.com/anycable/graphql-anycable/pull/20) 87 | - A bug when `#unsubscribe` happens before `#execute`. [@palkan] [#20](https://github.com/anycable/graphql-anycable/pull/20) 88 | 89 | ## 1.1.0 - 2021-11-17 90 | 91 | ### Added 92 | 93 | - Support for generating unique channel IDs server-side and storing them in the channel states. 94 | 95 | Currently, we rely on `params["channelId"]` to track subscriptions. This value is random when using `graphql-ruby` JS client, but is not guaranteed to be random in general. 96 | 97 | Now you can opt-in to use server-side IDs by specifying `use_client_provided_uniq_id: false` in YAML config or thru the `GRAPHQL_ANYCABLE_USE_CLIENT_PROVIDED_UNIQ_ID=false` env var. 98 | 99 | NOTE: Relying on client-side IDs is deprecated and will be removed in the future versions. 100 | 101 | You must also update your cleanup code in the `Channel#unsubscribed`: 102 | 103 | ```diff 104 | - channel_id = params.fetch("channelId") 105 | - MySchema.subscriptions.delete_channel_subscriptions(channel_id) 106 | + MySchema.subscriptions.delete_channel_subscriptions(self) 107 | ``` 108 | 109 | ## 1.0.1 - 2021-04-16 110 | 111 | ### Added 112 | 113 | - Guard check for presence of ActionCable channel instance in the GraphQL execution context. 114 | 115 | This allows to detect wrong configuration (user forgot to pass channel into context) or wrong usage (subscription query was sent via HTTP request, not via WebSocket channel) of the library and provides clear error message to gem users. 116 | 117 | ## 1.0.0 - 2021-04-01 118 | 119 | ### Added 120 | 121 | - Support for [Subscriptions Broadcast](https://graphql-ruby.org/subscriptions/broadcast.html) feature in GraphQL-Ruby 1.11+. [@Envek] ([#15](https://github.com/anycable/graphql-anycable/pull/15)) 122 | 123 | ### Changed 124 | 125 | - Subscription data storage format changed to support broadcasting feature (see [#15](https://github.com/anycable/graphql-anycable/pull/15)) 126 | 127 | ### Removed 128 | 129 | - Drop support for GraphQL-Ruby before 1.11 130 | 131 | - Drop support for AnyCable before 1.0 132 | 133 | - Drop `:action_cable_stream` option from context: it is not used in reality. 134 | 135 | See [rmosolgo/graphql-ruby#3076](https://github.com/rmosolgo/graphql-ruby/pull/3076) for details 136 | 137 | ### Upgrading notes 138 | 139 | 1. Change method of plugging in of this gem from `use GraphQL::Subscriptions::AnyCableSubscriptions` to `use GraphQL::AnyCable`: 140 | 141 | ```ruby 142 | use GraphQL::AnyCable 143 | ``` 144 | 145 | If you need broadcasting, add `broadcast: true` option and ensure that [Interpreter mode](https://graphql-ruby.org/queries/interpreter.html) is enabled. 146 | 147 | ```ruby 148 | use GraphQL::Execution::Interpreter 149 | use GraphQL::Analysis::AST 150 | use GraphQL::AnyCable, broadcast: true, default_broadcastable: true 151 | ``` 152 | 153 | 2. Enable `handle_legacy_subscriptions` setting for seamless upgrade from previous versions: 154 | 155 | ```sh 156 | GRAPHQL_ANYCABLE_HANDLE_LEGACY_SUBSCRIPTIONS=true 157 | ``` 158 | 159 | Disable or remove this setting when you sure that all clients has re-subscribed (e.g. after `subscription_expiration_seconds` has passed after upgrade) as it imposes small performance penalty. 160 | 161 | ## 0.5.0 - 2020-08-26 162 | 163 | ### Changed 164 | 165 | - Allow to plug in this gem by calling `use GraphQL::AnyCable` instead of `use GraphQL::Subscriptions::AnyCableSubscriptions`. [@Envek] 166 | - Rename `GraphQL::Anycable` constant to `GraphQL::AnyCable` for consistency with AnyCable itself. [@Envek] 167 | 168 | ## 0.4.2 - 2020-08-25 169 | 170 | Technical release to test publishing via GitHub Actions. 171 | 172 | ## 0.4.1 - 2020-08-21 173 | 174 | ### Fixed 175 | 176 | - Deprecation warning for `Redis#exist` usage on Redis Ruby client 4.2+. Switch to `exists?` method and require Redis 4.2+ (see [#14](https://github.com/anycable/graphql-anycable/issues/14)). [@Envek] 177 | 178 | ## 0.4.0 - 2020-03-19 179 | 180 | ### Added 181 | 182 | - Ability to configure the gem via `configure` block, in addition to enironment variables and yaml files. [@gsamokovarov] ([#11](https://github.com/Envek/graphql-anycable/pull/11)) 183 | - Ability to run Redis cleaning operations outside of Rake. [@gsamokovarov] ([#11](https://github.com/Envek/graphql-anycable/pull/11)) 184 | - AnyCable 1.0 compatibility. [@bibendi], [@Envek] ([#10](https://github.com/Envek/graphql-anycable/pull/10)) 185 | 186 | ## 0.3.3 - 2020-03-03 187 | 188 | ### Fixed 189 | 190 | - Redis command error on subscription query with multiple fields. [@Envek] ([#9](https://github.com/Envek/graphql-anycable/issues/9)) 191 | 192 | ## 0.3.2 - 2020-03-02 193 | 194 | ### Added 195 | 196 | - Ability to skip some cleanup on restricted Redis instances (like Heroku). [@Envek] ([#8](https://github.com/Envek/graphql-anycable/issues/8)) 197 | 198 | ## 0.3.1 - 2019-06-13 199 | 200 | ### Fixed 201 | 202 | - Empty operation name handling. [@FX-HAO] ([#3](https://github.com/Envek/graphql-anycable/pull/3)) 203 | 204 | ## 0.3.0 - 2018-11-16 205 | 206 | ### Added 207 | 208 | - AnyCable 0.6 compatibility. [@Envek] 209 | 210 | ## 0.2.0 - 2018-09-17 211 | 212 | ### Added 213 | 214 | - Subscription expiration and rake task for stale subscriptions cleanup. [@Envek] 215 | 216 | ### 0.1.0 - 2018-08-26 217 | 218 | Initial version: store subscriptions on redis, re-execute queries in sync. [@Envek] 219 | 220 | [@prog-supdex]: https://github.com/prog-supdex "Igor Platonov" 221 | [@ilyasgaraev]: https://github.com/ilyasgaraev "Ilyas Garaev" 222 | [@smasry]: https://github.com/smasry "Samer Masry" 223 | [@gsamokovarov]: https://github.com/gsamokovarov "Genadi Samokovarov" 224 | [@bibendi]: https://github.com/bibendi "Misha Merkushin" 225 | [@FX-HAO]: https://github.com/FX-HAO "Fuxin Hao" 226 | [@Envek]: https://github.com/Envek "Andrey Novikov" 227 | [@palkan]: https://github.com/palkan "Vladimir Dementyev" 228 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in graphql-anycable.gemspec 8 | gemspec 9 | 10 | gem "graphql", ENV.fetch("GRAPHQL_RUBY_VERSION", "~> 2.3") 11 | gem "anycable", ENV.fetch("ANYCABLE_VERSION", "~> 1.5") 12 | gem "anycable-rails", ENV.fetch("ANYCABLE_RAILS_VERSION", "~> 1.5") 13 | gem "rack", "< 3.0" if /1\.4/.match?(ENV.fetch("ANYCABLE_VERSION", "~> 1.5")) 14 | 15 | gem "ostruct" 16 | 17 | group :development, :test do 18 | gem "debug", platforms: [:mri] unless ENV["CI"] 19 | end 20 | 21 | group :development do 22 | eval_gemfile "gemfiles/rubocop.gemfile" 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Andrey Novikov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL subscriptions for AnyCable 2 | 3 | A (mostly) drop-in replacement for default ActionCable subscriptions adapter shipped with [graphql gem] but works with [AnyCable]! 4 | 5 | [![Gem Version](https://badge.fury.io/rb/graphql-anycable.svg)](https://badge.fury.io/rb/graphql-anycable) 6 | [![Tests](https://github.com/anycable/graphql-anycable/actions/workflows/test.yml/badge.svg)](https://github.com/anycable/graphql-anycable/actions/workflows/test.yml) 7 | 8 | 9 | Sponsored by Evil Martians 10 | 11 | 12 | ## Why? 13 | 14 | AnyCable is fast because it does not execute any Ruby code. But default subscription implementation shipped with [graphql gem] requires to do exactly that: re-evaluate GraphQL queries in Action Cable process. AnyCable doesn't support this (it's possible but hard to implement). 15 | 16 | See https://github.com/anycable/anycable-rails/issues/40 for more details and discussion. 17 | 18 | ## Differences 19 | 20 | - Subscription information is stored in a Redis database. By default, we use AnyCable Redis configuration. Expiration or data cleanup should be configured separately (see below). 21 | - GraphQL queries for all subscriptions are re-executed in the process that triggers event (it may be web server, async jobs, rake tasks or whatever) 22 | 23 | ## Compatibility 24 | 25 | - Works with Action Cable (e.g., in development/test) 26 | - Works without Rails (e.g., via [LiteCable][]) 27 | 28 | ## Installation 29 | 30 | Add this line to your application's Gemfile: 31 | 32 | ```ruby 33 | gem "graphql-anycable", "~> 1.0" 34 | ``` 35 | 36 | And then execute: 37 | 38 | ```sh 39 | bundle install 40 | ``` 41 | 42 | ## Usage 43 | 44 | 1. Plug it into the schema (replace the Action Cable adapter if you have one): 45 | 46 | ```ruby 47 | class MySchema < GraphQL::Schema 48 | use GraphQL::AnyCable, broadcast: true 49 | 50 | subscription SubscriptionType 51 | end 52 | ``` 53 | 54 | 2. Execute a query within an Action Cable/LiteCable channel. 55 | 56 | ```ruby 57 | class GraphqlChannel < ApplicationCable::Channel 58 | def execute(data) 59 | result = 60 | MySchema.execute( 61 | query: data["query"], 62 | context: context, 63 | variables: Hash(data["variables"]), 64 | operation_name: data["operationName"], 65 | ) 66 | 67 | transmit( 68 | result: result.subscription? ? { data: nil } : result.to_h, 69 | more: result.subscription?, 70 | ) 71 | end 72 | 73 | def unsubscribed 74 | MySchema.subscriptions.delete_channel_subscriptions(self) 75 | end 76 | 77 | private 78 | 79 | def context 80 | { 81 | account_id: account&.id, 82 | channel: self, 83 | } 84 | end 85 | end 86 | ``` 87 | 88 | Make sure that you're passing channel instance as `channel` key to the context. 89 | 90 | 3. Trigger events as usual: 91 | 92 | ```ruby 93 | MySchema.subscriptions.trigger(:product_updated, {}, Product.first!, scope: account.id) 94 | ``` 95 | 96 | 4. (Optional) When using other AnyCable broadcasting adapters than Redis, you MUST configure Redis for graphql-anycable yourself: 97 | 98 | ```ruby 99 | GraphQL::AnyCable.redis = Redis.new(url: ENV["REDIS_URL"]) 100 | 101 | # you can also use a Proc (e.g., if you want to use a connection pool) 102 | redis_pool = ConnectionPool.new(size: 10) { Redis.new(url: ENV["REDIS_URL"]) } 103 | 104 | GraphQL::AnyCable.redis = ->(&block) { redis_pool.with { |conn| block.call(conn) } } 105 | ``` 106 | 107 | ## Broadcasting 108 | 109 | By default, graphql-anycable evaluates queries and transmits results for every subscription client individually. Of course, it is a waste of resources if you have hundreds or thousands clients subscribed to the same data (and has huge negative impact on performance). 110 | 111 | Thankfully, GraphQL-Ruby has added [Subscriptions Broadcast](https://graphql-ruby.org/subscriptions/broadcast.html) feature that allows to group exact same subscriptions, execute them and transmit results only once. 112 | 113 | To enable this feature, pass the `broadcast` option set to `true` to graphql-anycable. 114 | 115 | By default all fields are marked as _not safe for broadcasting_. If a subscription has at least one non-broadcastable field in its query, GraphQL-Ruby will execute every subscription for every client independently. If you sure that all your fields are safe to be broadcasted, you can pass `default_broadcastable` option set to `true` (but be aware that it can have security impllications!) 116 | 117 | ```ruby 118 | class MySchema < GraphQL::Schema 119 | use GraphQL::AnyCable, broadcast: true, default_broadcastable: true 120 | 121 | subscription SubscriptionType 122 | end 123 | ``` 124 | 125 | See GraphQL-Ruby [broadcasting docs](https://graphql-ruby.org/subscriptions/broadcast.html) for more details. 126 | 127 | ## Operations 128 | 129 | To avoid filling Redis storage with stale subscription data: 130 | 131 | 1. Set `subscription_expiration_seconds` setting to number of seconds (e.g. `604800` for 1 week). See [configuration](#configuration) section below for details. 132 | 133 | 2. Execute `rake graphql:anycable:clean` once in a while to clean up stale subscription data. 134 | 135 | Heroku users should set up `use_redis_object_on_cleanup` setting to `false` due to [limitations in Heroku Redis](https://devcenter.heroku.com/articles/heroku-redis#connection-permissions). 136 | 137 | ## Configuration 138 | 139 | GraphQL-AnyCable uses [anyway_config] to configure itself. There are several possibilities to configure this gem: 140 | 141 | 1. Environment variables: 142 | 143 | ```.env 144 | GRAPHQL_ANYCABLE_SUBSCRIPTION_EXPIRATION_SECONDS=604800 145 | GRAPHQL_ANYCABLE_USE_REDIS_OBJECT_ON_CLEANUP=true 146 | GRAPHQL_ANYCABLE_REDIS_PREFIX=graphql 147 | ``` 148 | 149 | 2. YAML configuration files (note that this is `config/graphql_anycable.yml`, *not* `config/anycable.yml`): 150 | 151 | ```yaml 152 | # config/graphql_anycable.yml 153 | production: 154 | subscription_expiration_seconds: 300 # 5 minutes 155 | use_redis_object_on_cleanup: false # For restricted redis installations 156 | redis_prefix: graphql # You can configure redis_prefix for anycable-graphql subscription prefixes. Default value "graphql" 157 | ``` 158 | 159 | 3. Configuration from your application code: 160 | 161 | ```ruby 162 | GraphQL::AnyCable.configure do |config| 163 | config.subscription_expiration_seconds = 3600 # 1 hour 164 | config.redis_prefix = "graphql" # on our side, we add `-` ourselves after the redis_prefix 165 | end 166 | ``` 167 | 168 | And any other way provided by [anyway_config]. Check its documentation! 169 | 170 | ## Emergency actions 171 | 172 | In situations when you don't set `subscription_expiration_seconds`, have a lot of inactive subscriptions, and `GraphQL::AnyCable::Cleaner` does`t help in that, 173 | you can do the following actions for clearing subscriptions 174 | 175 | 1. Set `config.subscription_expiration_seconds`. After that, the new subscriptions will have `TTL` 176 | 177 | 2. Run the script 178 | 179 | ```ruby 180 | config = GraphQL::AnyCable.config 181 | 182 | GraphQL::AnyCable.with_redis do |redis| 183 | # do it for subscriptions 184 | redis.scan_each("graphql-subscription:*") do |key| 185 | redis.expire(key, config.subscription_expiration_seconds) if redis.ttl(key) < 0 186 | # or you can just remove it immediately 187 | # redis.del(key) if redis.ttl(key) < 0 188 | end 189 | 190 | # do it for channels 191 | redis.scan_each("graphql-channel:*") do |key| 192 | redis.expire(key, config.subscription_expiration_seconds) if redis.ttl(key) < 0 193 | # or you can just remove it immediately 194 | # redis.del(key) if redis.ttl(key) < 0 195 | end 196 | end 197 | ``` 198 | 199 | Or you can change the `redis_prefix` in the `configuration` and then remove all records with the old_prefix. For instance: 200 | 201 | 1. Change the `redis_prefix`. The default `redis_prefix` is `graphql`. 202 | 203 | 2. Run the ruby script, which remove all records with `old prefix`: 204 | 205 | ```ruby 206 | GraphQL::AnyCable.with_redis do |redis| 207 | redis.scan_each("graphql-*") do |key| 208 | redis.del(key) 209 | end 210 | end 211 | ``` 212 | 213 | ## Data model 214 | 215 | As in AnyCable there is no place to store subscription data in-memory, it should be persisted somewhere to be retrieved on `GraphQLSchema.subscriptions.trigger` and sent to subscribed clients. `graphql-anycable` uses the same Redis database as AnyCable itself. 216 | 217 | 1. Grouped event subscriptions: `graphql-fingerprints:#{event.topic}` sorted set. Used to find all subscriptions on `GraphQLSchema.subscriptions.trigger`. 218 | 219 | ```sh 220 | ZREVRANGE graphql-fingerprints:1:myStats: 0 -1 221 | => 1:myStats:/MyStats/fBDZmJU1UGTorQWvOyUeaHVwUxJ3T9SEqnetj6SKGXc=/0/RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o= 222 | ``` 223 | 224 | 2. Event subscriptions: `graphql-subscriptions:#{event.fingerptint}` set containing identifiers for all subscriptions for given operation with certain context and arguments (serialized in _topic_). Fingerprints are already scoped by topic. 225 | 226 | ```sh 227 | SMEMBERS graphql-subscriptions:1:myStats:/MyStats/fBDZmJU1UGTorQWvOyUeaHVwUxJ3T9SEqnetj6SKGXc=/0/RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o= 228 | => 52ee8d65-275e-4d22-94af-313129116388 229 | ``` 230 | 231 | 3. Subscription data: `graphql-subscription:#{subscription_id}` hash contains everything required to evaluate subscription on trigger and create data for client. 232 | 233 | ```sh 234 | HGETALL graphql-subscription:52ee8d65-275e-4d22-94af-313129116388 235 | => { 236 | context: '{"user_id":1,"user":{"__gid__":"Z2lkOi8vZWJheS1tYWcyL1VzZXIvMQ"}}', 237 | variables: '{}', 238 | operation_name: 'MyStats' 239 | query_string: 'subscription MyStats { myStatsUpdated { completed total processed __typename } }', 240 | } 241 | ``` 242 | 243 | 4. Channel subscriptions: `graphql-channel:#{subscription_id}` set containing identifiers for subscriptions created in ActionCable channel to delete them on client disconnect. 244 | 245 | ```sh 246 | SMEMBERS graphql-channel:17420c6ed9e 247 | => 52ee8d65-275e-4d22-94af-313129116388 248 | ``` 249 | 250 | ## Stats 251 | 252 | You can grab Redis subscription statistics by calling: 253 | 254 | ```ruby 255 | GraphQL::AnyCable.stats 256 | ``` 257 | 258 | It will return a total of the amount of the key with the following prefixes: 259 | 260 | ```txt 261 | graphql-subscription 262 | graphql-fingerprints 263 | graphql-subscriptions 264 | graphql-channel 265 | ``` 266 | 267 | The response will look like this: 268 | 269 | ```json 270 | { 271 | "total": { 272 | "subscription":22646, 273 | "fingerprints":3200, 274 | "subscriptions":20101, 275 | "channel": 4900 276 | } 277 | } 278 | ``` 279 | 280 | You can also grab the number of subscribers grouped by subscriptions: 281 | 282 | ```ruby 283 | GraphQL::AnyCable.stats(include_subscriptions: true) 284 | ``` 285 | 286 | It will return the response that contains `subscriptions`: 287 | 288 | ```json 289 | { 290 | "total": { 291 | "subscription":22646, 292 | "fingerprints":3200, 293 | "subscriptions":20101, 294 | "channel": 4900 295 | }, 296 | "subscriptions": { 297 | "productCreated": 11323, 298 | "productUpdated": 11323 299 | } 300 | } 301 | ``` 302 | 303 | Also, you can set another `scan_count`, if needed. The default value is 1_000: 304 | 305 | ```ruby 306 | GraphQL::AnyCable.stats(scan_count: 100) 307 | ``` 308 | 309 | We can set statistics data to [Yabeda][] for tracking amount of subscriptions: 310 | 311 | ```ruby 312 | # config/initializers/metrics.rb 313 | Yabeda.configure do 314 | group :graphql_anycable_statistics do 315 | gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions" 316 | end 317 | end 318 | ``` 319 | 320 | ```ruby 321 | # in your app 322 | statistics = GraphQL::AnyCable.stats[:total] 323 | 324 | statistics.each do |key , value| 325 | Yabeda.graphql_anycable_statistics.subscriptions_count.set({name: key}, value) 326 | end 327 | ``` 328 | 329 | Or you can use `collect`: 330 | 331 | ```ruby 332 | # config/initializers/metrics.rb 333 | Yabeda.configure do 334 | group :graphql_anycable_statistics do 335 | gauge :subscriptions_count, comment: "Number of graphql-anycable subscriptions" 336 | end 337 | 338 | collect do 339 | statistics = GraphQL::AnyCable.stats[:total] 340 | 341 | statistics.each do |redis_prefix, value| 342 | graphql_anycable_statistics.subscriptions_count.set({name: redis_prefix}, value) 343 | end 344 | end 345 | end 346 | ``` 347 | 348 | ## Testing applications which use `graphql-anycable` 349 | 350 | You can pass custom redis-server URL to AnyCable using ENV variable. 351 | 352 | ```bash 353 | REDIS_URL=redis://localhost:6379/5 bundle exec rspec 354 | ``` 355 | 356 | ## Development 357 | 358 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 359 | 360 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 361 | 362 | ### Releasing new versions 363 | 364 | 1. Bump version number in `lib/graphql/anycable/version.rb` 365 | 366 | In case of pre-releases keep in mind [rubygems/rubygems#3086](https://github.com/rubygems/rubygems/issues/3086) and check version with command like `Gem::Version.new(AfterCommitEverywhere::VERSION).to_s` 367 | 368 | 2. Fill `CHANGELOG.md` with missing changes, add header with version and date. 369 | 370 | 3. Make a commit: 371 | 372 | ```sh 373 | git add lib/graphql/anycable/version.rb CHANGELOG.md 374 | version=$(ruby -r ./lib/graphql/anycable/version.rb -e "puts Gem::Version.new(GraphQL::AnyCable::VERSION)") 375 | git commit --message="${version}: " --edit 376 | ``` 377 | 378 | 4. Create annotated tag: 379 | 380 | ```sh 381 | git tag v${version} --annotate --message="${version}: " --edit --sign 382 | ``` 383 | 384 | 5. Fill version name into subject line and (optionally) some description (list of changes will be taken from `CHANGELOG.md` and appended automatically) 385 | 386 | 6. Push it: 387 | 388 | ```sh 389 | git push --follow-tags 390 | ``` 391 | 392 | 7. GitHub Actions will create a new release, build and push gem into [rubygems.org](https://rubygems.org)! You're done! 393 | 394 | ## Contributing 395 | 396 | Bug reports and pull requests are welcome on GitHub at https://github.com/Envek/graphql-anycable. 397 | 398 | ## License 399 | 400 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 401 | 402 | [graphql gem]: https://github.com/rmosolgo/graphql-ruby "Ruby implementation of GraphQL" 403 | [AnyCable]: https://github.com/anycable/anycable "Polyglot replacement for Ruby WebSocket servers with Action Cable protocol" 404 | [LiteCable]: https://github.com/palkan/litecable "Lightweight Action Cable implementation (Rails-free)" 405 | [anyway_config]: https://github.com/palkan/anyway_config "Ruby libraries and applications configuration on steroids!" 406 | [Yabeda]: https://github.com/yabeda-rb/yabeda "Extendable solution for easy setup of monitoring in your Ruby apps" 407 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | begin 9 | require "rubocop/rake_task" 10 | RuboCop::RakeTask.new 11 | rescue LoadError 12 | task(:rubocop) {} 13 | end 14 | 15 | task default: [:rubocop, :spec] 16 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "graphql/anycable" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "pry" 11 | Pry.start 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /gemfiles/rubocop.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" do 2 | gem "standard", "~> 1.28" 3 | 4 | gem "rubocop-rspec" 5 | end 6 | -------------------------------------------------------------------------------- /graphql-anycable.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "graphql/anycable/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "graphql-anycable" 9 | spec.version = GraphQL::AnyCable::VERSION 10 | spec.authors = ["Andrey Novikov"] 11 | spec.email = ["envek@envek.name"] 12 | 13 | spec.summary = <<~SUMMARY 14 | A drop-in replacement for GraphQL ActionCable subscriptions for AnyCable. 15 | SUMMARY 16 | 17 | spec.homepage = "https://github.com/Envek/graphql-anycable" 18 | spec.license = "MIT" 19 | 20 | # Specify which files should be added to the gem when it is released. 21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 22 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 23 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 24 | end 25 | spec.bindir = "exe" 26 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 27 | spec.require_paths = ["lib"] 28 | 29 | spec.required_ruby_version = ">= 3.0.0" 30 | 31 | spec.add_dependency "anycable-core", "~> 1.1" 32 | spec.add_dependency "anyway_config", ">= 1.3", "< 3" 33 | spec.add_dependency "graphql", ">= 1.11", "< 3" 34 | spec.add_dependency "redis", ">= 4.2.0" 35 | 36 | spec.add_development_dependency "anycable-rails" 37 | spec.add_development_dependency "bundler", "~> 2.0" 38 | spec.add_development_dependency "rack" 39 | spec.add_development_dependency "railties" 40 | spec.add_development_dependency "rake", ">= 12.3.3" 41 | spec.add_development_dependency "rspec", "~> 3.0" 42 | end 43 | -------------------------------------------------------------------------------- /lib/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql-anycable" 4 | 5 | Dir.glob("#{File.expand_path(__dir__)}/tasks/**/*.rake").each { |f| import f } 6 | -------------------------------------------------------------------------------- /lib/graphql-anycable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql" 4 | 5 | require_relative "graphql/anycable/version" 6 | require_relative "graphql/anycable/cleaner" 7 | require_relative "graphql/anycable/config" 8 | require_relative "graphql/anycable/railtie" if defined?(Rails) 9 | require_relative "graphql/anycable/stats" 10 | require_relative "graphql/subscriptions/anycable_subscriptions" 11 | 12 | module GraphQL 13 | module AnyCable 14 | class << self 15 | def use(schema, **opts) 16 | schema.use(GraphQL::Subscriptions::AnyCableSubscriptions, **opts) 17 | end 18 | 19 | def stats(**opts) 20 | Stats.new(**opts).collect 21 | end 22 | 23 | def redis 24 | warn "Usage of `GraphQL::AnyCable.redis` is deprecated. Instead of `GraphQL::AnyCable.redis.whatever` use `GraphQL::AnyCable.with_redis { |redis| redis.whatever }`" 25 | @redis ||= with_redis { |conn| conn } 26 | end 27 | 28 | def redis=(connector) 29 | @redis_connector = if connector.is_a?(::Proc) 30 | connector 31 | else 32 | ->(&block) { block.call connector } 33 | end 34 | end 35 | 36 | def with_redis(&block) 37 | @redis_connector || default_redis_connector 38 | @redis_connector.call(&block) 39 | end 40 | 41 | def config 42 | @config ||= Config.new 43 | end 44 | 45 | def configure 46 | yield(config) if block_given? 47 | end 48 | 49 | private 50 | 51 | def default_redis_connector 52 | adapter = ::AnyCable.broadcast_adapter 53 | unless adapter.is_a?(::AnyCable::BroadcastAdapters::Redis) 54 | raise "Unsupported AnyCable adapter: #{adapter.class}. " \ 55 | "Please, configure Redis connector manually:\n\n" \ 56 | " GraphQL::AnyCable.configure do |config|\n" \ 57 | " config.redis = Redis.new(url: 'redis://localhost:6379/0')\n" \ 58 | " end\n" 59 | end 60 | 61 | self.redis = ::AnyCable.broadcast_adapter.redis_conn 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/graphql/anycable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../graphql-anycable" 4 | -------------------------------------------------------------------------------- /lib/graphql/anycable/cleaner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphQL 4 | module AnyCable 5 | module Cleaner 6 | extend self 7 | 8 | def clean 9 | clean_channels 10 | clean_subscriptions 11 | clean_fingerprint_subscriptions 12 | clean_topic_fingerprints 13 | end 14 | 15 | def clean_channels 16 | return unless config.subscription_expiration_seconds 17 | return unless config.use_redis_object_on_cleanup 18 | 19 | AnyCable.with_redis do |redis| 20 | redis.scan_each(match: "#{redis_key(adapter::CHANNEL_PREFIX)}*") do |key| 21 | idle = redis.object("IDLETIME", key) 22 | next if idle&.<= config.subscription_expiration_seconds 23 | 24 | redis.del(key) 25 | end 26 | end 27 | end 28 | 29 | def clean_subscriptions 30 | return unless config.subscription_expiration_seconds 31 | return unless config.use_redis_object_on_cleanup 32 | 33 | AnyCable.with_redis do |redis| 34 | redis.scan_each(match: "#{redis_key(adapter::SUBSCRIPTION_PREFIX)}*") do |key| 35 | idle = redis.object("IDLETIME", key) 36 | next if idle&.<= config.subscription_expiration_seconds 37 | 38 | redis.del(key) 39 | end 40 | end 41 | end 42 | 43 | def clean_fingerprint_subscriptions 44 | AnyCable.with_redis do |redis| 45 | redis.scan_each(match: "#{redis_key(adapter::SUBSCRIPTIONS_PREFIX)}*") do |key| 46 | redis.smembers(key).each do |subscription_id| 47 | next if redis.exists?(redis_key(adapter::SUBSCRIPTION_PREFIX) + subscription_id) 48 | 49 | redis.srem(key, subscription_id) 50 | end 51 | end 52 | end 53 | end 54 | 55 | def clean_topic_fingerprints 56 | AnyCable.with_redis do |redis| 57 | redis.scan_each(match: "#{redis_key(adapter::FINGERPRINTS_PREFIX)}*") do |key| 58 | redis.zremrangebyscore(key, "-inf", "0") 59 | redis.zrange(key, 0, -1).each do |fingerprint| 60 | next if redis.exists?(redis_key(adapter::SUBSCRIPTIONS_PREFIX) + fingerprint) 61 | 62 | redis.zrem(key, fingerprint) 63 | end 64 | end 65 | end 66 | end 67 | 68 | private 69 | 70 | def adapter 71 | GraphQL::Subscriptions::AnyCableSubscriptions 72 | end 73 | 74 | def config 75 | GraphQL::AnyCable.config 76 | end 77 | 78 | def redis_key(prefix) 79 | "#{config.redis_prefix}-#{prefix}" 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/graphql/anycable/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "anyway" 4 | 5 | module GraphQL 6 | module AnyCable 7 | class Config < Anyway::Config 8 | config_name :graphql_anycable 9 | env_prefix :graphql_anycable 10 | 11 | attr_config subscription_expiration_seconds: nil 12 | attr_config use_redis_object_on_cleanup: true 13 | attr_config redis_prefix: "graphql" # Here, we set clear redis_prefix without any hyphen. The hyphen is added at the end of this value on our side. 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/graphql/anycable/errors.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module AnyCable 3 | # This error is thrown when ActionCable channel wasn't provided to subscription implementation. 4 | # Typical cases: 5 | # 1. application developer forgot to pass ActionCable channel into context 6 | # 2. subscription query was sent via usual HTTP request, not websockets as intended 7 | class ChannelConfigurationError < ::RuntimeError 8 | def initialize(msg = nil) 9 | super(msg || <<~DEFAULT_MESSAGE) 10 | ActionCable channel wasn't provided in the context for GraphQL query execution! 11 | 12 | This can occur in the following cases: 13 | 1. ActionCable channel instance wasn't passed into GraphQL execution context in the channel's execute method. 14 | See https://github.com/anycable/graphql-anycable#usage 15 | 2. Subscription query was sent via usual HTTP request, not via WebSocket as intended 16 | DEFAULT_MESSAGE 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/graphql/anycable/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails" 4 | 5 | module GraphQL 6 | module AnyCable 7 | class Railtie < ::Rails::Railtie 8 | rake_tasks do 9 | path = File.expand_path(__dir__) 10 | Dir.glob("#{path}/tasks/**/*.rake").each { |f| load f } 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/graphql/anycable/stats.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphQL 4 | module AnyCable 5 | # Calculates amount of Graphql Redis keys 6 | # (graphql-subscription, graphql-fingerprints, graphql-subscriptions, graphql-channel) 7 | # Also, calculate the number of subscribers grouped by subscriptions 8 | class Stats 9 | SCAN_COUNT_RECORDS_AMOUNT = 1_000 10 | 11 | attr_reader :scan_count, :include_subscriptions 12 | 13 | def initialize(scan_count: SCAN_COUNT_RECORDS_AMOUNT, include_subscriptions: false) 14 | @scan_count = scan_count 15 | @include_subscriptions = include_subscriptions 16 | end 17 | 18 | def collect 19 | total_subscriptions_result = {total: {}} 20 | 21 | AnyCable.with_redis do |redis| 22 | list_prefixes_keys.each do |name, prefix| 23 | total_subscriptions_result[:total][name] = count_by_scan(redis, match: "#{prefix}*") 24 | end 25 | 26 | if include_subscriptions 27 | total_subscriptions_result[:subscriptions] = group_subscription_stats(redis) 28 | end 29 | end 30 | 31 | total_subscriptions_result 32 | end 33 | 34 | private 35 | 36 | # Counting all keys, that match the pattern with iterating by count 37 | def count_by_scan(redis, match:) 38 | sb_amount = 0 39 | cursor = "0" 40 | 41 | loop do 42 | cursor, result = redis.scan(cursor, match: match, count: scan_count) 43 | sb_amount += result.count 44 | 45 | break if cursor == "0" 46 | end 47 | 48 | sb_amount 49 | end 50 | 51 | # Calculate subscribes, grouped by subscriptions 52 | def group_subscription_stats(redis) 53 | subscription_groups = {} 54 | 55 | redis.scan_each(match: "#{list_prefixes_keys[:fingerprints]}*", count: scan_count) do |fingerprint_key| 56 | subscription_name = fingerprint_key.gsub(/#{list_prefixes_keys[:fingerprints]}|:/, "") 57 | subscription_groups[subscription_name] = 0 58 | 59 | redis.zscan_each(fingerprint_key) do |data| 60 | redis.sscan_each("#{list_prefixes_keys[:subscriptions]}#{data[0]}") do |subscription_key| 61 | next unless redis.exists?("#{list_prefixes_keys[:subscription]}#{subscription_key}") 62 | 63 | subscription_groups[subscription_name] += 1 64 | end 65 | end 66 | end 67 | 68 | subscription_groups 69 | end 70 | 71 | def list_prefixes_keys 72 | { 73 | subscription: redis_key(adapter::SUBSCRIPTION_PREFIX), 74 | fingerprints: redis_key(adapter::FINGERPRINTS_PREFIX), 75 | subscriptions: redis_key(adapter::SUBSCRIPTIONS_PREFIX), 76 | channel: redis_key(adapter::CHANNEL_PREFIX) 77 | } 78 | end 79 | 80 | def adapter 81 | GraphQL::Subscriptions::AnyCableSubscriptions 82 | end 83 | 84 | def config 85 | GraphQL::AnyCable.config 86 | end 87 | 88 | def redis_key(prefix) 89 | "#{config.redis_prefix}-#{prefix}" 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/graphql/anycable/tasks/clean_expired_subscriptions.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "graphql-anycable" 4 | 5 | namespace :graphql do 6 | namespace :anycable do 7 | desc "Clean up stale graphql channels, subscriptions, and events from redis" 8 | task clean: %i[clean:channels clean:subscriptions clean:fingerprint_subscriptions clean:topic_fingerprints] 9 | 10 | namespace :clean do 11 | # Clean up old channels 12 | task :channels do 13 | GraphQL::AnyCable::Cleaner.clean_channels 14 | end 15 | 16 | # Clean up old subscriptions (they should have expired by themselves) 17 | task :subscriptions do 18 | GraphQL::AnyCable::Cleaner.clean_subscriptions 19 | end 20 | 21 | # Clean up subscription_ids from event fingerprints for expired subscriptions 22 | task :fingerprint_subscriptions do 23 | GraphQL::AnyCable::Cleaner.clean_fingerprint_subscriptions 24 | end 25 | 26 | # Clean up fingerprints from event topics. for expired subscriptions 27 | task :topic_fingerprints do 28 | GraphQL::AnyCable::Cleaner.clean_topic_fingerprints 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/graphql/anycable/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphQL 4 | module AnyCable 5 | VERSION = "1.3.1" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/graphql/subscriptions/anycable_subscriptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "anycable" 4 | require "graphql/subscriptions" 5 | require "graphql/anycable/errors" 6 | 7 | # rubocop: disable Metrics/AbcSize, Metrics/LineLength, Metrics/MethodLength 8 | 9 | # A subscriptions implementation that sends data as AnyCable broadcastings. 10 | # 11 | # Since AnyCable is aimed to be compatible with ActionCable, this adapter 12 | # may be used as (practically) drop-in replacement to ActionCable adapter 13 | # shipped with graphql-ruby. 14 | # 15 | # @example Adding AnyCableSubscriptions to your schema 16 | # MySchema = GraphQL::Schema.define do 17 | # use GraphQL::Subscriptions::AnyCableSubscriptions 18 | # end 19 | # 20 | # @example Implementing a channel for GraphQL Subscriptions 21 | # class GraphqlChannel < ApplicationCable::Channel 22 | # def execute(data) 23 | # query = data["query"] 24 | # variables = ensure_hash(data["variables"]) 25 | # operation_name = data["operationName"] 26 | # context = { 27 | # current_user: current_user, 28 | # # Make sure the channel is in the context 29 | # channel: self, 30 | # } 31 | # 32 | # result = MySchema.execute({ 33 | # query: query, 34 | # context: context, 35 | # variables: variables, 36 | # operation_name: operation_name 37 | # }) 38 | # 39 | # payload = { 40 | # result: result.subscription? ? {data: nil} : result.to_h, 41 | # more: result.subscription?, 42 | # } 43 | # 44 | # transmit(payload) 45 | # end 46 | # 47 | # def unsubscribed 48 | # MySchema.subscriptions.delete_channel_subscriptions(self) 49 | # end 50 | # end 51 | # 52 | module GraphQL 53 | class Subscriptions 54 | class AnyCableSubscriptions < GraphQL::Subscriptions 55 | extend Forwardable 56 | 57 | def_delegators :"GraphQL::AnyCable", :with_redis, :config 58 | def_delegators :"::AnyCable", :broadcast 59 | 60 | SUBSCRIPTION_PREFIX = "subscription:" # HASH: Stores subscription data: query, context, … 61 | FINGERPRINTS_PREFIX = "fingerprints:" # ZSET: To get fingerprints by topic 62 | SUBSCRIPTIONS_PREFIX = "subscriptions:" # SET: To get subscriptions by fingerprint 63 | CHANNEL_PREFIX = "channel:" # SET: Auxiliary structure for whole channel's subscriptions cleanup 64 | 65 | # @param serializer [<#dump(obj), #load(string)] Used for serializing messages before handing them to `.broadcast(msg)` 66 | def initialize(serializer: Serialize, **rest) 67 | @serializer = serializer 68 | super 69 | end 70 | 71 | # An event was triggered. 72 | # Re-evaluate all subscribed queries and push the data over ActionCable. 73 | def execute_all(event, object) 74 | fingerprints = with_redis { |redis| redis.zrange(redis_key(FINGERPRINTS_PREFIX) + event.topic, 0, -1) } 75 | return if fingerprints.empty? 76 | 77 | fingerprint_subscription_ids = with_redis do |redis| 78 | fingerprints.zip( 79 | redis.pipelined do |pipeline| 80 | fingerprints.map do |fingerprint| 81 | pipeline.smembers(redis_key(SUBSCRIPTIONS_PREFIX) + fingerprint) 82 | end 83 | end 84 | ).to_h 85 | end 86 | 87 | fingerprint_subscription_ids.each do |fingerprint, subscription_ids| 88 | execute_grouped(fingerprint, subscription_ids, event, object) 89 | end 90 | 91 | # Call to +trigger+ returns this. Convenient for playing in console 92 | fingerprint_subscription_ids.map { |k, v| [k, v.size] }.to_h 93 | end 94 | 95 | # The fingerprint has told us that this response should be shared by all subscribers, 96 | # so just run it once, then deliver the result to every subscriber 97 | def execute_grouped(fingerprint, subscription_ids, event, object) 98 | return if subscription_ids.empty? 99 | 100 | subscription_id = with_redis { |redis| subscription_ids.find { |sid| redis.exists?(redis_key(SUBSCRIPTION_PREFIX) + sid) } } 101 | return unless subscription_id # All subscriptions has expired but haven't cleaned up yet 102 | 103 | result = execute_update(subscription_id, event, object) 104 | return unless result 105 | 106 | # Having calculated the result _once_, send the same payload to all subscribers 107 | deliver(redis_key(SUBSCRIPTIONS_PREFIX) + fingerprint, result) 108 | end 109 | 110 | # Disable this method as there is no fingerprint (it can be retrieved from subscription though) 111 | def execute(subscription_id, event, object) 112 | raise NotImplementedError, "Use execute_all method instead of execute to get actual event fingerprint" 113 | end 114 | 115 | # This subscription was re-evaluated. 116 | # Send it to the specific stream where this client was waiting. 117 | # @param strean_key [String] 118 | # @param result [#to_h] result to send to clients 119 | def deliver(stream_key, result) 120 | payload = {result: result.to_h, more: true}.to_json 121 | broadcast(stream_key, payload) 122 | end 123 | 124 | # Save query to "storage" (in redis) 125 | def write_subscription(query, events) 126 | context = query.context.to_h 127 | subscription_id = context.delete(:subscription_id) || build_id 128 | channel = context.delete(:channel) 129 | 130 | raise GraphQL::AnyCable::ChannelConfigurationError unless channel 131 | 132 | # Store subscription_id in the channel state to cleanup on disconnect 133 | write_subscription_id(channel, subscription_id) 134 | 135 | events.each do |event| 136 | channel.stream_from(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint) 137 | end 138 | 139 | data = { 140 | query_string: query.query_string, 141 | variables: query.provided_variables.to_json, 142 | context: @serializer.dump(context.to_h), 143 | operation_name: query.operation_name.to_s, 144 | events: events.map { |e| [e.topic, e.fingerprint] }.to_h.to_json 145 | } 146 | 147 | with_redis do |redis| 148 | redis.multi do |pipeline| 149 | pipeline.sadd(redis_key(CHANNEL_PREFIX) + subscription_id, [subscription_id]) 150 | pipeline.mapped_hmset(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, data) 151 | events.each do |event| 152 | pipeline.zincrby(redis_key(FINGERPRINTS_PREFIX) + event.topic, 1, event.fingerprint) 153 | pipeline.sadd(redis_key(SUBSCRIPTIONS_PREFIX) + event.fingerprint, [subscription_id]) 154 | end 155 | next unless config.subscription_expiration_seconds 156 | pipeline.expire(redis_key(CHANNEL_PREFIX) + subscription_id, config.subscription_expiration_seconds) 157 | pipeline.expire(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, config.subscription_expiration_seconds) 158 | end 159 | end 160 | end 161 | 162 | # Return the query from "storage" (in redis) 163 | def read_subscription(subscription_id) 164 | with_redis do |redis| 165 | redis.mapped_hmget( 166 | "#{redis_key(SUBSCRIPTION_PREFIX)}#{subscription_id}", 167 | :query_string, :variables, :context, :operation_name 168 | ).tap do |subscription| 169 | next if subscription.values.all?(&:nil?) # Redis returns hash with all nils for missing key 170 | 171 | subscription[:context] = @serializer.load(subscription[:context]) 172 | subscription[:variables] = JSON.parse(subscription[:variables]) 173 | subscription[:operation_name] = nil if subscription[:operation_name].strip == "" 174 | end 175 | end 176 | end 177 | 178 | # The channel was closed, forget about it and its subscriptions 179 | def delete_channel_subscriptions(channel) 180 | raise(ArgumentError, "Please pass channel instance to #{__method__} in your #unsubscribed method") if channel.is_a?(String) 181 | 182 | channel_id = read_subscription_id(channel) 183 | 184 | # Missing in case disconnect happens before #execute 185 | return unless channel_id 186 | 187 | with_redis do |redis| 188 | redis.smembers(redis_key(CHANNEL_PREFIX) + channel_id).each do |subscription_id| 189 | delete_subscription(subscription_id, redis: redis) 190 | end 191 | redis.del(redis_key(CHANNEL_PREFIX) + channel_id) 192 | end 193 | end 194 | 195 | def delete_subscription(subscription_id, redis: AnyCable.redis) 196 | events = redis.hget(redis_key(SUBSCRIPTION_PREFIX) + subscription_id, :events) 197 | events = events ? JSON.parse(events) : {} 198 | fingerprint_subscriptions = {} 199 | redis.pipelined do |pipeline| 200 | events.each do |topic, fingerprint| 201 | pipeline.srem(redis_key(SUBSCRIPTIONS_PREFIX) + fingerprint, subscription_id) 202 | score = pipeline.zincrby(redis_key(FINGERPRINTS_PREFIX) + topic, -1, fingerprint) 203 | fingerprint_subscriptions[redis_key(FINGERPRINTS_PREFIX) + topic] = score 204 | end 205 | # Delete subscription itself 206 | pipeline.del(redis_key(SUBSCRIPTION_PREFIX) + subscription_id) 207 | end 208 | # Clean up fingerprints that doesn't have any subscriptions left 209 | redis.pipelined do |pipeline| 210 | fingerprint_subscriptions.each do |key, score| 211 | pipeline.zremrangebyscore(key, "-inf", "0") if score.value.zero? 212 | end 213 | end 214 | end 215 | 216 | private 217 | 218 | def read_subscription_id(channel) 219 | return channel.instance_variable_get(:@__sid__) if channel.instance_variable_defined?(:@__sid__) 220 | 221 | istate = fetch_channel_istate(channel) 222 | 223 | return unless istate 224 | 225 | channel.instance_variable_set(:@__sid__, istate["sid"]) 226 | end 227 | 228 | def write_subscription_id(channel, val) 229 | channel.connection.anycable_socket.istate["sid"] = val 230 | channel.instance_variable_set(:@__sid__, val) 231 | end 232 | 233 | def fetch_channel_istate(channel) 234 | # For Rails integration 235 | return channel.__istate__ if channel.respond_to?(:__istate__) 236 | 237 | return unless channel.connection.socket.istate 238 | 239 | if channel.connection.socket.istate[channel.identifier] 240 | JSON.parse(channel.connection.socket.istate[channel.identifier]) 241 | else 242 | channel.connection.socket.istate 243 | end 244 | end 245 | 246 | def redis_key(prefix) 247 | "#{config.redis_prefix}-#{prefix}" 248 | end 249 | end 250 | end 251 | end 252 | # rubocop: enable Metrics/AbcSize, Metrics/LineLength, Metrics/MethodLength 253 | -------------------------------------------------------------------------------- /spec/graphql/anycable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe GraphQL::AnyCable do 4 | subject do 5 | AnycableSchema.execute( 6 | query: query, 7 | context: {channel: channel, subscription_id: subscription_id}, 8 | variables: {}, 9 | operation_name: "SomeSubscription" 10 | ).tap do |result| 11 | expect(result.to_h.fetch("errors", [])).to be_empty 12 | end 13 | end 14 | 15 | let(:query) do 16 | <<~GRAPHQL 17 | subscription SomeSubscription { productUpdated { id } } 18 | GRAPHQL 19 | end 20 | 21 | let(:expected_result) do 22 | <<~JSON.strip 23 | {"result":{"data":{"productUpdated":{"id":"1"}}},"more":true} 24 | JSON 25 | end 26 | 27 | let(:channel) do 28 | socket = double("Socket", istate: AnyCable::Socket::State.new({})) 29 | connection = double("Connection", anycable_socket: socket) 30 | double("Channel", id: "legacy_id", params: {"channelId" => "legacy_id"}, stream_from: nil, connection: connection) 31 | end 32 | 33 | let(:subscription_id) do 34 | "some-truly-random-number" 35 | end 36 | 37 | let(:fingerprint) do 38 | ":productUpdated:/SomeSubscription/fBDZmJU1UGTorQWvOyUeaHVwUxJ3T9SEqnetj6SKGXc=/0/RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o=" 39 | end 40 | 41 | before do 42 | allow(AnyCable).to receive(:broadcast) 43 | allow_any_instance_of(GraphQL::Subscriptions::Event).to receive(:fingerprint).and_return(fingerprint) 44 | allow_any_instance_of(GraphQL::Subscriptions).to receive(:build_id).and_return("ohmycables") 45 | end 46 | 47 | it "subscribes channel to stream updates from GraphQL subscription" do 48 | subject 49 | expect(channel).to have_received(:stream_from).with("graphql-subscriptions:#{fingerprint}") 50 | end 51 | 52 | it "broadcasts message when event is being triggered" do 53 | subject 54 | AnycableSchema.subscriptions.trigger(:product_updated, {}, {id: 1, title: "foo"}) 55 | expect(AnyCable).to have_received(:broadcast).with("graphql-subscriptions:#{fingerprint}", expected_result) 56 | end 57 | 58 | context "with multiple subscriptions in one query" do 59 | let(:query) do 60 | <<~GRAPHQL 61 | subscription SomeSubscription { 62 | productCreated { id title } 63 | productUpdated { id } 64 | } 65 | GRAPHQL 66 | end 67 | 68 | context "triggering update event" do 69 | it "broadcasts message only for update event" do 70 | subject 71 | AnycableSchema.subscriptions.trigger(:product_updated, {}, {id: 1, title: "foo"}) 72 | expect(AnyCable).to have_received(:broadcast).with("graphql-subscriptions:#{fingerprint}", expected_result) 73 | end 74 | end 75 | 76 | context "triggering create event" do 77 | let(:expected_result) do 78 | <<~JSON.strip 79 | {"result":{"data":{"productCreated":{"id":"1","title":"Gravizapa"}}},"more":true} 80 | JSON 81 | end 82 | 83 | it "broadcasts message only for create event" do 84 | subject 85 | AnycableSchema.subscriptions.trigger(:product_created, {}, {id: 1, title: "Gravizapa"}) 86 | 87 | expect(AnyCable).to have_received(:broadcast).with("graphql-subscriptions:#{fingerprint}", expected_result) 88 | end 89 | end 90 | end 91 | 92 | context "with empty operation name" do 93 | subject do 94 | AnycableSchema.execute( 95 | query: query, 96 | context: {channel: channel, subscription_id: subscription_id}, 97 | variables: {}, 98 | operation_name: nil 99 | ) 100 | end 101 | 102 | let(:query) do 103 | <<~GRAPHQL 104 | subscription { productUpdated { id } } 105 | GRAPHQL 106 | end 107 | 108 | it "subscribes channel to stream updates from GraphQL subscription" do 109 | subject 110 | expect(channel).to have_received(:stream_from).with("graphql-subscriptions:#{fingerprint}") 111 | end 112 | end 113 | 114 | describe ".delete_channel_subscriptions" do 115 | context "with default config.redis-prefix" do 116 | before do 117 | AnycableSchema.execute( 118 | query: query, 119 | context: {channel: channel, subscription_id: subscription_id}, 120 | variables: {}, 121 | operation_name: "SomeSubscription" 122 | ) 123 | end 124 | 125 | let(:redis) { $redis } 126 | 127 | subject do 128 | AnycableSchema.subscriptions.delete_channel_subscriptions(channel) 129 | end 130 | 131 | it "removes subscription from redis" do 132 | expect(redis.exists?("graphql-subscription:some-truly-random-number")).to be true 133 | expect(redis.exists?("graphql-channel:some-truly-random-number")).to be true 134 | expect(redis.exists?("graphql-fingerprints::productUpdated:")).to be true 135 | subject 136 | expect(redis.exists?("graphql-channel:some-truly-random-number")).to be false 137 | expect(redis.exists?("graphql-fingerprints::productUpdated:")).to be false 138 | expect(redis.exists?("graphql-subscription:some-truly-random-number")).to be false 139 | end 140 | end 141 | 142 | context "with different config.redis-prefix" do 143 | around do |ex| 144 | old_redis_prefix = GraphQL::AnyCable.config.redis_prefix 145 | GraphQL::AnyCable.config.redis_prefix = "graphql-test" 146 | 147 | ex.run 148 | 149 | GraphQL::AnyCable.config.redis_prefix = old_redis_prefix 150 | end 151 | 152 | before do 153 | AnycableSchema.execute( 154 | query: query, 155 | context: {channel: channel, subscription_id: subscription_id}, 156 | variables: {}, 157 | operation_name: "SomeSubscription" 158 | ) 159 | end 160 | 161 | let(:redis) { $redis } 162 | 163 | subject do 164 | AnycableSchema.subscriptions.delete_channel_subscriptions(channel) 165 | end 166 | 167 | it "removes subscription from redis" do 168 | expect(redis.exists?("graphql-test-subscription:some-truly-random-number")).to be true 169 | expect(redis.exists?("graphql-test-channel:some-truly-random-number")).to be true 170 | expect(redis.exists?("graphql-test-fingerprints::productUpdated:")).to be true 171 | subject 172 | expect(redis.exists?("graphql-test-channel:some-truly-random-number")).to be false 173 | expect(redis.exists?("graphql-test-fingerprints::productUpdated:")).to be false 174 | expect(redis.exists?("graphql-test-subscription:some-truly-random-number")).to be false 175 | end 176 | end 177 | end 178 | 179 | describe "with missing channel instance in execution context" do 180 | subject do 181 | AnycableSchema.execute( 182 | query: query, 183 | context: {}, # Intentionally left blank 184 | variables: {}, 185 | operation_name: "SomeSubscription" 186 | ) 187 | end 188 | 189 | let(:query) do 190 | <<~GRAPHQL 191 | subscription SomeSubscription { productUpdated { id } } 192 | GRAPHQL 193 | end 194 | 195 | it "raises configuration error" do 196 | expect { subject }.to raise_error( 197 | GraphQL::AnyCable::ChannelConfigurationError, 198 | /ActionCable channel wasn't provided in the context for GraphQL query execution!/ 199 | ) 200 | end 201 | end 202 | 203 | describe ".config" do 204 | it "returns the default redis_prefix" do 205 | expect(GraphQL::AnyCable.config.redis_prefix).to eq("graphql") 206 | end 207 | 208 | context "when changed redis_prefix" do 209 | after do 210 | GraphQL::AnyCable.config.redis_prefix = "graphql" 211 | end 212 | 213 | it "writes a new value to redis_prefix" do 214 | GraphQL::AnyCable.config.redis_prefix = "new-graphql" 215 | 216 | expect(GraphQL::AnyCable.config.redis_prefix).to eq("new-graphql") 217 | end 218 | end 219 | end 220 | 221 | describe ".stats" do 222 | it "calls Graphql::AnyCable::Stats" do 223 | allow_any_instance_of(GraphQL::AnyCable::Stats).to receive(:collect) 224 | 225 | described_class.stats 226 | end 227 | end 228 | end 229 | -------------------------------------------------------------------------------- /spec/graphql/broadcast_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Broadcasting" do 4 | def subscribe(query) 5 | BroadcastSchema.execute( 6 | query: query, 7 | context: {channel: channel}, 8 | variables: {}, 9 | operation_name: "SomeSubscription" 10 | ) 11 | end 12 | 13 | let(:channel) do 14 | socket = double("Socket", istate: AnyCable::Socket::State.new({})) 15 | connection = double("Connection", anycable_socket: socket) 16 | double("Channel", connection: connection) 17 | end 18 | 19 | let(:object) do 20 | double("Post", id: 1, title: "Broadcasting…", actions: %w[Edit Delete]) 21 | end 22 | 23 | let(:query) do 24 | <<~GRAPHQL.strip 25 | subscription SomeSubscription { postCreated{ id title } } 26 | GRAPHQL 27 | end 28 | 29 | before do 30 | allow(channel).to receive(:stream_from) 31 | allow(AnyCable).to receive(:broadcast) 32 | end 33 | 34 | context "when all clients asks for broadcastable fields only" do 35 | let(:query) do 36 | <<~GRAPHQL.strip 37 | subscription SomeSubscription { postCreated{ id title } } 38 | GRAPHQL 39 | end 40 | 41 | it "uses broadcasting to resolve query only once" do 42 | 2.times { subscribe(query) } 43 | BroadcastSchema.subscriptions.trigger(:post_created, {}, object) 44 | expect(object).to have_received(:title).once 45 | expect(AnyCable).to have_received(:broadcast).once 46 | end 47 | end 48 | 49 | context "when all clients asks for non-broadcastable fields" do 50 | let(:query) do 51 | <<~GRAPHQL.strip 52 | subscription SomeSubscription { postCreated{ id title actions } } 53 | GRAPHQL 54 | end 55 | 56 | it "resolves query for every client" do 57 | 2.times { subscribe(query) } 58 | BroadcastSchema.subscriptions.trigger(:post_created, {}, object) 59 | expect(object).to have_received(:title).twice 60 | expect(AnyCable).to have_received(:broadcast).twice 61 | end 62 | end 63 | 64 | context "when one of subscriptions got expired" do 65 | let(:query) do 66 | <<~GRAPHQL.strip 67 | subscription SomeSubscription { postCreated{ id title } } 68 | GRAPHQL 69 | end 70 | 71 | let(:redis) { $redis } 72 | 73 | it "doesn't fail" do 74 | 3.times { subscribe(query) } 75 | redis.keys("graphql-subscription:*").last.tap(&redis.method(:del)) 76 | expect(redis.keys("graphql-subscription:*").size).to eq(2) 77 | expect { BroadcastSchema.subscriptions.trigger(:post_created, {}, object) }.not_to raise_error 78 | expect(object).to have_received(:title).once 79 | expect(AnyCable).to have_received(:broadcast).once 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/graphql/stats_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe GraphQL::AnyCable::Stats do 4 | describe "#collect" do 5 | let(:query) do 6 | <<~GRAPHQL 7 | subscription SomeSubscription { 8 | productCreated { id title } 9 | productUpdated { id } 10 | } 11 | GRAPHQL 12 | end 13 | 14 | let(:channel) do 15 | socket = double("Socket", istate: AnyCable::Socket::State.new({})) 16 | connection = double("Connection", anycable_socket: socket) 17 | double("Channel", id: "legacy_id", params: {"channelId" => "legacy_id"}, stream_from: nil, connection: connection) 18 | end 19 | 20 | let(:subscription_id) do 21 | "some-truly-random-number" 22 | end 23 | 24 | before do 25 | AnycableSchema.execute( 26 | query: query, 27 | context: {channel: channel, subscription_id: subscription_id}, 28 | variables: {}, 29 | operation_name: "SomeSubscription" 30 | ) 31 | end 32 | 33 | context "when include_subscriptions is false" do 34 | let(:expected_result) do 35 | {total: {subscription: 1, fingerprints: 2, subscriptions: 2, channel: 1}} 36 | end 37 | 38 | it "returns total stat" do 39 | expect(subject.collect).to eq(expected_result) 40 | end 41 | end 42 | 43 | context "when include_subscriptions is true" do 44 | subject { described_class.new(include_subscriptions: true) } 45 | 46 | let(:expected_result) do 47 | { 48 | total: {subscription: 1, fingerprints: 2, subscriptions: 2, channel: 1}, 49 | subscriptions: { 50 | "productCreated" => 1, 51 | "productUpdated" => 1 52 | } 53 | } 54 | end 55 | 56 | it "returns total stat with grouped subscription stats" do 57 | expect(subject.collect).to eq(expected_result) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/integration_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "anycable/rspec" 4 | require "rack" 5 | 6 | RSpec.shared_context "rpc" do 7 | include_context "anycable:rpc:command" 8 | 9 | let(:user) { "john" } 10 | let(:schema) { nil } 11 | let(:identifiers) { {current_user: "john", schema: schema.to_s} } 12 | let(:channel_class) { "GraphqlChannel" } 13 | let(:channel_identifier) { {channel: channel_class} } 14 | let(:channel_id) { channel_identifier.to_json } 15 | 16 | let(:handler) { AnyCable::RPC::Handler.new } 17 | end 18 | 19 | # Minimal AnyCable connection implementation 20 | class FakeConnection 21 | class Channel 22 | attr_reader :connection, :params, :identifier 23 | 24 | def initialize(connection, identifier, params) 25 | @connection = connection 26 | @identifier = identifier 27 | @params = params 28 | end 29 | 30 | def stream_from(broadcasting) 31 | connection.socket.subscribe identifier, broadcasting 32 | end 33 | end 34 | 35 | attr_reader :request, :socket, :identifiers, :subscriptions, 36 | :schema 37 | 38 | alias_method :anycable_socket, :socket 39 | 40 | def initialize(socket, identifiers: nil, subscriptions: nil) 41 | @socket = socket 42 | @identifiers = identifiers ? JSON.parse(identifiers) : {} 43 | @request = Rack::Request.new(socket.env) 44 | @schema = Object.const_get(@identifiers["schema"]) 45 | @subscriptions = subscriptions 46 | end 47 | 48 | def handle_channel_command(identifier, command, data) 49 | parsed_id = JSON.parse(identifier) 50 | 51 | parsed_id.delete("channel") 52 | channel = Channel.new(self, identifier, parsed_id) 53 | 54 | case command 55 | when "message" 56 | data = JSON.parse(data) 57 | result = 58 | schema.execute( 59 | query: data["query"], 60 | context: identifiers.merge(channel: channel), 61 | variables: Hash(data["variables"]), 62 | operation_name: data["operationName"] 63 | ) 64 | 65 | transmit( 66 | result: result.subscription? ? {data: nil} : result.to_h, 67 | more: result.subscription? 68 | ) 69 | when "unsubscribe" 70 | schema.subscriptions.delete_channel_subscriptions(channel) 71 | true 72 | else 73 | raise "Unknown command" 74 | end 75 | end 76 | 77 | def transmit(data) 78 | socket.transmit data.to_json 79 | end 80 | 81 | def identifiers_json 82 | @identifiers.to_json 83 | end 84 | 85 | def close 86 | socket.close 87 | end 88 | end 89 | 90 | AnyCable.connection_factory = ->(socket, **options) { FakeConnection.new(socket, **options) } 91 | 92 | # Add verbose logging to exceptions 93 | AnyCable.capture_exception do |ex, method, message| 94 | $stdout.puts "RPC ##{method} failed: #{message}\n#{ex}\n#{ex.backtrace.take(5).join("\n")}" 95 | end 96 | 97 | RSpec.configure do |config| 98 | config.define_derived_metadata(file_path: %r{spec/integrations/}) do |metadata| 99 | metadata[:integration] = true 100 | end 101 | 102 | config.include_context "rpc", integration: true 103 | end 104 | -------------------------------------------------------------------------------- /spec/integrations/broadcastable_subscriptions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "integration_helper" 4 | 5 | RSpec.describe "broadcastable subscriptions" do 6 | let(:schema) { BroadcastSchema } 7 | 8 | let(:query) do 9 | <<~GQL 10 | subscription postSubscription($id: ID!) { 11 | postUpdated(id: $id) { 12 | post { 13 | title 14 | } 15 | } 16 | } 17 | GQL 18 | end 19 | let(:variables) { {id: "a"} } 20 | 21 | let(:subscription_payload) { {query: query, variables: variables} } 22 | 23 | let(:command) { "message" } 24 | let(:data) { {action: "execute", **subscription_payload} } 25 | 26 | subject { handler.handle(:command, request) } 27 | 28 | before { allow(AnyCable).to receive(:broadcast) } 29 | 30 | describe "execute" do 31 | it "responds with result" do 32 | expect(subject).to be_success 33 | expect(subject.transmissions.size).to eq 1 34 | expect(subject.transmissions.first).to eq({result: {data: nil}, more: true}.to_json) 35 | expect(subject.streams.size).to eq 1 36 | expect(subject.istate["sid"]).not_to be_nil 37 | end 38 | 39 | specify "streams depends only on query params and the same for equal subscriptions" do 40 | expect(subject).to be_success 41 | expect(subject.streams.size).to eq 1 42 | 43 | stream_name = subject.streams.first 44 | 45 | # update request context 46 | request.connection_identifiers = identifiers.merge(current_user: "alice").to_json 47 | 48 | response = handler.handle(:command, request) 49 | expect(response).to be_success 50 | expect(response.streams).to eq([stream_name]) 51 | 52 | # now update the query param 53 | request.data = data.merge(variables: {id: "b"}).to_json 54 | 55 | response = handler.handle(:command, request) 56 | expect(response).to be_success 57 | expect(response.streams.size).to eq 1 58 | expect(response.streams.first).not_to eq stream_name 59 | end 60 | end 61 | 62 | describe "unsubscribe + custom Redis connector" do 63 | let(:custom_redis_url) { REDIS_TEST_DB_URL.sub(/(\/\d+\/?)?$/, "/5") } 64 | 65 | let(:redis_connections) do 66 | Array.new(2) { Redis.new(url: custom_redis_url) } 67 | end 68 | 69 | let(:redis_enumerator) do 70 | redis_connections.cycle 71 | end 72 | 73 | before do 74 | GraphQL::AnyCable.redis = ->(&block) { block.call redis_enumerator.next } 75 | end 76 | 77 | after do 78 | GraphQL::AnyCable.remove_instance_variable(:@redis_connector) 79 | redis.flushdb 80 | end 81 | 82 | let(:redis) { Redis.new(url: custom_redis_url) } 83 | 84 | specify "removes subscription from the store" do 85 | # first, subscribe to obtain the connection state 86 | subscribe_response = handler.handle(:command, request) 87 | expect(subscribe_response).to be_success 88 | 89 | expect(redis.keys("graphql-subscription:*").size).to eq(1) 90 | 91 | first_state = subscribe_response.istate 92 | 93 | request.command = "unsubscribe" 94 | request.data = "" 95 | request.istate = first_state 96 | 97 | response = handler.handle(:command, request) 98 | expect(response).to be_success 99 | 100 | expect(redis.keys("graphql-subscription:*").size).to eq(0) 101 | end 102 | 103 | context "with nested istate" do 104 | specify "removes subscription from the store" do 105 | # first, subscribe to obtain the connection state 106 | subscribe_response = handler.handle(:command, request) 107 | expect(subscribe_response).to be_success 108 | 109 | expect(redis.keys("graphql-subscription:*").size).to eq(1) 110 | 111 | istate = subscribe_response.istate 112 | 113 | request.command = "unsubscribe" 114 | request.data = "" 115 | request.istate[channel_id] = istate.to_h.to_json 116 | 117 | response = handler.handle(:command, request) 118 | expect(response).to be_success 119 | 120 | expect(redis.keys("graphql-subscription:*").size).to eq(0) 121 | end 122 | end 123 | 124 | specify "creates single entry for similar subscriptions" do 125 | # first, subscribe to obtain the connection state 126 | response = handler.handle(:command, request) 127 | expect(response).to be_success 128 | 129 | expect(redis.keys("graphql-subscription:*").size).to eq(1) 130 | expect(redis.keys("graphql-subscriptions:*").size).to eq(1) 131 | 132 | request_2 = request.dup 133 | 134 | # update request context 135 | request_2.connection_identifiers = identifiers.merge(current_user: "alice").to_json 136 | 137 | response_2 = handler.handle(:command, request_2) 138 | 139 | expect(redis.keys("graphql-subscription:*").size).to eq(2) 140 | expect(redis.keys("graphql-subscriptions:*").size).to eq(1) 141 | 142 | schema.subscriptions.trigger(:post_updated, {id: "a"}, POSTS.first) 143 | expect(AnyCable).to have_received(:broadcast).once 144 | 145 | first_state = response.istate 146 | 147 | request.command = "unsubscribe" 148 | request.data = "" 149 | request.istate = first_state 150 | 151 | response = handler.handle(:command, request) 152 | expect(response).to be_success 153 | 154 | expect(redis.keys("graphql-subscription:*").size).to eq(1) 155 | expect(redis.keys("graphql-subscriptions:*").size).to eq(1) 156 | 157 | schema.subscriptions.trigger(:post_updated, {id: "a"}, POSTS.first) 158 | expect(AnyCable).to have_received(:broadcast).twice 159 | 160 | second_state = response_2.istate 161 | 162 | request_2.command = "unsubscribe" 163 | request_2.data = "" 164 | request_2.istate = second_state 165 | 166 | response = handler.handle(:command, request) 167 | expect(response).to be_success 168 | 169 | expect(redis.keys("graphql-subscription:*").size).to eq(0) 170 | expect(redis.keys("graphql-subscriptions:*").size).to eq(0) 171 | 172 | schema.subscriptions.trigger(:post_updated, {id: "a"}, POSTS.first) 173 | expect(AnyCable).to have_received(:broadcast).twice 174 | end 175 | 176 | context "without subscription" do 177 | let(:data) { nil } 178 | let(:command) { "unsubscribe" } 179 | 180 | specify do 181 | expect(subject).to be_success 182 | end 183 | end 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /spec/integrations/per_client_subscriptions_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "integration_helper" 4 | 5 | RSpec.describe "non-broadcastable subscriptions" do 6 | let(:schema) { AnycableSchema } 7 | 8 | let(:query) do 9 | <<~GQL 10 | subscription postSubscription($id: ID!) { 11 | postUpdated(id: $id) { 12 | post { 13 | title 14 | } 15 | } 16 | } 17 | GQL 18 | end 19 | let(:variables) { {id: "a"} } 20 | 21 | let(:subscription_payload) { {query: query, variables: variables} } 22 | 23 | let(:command) { "message" } 24 | let(:data) { {action: "execute", **subscription_payload} } 25 | 26 | subject { handler.handle(:command, request) } 27 | 28 | before { allow(AnyCable).to receive(:broadcast) } 29 | 30 | describe "execute" do 31 | it "responds with result" do 32 | expect(subject).to be_success 33 | expect(subject.transmissions.size).to eq 1 34 | expect(subject.transmissions.first).to eq({result: {data: nil}, more: true}.to_json) 35 | expect(subject.streams.size).to eq 1 36 | expect(subject.istate["sid"]).not_to be_nil 37 | end 38 | 39 | specify "creates uniq stream for each subscription" do 40 | expect(subject).to be_success 41 | expect(subject.streams.size).to eq 1 42 | 43 | all_streams = Set.new(subject.streams) 44 | 45 | response = handler.handle(:command, request) 46 | expect(response).to be_success 47 | expect(response.streams.size).to eq 1 48 | 49 | all_streams << response.streams.first 50 | expect(all_streams.size).to eq 2 51 | 52 | # now update the query param 53 | request.data = data.merge(variables: {id: "b"}).to_json 54 | 55 | response = handler.handle(:command, request) 56 | expect(response).to be_success 57 | expect(response.streams.size).to eq 1 58 | 59 | all_streams << response.streams.first 60 | expect(all_streams.size).to eq 3 61 | end 62 | end 63 | 64 | describe "unsubscribe" do 65 | let(:redis) { $redis } 66 | 67 | specify "removes subscription from the store" do 68 | # first, subscribe to obtain the connection state 69 | subscribe_response = handler.handle(:command, request) 70 | expect(subscribe_response).to be_success 71 | 72 | expect(redis.keys("graphql-subscription:*").size).to eq(1) 73 | 74 | first_state = subscribe_response.istate 75 | 76 | request.command = "unsubscribe" 77 | request.data = "" 78 | request.istate = first_state 79 | 80 | response = handler.handle(:command, request) 81 | expect(response).to be_success 82 | 83 | expect(redis.keys("graphql-subscription:*").size).to eq(0) 84 | end 85 | 86 | specify "creates an entry for each subscription" do 87 | # first, subscribe to obtain the connection state 88 | response = handler.handle(:command, request) 89 | expect(response).to be_success 90 | 91 | expect(redis.keys("graphql-subscription:*").size).to eq(1) 92 | expect(redis.keys("graphql-subscriptions:*").size).to eq(1) 93 | 94 | request_2 = request.dup 95 | 96 | # update request context 97 | request_2.connection_identifiers = identifiers.merge(current_user: "alice").to_json 98 | 99 | response_2 = handler.handle(:command, request_2) 100 | 101 | expect(redis.keys("graphql-subscription:*").size).to eq(2) 102 | expect(redis.keys("graphql-subscriptions:*").size).to eq(2) 103 | 104 | schema.subscriptions.trigger(:post_updated, {id: "a"}, POSTS.first) 105 | expect(AnyCable).to have_received(:broadcast).twice 106 | 107 | first_state = response.istate 108 | 109 | request.command = "unsubscribe" 110 | request.data = "" 111 | request.istate = first_state 112 | 113 | response = handler.handle(:command, request) 114 | expect(response).to be_success 115 | 116 | expect(redis.keys("graphql-subscription:*").size).to eq(1) 117 | expect(redis.keys("graphql-subscriptions:*").size).to eq(1) 118 | 119 | schema.subscriptions.trigger(:post_updated, {id: "a"}, POSTS.first) 120 | expect(AnyCable).to have_received(:broadcast).thrice 121 | 122 | second_state = response_2.istate 123 | 124 | request_2.command = "unsubscribe" 125 | request_2.data = "" 126 | request_2.istate = second_state 127 | 128 | response = handler.handle(:command, request) 129 | expect(response).to be_success 130 | 131 | expect(redis.keys("graphql-subscription:*").size).to eq(0) 132 | expect(redis.keys("graphql-subscriptions:*").size).to eq(0) 133 | 134 | schema.subscriptions.trigger(:post_updated, {id: "a"}, POSTS.first) 135 | expect(AnyCable).to have_received(:broadcast).thrice 136 | end 137 | 138 | context "without subscription" do 139 | let(:data) { nil } 140 | let(:command) { "unsubscribe" } 141 | 142 | specify do 143 | expect(subject).to be_success 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /spec/integrations/rails_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "integration_helper" 4 | require "rails" 5 | require "action_cable/engine" 6 | 7 | # Stub Rails.root for Anyway Config 8 | module Rails 9 | def self.root 10 | Pathname.new(__dir__) 11 | end 12 | end 13 | 14 | require "anycable-rails" 15 | 16 | # Load server to trigger load hooks 17 | begin 18 | require "action_cable/server/base" 19 | rescue LoadError 20 | require "action_cable/server" 21 | end 22 | # Only for anycable-rails <1.3.0 23 | unless defined?(::AnyCable::Rails::Connection) 24 | require "anycable/rails/channel_state" 25 | require "anycable/rails/actioncable/connection" 26 | end 27 | 28 | module ApplicationCable 29 | class Connection < ActionCable::Connection::Base 30 | identified_by :current_user, :schema 31 | 32 | def schema_class 33 | schema.constantize 34 | end 35 | end 36 | 37 | class GraphqlChannel < ActionCable::Channel::Base 38 | delegate :schema_class, to: :connection 39 | 40 | def execute(data) 41 | result = 42 | schema_class.execute( 43 | query: data["query"], 44 | context: context, 45 | variables: Hash(data["variables"]), 46 | operation_name: data["operationName"] 47 | ) 48 | 49 | transmit( 50 | { 51 | result: result.subscription? ? {data: nil} : result.to_h, 52 | more: result.subscription? 53 | } 54 | ) 55 | end 56 | 57 | def unsubscribed 58 | schema_class.subscriptions.delete_channel_subscriptions(self) 59 | end 60 | 61 | private 62 | 63 | def context 64 | { 65 | current_user: connection.current_user, 66 | channel: self 67 | } 68 | end 69 | end 70 | end 71 | 72 | RSpec.describe "Rails integration" do 73 | let(:schema) { BroadcastSchema } 74 | let(:channel_class) { "ApplicationCable::GraphqlChannel" } 75 | 76 | if defined?(::AnyCable::Rails::Connection) 77 | before do 78 | allow(AnyCable).to receive(:connection_factory) 79 | .and_return(->(socket, **options) { ::AnyCable::Rails::Connection.new(ApplicationCable::Connection, socket, **options) }) 80 | end 81 | else 82 | before do 83 | allow(AnyCable).to receive(:connection_factory) 84 | .and_return(->(socket, **options) { ApplicationCable::Connection.call(socket, **options) }) 85 | end 86 | end 87 | 88 | let(:variables) { {id: "a"} } 89 | 90 | let(:subscription_payload) { {query: query, variables: variables} } 91 | 92 | let(:command) { "message" } 93 | let(:data) { {action: "execute", **subscription_payload} } 94 | 95 | subject { handler.handle(:command, request) } 96 | 97 | before { allow(AnyCable).to receive(:broadcast) } 98 | 99 | let(:query) do 100 | <<~GQL 101 | subscription postSubscription($id: ID!) { 102 | postUpdated(id: $id) { 103 | post { 104 | title 105 | } 106 | } 107 | } 108 | GQL 109 | end 110 | 111 | let(:redis) { $redis } 112 | 113 | it "execute multiple clients + trigger + disconnect one by one" do 114 | # first, subscribe to obtain the connection state 115 | response = handler.handle(:command, request) 116 | expect(response).to be_success 117 | 118 | expect(redis.keys("graphql-subscription:*").size).to eq(1) 119 | expect(redis.keys("graphql-subscriptions:*").size).to eq(1) 120 | 121 | request_2 = request.dup 122 | 123 | # update request context 124 | request_2.connection_identifiers = identifiers.merge(current_user: "alice").to_json 125 | 126 | response_2 = handler.handle(:command, request_2) 127 | 128 | expect(redis.keys("graphql-subscription:*").size).to eq(2) 129 | expect(redis.keys("graphql-subscriptions:*").size).to eq(1) 130 | 131 | schema.subscriptions.trigger(:post_updated, {id: "a"}, POSTS.first) 132 | expect(AnyCable).to have_received(:broadcast).once 133 | 134 | first_state = response.istate 135 | 136 | request.command = "unsubscribe" 137 | request.data = "" 138 | request.istate = first_state 139 | 140 | response = handler.handle(:command, request) 141 | expect(response).to be_success 142 | 143 | expect(redis.keys("graphql-subscription:*").size).to eq(1) 144 | expect(redis.keys("graphql-subscriptions:*").size).to eq(1) 145 | 146 | schema.subscriptions.trigger(:post_updated, {id: "a"}, POSTS.first) 147 | expect(AnyCable).to have_received(:broadcast).twice 148 | 149 | second_state = response_2.istate 150 | 151 | # Disconnect the second one via #disconnect call 152 | disconnect_request = AnyCable::DisconnectRequest.new( 153 | identifiers: request_2.connection_identifiers, 154 | subscriptions: [request_2.identifier], 155 | env: request_2.env 156 | ) 157 | 158 | disconnect_request.istate[request_2.identifier] = second_state.to_h.to_json 159 | 160 | disconnect_response = handler.handle(:disconnect, disconnect_request) 161 | expect(disconnect_response).to be_success 162 | 163 | expect(redis.keys("graphql-subscription:*").size).to eq(0) 164 | expect(redis.keys("graphql-subscriptions:*").size).to eq(0) 165 | 166 | schema.subscriptions.trigger(:post_updated, {id: "a"}, POSTS.first) 167 | expect(AnyCable).to have_received(:broadcast).twice 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /spec/redis_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | REDIS_TEST_DB_URL = ENV.fetch("REDIS_URL", "redis://localhost:6379/6") 4 | 5 | channel = AnyCable.broadcast_adapter.channel 6 | 7 | AnyCable.broadcast_adapter = :redis, {url: REDIS_TEST_DB_URL, channel: channel} 8 | 9 | $redis = Redis.new(url: REDIS_TEST_DB_URL) 10 | 11 | RSpec.configure do |config| 12 | config.before do 13 | GraphQL::AnyCable.with_redis(&:flushdb) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "ostruct" 5 | require "graphql/anycable" 6 | require "debug" unless ENV["CI"] 7 | 8 | require_relative "support/graphql_schema" 9 | require_relative "redis_helper" 10 | 11 | RSpec.configure do |config| 12 | # Enable flags like --only-failures and --next-failure 13 | config.example_status_persistence_file_path = ".rspec_status" 14 | 15 | # Disable RSpec exposing methods globally on `Module` and `main` 16 | config.disable_monkey_patching! 17 | 18 | config.filter_run :focus 19 | config.run_all_when_everything_filtered = true 20 | 21 | config.expect_with :rspec do |c| 22 | c.syntax = :expect 23 | end 24 | 25 | config.mock_with :rspec 26 | 27 | Kernel.srand config.seed 28 | config.order = :random 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/graphql_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | POSTS = [ 4 | {id: "a", title: "GraphQL is good?", actions: %w[yes no]}, 5 | {id: "b", title: "Is there life after GraphQL?", actions: %w[no still-no]} 6 | ].freeze 7 | 8 | class Product < GraphQL::Schema::Object 9 | field :id, ID, null: false, hash_key: :id 10 | field :title, String, null: true, hash_key: :title 11 | end 12 | 13 | class Post < GraphQL::Schema::Object 14 | field :id, ID, null: false, broadcastable: true 15 | field :title, String, null: true 16 | field :actions, [String], null: false, broadcastable: false 17 | end 18 | 19 | class PostUpdated < GraphQL::Schema::Subscription 20 | argument :id, ID, required: true 21 | 22 | field :post, Post, null: false 23 | 24 | def subscribe(id:) 25 | {post: POSTS.find { |post| post[:id] == id }} 26 | end 27 | 28 | def update(*) 29 | {post: object} 30 | end 31 | end 32 | 33 | class SubscriptionType < GraphQL::Schema::Object 34 | field :product_created, Product, null: false, resolver_method: :default_resolver 35 | field :product_updated, Product, null: false, resolver_method: :default_resolver 36 | field :post_updated, subscription: PostUpdated 37 | 38 | def default_resolver 39 | return object if context.query.subscription_update? 40 | 41 | context.skip 42 | end 43 | end 44 | 45 | class AnycableSchema < GraphQL::Schema 46 | use GraphQL::AnyCable 47 | 48 | subscription SubscriptionType 49 | add_subscription_extension_if_necessary 50 | end 51 | 52 | module Broadcastable 53 | class PostCreated < GraphQL::Schema::Subscription 54 | payload_type Post 55 | end 56 | 57 | class PostUpdated < GraphQL::Schema::Subscription 58 | argument :id, ID, required: true 59 | 60 | field :post, Post, null: false 61 | 62 | def subscribe(id:) 63 | {post: POSTS.find { |post| post[:id] == id }} 64 | end 65 | 66 | def update(*) 67 | {post: object} 68 | end 69 | end 70 | 71 | class SubscriptionType < GraphQL::Schema::Object 72 | field :post_created, subscription: PostCreated 73 | field :post_updated, subscription: PostUpdated 74 | end 75 | end 76 | 77 | class BroadcastSchema < GraphQL::Schema 78 | use GraphQL::AnyCable, broadcast: true, default_broadcastable: true 79 | 80 | subscription Broadcastable::SubscriptionType 81 | add_subscription_extension_if_necessary 82 | end 83 | --------------------------------------------------------------------------------