ask-schema 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/LICENSE +21 -0
- data/README.md +350 -0
- data/lib/ask/schema/dsl/complex_types.rb +72 -0
- data/lib/ask/schema/dsl/conditionals.rb +243 -0
- data/lib/ask/schema/dsl/primitive_types.rb +60 -0
- data/lib/ask/schema/dsl/schema_builders.rb +254 -0
- data/lib/ask/schema/dsl/utilities.rb +99 -0
- data/lib/ask/schema/dsl.rb +24 -0
- data/lib/ask/schema/errors.rb +31 -0
- data/lib/ask/schema/helpers.rb +19 -0
- data/lib/ask/schema/json_output.rb +49 -0
- data/lib/ask/schema/validator.rb +100 -0
- data/lib/ask/schema/version.rb +7 -0
- data/lib/ask-schema.rb +176 -0
- metadata +97 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
class Schema
|
|
5
|
+
module DSL
|
|
6
|
+
# DSL methods for declaring primitive-type properties.
|
|
7
|
+
module PrimitiveTypes
|
|
8
|
+
# Declare a string property.
|
|
9
|
+
# @param name [Symbol] Property name
|
|
10
|
+
# @param description [String, nil] Property description
|
|
11
|
+
# @param required [Boolean] Whether the property is required (default: true)
|
|
12
|
+
# @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
|
|
13
|
+
# @param options [Hash] Additional JSON Schema constraints (enum:, min_length:, pattern:, etc.)
|
|
14
|
+
def string(name, description: nil, required: true, requires: nil, **options)
|
|
15
|
+
add_property(name, string_schema(description: description, **options), required: required, requires: requires)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Declare a number property.
|
|
19
|
+
# @param name [Symbol] Property name
|
|
20
|
+
# @param description [String, nil] Property description
|
|
21
|
+
# @param required [Boolean] Whether the property is required (default: true)
|
|
22
|
+
# @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
|
|
23
|
+
# @param options [Hash] Additional JSON Schema constraints (minimum:, maximum:, etc.)
|
|
24
|
+
def number(name, description: nil, required: true, requires: nil, **options)
|
|
25
|
+
add_property(name, number_schema(description: description, **options), required: required, requires: requires)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Declare an integer property.
|
|
29
|
+
# @param name [Symbol] Property name
|
|
30
|
+
# @param description [String, nil] Property description
|
|
31
|
+
# @param required [Boolean] Whether the property is required (default: true)
|
|
32
|
+
# @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
|
|
33
|
+
# @param options [Hash] Additional JSON Schema constraints (minimum:, maximum:, etc.)
|
|
34
|
+
def integer(name, description: nil, required: true, requires: nil, **options)
|
|
35
|
+
add_property(name, integer_schema(description: description, **options), required: required, requires: requires)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Declare a boolean property.
|
|
39
|
+
# @param name [Symbol] Property name
|
|
40
|
+
# @param description [String, nil] Property description
|
|
41
|
+
# @param required [Boolean] Whether the property is required (default: true)
|
|
42
|
+
# @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
|
|
43
|
+
# @param options [Hash] Additional JSON Schema constraints
|
|
44
|
+
def boolean(name, description: nil, required: true, requires: nil, **options)
|
|
45
|
+
add_property(name, boolean_schema(description: description, **options), required: required, requires: requires)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Declare a null property.
|
|
49
|
+
# @param name [Symbol] Property name
|
|
50
|
+
# @param description [String, nil] Property description
|
|
51
|
+
# @param required [Boolean] Whether the property is required (default: true)
|
|
52
|
+
# @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
|
|
53
|
+
# @param options [Hash] Additional JSON Schema constraints
|
|
54
|
+
def null(name, description: nil, required: true, requires: nil, **options)
|
|
55
|
+
add_property(name, null_schema(description: description, **options), required: required, requires: requires)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
class Schema
|
|
5
|
+
module DSL
|
|
6
|
+
# Core schema builders that generate JSON Schema fragments for each type.
|
|
7
|
+
#
|
|
8
|
+
# Each method returns a Hash representing a JSON Schema fragment for
|
|
9
|
+
# the given type, with all specified constraints.
|
|
10
|
+
module SchemaBuilders
|
|
11
|
+
# Build a string schema fragment.
|
|
12
|
+
# @param description [String, nil] Property description
|
|
13
|
+
# @param enum [Array<String>, nil] Allowed values
|
|
14
|
+
# @param min_length [Integer, nil] Minimum string length
|
|
15
|
+
# @param max_length [Integer, nil] Maximum string length
|
|
16
|
+
# @param pattern [String, nil] Regex pattern for validation
|
|
17
|
+
# @param format [String, nil] String format (e.g., "email", "uri")
|
|
18
|
+
# @return [Hash] JSON Schema fragment
|
|
19
|
+
def string_schema(description: nil, enum: nil, min_length: nil, max_length: nil, pattern: nil, format: nil)
|
|
20
|
+
{
|
|
21
|
+
type: "string",
|
|
22
|
+
enum: enum,
|
|
23
|
+
description: description,
|
|
24
|
+
minLength: min_length,
|
|
25
|
+
maxLength: max_length,
|
|
26
|
+
pattern: pattern,
|
|
27
|
+
format: format
|
|
28
|
+
}.compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Build a number schema fragment.
|
|
32
|
+
# @param description [String, nil] Property description
|
|
33
|
+
# @param minimum [Numeric, nil] Minimum value
|
|
34
|
+
# @param maximum [Numeric, nil] Maximum value
|
|
35
|
+
# @param multiple_of [Numeric, nil] Value must be multiple of this
|
|
36
|
+
# @param enum [Array<Numeric>, nil] Allowed values
|
|
37
|
+
# @return [Hash] JSON Schema fragment
|
|
38
|
+
def number_schema(description: nil, minimum: nil, maximum: nil, multiple_of: nil, enum: nil)
|
|
39
|
+
{
|
|
40
|
+
type: "number",
|
|
41
|
+
description: description,
|
|
42
|
+
minimum: minimum,
|
|
43
|
+
maximum: maximum,
|
|
44
|
+
multipleOf: multiple_of,
|
|
45
|
+
enum: enum
|
|
46
|
+
}.compact
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Build an integer schema fragment.
|
|
50
|
+
# @param description [String, nil] Property description
|
|
51
|
+
# @param minimum [Integer, nil] Minimum value
|
|
52
|
+
# @param maximum [Integer, nil] Maximum value
|
|
53
|
+
# @param multiple_of [Integer, nil] Value must be multiple of this
|
|
54
|
+
# @param enum [Array<Integer>, nil] Allowed values
|
|
55
|
+
# @return [Hash] JSON Schema fragment
|
|
56
|
+
def integer_schema(description: nil, minimum: nil, maximum: nil, multiple_of: nil, enum: nil)
|
|
57
|
+
{
|
|
58
|
+
type: "integer",
|
|
59
|
+
description: description,
|
|
60
|
+
minimum: minimum,
|
|
61
|
+
maximum: maximum,
|
|
62
|
+
multipleOf: multiple_of,
|
|
63
|
+
enum: enum
|
|
64
|
+
}.compact
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Build a boolean schema fragment.
|
|
68
|
+
# @param description [String, nil] Property description
|
|
69
|
+
# @return [Hash] JSON Schema fragment
|
|
70
|
+
def boolean_schema(description: nil)
|
|
71
|
+
{type: "boolean", description: description}.compact
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Build a null schema fragment.
|
|
75
|
+
# @param description [String, nil] Property description
|
|
76
|
+
# @return [Hash] JSON Schema fragment
|
|
77
|
+
def null_schema(description: nil)
|
|
78
|
+
{type: "null", description: description}.compact
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Build an object schema fragment, either inline or via reference.
|
|
82
|
+
#
|
|
83
|
+
# When called with a block, defines properties inline.
|
|
84
|
+
# When called with +:of+, creates a reference to a named definition.
|
|
85
|
+
# When called with +reference:+ (deprecated), creates a reference.
|
|
86
|
+
#
|
|
87
|
+
# @param description [String, nil] Property description
|
|
88
|
+
# @param of [Symbol, Class, nil] Reference target (definition name or Schema class)
|
|
89
|
+
# @param reference [Symbol, nil] Deprecated: use +of+ instead
|
|
90
|
+
# @param block [Proc] Inline property definitions
|
|
91
|
+
# @return [Hash] JSON Schema fragment
|
|
92
|
+
def object_schema(description: nil, of: nil, reference: nil, &block)
|
|
93
|
+
if reference
|
|
94
|
+
warn "[DEPRECATION] The `reference` option will be deprecated. Please use `of` instead."
|
|
95
|
+
of = reference
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if of
|
|
99
|
+
determine_object_reference(of, description)
|
|
100
|
+
else
|
|
101
|
+
sub_schema = Class.new(Schema)
|
|
102
|
+
result = sub_schema.class_eval(&block)
|
|
103
|
+
|
|
104
|
+
if result.is_a?(Hash) && result["$ref"] && sub_schema.properties.empty?
|
|
105
|
+
result.merge(description ? {description: description} : {})
|
|
106
|
+
elsif schema_class?(result) && sub_schema.properties.empty?
|
|
107
|
+
schema_class_to_inline_schema(result).merge(description ? {description: description} : {})
|
|
108
|
+
else
|
|
109
|
+
schema = {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: sub_schema.properties,
|
|
112
|
+
required: sub_schema.required_properties,
|
|
113
|
+
additionalProperties: sub_schema.additional_properties,
|
|
114
|
+
description: description
|
|
115
|
+
}.compact
|
|
116
|
+
|
|
117
|
+
merge_conditions(schema, sub_schema)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Build an array schema fragment.
|
|
123
|
+
#
|
|
124
|
+
# Items can be specified inline via a block, or via the +:of+ option
|
|
125
|
+
# for simple types, references, or Schema classes.
|
|
126
|
+
#
|
|
127
|
+
# @param description [String, nil] Property description
|
|
128
|
+
# @param of [Symbol, Class, nil] Items type (:string, :number, a definition name, or Schema class)
|
|
129
|
+
# @param min_items [Integer, nil] Minimum number of items
|
|
130
|
+
# @param max_items [Integer, nil] Maximum number of items
|
|
131
|
+
# @param block [Proc] Block for complex item schemas
|
|
132
|
+
# @return [Hash] JSON Schema fragment
|
|
133
|
+
def array_schema(description: nil, of: nil, min_items: nil, max_items: nil, &block)
|
|
134
|
+
items = determine_array_items(of, &block)
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
type: "array",
|
|
138
|
+
description: description,
|
|
139
|
+
items: items,
|
|
140
|
+
minItems: min_items,
|
|
141
|
+
maxItems: max_items
|
|
142
|
+
}.compact
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Build an +anyOf+ schema fragment.
|
|
146
|
+
#
|
|
147
|
+
# @param description [String, nil] Property description
|
|
148
|
+
# @param block [Proc] Block listing alternative schemas
|
|
149
|
+
# @return [Hash] JSON Schema fragment
|
|
150
|
+
def any_of_schema(description: nil, &block)
|
|
151
|
+
schemas = collect_schemas_from_block(&block)
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
description: description,
|
|
155
|
+
anyOf: schemas
|
|
156
|
+
}.compact
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Build a +oneOf+ schema fragment.
|
|
160
|
+
#
|
|
161
|
+
# @param description [String, nil] Property description
|
|
162
|
+
# @param block [Proc] Block listing alternative schemas
|
|
163
|
+
# @return [Hash] JSON Schema fragment
|
|
164
|
+
def one_of_schema(description: nil, &block)
|
|
165
|
+
schemas = collect_schemas_from_block(&block)
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
description: description,
|
|
169
|
+
oneOf: schemas
|
|
170
|
+
}.compact
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
# Determine the items schema for an array.
|
|
176
|
+
def determine_array_items(of, &)
|
|
177
|
+
return collect_schemas_from_block(&).first if block_given?
|
|
178
|
+
return send("#{of}_schema") if primitive_type?(of)
|
|
179
|
+
return reference(of) if of.is_a?(Symbol)
|
|
180
|
+
return schema_class_to_inline_schema(of) if schema_class?(of)
|
|
181
|
+
|
|
182
|
+
raise InvalidArrayTypeError, "Invalid array type: #{of.inspect}. Must be a primitive type (:string, :number, etc.), a symbol reference, a Schema class, or a Schema instance."
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Determine the target of an object reference.
|
|
186
|
+
def determine_object_reference(of, description = nil)
|
|
187
|
+
result = case of
|
|
188
|
+
when Symbol
|
|
189
|
+
reference(of)
|
|
190
|
+
when Class
|
|
191
|
+
raise InvalidObjectTypeError, "Invalid object type: #{of.inspect}. Class must inherit from Ask::Schema." unless schema_class?(of)
|
|
192
|
+
|
|
193
|
+
schema_class_to_inline_schema(of)
|
|
194
|
+
else
|
|
195
|
+
raise InvalidObjectTypeError, "Invalid object type: #{of.inspect}. Must be a symbol reference, a Schema class, or a Schema instance." unless schema_class?(of)
|
|
196
|
+
|
|
197
|
+
schema_class_to_inline_schema(of)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
description ? result.merge(description: description) : result
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Collect schemas from a composition block (any_of, one_of).
|
|
204
|
+
def collect_schemas_from_block(&block)
|
|
205
|
+
schemas = []
|
|
206
|
+
schema_builder = self
|
|
207
|
+
|
|
208
|
+
context = Object.new
|
|
209
|
+
|
|
210
|
+
schema_builder.methods.grep(/_schema$/).each do |schema_method|
|
|
211
|
+
type_name = schema_method.to_s.sub(/_schema$/, "")
|
|
212
|
+
|
|
213
|
+
context.define_singleton_method(type_name) do |_name = nil, **options, &blk|
|
|
214
|
+
schemas << schema_builder.send(schema_method, **options, &blk)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
context.define_singleton_method(:const_missing) do |name|
|
|
219
|
+
const_get(name) if const_defined?(name)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
context.instance_eval(&block)
|
|
223
|
+
schemas
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Convert a Schema class (or instance) into an inline object schema hash.
|
|
227
|
+
def schema_class_to_inline_schema(schema_class_or_instance)
|
|
228
|
+
schema_class = if schema_class_or_instance.is_a?(Class)
|
|
229
|
+
schema_class_or_instance
|
|
230
|
+
else
|
|
231
|
+
schema_class_or_instance.class
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
{
|
|
235
|
+
type: "object",
|
|
236
|
+
properties: schema_class.properties,
|
|
237
|
+
required: schema_class.required_properties,
|
|
238
|
+
additionalProperties: schema_class.additional_properties
|
|
239
|
+
}.tap do |schema|
|
|
240
|
+
description = if schema_class_or_instance.is_a?(Class)
|
|
241
|
+
schema_class.description
|
|
242
|
+
else
|
|
243
|
+
schema_class_or_instance.instance_variable_get(:@description) || schema_class.description
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
schema[:description] = description if description
|
|
247
|
+
|
|
248
|
+
merge_conditions(schema, schema_class)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
class Schema
|
|
5
|
+
module DSL
|
|
6
|
+
# Utility methods for schema definitions, references, and property registration.
|
|
7
|
+
module Utilities
|
|
8
|
+
# Define a named sub-schema for reuse via {reference}.
|
|
9
|
+
#
|
|
10
|
+
# Named definitions appear in the output under `$defs`.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# define(:address) do
|
|
14
|
+
# string :street
|
|
15
|
+
# string :city
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @param name [Symbol] The definition name (used in $ref)
|
|
19
|
+
# @param block [Proc] DSL block for the sub-schema properties
|
|
20
|
+
def define(name, &)
|
|
21
|
+
sub_schema = Class.new(Schema)
|
|
22
|
+
sub_schema.class_eval(&)
|
|
23
|
+
|
|
24
|
+
schema = {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: sub_schema.properties,
|
|
27
|
+
required: sub_schema.required_properties,
|
|
28
|
+
additionalProperties: sub_schema.additional_properties
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
merge_conditions(schema, sub_schema)
|
|
32
|
+
|
|
33
|
+
definitions[name] = schema
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Create a `$ref` reference to a named definition or root.
|
|
37
|
+
#
|
|
38
|
+
# Use with +object+ or +array+ via the +:of+ option, or standalone
|
|
39
|
+
# to produce a reference hash.
|
|
40
|
+
#
|
|
41
|
+
# @example
|
|
42
|
+
# reference(:address) # => { "$ref" => "#/$defs/address" }
|
|
43
|
+
# reference(:root) # => { "$ref" => "#" }
|
|
44
|
+
#
|
|
45
|
+
# @param schema_name [Symbol] The definition name, or +:root+ for root reference
|
|
46
|
+
# @return [Hash{"$ref" => String}] The reference
|
|
47
|
+
def reference(schema_name)
|
|
48
|
+
if schema_name == :root
|
|
49
|
+
{"$ref" => "#"}
|
|
50
|
+
else
|
|
51
|
+
{"$ref" => "#/$defs/#{schema_name}"}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Register a property on the schema class.
|
|
58
|
+
#
|
|
59
|
+
# @param name [Symbol] Property name
|
|
60
|
+
# @param definition [Hash] JSON Schema fragment for the property
|
|
61
|
+
# @param required [Boolean] Whether the property is required
|
|
62
|
+
# @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
|
|
63
|
+
# @return [nil]
|
|
64
|
+
def add_property(name, definition, required:, requires: nil)
|
|
65
|
+
property_name = name.to_sym
|
|
66
|
+
|
|
67
|
+
properties[property_name] = definition
|
|
68
|
+
if required
|
|
69
|
+
required_properties << property_name unless required_properties.include?(property_name)
|
|
70
|
+
else
|
|
71
|
+
required_properties.delete(property_name)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if requires
|
|
75
|
+
builder = ConditionalBuilder.new
|
|
76
|
+
builder.requires(*Array(requires))
|
|
77
|
+
dependencies[name.to_s] = builder
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Check if a type is a primitive JSON Schema type.
|
|
84
|
+
# @param type [Symbol] The type to check
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
def primitive_type?(type)
|
|
87
|
+
type.is_a?(Symbol) && PRIMITIVE_TYPES.include?(type)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check if a value is a Schema class or instance.
|
|
91
|
+
# @param type [Object] The value to check
|
|
92
|
+
# @return [Boolean]
|
|
93
|
+
def schema_class?(type)
|
|
94
|
+
(type.is_a?(Class) && type < Schema) || type.is_a?(Schema)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dsl/schema_builders"
|
|
4
|
+
require_relative "dsl/primitive_types"
|
|
5
|
+
require_relative "dsl/complex_types"
|
|
6
|
+
require_relative "dsl/conditionals"
|
|
7
|
+
require_relative "dsl/utilities"
|
|
8
|
+
|
|
9
|
+
module Ask
|
|
10
|
+
class Schema
|
|
11
|
+
# Assembles all DSL modules into the Schema class.
|
|
12
|
+
#
|
|
13
|
+
# Includes {SchemaBuilders}, {PrimitiveTypes}, {ComplexTypes},
|
|
14
|
+
# {Conditionals}, and {Utilities} to provide the full schema
|
|
15
|
+
# definition DSL.
|
|
16
|
+
module DSL
|
|
17
|
+
include SchemaBuilders
|
|
18
|
+
include PrimitiveTypes
|
|
19
|
+
include ComplexTypes
|
|
20
|
+
include Conditionals
|
|
21
|
+
include Utilities
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
class Schema
|
|
5
|
+
# Base error class for all schema-related errors.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when an invalid schema type is specified.
|
|
9
|
+
class InvalidSchemaTypeError < Error
|
|
10
|
+
# @param type [Symbol] The unrecognized type
|
|
11
|
+
def initialize(type)
|
|
12
|
+
super("Unknown schema type: #{type}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raised when an invalid type is passed to +array+ via the +:of+ option.
|
|
17
|
+
class InvalidArrayTypeError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when an invalid type is passed to +object+ via the +:of+ option.
|
|
20
|
+
class InvalidObjectTypeError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when a schema definition is structurally invalid.
|
|
23
|
+
class InvalidSchemaError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when schema validation fails (e.g., circular references).
|
|
26
|
+
class ValidationError < Error; end
|
|
27
|
+
|
|
28
|
+
# Raised when a maximum or limit is exceeded.
|
|
29
|
+
class LimitExceededError < Error; end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
class Schema
|
|
5
|
+
# Convenience helpers for creating schemas in a top-level context.
|
|
6
|
+
module Helpers
|
|
7
|
+
# Create a new schema instance using a DSL block.
|
|
8
|
+
#
|
|
9
|
+
# @param name [String, nil] Schema name
|
|
10
|
+
# @param description [String, nil] Schema description
|
|
11
|
+
# @param block [Proc] DSL block with type definitions
|
|
12
|
+
# @return [Schema] A new schema instance
|
|
13
|
+
def schema(name = nil, description: nil, &block)
|
|
14
|
+
schema_class = Ask::Schema.create(&block)
|
|
15
|
+
schema_class.new(name, description: description)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
class Schema
|
|
7
|
+
# Generates JSON Schema output from a Schema class definition.
|
|
8
|
+
module JsonOutput
|
|
9
|
+
# Generate a hash representation of the JSON Schema.
|
|
10
|
+
#
|
|
11
|
+
# Validates the schema before generating output. The returned hash
|
|
12
|
+
# includes +:name+, +:description+, and +:schema+ keys.
|
|
13
|
+
#
|
|
14
|
+
# @return [Hash] The JSON Schema representation
|
|
15
|
+
# @raise [ValidationError] if the schema is invalid
|
|
16
|
+
def to_json_schema
|
|
17
|
+
validate!
|
|
18
|
+
|
|
19
|
+
schema_hash = {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: self.class.properties,
|
|
22
|
+
required: self.class.required_properties,
|
|
23
|
+
additionalProperties: self.class.additional_properties
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
schema_hash[:strict] = self.class.strict unless self.class.strict.nil?
|
|
27
|
+
|
|
28
|
+
schema_hash["$defs"] = self.class.definitions unless self.class.definitions.empty?
|
|
29
|
+
|
|
30
|
+
self.class.send(:merge_conditions, schema_hash, self.class)
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
name: @name,
|
|
34
|
+
description: @description || self.class.description,
|
|
35
|
+
schema: schema_hash
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Generate a pretty-printed JSON string of the schema.
|
|
40
|
+
#
|
|
41
|
+
# @return [String] Pretty-printed JSON
|
|
42
|
+
# @raise [ValidationError] if the schema is invalid
|
|
43
|
+
def to_json(*_args)
|
|
44
|
+
validate!
|
|
45
|
+
JSON.pretty_generate(to_json_schema)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
class Schema
|
|
5
|
+
# Validates schema definitions for structural issues such as circular
|
|
6
|
+
# references in named definitions (`$defs`).
|
|
7
|
+
#
|
|
8
|
+
# Uses DFS-based topological sort with three-color marking (WHITE/GRAY/BLACK)
|
|
9
|
+
# to detect cycles in definition dependency graphs.
|
|
10
|
+
class Validator
|
|
11
|
+
# Node states for DFS-based topological sort
|
|
12
|
+
WHITE = :white # No mark (unvisited)
|
|
13
|
+
GRAY = :gray # Temporary mark (currently being processed)
|
|
14
|
+
BLACK = :black # Permanent mark (completely processed)
|
|
15
|
+
|
|
16
|
+
# @param schema_class [Class<Schema>] The schema class to validate
|
|
17
|
+
def initialize(schema_class)
|
|
18
|
+
@schema_class = schema_class
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Run all validations, raising on the first error.
|
|
22
|
+
#
|
|
23
|
+
# @return [nil] if the schema is valid
|
|
24
|
+
# @raise [ValidationError] if a circular reference is detected
|
|
25
|
+
def validate!
|
|
26
|
+
validate_circular_references!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check if the schema is valid without raising.
|
|
30
|
+
#
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def valid?
|
|
33
|
+
validate!
|
|
34
|
+
true
|
|
35
|
+
rescue ValidationError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Detect circular references in $defs using DFS.
|
|
42
|
+
def validate_circular_references!
|
|
43
|
+
definitions = @schema_class.definitions
|
|
44
|
+
return if definitions.empty?
|
|
45
|
+
|
|
46
|
+
marks = Hash.new { WHITE }
|
|
47
|
+
|
|
48
|
+
definitions.each_key do |node|
|
|
49
|
+
visit(node, definitions, marks) if marks[node] == WHITE
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# DFS visit function with three-color marking.
|
|
54
|
+
def visit(node, definitions, marks)
|
|
55
|
+
return if marks[node] == BLACK
|
|
56
|
+
|
|
57
|
+
raise ValidationError, "Circular reference detected involving '#{node}'" if marks[node] == GRAY
|
|
58
|
+
|
|
59
|
+
marks[node] = GRAY
|
|
60
|
+
|
|
61
|
+
definition = definitions[node]
|
|
62
|
+
if definition && definition[:properties]
|
|
63
|
+
definition[:properties].each_value do |property|
|
|
64
|
+
extract_references(property).each do |adjacent_node|
|
|
65
|
+
visit(adjacent_node, definitions, marks)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
marks[node] = BLACK
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Recursively extract `$ref` references from a property definition.
|
|
74
|
+
#
|
|
75
|
+
# @param property [Hash, Array] A property definition or nested structure
|
|
76
|
+
# @return [Array<Symbol>] Referenced definition names
|
|
77
|
+
def extract_references(property)
|
|
78
|
+
references = []
|
|
79
|
+
|
|
80
|
+
case property
|
|
81
|
+
when Hash
|
|
82
|
+
if property["$ref"]
|
|
83
|
+
ref_name = property["$ref"].split("/").last&.to_sym
|
|
84
|
+
references << ref_name if ref_name
|
|
85
|
+
else
|
|
86
|
+
property.each_value do |value|
|
|
87
|
+
references.concat(extract_references(value))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
when Array
|
|
91
|
+
property.each do |item|
|
|
92
|
+
references.concat(extract_references(item))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
references
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|