castkit 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec_status +195 -219
  3. data/CHANGELOG.md +42 -0
  4. data/README.md +744 -83
  5. data/castkit.gemspec +1 -0
  6. data/lib/castkit/attribute.rb +6 -24
  7. data/lib/castkit/castkit.rb +61 -10
  8. data/lib/castkit/cli/generate.rb +98 -0
  9. data/lib/castkit/cli/list.rb +200 -0
  10. data/lib/castkit/cli/main.rb +43 -0
  11. data/lib/castkit/cli.rb +24 -0
  12. data/lib/castkit/configuration.rb +116 -46
  13. data/lib/castkit/contract/base.rb +168 -0
  14. data/lib/castkit/contract/data_object.rb +62 -0
  15. data/lib/castkit/contract/result.rb +74 -0
  16. data/lib/castkit/contract/validator.rb +248 -0
  17. data/lib/castkit/contract.rb +67 -0
  18. data/lib/castkit/{data_object_extensions → core}/attribute_types.rb +21 -7
  19. data/lib/castkit/{data_object_extensions → core}/attributes.rb +8 -3
  20. data/lib/castkit/core/config.rb +74 -0
  21. data/lib/castkit/core/registerable.rb +59 -0
  22. data/lib/castkit/data_object.rb +56 -67
  23. data/lib/castkit/error.rb +15 -3
  24. data/lib/castkit/ext/attribute/access.rb +67 -0
  25. data/lib/castkit/ext/attribute/error_handling.rb +63 -0
  26. data/lib/castkit/ext/attribute/options.rb +142 -0
  27. data/lib/castkit/ext/attribute/validation.rb +85 -0
  28. data/lib/castkit/ext/data_object/contract.rb +96 -0
  29. data/lib/castkit/ext/data_object/deserialization.rb +167 -0
  30. data/lib/castkit/ext/data_object/plugins.rb +86 -0
  31. data/lib/castkit/ext/data_object/serialization.rb +61 -0
  32. data/lib/castkit/inflector.rb +47 -0
  33. data/lib/castkit/plugins.rb +82 -0
  34. data/lib/castkit/serializers/base.rb +94 -0
  35. data/lib/castkit/serializers/default_serializer.rb +156 -0
  36. data/lib/castkit/types/base.rb +122 -0
  37. data/lib/castkit/types/boolean.rb +47 -0
  38. data/lib/castkit/types/collection.rb +35 -0
  39. data/lib/castkit/types/date.rb +34 -0
  40. data/lib/castkit/types/date_time.rb +34 -0
  41. data/lib/castkit/types/float.rb +46 -0
  42. data/lib/castkit/types/integer.rb +46 -0
  43. data/lib/castkit/types/string.rb +44 -0
  44. data/lib/castkit/types.rb +15 -0
  45. data/lib/castkit/validators/base.rb +59 -0
  46. data/lib/castkit/validators/boolean_validator.rb +39 -0
  47. data/lib/castkit/validators/collection_validator.rb +29 -0
  48. data/lib/castkit/validators/float_validator.rb +31 -0
  49. data/lib/castkit/validators/integer_validator.rb +31 -0
  50. data/lib/castkit/validators/numeric_validator.rb +2 -2
  51. data/lib/castkit/validators/string_validator.rb +3 -4
  52. data/lib/castkit/version.rb +1 -1
  53. data/lib/castkit.rb +2 -0
  54. data/lib/generators/base.rb +97 -0
  55. data/lib/generators/contract.rb +68 -0
  56. data/lib/generators/data_object.rb +48 -0
  57. data/lib/generators/plugin.rb +25 -0
  58. data/lib/generators/serializer.rb +28 -0
  59. data/lib/generators/templates/contract.rb.tt +24 -0
  60. data/lib/generators/templates/contract_spec.rb.tt +76 -0
  61. data/lib/generators/templates/data_object.rb.tt +15 -0
  62. data/lib/generators/templates/data_object_spec.rb.tt +36 -0
  63. data/lib/generators/templates/plugin.rb.tt +37 -0
  64. data/lib/generators/templates/plugin_spec.rb.tt +18 -0
  65. data/lib/generators/templates/serializer.rb.tt +24 -0
  66. data/lib/generators/templates/serializer_spec.rb.tt +14 -0
  67. data/lib/generators/templates/type.rb.tt +55 -0
  68. data/lib/generators/templates/type_spec.rb.tt +42 -0
  69. data/lib/generators/templates/validator.rb.tt +26 -0
  70. data/lib/generators/templates/validator_spec.rb.tt +23 -0
  71. data/lib/generators/type.rb +29 -0
  72. data/lib/generators/validator.rb +41 -0
  73. metadata +74 -15
  74. data/lib/castkit/attribute_extensions/access.rb +0 -65
  75. data/lib/castkit/attribute_extensions/casting.rb +0 -147
  76. data/lib/castkit/attribute_extensions/error_handling.rb +0 -83
  77. data/lib/castkit/attribute_extensions/options.rb +0 -131
  78. data/lib/castkit/attribute_extensions/serialization.rb +0 -89
  79. data/lib/castkit/attribute_extensions/validation.rb +0 -72
  80. data/lib/castkit/data_object_extensions/config.rb +0 -113
  81. data/lib/castkit/data_object_extensions/deserialization.rb +0 -110
  82. data/lib/castkit/default_serializer.rb +0 -123
  83. data/lib/castkit/serializer.rb +0 -92
  84. data/lib/castkit/validators.rb +0 -4
@@ -1,39 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "validators"
3
+ require_relative "types"
4
4
 
5
5
  module Castkit
6
6
  # Configuration container for global Castkit settings.
7
7
  #
8
- # This includes validator registration and enforcement flags for various runtime checks.
8
+ # This includes type registration, validation, and enforcement flags
9
+ # used throughout Castkit's attribute system.
9
10
  class Configuration
10
- # Default mapping of primitive types to validators.
11
+ # Default mapping of primitive type definitions.
11
12
  #
12
- # @return [Hash<Symbol, Class>]
13
- DEFAULT_VALIDATORS = {
14
- string: Castkit::Validators::StringValidator,
15
- integer: Castkit::Validators::NumericValidator,
16
- float: Castkit::Validators::NumericValidator
13
+ # @return [Hash{Symbol => Castkit::Types::Base}]
14
+ DEFAULT_TYPES = {
15
+ array: Castkit::Types::Collection.new,
16
+ boolean: Castkit::Types::Boolean.new,
17
+ date: Castkit::Types::Date.new,
18
+ datetime: Castkit::Types::DateTime.new,
19
+ float: Castkit::Types::Float.new,
20
+ hash: Castkit::Types::Base.new,
21
+ integer: Castkit::Types::Integer.new,
22
+ string: Castkit::Types::String.new
17
23
  }.freeze
18
24
 
19
- # @return [Hash<Symbol, #call>] registered validators by type
20
- attr_reader :validators
21
-
22
- # Whether to raise an error if `of:` is missing for array types.
23
- # @return [Boolean]
24
- attr_accessor :enforce_array_of_type
25
+ # Type aliases for primitive type definitions.
26
+ #
27
+ # @return [Hash{Symbol => Symbol}]
28
+ TYPE_ALIASES = {
29
+ collection: :array,
30
+ bool: :boolean,
31
+ int: :integer,
32
+ map: :hash,
33
+ number: :float,
34
+ str: :string,
35
+ timestamp: :datetime,
36
+ uuid: :string
37
+ }.freeze
25
38
 
26
- # Whether to raise an error for unrecognized primitive types.
27
- # @return [Boolean]
28
- attr_accessor :enforce_known_primitive_type
39
+ # @return [Hash{Symbol => Castkit::Types::Base}] registered types
40
+ attr_reader :types
29
41
 
30
- # Whether to raise an error on invalid boolean coercion.
31
- # @return [Boolean]
32
- attr_accessor :enforce_boolean_casting
42
+ # Set default plugins that will be used globally in all Castkit::DataObject subclasses.
43
+ # This is equivalent to calling `enable_plugins` in every class.
44
+ #
45
+ # @return [Array<Symbol>] default plugin names to be applied to all DataObject subclasses
46
+ attr_accessor :default_plugins
33
47
 
34
- # Whether to raise an error if a union type has no matching candidate.
48
+ # Whether to raise an error if values should be validated before deserializing, e.g. true -> "true"
35
49
  # @return [Boolean]
36
- attr_accessor :enforce_union_match
50
+ attr_accessor :enforce_typing
37
51
 
38
52
  # Whether to raise an error if access mode is not recognized.
39
53
  # @return [Boolean]
@@ -47,55 +61,111 @@ module Castkit
47
61
  # @return [Boolean]
48
62
  attr_accessor :enforce_array_options
49
63
 
50
- # Whether to generating warnings or not, defaults to `true`.
64
+ # Whether to raise an error for unknown and invalid type definitions.
65
+ # @return [Boolean]
66
+ attr_accessor :raise_type_errors
67
+
68
+ # Whether to emit warnings when Castkit detects misconfigurations.
51
69
  # @return [Boolean]
52
70
  attr_accessor :enable_warnings
53
71
 
54
- # Initializes the configuration with default validators and enforcement settings.
72
+ # Whether the strict flag is enabled by default for all DataObjects and Contracts.
73
+ # @return [Boolean]
74
+ attr_accessor :strict_by_default
75
+
76
+ # Initializes the configuration with default types and enforcement flags.
55
77
  #
56
78
  # @return [void]
57
79
  def initialize
58
- @validators = DEFAULT_VALIDATORS.dup
59
- @enforce_array_of_type = true
60
- @enforce_known_primitive_type = true
61
- @enforce_boolean_casting = true
62
- @enforce_union_match = true
80
+ @types = DEFAULT_TYPES.dup
81
+ @enforce_typing = true
63
82
  @enforce_attribute_access = true
64
83
  @enforce_unwrapped_prefix = true
65
84
  @enforce_array_options = true
85
+ @raise_type_errors = true
66
86
  @enable_warnings = true
87
+ @strict_by_default = true
88
+ @default_plugins = []
89
+
90
+ apply_type_aliases!
67
91
  end
68
92
 
69
- # Registers a custom validator for a given type.
93
+ # Registers a new type definition.
70
94
  #
71
- # @param type [Symbol] the type symbol (e.g., :string, :integer)
72
- # @param validator [#call] a callable object that implements `call(value, options:, context:)`
73
- # @param override [Boolean] whether to override an existing validator
74
- # @raise [Castkit::Error] if validator does not respond to `.call`
95
+ # @param type [Symbol] the symbolic type name (e.g., :uuid)
96
+ # @param klass [Class<Castkit::Types::Base>] the class to register
97
+ # @param override [Boolean] whether to allow overwriting existing registration
98
+ # @raise [Castkit::TypeError] if the type class is invalid or not a subclass of Castkit::Types::Base
75
99
  # @return [void]
76
- def register_validator(type, validator, override: false)
77
- return if @validators.key?(type.to_sym) && !override
100
+ def register_type(type, klass, aliases: [], override: false)
101
+ type = type.to_sym
102
+ return if types.key?(type) && !override
78
103
 
79
- unless validator.respond_to?(:call)
80
- raise Castkit::Error, "Validator for `#{type}` must respond to `.call(value, options:, context:)`"
104
+ instance = klass.new
105
+ unless instance.is_a?(Castkit::Types::Base)
106
+ raise Castkit::TypeError, "Expected subclass of Castkit::Types::Base for `#{type}`"
81
107
  end
82
108
 
83
- @validators[type.to_sym] = validator
109
+ types[type] = instance
110
+
111
+ Castkit::Core::AttributeTypes.define_type_dsl(type) if Castkit::Core::AttributeTypes.respond_to?(:define_type_dsl)
112
+ return unless aliases.any?
113
+
114
+ aliases.each { |alias_type| register_type(alias_type, klass, override: override) }
115
+ end
116
+
117
+ # Register a custom plugin for use with Castkit::DataObject.
118
+ #
119
+ # @example Loading as a default plugin
120
+ # Castkit.configure do |config|
121
+ # config.register_plugin(:custom, CustomPlugin)
122
+ # config.default_plugins [:custom]
123
+ # end
124
+ #
125
+ # @example Loading it directly in a Castkit::DataObject
126
+ # class UserDto < Castkit::DataObject
127
+ # enable_plugins :custom
128
+ # end
129
+ def register_plugin(name, plugin)
130
+ Castkit::Plugins.register(name, plugin)
84
131
  end
85
132
 
86
- # Returns the registered validator for the given type.
133
+ # Returns the type handler for a given type symbol.
87
134
  #
88
135
  # @param type [Symbol]
89
- # @return [#call, nil]
90
- def validator_for(type)
91
- validators[type.to_sym]
136
+ # @return [Castkit::Types::Base]
137
+ # @raise [Castkit::TypeError] if the type is not registered
138
+ def fetch_type(type)
139
+ @types.fetch(type.to_sym) do
140
+ raise Castkit::TypeError, "Unknown type `#{type.inspect}`" if raise_type_errors
141
+ end
92
142
  end
93
143
 
94
- # Resets all validators to their default mappings.
144
+ # Returns whether a type is currently registered.
145
+ #
146
+ # @param type [Symbol]
147
+ # @return [Boolean]
148
+ def type_registered?(type)
149
+ @types.key?(type.to_sym)
150
+ end
151
+
152
+ # Restores the type registry to its default state.
95
153
  #
96
154
  # @return [void]
97
- def reset_validators!
98
- @validators = DEFAULT_VALIDATORS.dup
155
+ def reset_types!
156
+ @types = DEFAULT_TYPES.dup
157
+ apply_type_aliases!
158
+ end
159
+
160
+ private
161
+
162
+ # Registers aliases for primitive type definitions.
163
+ #
164
+ # @return [void]
165
+ def apply_type_aliases!
166
+ TYPE_ALIASES.each do |alias_key, canonical|
167
+ register_type(alias_key, DEFAULT_TYPES[canonical].class)
168
+ end
99
169
  end
100
170
  end
101
171
  end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../core/config"
4
+ require_relative "../core/attribute_types"
5
+ require_relative "../core/registerable"
6
+ require_relative "result"
7
+
8
+ module Castkit
9
+ module Contract
10
+ # Base class for all Castkit contracts.
11
+ #
12
+ # Castkit contracts define validation logic over a set of attributes using a DSL.
13
+ # You can either subclass this directly or use {Castkit::Contract.build} to generate
14
+ # ephemeral or reusable contract classes.
15
+ #
16
+ # @example Subclassing directly
17
+ # class MyContract < Castkit::Contract::Base
18
+ # string :id
19
+ # integer :count, required: false
20
+ # end
21
+ #
22
+ # MyContract.validate!(id: "abc")
23
+ #
24
+ # @example Using Contract.build (preferred for dynamic generation)
25
+ # UserContract = Castkit::Contract.build(:user) do
26
+ # string :id
27
+ # string :email, required: false
28
+ # end
29
+ #
30
+ # UserContract.validate!(id: "123")
31
+ #
32
+ # @see Castkit::Contract.build
33
+ class Base
34
+ extend Castkit::Core::Config
35
+ extend Castkit::Core::AttributeTypes
36
+ extend Castkit::Core::Registerable
37
+
38
+ ATTRIBUTE_OPTIONS = %i[
39
+ required aliases min max format of validator unwrapped prefix force_type
40
+ ].freeze
41
+
42
+ class << self
43
+ # Registers the current class under `Castkit::Contracts`.
44
+ #
45
+ # @param as [String, Symbol, nil] The constant name to use (PascalCase). Defaults to the name used when building
46
+ # the contract. If no name was provided, an error is raised.
47
+ # @return [Class] the registered contract class
48
+ # @raise [Castkit::Error] If a name cannot be resolved.
49
+ def register!(as: nil)
50
+ super(namespace: :contracts, as: as || definition[:name])
51
+ end
52
+
53
+ # Defines an attribute for the contract.
54
+ #
55
+ # Only a subset of options is allowed inside a contract.
56
+ #
57
+ # @param field [Symbol] the field name
58
+ # @param type [Symbol, Class, Array] the type or union of types
59
+ # @param options [Hash] allowed options like :required or :validator
60
+ # @return [void]
61
+ def attribute(field, type, **options)
62
+ options = options.slice(*ATTRIBUTE_OPTIONS)
63
+ attributes[field] = Castkit::Attribute.new(field, type, **options)
64
+ end
65
+
66
+ # Validates input against the contract and returns a Result.
67
+ #
68
+ # @param input [Hash]
69
+ # @return [Castkit::Contract::Result]
70
+ def validate(input)
71
+ validate!(input)
72
+ rescue Castkit::ContractError => e
73
+ Castkit::Contract::Result.new(definition[:name].to_s, input, errors: e.errors)
74
+ end
75
+
76
+ # Validates input and raises on failure.
77
+ #
78
+ # @param input [Hash]
79
+ # @raise [Castkit::ContractError]
80
+ # @return [void]
81
+ def validate!(input)
82
+ Castkit::Contract::Validator.call!(attributes.values, input, **validation_rules)
83
+ Castkit::Contract::Result.new(definition[:name].to_s, input)
84
+ end
85
+
86
+ # Returns internal contract metadata.
87
+ #
88
+ # @return [Hash]
89
+ def definition
90
+ @definition ||= {
91
+ name: :ephemeral,
92
+ attributes: {}
93
+ }
94
+ end
95
+
96
+ # Returns the defined attributes.
97
+ #
98
+ # @return [Hash{Symbol => Castkit::Attribute}]
99
+ def attributes
100
+ definition[:attributes]
101
+ end
102
+
103
+ private
104
+
105
+ # Defines the contract from a source or block.
106
+ #
107
+ # @param name [Symbol, String]
108
+ # @param source [Castkit::DataObject, nil]
109
+ # @param block [Proc, nil]
110
+ # @return [Hash]
111
+ def define(name = :ephemeral, source = nil, validation_rules: {}, &block)
112
+ validate_definition!(source, &block)
113
+
114
+ if source
115
+ define_from_source(name, source)
116
+ else
117
+ define_from_block(name, &block)
118
+ end
119
+
120
+ validation_rules.each { |k, v| self.validation_rules[k] = v }
121
+ attributes
122
+ end
123
+
124
+ # Copies attributes from a DataObject.
125
+ #
126
+ # @param name [Symbol, String]
127
+ # @param source [Castkit::DataObject]
128
+ # @return [void]
129
+ def define_from_source(name, source)
130
+ source_attributes = source.attributes.dup
131
+
132
+ @definition = {
133
+ name: name,
134
+ attributes: source_attributes.transform_values do |attr|
135
+ Castkit::Attribute.new(attr.field, attr.type, **attr.options.slice(*ATTRIBUTE_OPTIONS))
136
+ end
137
+ }
138
+ end
139
+
140
+ # Executes DSL block in the contract context.
141
+ #
142
+ # @param name [Symbol, String]
143
+ # @yield [block]
144
+ # @return [void]
145
+ def define_from_block(name, &block)
146
+ definition[:name] = name
147
+
148
+ @__castkit_contract_dsl = true
149
+ instance_eval(&block)
150
+ ensure
151
+ @__castkit_contract_dsl = false
152
+ end
153
+
154
+ # Ensures a valid contract definition input.
155
+ #
156
+ # @param source [Object, nil]
157
+ # @raise [Castkit::ContractError]
158
+ # @return [void]
159
+ def validate_definition!(source)
160
+ raise Castkit::ContractError, "Received both source and block" if source && block_given?
161
+ return if block_given? || Castkit.dataobject?(source)
162
+
163
+ raise Castkit::ContractError, "Expected a Castkit::DataObject or contract block"
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module Contract
5
+ # Extension module that adds `.to_dataobject` and `.dataobject` support to Castkit contracts.
6
+ #
7
+ # This allows any contract to be dynamically converted into a Castkit::DataObject class,
8
+ # enabling reuse of validation schemas for serialization, coercion, or API response modeling.
9
+ #
10
+ # This module is automatically included by Castkit contract classes and is not
11
+ # intended to be used manually.
12
+ #
13
+ # @example
14
+ # contract = Castkit::Contract.build(:user) do
15
+ # string :id
16
+ # string :email
17
+ # end
18
+ #
19
+ # UserDto = contract.to_dataobject
20
+ # UserDto.new(id: "123", email: "a@example.com")
21
+ module DataObject
22
+ # Returns or builds a Castkit::DataObject from the current contract.
23
+ #
24
+ # Memoizes the result to avoid repeated regeneration.
25
+ #
26
+ # @example
27
+ # contract = Castkit::Contract.build(:user) do
28
+ # string :id
29
+ # string :name
30
+ # end
31
+ #
32
+ # dto_class = contract.dataobject
33
+ # dto = dto_class.new(id: "123", name: "Alice")
34
+ #
35
+ # @return [Class<Castkit::DataObject>] the generated DTO class
36
+ def dataobject
37
+ @dataobject ||= to_dataobject
38
+ end
39
+
40
+ # Constructs an ephemeral Castkit::DataObject class from the current contract.
41
+ #
42
+ # This creates a new anonymous class each time unless memoized via {#dataobject}.
43
+ #
44
+ # @example
45
+ # dto_class = contract.to_dataobject
46
+ #
47
+ # @return [Class<Castkit::DataObject>] the dynamically generated DTO
48
+ def to_dataobject
49
+ Class.new(Castkit::DataObject).tap do |klass|
50
+ attributes.each_value do |attr|
51
+ klass.attribute(attr.field, attr.type, **attr.options)
52
+ end
53
+ end
54
+ end
55
+
56
+ # Alias for {#to_dataobject}
57
+ #
58
+ # @see #to_dataobject
59
+ alias to_dto to_dataobject
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Castkit
4
+ module Contract
5
+ # Represents the result of a contract validation.
6
+ #
7
+ # Provides access to the validation outcome, including whether it succeeded or failed,
8
+ # and includes the full list of errors if any.
9
+ class Result
10
+ # @return [Symbol] the name of the contract
11
+ attr_reader :contract
12
+
13
+ # @return [Hash{Symbol => Object}] the validated input
14
+ attr_reader :input
15
+
16
+ # @return [Hash{Symbol => Object}] the validation error hash
17
+ attr_reader :errors
18
+
19
+ # Initializes a new result object.
20
+ #
21
+ # @param contract [Symbol, String] the name of the contract
22
+ # @param input [Hash{Symbol => Object}] the validated input
23
+ # @param errors [Hash{Symbol => Object}] the validation errors
24
+ def initialize(contract, input, errors: {})
25
+ @contract = contract.to_sym.freeze
26
+ @input = input.freeze
27
+ @errors = errors.freeze
28
+ end
29
+
30
+ # A debug-friendly representation of the validation result.
31
+ #
32
+ # @return [String]
33
+ def inspect
34
+ "#<#{self.class.name} contract=#{contract.inspect} success=#{success?} errors=#{errors.inspect}>"
35
+ end
36
+
37
+ # Whether the validation passed with no errors.
38
+ #
39
+ # @return [Boolean]
40
+ def success?
41
+ errors.empty?
42
+ end
43
+
44
+ # Whether the validation failed with one or more errors.
45
+ #
46
+ # @return [Boolean]
47
+ def failure?
48
+ !success?
49
+ end
50
+
51
+ # A readable string representation of the validation result.
52
+ #
53
+ # @return [String]
54
+ def to_s
55
+ return "[Castkit] Contract validation passed for #{contract}" if success?
56
+
57
+ parsed_errors = errors.map { |k, v| " #{k}: #{v.inspect}" }.join("\n")
58
+ "[Castkit] Contract validation failed for #{contract}:\n#{parsed_errors}"
59
+ end
60
+
61
+ # @return [Hash{Symbol => Object}] the input validation and error hash
62
+ def to_hash
63
+ @to_hash ||= {
64
+ contract: contract,
65
+ input: input,
66
+ errors: errors
67
+ }.freeze
68
+ end
69
+
70
+ # @return [Hash{Symbol => Object}] the input and validation error hash
71
+ alias to_h to_hash
72
+ end
73
+ end
74
+ end