Skip to content

Instantly share code, notes, and snippets.

@coderanger
Last active August 29, 2015 14:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save coderanger/0e7116e656f6f6acfa18 to your computer and use it in GitHub Desktop.
Save coderanger/0e7116e656f6f6acfa18 to your computer and use it in GitHub Desktop.
What properties could look like.
#
# Copyright 2015, Noah Kantrowitz
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
require 'chef/resource'
module Poise
module Next
# Data validation utilities in a class.
class Validator
# Create a Validator.
#
# @param checks [Hash<Symbol, Object>] Validation checks to use.
def initialize(checks)
@checks = checks
end
# Check if a value matches the configured checks.
#
# @param value [Object] Value to check.
# @return [Boolean]
def validate(value)
_validate(value).empty?
end
# Check if a value matches the configured checks, and if not raise a
# ValidationFailed exception
#
# @param value [Object] Value to check.
# @param label [String] Descriptive label for the raised error message.
# @raises Chef::Exceptions::ValidationFailed
# @return [void]
def validate!(value, label: "Value #{value.inspect}")
failures = _validate(value)
unless failures.empty?
raise Chef::Exceptions::ValidationFailed.new("#{label} #{failures.values.join(', ')}.")
end
end
private
# Dispatch logic for data validation.
def _validate(value)
@checks.inject({}) do |memo, (type, check_value)|
unless send("_#{type}_validate", check_value, value)
memo[type] = send("_#{type}_message", check_value, value)
end
memo
end
end
# Implementation for the :is check.
def _is_validate(check, value)
Array(check).all? do |obj|
if obj.is_a?(Class)
value.is_a?(obj)
else
value === obj
end
end
end
# Error message for the :is check.
def _is_message(check, value)
"must match #{Array(check).map(&:inspect).join(', ')}"
end
# TODO: Add more validation types here.
end
# Data model for a single property on a resource class.
class Property
# Create a new property object. This is an extension point to possibly
# return a subclass of Property.
def self.create(*args, &block)
new(*args, &block)
end
# Create a new property object.
#
# @param name [Symbol] Name of the property.
# @param options [Array] An array of property options. Hashes will be
# used as key/value options, non-hashes will be used as the value for
# an :is data validator.
# @option options [Object] default Default value for the property.
# @option options [Proc] coerce Proc to coerce new values.
def initialize(name, *options)
@name = name
@options = options.inject(Mash.new) do |memo, option|
if option.is_a?(Hash)
memo.update(option)
else
memo[:is] ||= []
memo[:is] << option
end
memo
end
if @options.include?(:coerce)
@coerce = @options.delete(:coerce)
end
if @options.include?(:default)
@default = @options.delete(:default)
@default.freeze
end
@validator = Validator.new(@options)
@validator.validate!(@default, label: "Default value #{@default.inspect}") if defined?(@default) && !@default.is_a?(Chef::DelayedEvaluator)
end
# Create a bound version of the property.
#
# @param resource [Chef::Resource] Resource to bind to.
# @return [BoundProperty]
def bind(resource)
BoundProperty.new(self, resource)
end
# Retrieve the value of this property for a given resource.
#
# @param resource [Chef::Resource] Resource to get the value from.
# @return [Object]
def get(resource)
if set?(resource)
# We have a previously set value, use it.
resource.instance_variable_get(:"@#{@name}")
elsif defined?(@default)
# We have a default, compute the actual value of the default.
default(resource)
else
# The default default is nil.
nil
end
end
# Set the value of this property for a given resource.
#
# @param resource [Chef::Resource] Resource to set the value for.
# @param value [Object] Value to set.
# @return [Object]
def set!(resource, value)
value = coerce(resource, value)
@validator.validate!(value)
resource.instance_variable_set(:"@#{@name}", value)
end
# Get or set the value of this property. If no parameter is given, it
# acts like a get, if a parameter is give it acts like a set.
#
# @param resource [Chef::Resource] Resource to get or get the value for.
# @return [Object]
def get_or_set!(resource, *args)
if args.empty?
# This is a get.
get(resource)
else
# This is a set.
set!(resource, *args)
end
end
# Compute the default value of this property for a given resource.
#
# @param resource [Chef::Resource] Resource to get the default value for.
# @return [Object]
def default(resource)
value = if @default.is_a?(Chef::DelayedEvaluator)
# Check the sticky value on the resource, then evaluate the block.
ivar_key = :"@default_#{@name}"
if resource.instance_variable_defined?(ivar_key)
resource.instance_variable_get(ivar_key)
else
resource.instance_variable_set(ivar_key, resource.instance_exec(&@default))
end
else
@default
end
# This calls the coerce every time, which could be avoided on non-lazy
# defaults.
coerce(resource, value)
end
# Check if this property was set by the user for a given resource. This
# can be false even with data set if it was only set via a mutable
# default value.
#
# @param resource [Chef::Resource] Resource to check the value on.
# @return [Boolean]
def set?(resource)
resource.instance_variable_defined?(:"@#{@name}")
end
# Reset the value of this property to its default for a given resource.
#
# @param resource [Chef::Resource] Resource to reset the value for.
# @return [void]
def reset!(resource)
resource.remove_instance_variable(:"@#{@name}")
end
# Coerce a value for this property for a given resource.
#
# @param resource [Chef::Resource] Resource to coerce the value for.
# @param value [Object] Value to coerce.
# @return [Object]
def coerce(resource, value)
if @coerce
resource.instance_exec(value, &@coerce)
else
value
end
end
end
# A proxy for a Property object bound to a single resource instance. This
# will fill in that resource as the first parameter to all methods as they
# are proxied.
class BoundProperty
# Create a BoundProperty for given property and resource instances.
#
# @see Property#bind
# @param property [Property] Property to proxy to.
# @param resource [Chef::Resource] Resource to bind to.
# @return [BoundProperty]
def initialize(property, resource)
@property = property
@resource = resource
end
def method_missing(method, *args, &block)
@property.public_send(method, @resource, *args, &block)
end
def respond_to_missing?(method)
@property.respond_to?(method)
end
end
# A mapping of properies on a single resource class. This checks parent
# classes if the property is not defined in the current class.
class Properties < Hash
def initialize(resource_class)
super() do |hash, key|
# We could improve performance by caching this hash, but we would
# need to agree that reopening a resource class to add a new property
# requires manually clearing the cache on any subclasses or add code
# to do something similar.
resource_class.local_properties[key] || if resource_class.superclass && resource_class.superclass.respond_to?(:properties)
resource_class.superclass.properties[key]
end
end
end
end
# A mapping of bound properties on a single resource instance. This is
# cached so changes to the ancestor classes after the resource instance is
# created could have wonky behavior.
class BoundProperties < Hash
def initialize(resource)
super() do |hash, key|
hash[key] = resource.class.properties[key].bind(resource)
end
end
end
# Resource subclass to inject the property system.
class Resource < Chef::Resource
# Storage for properties within this class.
def self.local_properties
@local_properties ||= Mash.new
end
# Mapping of all properties including ancestor classes.
def self.properties
@properties ||= Properties.new(self)
end
# Define a new property on this class.
def self.property(name, *args, &block)
# Create and register the property object.
local_properties[name] = Property.create(name, *args, &block)
# Create a proxy method for the get_or_set.
define_method(name) do |*inner_args, &inner_block|
properties[name].get_or_set!(*inner_args, &inner_block)
end
# Create a proxy method for the set.
define_method("#{name}=") do |*inner_args, &inner_block|
properties[name].set!(*inner_args, &inner_block)
end
end
# Because this normally lives on LWRPBase and I want it for testing.
def self.lazy(&block)
Chef::DelayedEvaluator.new(&block)
end
# Mapping of all bound properties on this resource instance.
def properties
@properties ||= BoundProperties.new(self)
end
end
end
end
class TestResource < Poise::Next::Resource
property(:path, String)
property(:owner, String, default: 'root')
property(:keys, default: lazy { Hash.new })
property(:level, coerce: proc {|value| value.to_s }, default: :one)
end
r1 = TestResource.new('one', nil)
r1.path('/foo')
puts r1.path # => /foo
r1.keys[:one] = 1
r1.keys[:two] = 2
puts r1.keys # => {:one => 1, :two => 2}
puts r1.properties[:keys].set? # => false
puts r1.level.inspect # => "one"
r1.level = :two
puts r1.level.inspect # => "two"
puts r1.owner # => root
r1.path(12) # Value 12 must match String. (Chef::Exceptions::ValidationFailed)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment