require "active_model" module ApiRegulator module AttributeDefinitionMixin extend ActiveSupport::Concern included do class_attribute :defined_attributes, default: [] class_attribute :nested_validators, default: {} end class_methods do def define_attribute_and_validations(param, parent_key = nil) # Construct the full key full_key = parent_key ? "#{parent_key}.#{param.name}".to_sym : param.name.to_sym case param.type when :array define_array_validations(param, full_key) when :object define_object_validations(param, full_key) else define_scalar_validations(param, full_key) end end def define_scalar_validations(param, full_key) # Define scalar attributes attribute full_key, param.type if param.type self.defined_attributes += [full_key] param.options.each do |option, value| validates full_key, option => value, allow_blank: !param.required? end # Add type-specific validations validate -> { validate_boolean(full_key) } if param.type == :boolean validate -> { validate_integer(full_key) } if param.type == :integer validate -> { validate_string(full_key) } if param.type == :string end def define_object_validations(param, full_key) # Build nested validator class nested_validator_class = build_nested_validator_class(param.children, param.name, self) # Add a custom validation for the nested object validate -> { validate_nested_object(full_key, nested_validator_class, param) } # Store the nested validator nested_validators[full_key] = nested_validator_class end def define_array_validations(param, full_key) if param.children.any? # Build a nested validator class for array items item_validator_class = build_nested_validator_class(param.children, param.name, self) validate -> { validate_array_of_objects(full_key, item_validator_class, param) } # Store the nested validator nested_validators[full_key] = [item_validator_class] elsif param.item_type # Scalar array with specific item type validate -> { validate_array_of_scalars(full_key, param) } nested_validators[full_key] = :scalars else raise "Arrays must have children or an item_type" end end def build_nested_validator_class(children, parent_key, parent_class) # Create a unique class name based on the parent key class_name = "#{parent_key.to_s.camelize}" # Return the existing class if already defined if parent_class.const_defined?(class_name, false) return parent_class.const_get(class_name) end # Create the nested validator class nested_validator_class = Class.new do include ActiveModel::Model include ActiveModel::Attributes include AttributeDefinitionMixin def initialize(attributes = {}) @raw_attributes = attributes allowed_attributes = attributes.slice(*self.class.defined_attributes.map(&:to_sym)) super(allowed_attributes) end # Add child attributes and validations children.each do |child| define_attribute_and_validations(child) end end # Store the nested class under the parent class namespace parent_class.const_set(class_name, nested_validator_class) nested_validator_class end end # Instance methods def validate_array_of_objects(attribute, item_validator_class, param) raw_value = @raw_attributes[attribute] # Ensure the presence check works only on the array itself, not its contents if raw_value.blank? errors.add(attribute, "can't be blank") if param.options[:presence] return end unless raw_value.is_a?(Array) errors.add(attribute, "must be an array") return end raw_value.each_with_index do |value, index| unless value.is_a?(Hash) || value.is_a?(ActionController::Parameters) errors.add("#{attribute}[#{index}]", "must be a hash") next end validator = item_validator_class.new(value) unless validator.valid? validator.errors.each do |error| nested_attr = "#{attribute}[#{index}].#{error.attribute}" errors.add(nested_attr, error.message) end end end end def validate_array_of_scalars(attribute, param) raw_value = @raw_attributes[attribute] if raw_value.nil? errors.add(attribute, "can't be blank") if param.options[:presence] return end unless raw_value.is_a?(Array) errors.add(attribute, "must be an array") return end inclusion_list = param.options[:inclusion]&.dig(:in) raw_value.each_with_index do |value, index| # Skip type and inclusion validation if nil is allowed and value is nil if value.nil? && (inclusion_list&.include?(nil) || param.options[:allow_nil_items]) next end # Type validation for non-nil values case param.item_type when :string unless value.is_a?(String) errors.add("#{attribute}[#{index}]", "must be a string") next end when :integer unless value.is_a?(Integer) errors.add("#{attribute}[#{index}]", "must be an integer") next end when :boolean unless [true, false].include?(value) errors.add("#{attribute}[#{index}]", "must be a boolean") next end else errors.add("#{attribute}[#{index}]", "has an unsupported type") next end # Inclusion validation for non-nil values if inclusion_list && !inclusion_list.include?(value) errors.add("#{attribute}[#{index}]", "is not included in the list") end end end def validate_nested_object(attribute, nested_validator_class, param) raw_value = @raw_attributes[attribute] # Skip validation if optional and not present return if raw_value.nil? && param.options[:optional] if raw_value.nil? errors.add(attribute, "can't be blank") if param.options[:presence] return end unless raw_value.is_a?(Hash) || raw_value.is_a?(ActionController::Parameters) errors.add(attribute, "must be a hash") return end # Initialize the nested validator with the raw value validator = nested_validator_class.new(raw_value) # If validation fails, propagate errors unless validator.valid? validator.errors.each do |error| nested_attr = "#{attribute}.#{error.attribute}" errors.add(nested_attr, error.message) end end end def validate_boolean(attribute) raw_value = @raw_attributes[attribute] unless [true, false, "true", "false", nil].include?(raw_value) errors.add(attribute, "must be a boolean (true, false, or nil)") end end def validate_integer(attribute) raw_value = @raw_attributes[attribute] unless raw_value.to_s =~ /\A[-+]?\d+\z/ || raw_value.nil? errors.add(attribute, "must be an integer") end end def validate_string(attribute) raw_value = @raw_attributes[attribute] unless raw_value.is_a?(String) || raw_value.nil? errors.add(attribute, "must be a string") end end end class Validator include ActiveModel::Model include ActiveModel::Attributes include AttributeDefinitionMixin @validators = {} def self.build_all(api_definitions) api_definitions.each do |api_definition| class_name = "#{api_definition.controller_path}/#{api_definition.action_name}".gsub("/", "_").camelcase validator_class = build_class(api_definition.params) @validators[[api_definition.controller_path, api_definition.action_name]] = validator_class Validator.const_set(class_name, validator_class) end end def self.build_response_validators(api_definitions = ApiRegulator.api_definitions) api_definitions.each do |api_definition| api_definition.responses.each do |code, params| class_name = "#{api_definition.controller_path}/#{api_definition.action_name}/Response#{code}".gsub("/", "_").camelcase validator_class = build_class(params.children) @validators[[api_definition.controller_path, api_definition.action_name, code]] = validator_class Validator.const_set(class_name, validator_class) end end end def self.get(controller, action, code = nil) @validators[[controller.to_s, action.to_s, code].compact] end def self.build_class(params) Class.new do include ActiveModel::Model include ActiveModel::Attributes include AttributeDefinitionMixin def initialize(attributes = {}) @raw_attributes = attributes.deep_symbolize_keys self.class.defined_attributes allowed_attributes = attributes.slice(*self.class.defined_attributes.map(&:to_sym)) super(allowed_attributes) end params.each do |param| define_attribute_and_validations(param) end end end def self.validate_response(controller, action, code, body) validator_class = get(controller, action, code) unless validator_class raise "No validator found" end validator = validator_class.new(body) unless validator.valid? raise ApiRegulator::ValidationError.new(validator.errors) end end def self.reset_validators @validators = {} end end end