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.
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ class Schema
5
+ VERSION = "0.1.0"
6
+ end
7
+ end