rbdantic 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +245 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +852 -0
- data/README_CN.md +852 -0
- data/Rakefile +12 -0
- data/lib/rbdantic/base/access.rb +105 -0
- data/lib/rbdantic/base/dsl.rb +79 -0
- data/lib/rbdantic/base/validation.rb +152 -0
- data/lib/rbdantic/base.rb +30 -0
- data/lib/rbdantic/config.rb +60 -0
- data/lib/rbdantic/error_detail.rb +54 -0
- data/lib/rbdantic/field.rb +188 -0
- data/lib/rbdantic/json_schema/defs_registry.rb +79 -0
- data/lib/rbdantic/json_schema/generator.rb +148 -0
- data/lib/rbdantic/json_schema/types.rb +98 -0
- data/lib/rbdantic/serialization/dumper.rb +133 -0
- data/lib/rbdantic/serialization/json_serializer.rb +60 -0
- data/lib/rbdantic/validators/field_validator.rb +83 -0
- data/lib/rbdantic/validators/model_validator.rb +59 -0
- data/lib/rbdantic/validators/types/array.rb +77 -0
- data/lib/rbdantic/validators/types/base.rb +78 -0
- data/lib/rbdantic/validators/types/boolean.rb +37 -0
- data/lib/rbdantic/validators/types/float.rb +32 -0
- data/lib/rbdantic/validators/types/hash.rb +54 -0
- data/lib/rbdantic/validators/types/integer.rb +28 -0
- data/lib/rbdantic/validators/types/model.rb +75 -0
- data/lib/rbdantic/validators/types/number.rb +63 -0
- data/lib/rbdantic/validators/types/string.rb +70 -0
- data/lib/rbdantic/validators/types/symbol.rb +30 -0
- data/lib/rbdantic/validators/types/time.rb +33 -0
- data/lib/rbdantic/validators/types.rb +63 -0
- data/lib/rbdantic/validators/validator_context.rb +43 -0
- data/lib/rbdantic/version.rb +5 -0
- data/lib/rbdantic.rb +8 -0
- data/sig/rbdantic.rbs +4 -0
- metadata +84 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Rbdantic
|
|
6
|
+
module JsonSchema
|
|
7
|
+
# Registry for tracking model schemas during generation
|
|
8
|
+
# Used to implement $defs/$ref pattern for circular references
|
|
9
|
+
class DefsRegistry
|
|
10
|
+
def initialize
|
|
11
|
+
@defs = {}
|
|
12
|
+
@being_processed = Set.new # Currently being processed (for cycle detection)
|
|
13
|
+
@referenced = Set.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def key_for(model_class)
|
|
17
|
+
model_class.name || "AnonymousModel_#{model_class.object_id}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Check if a model is currently being processed (for cycle detection)
|
|
21
|
+
# @param model_class [Class] the model class
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def being_processed?(model_class)
|
|
24
|
+
@being_processed.include?(model_class)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Mark a model as being processed
|
|
28
|
+
# @param model_class [Class] the model class
|
|
29
|
+
def mark_processing(model_class)
|
|
30
|
+
@being_processed.add(model_class)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Register a model's completed schema and return its key
|
|
34
|
+
# @param model_class [Class] the model class
|
|
35
|
+
# @param schema [Hash] the generated schema
|
|
36
|
+
# @return [String] the key used in $defs
|
|
37
|
+
def register(model_class, schema)
|
|
38
|
+
key = key_for(model_class)
|
|
39
|
+
@defs[key] = schema
|
|
40
|
+
@being_processed.delete(model_class) # No longer being processed
|
|
41
|
+
key
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Check if a model's schema is registered (completed)
|
|
45
|
+
# @param model_class [Class] the model class
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def registered?(model_class)
|
|
48
|
+
@defs.key?(key_for(model_class))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def referenced?(model_class)
|
|
52
|
+
@referenced.include?(key_for(model_class))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Generate a $ref for a registered model
|
|
56
|
+
# @param model_class [Class] the model class
|
|
57
|
+
# @return [Hash] the $ref object
|
|
58
|
+
def ref_for(model_class)
|
|
59
|
+
key = key_for(model_class)
|
|
60
|
+
@referenced.add(key)
|
|
61
|
+
{ "$ref" => "#/$defs/#{key}" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get the $defs hash for inclusion in top-level schema
|
|
65
|
+
# @return [Hash, nil] the defs hash or nil if empty
|
|
66
|
+
def defs_hash(except: nil)
|
|
67
|
+
defs = except ? @defs.reject { |key, _| key == key_for(except) } : @defs
|
|
68
|
+
defs.empty? ? nil : defs
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Clear the registry (for fresh generation)
|
|
72
|
+
def clear
|
|
73
|
+
@defs.clear
|
|
74
|
+
@being_processed.clear
|
|
75
|
+
@referenced.clear
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "defs_registry"
|
|
4
|
+
|
|
5
|
+
module Rbdantic
|
|
6
|
+
module JsonSchema
|
|
7
|
+
# Generate JSON Schema from model class
|
|
8
|
+
class Generator
|
|
9
|
+
# JSON Schema version
|
|
10
|
+
SCHEMA_VERSION = "https://json-schema.org/draft/2020-12/schema"
|
|
11
|
+
|
|
12
|
+
# Generate schema for a model class
|
|
13
|
+
# @param model_class [Class] the model class
|
|
14
|
+
# @param options [Hash] generation options
|
|
15
|
+
# @option options [String] :title optional title (defaults to class name)
|
|
16
|
+
# @option options [String] :description optional description
|
|
17
|
+
# @option options [String] :schema_id optional $id for the schema
|
|
18
|
+
# @option options [Boolean] :include_defaults include default values in schema
|
|
19
|
+
# @option options [DefsRegistry] :defs_registry registry for $defs/$ref pattern
|
|
20
|
+
# @return [Hash] JSON Schema
|
|
21
|
+
def self.generate(model_class, **options)
|
|
22
|
+
new(model_class, **options).generate
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(model_class, title: nil, description: nil, schema_id: nil,
|
|
26
|
+
include_defaults: true, top_level: true, defs_registry: nil, by_alias: false)
|
|
27
|
+
@model_class = model_class
|
|
28
|
+
@title = title || model_class.name
|
|
29
|
+
@description = description
|
|
30
|
+
@schema_id = schema_id
|
|
31
|
+
@include_defaults = include_defaults
|
|
32
|
+
@top_level = top_level
|
|
33
|
+
@by_alias = by_alias
|
|
34
|
+
# Use provided registry or create one at top level
|
|
35
|
+
@defs_registry = defs_registry || (top_level ? DefsRegistry.new : nil)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def generate
|
|
39
|
+
# Handle circular references - if being processed, return $ref
|
|
40
|
+
if @defs_registry && @defs_registry.being_processed?(@model_class)
|
|
41
|
+
return @defs_registry.ref_for(@model_class)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
schema = {}
|
|
45
|
+
|
|
46
|
+
# Only add $schema and $id at top level
|
|
47
|
+
if @top_level
|
|
48
|
+
schema["$schema"] = SCHEMA_VERSION
|
|
49
|
+
schema["$id"] = @schema_id if @schema_id
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
schema["type"] = "object"
|
|
53
|
+
|
|
54
|
+
# Add optional metadata
|
|
55
|
+
schema["title"] = @title if @title && @top_level
|
|
56
|
+
schema["description"] = @description if @description
|
|
57
|
+
|
|
58
|
+
# Mark as being processed before processing fields (for cycle detection)
|
|
59
|
+
if @defs_registry
|
|
60
|
+
@defs_registry.mark_processing(@model_class)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Generate properties
|
|
64
|
+
properties = {}
|
|
65
|
+
required = []
|
|
66
|
+
|
|
67
|
+
@model_class.fields.each do |name, field_info|
|
|
68
|
+
property_name = schema_property_name(name, field_info)
|
|
69
|
+
properties[property_name] = generate_property(field_info)
|
|
70
|
+
required << property_name if field_info.required?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
schema["properties"] = properties
|
|
74
|
+
schema["required"] = required if required.any?
|
|
75
|
+
|
|
76
|
+
# Create a copy of schema for $defs (without $defs key to avoid circular JSON)
|
|
77
|
+
defs_schema = {
|
|
78
|
+
"type" => "object",
|
|
79
|
+
"title" => @title,
|
|
80
|
+
"description" => @description,
|
|
81
|
+
"properties" => properties
|
|
82
|
+
}.compact
|
|
83
|
+
defs_schema["required"] = required if required.any?
|
|
84
|
+
|
|
85
|
+
# Register this model's schema in defs registry
|
|
86
|
+
if @defs_registry
|
|
87
|
+
@defs_registry.register(@model_class, defs_schema)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Add $defs at top level if we have referenced models
|
|
91
|
+
if @top_level && @defs_registry
|
|
92
|
+
defs = if @defs_registry.referenced?(@model_class)
|
|
93
|
+
@defs_registry.defs_hash
|
|
94
|
+
else
|
|
95
|
+
@defs_registry.defs_hash(except: @model_class)
|
|
96
|
+
end
|
|
97
|
+
if defs && defs.any?
|
|
98
|
+
schema["$defs"] = defs
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
schema
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def generate_property(field_info)
|
|
108
|
+
schema = Types.to_schema(
|
|
109
|
+
field_info.type,
|
|
110
|
+
**field_info.constraints,
|
|
111
|
+
defs_registry: @defs_registry,
|
|
112
|
+
by_alias: @by_alias
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Handle optional fields - allow null
|
|
116
|
+
schema = handle_optional(schema) if field_info.optional
|
|
117
|
+
|
|
118
|
+
# Include default value if present and not a factory
|
|
119
|
+
if @include_defaults && field_info.has_default? && !field_info.default_factory
|
|
120
|
+
schema["default"] = field_info.default
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
schema
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def handle_optional(schema)
|
|
127
|
+
if schema["$ref"]
|
|
128
|
+
# For $ref, use oneOf to allow null
|
|
129
|
+
{ "oneOf" => [schema, { "type" => "null" }] }
|
|
130
|
+
elsif schema["type"].is_a?(Array)
|
|
131
|
+
schema["type"] = schema["type"] + ["null"]
|
|
132
|
+
schema
|
|
133
|
+
else
|
|
134
|
+
schema["type"] = [schema["type"], "null"]
|
|
135
|
+
schema
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def schema_property_name(name, field_info)
|
|
140
|
+
if @by_alias && field_info.alias_name
|
|
141
|
+
field_info.alias_name.to_s
|
|
142
|
+
else
|
|
143
|
+
name.to_s
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rbdantic
|
|
4
|
+
module JsonSchema
|
|
5
|
+
# Type-to-schema mappings
|
|
6
|
+
module Types
|
|
7
|
+
# Map Ruby type to JSON Schema type
|
|
8
|
+
# @param type [Class] Ruby type
|
|
9
|
+
# @param constraints [Hash] field constraints
|
|
10
|
+
# @param defs_registry [DefsRegistry] registry for $defs/$ref pattern
|
|
11
|
+
# @return [Hash] JSON Schema for the type
|
|
12
|
+
def self.to_schema(type, **constraints)
|
|
13
|
+
defs_registry = constraints[:defs_registry]
|
|
14
|
+
by_alias = constraints[:by_alias]
|
|
15
|
+
constraints = constraints.reject { |k, _| k == :defs_registry || k == :by_alias }
|
|
16
|
+
|
|
17
|
+
schema = base_schema(type, defs_registry: defs_registry, by_alias: by_alias)
|
|
18
|
+
|
|
19
|
+
# Add constraints
|
|
20
|
+
add_string_constraints(schema, constraints) if type == ::String
|
|
21
|
+
add_numeric_constraints(schema, constraints) if type == ::Integer || type == ::Float
|
|
22
|
+
add_array_constraints(schema, constraints, defs_registry: defs_registry, by_alias: by_alias) if type == ::Array
|
|
23
|
+
add_hash_constraints(schema, constraints) if type == ::Hash
|
|
24
|
+
|
|
25
|
+
schema
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.base_schema(type, defs_registry: nil, by_alias: false)
|
|
29
|
+
# Use direct class comparison (type == Class), not === (which checks instance type)
|
|
30
|
+
if type == ::String
|
|
31
|
+
{ "type" => "string" }
|
|
32
|
+
elsif type == ::Integer
|
|
33
|
+
{ "type" => "integer" }
|
|
34
|
+
elsif type == ::Float
|
|
35
|
+
{ "type" => "number" }
|
|
36
|
+
elsif type == ::Time
|
|
37
|
+
{ "type" => "string", "format" => "date-time" }
|
|
38
|
+
elsif type == ::Rbdantic::Boolean
|
|
39
|
+
{ "type" => "boolean" }
|
|
40
|
+
elsif type == ::Array
|
|
41
|
+
{ "type" => "array" }
|
|
42
|
+
elsif type == ::Hash
|
|
43
|
+
{ "type" => "object" }
|
|
44
|
+
elsif type.is_a?(Class) && type < ::Rbdantic::BaseModel
|
|
45
|
+
if defs_registry
|
|
46
|
+
unless defs_registry.registered?(type) || defs_registry.being_processed?(type)
|
|
47
|
+
Generator.generate(type, top_level: false, defs_registry: defs_registry, by_alias: by_alias)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
defs_registry.ref_for(type)
|
|
51
|
+
else
|
|
52
|
+
Generator.generate(type, top_level: false, defs_registry: nil, by_alias: by_alias)
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
{ "type" => "object" }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.add_string_constraints(schema, constraints)
|
|
60
|
+
schema["minLength"] = constraints[:min_length] if constraints[:min_length]
|
|
61
|
+
schema["maxLength"] = constraints[:max_length] if constraints[:max_length]
|
|
62
|
+
schema["pattern"] = constraints[:pattern].source if constraints[:pattern]
|
|
63
|
+
|
|
64
|
+
if constraints[:format]
|
|
65
|
+
format_map = {
|
|
66
|
+
email: "email",
|
|
67
|
+
uri: "uri",
|
|
68
|
+
uuid: "uuid"
|
|
69
|
+
}
|
|
70
|
+
schema["format"] = format_map[constraints[:format]] if format_map[constraints[:format]]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.add_numeric_constraints(schema, constraints)
|
|
75
|
+
schema["minimum"] = constraints[:ge] if constraints[:ge]
|
|
76
|
+
schema["exclusiveMinimum"] = constraints[:gt] if constraints[:gt]
|
|
77
|
+
schema["maximum"] = constraints[:le] if constraints[:le]
|
|
78
|
+
schema["exclusiveMaximum"] = constraints[:lt] if constraints[:lt]
|
|
79
|
+
schema["multipleOf"] = constraints[:multiple_of] if constraints[:multiple_of]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.add_array_constraints(schema, constraints, defs_registry: nil, by_alias: false)
|
|
83
|
+
schema["minItems"] = constraints[:min_items] if constraints[:min_items]
|
|
84
|
+
schema["maxItems"] = constraints[:max_items] if constraints[:max_items]
|
|
85
|
+
schema["uniqueItems"] = constraints[:unique_items] if constraints[:unique_items]
|
|
86
|
+
if constraints[:element_type]
|
|
87
|
+
schema["items"] =
|
|
88
|
+
to_schema(constraints[:element_type], defs_registry: defs_registry, by_alias: by_alias)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.add_hash_constraints(schema, constraints)
|
|
93
|
+
schema["minProperties"] = constraints[:min_properties] if constraints[:min_properties]
|
|
94
|
+
schema["maxProperties"] = constraints[:max_properties] if constraints[:max_properties]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rbdantic
|
|
4
|
+
module Serialization
|
|
5
|
+
# Handles model_dump with various options
|
|
6
|
+
class Dumper
|
|
7
|
+
def self.dump(model, **options)
|
|
8
|
+
new(model, **options).dump
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(model, mode: nil, include: nil, exclude: nil,
|
|
12
|
+
exclude_unset: false, exclude_defaults: false, by_alias: false)
|
|
13
|
+
@model = model
|
|
14
|
+
@mode = mode
|
|
15
|
+
@include = include
|
|
16
|
+
@exclude = exclude
|
|
17
|
+
@exclude_unset = exclude_unset
|
|
18
|
+
@exclude_defaults = exclude_defaults
|
|
19
|
+
@by_alias = by_alias
|
|
20
|
+
@set_fields = Array(model.instance_variable_get("@__fields_set__"))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def dump
|
|
24
|
+
result = {}
|
|
25
|
+
|
|
26
|
+
@model.class.fields.each do |name, field_info|
|
|
27
|
+
next if should_exclude?(name, field_info)
|
|
28
|
+
|
|
29
|
+
# Use alias if requested and present
|
|
30
|
+
output_name = (@by_alias && field_info.alias_name) ? field_info.alias_name.to_sym : name.to_sym
|
|
31
|
+
|
|
32
|
+
result[output_name] = serialize_item(
|
|
33
|
+
@model.instance_variable_get("@#{name}"),
|
|
34
|
+
**nested_dump_options(name, field_info)
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
extra_fields.each do |name|
|
|
39
|
+
next if should_exclude_extra?(name)
|
|
40
|
+
result[name.to_sym] = serialize_item(@model.instance_variable_get("@#{name}"))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def extra_fields
|
|
49
|
+
Array(@model.instance_variable_get("@__extra_fields__"))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def should_exclude?(name, field_info)
|
|
53
|
+
return true if filter_matches?(@exclude, name, field_info)
|
|
54
|
+
return true if @include && !filter_matches?(@include, name, field_info)
|
|
55
|
+
return true if @exclude_unset && !@set_fields.include?(name)
|
|
56
|
+
|
|
57
|
+
if @exclude_defaults && field_info.has_default? && field_info.default_factory.nil?
|
|
58
|
+
current_val = @model.instance_variable_get("@#{name}")
|
|
59
|
+
return true if current_val == field_info.default
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def should_exclude_extra?(name)
|
|
66
|
+
return true if extra_filter_matches?(@exclude, name)
|
|
67
|
+
return true if @include && !extra_filter_matches?(@include, name)
|
|
68
|
+
return true if @exclude_unset && !@set_fields.include?(name)
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def nested_dump_options(field_name, field_info)
|
|
73
|
+
{
|
|
74
|
+
mode: @mode,
|
|
75
|
+
exclude_defaults: @exclude_defaults,
|
|
76
|
+
exclude_unset: @exclude_unset,
|
|
77
|
+
by_alias: @by_alias,
|
|
78
|
+
include: nested_filter_for(@include, field_name, field_info),
|
|
79
|
+
exclude: nested_filter_for(@exclude, field_name, field_info)
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def nested_filter_for(filter, field_name, field_info = nil)
|
|
84
|
+
return unless filter.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
filter_keys_for(field_name, field_info).each do |key|
|
|
87
|
+
return filter[key] if filter.key?(key)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def serialize_item(item, **options)
|
|
94
|
+
case item
|
|
95
|
+
when Rbdantic::BaseModel then Dumper.dump(item, **options)
|
|
96
|
+
when Array then item.map { |v| serialize_item(v, **options) }
|
|
97
|
+
when Hash then item.transform_values { |v| serialize_item(v, **options) }
|
|
98
|
+
else item
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def filter_matches?(filter, field_name, field_info)
|
|
103
|
+
return false unless filter
|
|
104
|
+
|
|
105
|
+
if filter.is_a?(Hash)
|
|
106
|
+
filter_keys_for(field_name, field_info).any? { |key| filter.key?(key) }
|
|
107
|
+
else
|
|
108
|
+
filter_keys_for(field_name, field_info).any? { |key| filter.include?(key) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def extra_filter_matches?(filter, field_name)
|
|
113
|
+
return false unless filter
|
|
114
|
+
|
|
115
|
+
keys = filter_keys_for(field_name)
|
|
116
|
+
if filter.is_a?(Hash)
|
|
117
|
+
keys.any? { |key| filter.key?(key) }
|
|
118
|
+
else
|
|
119
|
+
keys.any? { |key| filter.include?(key) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def filter_keys_for(field_name, field_info = nil)
|
|
124
|
+
keys = [field_name, field_name.to_s]
|
|
125
|
+
if @by_alias && field_info&.alias_name
|
|
126
|
+
keys << field_info.alias_name.to_sym
|
|
127
|
+
keys << field_info.alias_name.to_s
|
|
128
|
+
end
|
|
129
|
+
keys.uniq
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rbdantic
|
|
6
|
+
module Serialization
|
|
7
|
+
# JSON serialization and deserialization
|
|
8
|
+
class JsonSerializer
|
|
9
|
+
# Serialize model to JSON string
|
|
10
|
+
# @param model [BaseModel] the model instance
|
|
11
|
+
# @param indent [Integer, nil] JSON indentation
|
|
12
|
+
# @return [String] JSON string
|
|
13
|
+
def self.dump(model, indent: nil, **options)
|
|
14
|
+
data = Dumper.dump(model, mode: :json, **options)
|
|
15
|
+
|
|
16
|
+
if indent
|
|
17
|
+
JSON.pretty_generate(data, indent: normalize_indent(indent))
|
|
18
|
+
else
|
|
19
|
+
JSON.generate(data)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Parse JSON string into model
|
|
24
|
+
# @param json_string [String] JSON data
|
|
25
|
+
# @param model_class [Class] target model class
|
|
26
|
+
# @return [BaseModel] model instance
|
|
27
|
+
def self.load(json_string, model_class)
|
|
28
|
+
data = JSON.parse(json_string, symbolize_names: true)
|
|
29
|
+
model_class.new(data)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Parse JSON string into model (raises on error)
|
|
33
|
+
# @param json_string [String] JSON data
|
|
34
|
+
# @param model_class [Class] target model class
|
|
35
|
+
# @return [BaseModel] model instance
|
|
36
|
+
def self.parse!(json_string, model_class)
|
|
37
|
+
load(json_string, model_class)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Parse JSON string safely, returns nil on error
|
|
41
|
+
# @param json_string [String] JSON data
|
|
42
|
+
# @param model_class [Class] target model class
|
|
43
|
+
# @return [BaseModel, nil] model instance or nil
|
|
44
|
+
def self.parse(json_string, model_class)
|
|
45
|
+
begin
|
|
46
|
+
load(json_string, model_class)
|
|
47
|
+
rescue JSON::ParserError, ValidationError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.normalize_indent(indent)
|
|
53
|
+
return indent if indent.is_a?(String)
|
|
54
|
+
|
|
55
|
+
" " * indent.to_i
|
|
56
|
+
end
|
|
57
|
+
private_class_method :normalize_indent
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rbdantic
|
|
4
|
+
module Validators
|
|
5
|
+
# Decorator for field-level validators
|
|
6
|
+
# Usage:
|
|
7
|
+
# field_validator :age, mode: :after do |value|
|
|
8
|
+
# raise "must be at least 18" if value < 18
|
|
9
|
+
# end
|
|
10
|
+
class FieldValidator
|
|
11
|
+
MODES = %i[before after plain wrap].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :field_name, :mode, :validator_proc
|
|
14
|
+
|
|
15
|
+
def initialize(field_name, mode: :after, &block)
|
|
16
|
+
@field_name = field_name
|
|
17
|
+
@mode = validate_mode!(mode)
|
|
18
|
+
@validator_proc = block
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Execute the validator
|
|
22
|
+
# @param value the field value
|
|
23
|
+
# @param context [ValidatorContext] validation context
|
|
24
|
+
# @return [Array<ErrorDetail>] errors from validation
|
|
25
|
+
def call(value, context)
|
|
26
|
+
errors, = apply(value, context)
|
|
27
|
+
errors
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Execute the validator and optionally transform the value
|
|
31
|
+
# @param value the field value
|
|
32
|
+
# @param context [ValidatorContext] validation context
|
|
33
|
+
# @param handler [Proc, nil] callable for inner validation (wrap mode only)
|
|
34
|
+
# @return [Array] tuple of [errors, transformed_value]
|
|
35
|
+
def apply(value, context, handler = nil, &handler_block)
|
|
36
|
+
errors = []
|
|
37
|
+
transformed_value = value
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
handler ||= handler_block
|
|
41
|
+
|
|
42
|
+
# For wrap mode, pass handler if available (Issue 4 fix)
|
|
43
|
+
if @mode == :wrap && handler
|
|
44
|
+
result = @validator_proc.call(value, context, handler)
|
|
45
|
+
else
|
|
46
|
+
result = @validator_proc.call(value, context)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returning false signals validation failure.
|
|
50
|
+
# Any other non-nil/non-true return value is treated as a transformed value.
|
|
51
|
+
if result == false
|
|
52
|
+
errors << ErrorDetail.new(
|
|
53
|
+
type: :validation_failed,
|
|
54
|
+
loc: [@field_name],
|
|
55
|
+
msg: "Custom validation failed",
|
|
56
|
+
input: value
|
|
57
|
+
)
|
|
58
|
+
elsif !result.nil? && result != true
|
|
59
|
+
transformed_value = result
|
|
60
|
+
end
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
errors << ErrorDetail.new(
|
|
63
|
+
type: :validation_failed,
|
|
64
|
+
loc: [@field_name],
|
|
65
|
+
msg: e.message,
|
|
66
|
+
input: value
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
[errors, transformed_value]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def validate_mode!(mode)
|
|
76
|
+
unless MODES.include?(mode)
|
|
77
|
+
raise ArgumentError, "mode must be one of #{MODES.join(", ")}"
|
|
78
|
+
end
|
|
79
|
+
mode
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rbdantic
|
|
4
|
+
module Validators
|
|
5
|
+
# Decorator for model-level validators
|
|
6
|
+
# Usage:
|
|
7
|
+
# model_validator mode: :before do |data|
|
|
8
|
+
# data[:email] = data[:email]&.downcase
|
|
9
|
+
# data
|
|
10
|
+
# end
|
|
11
|
+
class ModelValidator
|
|
12
|
+
MODES = %i[before after].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :mode, :validator_proc
|
|
15
|
+
|
|
16
|
+
def initialize(mode: :after, &block)
|
|
17
|
+
@mode = validate_mode!(mode)
|
|
18
|
+
@validator_proc = block
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Execute the :before validator, which receives and must return a Hash.
|
|
22
|
+
# Only valid to call when mode == :before.
|
|
23
|
+
# @param data [Hash] the input data
|
|
24
|
+
# @return [Hash] modified data
|
|
25
|
+
def call(data)
|
|
26
|
+
@validator_proc.call(data)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Run validation and collect errors
|
|
30
|
+
# @param model_instance the model instance (for after mode)
|
|
31
|
+
# @return [Array<ErrorDetail>] errors from validation
|
|
32
|
+
def validate(model_instance)
|
|
33
|
+
errors = []
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
@validator_proc.call(model_instance)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
errors << ErrorDetail.new(
|
|
39
|
+
type: :model_validation_failed,
|
|
40
|
+
loc: [],
|
|
41
|
+
msg: e.message,
|
|
42
|
+
input: nil
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
errors
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def validate_mode!(mode)
|
|
52
|
+
unless MODES.include?(mode)
|
|
53
|
+
raise ArgumentError, "mode must be one of #{MODES.join(", ")}"
|
|
54
|
+
end
|
|
55
|
+
mode
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|