hyperstack icon indicating copy to clipboard operation
hyperstack copied to clipboard

expose_as_operations method

Open catmando opened this issue 4 years ago • 0 comments

This is a nice standalone module that adds two class level methods to ActiveRecord Models, that will expose server side methods to the client as operations.

Unlike server_methods, methods exposed using expose_as_operation run as server operations. The method is asynchronously executed on the server, and the client gets a promise that resolves when the method completes.

This is much more useful than server_methods if the you are trying to trigger some server side operation in an event handler. The downside is that unlike server_methods there will be no automatic client re-render when the operation returns.

So the rule is: If its returning data for the user - use a server_method. If its triggering an operation on the server then expose it as an operation.

Add the ExposeAsOperation module to hyperstack/models, and then include the module in ApplicationRecord:

# hyperstack/models/application_record.rb 
class ApplicationRecord < ActiveRecord::Base 
  ...
  include ExposeAsOperations
  ...
end

Now to expose a method as an operation say this in your model defintion:

# hyperstack/models/my_model.rb
class MyModel < ApplicationRecord 
   def foo(value)
      ... do something on the server ...
    end 
    ...
    expose_as_operation(:foo)
end

somewhere on the client (usually in an event handler) you can say

  my_model_instance.foo(12)

Note that foo returns a promise so you can add a .then clause to do anything you need to when the operation completes:

  my_model_instance.foo(12).then { |ret_value| mutate @foo_ret_value = ret_value }

The expose_as_operation method can take a block that is used to protect the method from illegal access. Like other policy regulations, the block is executed with self equal to the model instance, and the acting_user attribute of self set to the current acting user, so you can say for example:

  expose_as_operation(:admins_only) { acting_user.admin? }

Returning a falsy value, or raising an error will abort the method call, and return an access violation error to the client.

You can expose multiple methods at once:

  expose_as_operations(:foo, :bar, :baz) { ... common regulation ... }

Typically the exposed methods will either be in a separate file visible on the server, or will be surrounded by an unless RUBY_ENGINE == 'opal' guard.

class MyModel < ApplicationRecord 
  include MyModelServerDefinitions unless RUBY_ENGINE == 'opal'  # defines meth1, and meth2 plus others...
  expose_as_operation(:meth1, :meth2)
end

We may eventually include this code in Hyperstack, but for now its easy enough to add the file to your models directory:

# hyperstack/models/expose_as_operations.rb
# frozen_string_literal: true

# ExposeAsOperations adds the following methods to ActiveRecord models
#   expose_as_operations(list of method names) { security block }
#   each of the method names will now be callable from the client
#   as an ServerOp.  In otherwords on the client the method will
#   return a promise that will resolve (or reject) when the method
#   completes execution on the server.
#
#   The optional security block will be called before the method is invoked
#   and is passed the method name, and the arguments.  Within the block
#   self is the active record instance, and will have the current value
#   of acting_user.  If the security block returns a falsy value or
#   raises an error, the operation will not be executed, and
#   the operation will fail with a Hyperstack::AccessViolation error.
#
#   For example
#     expose_as_operations(:set_brightness, :toggle_switch) do
#       users.includes? acting_user
#     end
#
#  For readability you can also use expose_as_operation for a single method.
#
#  Caveat:  The record must be saved.  I.e. you cannot execute the methods
#  from the client on unsaved records.
#
module ExposeAsOperations
  def self.included(base)
    base.extend ClassMethods
  end

  # each class calling exposing_as_operation will create its subclass
  # of the Base operation.  Each subclass of Base has its own internal
  # list of exposed methods, with an associated security block.
  # The Base operation simply validates the operation using the security
  # block, and then calls the method on the parent class.
  class Base < Hyperstack::ServerOp
    param :acting_user, nils: true
    param :id
    param :method
    param :args

    unless RUBY_ENGINE == 'opal'
      # Abort with a nice message unless we can find the record using the id.
      validate :insure_record_exists

      # Call the security block to validate the method is callable,
      validate :with_security_block

      # and convert any failures above to AccessViolations.
      failed { raise Hyperstack::AccessViolation }

      # If we get here then we have a valid record, and we are allowed to call
      # the method. Any errors in the method will get sent back to the client.
      step { @record.send(params.method, *params.args) }

      def insure_record_exists
        @record =
          self.class.parent.find_by(self.class.parent.primary_key => params.id)
        return true if @record

        add_error(
          :id, :not_found, "#{self.class.parent} id: #{params.id} not found."
        )
        # abort so we skip the conversion of failures to access violations.
        abort!
      end

      def with_security_block
        @record.acting_user = params.acting_user
        @record.instance_exec(
          params.method.to_sym,
          *params.args,
          &self.class.security_blocks[params.method.to_sym]
        )
      end

      # list of security blocks indexed by the method name
      def self.security_blocks
        @security_blocks ||= {}
      end
    end
  end

  # define the two class methods
  module ClassMethods
    def expose_as_operations(*methods, &security_block)
      methods.each { |method| add_security_block(method, security_block) }
    end

    alias expose_as_operation expose_as_operations

    private

    # the runner method insures that each class that uses expose_as_operations
    # has its own subclass of the Base operation, which we call MethodRunner
    def runner
      return self::MethodRunner if defined? self::MethodRunner

      const_set 'MethodRunner', Class.new(Base)
    end

    if RUBY_ENGINE == 'opal'
      # on the client we dont really add a security block, but instead
      # just make sure that the MethodRunner API is defined and then
      # define the method.  The method first makes sure that the id of
      # model is loaded, and then calls the MethodRunner's run method
      def add_security_block(method, _security_block)
        runner # insure runner is defined
        define_method(method) do |*args|
          Hyperstack::Model.load { id }.then do |id|
            self.class::MethodRunner.run(id: id, method: method, args: args)
          end
        end
      end
    else
      # on the server we add the security block for the method.  If no
      # security block is provided we create a proc that will return true.
      def add_security_block(method, security_block)
        runner.security_blocks[method] = security_block || ->(*) { true }
      end
    end
  end
end

catmando avatar Feb 24 '21 04:02 catmando