Skip to content

Instantly share code, notes, and snippets.

@cthornton
Created April 1, 2014 16:44
Show Gist options
  • Save cthornton/9918020 to your computer and use it in GitHub Desktop.
Save cthornton/9918020 to your computer and use it in GitHub Desktop.
Exposed Variables
# Creates an interface to safely expose internal methods and variables to be used by some sort of templating system.
#
# Say for example, we want to send users an email when they register, and we want companies to be able to modify how
# the messages appear to their end users. One solution is to allow evaling in the email templates:
#
# ```
# Hello #{@user.fullname}! Welcome to the application! ...
# ```
#
# This is clearly a security risk as it allows users to enter malicious code. Another solution can be to just gsub
# everything:
#
# ```
# email_template.gsub!('$name', @user.fullname).gsub!('$email', @user.email)...
# ```
#
# However, this can become difficult to maintain, and unclean if this logic is used in many places. As a solution, the
# ExposedVariables module defines an interface to expose model data to the external world.
#
# ## Implementing an Exposed Variable Class
# Say for example, our {User} class. We could then define our variables to expose to the outside world. Note that
# the variables must be methods:
#
# ```ruby
# class User < ActiveRecord::Base
# include ExposedVariables
# exposed_variables :id, :username, :email, :display_name
# end
# ```
#
# Next, we can define some sort of email template:
#
# ```
# Hello {$user.display_name}! Your username is {$user.username} and your email is {$user.email}.
# {$user.this_will_never_appear}
# ```
#
# Finally, we can then parse the email template:
#
# ```ruby
# @user = User.find 123
# string = ExposedVariables.parse_template email_template, @user
# ```
#
# ## Multiple Objects
# You can use multiple objects at the same time. Say for example, the {Account} class:
#
# ```ruby
# class Account < ActiveRecord::Base
# include ExposedVariables
# expose_variables :name
# end
# ```
#
# Now an email template:
#
# ```
# Welcome {$user.display_name}! You are part of the {$account.name} account!
# ```
#
# Finally, our ruby code:
# ```ruby
# @user = User.find 123
# string = ExposedVariables.parse_template email_template, @user, @user.account
# ```
#
# ## Nested Variables
# There is support for object-oriented style nested variables. Take, for example:
# class User < ActiveRecord::Base
# include ExposedVariables
# exposed_variables :id, :username, :email, :display_name, :account
# end
# ```
#
# Since the `:account` relation implements ExposedVariables, we can now chain variables like so:
#
# ```
# Welcome {$user.display_name}! You are part of the {$user.account.name} account!
# ```
#
# Resulting in our ruby code:
#
# ```ruby
# @user = User.find 123
# string = ExposedVariables.parse_template email_template, @user
# ```
#
#
module ExposedVariables
# A regex that matches the complete part of a variable
EXPOSED_VARIABLE_COMPLETE_REGEX = /\{\$[a-zA-Z0-9\._]+\}/
module ClassMethods
# Sets or gets a namespace for the current class for exposed variables. A namespace is how we match objects.
# For example, `$user.username`, `user` is the namespace. This allows you to change the namespace. Defaults to
# downcased version of the current class name (i.e. `User` -> `user`).
# @param [String,Symbol,nil] namespace the namespace to set. If nil, does not set the namespace.
# @return [String] the current namespace
# @example
# User.exposed_variable_namespace # => 'user'
# class User < ActiveRecord::Base
# include ExposedVariables
# exposed_variable_namespace :cool_user
# exposed_variables :id, :username, :email, :display_name
# end
#
# @user = User.first
# ExposedVariables.parse_template "Hello {$cool_user.display_name}", @user
def exposed_variable_namespace(namespace = nil)
@_exposed_variable_namespace = namespace.to_s if namespace.present?
return @_exposed_variable_namespace ||= name.underscore
end
# Defines a whitelist of which variables this class may use, and returns the list of current variables
# @return [Array<String>] an array of variables
def expose_variables(*variables)
(@exposed_variables ||= Array.new).concat variables.map(&:to_s) if variables.any?
return @exposed_variables
end
alias_method :exposed_variables, :expose_variables
end
# For the current object instance, returns a hash containing variables.
# @return [HashWithIndifferentAccess] a hash of exposed variables (see example)
# @example Class Definition
# class User < ActiveRecord::Base
# include ExposedVariables
# exposed_variables :id, :email, :account
# end
# @example Usage
# User.find(123).exposed_variables # => {'id' => '123', 'example@example.com', 'account' => AccountObject }
def exposed_variables
results = HashWithIndifferentAccess.new
self.class.exposed_variables.each do |var|
object = send var
object = ExposedVariables.parse_exposed_object object
results[var] = object
end
return results
end
# Takes a given variable name and returns a String or null value. This also handles nested variable definitions.
# @param [String,Symbol] variable the variable name to parse
# @return [String,nil] a String if `variable` is valid, nil if it is invalid.
# @example Class Definition
# class User < ActiveRecord::Base
# include ExposedVariables
# exposed_variables :id, :email, :account
# end
# @example Usage
# @user = User.find 123
# @user.exposed_variable_for :id # => '123'
# @user.exposed_variable_for :email # => 'example@example.com'
# @user.exposed_variable_for 'does-not-exist' # => nil
# @user.exposed_variable_for 'account.name' # => 'Some Account Name'
# @user.exposed_variable_for 'account' # => nil
# @note This does *not* take into account the namespace of the class
def exposed_variable_for(variable)
return nil if variable.blank?
nesting = variable.to_s.split '.'
variables = exposed_variables
object = nil
nesting.each do |item|
object = variables[item]
if object.is_a?(ExposedVariables)
variables = object.exposed_variables
elsif object.is_a?(Hash)
variables = object
break if (variables.nil? or !variables.is_a? Hash)
else
break
end
end
return object.is_a?(String) ? object : nil # Return nil if we are still at non-string object (i.e. 'user.account')
end
# Given a variable *with* a namespace, attempt to return a value from a pool of possible objects.
# @param [String] variable the variable to parse. **Must have a namespace!**
# @param [Array<ExposedVariables>] possible_objects a pool of possible objects to pick from
# @return [String,nil] a String if a variable was parsed, nil if it was not possible to parse the variable
# @raise [ArgumentError] if one of `possible_objects` is not an instance of ExposedVariables
# @example
# @user = User.first
# @account = Account.first
# ExposedVariables.parse_exposed_variable 'user.name', @user, @account # => 'Joe Smith'
# ExposedVariables.parse_exposed_variable 'account.name', @user, @account # => 'ACME Co'
# ExposedVariables.parse_exposed_variable 'name', @user, @account # => nil
# ExposedVariables.parse_exposed_variable 'dne', @user, @account # => nil
def self.parse_exposed_variable(variable, *possible_objects)
namespace,variable = variable.to_s.split '.', 2 # Expects 'namespace.variable'
return nil if variable.nil? # Case of 'namespace' and no variable
possible_objects.each do |object|
raise ArgumentError, 'Possible object does not include ExposedVariables' unless object.is_a?(ExposedVariables)
return object.exposed_variable_for(variable) if object.class.exposed_variable_namespace == namespace
end
return nil
end
# See class documentation on usage.
def self.parse_template(template, *possible_objects)
template = template.dup
# Find all matches that include variables
template.scan(EXPOSED_VARIABLE_COMPLETE_REGEX).uniq.each do |match|
variable = match[2..-2] # {$hello.world} => hello.world
parsed = parse_exposed_variable(variable, *possible_objects)
parsed = yield variable, parsed if block_given? and !parsed.nil?
template.gsub! match, (parsed || match)
end
return template
end
def self.parse_exposed_object(object)
return object if object.is_a? Hash
return object if object.is_a? ExposedVariables
return object.to_s
end
def self.included(base)
base.extend ClassMethods
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment