Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 106 additions & 110 deletions .rspec_status

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ Gemspec/DevelopmentDependencies:
Layout/LineLength:
Max: 120

Lint/MissingSuper:
Enabled: false

Metrics/ClassLength:
Max: 200

Expand Down
4 changes: 3 additions & 1 deletion castkit.gemspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

require_relative "lib/castkit/version"
require_relative "lib/castkit_o/version"

Gem::Specification.new do |spec|
spec.name = "castkit"
Expand Down Expand Up @@ -33,12 +33,14 @@ Gem::Specification.new do |spec|
spec.require_paths = ["lib"]

# Runtime dependencies
spec.add_dependency "cattri", "~> 0.1", ">= 0.1.2"
spec.add_dependency "thor"

# Development dependencies
spec.add_development_dependency "rspec"
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
26 changes: 11 additions & 15 deletions lib/castkit.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
# frozen_string_literal: true

require_relative "castkit/castkit"
module Castkit
class << self
def data_object?(obj)
obj.is_a?(Class) && (
obj <= Castkit::DataObject ||
obj.ancestors.include?(Castkit::DSL::DataObject)
)
end
end
end

# Castkit is a lightweight, type-safe data object system for Ruby.
#
# It provides a declarative DSL for defining DTOs with typecasting, validation,
# access control, serialization, deserialization, and OpenAPI-friendly schema generation.
#
# @example Defining a simple data object
# class UserDto < Castkit::DataObject
# string :name
# integer :age, required: false
# end
#
# user = UserDto.new(name: "Alice", age: 30)
# user.to_h #=> { name: "Alice", age: 30 }
module Castkit; end
require_relative "castkit/attribute"
130 changes: 27 additions & 103 deletions lib/castkit/attribute.rb
Original file line number Diff line number Diff line change
@@ -1,136 +1,60 @@
# frozen_string_literal: true

require_relative "castkit"
require_relative "error"
require_relative "attributes/options"
require_relative "support/attribute"
require_relative "dsl/attribute"

module Castkit
# Represents a typed attribute on a `Castkit::DataObject`.
#
# This class is responsible for:
# - Type normalization (symbol, class, or data object)
# - Default and option resolution
# - Validation hooks
# - Access and serialization control
#
# Attributes are created automatically when using the DSL in `DataObject`, but
# can also be created manually or through reusable definitions.
#
# @see Castkit::Attributes::Definition
# @see Castkit::DSL::Attribute::Options
# @see Castkit::DSL::Attribute::Access
# @see Castkit::DSL::Attribute::Validation
class Attribute
include Castkit::DSL::Attribute
include DSL::Attribute

class << self
# Defines a reusable attribute definition via a DSL wrapper.
#
# @param type [Symbol, Class] The base type to define.
# @param options [Hash] Additional attribute options.
# @yield The block to configure options or transformations.
# @return [Array<(Symbol, Hash)>] a tuple of the final type and options hash
def define(type, **options, &block)
normalized_type = normalize_type(type)
Castkit::Attributes::Definition.define(normalized_type, **options, &block)
normalized_type = Support::Attribute.normalize_type(type)
Attribute::Definition.define(normalized_type, **options, &block)
end

# Normalizes a declared type (symbol, class, or array) for internal usage.
#
# @param type [Symbol, Class, Array] the input type
# @return [Symbol, Class<Castkit::DataObject>] the normalized form
def normalize_type(type)
return type.map { |t| normalize_type(t) } if type.is_a?(Array)
return type if Castkit.dataobject?(type)
def from_definition(type = nil, definition)
type ||= definition.type
raise AttributeTypeMissing if type.nil?
raise AttributeTypeMismatch if type != definition.type

process_type(type).to_sym
end

# Converts a raw type into a normalized symbol.
#
# Recognized forms:
# - `TrueClass`/`FalseClass` → `:boolean`
# - Class → `class.name.downcase.to_sym`
# - Symbol → passed through
#
# @param type [Symbol, Class] the type to convert
# @return [Symbol] normalized type symbol
# @raise [Castkit::AttributeError] if the type is invalid
def process_type(type)
case type
when Class
return :boolean if [TrueClass, FalseClass].include?(type)

type.name.downcase.to_sym
when Symbol
type
else
raise Castkit::AttributeError, "Unknown type: #{type.inspect}"
end
new(type, definition.options)
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
# @param type [Symbol, Class, Array<Symbol, Class>] the type (or list of types)
# @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)
def initialize(name, type, default: nil, **options)
@name = name
@type = Support::Attribute.normalize_type(type)
@default = default
@options = populate_options(options)
@options = resolve_options(options)

validate!
# validate!
end

def to_definition
Attribute::Definition.new(type, )
end

# Converts the attribute definition to a serializable hash.
#
# @return [Hash] the full attribute metadata
def to_hash
{
field: field,
type: type,
options: options,
default: default
name: @name,
type: @type,
default: @default,
options: @options
}
end

# @see #to_hash
alias to_h to_hash

private

# Populates default values and prepares internal options.
#
# @param options [Hash] the user-provided options
# @return [Hash] the merged and normalized options
def populate_options(options)
options = Castkit::Attributes::Options::DEFAULTS.merge(options)
options[:aliases] = Array(options[:aliases] || [])
options[:of] = self.class.normalize_type(options[:of]) if options[:of]
def resolve_options(options)
options = DSL::Attributes::Options::DEFAULTS.merge(options)

options
end
options[:aliases] = Array(options[:aliases]).compact
options[:of] = Support::Attribute.normalize_type(options[:of]) if options[:of]

# Raises a standardized attribute error with context.
#
# @param message [String] the error message
# @param context [Hash, nil] optional override for context payload
# @raise [Castkit::AttributeError]
def raise_error!(message, context: nil)
raise Castkit::AttributeError.new(message, context: context || to_h)
options
end
end
end
169 changes: 0 additions & 169 deletions lib/castkit/configuration.rb
Original file line number Diff line number Diff line change
@@ -1,171 +1,2 @@
# frozen_string_literal: true

require_relative "types"

module Castkit
# Configuration container for global Castkit settings.
#
# This includes type registration, validation, and enforcement flags
# used throughout Castkit's attribute system.
class Configuration
# Default mapping of primitive type definitions.
#
# @return [Hash{Symbol => Castkit::Types::Base}]
DEFAULT_TYPES = {
array: Castkit::Types::Collection.new,
boolean: Castkit::Types::Boolean.new,
date: Castkit::Types::Date.new,
datetime: Castkit::Types::DateTime.new,
float: Castkit::Types::Float.new,
hash: Castkit::Types::Base.new,
integer: Castkit::Types::Integer.new,
string: Castkit::Types::String.new
}.freeze

# Type aliases for primitive type definitions.
#
# @return [Hash{Symbol => Symbol}]
TYPE_ALIASES = {
collection: :array,
bool: :boolean,
int: :integer,
map: :hash,
number: :float,
str: :string,
timestamp: :datetime,
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<Symbol>] 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

# 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 = []

apply_type_aliases!
end

# Registers a new type definition.
#
# @param type [Symbol] the symbolic type name (e.g., :uuid)
# @param klass [Class<Castkit::Types::Base>] the class to register
# @param override [Boolean] whether to allow overwriting existing registration
# @raise [Castkit::TypeError] if the type class is invalid or not a subclass of Castkit::Types::Base
# @return [void]
def register_type(type, klass, aliases: [], override: false)
type = type.to_sym
return if types.key?(type) && !override

instance = klass.new
unless instance.is_a?(Castkit::Types::Base)
raise Castkit::TypeError, "Expected subclass of Castkit::Types::Base for `#{type}`"
end

types[type] = instance

Castkit::Core::AttributeTypes.define_type_dsl(type) if Castkit::Core::AttributeTypes.respond_to?(:define_type_dsl)
return unless aliases.any?

aliases.each { |alias_type| register_type(alias_type, klass, override: override) }
end

# Register a custom plugin for use with Castkit::DataObject.
#
# @example Loading as a default plugin
# Castkit.configure do |config|
# config.register_plugin(:custom, CustomPlugin)
# config.default_plugins [:custom]
# end
#
# @example Loading it directly in a Castkit::DataObject
# class UserDto < Castkit::DataObject
# enable_plugins :custom
# end
def register_plugin(name, plugin)
Castkit::Plugins.register(name, plugin)
end

# Returns the type handler for a given type symbol.
#
# @param type [Symbol]
# @return [Castkit::Types::Base]
# @raise [Castkit::TypeError] if the type is not registered
def fetch_type(type)
@types.fetch(type.to_sym) do
raise Castkit::TypeError, "Unknown type `#{type.inspect}`" if raise_type_errors
end
end

# Returns whether a type is currently registered.
#
# @param type [Symbol]
# @return [Boolean]
def type_registered?(type)
@types.key?(type.to_sym)
end

# Restores the type registry to its default state.
#
# @return [void]
def reset_types!
@types = DEFAULT_TYPES.dup
apply_type_aliases!
end

private

# Registers aliases for primitive type definitions.
#
# @return [void]
def apply_type_aliases!
TYPE_ALIASES.each do |alias_key, canonical|
register_type(alias_key, DEFAULT_TYPES[canonical].class)
end
end
end
end
Loading
Loading