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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -0
- data/.yardopts +13 -0
- data/CHANGELOG.md +75 -0
- data/README.md +616 -35
- data/Rakefile +27 -0
- data/docs/.gitignore +1 -0
- data/docs/about.markdown +28 -8
- data/docs/getting-started.markdown +102 -0
- data/docs/index.markdown +51 -4
- data/docs/json_schema_compliance.md +55 -0
- data/docs/nested-models.markdown +216 -0
- data/docs/property-types.markdown +212 -0
- data/docs/schema-definition.markdown +180 -0
- data/lib/easy_talk/builders/base_builder.rb +4 -2
- data/lib/easy_talk/builders/composition_builder.rb +10 -12
- data/lib/easy_talk/builders/object_builder.rb +45 -30
- data/lib/easy_talk/builders/registry.rb +168 -0
- data/lib/easy_talk/builders/typed_array_builder.rb +15 -4
- data/lib/easy_talk/configuration.rb +31 -1
- data/lib/easy_talk/error_formatter/base.rb +100 -0
- data/lib/easy_talk/error_formatter/error_code_mapper.rb +82 -0
- data/lib/easy_talk/error_formatter/flat.rb +38 -0
- data/lib/easy_talk/error_formatter/json_pointer.rb +38 -0
- data/lib/easy_talk/error_formatter/jsonapi.rb +64 -0
- data/lib/easy_talk/error_formatter/path_converter.rb +53 -0
- data/lib/easy_talk/error_formatter/rfc7807.rb +69 -0
- data/lib/easy_talk/error_formatter.rb +143 -0
- data/lib/easy_talk/errors.rb +2 -0
- data/lib/easy_talk/errors_helper.rb +63 -34
- data/lib/easy_talk/model.rb +123 -90
- data/lib/easy_talk/model_helper.rb +13 -0
- data/lib/easy_talk/naming_strategies.rb +20 -0
- data/lib/easy_talk/property.rb +16 -94
- data/lib/easy_talk/ref_helper.rb +27 -0
- data/lib/easy_talk/schema.rb +198 -0
- data/lib/easy_talk/schema_definition.rb +7 -1
- data/lib/easy_talk/schema_methods.rb +80 -0
- data/lib/easy_talk/tools/function_builder.rb +1 -1
- data/lib/easy_talk/type_introspection.rb +178 -0
- data/lib/easy_talk/types/base_composer.rb +2 -1
- data/lib/easy_talk/types/composer.rb +4 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +329 -0
- data/lib/easy_talk/validation_adapters/base.rb +144 -0
- data/lib/easy_talk/validation_adapters/none_adapter.rb +36 -0
- data/lib/easy_talk/validation_adapters/registry.rb +87 -0
- data/lib/easy_talk/validation_builder.rb +28 -309
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +41 -0
- metadata +26 -4
- data/docs/404.html +0 -25
- data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
- 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,
|
|
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.
|
|
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
|
-
|
|
55
|
-
|
|
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
|