Description
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