Skip to content

expose_as_operations method #359

Open
@catmando

Description

@catmando

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestneeds docEverything is working, but documentation is needed.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions