From 173e606f0e096be2a285880144ddc7b2f5bad6c5 Mon Sep 17 00:00:00 2001 From: "Gregory N. Schmit" Date: Wed, 7 Dec 2022 01:49:30 -0600 Subject: [PATCH 1/2] Initial work on building websockets API from rest controllers. --- .gitignore | 1 + Gemfile | 8 ++-- Gemfile.lock | 13 ++++++- lib/rest_framework/controller_mixins/base.rb | 38 +++++++++---------- .../controller_mixins/models.rb | 13 ++++--- lib/rest_framework/serializers.rb | 2 +- test/Procfile | 3 ++ .../app/channels/application_cable/channel.rb | 4 ++ .../channels/application_cable/connection.rb | 9 +++++ test/app/channels/test_channel.rb | 9 +++++ .../controllers/demo_api/things_controller.rb | 32 ++++++++++++++++ test/config/application.rb | 3 ++ test/config/cable.yml | 12 ++++++ 13 files changed, 115 insertions(+), 32 deletions(-) create mode 100644 test/Procfile create mode 100644 test/app/channels/application_cable/channel.rb create mode 100644 test/app/channels/application_cable/connection.rb create mode 100644 test/app/channels/test_channel.rb create mode 100644 test/config/cable.yml diff --git a/.gitignore b/.gitignore index 55c49e8..debb5e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ VERSION coverage +dump.rdb # Created by https://www.toptal.com/developers/gitignore/api/ruby,rails,macos,windows,linux,vim,emacs # Edit at https://www.toptal.com/developers/gitignore?templates=ruby,rails,macos,windows,linux,vim,emacs diff --git a/Gemfile b/Gemfile index ee51326..c53dfa4 100644 --- a/Gemfile +++ b/Gemfile @@ -10,11 +10,8 @@ RAILS_VERSION = Gem::Version.new( ) gem "rails", "~> #{RAILS_VERSION}" gem "rake", ">= 12.0" - -# Ruby 3 removed webrick, so we need to install it manually. -if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3") - gem "webrick" -end +gem "puma" +gem "redis" # Include gem active_model_serializers so we can test against their interface (Rails >=6 only). if RAILS_VERSION > Gem::Version.new("6") @@ -25,6 +22,7 @@ group :development do gem "better_errors" gem "binding_of_caller" gem "byebug" + gem "foreman" gem "pry-rails" gem "rubocop-shopify", require: false gem "web-console" diff --git a/Gemfile.lock b/Gemfile.lock index 84ae1ff..a5feef9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -91,6 +91,7 @@ GEM activesupport coderay (1.1.3) concurrent-ruby (1.1.10) + connection_pool (2.3.0) coveralls (0.8.23) json (>= 1.8, < 3) simplecov (~> 0.16.1) @@ -101,6 +102,7 @@ GEM debug_inspector (1.1.0) docile (1.4.0) erubi (1.11.0) + foreman (0.87.2) globalid (1.0.0) activesupport (>= 5.0) i18n (1.12.0) @@ -138,6 +140,8 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) + puma (6.0.0) + nio4r (~> 2.0) racc (1.6.0) rack (2.2.4) rack-test (2.0.2) @@ -170,6 +174,10 @@ GEM zeitwerk (~> 2.5) rainbow (3.1.1) rake (13.0.6) + redis (5.0.5) + redis-client (>= 0.9.0) + redis-client (0.11.2) + connection_pool regexp_parser (2.6.0) rexml (3.2.5) rubocop (1.38.0) @@ -208,7 +216,6 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webrick (1.7.0) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -223,16 +230,18 @@ DEPENDENCIES binding_of_caller byebug coveralls + foreman minitest (>= 5.0) pg pry-rails + puma rails (~> 7.0.4) rake (>= 12.0) + redis rest_framework! rubocop-shopify sqlite3 (~> 1.4.0) web-console - webrick BUNDLED WITH 2.2.33 diff --git a/lib/rest_framework/controller_mixins/base.rb b/lib/rest_framework/controller_mixins/base.rb index 1477f07..d3c23d6 100644 --- a/lib/rest_framework/controller_mixins/base.rb +++ b/lib/rest_framework/controller_mixins/base.rb @@ -26,6 +26,24 @@ def get_skip_actions(skip_undefined: true) return skip end + + # Get the configured serializer class. + def get_serializer_class + return nil unless serializer_class = self.serializer_class + + # Support dynamically resolving serializer given a symbol or string. + serializer_class = serializer_class.to_s if serializer_class.is_a?(Symbol) + if serializer_class.is_a?(String) + serializer_class = self.const_get(serializer_class) + end + + # Wrap it with an adapter if it's an active_model_serializer. + if defined?(ActiveModel::Serializer) && (serializer_class < ActiveModel::Serializer) + serializer_class = RESTFramework::ActiveModelSerializerAdapterFactory.for(serializer_class) + end + + return serializer_class + end end def self.included(base) @@ -92,27 +110,9 @@ def self.included(base) end end - # Helper to get the configured serializer class. - def get_serializer_class - return nil unless serializer_class = self.class.serializer_class - - # Support dynamically resolving serializer given a symbol or string. - serializer_class = serializer_class.to_s if serializer_class.is_a?(Symbol) - if serializer_class.is_a?(String) - serializer_class = self.class.const_get(serializer_class) - end - - # Wrap it with an adapter if it's an active_model_serializer. - if defined?(ActiveModel::Serializer) && (serializer_class < ActiveModel::Serializer) - serializer_class = RESTFramework::ActiveModelSerializerAdapterFactory.for(serializer_class) - end - - return serializer_class - end - # Helper to serialize data using the `serializer_class`. def serialize(data, **kwargs) - return self.get_serializer_class.new(data, controller: self, **kwargs).serialize + return self.class.get_serializer_class.new(data, controller: self, **kwargs).serialize end # Helper to get filtering backends, defaulting to no backends. diff --git a/lib/rest_framework/controller_mixins/models.rb b/lib/rest_framework/controller_mixins/models.rb index 5ecb4cd..fe55f7a 100644 --- a/lib/rest_framework/controller_mixins/models.rb +++ b/lib/rest_framework/controller_mixins/models.rb @@ -5,9 +5,17 @@ module RESTFramework::BaseModelControllerMixin include RESTFramework::BaseControllerMixin + module ClassMethods + # Get the configured serializer class, or `NativeSerializer` as a default. + def get_serializer_class + return super || RESTFramework::NativeSerializer + end + end + def self.included(base) if base.is_a?(Class) RESTFramework::BaseControllerMixin.included(base) + base.extend(ClassMethods) # Add class attributes (with defaults) unless they already exist. { @@ -109,11 +117,6 @@ def get_allowed_parameters ) || self.fields end - # Helper to get the configured serializer class, or `NativeSerializer` as a default. - def get_serializer_class - return super || RESTFramework::NativeSerializer - end - # Helper to get filtering backends, defaulting to using `ModelFilter` and `ModelOrderingFilter`. def get_filter_backends return self.class.filter_backends || [ diff --git a/lib/rest_framework/serializers.rb b/lib/rest_framework/serializers.rb index 4e63c28..a8ec477 100644 --- a/lib/rest_framework/serializers.rb +++ b/lib/rest_framework/serializers.rb @@ -248,7 +248,7 @@ def _serialize(record, config, serializer_methods) ) end - def serialize(*args) + def serialize(*_args) config = self.get_serializer_config serializer_methods = config.delete(:serializer_methods) diff --git a/test/Procfile b/test/Procfile new file mode 100644 index 0000000..f230ec2 --- /dev/null +++ b/test/Procfile @@ -0,0 +1,3 @@ +# This Procfile is for running the test app on development systems. +rails: bin/rails server +redis: redis-server diff --git a/test/app/channels/application_cable/channel.rb b/test/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/test/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/test/app/channels/application_cable/connection.rb b/test/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..288f223 --- /dev/null +++ b/test/app/channels/application_cable/connection.rb @@ -0,0 +1,9 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :random_token + + def connect + self.random_token = SecureRandom.hex(4) + end + end +end diff --git a/test/app/channels/test_channel.rb b/test/app/channels/test_channel.rb new file mode 100644 index 0000000..9a91d82 --- /dev/null +++ b/test/app/channels/test_channel.rb @@ -0,0 +1,9 @@ +class TestChannel < ApplicationCable::Channel + def subscribed + # stream_from "some_channel" + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + end +end diff --git a/test/app/controllers/demo_api/things_controller.rb b/test/app/controllers/demo_api/things_controller.rb index 555e403..8b3d746 100644 --- a/test/app/controllers/demo_api/things_controller.rb +++ b/test/app/controllers/demo_api/things_controller.rb @@ -13,4 +13,36 @@ def toggle_is_discounted thing.update!(is_discounted: !thing.is_discounted) return api_response({message: "Is discounted toggled to #{thing.is_discounted}."}) end + + class Channel < ApplicationCable::Channel + # Subscribe with: + # {"command": "subscribe","identifier": "{\"channel\": \"DemoApi::ThingsController::Channel\"}"} + def subscribed + logger.info("GNS: subscribed") + stream_from("demo_api/things") + end + + def index(_data) + controller = self._controller.new + controller.request = ActionDispatch::Request.new({}) + controller.request.format + controller.index + ActionCable.server.broadcast( + "demo_api/things", + self._controller.get_serializer_class.new(self._controller.get_recordset).serialize, + ) + end + + def _controller + return self.class.to_s.deconstantize.constantize + end + end + + this_controller = self + Thing.after_commit do + ActionCable.server.broadcast( + "demo_api/things", + this_controller.get_serializer_class.new(self).serialize, + ) + end end diff --git a/test/config/application.rb b/test/config/application.rb index a5b0ed1..53dbc67 100644 --- a/test/config/application.rb +++ b/test/config/application.rb @@ -8,6 +8,7 @@ "action_controller/railtie", "action_view/railtie", "active_job/railtie", + "action_cable/engine", "rails/test_unit/railtie", ].each do |railtie| require railtie @@ -34,6 +35,8 @@ class Application < Rails::Application config.cache_classes = false config.action_controller.perform_caching = false + config.action_cable.disable_request_forgery_protection = true + if Rails::VERSION::MAJOR >= 7 config.active_support.remove_deprecated_time_with_zone_name = true end diff --git a/test/config/cable.yml b/test/config/cable.yml new file mode 100644 index 0000000..262ee3c --- /dev/null +++ b/test/config/cable.yml @@ -0,0 +1,12 @@ +development: + adapter: redis + url: redis://localhost:6379 + channel_prefix: rails_rest_framework_development + +test: + adapter: test + +production: + adapter: redis + url: redis://localhost:6379 + channel_prefix: rails_rest_framework_production From 38a91a399341ed927e62b9edc892a087f542643b Mon Sep 17 00:00:00 2001 From: "Gregory N. Schmit" Date: Mon, 19 Dec 2022 11:16:57 -0600 Subject: [PATCH 2/2] more websockets demo/testing --- .../controller_mixins/channels.rb | 18 ++++++++++++++++ .../controllers/demo_api/things_controller.rb | 21 +++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 lib/rest_framework/controller_mixins/channels.rb diff --git a/lib/rest_framework/controller_mixins/channels.rb b/lib/rest_framework/controller_mixins/channels.rb new file mode 100644 index 0000000..6c80da2 --- /dev/null +++ b/lib/rest_framework/controller_mixins/channels.rb @@ -0,0 +1,18 @@ +require_relative "models" + +# This module extends the base REST controller to support Action Cable Channels. If the controller +# is associated to a specific model, then we can even support broadcasting table/record updates. +# +# The general idea is to dynamically construct a namespaced `::Channel` class to implement the +# channel functionality. +# +# For controllers associated to models, we will support 2 types of updates: +# - Table Updates: Table updates will notify the subscriber when ANY record of a table is modified, +# whether it's a create, update, or delete. The notification will not include details on the +# specific record or records affected. +# - Record Updates: Record updates will notify the subscriber when a record is created, updated, or +# deleted, and will include the entire record and the action that was performed. +# TODO: this must be constrained to the records in `get_recordset`. +# +module RESTFramework::ChannelControllerMixin +end diff --git a/test/app/controllers/demo_api/things_controller.rb b/test/app/controllers/demo_api/things_controller.rb index 8b3d746..2bcb7c9 100644 --- a/test/app/controllers/demo_api/things_controller.rb +++ b/test/app/controllers/demo_api/things_controller.rb @@ -39,10 +39,19 @@ def _controller end this_controller = self - Thing.after_commit do - ActionCable.server.broadcast( - "demo_api/things", - this_controller.get_serializer_class.new(self).serialize, - ) - end + Thing.define_method(:bcast) { + if self.in?(controller.get_recordset) + ActionCable.server.broadcast( + "demo_api/things", + this_controller.get_serializer_class.new(self).serialize, + ) + end + } + Thing.after_commit(:bcast) + # Thing.after_commit do + # ActionCable.server.broadcast( + # "demo_api/things", + # this_controller.get_serializer_class.new(self).serialize, + # ) + # end end