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



188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/anyvali/schema.rb', line 188

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) ⇒ Object



69
70
71
# File 'lib/anyvali/schema.rb', line 69

def coerce(config)
  dup_with(coerce_config: config)
end

#default(value) ⇒ Object



65
66
67
# File 'lib/anyvali/schema.rb', line 65

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

#describe(description, **opts) ⇒ Object

Raises:

  • (ArgumentError)


73
74
75
76
77
78
79
80
81
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
# File 'lib/anyvali/schema.rb', line 73

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



145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/anyvali/schema.rb', line 145

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)


141
142
143
# File 'lib/anyvali/schema.rb', line 141

def portable?
  @custom_validators.empty?
end

#refine(&block) ⇒ Object



137
138
139
# File 'lib/anyvali/schema.rb', line 137

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
# File 'lib/anyvali/schema.rb', line 26

def safe_parse(input, path: [], context: nil)
  context ||= ValidationContext.new
  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



159
160
161
162
163
164
165
166
# File 'lib/anyvali/schema.rb', line 159

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



122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/anyvali/schema.rb', line 122

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