easy_talk 3.1.0 → 3.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -39
  3. data/.yardopts +13 -0
  4. data/CHANGELOG.md +164 -0
  5. data/README.md +442 -1529
  6. data/Rakefile +27 -0
  7. data/docs/.gitignore +1 -0
  8. data/docs/about.markdown +28 -8
  9. data/docs/getting-started.markdown +102 -0
  10. data/docs/index.markdown +51 -4
  11. data/docs/json_schema_compliance.md +169 -0
  12. data/docs/nested-models.markdown +216 -0
  13. data/docs/primitive-schema-rfc.md +894 -0
  14. data/docs/property-types.markdown +212 -0
  15. data/docs/schema-definition.markdown +180 -0
  16. data/lib/easy_talk/builders/base_builder.rb +6 -3
  17. data/lib/easy_talk/builders/boolean_builder.rb +2 -1
  18. data/lib/easy_talk/builders/collection_helpers.rb +4 -0
  19. data/lib/easy_talk/builders/composition_builder.rb +16 -13
  20. data/lib/easy_talk/builders/integer_builder.rb +2 -1
  21. data/lib/easy_talk/builders/null_builder.rb +4 -1
  22. data/lib/easy_talk/builders/number_builder.rb +4 -1
  23. data/lib/easy_talk/builders/object_builder.rb +109 -33
  24. data/lib/easy_talk/builders/registry.rb +182 -0
  25. data/lib/easy_talk/builders/string_builder.rb +3 -1
  26. data/lib/easy_talk/builders/temporal_builder.rb +7 -0
  27. data/lib/easy_talk/builders/tuple_builder.rb +89 -0
  28. data/lib/easy_talk/builders/typed_array_builder.rb +19 -6
  29. data/lib/easy_talk/builders/union_builder.rb +5 -1
  30. data/lib/easy_talk/configuration.rb +47 -2
  31. data/lib/easy_talk/error_formatter/base.rb +100 -0
  32. data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
  33. data/lib/easy_talk/error_formatter/flat.rb +38 -0
  34. data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
  35. data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
  36. data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
  37. data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
  38. data/lib/easy_talk/error_formatter.rb +143 -0
  39. data/lib/easy_talk/errors.rb +3 -0
  40. data/lib/easy_talk/errors_helper.rb +66 -34
  41. data/lib/easy_talk/json_schema_equality.rb +46 -0
  42. data/lib/easy_talk/keywords.rb +0 -1
  43. data/lib/easy_talk/model.rb +148 -89
  44. data/lib/easy_talk/model_helper.rb +17 -0
  45. data/lib/easy_talk/naming_strategies.rb +24 -0
  46. data/lib/easy_talk/property.rb +23 -94
  47. data/lib/easy_talk/ref_helper.rb +33 -0
  48. data/lib/easy_talk/schema.rb +199 -0
  49. data/lib/easy_talk/schema_definition.rb +57 -5
  50. data/lib/easy_talk/schema_methods.rb +111 -0
  51. data/lib/easy_talk/sorbet_extension.rb +1 -0
  52. data/lib/easy_talk/tools/function_builder.rb +1 -1
  53. data/lib/easy_talk/type_introspection.rb +222 -0
  54. data/lib/easy_talk/types/base_composer.rb +2 -1
  55. data/lib/easy_talk/types/composer.rb +4 -0
  56. data/lib/easy_talk/types/tuple.rb +77 -0
  57. data/lib/easy_talk/validation_adapters/active_model_adapter.rb +617 -0
  58. data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
  59. data/lib/easy_talk/validation_adapters/base.rb +156 -0
  60. data/lib/easy_talk/validation_adapters/none_adapter.rb +45 -0
  61. data/lib/easy_talk/validation_adapters/registry.rb +87 -0
  62. data/lib/easy_talk/validation_builder.rb +29 -309
  63. data/lib/easy_talk/version.rb +1 -1
  64. data/lib/easy_talk.rb +42 -0
  65. metadata +38 -7
  66. data/docs/404.html +0 -25
  67. data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
  68. data/easy_talk.gemspec +0 -39
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module ValidationAdapters
5
+ # ActiveModel-specific schema-level validations for object-level constraints.
6
+ #
7
+ # This module provides ActiveModel validations for JSON Schema keywords that apply
8
+ # to the object as a whole rather than individual properties:
9
+ # - minProperties: minimum number of properties that must be present
10
+ # - maxProperties: maximum number of properties that can be present
11
+ # - dependentRequired: conditional property requirements
12
+ #
13
+ # This module is tightly coupled with ActiveModel and is used exclusively by
14
+ # ActiveModelAdapter. Other validation adapters should implement their own
15
+ # schema-level validation logic.
16
+ #
17
+ module ActiveModelSchemaValidation
18
+ # Apply all applicable schema-level validations.
19
+ #
20
+ # @param klass [Class] The model class to apply validations to
21
+ # @param schema [Hash] The full schema hash containing schema-level constraints
22
+ # @return [void]
23
+ def self.apply(klass, schema)
24
+ apply_min_properties_validation(klass, schema[:min_properties]) if schema[:min_properties]
25
+ apply_max_properties_validation(klass, schema[:max_properties]) if schema[:max_properties]
26
+ apply_dependent_required_validation(klass, schema[:dependent_required]) if schema[:dependent_required]
27
+ end
28
+
29
+ # Apply minimum properties validation.
30
+ #
31
+ # @param klass [Class] The model class
32
+ # @param min_count [Integer] Minimum number of properties that must be present
33
+ def self.apply_min_properties_validation(klass, min_count)
34
+ define_count_method(klass)
35
+
36
+ klass.validate do |record|
37
+ present_count = record.send(:count_present_properties)
38
+ if present_count < min_count
39
+ record.errors.add(:base, "must have at least #{min_count} #{min_count == 1 ? 'property' : 'properties'} present")
40
+ end
41
+ end
42
+ end
43
+
44
+ # Apply maximum properties validation.
45
+ #
46
+ # @param klass [Class] The model class
47
+ # @param max_count [Integer] Maximum number of properties that can be present
48
+ def self.apply_max_properties_validation(klass, max_count)
49
+ define_count_method(klass)
50
+
51
+ klass.validate do |record|
52
+ present_count = record.send(:count_present_properties)
53
+ if present_count > max_count
54
+ record.errors.add(:base, "must have at most #{max_count} #{max_count == 1 ? 'property' : 'properties'} present")
55
+ end
56
+ end
57
+ end
58
+
59
+ # Apply dependent required validation.
60
+ # When a trigger property is present, all dependent properties must also be present.
61
+ #
62
+ # @param klass [Class] The model class
63
+ # @param dependencies [Hash<String, Array<String>>] Map of trigger properties to required properties
64
+ def self.apply_dependent_required_validation(klass, dependencies)
65
+ dependencies.each do |trigger_property, required_properties|
66
+ trigger_prop = trigger_property.to_sym
67
+ required_props = required_properties.map(&:to_sym)
68
+
69
+ klass.validate do |record|
70
+ trigger_value = record.public_send(trigger_prop)
71
+ trigger_present = trigger_value.present? || trigger_value == false
72
+
73
+ next unless trigger_present
74
+
75
+ required_props.each do |required_prop|
76
+ value = record.public_send(required_prop)
77
+ value_present = value.present? || value == false
78
+
79
+ record.errors.add(required_prop, "is required when #{trigger_prop} is present") unless value_present
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ # Define the count_present_properties private instance method on the class if not already defined.
86
+ # The method counts how many schema properties have non-nil/non-blank values.
87
+ #
88
+ # @param klass [Class] The model class
89
+ def self.define_count_method(klass)
90
+ # Check for private methods as well with the second argument
91
+ return if klass.method_defined?(:count_present_properties, true)
92
+
93
+ klass.send(:define_method, :count_present_properties) do
94
+ schema_props = self.class.schema_definition.schema[:properties] || {}
95
+ schema_props.keys.count do |prop|
96
+ value = public_send(prop)
97
+ value.present? || value == false # false is a valid present value
98
+ end
99
+ end
100
+ klass.send(:private, :count_present_properties)
101
+ end
102
+
103
+ private_class_method :define_count_method
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module ValidationAdapters
5
+ # Abstract base class for validation adapters.
6
+ #
7
+ # Validation adapters are responsible for converting JSON Schema constraints
8
+ # into validation rules for the target validation framework (e.g., ActiveModel,
9
+ # dry-validation, or custom validators).
10
+ #
11
+ # To create a custom adapter, subclass this class and implement the
12
+ # `apply_validations` method.
13
+ #
14
+ # @example Creating a custom adapter
15
+ # class MyCustomAdapter < EasyTalk::ValidationAdapters::Base
16
+ # def apply_validations
17
+ # # Apply custom validations to @klass based on @constraints
18
+ # @klass.validates @property_name, presence: true unless optional?
19
+ # end
20
+ # end
21
+ #
22
+ # @example Registering and using a custom adapter
23
+ # EasyTalk::ValidationAdapters::Registry.register(:custom, MyCustomAdapter)
24
+ #
25
+ # class User
26
+ # include EasyTalk::Model
27
+ # define_schema(validations: :custom) do
28
+ # property :name, String
29
+ # end
30
+ # end
31
+ #
32
+ class Base
33
+ # Build validations for a property and apply them to the model class.
34
+ # This is the primary interface that adapters must implement.
35
+ #
36
+ # @param klass [Class] The model class to apply validations to
37
+ # @param property_name [Symbol, String] The name of the property
38
+ # @param type [Class, Object] The type of the property (Ruby class or Sorbet type)
39
+ # @param constraints [Hash] The JSON Schema constraints for the property
40
+ # Possible keys: :min_length, :max_length, :minimum, :maximum, :pattern,
41
+ # :format, :enum, :const, :min_items, :max_items, :unique_items, :optional
42
+ # @return [void]
43
+ def self.build_validations(klass, property_name, type, constraints)
44
+ new(klass, property_name, type, constraints).apply_validations
45
+ end
46
+
47
+ # Build schema-level validations (e.g., min_properties, max_properties, dependent_required).
48
+ # Subclasses can override this method to implement schema-level validations.
49
+ #
50
+ # @param klass [Class] The model class to apply validations to
51
+ # @param schema [Hash] The full schema hash containing schema-level constraints
52
+ # @return [void]
53
+ def self.build_schema_validations(klass, schema)
54
+ # Default implementation does nothing - subclasses can override
55
+ end
56
+
57
+ # Initialize a new validation adapter instance.
58
+ #
59
+ # @param klass [Class] The model class to apply validations to
60
+ # @param property_name [Symbol, String] The name of the property
61
+ # @param type [Class, Object] The type of the property
62
+ # @param constraints [Hash] The JSON Schema constraints for the property
63
+ def initialize(klass, property_name, type, constraints)
64
+ @klass = klass
65
+ @property_name = property_name.to_sym
66
+ @type = type
67
+ @constraints = constraints || {}
68
+ end
69
+
70
+ # Apply validations based on property type and constraints.
71
+ # Subclasses MUST implement this method.
72
+ #
73
+ # @abstract
74
+ # @return [void]
75
+ # @raise [NotImplementedError] if the subclass does not implement this method
76
+ def apply_validations
77
+ raise NotImplementedError, "#{self.class} must implement #apply_validations"
78
+ end
79
+
80
+ protected
81
+
82
+ attr_reader :klass, :property_name, :type, :constraints
83
+
84
+ # Check if a property is optional based on constraints and configuration.
85
+ #
86
+ # A property is considered optional if:
87
+ # - The :optional constraint is explicitly set to true
88
+ # - The type is nilable AND nilable_is_optional configuration is true
89
+ #
90
+ # @return [Boolean] true if the property is optional
91
+ def optional?
92
+ @constraints[:optional] == true ||
93
+ (@type.respond_to?(:nilable?) && @type.nilable? && EasyTalk.configuration.nilable_is_optional)
94
+ end
95
+
96
+ # Check if the type is nilable (e.g., T.nilable(String)).
97
+ #
98
+ # @param t [Class, Object] The type to check (defaults to @type)
99
+ # @return [Boolean] true if the type is nilable
100
+ def nilable_type?(type_to_check = @type)
101
+ type_to_check.respond_to?(:nilable?) && type_to_check.nilable?
102
+ end
103
+
104
+ # Extract the inner type from a complex type like T.nilable(String) or T.nilable(T::Array[Model]).
105
+ #
106
+ # @param type_to_unwrap [Class, Object] The type to unwrap (defaults to @type)
107
+ # @return [Class, Object] The inner type, or the original type if not wrapped
108
+ def extract_inner_type(type_to_unwrap = @type)
109
+ if type_to_unwrap.respond_to?(:unwrap_nilable)
110
+ unwrapped = type_to_unwrap.unwrap_nilable
111
+ # Return TypedArray directly (for T.nilable(T::Array[Model]))
112
+ return unwrapped if unwrapped.is_a?(T::Types::TypedArray)
113
+ # Return raw_type for simple types (for T.nilable(String))
114
+ return unwrapped.raw_type if unwrapped.respond_to?(:raw_type)
115
+ end
116
+
117
+ if type_to_unwrap.respond_to?(:types)
118
+ # For union types, find the non-nil type
119
+ # Prefer TypedArray if present, otherwise find type with raw_type
120
+ type_to_unwrap.types.find { |t| t.is_a?(T::Types::TypedArray) } ||
121
+ type_to_unwrap.types.find { |t| t.respond_to?(:raw_type) && t.raw_type != NilClass }
122
+ else
123
+ type_to_unwrap
124
+ end
125
+ end
126
+
127
+ # Determine the actual class for a type, handling Sorbet types.
128
+ #
129
+ # @param type_to_resolve [Class, Object] The type to resolve
130
+ # @return [Class, Array<Class>] The resolved class or classes
131
+ def get_type_class(type_to_resolve)
132
+ if type_to_resolve.is_a?(Class)
133
+ type_to_resolve
134
+ elsif type_to_resolve.respond_to?(:raw_type)
135
+ type_to_resolve.raw_type
136
+ elsif type_to_resolve.is_a?(T::Types::TypedArray)
137
+ Array
138
+ elsif type_to_resolve.is_a?(EasyTalk::Types::Tuple)
139
+ Array
140
+ elsif type_to_resolve.is_a?(Symbol) || type_to_resolve.is_a?(String)
141
+ begin
142
+ type_to_resolve.to_s.classify.constantize
143
+ rescue StandardError
144
+ String
145
+ end
146
+ elsif TypeIntrospection.boolean_type?(type_to_resolve)
147
+ [TrueClass, FalseClass]
148
+ elsif nilable_type?(type_to_resolve)
149
+ extract_inner_type(type_to_resolve)
150
+ else
151
+ String
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module ValidationAdapters
5
+ # No-op validation adapter.
6
+ #
7
+ # This adapter does not apply any validations. Use it when you want
8
+ # schema generation without any validation side effects.
9
+ #
10
+ # @example Disabling validations for a model
11
+ # class ApiContract
12
+ # include EasyTalk::Model
13
+ #
14
+ # define_schema(validations: :none) do
15
+ # property :name, String, min_length: 5
16
+ # end
17
+ # end
18
+ #
19
+ # contract = ApiContract.new(name: 'X')
20
+ # contract.valid? # => true (no validations applied)
21
+ #
22
+ # @example Using globally
23
+ # EasyTalk.configure do |config|
24
+ # config.validation_adapter = :none
25
+ # end
26
+ #
27
+ class NoneAdapter < Base
28
+ # Build no schema-level validations (no-op).
29
+ #
30
+ # @param klass [Class] The model class (unused)
31
+ # @param schema [Hash] The schema hash (unused)
32
+ # @return [void]
33
+ def self.build_schema_validations(klass, schema)
34
+ # Intentionally empty - no validations applied
35
+ end
36
+
37
+ # Apply no validations (no-op).
38
+ #
39
+ # @return [void]
40
+ def apply_validations
41
+ # Intentionally empty - no validations applied
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module ValidationAdapters
5
+ # Registry for validation adapters.
6
+ #
7
+ # The registry allows adapters to be registered with symbolic names and
8
+ # resolved from various input types (symbols, classes, or nil for default).
9
+ #
10
+ # @example Registering an adapter
11
+ # EasyTalk::ValidationAdapters::Registry.register(:custom, MyCustomAdapter)
12
+ #
13
+ # @example Resolving an adapter
14
+ # adapter = EasyTalk::ValidationAdapters::Registry.resolve(:active_model)
15
+ # adapter.build_validations(klass, :name, String, {})
16
+ #
17
+ class Registry
18
+ class << self
19
+ # Get the hash of registered adapters.
20
+ #
21
+ # @return [Hash{Symbol => Class}] The registered adapters
22
+ def adapters
23
+ @adapters ||= {}
24
+ end
25
+
26
+ # Register an adapter with a symbolic name.
27
+ #
28
+ # @param name [Symbol, String] The adapter identifier
29
+ # @param adapter_class [Class] The adapter class (must respond to .build_validations)
30
+ # @raise [ArgumentError] if the adapter does not respond to .build_validations
31
+ # @return [void]
32
+ def register(name, adapter_class)
33
+ raise ArgumentError, "Adapter must respond to .build_validations" unless adapter_class.respond_to?(:build_validations)
34
+
35
+ adapters[name.to_sym] = adapter_class
36
+ end
37
+
38
+ # Resolve an adapter from various input types.
39
+ #
40
+ # @param adapter [Symbol, Class, nil] The adapter identifier or class
41
+ # - nil: returns the default :active_model adapter
42
+ # - Symbol: looks up the adapter by name in the registry
43
+ # - Class: returns the class directly (assumes it implements the adapter interface)
44
+ # @return [Class] The adapter class
45
+ # @raise [ArgumentError] if the adapter symbol is not registered or type is invalid
46
+ def resolve(adapter)
47
+ case adapter
48
+ when nil
49
+ adapters[:active_model] || raise(ArgumentError, "No default adapter registered")
50
+ when Symbol
51
+ adapters[adapter] || raise(ArgumentError, "Unknown validation adapter: #{adapter.inspect}")
52
+ when Class
53
+ adapter
54
+ else
55
+ raise ArgumentError, "Invalid adapter type: #{adapter.class}. Expected Symbol, Class, or nil."
56
+ end
57
+ end
58
+
59
+ # Check if an adapter is registered with the given name.
60
+ #
61
+ # @param name [Symbol, String] The adapter name to check
62
+ # @return [Boolean] true if the adapter is registered
63
+ def registered?(name)
64
+ adapters.key?(name.to_sym)
65
+ end
66
+
67
+ # Reset the registry (useful for testing).
68
+ # Re-registers the default adapters after clearing.
69
+ #
70
+ # @return [void]
71
+ def reset!
72
+ @adapters = nil
73
+ register_default_adapters
74
+ end
75
+
76
+ # Register the default validation adapters.
77
+ # This is called during gem initialization and after reset!
78
+ #
79
+ # @return [void]
80
+ def register_default_adapters
81
+ register(:active_model, ActiveModelAdapter)
82
+ register(:none, NoneAdapter)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end