easy_talk 3.1.0 → 3.2.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/.yardopts +13 -0
  4. data/CHANGELOG.md +75 -0
  5. data/README.md +616 -35
  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 +55 -0
  12. data/docs/nested-models.markdown +216 -0
  13. data/docs/property-types.markdown +212 -0
  14. data/docs/schema-definition.markdown +180 -0
  15. data/lib/easy_talk/builders/base_builder.rb +4 -2
  16. data/lib/easy_talk/builders/composition_builder.rb +10 -12
  17. data/lib/easy_talk/builders/object_builder.rb +45 -30
  18. data/lib/easy_talk/builders/registry.rb +168 -0
  19. data/lib/easy_talk/builders/typed_array_builder.rb +15 -4
  20. data/lib/easy_talk/configuration.rb +31 -1
  21. data/lib/easy_talk/error_formatter/base.rb +100 -0
  22. data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
  23. data/lib/easy_talk/error_formatter/flat.rb +38 -0
  24. data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
  25. data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
  26. data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
  27. data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
  28. data/lib/easy_talk/error_formatter.rb +143 -0
  29. data/lib/easy_talk/errors.rb +2 -0
  30. data/lib/easy_talk/errors_helper.rb +63 -34
  31. data/lib/easy_talk/model.rb +123 -90
  32. data/lib/easy_talk/model_helper.rb +13 -0
  33. data/lib/easy_talk/naming_strategies.rb +20 -0
  34. data/lib/easy_talk/property.rb +16 -94
  35. data/lib/easy_talk/ref_helper.rb +27 -0
  36. data/lib/easy_talk/schema.rb +198 -0
  37. data/lib/easy_talk/schema_definition.rb +7 -1
  38. data/lib/easy_talk/schema_methods.rb +80 -0
  39. data/lib/easy_talk/tools/function_builder.rb +1 -1
  40. data/lib/easy_talk/type_introspection.rb +178 -0
  41. data/lib/easy_talk/types/base_composer.rb +2 -1
  42. data/lib/easy_talk/types/composer.rb +4 -0
  43. data/lib/easy_talk/validation_adapters/active_model_adapter.rb +329 -0
  44. data/lib/easy_talk/validation_adapters/base.rb +144 -0
  45. data/lib/easy_talk/validation_adapters/none_adapter.rb +36 -0
  46. data/lib/easy_talk/validation_adapters/registry.rb +87 -0
  47. data/lib/easy_talk/validation_builder.rb +28 -309
  48. data/lib/easy_talk/version.rb +1 -1
  49. data/lib/easy_talk.rb +41 -0
  50. metadata +26 -4
  51. data/docs/404.html +0 -25
  52. data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
  53. data/easy_talk.gemspec +0 -39
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module Builders
5
+ # Registry for type-to-builder mappings.
6
+ #
7
+ # The registry allows custom types to be registered with their corresponding
8
+ # schema builder classes at runtime, without modifying the gem's source code.
9
+ #
10
+ # Custom registrations take priority over built-in types, allowing users to
11
+ # override default behavior when needed.
12
+ #
13
+ # @example Registering a custom type
14
+ # EasyTalk::Builders::Registry.register(Money, MoneySchemaBuilder)
15
+ #
16
+ # @example Registering a collection type
17
+ # EasyTalk::Builders::Registry.register(CustomArray, CustomArrayBuilder, collection: true)
18
+ #
19
+ # @example Resolving a builder for a type
20
+ # builder_class, is_collection = EasyTalk::Builders::Registry.resolve(Money)
21
+ # builder_class.new(name, constraints).build
22
+ #
23
+ class Registry
24
+ class << self
25
+ # Get the hash of registered type builders.
26
+ #
27
+ # @return [Hash{String => Hash}] The registered builders with metadata
28
+ def registry
29
+ @registry ||= {}
30
+ end
31
+
32
+ # Register a type with its corresponding builder class.
33
+ #
34
+ # @param type_key [Class, String, Symbol] The type identifier
35
+ # @param builder_class [Class] The builder class (must respond to .new)
36
+ # @param collection [Boolean] Whether this is a collection type builder
37
+ # Collection builders receive (name, type, constraints) instead of (name, constraints)
38
+ # @raise [ArgumentError] if the builder does not respond to .new
39
+ # @return [void]
40
+ #
41
+ # @example Register a simple type
42
+ # Registry.register(Money, MoneySchemaBuilder)
43
+ #
44
+ # @example Register a collection type
45
+ # Registry.register(CustomArray, CustomArrayBuilder, collection: true)
46
+ def register(type_key, builder_class, collection: false)
47
+ raise ArgumentError, 'Builder must respond to .new' unless builder_class.respond_to?(:new)
48
+
49
+ key = normalize_key(type_key)
50
+ registry[key] = { builder: builder_class, collection: collection }
51
+ end
52
+
53
+ # Resolve a builder for the given type.
54
+ #
55
+ # Resolution order:
56
+ # 1. Check type.class.name (e.g., "T::Types::TypedArray")
57
+ # 2. Check type.name if type responds to :name (e.g., "String")
58
+ # 3. Check type itself if it's a Class (e.g., String class)
59
+ #
60
+ # @param type [Object] The type to find a builder for
61
+ # @return [Array(Class, Boolean), nil] A tuple of [builder_class, is_collection] or nil if not found
62
+ #
63
+ # @example
64
+ # builder_class, is_collection = Registry.resolve(String)
65
+ # # => [StringBuilder, false]
66
+ def resolve(type)
67
+ entry = find_registration(type)
68
+ return nil unless entry
69
+
70
+ [entry[:builder], entry[:collection]]
71
+ end
72
+
73
+ # Check if a type is registered.
74
+ #
75
+ # @param type_key [Class, String, Symbol] The type to check
76
+ # @return [Boolean] true if the type is registered
77
+ def registered?(type_key)
78
+ registry.key?(normalize_key(type_key))
79
+ end
80
+
81
+ # Unregister a type.
82
+ #
83
+ # @param type_key [Class, String, Symbol] The type to unregister
84
+ # @return [Hash, nil] The removed registration or nil if not found
85
+ def unregister(type_key)
86
+ registry.delete(normalize_key(type_key))
87
+ end
88
+
89
+ # Get a list of all registered type keys.
90
+ #
91
+ # @return [Array<String>] The registered type keys
92
+ def registered_types
93
+ registry.keys
94
+ end
95
+
96
+ # Reset the registry to empty state and re-register built-in types.
97
+ #
98
+ # @return [void]
99
+ def reset!
100
+ @registry = nil
101
+ register_built_in_types
102
+ end
103
+
104
+ # Register all built-in type builders.
105
+ # This is called during gem initialization and after reset!
106
+ #
107
+ # @return [void]
108
+ def register_built_in_types
109
+ register(String, Builders::StringBuilder)
110
+ register(Integer, Builders::IntegerBuilder)
111
+ register(Float, Builders::NumberBuilder)
112
+ register(BigDecimal, Builders::NumberBuilder)
113
+ register('T::Boolean', Builders::BooleanBuilder)
114
+ register(TrueClass, Builders::BooleanBuilder)
115
+ register(NilClass, Builders::NullBuilder)
116
+ register(Date, Builders::TemporalBuilder::DateBuilder)
117
+ register(DateTime, Builders::TemporalBuilder::DatetimeBuilder)
118
+ register(Time, Builders::TemporalBuilder::TimeBuilder)
119
+ register('anyOf', Builders::CompositionBuilder::AnyOfBuilder, collection: true)
120
+ register('allOf', Builders::CompositionBuilder::AllOfBuilder, collection: true)
121
+ register('oneOf', Builders::CompositionBuilder::OneOfBuilder, collection: true)
122
+ register('T::Types::TypedArray', Builders::TypedArrayBuilder, collection: true)
123
+ register('T::Types::Union', Builders::UnionBuilder, collection: true)
124
+ end
125
+
126
+ private
127
+
128
+ # Normalize a type key to a canonical string form.
129
+ #
130
+ # @param type_key [Class, String, Symbol] The type key to normalize
131
+ # @return [String] The normalized key
132
+ def normalize_key(type_key)
133
+ case type_key
134
+ when Class
135
+ type_key.name.to_s
136
+ when Symbol
137
+ type_key.to_s
138
+ else
139
+ type_key.to_s
140
+ end
141
+ end
142
+
143
+ # Find a registration for the given type.
144
+ #
145
+ # Tries multiple resolution strategies in order.
146
+ #
147
+ # @param type [Object] The type to find
148
+ # @return [Hash, nil] The registration entry or nil
149
+ def find_registration(type)
150
+ # Strategy 1: Check type.class.name (for Sorbet types like T::Types::TypedArray)
151
+ class_name = type.class.name.to_s
152
+ return registry[class_name] if registry.key?(class_name)
153
+
154
+ # Strategy 2: Check type.name (for types that respond to :name, like "String")
155
+ if type.respond_to?(:name)
156
+ type_name = type.name.to_s
157
+ return registry[type_name] if registry.key?(type_name)
158
+ end
159
+
160
+ # Strategy 3: Check the type itself if it's a Class
161
+ return registry[type.name.to_s] if type.is_a?(Class) && registry.key?(type.name.to_s)
162
+
163
+ nil
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -24,12 +24,19 @@ module EasyTalk
24
24
  def initialize(name, type, constraints = {})
25
25
  @name = name
26
26
  @type = type
27
+ @valid_options = deep_dup_options(VALID_OPTIONS)
27
28
  update_option_types
28
- super(name, { type: 'array' }, constraints, VALID_OPTIONS)
29
+ super(name, { type: 'array' }, constraints, @valid_options)
29
30
  end
30
31
 
31
32
  private
32
33
 
34
+ # Creates a copy of options with duped nested hashes to avoid mutating the constant.
35
+ sig { params(options: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
36
+ def deep_dup_options(options)
37
+ options.transform_values(&:dup)
38
+ end
39
+
33
40
  # Modifies the schema to include the `items` property.
34
41
  #
35
42
  # @return [Hash] The built schema.
@@ -45,14 +52,18 @@ module EasyTalk
45
52
  def inner_type
46
53
  return unless type.is_a?(T::Types::TypedArray)
47
54
 
48
- type.type.raw_type
55
+ if type.type.is_a?(EasyTalk::Types::Composer)
56
+ type.type
57
+ else
58
+ type.type.raw_type
59
+ end
49
60
  end
50
61
 
51
62
  sig { void }
52
63
  # Updates the option types for the array builder.
53
64
  def update_option_types
54
- VALID_OPTIONS[:enum][:type] = T::Array[inner_type]
55
- VALID_OPTIONS[:const][:type] = T::Array[inner_type]
65
+ @valid_options[:enum][:type] = T::Array[inner_type]
66
+ @valid_options[:const][:type] = T::Array[inner_type]
56
67
  end
57
68
  end
58
69
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'naming_strategies'
4
+
3
5
  module EasyTalk
4
6
  class Configuration
5
7
  # JSON Schema draft version URIs
@@ -12,15 +14,21 @@ module EasyTalk
12
14
  }.freeze
13
15
 
14
16
  attr_accessor :default_additional_properties, :nilable_is_optional, :auto_validations, :schema_version, :schema_id,
15
- :use_refs
17
+ :use_refs, :validation_adapter, :default_error_format, :error_type_base_uri, :include_error_codes
18
+ attr_reader :property_naming_strategy
16
19
 
17
20
  def initialize
18
21
  @default_additional_properties = false
19
22
  @nilable_is_optional = false
20
23
  @auto_validations = true
24
+ @validation_adapter = :active_model
21
25
  @schema_version = :none
22
26
  @schema_id = nil
23
27
  @use_refs = false
28
+ @default_error_format = :flat
29
+ @error_type_base_uri = 'about:blank'
30
+ @include_error_codes = true
31
+ self.property_naming_strategy = :identity
24
32
  end
25
33
 
26
34
  # Returns the URI for the configured schema version, or nil if :none
@@ -29,6 +37,28 @@ module EasyTalk
29
37
 
30
38
  SCHEMA_VERSIONS[@schema_version] || @schema_version.to_s
31
39
  end
40
+
41
+ def property_naming_strategy=(strategy)
42
+ @property_naming_strategy = EasyTalk::NamingStrategies.derive_strategy(strategy)
43
+ end
44
+
45
+ # Register a custom type with its corresponding schema builder.
46
+ #
47
+ # This convenience method delegates to Builders::Registry.register
48
+ # and allows type registration within a configuration block.
49
+ #
50
+ # @param type_key [Class, String, Symbol] The type to register
51
+ # @param builder_class [Class] The builder class that generates JSON Schema
52
+ # @param collection [Boolean] Whether this is a collection type builder
53
+ # @return [void]
54
+ #
55
+ # @example
56
+ # EasyTalk.configure do |config|
57
+ # config.register_type Money, MoneySchemaBuilder
58
+ # end
59
+ def register_type(type_key, builder_class, collection: false)
60
+ EasyTalk::Builders::Registry.register(type_key, builder_class, collection: collection)
61
+ end
32
62
  end
33
63
 
34
64
  class << self
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module ErrorFormatter
5
+ # Abstract base class for error formatters.
6
+ #
7
+ # Provides common functionality for transforming ActiveModel::Errors
8
+ # into standardized formats. Subclasses implement the `format` method
9
+ # to produce specific output formats.
10
+ #
11
+ # @abstract Subclass and implement {#format} to create a formatter.
12
+ #
13
+ # @example Creating a custom formatter
14
+ # class CustomFormatter < EasyTalk::ErrorFormatter::Base
15
+ # def format
16
+ # error_entries.map do |entry|
17
+ # { custom_field: entry[:attribute], custom_message: entry[:message] }
18
+ # end
19
+ # end
20
+ # end
21
+ #
22
+ class Base
23
+ attr_reader :errors, :options
24
+
25
+ # Initialize a new formatter.
26
+ #
27
+ # @param errors [ActiveModel::Errors] The errors object to format
28
+ # @param options [Hash] Formatting options
29
+ # @option options [Boolean] :include_codes Whether to include error codes
30
+ def initialize(errors, options = {})
31
+ @errors = errors
32
+ @options = options
33
+ end
34
+
35
+ # Format the errors into the target format.
36
+ #
37
+ # @abstract
38
+ # @return [Hash, Array] The formatted errors
39
+ # @raise [NotImplementedError] if the subclass does not implement this method
40
+ def format
41
+ raise NotImplementedError, "#{self.class} must implement #format"
42
+ end
43
+
44
+ protected
45
+
46
+ # Check if error codes should be included in the output.
47
+ #
48
+ # @return [Boolean]
49
+ def include_codes?
50
+ @options.fetch(:include_codes, EasyTalk.configuration.include_error_codes)
51
+ end
52
+
53
+ # Build a normalized list of error entries from ActiveModel::Errors.
54
+ #
55
+ # Each entry contains:
56
+ # - :attribute - The attribute name (may include dots for nested)
57
+ # - :message - The error message
58
+ # - :full_message - The full error message with attribute name
59
+ # - :type - The error type from errors.details (e.g., :blank, :too_short)
60
+ # - :detail_options - Additional options from the error detail
61
+ #
62
+ # @return [Array<Hash>] The normalized error entries
63
+ def error_entries
64
+ @error_entries ||= build_error_entries
65
+ end
66
+
67
+ private
68
+
69
+ def build_error_entries
70
+ # Group details by attribute for matching
71
+ details_by_attr = {}
72
+ errors.details.each do |attr, detail_list|
73
+ details_by_attr[attr] = detail_list.dup
74
+ end
75
+
76
+ errors.map do |error|
77
+ attr = error.attribute
78
+ detail = find_and_consume_detail(details_by_attr, attr)
79
+
80
+ {
81
+ attribute: attr,
82
+ message: error.message,
83
+ full_message: error.full_message,
84
+ type: detail[:error],
85
+ detail_options: detail.except(:error)
86
+ }
87
+ end
88
+ end
89
+
90
+ # Find and consume a detail entry for an attribute.
91
+ # This handles the case where an attribute has multiple errors.
92
+ def find_and_consume_detail(details_by_attr, attribute)
93
+ detail_list = details_by_attr[attribute]
94
+ return {} if detail_list.nil? || detail_list.empty?
95
+
96
+ detail_list.shift || {}
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module ErrorFormatter
5
+ # Maps ActiveModel validation error types to semantic error codes.
6
+ #
7
+ # ActiveModel's `errors.details` provides the validation type (e.g., :blank, :too_short).
8
+ # This class maps those types to standardized semantic codes for API responses.
9
+ #
10
+ # @example
11
+ # ErrorCodeMapper.map(:blank) # => "blank"
12
+ # ErrorCodeMapper.map(:too_short) # => "too_short"
13
+ # ErrorCodeMapper.map(:invalid) # => "invalid_format"
14
+ #
15
+ class ErrorCodeMapper
16
+ # Mapping of ActiveModel error types to semantic codes
17
+ VALIDATION_TO_CODE = {
18
+ # Presence validations
19
+ blank: 'blank',
20
+ present: 'present',
21
+ empty: 'empty',
22
+
23
+ # Format validations
24
+ invalid: 'invalid_format',
25
+
26
+ # Length validations
27
+ too_short: 'too_short',
28
+ too_long: 'too_long',
29
+ wrong_length: 'wrong_length',
30
+
31
+ # Numericality validations
32
+ not_a_number: 'not_a_number',
33
+ not_an_integer: 'not_an_integer',
34
+ greater_than: 'too_small',
35
+ greater_than_or_equal_to: 'too_small',
36
+ less_than: 'too_large',
37
+ less_than_or_equal_to: 'too_large',
38
+ equal_to: 'not_equal',
39
+ other_than: 'equal',
40
+ odd: 'not_odd',
41
+ even: 'not_even',
42
+
43
+ # Inclusion/exclusion validations
44
+ inclusion: 'not_included',
45
+ exclusion: 'excluded',
46
+
47
+ # Other validations
48
+ taken: 'taken',
49
+ confirmation: 'confirmation',
50
+ accepted: 'not_accepted'
51
+ }.freeze
52
+
53
+ class << self
54
+ # Map an ActiveModel error type to a semantic code.
55
+ #
56
+ # @param error_type [Symbol, String] The ActiveModel error type
57
+ # @return [String] The semantic error code
58
+ def map(error_type)
59
+ VALIDATION_TO_CODE[error_type.to_sym] || error_type.to_s
60
+ end
61
+
62
+ # Extract the error code from an ActiveModel error detail hash.
63
+ #
64
+ # @param detail [Hash] The error detail from errors.details
65
+ # @return [String] The semantic error code
66
+ #
67
+ # @example
68
+ # ErrorCodeMapper.code_from_detail({ error: :blank })
69
+ # # => "blank"
70
+ #
71
+ # ErrorCodeMapper.code_from_detail({ error: :too_short, count: 2 })
72
+ # # => "too_short"
73
+ def code_from_detail(detail)
74
+ error_key = detail[:error]
75
+ return 'unknown' unless error_key
76
+
77
+ map(error_key)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module ErrorFormatter
5
+ # Formats validation errors as a simple flat array.
6
+ #
7
+ # This is the simplest format, providing an array of error objects
8
+ # with field name, message, and optional error code.
9
+ #
10
+ # @example Output
11
+ # [
12
+ # { "field" => "name", "message" => "can't be blank", "code" => "blank" },
13
+ # { "field" => "email.address", "message" => "is invalid", "code" => "invalid_format" }
14
+ # ]
15
+ #
16
+ class Flat < Base
17
+ # Format the errors as a flat array.
18
+ #
19
+ # @return [Array<Hash>] Array of error objects
20
+ def format
21
+ error_entries.map do |entry|
22
+ build_error_object(entry)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def build_error_object(entry)
29
+ error = {
30
+ 'field' => PathConverter.to_flat(entry[:attribute]),
31
+ 'message' => entry[:message]
32
+ }
33
+ error['code'] = ErrorCodeMapper.map(entry[:type]) if include_codes? && entry[:type]
34
+ error
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module ErrorFormatter
5
+ # Formats validation errors using JSON Pointer (RFC 6901) paths.
6
+ #
7
+ # Converts attribute paths to JSON Pointer format pointing to the
8
+ # property location in the JSON Schema.
9
+ #
10
+ # @example Output
11
+ # [
12
+ # { "pointer" => "/properties/name", "message" => "can't be blank", "code" => "blank" },
13
+ # { "pointer" => "/properties/email/properties/address", "message" => "is invalid", "code" => "invalid_format" }
14
+ # ]
15
+ #
16
+ class JsonPointer < Base
17
+ # Format the errors as an array with JSON Pointer paths.
18
+ #
19
+ # @return [Array<Hash>] Array of error objects with pointer paths
20
+ def format
21
+ error_entries.map do |entry|
22
+ build_error_object(entry)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def build_error_object(entry)
29
+ error = {
30
+ 'pointer' => PathConverter.to_json_pointer(entry[:attribute]),
31
+ 'message' => entry[:message]
32
+ }
33
+ error['code'] = ErrorCodeMapper.map(entry[:type]) if include_codes? && entry[:type]
34
+ error
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module ErrorFormatter
5
+ # Formats validation errors according to the JSON:API specification.
6
+ #
7
+ # JSON:API defines a standard error format with specific fields for
8
+ # status, source, title, detail, and optional code.
9
+ #
10
+ # @see https://jsonapi.org/format/#error-objects
11
+ #
12
+ # @example Output
13
+ # {
14
+ # "errors" => [
15
+ # {
16
+ # "status" => "422",
17
+ # "code" => "blank",
18
+ # "source" => { "pointer" => "/data/attributes/name" },
19
+ # "title" => "Invalid Attribute",
20
+ # "detail" => "Name can't be blank"
21
+ # }
22
+ # ]
23
+ # }
24
+ #
25
+ class Jsonapi < Base
26
+ # Default values for JSON:API error fields
27
+ DEFAULT_STATUS = '422'
28
+ DEFAULT_TITLE = 'Invalid Attribute'
29
+
30
+ # Format the errors as a JSON:API error response.
31
+ #
32
+ # @return [Hash] The JSON:API error object with "errors" array
33
+ def format
34
+ {
35
+ 'errors' => build_errors_array
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def build_errors_array
42
+ error_entries.map do |entry|
43
+ build_error_object(entry)
44
+ end
45
+ end
46
+
47
+ def build_error_object(entry)
48
+ error = {
49
+ 'status' => options.fetch(:status, DEFAULT_STATUS).to_s,
50
+ 'source' => {
51
+ 'pointer' => PathConverter.to_jsonapi_pointer(
52
+ entry[:attribute],
53
+ prefix: options.fetch(:source_prefix, '/data/attributes')
54
+ )
55
+ },
56
+ 'title' => options.fetch(:title, DEFAULT_TITLE),
57
+ 'detail' => entry[:full_message]
58
+ }
59
+ error['code'] = ErrorCodeMapper.map(entry[:type]) if include_codes? && entry[:type]
60
+ error
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasyTalk
4
+ module ErrorFormatter
5
+ # Converts attribute paths between different formats.
6
+ #
7
+ # EasyTalk uses dot notation for nested model errors (e.g., "email.address").
8
+ # This class converts those paths to different standard formats:
9
+ # - JSON Pointer (RFC 6901): /properties/email/properties/address
10
+ # - JSON:API: /data/attributes/email/address
11
+ # - Flat: email.address (unchanged)
12
+ #
13
+ # @example
14
+ # PathConverter.to_json_pointer("email.address")
15
+ # # => "/properties/email/properties/address"
16
+ #
17
+ # PathConverter.to_jsonapi_pointer("email.address")
18
+ # # => "/data/attributes/email/address"
19
+ #
20
+ class PathConverter
21
+ class << self
22
+ # Convert an attribute path to JSON Schema pointer format.
23
+ #
24
+ # @param attribute [Symbol, String] The attribute path (e.g., "email.address")
25
+ # @return [String] The JSON Pointer path (e.g., "/properties/email/properties/address")
26
+ def to_json_pointer(attribute)
27
+ parts = attribute.to_s.split('.')
28
+ return "/properties/#{parts.first}" if parts.length == 1
29
+
30
+ parts.map { |part| "/properties/#{part}" }.join
31
+ end
32
+
33
+ # Convert an attribute path to JSON:API pointer format.
34
+ #
35
+ # @param attribute [Symbol, String] The attribute path
36
+ # @param prefix [String] The path prefix (default: "/data/attributes")
37
+ # @return [String] The JSON:API pointer path
38
+ def to_jsonapi_pointer(attribute, prefix: '/data/attributes')
39
+ parts = attribute.to_s.split('.')
40
+ "#{prefix}/#{parts.join('/')}"
41
+ end
42
+
43
+ # Return the attribute path as-is (flat format).
44
+ #
45
+ # @param attribute [Symbol, String] The attribute path
46
+ # @return [String] The attribute as a string
47
+ def to_flat(attribute)
48
+ attribute.to_s
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end