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.
- checksums.yaml +4 -4
- data/.rubocop.yml +15 -39
- data/.yardopts +13 -0
- data/CHANGELOG.md +164 -0
- data/README.md +442 -1529
- 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 +169 -0
- data/docs/nested-models.markdown +216 -0
- data/docs/primitive-schema-rfc.md +894 -0
- data/docs/property-types.markdown +212 -0
- data/docs/schema-definition.markdown +180 -0
- data/lib/easy_talk/builders/base_builder.rb +6 -3
- data/lib/easy_talk/builders/boolean_builder.rb +2 -1
- data/lib/easy_talk/builders/collection_helpers.rb +4 -0
- data/lib/easy_talk/builders/composition_builder.rb +16 -13
- data/lib/easy_talk/builders/integer_builder.rb +2 -1
- data/lib/easy_talk/builders/null_builder.rb +4 -1
- data/lib/easy_talk/builders/number_builder.rb +4 -1
- data/lib/easy_talk/builders/object_builder.rb +109 -33
- data/lib/easy_talk/builders/registry.rb +182 -0
- data/lib/easy_talk/builders/string_builder.rb +3 -1
- data/lib/easy_talk/builders/temporal_builder.rb +7 -0
- data/lib/easy_talk/builders/tuple_builder.rb +89 -0
- data/lib/easy_talk/builders/typed_array_builder.rb +19 -6
- data/lib/easy_talk/builders/union_builder.rb +5 -1
- data/lib/easy_talk/configuration.rb +47 -2
- 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 +3 -0
- data/lib/easy_talk/errors_helper.rb +66 -34
- data/lib/easy_talk/json_schema_equality.rb +46 -0
- data/lib/easy_talk/keywords.rb +0 -1
- data/lib/easy_talk/model.rb +148 -89
- data/lib/easy_talk/model_helper.rb +17 -0
- data/lib/easy_talk/naming_strategies.rb +24 -0
- data/lib/easy_talk/property.rb +23 -94
- data/lib/easy_talk/ref_helper.rb +33 -0
- data/lib/easy_talk/schema.rb +199 -0
- data/lib/easy_talk/schema_definition.rb +57 -5
- data/lib/easy_talk/schema_methods.rb +111 -0
- data/lib/easy_talk/sorbet_extension.rb +1 -0
- data/lib/easy_talk/tools/function_builder.rb +1 -1
- data/lib/easy_talk/type_introspection.rb +222 -0
- data/lib/easy_talk/types/base_composer.rb +2 -1
- data/lib/easy_talk/types/composer.rb +4 -0
- data/lib/easy_talk/types/tuple.rb +77 -0
- data/lib/easy_talk/validation_adapters/active_model_adapter.rb +617 -0
- data/lib/easy_talk/validation_adapters/active_model_schema_validation.rb +106 -0
- data/lib/easy_talk/validation_adapters/base.rb +156 -0
- data/lib/easy_talk/validation_adapters/none_adapter.rb +45 -0
- data/lib/easy_talk/validation_adapters/registry.rb +87 -0
- data/lib/easy_talk/validation_builder.rb +29 -309
- data/lib/easy_talk/version.rb +1 -1
- data/lib/easy_talk.rb +42 -0
- metadata +38 -7
- data/docs/404.html +0 -25
- data/docs/_posts/2024-05-07-welcome-to-jekyll.markdown +0 -29
- data/easy_talk.gemspec +0 -39
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
3
|
+
|
|
4
|
+
require_relative 'naming_strategies'
|
|
2
5
|
|
|
3
6
|
module EasyTalk
|
|
4
7
|
class Configuration
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
5
10
|
# JSON Schema draft version URIs
|
|
6
11
|
SCHEMA_VERSIONS = {
|
|
7
12
|
draft202012: 'https://json-schema.org/draft/2020-12/schema',
|
|
@@ -12,31 +17,71 @@ module EasyTalk
|
|
|
12
17
|
}.freeze
|
|
13
18
|
|
|
14
19
|
attr_accessor :default_additional_properties, :nilable_is_optional, :auto_validations, :schema_version, :schema_id,
|
|
15
|
-
:use_refs
|
|
20
|
+
:use_refs, :validation_adapter, :default_error_format, :error_type_base_uri, :include_error_codes,
|
|
21
|
+
:base_schema_uri, :auto_generate_ids, :prefer_external_refs
|
|
22
|
+
attr_reader :property_naming_strategy
|
|
16
23
|
|
|
24
|
+
sig { void }
|
|
17
25
|
def initialize
|
|
18
26
|
@default_additional_properties = false
|
|
19
27
|
@nilable_is_optional = false
|
|
20
28
|
@auto_validations = true
|
|
29
|
+
@validation_adapter = :active_model
|
|
21
30
|
@schema_version = :none
|
|
22
31
|
@schema_id = nil
|
|
23
32
|
@use_refs = false
|
|
33
|
+
@default_error_format = :flat
|
|
34
|
+
@error_type_base_uri = 'about:blank'
|
|
35
|
+
@include_error_codes = true
|
|
36
|
+
@base_schema_uri = nil
|
|
37
|
+
@auto_generate_ids = false
|
|
38
|
+
@prefer_external_refs = false
|
|
39
|
+
self.property_naming_strategy = :identity
|
|
24
40
|
end
|
|
25
41
|
|
|
26
42
|
# Returns the URI for the configured schema version, or nil if :none
|
|
43
|
+
sig { returns(T.nilable(String)) }
|
|
27
44
|
def schema_uri
|
|
28
45
|
return nil if @schema_version == :none
|
|
29
46
|
|
|
30
47
|
SCHEMA_VERSIONS[@schema_version] || @schema_version.to_s
|
|
31
48
|
end
|
|
49
|
+
|
|
50
|
+
sig { params(strategy: T.any(Symbol, T.proc.params(arg0: T.untyped).returns(Symbol))).void }
|
|
51
|
+
def property_naming_strategy=(strategy)
|
|
52
|
+
@property_naming_strategy = EasyTalk::NamingStrategies.derive_strategy(strategy)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Register a custom type with its corresponding schema builder.
|
|
56
|
+
#
|
|
57
|
+
# This convenience method delegates to Builders::Registry.register
|
|
58
|
+
# and allows type registration within a configuration block.
|
|
59
|
+
#
|
|
60
|
+
# @param type_key [Class, String, Symbol] The type to register
|
|
61
|
+
# @param builder_class [Class] The builder class that generates JSON Schema
|
|
62
|
+
# @param collection [Boolean] Whether this is a collection type builder
|
|
63
|
+
# @return [void]
|
|
64
|
+
#
|
|
65
|
+
# @example
|
|
66
|
+
# EasyTalk.configure do |config|
|
|
67
|
+
# config.register_type Money, MoneySchemaBuilder
|
|
68
|
+
# end
|
|
69
|
+
sig { params(type_key: T.any(T::Class[T.anything], String, Symbol), builder_class: T.untyped, collection: T::Boolean).void }
|
|
70
|
+
def register_type(type_key, builder_class, collection: false)
|
|
71
|
+
EasyTalk::Builders::Registry.register(type_key, builder_class, collection: collection)
|
|
72
|
+
end
|
|
32
73
|
end
|
|
33
74
|
|
|
34
75
|
class << self
|
|
76
|
+
extend T::Sig
|
|
77
|
+
|
|
78
|
+
sig { returns(Configuration) }
|
|
35
79
|
def configuration
|
|
36
80
|
@configuration ||= Configuration.new
|
|
37
81
|
end
|
|
38
82
|
|
|
39
|
-
|
|
83
|
+
sig { params(block: T.proc.params(config: Configuration).void).void }
|
|
84
|
+
def configure(&block)
|
|
40
85
|
yield(configuration)
|
|
41
86
|
end
|
|
42
87
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyTalk
|
|
4
|
+
module ErrorFormatter
|
|
5
|
+
# Formats validation errors according to RFC 7807 (Problem Details for HTTP APIs).
|
|
6
|
+
#
|
|
7
|
+
# RFC 7807 defines a standard format for describing errors in HTTP APIs.
|
|
8
|
+
# This formatter produces a Problem Details object with validation errors
|
|
9
|
+
# in an extended "errors" array.
|
|
10
|
+
#
|
|
11
|
+
# @see https://tools.ietf.org/html/rfc7807
|
|
12
|
+
#
|
|
13
|
+
# @example Output
|
|
14
|
+
# {
|
|
15
|
+
# "type" => "https://example.com/validation-error",
|
|
16
|
+
# "title" => "Validation Failed",
|
|
17
|
+
# "status" => 422,
|
|
18
|
+
# "detail" => "The request contains invalid parameters",
|
|
19
|
+
# "errors" => [
|
|
20
|
+
# { "pointer" => "/properties/name", "detail" => "can't be blank", "code" => "blank" }
|
|
21
|
+
# ]
|
|
22
|
+
# }
|
|
23
|
+
#
|
|
24
|
+
class Rfc7807 < Base
|
|
25
|
+
# Default values for RFC 7807 fields
|
|
26
|
+
DEFAULT_TITLE = 'Validation Failed'
|
|
27
|
+
DEFAULT_STATUS = 422
|
|
28
|
+
DEFAULT_DETAIL = 'The request contains invalid parameters'
|
|
29
|
+
|
|
30
|
+
# Format the errors as an RFC 7807 Problem Details object.
|
|
31
|
+
#
|
|
32
|
+
# @return [Hash] The Problem Details object
|
|
33
|
+
def format
|
|
34
|
+
{
|
|
35
|
+
'type' => error_type_uri,
|
|
36
|
+
'title' => options.fetch(:title, DEFAULT_TITLE),
|
|
37
|
+
'status' => options.fetch(:status, DEFAULT_STATUS),
|
|
38
|
+
'detail' => options.fetch(:detail, DEFAULT_DETAIL),
|
|
39
|
+
'errors' => build_errors_array
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def error_type_uri
|
|
46
|
+
base_uri = options.fetch(:type_base_uri, EasyTalk.configuration.error_type_base_uri)
|
|
47
|
+
type_suffix = options.fetch(:type, 'validation-error')
|
|
48
|
+
return type_suffix if base_uri.nil? || base_uri == 'about:blank'
|
|
49
|
+
|
|
50
|
+
"#{base_uri.chomp('/')}/#{type_suffix}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_errors_array
|
|
54
|
+
error_entries.map do |entry|
|
|
55
|
+
build_error_object(entry)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_error_object(entry)
|
|
60
|
+
error = {
|
|
61
|
+
'pointer' => PathConverter.to_json_pointer(entry[:attribute]),
|
|
62
|
+
'detail' => entry[:message]
|
|
63
|
+
}
|
|
64
|
+
error['code'] = ErrorCodeMapper.map(entry[:type]) if include_codes? && entry[:type]
|
|
65
|
+
error
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'error_formatter/error_code_mapper'
|
|
4
|
+
require_relative 'error_formatter/path_converter'
|
|
5
|
+
require_relative 'error_formatter/base'
|
|
6
|
+
require_relative 'error_formatter/flat'
|
|
7
|
+
require_relative 'error_formatter/json_pointer'
|
|
8
|
+
require_relative 'error_formatter/rfc7807'
|
|
9
|
+
require_relative 'error_formatter/jsonapi'
|
|
10
|
+
|
|
11
|
+
module EasyTalk
|
|
12
|
+
# Module for formatting ActiveModel validation errors into standardized formats.
|
|
13
|
+
#
|
|
14
|
+
# Provides multiple output formats for API responses:
|
|
15
|
+
# - `:flat` - Simple flat array of field/message/code objects
|
|
16
|
+
# - `:json_pointer` - Array with JSON Pointer (RFC 6901) paths
|
|
17
|
+
# - `:rfc7807` - RFC 7807 Problem Details format
|
|
18
|
+
# - `:jsonapi` - JSON:API specification error format
|
|
19
|
+
#
|
|
20
|
+
# @example Using via model instance methods
|
|
21
|
+
# user = User.new(name: '')
|
|
22
|
+
# user.valid?
|
|
23
|
+
#
|
|
24
|
+
# user.validation_errors # Uses default format from config
|
|
25
|
+
# user.validation_errors_flat # Flat format
|
|
26
|
+
# user.validation_errors_json_pointer # JSON Pointer format
|
|
27
|
+
# user.validation_errors_rfc7807 # RFC 7807 format
|
|
28
|
+
# user.validation_errors_jsonapi # JSON:API format
|
|
29
|
+
#
|
|
30
|
+
# @example Using the format method directly
|
|
31
|
+
# EasyTalk::ErrorFormatter.format(user.errors, format: :rfc7807, title: 'Custom Title')
|
|
32
|
+
#
|
|
33
|
+
module ErrorFormatter
|
|
34
|
+
# Map of format symbols to formatter classes
|
|
35
|
+
FORMATTERS = {
|
|
36
|
+
flat: Flat,
|
|
37
|
+
json_pointer: JsonPointer,
|
|
38
|
+
rfc7807: Rfc7807,
|
|
39
|
+
jsonapi: Jsonapi
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# Format validation errors using the specified format.
|
|
44
|
+
#
|
|
45
|
+
# @param errors [ActiveModel::Errors] The errors object to format
|
|
46
|
+
# @param format [Symbol] The output format (:flat, :json_pointer, :rfc7807, :jsonapi)
|
|
47
|
+
# @param options [Hash] Format-specific options
|
|
48
|
+
# @return [Hash, Array] The formatted errors
|
|
49
|
+
# @raise [ArgumentError] If the format is not recognized
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# EasyTalk::ErrorFormatter.format(user.errors, format: :flat)
|
|
53
|
+
# EasyTalk::ErrorFormatter.format(user.errors, format: :rfc7807, title: 'Validation Error')
|
|
54
|
+
def format(errors, format: nil, **options)
|
|
55
|
+
format ||= EasyTalk.configuration.default_error_format
|
|
56
|
+
formatter_class = FORMATTERS[format.to_sym]
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, "Unknown error format: #{format}. Valid formats: #{FORMATTERS.keys.join(', ')}" unless formatter_class
|
|
59
|
+
|
|
60
|
+
formatter_class.new(errors, options).format
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Instance methods mixed into EasyTalk::Model classes.
|
|
65
|
+
#
|
|
66
|
+
# Provides convenient methods for formatting validation errors
|
|
67
|
+
# on model instances.
|
|
68
|
+
module InstanceMethods
|
|
69
|
+
# Format validation errors using the default or specified format.
|
|
70
|
+
#
|
|
71
|
+
# @param format [Symbol] The output format (optional, uses config default)
|
|
72
|
+
# @param options [Hash] Format-specific options
|
|
73
|
+
# @return [Hash, Array] The formatted errors
|
|
74
|
+
#
|
|
75
|
+
# @example
|
|
76
|
+
# user.validation_errors
|
|
77
|
+
# user.validation_errors(format: :rfc7807, title: 'User Error')
|
|
78
|
+
def validation_errors(format: nil, **)
|
|
79
|
+
ErrorFormatter.format(errors, format: format, **)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Format validation errors as a flat array.
|
|
83
|
+
#
|
|
84
|
+
# @param options [Hash] Format options
|
|
85
|
+
# @option options [Boolean] :include_codes Whether to include error codes
|
|
86
|
+
# @return [Array<Hash>] Array of error objects
|
|
87
|
+
#
|
|
88
|
+
# @example
|
|
89
|
+
# user.validation_errors_flat
|
|
90
|
+
# # => [{ "field" => "name", "message" => "can't be blank", "code" => "blank" }]
|
|
91
|
+
def validation_errors_flat(**)
|
|
92
|
+
ErrorFormatter.format(errors, format: :flat, **)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Format validation errors with JSON Pointer paths.
|
|
96
|
+
#
|
|
97
|
+
# @param options [Hash] Format options
|
|
98
|
+
# @option options [Boolean] :include_codes Whether to include error codes
|
|
99
|
+
# @return [Array<Hash>] Array of error objects with pointer paths
|
|
100
|
+
#
|
|
101
|
+
# @example
|
|
102
|
+
# user.validation_errors_json_pointer
|
|
103
|
+
# # => [{ "pointer" => "/properties/name", "message" => "can't be blank", "code" => "blank" }]
|
|
104
|
+
def validation_errors_json_pointer(**)
|
|
105
|
+
ErrorFormatter.format(errors, format: :json_pointer, **)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Format validation errors as RFC 7807 Problem Details.
|
|
109
|
+
#
|
|
110
|
+
# @param options [Hash] Format options
|
|
111
|
+
# @option options [String] :title The problem title
|
|
112
|
+
# @option options [Integer] :status The HTTP status code
|
|
113
|
+
# @option options [String] :detail The problem detail message
|
|
114
|
+
# @option options [String] :type_base_uri Base URI for error type
|
|
115
|
+
# @option options [String] :type The error type suffix
|
|
116
|
+
# @option options [Boolean] :include_codes Whether to include error codes
|
|
117
|
+
# @return [Hash] The Problem Details object
|
|
118
|
+
#
|
|
119
|
+
# @example
|
|
120
|
+
# user.validation_errors_rfc7807
|
|
121
|
+
# user.validation_errors_rfc7807(title: 'User Validation Failed', status: 400)
|
|
122
|
+
def validation_errors_rfc7807(**)
|
|
123
|
+
ErrorFormatter.format(errors, format: :rfc7807, **)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Format validation errors according to JSON:API specification.
|
|
127
|
+
#
|
|
128
|
+
# @param options [Hash] Format options
|
|
129
|
+
# @option options [String] :status The HTTP status code (as string)
|
|
130
|
+
# @option options [String] :title The error title
|
|
131
|
+
# @option options [String] :source_prefix The source pointer prefix
|
|
132
|
+
# @option options [Boolean] :include_codes Whether to include error codes
|
|
133
|
+
# @return [Hash] The JSON:API error object
|
|
134
|
+
#
|
|
135
|
+
# @example
|
|
136
|
+
# user.validation_errors_jsonapi
|
|
137
|
+
# user.validation_errors_jsonapi(title: 'Validation Error', source_prefix: '/data')
|
|
138
|
+
def validation_errors_jsonapi(**)
|
|
139
|
+
ErrorFormatter.format(errors, format: :jsonapi, **)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
data/lib/easy_talk/errors.rb
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# typed: true
|
|
2
3
|
|
|
3
4
|
module EasyTalk
|
|
4
5
|
class Error < StandardError; end
|
|
5
6
|
class ConstraintError < Error; end
|
|
6
7
|
class UnknownOptionError < Error; end
|
|
8
|
+
class UnknownTypeError < Error; end
|
|
9
|
+
class InvalidInstructionsError < Error; end
|
|
7
10
|
class InvalidPropertyNameError < Error; end
|
|
8
11
|
end
|