Created
April 1, 2014 16:44
-
-
Save cthornton/9918020 to your computer and use it in GitHub Desktop.
Exposed Variables
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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', '[email protected]', '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 # => '[email protected]' | |
# @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