Last active
August 29, 2015 14:23
-
-
Save coderanger/0e7116e656f6f6acfa18 to your computer and use it in GitHub Desktop.
What properties could look like.
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
# | |
# 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