module Eco module API class Session class Batch # Helper class to build a hiearchical model of policies # @example Usage: # class PolicyModel < Eco::API::Session::Batch::BasePolicy # MODEL = {attr1: ["prop1a", "prop1b", {"prop1c": ["prop1c1"]}]} # self.model = MODEL # policy_attrs *model_attrs # end # # policies = PolicyModel.new("batch_policy") # policies.attr1c do |attr1c| # attr1c.prop1c1.max = 30 # end # # @attr_reader attr [Symbol] the Symbol `name` of the current policy # @attr_reader max [Integer] `max` **allowed** number of occurrences of the property # @attr_reader min [Integer] `min` **required** number of occurrences of the property class BasePolicy extend Eco::API::Common::ClassHelpers # @attr_reader model [Hash, nil] the `model` of the current `class` class << self attr_reader :model # @param value [Hash, Enumerable, String, Symbol, nil] unparsed model to be assigned to the `class` def model=(value) @model = parse_model(value) end # @return [Array] the `keys` of the current class' `model` def model_attrs (model && model.keys) || [] end # Helper to normalize `key` into a correct `ruby` **constant name** # @param key [String, Symbol] to be normalized # @return [String] a correct constant name def titleize(key) str_name = key.to_s.strip.split(/[\-\_ ]/i).compact.map do |str| str.slice(0).upcase + str.slice(1..-1).downcase end.join("") end # If the class for `key` exists, it returns it. Otherwise it generates it. # @note for this to work, `key` should be one of the submodels of the current class' `model` # @return [Eco::API::Session::Batch::BasePolicy] or subclass thereof def policy_class(key) key = key.to_sym.freeze class_name = titleize(key) full_class_name = "#{self}::#{class_name}" unless target_class = resolve_class(full_class_name, exception: false) submodel = model[key] target_class = Class.new(self) do |klass| klass.model = submodel policy_attrs *klass.model_attrs end self.const_set class_name, target_class end target_class end # Thanks to this step the format on the declaration of the model is flexible # @param value [Hash, Enumerable, String, Symbol, nil] # @return [Hash, nil] where keys are `Symbol` s def parse_model(model) case model when String return parse_model(model.to_sym) when Symbol return {model => nil} when Hash return model.each_with_object({}) do |(k,v), hash| hash[k.to_sym] = v end when Enumerable return model.each_with_object({}) do |sub, hash| hash.merge!(parse_model(sub)) end when NilClass return nil else raise "Incorrect model declaration, allowed String, Symbol, Hash and Enumerable. Given: #{model.class}" end end # Attributes of this level of the model that should be included # @param attr [Symbol, String] each of the subpolicies of the model that should be available def policy_attrs(*attrs) attrs = attrs.map(&:to_sym) attrs.each do |attr| method = attr.to_s.freeze var = "@#{method}".freeze define_method(method) do |&block| unless policy = self[attr] klass = self.class.policy_class(attr) policy = self[attr] = klass.new(attr, _parent: self) end if block block.call(policy) self else policy end end end end end include Enumerable attr_reader :attr attr_accessor :max, :min def initialize(attr = nil, _parent: self) @_parent = _parent @attr = attr.to_sym @policies = {} end def attr(as_namespace: false) return @attr if !as_namespace || root? "#{@_parent.attr(as_namespace: true)}:#{@attr}" end # @note if there's no `min` defined, it always returns `true` # @param value [Integer] value to check if it's in the minimum required # @retrun [Boolen] `true` if `value` is grater or equal to `min` def min?(value) !min || !value|| (min <= value) end # @note if there's no `max` defined, it always returns `true` # @param value [Integer] value to check if it's in the maximum allowed # @retrun [Boolen] `true` if `value` is lesser or equal to `min` def max?(value) !max || !value || (max >= value) end # return [Integer] number of declared subpolicies def length count end # @return [Boolean] `true` if there are no active subpolicies, `false` otherwise def empty? count == 0 end # @return [Boolean] `true` if there are active subpolicies, `false` otherwise def subpolicies? !empty? end def each(params: {}, &block) return to_enum(:each) unless block items.each(&block) end # @return [Array] the active subpolicies def items @policies.values end # @param attr [Symbol, String] name of the policy # @return [Boolean] if `attr` is an active subpolicy def active?(attr) @policies.key?(attr.to_sym) end # @param attr [Symbol, String] name of the policy # @return [Array] the used subpolicies def [](attr) @policies[attr.to_sym] end # @param attr [Symbol, String] name of the policy # @param value [Expected object of Eco::API::Session::Batch::BasePolicy] a subpolicy to assign to a name `attr` def []=(attr, value) raise "Expected object of Eco::API::Session::Batch::BasePolicy. Given #{value.class}" unless value.is_a?(Eco::API::Session::Batch::BasePolicy) @policies[attr.to_sym] = value end # @param model [Hash] plain hash (or hashable object) with the stats to check policy compliance against # @param recurse [Boolean] to determine if we only check the current policy or also all active subpolicies # @return [Boolean] `true` if `model` is compliant with the current policy def compliant?(model, recurse: true) unless hash = model_to_hash(model) raise "Expected 'model' to be a Hash (or hashable) object. Given: #{model}" end value = model_attr(hash) good = !model_attr?(hash) || (min?(value) && max?(value)) #pp "batch_policy: '#{attr}' - #{value}: 'min' #{min?(value)}; 'max' #{max?(value)}" good &&= all? {|active| active.compliant?(model, recurse: recurse)} if recurse good end def validate!(model) unless compliant?(model) msg = self.uncompliance(model) raise "Uncompliance Exception\n#{msg}" end end # @param model [Hash] plain hash (or hashable object) with the stats to check policy compliance against # @return [Array] **non-compliant** policies for the `model` def uncompliant(model) each_with_object([]) do |active, arr| arr.concat(active.uncompliant(model)) end.tap do |arr| arr.unshift(self) unless compliant?(model, recurse: false) end end # @param model [Hash] plain hash (or hashable object) with the stats to check policy compliance against # @param recurse [Boolean] to determine if we only check the current policy or also all active subpolicies # @return [String] message with what failed to meet compliance def uncompliance(model, recurse: true) unless hash = model_to_hash(model) raise "Expected 'model' to be a Hash (or hashable) object. Given: #{model}" end msg = "" unless compliant?(hash, recurse: false) value = model_attr(hash) msg += "'#{attr(as_namespace: true)}' fails to meet: " msg += " [ min(#{min}) >= #{value}] " unless min?(value) msg += " [ max(#{max}) <= #{value}] " unless max?(value) msg += "\n" end if recurse map do |active| active.uncompliance(hash, recurse: true) end.compact.tap do |msgs| msg += "\n" + msgs.join("\n") unless msgs.empty? end end msg end protected # Internal helper to know if we are at the top/root of the hierarchical model # @return [Boolean] `true` if this object is the top `root`, `false` otherwise def root? @_parent == self end def to_h @policies end def model_attr?(hash) hash.key?(self.attr) || hash.key?(self.attr.to_s) end def model_attr(hash) hash[self.attr] || hash[self.attr.to_s] if model_attr?(hash) end private def model_to_hash(model) return model if model.is_a?(Hash) model.to_h if model.respond_to?(:to_h) end end end end end end