From d54aa5639ef2c9acd19375d77658882b7286c767 Mon Sep 17 00:00:00 2001 From: Nathan Lucas Date: Sun, 14 Dec 2025 15:40:11 -0500 Subject: [PATCH 1/7] GASKIT-8: Alpha of v0.4.0, implementing Cattri backend --- .github/workflows/main.yml | 3 + CHANGELOG.md | 5 + README.md | 16 +-- castkit.gemspec | 2 + lib/castkit/attribute.rb | 23 ++-- lib/castkit/castkit.rb | 1 + lib/castkit/configuration.rb | 73 ++++------ lib/castkit/contract/base.rb | 21 ++- lib/castkit/contract/result.rb | 16 ++- lib/castkit/core/attribute_types.rb | 4 +- lib/castkit/core/attributes.rb | 74 ++++++---- lib/castkit/core/config.rb | 36 +++-- lib/castkit/data_object.rb | 11 +- lib/castkit/dsl/data_object.rb | 4 + .../dsl/data_object/deserialization.rb | 19 ++- lib/castkit/dsl/data_object/introspection.rb | 52 +++++++ lib/castkit/dsl/data_object/serialization.rb | 5 +- lib/castkit/error.rb | 12 +- lib/castkit/plugins.rb | 15 ++- lib/castkit/serializers/base.rb | 13 +- lib/castkit/serializers/default_serializer.rb | 20 +-- .../validators/collection_validator.rb | 4 +- lib/castkit/version.rb | 2 +- spec/castkit/attribute_spec.rb | 43 ++++++ spec/castkit/attributes/definition_spec.rb | 12 ++ spec/castkit/attributes/options_spec.rb | 105 +++++++++++++++ spec/castkit/contract/validator_spec.rb | 31 +++++ spec/castkit/contract_spec.rb | 49 +++++++ spec/castkit/core/attribute_types_spec.rb | 125 ++++++++--------- spec/castkit/core/attributes_spec.rb | 38 ++++++ spec/castkit/core/config_spec.rb | 5 + spec/castkit/data_object_spec.rb | 127 ++++++++++++++++++ spec/castkit/default_serializer_spec.rb | 14 ++ spec/castkit/dsl/data_object/contract_spec.rb | 40 ++++++ spec/castkit/dsl/data_object/plugins_spec.rb | 78 +++++++++++ .../dsl/data_object/serialization_spec.rb | 29 ++++ .../ext/data_object/deserialization_spec.rb | 16 +++ spec/castkit/ext/data_object/plugins_spec.rb | 48 +++++++ spec/castkit/plugins_spec.rb | 36 ++++- spec/castkit/types/base_spec.rb | 101 +++++++++++--- spec/castkit/types/primitives_spec.rb | 53 ++++++++ spec/castkit/validator_spec.rb | 22 +++ spec/castkit/validators/additional_spec.rb | 75 +++++++++++ spec/castkit_spec.rb | 24 ++++ spec/configuration_spec.rb | 17 +++ spec/simplecov_helper.rb | 17 ++- spec/spec_helper.rb | 2 +- 47 files changed, 1292 insertions(+), 246 deletions(-) create mode 100644 lib/castkit/dsl/data_object/introspection.rb create mode 100644 spec/castkit/attributes/definition_spec.rb create mode 100644 spec/castkit/attributes/options_spec.rb create mode 100644 spec/castkit/dsl/data_object/contract_spec.rb create mode 100644 spec/castkit/dsl/data_object/plugins_spec.rb create mode 100644 spec/castkit/dsl/data_object/serialization_spec.rb create mode 100644 spec/castkit/ext/data_object/plugins_spec.rb create mode 100644 spec/castkit/types/primitives_spec.rb create mode 100644 spec/castkit/validator_spec.rb create mode 100644 spec/castkit/validators/additional_spec.rb diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 10ce0fd..91ae645 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,6 +15,9 @@ jobs: matrix: ruby: - '3.1.4' + - '3.2.9' + - '3.3.10' + - '3.4.7' steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0eaab..5cd11f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## [Unreleased] +# v0.4.0 + +- refactored Castkit internals to build on Cattri registries for attributes, configuration, contracts, and attribute definitions +- added Cattri-powered introspection helpers, serialization toggle behavior and enhanced type/validator DSLs, plus coverage for the new plugin helpers +- introduced SimpleCov HTML/Cobertura reporting, raised the minimum coverage target to 100%, and grated 100% line coverage across the core library (Ruby 3.1 limits still block rspec via SimpleCov/json incompatibility) ## [0.3.0] - 2025-04-16 ### Added diff --git a/README.md b/README.md index f87c51f..f8823a1 100644 --- a/README.md +++ b/README.md @@ -60,14 +60,14 @@ Castkit comes with built-in support for primitive types and allows registration ```ruby { - array: Castkit::Types::Collection, - boolean: Castkit::Types::Boolean, - date: Castkit::Types::Date, - datetime: Castkit::Types::DateTime, - float: Castkit::Types::Float, - hash: Castkit::Types::Base, - integer: Castkit::Types::Integer, - string: Castkit::Types::String + array: Castkit::Types::Collection, + boolean: Castkit::Types::Boolean, + date: Castkit::Types::Date, + datetime: Castkit::Types::DateTime, + float: Castkit::Types::Float, + hash: Castkit::Types::Base, + integer: Castkit::Types::Integer, + string: Castkit::Types::String } ``` diff --git a/castkit.gemspec b/castkit.gemspec index 97ca975..39b00c0 100644 --- a/castkit.gemspec +++ b/castkit.gemspec @@ -33,6 +33,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] # Runtime dependencies + spec.add_dependency "cattri", ">=0.2.3" spec.add_dependency "thor" # Development dependencies @@ -40,5 +41,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rubocop" spec.add_development_dependency "simplecov" spec.add_development_dependency "simplecov-cobertura" + spec.add_development_dependency "simplecov-html" spec.add_development_dependency "yard" end diff --git a/lib/castkit/attribute.rb b/lib/castkit/attribute.rb index df10cf1..b5cf3de 100644 --- a/lib/castkit/attribute.rb +++ b/lib/castkit/attribute.rb @@ -23,6 +23,11 @@ module Castkit # @see Castkit::DSL::Attribute::Validation class Attribute include Castkit::DSL::Attribute + include Cattri + + cattri :field, nil, expose: :read, final: true + cattri :type, nil, expose: :read, final: true + cattri :options, nil, expose: :read, final: true class << self # Defines a reusable attribute definition via a DSL wrapper. @@ -71,15 +76,6 @@ def process_type(type) end end - # @return [Symbol] the attribute name - attr_reader :field - - # @return [Symbol, Class, Array] the declared or normalized type - attr_reader :type - - # @return [Hash] full option hash, including merged defaults - attr_reader :options - # Initializes a new attribute definition. # # @param field [Symbol] the attribute name @@ -87,10 +83,13 @@ def process_type(type) # @param default [Object, Proc, nil] optional static or callable default # @param options [Hash] additional attribute options def initialize(field, type, default: nil, **options) - @field = field - @type = self.class.normalize_type(type) + super() + + cattri_variable_set(:field, field, final: true) + cattri_variable_set(:type, self.class.normalize_type(type), final: true) + @default = default - @options = populate_options(options) + cattri_variable_set(:options, populate_options(options), final: true) validate! end diff --git a/lib/castkit/castkit.rb b/lib/castkit/castkit.rb index 80ee35d..1c705fd 100644 --- a/lib/castkit/castkit.rb +++ b/lib/castkit/castkit.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "cattri" require_relative "core/attribute_types" # Castkit is a lightweight, type-safe data object system for Ruby. diff --git a/lib/castkit/configuration.rb b/lib/castkit/configuration.rb index 651f6cb..3ac0bd3 100644 --- a/lib/castkit/configuration.rb +++ b/lib/castkit/configuration.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "types" +require "cattri" module Castkit # Configuration container for global Castkit settings. @@ -8,6 +9,8 @@ module Castkit # This includes type registration, validation, and enforcement flags # used throughout Castkit's attribute system. class Configuration + include Cattri + # Default mapping of primitive type definitions. # # @return [Hash{Symbol => Castkit::Types::Base}] @@ -36,56 +39,30 @@ class Configuration uuid: :string }.freeze - # @return [Hash{Symbol => Castkit::Types::Base}] registered types - attr_reader :types - - # Set default plugins that will be used globally in all Castkit::DataObject subclasses. - # This is equivalent to calling `enable_plugins` in every class. - # - # @return [Array] default plugin names to be applied to all DataObject subclasses - attr_accessor :default_plugins - - # Whether to raise an error if values should be validated before deserializing, e.g. true -> "true" - # @return [Boolean] - attr_accessor :enforce_typing - - # Whether to raise an error if access mode is not recognized. - # @return [Boolean] - attr_accessor :enforce_attribute_access - - # Whether to raise an error if a prefix is defined without `unwrapped: true`. - # @return [Boolean] - attr_accessor :enforce_unwrapped_prefix - - # Whether to raise an error if an array attribute is missing the `of:` type. - # @return [Boolean] - attr_accessor :enforce_array_options - - # Whether to raise an error for unknown and invalid type definitions. - # @return [Boolean] - attr_accessor :raise_type_errors - - # Whether to emit warnings when Castkit detects misconfigurations. - # @return [Boolean] - attr_accessor :enable_warnings - - # Whether the strict flag is enabled by default for all DataObjects and Contracts. - # @return [Boolean] - attr_accessor :strict_by_default + cattri :types, -> { DEFAULT_TYPES.dup }, expose: :read_write + cattri :default_plugins, [], expose: :read_write + cattri :enforce_typing, true, expose: :read_write + cattri :enforce_attribute_access, true, expose: :read_write + cattri :enforce_unwrapped_prefix, true, expose: :read_write + cattri :enforce_array_options, true, expose: :read_write + cattri :raise_type_errors, true, expose: :read_write + cattri :enable_warnings, true, expose: :read_write + cattri :strict_by_default, true, expose: :read_write # Initializes the configuration with default types and enforcement flags. # # @return [void] def initialize - @types = DEFAULT_TYPES.dup - @enforce_typing = true - @enforce_attribute_access = true - @enforce_unwrapped_prefix = true - @enforce_array_options = true - @raise_type_errors = true - @enable_warnings = true - @strict_by_default = true - @default_plugins = [] + super + self.types = DEFAULT_TYPES.dup + self.enforce_typing = true + self.enforce_attribute_access = true + self.enforce_unwrapped_prefix = true + self.enforce_array_options = true + self.raise_type_errors = true + self.enable_warnings = true + self.strict_by_default = true + self.default_plugins = [] apply_type_aliases! end @@ -136,7 +113,7 @@ def register_plugin(name, plugin) # @return [Castkit::Types::Base] # @raise [Castkit::TypeError] if the type is not registered def fetch_type(type) - @types.fetch(type.to_sym) do + types.fetch(type.to_sym) do raise Castkit::TypeError, "Unknown type `#{type.inspect}`" if raise_type_errors end end @@ -146,14 +123,14 @@ def fetch_type(type) # @param type [Symbol] # @return [Boolean] def type_registered?(type) - @types.key?(type.to_sym) + types.key?(type.to_sym) end # Restores the type registry to its default state. # # @return [void] def reset_types! - @types = DEFAULT_TYPES.dup + self.types = DEFAULT_TYPES.dup apply_type_aliases! end diff --git a/lib/castkit/contract/base.rb b/lib/castkit/contract/base.rb index 50a1780..2806223 100644 --- a/lib/castkit/contract/base.rb +++ b/lib/castkit/contract/base.rb @@ -38,6 +38,8 @@ class Base ].freeze class << self + include Cattri + # Defines an attribute for the contract. # # Only a subset of options is allowed inside a contract. @@ -71,15 +73,9 @@ def validate!(input) Castkit::Contract::Result.new(definition[:name].to_s, input) end - # Returns internal contract metadata. - # - # @return [Hash] - def definition - @definition ||= { - name: :ephemeral, - attributes: {} - } - end + cattri :definition, + -> { { name: :ephemeral, attributes: {} } }, + scope: :class, expose: :read_write # Returns the defined attributes. # @@ -117,8 +113,8 @@ def define(name = :ephemeral, source = nil, validation_rules: {}, &block) def define_from_source(name, source) source_attributes = source.attributes.dup - @definition = { - name: name, + self.definition = { + name: name.to_sym, attributes: source_attributes.transform_values do |attr| Castkit::Attribute.new(attr.field, attr.type, **attr.options.slice(*ATTRIBUTE_OPTIONS)) end @@ -131,7 +127,8 @@ def define_from_source(name, source) # @yield [block] # @return [void] def define_from_block(name, &block) - definition[:name] = name + contract_name = name ? name.to_sym : :ephemeral + self.definition = { name: contract_name, attributes: {} } @__castkit_contract_dsl = true instance_eval(&block) diff --git a/lib/castkit/contract/result.rb b/lib/castkit/contract/result.rb index 765e589..965cd52 100644 --- a/lib/castkit/contract/result.rb +++ b/lib/castkit/contract/result.rb @@ -7,14 +7,16 @@ module Contract # Provides access to the validation outcome, including whether it succeeded or failed, # and includes the full list of errors if any. class Result + include Cattri + # @return [Symbol] the name of the contract - attr_reader :contract + cattri :contract, nil, expose: :read # @return [Hash{Symbol => Object}] the validated input - attr_reader :input + cattri :input, nil, expose: :read # @return [Hash{Symbol => Object}] the validation error hash - attr_reader :errors + cattri :errors, {}, expose: :read # Initializes a new result object. # @@ -22,9 +24,11 @@ class Result # @param input [Hash{Symbol => Object}] the validated input # @param errors [Hash{Symbol => Object}] the validation errors def initialize(contract, input, errors: {}) - @contract = contract.to_sym.freeze - @input = input.freeze - @errors = errors.freeze + super() + + cattri_variable_set(:contract, contract.to_sym.freeze) + cattri_variable_set(:input, input.freeze) + cattri_variable_set(:errors, errors.freeze) end # A debug-friendly representation of the validation result. diff --git a/lib/castkit/core/attribute_types.rb b/lib/castkit/core/attribute_types.rb index f6eaf51..49457e3 100644 --- a/lib/castkit/core/attribute_types.rb +++ b/lib/castkit/core/attribute_types.rb @@ -86,7 +86,9 @@ def array(field, **options) # # @param field [Symbol] # @param options [Hash] - def hash(field, **options) + def hash(field = nil, **options) + return super() if field.nil? + attribute(field, :hash, **options) end diff --git a/lib/castkit/core/attributes.rb b/lib/castkit/core/attributes.rb index be8e853..e4e6ded 100644 --- a/lib/castkit/core/attributes.rb +++ b/lib/castkit/core/attributes.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "cattri" + module Castkit module Core # Provides DSL and implementation for declaring attributes within a Castkit::DataObject. @@ -9,7 +11,11 @@ module Core # # This module is included into `Castkit::DataObject` and handles attribute registration, # accessor generation, and typed writing behavior. - module Attributes + module Attributes # rubocop:disable Metrics/ModuleLength + def self.extended(base) + base.include(Cattri) + end + # Declares an attribute on the data object. # # Accepts either inline options or a reusable attribute definition (`using` or `definition`). @@ -29,7 +35,7 @@ def attribute(field, type = nil, definition = nil, using: nil, **options) type, options = use_definition(field, definition || using&.definition, type, options) return define_attribute(field, type, **options) unless options[:transient] - attr_accessor field + define_transient_accessor(field) end # Declares a composite (computed) attribute. @@ -97,14 +103,14 @@ def optional(**options, &block) # # @return [Hash{Symbol => Castkit::Attribute}] def attributes - @attributes ||= {} + cattri_variable_memoize(:__castkit_attributes_registry) { {} } end def inherited(subclass) super - parent_attributes = instance_variable_get(:@attributes) - subclass.instance_variable_set(:@attributes, parent_attributes.dup) if parent_attributes + parent_attributes = cattri_variable_get(:__castkit_attributes_registry) + subclass.cattri_variable_set(:__castkit_attributes_registry, parent_attributes.dup) if parent_attributes end # Alias for {#attribute} @@ -152,31 +158,27 @@ def define_attribute(field, type, **options) attribute = Castkit::Attribute.new(field, type, **options) attributes[field] = attribute - if attribute.full_access? - attr_reader field - - define_typed_writer(field, attribute) - elsif attribute.writeable? - define_typed_writer(field, attribute) - elsif attribute.readable? - attr_reader field - end + define_accessors(attribute) end - # Defines a writer method that enforces type coercion. + # Creates readers/writers for a defined attribute using Cattri. # - # @param field [Symbol] # @param attribute [Castkit::Attribute] # @return [void] - def define_typed_writer(field, attribute) - define_method("#{field}=") do |value| - deserialized_value = Castkit.type_caster(attribute.type.to_sym).call( - value, - options: attribute.options, - context: attribute.field - ) - - instance_variable_set("@#{field}", deserialized_value) + def define_accessors(attribute) + expose = exposure_for(attribute) + return if expose == :none + + if attribute.writeable? + cattri(attribute.field, nil, expose: expose) do |value| + Castkit.type_caster(attribute.type.to_sym).call( + value, + options: attribute.options, + context: attribute.field + ) + end + else + cattri(attribute.field, nil, expose: expose) end end @@ -189,6 +191,7 @@ def define_typed_writer(field, attribute) def with_access(access, options = {}, &block) @__access_context = access @__block_options = options + instance_eval(&block) ensure @__access_context = nil @@ -204,6 +207,7 @@ def with_access(access, options = {}, &block) def with_required(flag, options = {}, &block) @__required_context = flag @__block_options = options + instance_eval(&block) ensure @__required_context = nil @@ -222,6 +226,26 @@ def build_options(options) base.merge(options) end + + # Maps Castkit access flags onto Cattri's expose option. + # + # @param attribute [Castkit::Attribute] + # @return [Symbol] + def exposure_for(attribute) + return :read_write if attribute.full_access? + return :write if attribute.writeable? + return :read if attribute.readable? + + :none + end + + # Defines read/write accessors for transient attributes. + # + # @param field [Symbol] + # @return [void] + def define_transient_accessor(field) + cattri(field, nil, expose: :read_write) + end end end end diff --git a/lib/castkit/core/config.rb b/lib/castkit/core/config.rb index ad1fbb6..b5dca11 100644 --- a/lib/castkit/core/config.rb +++ b/lib/castkit/core/config.rb @@ -5,6 +5,17 @@ module Core # Provides per-class configuration for a Castkit::DataObject, # including root key handling, strict mode, and unknown key behavior. module Config + def self.extended(base) + super + + base.include(Cattri) unless base.is_a?(Class) && base < Cattri + return unless base.respond_to?(:cattri) + + base.cattri :strict_flag, nil, scope: :class, expose: :read_write + base.cattri :warn_on_unknown_flag, nil, scope: :class, expose: :read_write + base.cattri :allow_unknown_flag, nil, scope: :class, expose: :read_write + end + # Sets or retrieves strict mode behavior. # # In strict mode, unknown keys during deserialization raise errors. If unset, falls back @@ -13,11 +24,9 @@ module Config # @param value [Boolean, nil] # @return [Boolean] def strict(value = nil) - if value.nil? - @strict.nil? ? Castkit.configuration.strict_by_default : @strict - else - @strict = !!value - end + return (strict_flag.nil? ? Castkit.configuration.strict_by_default : strict_flag) if value.nil? + + self.strict_flag = !!value end # Enables or disables ignoring unknown keys during deserialization. @@ -27,7 +36,7 @@ def strict(value = nil) # @param value [Boolean] # @return [void] def ignore_unknown(value = nil) - @strict = !value + self.strict_flag = !value end # Sets or retrieves whether to emit warnings when unknown keys are encountered. @@ -35,7 +44,7 @@ def ignore_unknown(value = nil) # @param value [Boolean, nil] # @return [Boolean, nil] def warn_on_unknown(value = nil) - value.nil? ? @warn_on_unknown : (@warn_on_unknown = value) + value.nil? ? warn_on_unknown_flag : (self.warn_on_unknown_flag = value) end # Sets or retrieves whether to allow unknown keys when they are encountered. @@ -43,7 +52,7 @@ def warn_on_unknown(value = nil) # @param value [Boolean, nil] # @return [Boolean, nil] def allow_unknown(value = nil) - value.nil? ? @allow_unknown : (@allow_unknown = value) + value.nil? ? allow_unknown_flag : (self.allow_unknown_flag = value) end # Returns a relaxed version of the current class with strict mode off. @@ -63,11 +72,12 @@ def relaxed(warn_on_unknown: true) # # @return [Hash{Symbol => Boolean}] def validation_rules - @validation_rules ||= { - strict: strict, - allow_unknown: allow_unknown, - warn_on_unknown: warn_on_unknown - } + @validation_rules ||= {} + @validation_rules[:strict] = strict + @validation_rules[:allow_unknown] = allow_unknown + @validation_rules[:warn_on_unknown] = warn_on_unknown + + @validation_rules end end end diff --git a/lib/castkit/data_object.rb b/lib/castkit/data_object.rb index 9de7f6c..f871f7b 100644 --- a/lib/castkit/data_object.rb +++ b/lib/castkit/data_object.rb @@ -76,20 +76,23 @@ def dump(obj) end # @return [Hash{Symbol => Object}] The raw data provided during instantiation. - attr_reader :__raw + cattri :__raw, nil, expose: :read # @return [Hash{Symbol => Object}] Undefined attributes provided during instantiation. - attr_reader :unknown_attributes + cattri :unknown_attributes, nil, expose: :read # Initializes the DTO from a hash of attributes. # # @param data [Hash] raw input hash # @raise [Castkit::DataObjectError] if strict mode is enabled and unknown keys are present def initialize(data = {}) - @__raw = data.dup.freeze + super() + + cattri_variable_set(:__raw, data.dup.freeze) data = unwrap_root(data) - @unknown_attributes = data.reject { |key, _| self.class.attributes.key?(key.to_sym) }.freeze + cattri_variable_set(:unknown_attributes, + data.reject { |key, _| self.class.attributes.key?(key.to_sym) }.freeze) validate_data!(data) deserialize_attributes!(data) diff --git a/lib/castkit/dsl/data_object.rb b/lib/castkit/dsl/data_object.rb index 0da57b7..4a94bdf 100644 --- a/lib/castkit/dsl/data_object.rb +++ b/lib/castkit/dsl/data_object.rb @@ -7,6 +7,7 @@ require_relative "data_object/plugins" require_relative "data_object/serialization" require_relative "data_object/deserialization" +require_relative "data_object/introspection" module Castkit module DSL @@ -47,11 +48,14 @@ module DataObject # @param base [Class] the including class # @return [void] def self.included(base) + base.include(Cattri) + base.extend(Castkit::Core::Config) base.extend(Castkit::Core::Attributes) base.extend(Castkit::Core::AttributeTypes) base.extend(Castkit::DSL::DataObject::Contract) base.extend(Castkit::DSL::DataObject::Plugins) + base.extend(Castkit::DSL::DataObject::Introspection) base.include(Castkit::DSL::DataObject::Serialization) base.include(Castkit::DSL::DataObject::Deserialization) diff --git a/lib/castkit/dsl/data_object/deserialization.rb b/lib/castkit/dsl/data_object/deserialization.rb index e0a3f82..c6216d2 100644 --- a/lib/castkit/dsl/data_object/deserialization.rb +++ b/lib/castkit/dsl/data_object/deserialization.rb @@ -49,7 +49,7 @@ def deserialize_attributes!(input) next if value.nil? && attribute.optional? value = deserialize_attribute_value!(attribute, value) - instance_variable_set("@#{attribute.field}", value) + assign_attribute_value!(attribute, value) end end @@ -115,6 +115,23 @@ def resolve_input_value(input, attribute) nil end + # Stores a deserialized value using Cattri's internal store when available. + # + # @param attribute [Castkit::Attribute] + # @param value [Object] + # @return [void] + def assign_attribute_value!(attribute, value) + if respond_to?(:cattri_variable_set, true) + cattri_variable_set( + attribute.field, + value, + final: attribute.options[:final] + ) + else + instance_variable_set("@#{attribute.field}", value) + end + end + # Resolves root-wrapped and unwrapped data. # # @param data [Hash] diff --git a/lib/castkit/dsl/data_object/introspection.rb b/lib/castkit/dsl/data_object/introspection.rb new file mode 100644 index 0000000..82acee3 --- /dev/null +++ b/lib/castkit/dsl/data_object/introspection.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Castkit + module DSL + module DataObject + # Provides opt-in attribute introspection for data objects using Cattri's registry + # without overriding Castkit's attribute DSL. + module Introspection + # Enables introspection helpers on the including class. + # + # @return [void] + def enable_cattri_introspection! + extend IntrospectionHelpers + + @cattri_attribute_registry = nil + end + + # Class-level helpers that read from Cattri's attribute registry but do not + # override Castkit's attribute builder. + module IntrospectionHelpers + def attribute_defined?(name) + !!cattri_attribute(name) + end + + def attribute_definitions(with_ancestors: false) + cattri_attribute_registry.defined_attributes(with_ancestors: with_ancestors) + end + + def attribute_methods + cattri_attribute_registry.defined_attributes(with_ancestors: true).transform_values do |attribute| + Set.new(attribute.allowed_methods) + end + end + + def attribute_source(name) + cattri_attribute(name)&.defined_in + end + + private + + def cattri_attribute_registry + @cattri_attribute_registry ||= attribute_registry + end + + def cattri_attribute(name) + cattri_attribute_registry.defined_attributes(with_ancestors: true)[name.to_sym] + end + end + end + end + end +end diff --git a/lib/castkit/dsl/data_object/serialization.rb b/lib/castkit/dsl/data_object/serialization.rb index a155104..0a4adf3 100644 --- a/lib/castkit/dsl/data_object/serialization.rb +++ b/lib/castkit/dsl/data_object/serialization.rb @@ -38,7 +38,10 @@ def ignore_nil(value = nil) # @param value [Boolean, nil] # @return [Boolean] def ignore_blank(value = nil) - @ignore_blank = value.nil? || value + return (@ignore_blank = true) if value.nil? && !defined?(@ignore_blank) + return @ignore_blank if value.nil? + + @ignore_blank = value end end diff --git a/lib/castkit/error.rb b/lib/castkit/error.rb index 298ff6e..a600c17 100644 --- a/lib/castkit/error.rb +++ b/lib/castkit/error.rb @@ -3,8 +3,10 @@ module Castkit # Base error class for all Castkit-related exceptions. class Error < StandardError + include Cattri + # @return [Hash, Object, nil] contextual data to aid in debugging - attr_reader :context + cattri :context, nil, expose: :read # Initializes a Castkit error. # @@ -12,7 +14,8 @@ class Error < StandardError # @param context [Object, String, nil] optional data object or hash for context def initialize(msg, context: nil) super(msg) - @context = context + + cattri_variable_set(:context, context, final: true) end end @@ -44,11 +47,12 @@ class SerializationError < Error; end # Raised during contract validation. class ContractError < Error - attr_reader :errors + cattri :errors, {}, expose: :read def initialize(msg, context: nil, errors: nil) super(msg, context: context) - @errors = errors || {} + + cattri_variable_set(:errors, errors || {}) end end end diff --git a/lib/castkit/plugins.rb b/lib/castkit/plugins.rb index 6d00b33..6ed1320 100644 --- a/lib/castkit/plugins.rb +++ b/lib/castkit/plugins.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "cattri" + module Castkit # Internal registry for Castkit plugin modules. # @@ -19,9 +21,16 @@ module Castkit # enable_plugins :custom, :oj # end module Plugins - @registered_plugins = {} + include Cattri class << self + include Cattri + extend Cattri::Dsl + extend Cattri::ClassMethods + extend Cattri::Visibility + + cattri :registered_plugins, {}, expose: :read_write + # Activates one or more plugins on the given class. # # Each plugin module is included into the class. If the module responds to `setup!`, @@ -58,7 +67,7 @@ def deactivate(_klass, *names) # @return [Module] the plugin module # @raise [Castkit::Error] if no plugin is found def lookup!(name) - @registered_plugins[name.to_sym] || + registered_plugins[name.to_sym] || const_get(Castkit::Inflector.pascalize(name.to_s), false) rescue NameError raise Castkit::Error, @@ -75,7 +84,7 @@ def lookup!(name) # @param plugin [Module] the plugin module to register # @return [void] def register(name, plugin) - @registered_plugins[name] = plugin + registered_plugins[name.to_sym] = plugin end end end diff --git a/lib/castkit/serializers/base.rb b/lib/castkit/serializers/base.rb index b17164c..ac710a3 100644 --- a/lib/castkit/serializers/base.rb +++ b/lib/castkit/serializers/base.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "set" +require "cattri" module Castkit module Serializers @@ -21,6 +22,8 @@ module Serializers # # CustomSerializer.call(user_dto) class Base + include Cattri + class << self # Entrypoint for serializing an object. # @@ -33,7 +36,7 @@ def call(object, visited: nil) end # @return [Castkit::DataObject] the object being serialized - attr_reader :object + cattri :object, nil, expose: :read protected @@ -47,15 +50,17 @@ def serialize_with_default private # @return [Set] a set of visited object IDs to detect circular references - attr_reader :visited + cattri :visited, nil, expose: :read # Initializes the serializer instance. # # @param object [Castkit::DataObject] # @param visited [Set, nil] def initialize(object, visited: nil) - @object = object - @visited = visited || Set.new + super() + + cattri_variable_set(:object, object) + cattri_variable_set(:visited, visited || Set.new) end # Subclasses must override this method to implement serialization logic. diff --git a/lib/castkit/serializers/default_serializer.rb b/lib/castkit/serializers/default_serializer.rb index 8c2041f..830e58c 100644 --- a/lib/castkit/serializers/default_serializer.rb +++ b/lib/castkit/serializers/default_serializer.rb @@ -11,13 +11,13 @@ module Serializers # and respects the class-level serialization configuration. class DefaultSerializer < Castkit::Serializers::Base # @return [Hash{Symbol => Castkit::Attribute}] the attributes to serialize - attr_reader :attributes + cattri :attributes, nil, expose: :read # @return [Hash{Symbol => Object}] unrecognized attributes captured during deserialization - attr_reader :unknown_attributes + cattri :unknown_attributes, nil, expose: :read # @return [Hash] serialization config flags like :root, :ignore_nil, :allow_unknown - attr_reader :options + cattri :options, nil, expose: :read # Serializes the object to a hash. # @@ -41,13 +41,13 @@ def initialize(object, visited: nil) super @skip_flag = "__castkit_#{object.object_id}" - @attributes = object.class.attributes.freeze - @unknown_attributes = object.unknown_attributes.freeze - @options = { - root: object.class.root, - ignore_nil: object.class.ignore_nil || false, - allow_unknown: object.class.allow_unknown || false - } + cattri_variable_set(:attributes, object.class.attributes.freeze) + cattri_variable_set(:unknown_attributes, object.unknown_attributes.freeze) + cattri_variable_set(:options, { + root: object.class.root, + ignore_nil: object.class.ignore_nil || false, + allow_unknown: object.class.allow_unknown || false + }) end # Serializes all defined attributes. diff --git a/lib/castkit/validators/collection_validator.rb b/lib/castkit/validators/collection_validator.rb index f82905c..ab53fbb 100644 --- a/lib/castkit/validators/collection_validator.rb +++ b/lib/castkit/validators/collection_validator.rb @@ -17,11 +17,11 @@ class CollectionValidator < Castkit::Validators::Base # Validates that the value is an Array. # # @param value [Object] the value to validate - # @param _options [Hash] unused, for interface consistency + # @param options [Hash] unused, for interface consistency # @param context [Symbol, String] the field or context for error messaging # @return [void] # @raise [Castkit::AttributeError] if value is not an Array - def call(value, _options:, context:) + def call(value, options:, context:) # rubocop:disable Lint/UnusedMethodArgument type_error!(:array, value, context: context) unless value.is_a?(::Array) end end diff --git a/lib/castkit/version.rb b/lib/castkit/version.rb index 3556a6d..b5f84e0 100644 --- a/lib/castkit/version.rb +++ b/lib/castkit/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Castkit - VERSION = "0.3.1" + VERSION = "0.4.0-alpha" end diff --git a/spec/castkit/attribute_spec.rb b/spec/castkit/attribute_spec.rb index 1e287fc..f7007df 100644 --- a/spec/castkit/attribute_spec.rb +++ b/spec/castkit/attribute_spec.rb @@ -4,6 +4,7 @@ require "castkit/attribute" require "castkit/data_object" require "castkit/validator" +require "castkit/attributes/definition" RSpec.describe Castkit::Attribute do subject(:instance) { described_class.new(:foo, type, **options) } @@ -63,5 +64,47 @@ described_class.new(:foo, Object.new) end.to raise_error(Castkit::AttributeError) end + + it "raises for symbol types that are not registered" do + expect do + described_class.new(:foo, :nope) + end.to raise_error(Castkit::AttributeError) + end + end + + describe ".define" do + it "builds reusable definitions" do + definition = described_class.define(:string) { required false } + expect(definition[:type]).to eq(:string) + expect(definition[:options][:required]).to be(true) + end + end + + describe "#raise_error!" do + it "raises a standardized attribute error" do + attribute = described_class.new(:foo, :string) + expect do + attribute.send(:raise_error!, "boom") + end.to raise_error(Castkit::AttributeError, /boom/) + end + end + + describe "option helpers" do + let(:attribute) do + dataobject_type = Class.new(Castkit::DataObject) + described_class.new(:foo, dataobject_type, required: false, ignore_nil: true, ignore_blank: true, + composite: true, transient: true, unwrapped: true, prefix: nil) + end + + it "exposes option predicates from DSL::Attribute::Options" do + expect(attribute.required?).to be(false) + expect(attribute.optional?).to be(true) + expect(attribute.ignore_nil?).to be(true) + expect(attribute.ignore_blank?).to be(true) + expect(attribute.composite?).to be(true) + expect(attribute.transient?).to be(true) + expect(attribute.unwrapped?).to be(true) + expect(attribute.prefix).to be_nil + end end end diff --git a/spec/castkit/attributes/definition_spec.rb b/spec/castkit/attributes/definition_spec.rb new file mode 100644 index 0000000..0c352a0 --- /dev/null +++ b/spec/castkit/attributes/definition_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "spec_helper" +require "castkit/attributes/definition" + +RSpec.describe Castkit::Attributes::Definition do + it "returns options via .options" do + definition = Class.new(described_class) + expect(definition.options).to be_a(Hash) + expect(definition.options[:required]).to eq(true) + end +end diff --git a/spec/castkit/attributes/options_spec.rb b/spec/castkit/attributes/options_spec.rb new file mode 100644 index 0000000..b5991b4 --- /dev/null +++ b/spec/castkit/attributes/options_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Castkit::Attributes::Options do + let(:definition_class) do + Class.new do + extend Castkit::Attributes::Options + + def self.definition + @definition ||= { + type: nil, + options: Castkit::Attributes::Options::DEFAULTS.dup + } + end + + def self.options + definition[:options] + end + + def self.to_h + definition + end + end + end + + before do + definition_class.instance_variable_set( + :@definition, + { type: nil, options: Castkit::Attributes::Options::DEFAULTS.dup } + ) + end + + it "returns nil when type is unset and assigns when provided" do + expect(definition_class.type).to be_nil + expect(definition_class.type(:string)).to eq(:string) + end + + it "only sets :of when type is :array" do + definition_class.type(:string) + expect(definition_class.of(:integer)).to be_nil + + definition_class.type(:array) + definition_class.instance_variable_set(:@type, :array) + definition_class.of(:integer) + expect(definition_class.options[:of]).to eq(:integer) + end + + it "normalizes and validates types" do + expect(definition_class.send(:process_type, TrueClass)).to eq(:boolean) + expect(definition_class.send(:process_type, String)).to eq(:string) + expect(definition_class.send(:process_type, :symbolic)).to eq(:symbolic) + + expect do + definition_class.send(:process_type, Object.new) + end.to raise_error(Castkit::AttributeError) + end + + it "respects nil in set_option and validates access modes" do + expect(definition_class.send(:set_option, :custom, nil)).to be_nil + + definition_class.readonly + expect(definition_class.options[:access]).to eq([:read]) + + definition_class.required(false) + expect(definition_class.options[:required]).to be(true) + + expect do + definition_class.send(:validate_access_modes!, [:bogus]) + end.to raise_error(Castkit::AttributeError, /Unknown access flags/) + end + + it "returns booleans from option predicates" do + definition_class.required true + definition_class.default "x" + definition_class.force_type + definition_class.ignore + definition_class.ignore_nil true + definition_class.ignore_blank true + definition_class.composite true + definition_class.transient true + definition_class.unwrapped true + definition_class.prefix :pre + definition_class.readonly false + definition_class.access %i[read write] + definition_class.validator -> {} + definition_class.format(/x/) + definition_class.of :string # ignored because type is nil + + expect(definition_class.options[:default]).to eq("x") + expect(definition_class.options[:force_type]).to eq(false) + expect(definition_class.options[:ignore]).to eq(true) + expect(definition_class.options[:required]).to eq(true) + expect(definition_class.options[:ignore_nil]).to eq(true) + expect(definition_class.options[:ignore_blank]).to eq(true) + expect(definition_class.options[:composite]).to eq(true) + expect(definition_class.options[:transient]).to eq(true) + expect(definition_class.options[:unwrapped]).to eq(true) + expect(definition_class.options[:prefix]).to eq(:pre) + expect(definition_class.options[:access]).to eq(%i[read write]) + expect(definition_class.options[:validator]).to be_a(Proc) + expect(definition_class.options[:format]).to eq(/x/) + expect(definition_class.options[:of]).to be_nil + end +end diff --git a/spec/castkit/contract/validator_spec.rb b/spec/castkit/contract/validator_spec.rb index 322cc83..3a80417 100644 --- a/spec/castkit/contract/validator_spec.rb +++ b/spec/castkit/contract/validator_spec.rb @@ -79,5 +79,36 @@ expect(result[:items][0]).to include(:name) end end + + context "with aliases defined" do + let(:attribute) { Castkit::Attribute.new(:id, :string, aliases: [:identifier]) } + let(:attributes) { [attribute] } + let(:input) { { identifier: "abc" } } + let(:options) { {} } + + it "treats alias keys as known attributes" do + expect(result).to eq({}) + end + end + + context "with strict and allow_unknown both true" do + let(:input) { { id: "abc", extra: 1 } } + let(:options) { { strict: true, allow_unknown: true, warn_on_unknown: false } } + + it "logs a warning and does not raise on unknown keys" do + expect(Castkit).to receive(:warning).with(/Both `strict` and `allow_unknown`/).and_call_original + expect { described_class.call!(attributes, input, **options) }.not_to raise_error + end + end + + context "with warn_on_unknown enabled and strict off" do + let(:input) { { id: "abc", extra: 1 } } + let(:options) { { strict: false, warn_on_unknown: true, allow_unknown: false } } + + it "warns instead of raising on unknown keys" do + expect(Castkit).to receive(:warning).with(/Unknown attribute/).and_call_original + expect { described_class.call!(attributes, input, **options) }.not_to raise_error + end + end end end diff --git a/spec/castkit/contract_spec.rb b/spec/castkit/contract_spec.rb index 94cd05b..e83cc37 100644 --- a/spec/castkit/contract_spec.rb +++ b/spec/castkit/contract_spec.rb @@ -54,5 +54,54 @@ expect(result).to be_success expect(result.contract).to eq(contract_name) end + + it "stores definition via class-level Cattri" do + contract = Castkit::Contract.build(:cattri_contract) do + string :id + end + + expect(contract.definition[:name]).to eq(:cattri_contract) + expect(contract.definition[:attributes].keys).to include(:id) + end + + it "raises when source is not a DataObject or block" do + klass = Class.new(Castkit::Contract::Base) + expect do + klass.send(:define, :bad, "not a dataobject") + end.to raise_error(Castkit::ContractError, /Expected a Castkit::DataObject/) + end + + it "raises when both source and block are provided" do + dataobject = Class.new(Castkit::DataObject) { attribute :id, :string } + klass = Class.new(Castkit::Contract::Base) + + expect do + klass.send(:define, :bad, dataobject) { string :id } + end.to raise_error(Castkit::ContractError, /both source and block/) + end + end + + describe "base definition" do + it "exposes the default definition cached by Cattri" do + definition = Castkit::Contract::Base.definition + + expect(definition[:name]).to eq(:ephemeral) + expect(definition[:attributes]).to eq({}) + end + end + + describe Castkit::Contract::Result do + it "renders a success string when there are no errors" do + result = described_class.new(:sample, {}, errors: {}) + expect(result.to_s).to include("passed") + end + + it "renders a failure string with parsed errors" do + result = described_class.new(:sample, {}, errors: { foo: "bar" }) + expect(result.to_s).to include("failed") + expect(result.to_s).to include("foo") + expect(result.to_hash[:errors]).to eq({ foo: "bar" }) + expect(result.inspect).to include("success=false") + end end end diff --git a/spec/castkit/core/attribute_types_spec.rb b/spec/castkit/core/attribute_types_spec.rb index 0893487..c729130 100644 --- a/spec/castkit/core/attribute_types_spec.rb +++ b/spec/castkit/core/attribute_types_spec.rb @@ -3,84 +3,85 @@ require "spec_helper" RSpec.describe Castkit::Core::AttributeTypes do - let(:klass) do - Class.new do - extend Castkit::Core::AttributeTypes - - def self.attribute(field, type, **options) - @attributes ||= [] - @attributes << { field: field, type: type, options: options } - end - - class << self - attr_reader :attributes - end + it "extends including class with type helpers and initializes config" do + klass = Class.new do + include Castkit::Core::AttributeTypes + extend Castkit::Core::Attributes end - end - describe "type methods" do - it "registers a string attribute" do - klass.string :name, required: false - expect(klass.attributes.last).to eq({ field: :name, type: :string, options: { required: false } }) - end + klass.string :name + instance = klass.new + instance.name = "hi" - it "registers an integer attribute" do - klass.integer :age, min: 0 - expect(klass.attributes.last).to eq({ field: :age, type: :integer, options: { min: 0 } }) - end + expect(instance.name).to eq("hi") + end - it "registers a boolean attribute" do - klass.boolean :admin - expect(klass.attributes.last).to eq({ field: :admin, type: :boolean, options: {} }) + it "supports dynamic DSL definition for registered types" do + klass = Class.new do + extend Castkit::Core::Attributes + include Castkit::Core::AttributeTypes end - it "registers a float attribute" do - klass.float :price - expect(klass.attributes.last).to eq({ field: :price, type: :float, options: {} }) - end + Castkit::Core::AttributeTypes.define_type_dsl(:custom_type) + allow(klass).to receive(:attribute) - it "registers a date attribute" do - klass.date :dob - expect(klass.attributes.last).to eq({ field: :dob, type: :date, options: {} }) - end + klass.custom_type :field, foo: :bar + expect(klass).to have_received(:attribute).with(:field, :custom_type, foo: :bar) + end - it "registers a datetime attribute" do - klass.datetime :created_at - expect(klass.attributes.last).to eq({ field: :created_at, type: :datetime, options: {} }) - end + it "falls back to Object#hash when called without arguments" do + klass = Class.new do + include Castkit::Core::AttributeTypes - it "registers an array attribute" do - klass.array :tags - expect(klass.attributes.last).to eq({ field: :tags, type: :array, options: {} }) + def attribute(*); end end - it "registers a hash attribute" do - klass.hash :metadata - expect(klass.attributes.last).to eq({ field: :metadata, type: :hash, options: {} }) - end + expect { klass.hash }.not_to raise_error + end - it "registers a dataobject attribute" do - dto_class = Class.new(Castkit::DataObject) - klass.dataobject :profile, dto_class, required: false - expect(klass.attributes.last).to eq({ field: :profile, type: dto_class, options: { required: false } }) + it "raises when dataobject type is not a subclass of Castkit::DataObject" do + klass = Class.new do + include Castkit::Core::AttributeTypes + extend Castkit::Core::Attributes end - it "raises if dataobject type is not a subclass of Castkit::DataObject" do - expect do - klass.dataobject :bad, String - end.to raise_error(Castkit::AttributeError, /must extend from Castkit::DataObject/) - end + expect do + klass.dataobject(:bad, String) + end.to raise_error(Castkit::AttributeError, /must extend/) + end - it "registers an unwrapped dataobject" do - dto_class = Class.new(Castkit::DataObject) - klass.unwrapped :location, dto_class - expect(klass.attributes.last).to eq({ field: :location, type: dto_class, options: { unwrapped: true } }) - end + it "defines helper methods for all built-in types" do + recorded = [] + klass = Class.new do + extend Castkit::Core::Attributes + include Castkit::Core::AttributeTypes - it "aliases dto to dataobject" do - dto_class = Class.new(Castkit::DataObject) - klass.dto :thing, dto_class - expect(klass.attributes.last).to eq({ field: :thing, type: dto_class, options: {} }) + define_singleton_method(:attribute) { |field, type, **| recorded << [field, type] } end + + klass.integer :a + klass.boolean :b + klass.float :c + klass.date :d + klass.datetime :e + klass.array :f, of: :string + klass.hash :g + dataobject_type = Class.new(Castkit::DataObject) + klass.dataobject(:h, dataobject_type) + klass.unwrapped(:h, dataobject_type) + klass.hash + + expect(recorded).to include( + %i[a integer], + %i[b boolean], + %i[c float], + %i[d date], + %i[e datetime], + %i[f array], + %i[g hash] + ) + h_entries = recorded.select { |field, _| field == :h } + expect(h_entries.size).to eq(2) + expect(h_entries).to all(satisfy { |_, type| type == dataobject_type }) end end diff --git a/spec/castkit/core/attributes_spec.rb b/spec/castkit/core/attributes_spec.rb index 08216b3..327d2d7 100644 --- a/spec/castkit/core/attributes_spec.rb +++ b/spec/castkit/core/attributes_spec.rb @@ -103,4 +103,42 @@ expect(instance.thing).to eq("alias_works") end end + + describe "attribute registry inheritance" do + it "copies defined attributes to subclasses via Cattri registry" do + klass.attribute :base_field, :string + + subclass = Class.new(klass) + + expect(subclass.attributes.keys).to include(:base_field) + expect(subclass.new).to respond_to(:base_field) + end + + it "initializes an empty registry when parent has none" do + subclass = Class.new(klass) + + expect(subclass.attributes).to eq({}) + end + end + + describe "#exposure_for" do + it "returns :none when attribute is neither readable nor writeable" do + attribute = Castkit::Attribute.new(:secret, :string, access: [:write], composite: true) + exposure = klass.send(:exposure_for, attribute) + expect(exposure).to eq(:none) + end + + it "raises when type mismatches a provided definition" do + definition = { type: :string, options: {} } + expect do + klass.send(:use_definition, :field, definition, :integer, {}) + end.to raise_error(Castkit::AttributeError, /type mismatch/) + end + + it "raises when type is missing" do + expect do + klass.send(:use_definition, :field, nil, nil, {}) + end.to raise_error(Castkit::AttributeError, /has no type/) + end + end end diff --git a/spec/castkit/core/config_spec.rb b/spec/castkit/core/config_spec.rb index aa61c88..3393a26 100644 --- a/spec/castkit/core/config_spec.rb +++ b/spec/castkit/core/config_spec.rb @@ -61,5 +61,10 @@ relaxed_class = klass.relaxed(warn_on_unknown: false) expect(relaxed_class.warn_on_unknown).to be(false) end + + it "rebuilds validation rules via cattri-backed flags" do + relaxed_class = klass.relaxed + expect(relaxed_class.validation_rules).to include(:strict, :allow_unknown, :warn_on_unknown) + end end end diff --git a/spec/castkit/data_object_spec.rb b/spec/castkit/data_object_spec.rb index 3bae005..e6afa71 100644 --- a/spec/castkit/data_object_spec.rb +++ b/spec/castkit/data_object_spec.rb @@ -52,6 +52,39 @@ def self.name end end + describe ".cast" do + it "returns self if already a DataObject" do + instance = subclass.new(valid_input) + expect(subclass.cast(instance)).to equal(instance) + end + + it "raises for unsupported types" do + expect { subclass.cast("nope") }.to raise_error(Castkit::DataObjectError) + end + end + + describe ".build" do + it "builds a subclass even without a block" do + built = described_class.build + expect(built).to be < described_class + end + end + + describe ".serializer" do + it "allows overriding serializer" do + custom = Class.new(Castkit::Serializers::Base) do + def call + { custom: object.__raw } + end + end + + subclass.serializer(custom) + instance = subclass.new(valid_input) + + expect(instance.to_hash).to eq(custom: instance.__raw) + end + end + describe ".cast" do it "returns the object if it's already an instance" do instance = subclass.new(valid_input) @@ -67,6 +100,13 @@ def self.name end end + describe ".build" do + it "builds a subclass even without a block" do + built = described_class.build + expect(built.superclass).to eq(described_class) + end + end + describe ".serializer" do it "gets and sets a custom serializer" do custom_serializer = Class.new(Castkit::Serializers::Base) @@ -172,6 +212,16 @@ def self.name end end + describe "#strict?" do + it "returns false when allow_unknown is true" do + klass = Class.new(described_class) do + allow_unknown true + end + + expect(klass.new({}).send(:strict?)).to be(false) + end + end + describe "#__raw" do it "returns the raw data on instantiation" do subclass.root :person @@ -213,4 +263,81 @@ def self.name expect(JSON.parse(json)).to eq({ "name" => "Tester", "age" => 30 }) end end + + describe "introspection (Cattri opt-in)" do + let(:introspective_klass) do + Class.new(described_class) do + enable_cattri_introspection! + string :name + string :email, required: false + end + end + + it "exposes introspection helpers when enabled" do + expect(introspective_klass).to respond_to(:attribute_defined?) + expect(introspective_klass.attribute_defined?(:name)).to be(true) + expect(introspective_klass.attribute_methods[:name]).to include(:name, :name=) + expect(introspective_klass.attribute_source(:name)).to eq(introspective_klass) + expect(introspective_klass.attribute_definitions[:name]).to be_a(Cattri::Attribute) + end + + it "memoizes the cattri attribute registry" do + registry = double("Registry", + defined_attributes: { name: double(allowed_methods: %i[name name=], + defined_in: introspective_klass) }) + + allow(introspective_klass).to receive(:attribute_registry).and_return(registry) + introspective_klass.attribute_methods + + expect(introspective_klass).not_to receive(:attribute_registry) + introspective_klass.attribute_methods + end + end + + describe "deserialization failures" do + it "raises when value cannot be cast to any union type" do + failing_type = Class.new(Castkit::Types::Base) do + def deserialize(_value) + raise Castkit::TypeError, "nope" + end + end + + begin + Castkit.configuration.register_type(:failing_union, failing_type, override: true) + attribute = Castkit::Attribute.new(:status, [:failing_union]) + instance = subclass.allocate + + expect do + instance.send(:deserialize_primitive_value!, attribute, "nope") + end.to raise_error(Castkit::AttributeError, /could not be deserialized/) + ensure + Castkit.configuration.reset_types! + end + end + + it "treats allow_unknown as overriding strict? on the instance" do + relaxed = Class.new(described_class) do + allow_unknown true + end + + expect(relaxed.new(valid_input).send(:strict?)).to be(false) + end + + it "respects explicit strict flag when allow_unknown is false" do + strict_class = Class.new(described_class) do + strict true + string :name + integer :age + end + + relaxed_class = Class.new(described_class) do + strict false + string :name + integer :age + end + + expect(strict_class.new(name: "x", age: 1).send(:strict?)).to be(true) + expect(relaxed_class.new(name: "x", age: 1).send(:strict?)).to be(false) + end + end end diff --git a/spec/castkit/default_serializer_spec.rb b/spec/castkit/default_serializer_spec.rb index 39801fc..5313cbc 100644 --- a/spec/castkit/default_serializer_spec.rb +++ b/spec/castkit/default_serializer_spec.rb @@ -61,6 +61,20 @@ serializer = described_class.new(obj) expect(serializer.call).to eq({ name: "Tester", unknown: "key" }) end + + it "serializes dataobject collections" do + child_class = Class.new(Castkit::DataObject) do + string :value + end + + parent_class = Class.new(Castkit::DataObject) do + array :children, of: child_class + end + + parent = parent_class.new(children: [{ value: "x" }]) + serialized = described_class.call(parent) + expect(serialized[:children]).to eq([{ value: "x" }]) + end end describe "#serialize_attributes" do diff --git a/spec/castkit/dsl/data_object/contract_spec.rb b/spec/castkit/dsl/data_object/contract_spec.rb new file mode 100644 index 0000000..57b013a --- /dev/null +++ b/spec/castkit/dsl/data_object/contract_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "spec_helper" +require "castkit/dsl/data_object/contract" + +RSpec.describe Castkit::DSL::DataObject::Contract do + let(:dataobject_class) do + Class.new(Castkit::DataObject) do + string :id + integer :age, required: false + end + end + + describe ".contract" do + it "memoizes the contract" do + first = dataobject_class.contract + expect(dataobject_class.contract).to equal(first) + end + end + + describe ".from_contract" do + it "rebuilds a DataObject class from a contract" do + contract = dataobject_class.contract + rebuilt = Castkit::DataObject.from_contract(contract) + + instance = rebuilt.new(id: "x") + expect(instance.id).to eq("x") + expect(instance).to respond_to(:age=) + expect(rebuilt.attributes.keys).to include(:id, :age) + end + + it "validates data via the rebuilt contract helpers" do + contract = dataobject_class.contract + rebuilt = Castkit::DataObject.from_contract(contract) + + expect(rebuilt.validate(id: "ok").success?).to be(true) + expect { rebuilt.validate!(id: 1) }.to raise_error(Castkit::ContractError) + end + end +end diff --git a/spec/castkit/dsl/data_object/plugins_spec.rb b/spec/castkit/dsl/data_object/plugins_spec.rb new file mode 100644 index 0000000..9ef4317 --- /dev/null +++ b/spec/castkit/dsl/data_object/plugins_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "spec_helper" +require "set" +require "castkit/dsl/data_object/plugins" + +module Castkit + module Plugins + module Json + def self.setup!(host) + host.instance_variable_set(:@plugin_setup_called, true) + end + end + + module Wire + def self.setup!(host) + host.instance_variable_set(:@plugin_setup_called, true) + end + end + end +end + +RSpec.describe Castkit::DSL::DataObject::Plugins do + before do + allow(Castkit.configuration).to receive(:default_plugins).and_return([]) + end + + let(:host_class) do + Class.new(Castkit::DataObject).tap do |klass| + klass.extend(described_class) + end + end + + describe ".enabled_plugins" do + it "initializes the plugin set when accessed" do + host_class.remove_instance_variable(:@enabled_plugins) if host_class.instance_variable_defined?(:@enabled_plugins) + expect(host_class.enabled_plugins).to be_a(Set) + expect(host_class.instance_variable_defined?(:@enabled_plugins)).to be(true) + end + end + + describe ".disabled_plugins" do + it "initializes the disabled set when accessed" do + if host_class.instance_variable_defined?(:@disabled_plugins) + host_class.remove_instance_variable(:@disabled_plugins) + end + expect(host_class.disabled_plugins).to be_a(Set) + expect(host_class.instance_variable_defined?(:@disabled_plugins)).to be(true) + end + end + + describe ".enable_plugins" do + it "adds plugins to the enabled set" do + host_class.enable_plugins(:json, :wire) + expect(host_class.enabled_plugins).to include(:json, :wire) + ensure + host_class.instance_variable_set(:@enabled_plugins, nil) + end + + it "is a no-op when no plugins are provided" do + expect { host_class.enable_plugins }.not_to raise_error + end + end + + describe ".disable_plugins" do + it "adds plugins to the disabled set" do + host_class.disable_plugins(:xml) + expect(host_class.disabled_plugins).to include(:xml) + ensure + host_class.instance_variable_set(:@disabled_plugins, nil) + end + + it "no-ops when called without args" do + host_class.disable_plugins + expect(host_class.disabled_plugins).not_to be_nil + end + end +end diff --git a/spec/castkit/dsl/data_object/serialization_spec.rb b/spec/castkit/dsl/data_object/serialization_spec.rb new file mode 100644 index 0000000..6a27303 --- /dev/null +++ b/spec/castkit/dsl/data_object/serialization_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Castkit::DSL::DataObject::Serialization::ClassMethods do + subject(:host_class) do + Class.new do + extend Castkit::DSL::DataObject::Serialization::ClassMethods + end + end + + describe ".ignore_blank" do + it "defaults to true when unset" do + host_class.remove_instance_variable(:@ignore_blank) if host_class.instance_variable_defined?(:@ignore_blank) + + expect(host_class.instance_variable_defined?(:@ignore_blank)).to be(false) + expect(host_class.ignore_blank).to be(true) + expect(host_class.instance_variable_get(:@ignore_blank)).to be(true) + end + + it "allows toggling the flag to false" do + host_class.remove_instance_variable(:@ignore_blank) if host_class.instance_variable_defined?(:@ignore_blank) + host_class.ignore_blank(false) + + expect(host_class.ignore_blank).to be(false) + expect(host_class.instance_variable_get(:@ignore_blank)).to be(false) + end + end +end diff --git a/spec/castkit/ext/data_object/deserialization_spec.rb b/spec/castkit/ext/data_object/deserialization_spec.rb index 722b4d5..8eb7a90 100644 --- a/spec/castkit/ext/data_object/deserialization_spec.rb +++ b/spec/castkit/ext/data_object/deserialization_spec.rb @@ -58,6 +58,22 @@ def initialize(data = {}) end end + describe "#deserialize_attribute_value!" do + let(:instance) { klass.new } + + it "uses default when value is nil" do + attribute = Castkit::Attribute.new(:foo, :string, required: false, default: -> { "fallback" }) + expect(instance.send(:deserialize_attribute_value!, attribute, nil)).to eq("fallback") + end + + it "raises when required and value is nil" do + attribute = Castkit::Attribute.new(:foo, :string, required: true) + expect do + instance.send(:deserialize_attribute_value!, attribute, nil) + end.to raise_error(Castkit::AttributeError) + end + end + describe "#unwrap_prefixed_fields!" do let(:attribute) do double("Attribute", diff --git a/spec/castkit/ext/data_object/plugins_spec.rb b/spec/castkit/ext/data_object/plugins_spec.rb new file mode 100644 index 0000000..fb7bdc2 --- /dev/null +++ b/spec/castkit/ext/data_object/plugins_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Castkit::DSL::DataObject::Plugins do + let(:base_class) { Class.new(Castkit::DataObject) } + + before do + @original_defaults = Castkit.configuration.default_plugins.dup + end + + after do + Castkit.configuration.default_plugins = @original_defaults + end + + it "no-ops enable_plugins when no plugins provided" do + expect { base_class.enable_plugins }.not_to raise_error + end + + it "no-ops disable_plugins when no plugins provided" do + expect { base_class.disable_plugins }.not_to raise_error + end + + it "applies default plugins on inheritance unless disabled" do + plugin_module = Module.new do + def self.setup!(klass) + klass.instance_variable_set(:@plugin_setup_called, true) + end + end + + Castkit::Plugins.register(:sample_default, plugin_module) + Castkit.configuration.default_plugins = [:sample_default] + + base_class.disable_plugins :sample_default + + enabled_subclass = Class.new(base_class) do + enable_plugins :sample_default + end + disabled_subclass = Class.new(base_class) do + disable_plugins :sample_default + end + inherited_disabled = Class.new(disabled_subclass) + + expect(enabled_subclass.instance_variable_get(:@plugin_setup_called)).to be(true) + expect(disabled_subclass.instance_variable_get(:@plugin_setup_called)).to be_nil + expect(inherited_disabled.instance_variable_get(:@plugin_setup_called)).to be_nil + end +end diff --git a/spec/castkit/plugins_spec.rb b/spec/castkit/plugins_spec.rb index 7683f76..f78a8ff 100644 --- a/spec/castkit/plugins_spec.rb +++ b/spec/castkit/plugins_spec.rb @@ -5,11 +5,13 @@ RSpec.describe Castkit::Plugins do before do - @original_registry = described_class.instance_variable_get(:@registered_plugins).dup + @original_registry = described_class.registered_plugins.dup if described_class.respond_to?(:registered_plugins) end after do - described_class.instance_variable_set(:@registered_plugins, @original_registry) + if described_class.respond_to?(:registered_plugins) && @original_registry + described_class.registered_plugins.replace(@original_registry) + end end it "allows registering a plugin via configuration" do @@ -20,6 +22,7 @@ end expect(Castkit::Plugins.lookup!(:path_plugin)).to eq(plugin_module) + expect(Castkit::Plugins.registered_plugins[:path_plugin]).to eq(plugin_module) end it "looks up plugins defined under Castkit::Plugins namespace" do @@ -45,4 +48,33 @@ def plugin_method; end expect(klass.instance_variable_get(:@setup_called)).to be(true) expect(klass.new).to respond_to(:plugin_method) end + + it "activates plugins that do not implement setup!" do + plugin = Module.new do + def marker; end + end + + Castkit::Plugins.register(:no_setup, plugin) + klass = Class.new + + expect { Castkit::Plugins.activate(klass, :no_setup) }.not_to raise_error + expect(klass.new).to respond_to(:marker) + end + + it "records deactivation requests" do + klass = Class.new + expect { Castkit::Plugins.deactivate(klass, :foo, :bar) }.not_to raise_error + expect(Castkit::Plugins.instance_variable_get(:@deactivate_plugins)).to include(:foo, :bar) + end + + it "allows disable_plugins no-op when no plugins provided" do + klass = Class.new(Castkit::DataObject) + expect { klass.disable_plugins }.not_to raise_error + end + + it "raises a helpful error when plugin is missing" do + expect do + Castkit::Plugins.lookup!(:unknown_plugin) + end.to raise_error(Castkit::Error, /could not be found/) + end end diff --git a/spec/castkit/types/base_spec.rb b/spec/castkit/types/base_spec.rb index 82283e9..a22f5bb 100644 --- a/spec/castkit/types/base_spec.rb +++ b/spec/castkit/types/base_spec.rb @@ -1,40 +1,97 @@ # frozen_string_literal: true require "spec_helper" -require "castkit/types/base" RSpec.describe Castkit::Types::Base do - let(:type_class) do - Class.new(described_class) do + let(:config) { Castkit.configuration } + + before do + @orig_raise = config.raise_type_errors + @orig_warn = config.enable_warnings + end + + after do + config.raise_type_errors = @orig_raise + config.enable_warnings = @orig_warn + end + + it "casts with force_type before validation" do + klass = Class.new(described_class) do def deserialize(value) - value + value.to_i end end + + result = klass.cast!("5", force_type: true) + expect(result).to eq(5) end - describe ".cast!" do - it "supports custom validators that accept only the value" do - validator = ->(v) { raise Castkit::AttributeError, "empty" if v.to_s.empty? } + it "emits a warning instead of raising when configured" do + config.raise_type_errors = false + config.enable_warnings = true + + klass = Class.new(described_class) do + def call_warning + type_error!(:integer, "bad", context: :field) + end + end - expect do - type_class.cast!("valid", validator: validator) - end.not_to raise_error + instance = klass.new + expect { instance.call_warning }.to output(/field must be a integer/).to_stderr + end - expect do - type_class.cast!("", validator: validator) - end.to raise_error(Castkit::AttributeError, /empty/) + it "supports validators of varying arity" do + called = [] + validator1 = lambda { |v| + called << [:arity1, v] + v + } + validator2 = lambda do |v, opts| + called << [:arity2, v, opts] + v + opts[:offset] + end + validator3 = lambda do |v, **kw| + called << [:keyword, v, kw] + "#{v}-#{kw[:context]}-#{kw.dig(:options, :suffix)}" end - it "supports custom validators that accept value and options hash" do - validator = ->(v, opts) { raise Castkit::AttributeError, "missing #{opts[:name]}" if v.nil? } + expect(described_class.cast!(1, validator: validator1)).to eq(1) + result2 = described_class.cast!(1, validator: validator2, options: { offset: 2 }) + expect(result2).to eq(1) + result3 = described_class.cast!("a", validator: validator3, options: { suffix: "z" }, context: :ctx) + expect(result3).to eq("a") - expect do - type_class.cast!("ok", validator: validator, name: "name") - end.not_to raise_error + expect(called).to include( + [:arity1, 1], + [:arity2, 1, hash_including(offset: 2)], + [:keyword, "a", hash_including(options: hash_including(suffix: "z"), context: :ctx)] + ) + end - expect do - type_class.cast!(nil, validator: validator, name: "name") - end.to raise_error(Castkit::AttributeError, /missing name/) - end + it "exposes base serialize/deserialize/validate defaults" do + base = described_class.new + expect(base.deserialize("x")).to eq("x") + expect(base.serialize("y")).to eq("y") + expect { base.validate!("z") }.not_to raise_error + end + + it "invokes non-proc validators with keyword options/context" do + called = [] + validator_obj = Class.new do + attr_reader :called + + def initialize(called) + @called = called + end + + def call(value, options: {}, context: nil) + called << [value, options, context] + value + end + end.new(called) + + described_class.cast!(5, validator: validator_obj, options: { foo: :bar }, context: :ctx) + + expect(called).to include([5, hash_including(foo: :bar), :ctx]) end end diff --git a/spec/castkit/types/primitives_spec.rb b/spec/castkit/types/primitives_spec.rb new file mode 100644 index 0000000..4fb1c16 --- /dev/null +++ b/spec/castkit/types/primitives_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Primitive type wrappers" do + it "deserializes and validates float" do + expect(Castkit::Types::Float.deserialize("1.2")).to eq(1.2) + expect(Castkit::Types::Float.serialize(1.2)).to eq(1.2) + expect { Castkit::Types::Float.validate!(1.2) }.not_to raise_error + expect { Castkit::Types::Float.validate!("x") }.to raise_error(Castkit::AttributeError) + end + + it "deserializes and validates integer" do + expect(Castkit::Types::Integer.deserialize("3")).to eq(3) + expect(Castkit::Types::Integer.serialize(3)).to eq(3) + expect { Castkit::Types::Integer.validate!(3) }.not_to raise_error + expect { Castkit::Types::Integer.validate!("x") }.to raise_error(Castkit::AttributeError) + end + + it "deserializes and validates boolean" do + expect(Castkit::Types::Boolean.deserialize("1")).to eq("1") + expect(Castkit::Types::Boolean.serialize(false)).to eq(false) + expect { Castkit::Types::Boolean.validate!(true) }.not_to raise_error + expect { Castkit::Types::Boolean.validate!("x") }.to raise_error(Castkit::AttributeError) + end + + it "deserializes and validates date and datetime" do + date = Date.new(2024, 1, 2) + expect(Castkit::Types::Date.deserialize("2024-01-02")).to eq(date) + expect(Castkit::Types::Date.serialize(date)).to eq("2024-01-02") + expect { Castkit::Types::Date.deserialize("x") }.to raise_error(ArgumentError) + expect { Castkit::Types::Date.validate!(date) }.not_to raise_error + + parsed = Castkit::Types::DateTime.deserialize("2024-01-02T03:04:05Z") + expect(parsed).to be_a(DateTime) + expect(parsed.year).to eq(2024) + expect { Castkit::Types::DateTime.deserialize("x") }.to raise_error(ArgumentError) + expect { Castkit::Types::DateTime.validate!(DateTime.now) }.not_to raise_error + expect(Castkit::Types::DateTime.serialize(parsed)).to eq("2024-01-02T03:04:05+00:00") + end + + it "deserializes and validates collection" do + expect(Castkit::Types::Collection.deserialize([1, 2])).to eq([1, 2]) + expect do + Castkit::Types::Collection.validate!([1, 2], options: {}, context: :tags) + end.not_to raise_error + expect { Castkit::Types::Collection.validate!("x") }.to raise_error(Castkit::AttributeError) + end + + it "exposes base class helpers" do + expect(Castkit::Types::Base.serialize("x")).to eq("x") + end +end diff --git a/spec/castkit/validator_spec.rb b/spec/castkit/validator_spec.rb new file mode 100644 index 0000000..485347d --- /dev/null +++ b/spec/castkit/validator_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Castkit::Validator do + it "delegates .call to an instance" do + klass = Class.new(described_class) do + def call(value, options:, context:) + [value, options, context] + end + end + + result = klass.call("v", options: { min: 1 }, context: :field) + expect(result).to eq(["v", { min: 1 }, :field]) + end + + it "raises NotImplementedError by default for #call" do + expect do + described_class.new.call("v", options: {}, context: :field) + end.to raise_error(NotImplementedError) + end +end diff --git a/spec/castkit/validators/additional_spec.rb b/spec/castkit/validators/additional_spec.rb new file mode 100644 index 0000000..35b6d67 --- /dev/null +++ b/spec/castkit/validators/additional_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Additional validator coverage" do + let(:config) { Castkit.configuration } + + before do + @orig_raise = config.raise_type_errors + @orig_warn = config.enable_warnings + end + + after do + config.raise_type_errors = @orig_raise + config.enable_warnings = @orig_warn + end + + it "raises on non-array values for collection validator" do + config.raise_type_errors = true + validator = Castkit::Validators::CollectionValidator.new + expect do + validator.call("not array", options: {}, context: :tags) + end.to raise_error(Castkit::AttributeError) + end + + it "raises on non-float values for float validator" do + config.raise_type_errors = true + validator = Castkit::Validators::FloatValidator.new + expect do + validator.call(1, options: {}, context: :price) + end.to raise_error(Castkit::AttributeError) + end + + it "raises on non-integer values for integer validator" do + config.raise_type_errors = true + validator = Castkit::Validators::IntegerValidator.new + expect do + validator.call("not int", options: {}, context: :age) + end.to raise_error(Castkit::AttributeError) + end + + it "passes validation for correct numeric types" do + collection = Castkit::Validators::CollectionValidator.new + float_validator = Castkit::Validators::FloatValidator.new + integer_validator = Castkit::Validators::IntegerValidator.new + + expect { collection.call([1], options: {}, context: :tags) }.not_to raise_error + expect { float_validator.call(1.5, options: {}, context: :price) }.not_to raise_error + expect { integer_validator.call(2, options: {}, context: :count) }.not_to raise_error + end + + it "warns instead of raising when raise_type_errors is false" do + config.raise_type_errors = false + config.enable_warnings = true + validator = Castkit::Validators::Base.new + expect do + validator.send(:type_error!, :integer, "nope", context: :id) + end.to output(/id must be a integer/).to_stderr + end + + it "silently skips when errors and warnings are disabled" do + config.raise_type_errors = false + config.enable_warnings = false + validator = Castkit::Validators::Base.new + + expect do + validator.send(:type_error!, :integer, "nope", context: :id) + end.not_to raise_error + end + + it "raises NotImplementedError from Validators::Base#call by default" do + validator = Castkit::Validators::Base.new + expect { validator.call("x", options: {}, context: :id) }.to raise_error(NotImplementedError) + end +end diff --git a/spec/castkit_spec.rb b/spec/castkit_spec.rb index df7bc96..e87eb1a 100644 --- a/spec/castkit_spec.rb +++ b/spec/castkit_spec.rb @@ -8,6 +8,30 @@ expect(Castkit::VERSION).not_to be nil end + describe ".warning" do + it "emits warnings when enabled" do + Castkit.configuration.enable_warnings = true + + expect { Castkit.warning("warn") }.to output(/warn/).to_stderr + ensure + Castkit.configuration.enable_warnings = false + end + + it "provides type validator shortcut" do + validator = Castkit.type_validator(:string) + expect { validator.call("hi") }.not_to raise_error + expect(validator.call("hi")).to be_nil + end + + it "exposes serializer and deserializer helpers" do + serializer = Castkit.type_serializer(:string) + deserializer = Castkit.type_deserializer(:string) + + expect(serializer.call("x")).to eq("x") + expect(deserializer.call("y")).to eq("y") + end + end + describe Castkit::DataObject do describe "with optional attributes" do let(:klass) do diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 1bc4dc4..508f540 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -23,6 +23,18 @@ expect(config.fetch_type(:integer)).to be_a(Castkit::Types::Integer) expect(config.fetch_type(:float)).to be_a(Castkit::Types::Float) end + + it "raises when type is unknown and raise_type_errors is true" do + config.raise_type_errors = true + + expect { config.fetch_type(:missing_type) }.to raise_error(Castkit::TypeError) + end + + it "returns nil when type is unknown and raise_type_errors is false" do + config.raise_type_errors = false + + expect(config.fetch_type(:missing_type)).to be_nil + end end describe "#register_type" do @@ -33,6 +45,11 @@ expect(config.fetch_type(:custom)).to be_a(mock_definition) end + it "registers aliases when provided" do + config.register_type(:primary, mock_definition, aliases: [:alias_one]) + expect(config.fetch_type(:alias_one)).to be_a(mock_definition) + end + it "does not override existing validator by default" do original = config.fetch_type(:string) config.register_type(:string, mock_definition) diff --git a/spec/simplecov_helper.rb b/spec/simplecov_helper.rb index 40a9f71..b388cc6 100644 --- a/spec/simplecov_helper.rb +++ b/spec/simplecov_helper.rb @@ -1,10 +1,22 @@ # frozen_string_literal: true require "simplecov" +require "simplecov-cobertura" +require "simplecov-html" + +SimpleCov.formatters = [ + SimpleCov::Formatter::CoberturaFormatter, + SimpleCov::Formatter::HTMLFormatter +] SimpleCov.start do enable_coverage :branch + + track_files "lib/cattri/**/*.rb" + + add_filter "lib/cattri/version.rb" add_filter "/spec/" + add_group "DataObjects", "lib/castkit/data_object" add_group "Attributes", "lib/castkit/attribute" add_group "Contracts", "lib/castkit/contract" @@ -12,7 +24,4 @@ add_group "Plugins", "lib/castkit/plugins" end -SimpleCov.minimum_coverage 50 - -require "simplecov-cobertura" -SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter +SimpleCov.minimum_coverage 100 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 57be437..76cb8d8 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "castkit" require_relative "simplecov_helper" +require "castkit" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure From 63ea694e4417b134d256190d388266976dbdf2d7 Mon Sep 17 00:00:00 2001 From: Nathan Lucas Date: Sun, 14 Dec 2025 15:41:14 -0500 Subject: [PATCH 2/7] changelog --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd11f7..e5449a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ # v0.4.0 -- refactored Castkit internals to build on Cattri registries for attributes, configuration, contracts, and attribute definitions -- added Cattri-powered introspection helpers, serialization toggle behavior and enhanced type/validator DSLs, plus coverage for the new plugin helpers -- introduced SimpleCov HTML/Cobertura reporting, raised the minimum coverage target to 100%, and grated 100% line coverage across the core library (Ruby 3.1 limits still block rspec via SimpleCov/json incompatibility) +- Refactored Castkit internals to build on Cattri registries for attributes, configuration, contracts, and attribute definitions +- Added Cattri-powered introspection helpers, serialization toggle behavior and enhanced type/validator DSLs, plus coverage for the new plugin helpers + ## [0.3.0] - 2025-04-16 ### Added From ecd66e844cdd829b008ed88871afea0fa3f6c4d5 Mon Sep 17 00:00:00 2001 From: Nathan Lucas Date: Sun, 14 Dec 2025 15:43:05 -0500 Subject: [PATCH 3/7] cleanup --- spec/castkit/core/attribute_types_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/castkit/core/attribute_types_spec.rb b/spec/castkit/core/attribute_types_spec.rb index c729130..1117a26 100644 --- a/spec/castkit/core/attribute_types_spec.rb +++ b/spec/castkit/core/attribute_types_spec.rb @@ -80,7 +80,7 @@ def attribute(*); end %i[f array], %i[g hash] ) - h_entries = recorded.select { |field, _| field == :h } + h_entries = recorded.slice(:h) expect(h_entries.size).to eq(2) expect(h_entries).to all(satisfy { |_, type| type == dataobject_type }) end From 203f0e6873dcc8bd327fbc909f9ba81bc1385494 Mon Sep 17 00:00:00 2001 From: Nathan Lucas Date: Sun, 14 Dec 2025 15:43:58 -0500 Subject: [PATCH 4/7] cleanup --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 91ae645..e406ef9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: ruby: - - '3.1.4' +# - '3.1.4' - '3.2.9' - '3.3.10' - '3.4.7' From 01e7f23fd677dac591a81ce6c47d6c8564082d78 Mon Sep 17 00:00:00 2001 From: Nathan Lucas Date: Sun, 14 Dec 2025 15:46:18 -0500 Subject: [PATCH 5/7] cleanup --- spec/castkit/core/attribute_types_spec.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/spec/castkit/core/attribute_types_spec.rb b/spec/castkit/core/attribute_types_spec.rb index 1117a26..5cc171e 100644 --- a/spec/castkit/core/attribute_types_spec.rb +++ b/spec/castkit/core/attribute_types_spec.rb @@ -80,8 +80,12 @@ def attribute(*); end %i[f array], %i[g hash] ) - h_entries = recorded.slice(:h) - expect(h_entries.size).to eq(2) - expect(h_entries).to all(satisfy { |_, type| type == dataobject_type }) + + entries = recorded.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(field, type), memo| + memo[field] << type + end + + expect(entries[:h].size).to eq(2) + expect(entries[:h]).to all(eq(dataobject_type)) end end From f9371c62f755ee28b0452fb20ae2a61ea04e5b9e Mon Sep 17 00:00:00 2001 From: Nathan Lucas Date: Sun, 14 Dec 2025 15:46:35 -0500 Subject: [PATCH 6/7] cleanup --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e406ef9..91ae645 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: ruby: -# - '3.1.4' + - '3.1.4' - '3.2.9' - '3.3.10' - '3.4.7' From 952ae69732255cab0b5f2538d167f011f2a817a6 Mon Sep 17 00:00:00 2001 From: Nathan Lucas Date: Sun, 14 Dec 2025 15:49:08 -0500 Subject: [PATCH 7/7] version --- lib/castkit/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/castkit/version.rb b/lib/castkit/version.rb index b5f84e0..4b66c78 100644 --- a/lib/castkit/version.rb +++ b/lib/castkit/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Castkit - VERSION = "0.4.0-alpha" + VERSION = "0.4.0" end