Class: AnyVali::Schema

Inherits:
Object
  • Object
show all
Defined in:
lib/anyvali/schema.rb

Constant Summary collapse

RESERVED_METADATA_KEYS =
%w[title description deprecated deprecatedMessage notStable since sensitive readonly writeonly examples].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(kind:, constraints: {}, coerce_config: nil, default_value: nil, has_default: false, custom_validators: [], metadata: {}) ⇒ Schema

Returns a new instance of Schema.



10
11
12
13
14
15
16
17
18
# File 'lib/anyvali/schema.rb', line 10

def initialize(kind:, constraints: {}, coerce_config: nil, default_value: nil, has_default: false, custom_validators: [], metadata: {})
  @kind = kind
  @constraints = constraints.freeze
  @coerce_config = coerce_config
  @default_value = default_value
  @has_default = has_default
  @custom_validators = custom_validators.freeze
  @metadata = .freeze
end

Instance Attribute Details

#coerce_configObject (readonly)

Returns the value of attribute coerce_config.



5
6
7
# File 'lib/anyvali/schema.rb', line 5

def coerce_config
  @coerce_config
end

#constraintsObject (readonly)

Returns the value of attribute constraints.



5
6
7
# File 'lib/anyvali/schema.rb', line 5

def constraints
  @constraints
end

#custom_validatorsObject (readonly)

Returns the value of attribute custom_validators.



5
6
7
# File 'lib/anyvali/schema.rb', line 5

def custom_validators
  @custom_validators
end

#default_valueObject (readonly)

Returns the value of attribute default_value.



5
6
7
# File 'lib/anyvali/schema.rb', line 5

def default_value
  @default_value
end

#has_defaultObject (readonly)

Returns the value of attribute has_default.



5
6
7
# File 'lib/anyvali/schema.rb', line 5

def has_default
  @has_default
end

#kindObject (readonly)

Returns the value of attribute kind.



5
6
7
# File 'lib/anyvali/schema.rb', line 5

def kind
  @kind
end

#metadataObject (readonly)

Returns the value of attribute metadata.



5
6
7
# File 'lib/anyvali/schema.rb', line 5

def 
  @metadata
end

Class Method Details

.type_name(value) ⇒ Object

Helper to get the JSON type name for a Ruby value



236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/anyvali/schema.rb', line 236

def self.type_name(value)
  case value
  when NilClass then "null"
  when TrueClass, FalseClass then "boolean"
  when Integer then "number"
  when Float then "number"
  when String then "string"
  when Array then "array"
  when Hash then "object"
  else value.class.name.downcase
  end
end

Instance Method Details

#coerce(config = "string") ⇒ Object

Enable coercion on this schema.

With no argument (schema.coerce) the only portable coercion source — "string" (spec 5.1) — is implied, and the coercion target is inferred from the schema kind (e.g. number().coerce coerces a string to a number). An explicit config string/array (e.g. "string->int", "trim", ["trim", "lower"]) is still accepted for backwards compatibility.



78
79
80
# File 'lib/anyvali/schema.rb', line 78

def coerce(config = "string")
  dup_with(coerce_config: config)
end

#default(value) ⇒ Object



67
68
69
# File 'lib/anyvali/schema.rb', line 67

def default(value)
  dup_with(default_value: value, has_default: true)
end

#describe(description, **opts) ⇒ Object

Raises:

  • (ArgumentError)


82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/anyvali/schema.rb', line 82

def describe(description, **opts)
  raise ArgumentError, "describe(): description must be a string" unless description.is_a?(String)

  meta = { "description" => description }

  if opts.key?(:title)
    raise ArgumentError, "describe(): title must be a string" unless opts[:title].is_a?(String)
    meta["title"] = opts[:title]
  end
  if opts.key?(:deprecated)
    raise ArgumentError, "describe(): deprecated must be a boolean" unless [true, false].include?(opts[:deprecated])
    meta["deprecated"] = opts[:deprecated]
  end
  if opts.key?(:deprecated_message)
    raise ArgumentError, "describe(): deprecatedMessage must be a string" unless opts[:deprecated_message].is_a?(String)
    raise ArgumentError, "describe(): deprecatedMessage requires deprecated: true" unless opts[:deprecated]
    meta["deprecatedMessage"] = opts[:deprecated_message]
  end
  if opts.key?(:not_stable)
    raise ArgumentError, "describe(): notStable must be a boolean" unless [true, false].include?(opts[:not_stable])
    meta["notStable"] = opts[:not_stable]
  end
  if opts.key?(:since)
    raise ArgumentError, "describe(): since must be a string" unless opts[:since].is_a?(String)
    meta["since"] = opts[:since]
  end
  if opts.key?(:sensitive)
    raise ArgumentError, "describe(): sensitive must be a boolean" unless [true, false].include?(opts[:sensitive])
    meta["sensitive"] = opts[:sensitive]
  end
  if opts.key?(:readonly)
    raise ArgumentError, "describe(): readonly must be a boolean" unless [true, false].include?(opts[:readonly])
    meta["readonly"] = opts[:readonly]
  end
  if opts.key?(:writeonly)
    raise ArgumentError, "describe(): writeonly must be a boolean" unless [true, false].include?(opts[:writeonly])
    meta["writeonly"] = opts[:writeonly]
  end
  if opts[:readonly] && opts[:writeonly]
    raise ArgumentError, "describe(): readonly and writeonly cannot both be true"
  end
  if opts.key?(:examples)
    raise ArgumentError, "describe(): examples must be an array" unless opts[:examples].is_a?(Array)
    meta["examples"] = opts[:examples]
  end

  dup_with(metadata: @metadata.merge(meta))
end

#export(mode: :portable) ⇒ Object



154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/anyvali/schema.rb', line 154

def export(mode: :portable)
  if mode == :portable && !portable?
    raise ValidationError, [
      ValidationIssue.new(
        code: IssueCodes::CUSTOM_VALIDATION_NOT_PORTABLE,
        expected: "portable schema",
        received: "schema with custom validators"
      )
    ]
  end
  doc = AnyValiDocument.new(root: self)
  doc.to_h
end

#parse(input) ⇒ Object

Raises:



20
21
22
23
24
# File 'lib/anyvali/schema.rb', line 20

def parse(input)
  result = safe_parse(input)
  raise ValidationError, result.issues if result.failure?
  result.value
end

#portable?Boolean

Returns:

  • (Boolean)


150
151
152
# File 'lib/anyvali/schema.rb', line 150

def portable?
  @custom_validators.empty?
end

#refine(&block) ⇒ Object



146
147
148
# File 'lib/anyvali/schema.rb', line 146

def refine(&block)
  dup_with(custom_validators: @custom_validators + [block])
end

#safe_parse(input, path: [], context: nil) ⇒ Object



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/anyvali/schema.rb', line 26

def safe_parse(input, path: [], context: nil)
  context ||= ValidationContext.new
  handled, sensitive = handle_sensitive(input, path, context)
  return sensitive if handled
  value = input
  issues = []

  # Step 1: Coercion (if present and configured)
  if @coerce_config && !value.nil?
    coerced = Coercion.apply(value, @coerce_config, @kind)
    if coerced[:success]
      value = coerced[:value]
    else
      issues << ValidationIssue.new(
        code: IssueCodes::COERCION_FAILED,
        path: path,
        expected: @kind,
        received: value.is_a?(String) ? value : value.to_s
      )
      return ParseResult.new(value: nil, issues: issues)
    end
  end

  # Step 2: Validate
  validate(value, path, issues, context)

  # Step 3: Custom validators
  if issues.empty? && !@custom_validators.empty?
    @custom_validators.each do |validator|
      validator_issues = validator.call(value, path)
      issues.concat(validator_issues) if validator_issues
    end
  end

  if issues.empty?
    ParseResult.new(value: value, issues: [])
  else
    ParseResult.new(value: nil, issues: issues)
  end
end

#to_nodeObject



168
169
170
171
172
173
174
175
# File 'lib/anyvali/schema.rb', line 168

def to_node
  node = { "kind" => @kind }
  @constraints.each { |k, v| node[k] = v }
  node["coerce"] = @coerce_config if @coerce_config
  node["default"] = @default_value if @has_default
  node["metadata"] = @metadata.dup unless @metadata.empty?
  node
end

#with_metadata(meta, replace: false) ⇒ Object



131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/anyvali/schema.rb', line 131

def (meta, replace: false)
  meta.each_key do |key|
    if RESERVED_METADATA_KEYS.include?(key)
      raise ArgumentError, "with_metadata(): \"#{key}\" is a reserved key. Use describe() instead."
    end
  end

  if replace
    preserved = @metadata.select { |k, _| RESERVED_METADATA_KEYS.include?(k) }
    dup_with(metadata: preserved.merge(meta))
  else
    dup_with(metadata: @metadata.merge(meta))
  end
end