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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f5a9c41e5de3b5e4c3bdaad787059efa89197dc88048cb24b4f545a34adfd060
4
+ data.tar.gz: 2f3dc5d00239bf5364ffd0894feb138a060cca73359310aee1b576ac2f6a77bb
5
+ SHA512:
6
+ metadata.gz: e05cfa19bd6d8160fb98c14b46a39dd5cc69970dacd1e571cb813c27b1f73454d73ce7bf74dfaa7e14ad1399ec0fd3f93bfe193aaf4e0939f6ccae59beeb0636
7
+ data.tar.gz: bc91c29e8bcea693ed199cccea34a6581f4289b2ddf1420634fc1b0b32e7828b591a7bc1addc28cd028a99fbd888b2e5ebf5e6a97a432e7985ac88b67e16ee4f
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,350 @@
1
+ # ask-schema
2
+
3
+ A compact Ruby DSL for building standards-compliant JSON Schema documents. Zero dependencies.
4
+
5
+ ```ruby
6
+ gem "ask-schema"
7
+ ```
8
+
9
+ ```ruby
10
+ require "ask-schema"
11
+
12
+ schema = Ask::Schema.create do
13
+ string :name, description: "Full name"
14
+ integer :age, description: "Age in years", minimum: 0
15
+ boolean :active, required: false
16
+ end
17
+
18
+ schema.new("user", description: "A user profile").to_json
19
+ # => {
20
+ # "name": "user",
21
+ # "description": "A user profile",
22
+ # "schema": {
23
+ # "type": "object",
24
+ # "properties": {
25
+ # "name": { "type": "string", "description": "Full name" },
26
+ # "age": { "type": "integer", "description": "Age in years", "minimum": 0 },
27
+ # "active": { "type": "boolean" }
28
+ # },
29
+ # "required": ["name", "age"],
30
+ # "additionalProperties": false,
31
+ # "strict": true
32
+ # }
33
+ # }
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ### Block-based DSL
39
+
40
+ ```ruby
41
+ schema = Ask::Schema.create do
42
+ string :name, description: "The user's name"
43
+ integer :age, description: "Age in years"
44
+ boolean :active, required: false
45
+ end
46
+
47
+ instance = schema.new("user_profile", description: "A user profile")
48
+ instance.to_json_schema
49
+ # => { name: "user_profile", description: "A user profile", schema: { ... } }
50
+ ```
51
+
52
+ ### Class-based DSL
53
+
54
+ ```ruby
55
+ class Address < Ask::Schema
56
+ string :street
57
+ string :city
58
+ string :zip
59
+ string :country, required: false
60
+ end
61
+
62
+ class User < Ask::Schema
63
+ string :name, description: "Full name"
64
+ string :email, format: "email"
65
+ integer :age
66
+ object :address, of: Address
67
+ end
68
+
69
+ User.new("user").to_json_schema
70
+ ```
71
+
72
+ ## Primitive Types
73
+
74
+ Each primitive type supports standard JSON Schema constraints.
75
+
76
+ ### String
77
+
78
+ ```ruby
79
+ string :username,
80
+ description: "Username",
81
+ enum: %w[admin user guest],
82
+ min_length: 3,
83
+ max_length: 50,
84
+ pattern: "^[a-zA-Z0-9_]+$",
85
+ format: "email"
86
+ ```
87
+
88
+ ### Number
89
+
90
+ ```ruby
91
+ number :price,
92
+ description: "Price in USD",
93
+ minimum: 0,
94
+ maximum: 999999.99,
95
+ multiple_of: 0.01
96
+ ```
97
+
98
+ ### Integer
99
+
100
+ ```ruby
101
+ integer :age,
102
+ minimum: 0,
103
+ maximum: 150
104
+ ```
105
+
106
+ ### Boolean
107
+
108
+ ```ruby
109
+ boolean :active, description: "Is the user active?"
110
+ ```
111
+
112
+ ### Null
113
+
114
+ ```ruby
115
+ null :deleted_at, description: "When the record was deleted"
116
+ ```
117
+
118
+ ## Complex Types
119
+
120
+ ### Object
121
+
122
+ ```ruby
123
+ # Inline object
124
+ object :address do
125
+ string :street
126
+ string :city
127
+ string :zip
128
+ end
129
+
130
+ # Reference to a defined schema
131
+ define(:address) do
132
+ string :street
133
+ string :city
134
+ end
135
+ object :billing, of: :address
136
+
137
+ # Reference to a Schema class
138
+ object :shipping, of: Address
139
+ ```
140
+
141
+ ### Array
142
+
143
+ ```ruby
144
+ # Array of primitive type
145
+ array :tags, of: :string, description: "List of tags"
146
+
147
+ # Array with min/max items
148
+ array :prices, of: :number, min_items: 1, max_items: 100
149
+
150
+ # Array with complex items (block)
151
+ array :contacts do
152
+ object do
153
+ string :name
154
+ string :email
155
+ end
156
+ end
157
+
158
+ # Array with any_of items
159
+ array :identifiers do
160
+ any_of do
161
+ string
162
+ integer
163
+ end
164
+ end
165
+ ```
166
+
167
+ ### any_of / one_of
168
+
169
+ ```ruby
170
+ any_of :contact do
171
+ string description: "Phone number"
172
+ object do
173
+ string :email
174
+ end
175
+ end
176
+
177
+ one_of :payment_method do
178
+ string :credit_card
179
+ string :paypal
180
+ end
181
+ ```
182
+
183
+ ### Optional (nullable)
184
+
185
+ ```ruby
186
+ optional :nickname do
187
+ string
188
+ end
189
+ # Produces: anyOf: [{ type: "string" }, { type: "null" }]
190
+ ```
191
+
192
+ ## Named Definitions and References
193
+
194
+ Use `define` to create reusable named sub-schemas and `reference` (or `of:`) to reference them:
195
+
196
+ ```ruby
197
+ class User < Ask::Schema
198
+ define(:address) do
199
+ string :street
200
+ string :city
201
+ string :zip
202
+ end
203
+
204
+ string :name
205
+ object :home_address, of: :address
206
+ object :work_address, of: :address
207
+ end
208
+ ```
209
+
210
+ Output includes proper `$defs` and `$ref`:
211
+
212
+ ```json
213
+ {
214
+ "type": "object",
215
+ "properties": {
216
+ "name": { "type": "string" },
217
+ "home_address": { "$ref": "#/$defs/address" },
218
+ "work_address": { "$ref": "#/$defs/address" }
219
+ },
220
+ "$defs": {
221
+ "address": {
222
+ "type": "object",
223
+ "properties": { "street": { "type": "string" }, ... }
224
+ }
225
+ }
226
+ }
227
+ ```
228
+
229
+ ## Conditionals
230
+
231
+ ### If/Then/Else
232
+
233
+ ```ruby
234
+ schema = Ask::Schema.create do
235
+ integer :age
236
+ string :country
237
+
238
+ given(age: 18, country: "US") do
239
+ requires :license_number
240
+ validates :license_number, type: :string, pattern: /^[A-Z]{2}\d{6}$/
241
+ otherwise do
242
+ requires :country_name
243
+ end
244
+ end
245
+ end
246
+ ```
247
+
248
+ ### Dependent Required
249
+
250
+ ```ruby
251
+ dependent :shipping_address do
252
+ requires :name, :street, :city
253
+ end
254
+ ```
255
+
256
+ ### Coercion rules
257
+
258
+ | Ruby value | JSON Schema |
259
+ |---|---|
260
+ | `18` (scalar) | `{ const: 18 }` |
261
+ | `["admin", "user"]` (Array) | `{ enum: ["admin", "user"] }` |
262
+ | `/^[A-Z]+$/` (Regexp) | `{ pattern: "^[A-Z]+$" }` |
263
+ | `{ minimum: 0 }` (Hash) | Passed through as-is |
264
+
265
+ ## Validation
266
+
267
+ ```ruby
268
+ schema = Ask::Schema.create { string :name }
269
+ schema.valid? # => true
270
+ schema.validate! # => nil (or raises Ask::Schema::ValidationError)
271
+
272
+ # Circular reference detection
273
+ schema = Ask::Schema.create do
274
+ define(:a) { object :b, of: :b }
275
+ define(:b) { object :a, of: :a }
276
+ end
277
+ schema.valid? # => false
278
+ schema.validate! # => raises Ask::Schema::ValidationError
279
+ ```
280
+
281
+ ## Output Formats
282
+
283
+ ```ruby
284
+ instance.to_json_schema
285
+ # => Hash with :name, :description, :schema keys
286
+
287
+ instance.to_json
288
+ # => Pretty-printed JSON string
289
+ ```
290
+
291
+ ## Configuration
292
+
293
+ ```ruby
294
+ class StrictSchema < Ask::Schema
295
+ string :name
296
+ strict true # defaults to true
297
+ additional_properties false # defaults to false
298
+ end
299
+ ```
300
+
301
+ ## Integration with ask-tools
302
+
303
+ `ask-schema` powers tool parameter schemas in `ask-tools`:
304
+
305
+ ```ruby
306
+ class WeatherTool < Ask::Tool
307
+ description "Get weather for a location"
308
+
309
+ params do
310
+ string :location, description: "City name"
311
+ string :unit, enum: %w[celsius fahrenheit]
312
+ end
313
+
314
+ def execute(location:, unit: "celsius")
315
+ # ...
316
+ end
317
+ end
318
+ ```
319
+
320
+ Under the hood, `Ask::Schema.create` is used to build the JSON Schema for tool parameters.
321
+
322
+ ## Error Types
323
+
324
+ | Error | When |
325
+ |---|---|
326
+ | `Ask::Schema::InvalidArrayTypeError` | Invalid type for array `:of` |
327
+ | `Ask::Schema::InvalidObjectTypeError` | Invalid type for object `:of` |
328
+ | `Ask::Schema::ValidationError` | Schema validation fails (e.g., circular refs) |
329
+ | `Ask::Schema::InvalidSchemaTypeError` | Unknown schema type specified |
330
+ | `Ask::Schema::InvalidSchemaError` | Schema definition is invalid |
331
+ | `Ask::Schema::LimitExceededError` | Maximum limits exceeded |
332
+
333
+ ## Development
334
+
335
+ ```
336
+ bundle install
337
+ bundle exec rake test
338
+ ```
339
+
340
+ ## Status
341
+
342
+ **Phase 3** of the ask-rb ecosystem migration. This gem replaces `ruby_llm-schema`
343
+ in the ask-rb stack. It should be built after `ask-core` and `ask-llm-providers`
344
+ are stable.
345
+
346
+ Current state: v0.1.0 — initial port complete with full feature parity.
347
+
348
+ ## License
349
+
350
+ MIT
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ class Schema
5
+ module DSL
6
+ # DSL methods for declaring complex (non-primitive) property types.
7
+ module ComplexTypes
8
+ # Declare an object property with inline or referenced sub-schema.
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 options (of:, reference:)
14
+ # @param block [Proc] Inline property definitions
15
+ def object(name, description: nil, required: true, requires: nil, **options, &block)
16
+ add_property(name, object_schema(description: description, **options, &block), required: required, requires: requires)
17
+ end
18
+
19
+ # Declare an array property.
20
+ # @param name [Symbol] Property name
21
+ # @param description [String, nil] Property description
22
+ # @param required [Boolean] Whether the property is required (default: true)
23
+ # @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
24
+ # @param options [Hash] Additional options (of:, min_items:, max_items:)
25
+ # @param block [Proc] Block for complex item definitions
26
+ def array(name, description: nil, required: true, requires: nil, **options, &block)
27
+ add_property(name, array_schema(description: description, **options, &block), required: required, requires: requires)
28
+ end
29
+
30
+ # Declare a property accepting any of the listed schemas.
31
+ # @param name [Symbol] Property name
32
+ # @param description [String, nil] Property description
33
+ # @param required [Boolean] Whether the property is required (default: true)
34
+ # @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
35
+ # @param options [Hash] Additional options
36
+ # @param block [Proc] Block listing alternative schemas
37
+ def any_of(name, description: nil, required: true, requires: nil, **options, &block)
38
+ add_property(name, any_of_schema(description: description, **options, &block), required: required, requires: requires)
39
+ end
40
+
41
+ # Declare a property accepting exactly one of the listed schemas.
42
+ # @param name [Symbol] Property name
43
+ # @param description [String, nil] Property description
44
+ # @param required [Boolean] Whether the property is required (default: true)
45
+ # @param requires [Symbol, Array<Symbol>, nil] Dependent property requirements
46
+ # @param options [Hash] Additional options
47
+ # @param block [Proc] Block listing alternative schemas
48
+ def one_of(name, description: nil, required: true, requires: nil, **options, &block)
49
+ add_property(name, one_of_schema(description: description, **options, &block), required: required, requires: requires)
50
+ end
51
+
52
+ # Declare an optional (nullable) property using +anyOf+ with +null+.
53
+ #
54
+ # @example
55
+ # optional :nickname do
56
+ # string
57
+ # end
58
+ # # Produces: anyOf: [{ type: "string" }, { type: "null" }]
59
+ #
60
+ # @param name [Symbol] Property name
61
+ # @param description [String, nil] Property description
62
+ # @param block [Proc] Block defining the non-null type
63
+ def optional(name, description: nil, &block)
64
+ any_of(name, description: description) do
65
+ instance_eval(&block)
66
+ null
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ class Schema
5
+ module DSL
6
+ # Conditional schema features: +if/then/else+, +dependentRequired+,
7
+ # and +dependentSchemas+ for JSON Schema conditional validation.
8
+ module Conditionals
9
+ # Collection of conditionals (if/then/else) defined on this schema.
10
+ # @return [Array<Hash>] The conditions
11
+ def conditions
12
+ @conditions ||= []
13
+ end
14
+
15
+ # Collection of dependencies (dependentRequired/dependentSchemas) defined.
16
+ # @return [Hash{String => ConditionalBuilder}] The dependencies
17
+ def dependencies
18
+ @dependencies ||= {}
19
+ end
20
+
21
+ # Declare that a property has dependencies on other properties.
22
+ #
23
+ # @example
24
+ # dependent :shipping_address do
25
+ # requires :name, :street, :city
26
+ # end
27
+ #
28
+ # @param property [Symbol] The property that has dependencies
29
+ # @param block [Proc] Block declaring requirements via +requires+ and +validates+
30
+ def dependent(property, &block)
31
+ builder = ConditionalBuilder.new
32
+ builder.instance_eval(&block)
33
+
34
+ dependencies[property.to_s] = builder
35
+ end
36
+
37
+ # Declare a conditional (if/then/else) constraint.
38
+ #
39
+ # Values are automatically coerced: scalars become +const+, arrays become
40
+ # +enum+, and Regexps become +pattern+.
41
+ #
42
+ # @example
43
+ # given(age: 18) do
44
+ # requires :license_number
45
+ # otherwise do
46
+ # requires :guardian_name
47
+ # end
48
+ # end
49
+ #
50
+ # @param properties [Hash{Symbol => Object}] Property conditions
51
+ # @param block [Proc] Block declaring then/else requirements
52
+ # @raise [ArgumentError] If no conditions are provided
53
+ def given(**properties, &block)
54
+ raise ArgumentError, "given requires at least one property condition" if properties.empty?
55
+
56
+ if_schema = {
57
+ properties: properties.transform_keys(&:to_s).transform_values { |v| coerce_condition(v) },
58
+ required: properties.keys.map(&:to_s)
59
+ }
60
+
61
+ then_builder = ConditionalBuilder.new
62
+ else_builder = ConditionalBuilder.new
63
+
64
+ context = ConditionalContext.new(then_builder, else_builder)
65
+ context.instance_eval(&block)
66
+
67
+ condition = {if: if_schema, then: then_builder.to_schema}
68
+ condition[:else] = else_builder.to_schema unless else_builder.empty?
69
+
70
+ conditions << condition
71
+ end
72
+
73
+ private
74
+
75
+ # Merge any conditions and dependencies from a sub-schema into the schema hash.
76
+ def merge_conditions(schema, schema_class)
77
+ if schema_class.respond_to?(:conditions) && schema_class.conditions.any?
78
+ if schema_class.conditions.length == 1
79
+ schema.merge!(schema_class.conditions.first)
80
+ else
81
+ schema[:allOf] = schema_class.conditions
82
+ end
83
+ end
84
+
85
+ if schema_class.respond_to?(:dependencies) && schema_class.dependencies.any?
86
+ dependent_required = {}
87
+ dependent_schemas = {}
88
+
89
+ schema_class.dependencies.each do |property, builder|
90
+ if builder.validations_empty?
91
+ dependent_required[property] = builder.required_fields
92
+ else
93
+ dependent_schemas[property] = builder.to_schema
94
+ end
95
+ end
96
+
97
+ schema[:dependentRequired] = dependent_required if dependent_required.any?
98
+ schema[:dependentSchemas] = dependent_schemas if dependent_schemas.any?
99
+ end
100
+
101
+ schema
102
+ end
103
+
104
+ # Coerce a Ruby value into a JSON Schema condition.
105
+ # @param value [Object] The condition value
106
+ # @return [Hash] JSON Schema condition fragment
107
+ def coerce_condition(value)
108
+ case value
109
+ when Array then {enum: value}
110
+ when Regexp then {pattern: value.source}
111
+ when Hash then value
112
+ else {const: value}
113
+ end
114
+ end
115
+ end
116
+
117
+ # Execution context for +given+ blocks, providing +requires+, +validates+,
118
+ # and +otherwise+ DSL methods.
119
+ class ConditionalContext
120
+ # @param then_builder [ConditionalBuilder] Builder for the "then" clause
121
+ # @param else_builder [ConditionalBuilder] Builder for the "else" clause
122
+ def initialize(then_builder, else_builder)
123
+ @then_builder = then_builder
124
+ @else_builder = else_builder
125
+ end
126
+
127
+ # Mark fields as required when the condition is met.
128
+ # @param fields [Array<Symbol>] Field names to require
129
+ def requires(*fields)
130
+ @then_builder.requires(*fields)
131
+ end
132
+
133
+ # Add validation constraints for a field.
134
+ # @param field [Symbol] Field name
135
+ # @param options [Hash] Validation constraints
136
+ # @option options [Symbol] :type Expected JSON type
137
+ # @option options [Object] :const Expected constant value
138
+ # @option options [Array] :enum Allowed values
139
+ # @option options [Object] :not_value Disallowed value (maps to +not+)
140
+ # @option options [Integer] :min_length Minimum string length
141
+ # @option options [Integer] :max_length Maximum string length
142
+ # @option options [String, Regexp] :pattern Regex pattern
143
+ # @option options [Numeric] :minimum Minimum number
144
+ # @option options [Numeric] :maximum Maximum number
145
+ def validates(field, **options)
146
+ @then_builder.validates(field, **options)
147
+ end
148
+
149
+ # Define the "else" clause for when the condition is not met.
150
+ # @param block [Proc] Block declaring requirements
151
+ def otherwise(&block)
152
+ @else_builder.instance_eval(&block)
153
+ end
154
+ end
155
+
156
+ # Builder for collecting requirements and validations within a conditional clause.
157
+ class ConditionalBuilder
158
+ # Mark fields as required.
159
+ # @param fields [Array<Symbol, String>] Field names
160
+ def requires(*fields)
161
+ required.concat(fields.map(&:to_s))
162
+ end
163
+
164
+ # Map of validated option names to JSON Schema key names.
165
+ VALIDATES_KEY_MAP = {
166
+ type: :type,
167
+ const: :const,
168
+ enum: :enum,
169
+ not_value: :not,
170
+ min_length: :minLength,
171
+ max_length: :maxLength,
172
+ pattern: :pattern,
173
+ minimum: :minimum,
174
+ maximum: :maximum
175
+ }.freeze
176
+
177
+ # Add validation constraints for a field.
178
+ #
179
+ # @param field [Symbol] Field name
180
+ # @param options [Hash] Validation constraints (see {ConditionalContext#validates})
181
+ # @raise [ArgumentError] If an unknown option is provided
182
+ def validates(field, **options)
183
+ constraints = {}
184
+
185
+ options.each do |key, value|
186
+ schema_key = VALIDATES_KEY_MAP[key]
187
+ raise ArgumentError, "unknown validates option: #{key.inspect}" unless schema_key
188
+
189
+ case key
190
+ when :type then constraints[:type] = value.to_s
191
+ when :not_value then constraints[:not] = {const: value}
192
+ when :pattern then constraints[:pattern] = value.is_a?(Regexp) ? value.source : value
193
+ else constraints[schema_key] = value
194
+ end
195
+ end
196
+
197
+ validations[field.to_s] = constraints
198
+ end
199
+
200
+ # Convert to a JSON Schema fragment.
201
+ # @return [Hash] Schema fragment with +required+ and +properties+
202
+ def to_schema
203
+ schema = {}
204
+
205
+ schema[:required] = required if required.any?
206
+ schema[:properties] = validations if validations.any?
207
+
208
+ schema
209
+ end
210
+
211
+ # Check if the builder has no requirements or validations.
212
+ # @return [Boolean]
213
+ def empty?
214
+ required.empty? && validations.empty?
215
+ end
216
+
217
+ # Get the required field names (duped).
218
+ # @return [Array<String>]
219
+ def required_fields
220
+ required.dup
221
+ end
222
+
223
+ # Check if no validations have been defined.
224
+ # @return [Boolean]
225
+ def validations_empty?
226
+ validations.empty?
227
+ end
228
+
229
+ private
230
+
231
+ # @return [Array<String>] Accumulated required fields
232
+ def required
233
+ @required ||= []
234
+ end
235
+
236
+ # @return [Hash{String => Hash}] Accumulated field validations
237
+ def validations
238
+ @validations ||= {}
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end