schematist 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c43b42ba7351bde161abfd42fb394278baec7dc8207fba7a46fa73e7d3f455e0
4
- data.tar.gz: 82a99d0c2c5c0facb2b3e8d44fbd3b99241d2294fc3f049f726b84c68bd37ef6
3
+ metadata.gz: 2acb271d17c754d0d444729086bc552a07c3bb3fd2e66af289bde8d9d5db5b22
4
+ data.tar.gz: 4469284644cddf477d91afee10b70960673546c3ad11e0b7554f0b5b09f4e5ef
5
5
  SHA512:
6
- metadata.gz: c0a01457f987bdaead9aaa0143a1fc7ed1a3650929f3a546638bf27743b0501014b6e2563127dea7550d234275019a45e318d3cf3bd10f0cf0781f6f62916357
7
- data.tar.gz: a7147cf4e9d7c9f78a602e98e2aef7f8607f7cae735a45777a59ad25d32e3cb172feff4da2848133fcdcb8cade91e02bb31fd2895d54bf3caa0e679474260ca4
6
+ metadata.gz: e4c561f755bf2d2a0b33a144817239f585aaad38f7274149597e8a3a129021a8ec1a41606ad51469509152c32210596031b7edfe108a00946d493b30e244566b
7
+ data.tar.gz: eb97a91efb9754f9fa0d652273a340e5cb8048f45f8b9271ee2537e5196372d3e3d13432ada2615b684fe78aa02b957ee58bb165a10f6f2c7d0c7a3f5e4e7695
data/README.md CHANGED
@@ -5,7 +5,9 @@
5
5
  [![codecov](https://codecov.io/gh/crmne/schematist/branch/main/graph/badge.svg)](https://codecov.io/gh/crmne/schematist)
6
6
  [![Ruby Style Guide](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop/rubocop)
7
7
 
8
- A Ruby DSL for creating JSON schemas with a clean, Rails-inspired API.
8
+ A general purpose JSON Schema DSL for Ruby with a clean, Rails-inspired API. Emits Draft 2020-12 schemas and depends on nothing.
9
+
10
+ **Formerly `RubyLLM::Schema`.** Trapping a general purpose JSON Schema DSL inside another gem's namespace was a disservice to anyone looking for one, so 1.0 gave it its own name. See [Migrating from ruby_llm-schema](#migrating-from-ruby_llm-schema).
9
11
 
10
12
  Originally created by [Daniel Friis](https://github.com/danielfriis).
11
13
 
@@ -69,9 +71,9 @@ chat = RubyLLM.chat
69
71
  response = chat.with_schema(PersonSchema)
70
72
  .ask("Generate a person named Alice who is 30 years old and lives in New York")
71
73
 
72
- # The response is automatically parsed from JSON
73
- puts response.content # => {"name" => "Alice", "age" => 30}
74
- puts response.content.class # => Hash
74
+ # Content stays the raw JSON string, and #parsed gives you the Hash
75
+ puts response.content # => "{\"name\":\"Alice\",\"age\":30}"
76
+ puts response.parsed # => {"name" => "Alice", "age" => 30}
75
77
  ```
76
78
 
77
79
  ### RubyLLM tools
@@ -85,8 +87,8 @@ class SearchParams < Schematist::Schema
85
87
  end
86
88
 
87
89
  class SearchDocuments < RubyLLM::Tool
88
- desc "Searches internal documents"
89
- params SearchParams
90
+ description "Searches internal documents"
91
+ parameters SearchParams
90
92
 
91
93
  def execute(query:, limit: 10)
92
94
  DocumentSearch.call(query:, limit:)
@@ -94,13 +96,13 @@ class SearchDocuments < RubyLLM::Tool
94
96
  end
95
97
  ```
96
98
 
97
- For tool-specific parameters, define the schema inline with `params do ... end`.
99
+ For tool-specific arguments, define the schema inline with `parameters do ... end`.
98
100
 
99
101
  ```ruby
100
102
  class Weather < RubyLLM::Tool
101
- desc "Gets current weather"
103
+ description "Gets current weather"
102
104
 
103
- params do
105
+ parameters do
104
106
  string :city, description: "City name"
105
107
  string :units, enum: %w[celsius fahrenheit], required: false
106
108
  end
@@ -300,6 +302,7 @@ Number and integer types support the following properties:
300
302
 
301
303
  - `enum`: an array of allowed numeric values (e.g. `enum: [0, 1, 2]`)
302
304
  - `const`: the single allowed value (e.g. `const: 1`)
305
+ - `format`: a format string (e.g. `format: "int64"`)
303
306
  - `multiple_of`: a multiple of the number (e.g. `multiple_of: 0.01`)
304
307
  - `minimum`: the minimum value of the number (e.g. `minimum: 0`)
305
308
  - `maximum`: the maximum value of the number (e.g. `maximum: 100`)
@@ -318,17 +321,19 @@ integer :level, enum: [0, 1, 2]
318
321
  ```ruby
319
322
  boolean :is_active
320
323
  boolean :accepted_terms, const: true
324
+ boolean :flag, enum: [true]
321
325
  ```
322
326
 
323
- Boolean types only support `const`, the single allowed value.
327
+ Booleans support `const` and `enum`.
324
328
 
325
329
  ### Null
326
330
 
327
331
  ```ruby
328
332
  null :placeholder
333
+ null :nothing, enum: [nil]
329
334
  ```
330
335
 
331
- Null types doesn't support any additional properties.
336
+ Nulls support `enum`.
332
337
 
333
338
  ### Arrays
334
339
 
@@ -361,7 +366,7 @@ end
361
366
 
362
367
  ### Tuples
363
368
 
364
- A tuple is a fixed-length array where each position has its own schema. It emits `prefixItems` along with matching `minItems` and `maxItems`.
369
+ A tuple is an array where each position has its own schema. It emits `prefixItems`, and by default it is exactly as long as its prefix.
365
370
 
366
371
  ```ruby
367
372
  tuple :coordinates do
@@ -370,6 +375,20 @@ tuple :coordinates do
370
375
  end
371
376
  ```
372
377
 
378
+ Give it somewhere for the rest to go and it stops being fixed length. `of:` types the tail, `unevaluated_items:` closes it off after the prefix, and an explicit `max_items:` sets its own bound.
379
+
380
+ ```ruby
381
+ tuple :event, of: :string do # prefixItems, then strings
382
+ string
383
+ integer
384
+ end
385
+
386
+ tuple :pair, unevaluated_items: false do
387
+ string
388
+ string
389
+ end
390
+ ```
391
+
373
392
  ### Objects
374
393
 
375
394
  Objects types expect a block with the properties of the object.
@@ -482,7 +501,7 @@ array :values, of: :integer, unevaluated_items: false
482
501
 
483
502
  ### Runtime Values
484
503
 
485
- Any schema value can be a proc, resolved when the schema is rendered. That lets one schema class produce different documents per instance useful when an enum comes from the database.
504
+ Any schema value can be a proc, resolved when the schema is rendered. One schema class then produces a different document per instance, which is what you want when an enum comes from the database.
486
505
 
487
506
  ```ruby
488
507
  class RoleSchema < Schematist::Schema
@@ -523,6 +542,50 @@ any_of :value do
523
542
  end
524
543
  ```
525
544
 
545
+ ### Schemas That Aren't Objects
546
+
547
+ A type with a name declares a property. Without a name it declares what the schema itself is. That is how a root, or a definition, becomes something other than an object.
548
+
549
+ ```ruby
550
+ class Tags < Schematist::Schema
551
+ array of: :string, unique: true # the whole schema is an array
552
+ end
553
+
554
+ class Id < Schematist::Schema
555
+ one_of do # the whole schema is a choice
556
+ string
557
+ integer
558
+ end
559
+ end
560
+
561
+ class Person < Schematist::Schema
562
+ raw({ "$ref" => "https://example.com/person.json" })
563
+ end
564
+ ```
565
+
566
+ The same rule applies inside `define`, so a definition can be any schema:
567
+
568
+ ```ruby
569
+ define :status do
570
+ string enum: %w[draft sent] # a reusable string
571
+ end
572
+
573
+ define :address do
574
+ string :street # named, so an object with properties
575
+ end
576
+ ```
577
+
578
+ Objects also take their keywords at the root:
579
+
580
+ ```ruby
581
+ class Metadata < Schematist::Schema
582
+ string :name
583
+ min_properties 1
584
+ max_properties 10
585
+ unevaluated_properties false
586
+ end
587
+ ```
588
+
526
589
  ### Schema Definitions and References
527
590
 
528
591
  You can define sub-schemas and reference them in other schemas, or reference the root schema to generate recursive schemas.
@@ -657,7 +720,7 @@ class PaymentSchema < Schematist::Schema
657
720
  end
658
721
  ```
659
722
 
660
- Use a `dependent` block when you also need validations this upgrades the output to `dependentSchemas`:
723
+ Use a `dependent` block when you also need validations. This upgrades the output to `dependentSchemas`:
661
724
 
662
725
  ```ruby
663
726
  dependent :credit_card do
@@ -703,6 +766,32 @@ end
703
766
 
704
767
  Conditions propagate through nested schemas via `of:`.
705
768
 
769
+ A branch is a schema, so anything you can write in a schema you can write in a branch. `requires` and `validates` stay as shorthands for the two common cases.
770
+
771
+ ```ruby
772
+ given kind: "business" do
773
+ requires :vat_id
774
+
775
+ object :tax_details do
776
+ string :vat_number
777
+ end
778
+
779
+ array :filings, of: :string
780
+
781
+ otherwise do
782
+ validates :vat_id, type: :string
783
+ end
784
+ end
785
+ ```
786
+
787
+ `given` matches on property values. Pass a schema explicitly when the condition is something else:
788
+
789
+ ```ruby
790
+ given({ required: %w[tax_id] }) do
791
+ requires :summary
792
+ end
793
+ ```
794
+
706
795
  ## JSON Output
707
796
 
708
797
  `to_json_schema` returns a Draft 2020-12 JSON Schema document with string keys, ready to hand to any JSON Schema validator.
@@ -722,13 +811,11 @@ schema.to_json_schema
722
811
  puts schema.to_json # Pretty JSON string of the same document
723
812
  ```
724
813
 
725
- The schema name maps to `title`. Provider-only keys are not part of the document `strict` was an OpenAI `response_format` flag, not a JSON Schema keyword, and has been removed. Set it where you build the request.
814
+ The schema name maps to `title`. Provider-only keys are not part of the document. `strict` was an OpenAI `response_format` flag rather than a JSON Schema keyword, so it has been removed. Set it where you build the request.
726
815
 
727
816
  ### Migrating from ruby_llm-schema
728
817
 
729
- Schematist was called `ruby_llm-schema`. The old name put a general-purpose JSON Schema DSL inside another gem's namespace and implied it only made sense alongside an LLM client, which was never true.
730
-
731
- Update the gem, then the constants:
818
+ `RubyLLM::Schema` is now `Schematist`. Update the gem, then the constants:
732
819
 
733
820
  ```ruby
734
821
  gem 'schematist' # was: gem 'ruby_llm-schema'
@@ -739,13 +826,13 @@ end
739
826
  include Schematist::Helpers # was: RubyLLM::Helpers
740
827
  ```
741
828
 
742
- Errors moved up a level with the rename `Schematist::ValidationError`, not `RubyLLM::Schema::ValidationError`. `strict` is gone; see below.
829
+ Errors moved up a level with the rename: `Schematist::ValidationError`, not `RubyLLM::Schema::ValidationError`. `strict` is gone; see below.
743
830
 
744
831
  ### Migrating from the provider envelope
745
832
 
746
- `to_json_schema` used to return a provider envelope `{name:, description:, schema:, strict:}`, the shape OpenAI's `response_format` expects. That envelope is gone. Building it is the provider client's job, not this gem's.
833
+ `to_json_schema` returns the schema document itself. It used to return `{name:, description:, schema:, strict:}`, the shape OpenAI's `response_format` expects, and building that belongs in whatever talks to the provider.
747
834
 
748
- If you were reaching into `[:schema]` to get at the document, drop the digging — `to_json_schema` now returns the document itself. Note its keys are strings, not symbols:
835
+ If you were reaching into `[:schema]` to get at the document, drop the digging. Note the keys are strings, not symbols:
749
836
 
750
837
  ```ruby
751
838
  schema.to_json_schema[:schema][:properties] # before
@@ -3,37 +3,39 @@
3
3
  module Schematist
4
4
  module DSL
5
5
  module ComplexTypes
6
- def object(name, description: nil, required: true, requires: nil, **options, &block)
7
- add_property(name, object_schema(description: description, **options, &block), required: required, requires: requires)
6
+ def object(name = nil, description: nil, required: true, requires: nil, **options, &block)
7
+ add_property_or_self(name, object_schema(description: description, **options, &block), required: required, requires: requires)
8
8
  end
9
9
 
10
- def array(name, description: nil, required: true, requires: nil, **options, &block)
11
- add_property(name, array_schema(description: description, **options, &block), required: required, requires: requires)
10
+ def array(name = nil, description: nil, required: true, requires: nil, **options, &block)
11
+ add_property_or_self(name, array_schema(description: description, **options, &block), required: required, requires: requires)
12
12
  end
13
13
 
14
- def tuple(name, description: nil, required: true, requires: nil, **options, &block)
15
- add_property(name, tuple_schema(description: description, **options, &block), required: required, requires: requires)
14
+ def tuple(name = nil, description: nil, required: true, requires: nil, **options, &block)
15
+ add_property_or_self(name, tuple_schema(description: description, **options, &block), required: required, requires: requires)
16
16
  end
17
17
 
18
- def any_of(name, description: nil, required: true, requires: nil, **options, &block)
19
- add_property(name, any_of_schema(description: description, **options, &block), required: required, requires: requires)
18
+ def any_of(name = nil, description: nil, required: true, requires: nil, **options, &block)
19
+ add_property_or_self(name, any_of_schema(description: description, **options, &block), required: required, requires: requires)
20
20
  end
21
21
 
22
- def one_of(name, description: nil, required: true, requires: nil, **options, &block)
23
- add_property(name, one_of_schema(description: description, **options, &block), required: required, requires: requires)
22
+ def one_of(name = nil, description: nil, required: true, requires: nil, **options, &block)
23
+ add_property_or_self(name, one_of_schema(description: description, **options, &block), required: required, requires: requires)
24
24
  end
25
25
 
26
- def all_of(name, description: nil, required: true, requires: nil, **options, &block)
27
- add_property(name, all_of_schema(description: description, **options, &block), required: required, requires: requires)
26
+ def all_of(name = nil, description: nil, required: true, requires: nil, **options, &block)
27
+ add_property_or_self(name, all_of_schema(description: description, **options, &block), required: required, requires: requires)
28
28
  end
29
29
 
30
- def none_of(name, description: nil, required: true, requires: nil, **options, &block)
31
- add_property(name, none_of_schema(description: description, **options, &block), required: required, requires: requires)
30
+ def none_of(name = nil, description: nil, required: true, requires: nil, **options, &block)
31
+ add_property_or_self(name, none_of_schema(description: description, **options, &block), required: required, requires: requires)
32
32
  end
33
33
 
34
34
  # Emits a schema fragment verbatim, for the corners of JSON Schema this DSL does not cover
35
- def raw(name, schema, required: true, requires: nil)
36
- add_property(name, schema, required: required, requires: requires)
35
+ def raw(name, schema = nil, required: true, requires: nil)
36
+ return self_schema(name) && nil if schema.nil? && name.is_a?(Hash)
37
+
38
+ add_property_or_self(name, schema, required: required, requires: requires)
37
39
  end
38
40
 
39
41
  def optional(name, description: nil, &block)
@@ -2,11 +2,10 @@
2
2
 
3
3
  module Schematist
4
4
  module DSL
5
+ # A branch of a conditional. Backed by a schema class, so anything you can write in a schema
6
+ # you can write in a branch: nested objects, arrays, composition, references, raw fragments.
7
+ # `requires` and `validates` stay as shorthands for the two common cases.
5
8
  class ConditionalBuilder
6
- def requires(*fields)
7
- required.concat(fields.map(&:to_s))
8
- end
9
-
10
9
  VALIDATES_KEY_MAP = {
11
10
  type: :type,
12
11
  const: :const,
@@ -19,6 +18,15 @@ module Schematist
19
18
  maximum: :maximum
20
19
  }.freeze
21
20
 
21
+ def initialize
22
+ @schema_class = Class.new(Schema)
23
+ end
24
+
25
+ # Requires properties without saying anything else about them
26
+ def requires(*fields)
27
+ required.concat(fields.map(&:to_s))
28
+ end
29
+
22
30
  def validates(field, **options)
23
31
  constraints = {}
24
32
 
@@ -38,28 +46,56 @@ module Schematist
38
46
  end
39
47
 
40
48
  def to_schema
41
- schema = {}
49
+ return @schema_class.self_schema.dup if @schema_class.self_schema
42
50
 
43
- schema[:required] = required if required.any?
44
- schema[:properties] = validations if validations.any?
51
+ schema = {}
52
+ schema[:properties] = branch_properties if branch_properties.any?
53
+ schema[:required] = branch_required if branch_required.any?
54
+ schema[:additionalProperties] = @schema_class.additional_properties if additional_properties_set?
45
55
 
46
- schema
56
+ @schema_class.send(:merge_schema_keywords, schema, @schema_class)
47
57
  end
48
58
 
49
59
  def empty?
50
- required.empty? && validations.empty?
60
+ to_schema.empty?
51
61
  end
52
62
 
53
63
  def required_fields
54
- required.dup
64
+ branch_required
55
65
  end
56
66
 
67
+ # A branch that only lists required properties becomes dependentRequired rather than
68
+ # dependentSchemas, which is the smaller thing to say when it is all you mean.
57
69
  def validations_empty?
58
- validations.empty?
70
+ to_schema.keys == [:required]
71
+ end
72
+
73
+ # Everything else is the ordinary schema DSL, evaluated against the branch's schema class
74
+ def method_missing(name, ...)
75
+ return super unless @schema_class.respond_to?(name)
76
+
77
+ @schema_class.public_send(name, ...)
78
+ end
79
+
80
+ def respond_to_missing?(name, include_private = false)
81
+ @schema_class.respond_to?(name) || super
59
82
  end
60
83
 
61
84
  private
62
85
 
86
+ def branch_properties
87
+ declared = @schema_class.properties.transform_keys(&:to_s)
88
+ validations.merge(declared)
89
+ end
90
+
91
+ def branch_required
92
+ (@schema_class.required_properties.map(&:to_s) + required).uniq
93
+ end
94
+
95
+ def additional_properties_set?
96
+ @schema_class.instance_variable_defined?(:@additional_properties)
97
+ end
98
+
63
99
  def required
64
100
  @required ||= []
65
101
  end
@@ -8,16 +8,19 @@ module Schematist
8
8
  @else_builder = else_builder
9
9
  end
10
10
 
11
- def requires(*fields)
12
- @then_builder.requires(*fields)
11
+ def otherwise(&block)
12
+ @else_builder.instance_eval(&block)
13
13
  end
14
14
 
15
- def validates(field, **options)
16
- @then_builder.validates(field, **options)
15
+ # Everything else describes the then branch
16
+ def method_missing(name, ...)
17
+ return super unless @then_builder.respond_to?(name)
18
+
19
+ @then_builder.public_send(name, ...)
17
20
  end
18
21
 
19
- def otherwise(&block)
20
- @else_builder.instance_eval(&block)
22
+ def respond_to_missing?(name, include_private = false)
23
+ @then_builder.respond_to?(name) || super
21
24
  end
22
25
  end
23
26
  end
@@ -18,10 +18,13 @@ module Schematist
18
18
  dependencies[property.to_s] = builder
19
19
  end
20
20
 
21
- def given(**properties, &block)
22
- raise ArgumentError, "given requires at least one property condition" if properties.empty?
21
+ # Matches on property values, or on any schema when one is passed explicitly:
22
+ # given(status: "shipped") { ... }
23
+ # given({required: %w[tax_id]}) { ... }
24
+ def given(condition = nil, **properties, &block)
25
+ raise ArgumentError, "given requires a condition" if condition.nil? && properties.empty?
23
26
 
24
- if_schema = {
27
+ if_schema = condition || {
25
28
  properties: properties.transform_keys(&:to_s).transform_values { |v| coerce_condition(v) },
26
29
  required: properties.keys.map(&:to_s)
27
30
  }
@@ -3,24 +3,24 @@
3
3
  module Schematist
4
4
  module DSL
5
5
  module PrimitiveTypes
6
- def string(name, description: nil, required: true, requires: nil, **options, &block)
7
- add_property(name, string_schema(description: description, **options, &block), required: required, requires: requires)
6
+ def string(name = nil, description: nil, required: true, requires: nil, **options, &block)
7
+ add_property_or_self(name, string_schema(description: description, **options, &block), required: required, requires: requires)
8
8
  end
9
9
 
10
- def number(name, description: nil, required: true, requires: nil, **options, &block)
11
- add_property(name, number_schema(description: description, **options, &block), required: required, requires: requires)
10
+ def number(name = nil, description: nil, required: true, requires: nil, **options, &block)
11
+ add_property_or_self(name, number_schema(description: description, **options, &block), required: required, requires: requires)
12
12
  end
13
13
 
14
- def integer(name, description: nil, required: true, requires: nil, **options, &block)
15
- add_property(name, integer_schema(description: description, **options, &block), required: required, requires: requires)
14
+ def integer(name = nil, description: nil, required: true, requires: nil, **options, &block)
15
+ add_property_or_self(name, integer_schema(description: description, **options, &block), required: required, requires: requires)
16
16
  end
17
17
 
18
- def boolean(name, description: nil, required: true, requires: nil, **options, &block)
19
- add_property(name, boolean_schema(description: description, **options, &block), required: required, requires: requires)
18
+ def boolean(name = nil, description: nil, required: true, requires: nil, **options, &block)
19
+ add_property_or_self(name, boolean_schema(description: description, **options, &block), required: required, requires: requires)
20
20
  end
21
21
 
22
- def null(name, description: nil, required: true, requires: nil, **options, &block)
23
- add_property(name, null_schema(description: description, **options, &block), required: required, requires: requires)
22
+ def null(name = nil, description: nil, required: true, requires: nil, **options, &block)
23
+ add_property_or_self(name, null_schema(description: description, **options, &block), required: required, requires: requires)
24
24
  end
25
25
  end
26
26
  end
@@ -23,12 +23,13 @@ module Schematist
23
23
  }.compact, annotations, schema_block)
24
24
  end
25
25
 
26
- def number_schema(description: nil, minimum: nil, maximum: nil, greater_than: nil, less_than: nil, multiple_of: nil, enum: nil, const: nil, **annotations, &block)
26
+ def number_schema(description: nil, minimum: nil, maximum: nil, greater_than: nil, less_than: nil, multiple_of: nil, enum: nil, const: nil, format: nil, **annotations, &block)
27
27
  schema_block = collect_schema_block(&block) if block
28
28
 
29
29
  annotate({
30
30
  type: "number",
31
31
  description: description,
32
+ format: format,
32
33
  minimum: minimum,
33
34
  maximum: maximum,
34
35
  exclusiveMinimum: greater_than,
@@ -39,12 +40,13 @@ module Schematist
39
40
  }.compact, annotations, schema_block)
40
41
  end
41
42
 
42
- def integer_schema(description: nil, minimum: nil, maximum: nil, greater_than: nil, less_than: nil, multiple_of: nil, enum: nil, const: nil, **annotations, &block)
43
+ def integer_schema(description: nil, minimum: nil, maximum: nil, greater_than: nil, less_than: nil, multiple_of: nil, enum: nil, const: nil, format: nil, **annotations, &block)
43
44
  schema_block = collect_schema_block(&block) if block
44
45
 
45
46
  annotate({
46
47
  type: "integer",
47
48
  description: description,
49
+ format: format,
48
50
  minimum: minimum,
49
51
  maximum: maximum,
50
52
  exclusiveMinimum: greater_than,
@@ -55,16 +57,16 @@ module Schematist
55
57
  }.compact, annotations, schema_block)
56
58
  end
57
59
 
58
- def boolean_schema(description: nil, const: nil, **annotations, &block)
60
+ def boolean_schema(description: nil, enum: nil, const: nil, **annotations, &block)
59
61
  schema_block = collect_schema_block(&block) if block
60
62
 
61
- annotate({type: "boolean", description: description, const: const}.compact, annotations, schema_block)
63
+ annotate({type: "boolean", description: description, enum: enum, const: const}.compact, annotations, schema_block)
62
64
  end
63
65
 
64
- def null_schema(description: nil, **annotations, &block)
66
+ def null_schema(description: nil, enum: nil, **annotations, &block)
65
67
  schema_block = collect_schema_block(&block) if block
66
68
 
67
- annotate({type: "null", description: description}.compact, annotations, schema_block)
69
+ annotate({type: "null", description: description, enum: enum}.compact, annotations, schema_block)
68
70
  end
69
71
 
70
72
  def object_schema(description: nil, of: nil, reference: nil, min_properties: nil, max_properties: nil, unevaluated_properties: nil, **annotations, &block)
@@ -96,57 +98,67 @@ module Schematist
96
98
  }.compact, annotations, schema_block)
97
99
  end
98
100
 
99
- def tuple_schema(description: nil, **annotations, &block)
101
+ def tuple_schema(description: nil, of: nil, min_items: nil, max_items: nil, unevaluated_items: nil, **annotations, &block)
100
102
  schema_block = collect_schema_block(&block)
101
103
  schemas = schema_block.schemas
104
+ tail = determine_array_items(of) if of
105
+
106
+ # A tuple is exactly its prefix unless you give it somewhere for the rest to go
107
+ closed = tail.nil? && unevaluated_items.nil? && max_items.nil?
102
108
 
103
109
  annotate({
104
110
  type: "array",
105
111
  description: description,
106
112
  prefixItems: schemas,
107
- minItems: schemas.length,
108
- maxItems: schemas.length
113
+ items: tail,
114
+ minItems: min_items || schemas.length,
115
+ maxItems: closed ? schemas.length : max_items,
116
+ unevaluatedItems: unevaluated_items
109
117
  }.compact, annotations, schema_block)
110
118
  end
111
119
 
112
- def any_of_schema(description: nil, unevaluated_properties: nil, **annotations, &block)
120
+ def any_of_schema(description: nil, unevaluated_properties: nil, unevaluated_items: nil, **annotations, &block)
113
121
  schema_block = collect_schema_block(&block)
114
122
 
115
123
  annotate({
116
124
  description: description,
117
125
  anyOf: schema_block.schemas,
118
- unevaluatedProperties: unevaluated_properties
126
+ unevaluatedProperties: unevaluated_properties,
127
+ unevaluatedItems: unevaluated_items
119
128
  }.compact, annotations, schema_block)
120
129
  end
121
130
 
122
- def one_of_schema(description: nil, unevaluated_properties: nil, **annotations, &block)
131
+ def one_of_schema(description: nil, unevaluated_properties: nil, unevaluated_items: nil, **annotations, &block)
123
132
  schema_block = collect_schema_block(&block)
124
133
 
125
134
  annotate({
126
135
  description: description,
127
136
  oneOf: schema_block.schemas,
128
- unevaluatedProperties: unevaluated_properties
137
+ unevaluatedProperties: unevaluated_properties,
138
+ unevaluatedItems: unevaluated_items
129
139
  }.compact, annotations, schema_block)
130
140
  end
131
141
 
132
- def all_of_schema(description: nil, unevaluated_properties: nil, **annotations, &block)
142
+ def all_of_schema(description: nil, unevaluated_properties: nil, unevaluated_items: nil, **annotations, &block)
133
143
  schema_block = collect_schema_block(&block)
134
144
 
135
145
  annotate({
136
146
  description: description,
137
147
  allOf: schema_block.schemas,
138
- unevaluatedProperties: unevaluated_properties
148
+ unevaluatedProperties: unevaluated_properties,
149
+ unevaluatedItems: unevaluated_items
139
150
  }.compact, annotations, schema_block)
140
151
  end
141
152
 
142
- def none_of_schema(description: nil, unevaluated_properties: nil, **annotations, &block)
153
+ def none_of_schema(description: nil, unevaluated_properties: nil, unevaluated_items: nil, **annotations, &block)
143
154
  schema_block = collect_schema_block(&block)
144
155
  schemas = schema_block.schemas
145
156
 
146
157
  annotate({
147
158
  description: description,
148
159
  not: schemas.size == 1 ? schemas.first : {anyOf: schemas},
149
- unevaluatedProperties: unevaluated_properties
160
+ unevaluatedProperties: unevaluated_properties,
161
+ unevaluatedItems: unevaluated_items
150
162
  }.compact, annotations, schema_block)
151
163
  end
152
164
 
@@ -162,6 +174,8 @@ module Schematist
162
174
  end
163
175
 
164
176
  def build_object_schema(description, &block)
177
+ raise InvalidObjectTypeError, "An object needs a block or an `of:` schema to describe it." unless block
178
+
165
179
  sub_schema = Class.new(Schema)
166
180
  result = sub_schema.class_eval(&block)
167
181
 
@@ -173,15 +187,10 @@ module Schematist
173
187
  schema_class_to_inline_schema(result).merge(description ? {description: description} : {})
174
188
  # Block didn't return reference or schema, so we build an inline object schema
175
189
  else
176
- schema = {
177
- type: "object",
178
- properties: sub_schema.properties,
179
- required: sub_schema.required_properties,
180
- additionalProperties: sub_schema.additional_properties,
181
- description: description
182
- }.compact
183
-
184
- merge_schema_keywords(schema, sub_schema)
190
+ schema = schema_for(sub_schema)
191
+ # The keyword wins over an annotation set inside the block
192
+ schema[:description] = description if description
193
+ schema
185
194
  end
186
195
  end
187
196
 
@@ -8,16 +8,7 @@ module Schematist
8
8
  sub_schema = Class.new(Schema)
9
9
  sub_schema.class_eval(&)
10
10
 
11
- schema = {
12
- type: "object",
13
- properties: sub_schema.properties,
14
- required: sub_schema.required_properties,
15
- additionalProperties: sub_schema.additional_properties
16
- }
17
-
18
- merge_schema_keywords(schema, sub_schema)
19
-
20
- definitions[name] = schema
11
+ definitions[name] = schema_for(sub_schema)
21
12
  end
22
13
 
23
14
  def object_keywords
@@ -47,6 +38,26 @@ module Schematist
47
38
 
48
39
  private
49
40
 
41
+ # What a schema class means as a schema: whatever it declared itself to be, or an object
42
+ # built from its properties.
43
+ def schema_for(schema_class)
44
+ schema = schema_class.self_schema&.dup || {
45
+ type: "object",
46
+ properties: schema_class.properties,
47
+ required: schema_class.required_properties,
48
+ additionalProperties: schema_class.additional_properties
49
+ }
50
+
51
+ merge_schema_keywords(schema, schema_class)
52
+ end
53
+
54
+ # Declares a property when given a name, and what this schema is when not.
55
+ def add_property_or_self(name, definition, required:, requires: nil)
56
+ return self_schema(definition) && nil if name.nil?
57
+
58
+ add_property(name, definition, required: required, requires: requires)
59
+ end
60
+
50
61
  # Merges everything a schema class collects beyond its properties: annotations, core keywords,
51
62
  # key constraints, conditionals. Annotations are defaults, so an option passed to the enclosing
52
63
  # builder wins over them.
@@ -27,18 +27,11 @@ module Schematist
27
27
  private
28
28
 
29
29
  def schema_body
30
- schema_hash = {
31
- type: "object",
32
- properties: self.class.properties,
33
- required: self.class.required_properties,
34
- additionalProperties: self.class.additional_properties
35
- }
30
+ schema_hash = self.class.send(:schema_for, self.class)
36
31
 
37
32
  # Only include $defs if there are definitions
38
33
  schema_hash["$defs"] = self.class.definitions unless self.class.definitions.empty?
39
34
 
40
- self.class.send(:merge_schema_keywords, schema_hash, self.class)
41
-
42
35
  schema_hash
43
36
  end
44
37
 
@@ -91,6 +91,29 @@ module Schematist
91
91
  core_keyword(:vocabulary, *args)
92
92
  end
93
93
 
94
+ # The schema this class is, when it is not an object built from properties. Set by
95
+ # calling a type without a name: `string enum: %w[a b]` rather than `string :status`.
96
+ def self_schema(definition = nil)
97
+ @self_schema = definition if definition
98
+ @self_schema
99
+ end
100
+
101
+ def min_properties(value = nil)
102
+ object_keyword(:minProperties, value)
103
+ end
104
+
105
+ def max_properties(value = nil)
106
+ object_keyword(:maxProperties, value)
107
+ end
108
+
109
+ def unevaluated_properties(value = nil)
110
+ object_keyword(:unevaluatedProperties, value)
111
+ end
112
+
113
+ def unevaluated_items(value = nil)
114
+ object_keyword(:unevaluatedItems, value)
115
+ end
116
+
94
117
  def additional_properties(value = nil)
95
118
  return @additional_properties ||= false if value.nil?
96
119
 
@@ -109,6 +132,12 @@ module Schematist
109
132
 
110
133
  private
111
134
 
135
+ def object_keyword(keyword, value)
136
+ return object_keywords[keyword] if value.nil?
137
+
138
+ object_keywords[keyword] = value
139
+ end
140
+
112
141
  def annotation(name, *args)
113
142
  read_or_write(annotations, ANNOTATIONS.fetch(name), *args)
114
143
  end
@@ -49,15 +49,10 @@ module Schematist
49
49
  # Mark node with temporary mark
50
50
  marks[node] = GRAY
51
51
 
52
- # Visit all adjacent nodes (dependencies)
53
- definition = definitions[node]
54
- if definition && definition[:properties]
55
- definition[:properties].each_value do |property|
56
- references = extract_references(property)
57
- references.each do |adjacent_node|
58
- visit(adjacent_node, definitions, marks)
59
- end
60
- end
52
+ # Visit all adjacent nodes (dependencies). A definition is any schema, not only an
53
+ # object with properties, so the whole thing is searched for references.
54
+ extract_references(definitions[node]).each do |adjacent_node|
55
+ visit(adjacent_node, definitions, marks)
61
56
  end
62
57
 
63
58
  # Mark node with permanent mark
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Schematist
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schematist
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Friis