schematist 0.1.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.
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module DSL
5
+ module Utilities
6
+ # Schema definition and reference methods
7
+ def define(name, &)
8
+ sub_schema = Class.new(Schema)
9
+ sub_schema.class_eval(&)
10
+
11
+ definitions[name] = schema_for(sub_schema)
12
+ end
13
+
14
+ def object_keywords
15
+ @object_keywords ||= {}
16
+ end
17
+
18
+ # Constrains the names of properties matching a pattern, as JSON Schema patternProperties
19
+ def keys_matching(pattern, &block)
20
+ pattern = pattern.source if pattern.is_a?(Regexp)
21
+
22
+ object_keywords[:patternProperties] ||= {}
23
+ object_keywords[:patternProperties][pattern] = collect_schemas_from_block(&block).first
24
+ end
25
+
26
+ # Constrains every property name, as JSON Schema propertyNames
27
+ def keys(&block)
28
+ object_keywords[:propertyNames] = collect_schemas_from_block(&block).first
29
+ end
30
+
31
+ def reference(schema_name)
32
+ if schema_name == :root
33
+ {"$ref" => "#"}
34
+ else
35
+ {"$ref" => "#/$defs/#{schema_name}"}
36
+ end
37
+ end
38
+
39
+ private
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
+
61
+ # Merges everything a schema class collects beyond its properties: annotations, core keywords,
62
+ # key constraints, conditionals. Annotations are defaults, so an option passed to the enclosing
63
+ # builder wins over them.
64
+ def merge_schema_keywords(schema, schema_class)
65
+ schema.replace(schema_class.annotations.merge(schema))
66
+ schema.merge!(schema_class.core_keywords)
67
+ schema.merge!(schema_class.object_keywords)
68
+ merge_conditions(schema, schema_class)
69
+ end
70
+
71
+ def add_property(name, definition, required:, requires: nil)
72
+ property_name = name.to_sym
73
+
74
+ properties[property_name] = definition
75
+ if required
76
+ required_properties << property_name unless required_properties.include?(property_name)
77
+ else
78
+ required_properties.delete(property_name)
79
+ end
80
+
81
+ if requires
82
+ builder = ConditionalBuilder.new
83
+ builder.requires(*Array(requires))
84
+ dependencies[name.to_s] = builder
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ def primitive_type?(type)
91
+ type.is_a?(Symbol) && PRIMITIVE_TYPES.include?(type)
92
+ end
93
+
94
+ def schema_class?(type)
95
+ (type.is_a?(Class) && type < Schema) || type.is_a?(Schema)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module DSL
5
+ include SchemaBuilders
6
+ include PrimitiveTypes
7
+ include ComplexTypes
8
+ include Conditionals
9
+ include Utilities
10
+ end
11
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ # Base error class for all schema-related errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when an invalid schema type is specified
8
+ class InvalidSchemaTypeError < Error
9
+ def initialize(type)
10
+ super("Unknown schema type: #{type}")
11
+ end
12
+ end
13
+
14
+ # Raised when an invalid array type is specified
15
+ class InvalidArrayTypeError < Error; end
16
+
17
+ # Raised when an invalid object type is specified
18
+ class InvalidObjectTypeError < Error; end
19
+
20
+ # Raised when schema definition is invalid
21
+ class InvalidSchemaError < Error; end
22
+
23
+ # Raised when schema validation fails
24
+ class ValidationError < Error; end
25
+
26
+ # Raised when maximum limits are exceeded
27
+ class LimitExceededError < Error; end
28
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module Helpers
5
+ def schema(name = nil, description: nil, &block)
6
+ schema_class = Schema.create(&block)
7
+ schema_class.new(name, description: description)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module JsonOutput
5
+ DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema"
6
+
7
+ # A Draft 2020-12 JSON Schema document, string-keyed and ready for any JSON Schema validator
8
+ def to_json_schema
9
+ validate! # Validate schema before generating JSON
10
+
11
+ document = schema_body
12
+ class_description = document.delete(:description)
13
+ header = {
14
+ "$schema" => DRAFT_2020_12,
15
+ title: document.delete(:title) || @name,
16
+ description: @description || class_description
17
+ }.compact
18
+
19
+ json_compatible(resolve_runtime_values(header.merge(document)))
20
+ end
21
+
22
+ def to_json(*_args)
23
+ validate! # Validate schema before generating JSON string
24
+ JSON.pretty_generate(to_json_schema)
25
+ end
26
+
27
+ private
28
+
29
+ def schema_body
30
+ schema_hash = self.class.send(:schema_for, self.class)
31
+
32
+ # Only include $defs if there are definitions
33
+ schema_hash["$defs"] = self.class.definitions unless self.class.definitions.empty?
34
+
35
+ schema_hash
36
+ end
37
+
38
+ # JSON Schema documents are string-keyed. Symbols survive a Ruby comparison but not a JSON round trip.
39
+ def json_compatible(value)
40
+ case value
41
+ when Hash
42
+ value.to_h { |key, nested_value| [key.to_s, json_compatible(nested_value)] }
43
+ when Array
44
+ value.map { |nested_value| json_compatible(nested_value) }
45
+ when Symbol
46
+ value.to_s
47
+ else
48
+ value
49
+ end
50
+ end
51
+
52
+ # Values declared as procs are resolved here, so one schema class can render differently per instance
53
+ def resolve_runtime_values(value)
54
+ case value
55
+ when Proc
56
+ resolve_runtime_values(value.arity.zero? ? instance_exec(&value) : value.call(self))
57
+ when Hash
58
+ value.transform_values { |nested_value| resolve_runtime_values(nested_value) }
59
+ when Array
60
+ value.map { |nested_value| resolve_runtime_values(nested_value) }
61
+ else
62
+ value
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ class Schema
5
+ extend DSL
6
+ include JsonOutput
7
+
8
+ class << self
9
+ def create(&block)
10
+ schema_class = Class.new(Schema)
11
+ schema_class.class_eval(&block)
12
+ schema_class
13
+ end
14
+
15
+ def properties
16
+ @properties ||= {}
17
+ end
18
+
19
+ def required_properties
20
+ @required_properties ||= []
21
+ end
22
+
23
+ def definitions
24
+ @definitions ||= {}
25
+ end
26
+
27
+ def name(name = nil)
28
+ @schema_name = name if name
29
+ return @schema_name if defined?(@schema_name)
30
+
31
+ super()
32
+ end
33
+
34
+ def annotations
35
+ @annotations ||= {}
36
+ end
37
+
38
+ def title(*args)
39
+ annotation(:title, *args)
40
+ end
41
+
42
+ def description(*args)
43
+ annotation(:description, *args)
44
+ end
45
+
46
+ def default(*args)
47
+ annotation(:default, *args)
48
+ end
49
+
50
+ def examples(*args)
51
+ annotation(:examples, *args)
52
+ end
53
+
54
+ def deprecated(*args)
55
+ annotation(:deprecated, *args)
56
+ end
57
+
58
+ def read_only(*args)
59
+ annotation(:read_only, *args)
60
+ end
61
+
62
+ def write_only(*args)
63
+ annotation(:write_only, *args)
64
+ end
65
+
66
+ def core_keywords
67
+ @core_keywords ||= {}
68
+ end
69
+
70
+ def id(*args)
71
+ core_keyword(:id, *args)
72
+ end
73
+
74
+ def anchor(*args)
75
+ core_keyword(:anchor, *args)
76
+ end
77
+
78
+ def comment(*args)
79
+ core_keyword(:comment, *args)
80
+ end
81
+
82
+ def dynamic_anchor(*args)
83
+ core_keyword(:dynamic_anchor, *args)
84
+ end
85
+
86
+ def dynamic_ref(*args)
87
+ core_keyword(:dynamic_ref, *args)
88
+ end
89
+
90
+ def vocabulary(*args)
91
+ core_keyword(:vocabulary, *args)
92
+ end
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
+
117
+ def additional_properties(value = nil)
118
+ return @additional_properties ||= false if value.nil?
119
+
120
+ @additional_properties = value
121
+ end
122
+
123
+ def validate!
124
+ validator = Validator.new(self)
125
+ validator.validate!
126
+ end
127
+
128
+ def valid?
129
+ validator = Validator.new(self)
130
+ validator.valid?
131
+ end
132
+
133
+ private
134
+
135
+ def object_keyword(keyword, value)
136
+ return object_keywords[keyword] if value.nil?
137
+
138
+ object_keywords[keyword] = value
139
+ end
140
+
141
+ def annotation(name, *args)
142
+ read_or_write(annotations, ANNOTATIONS.fetch(name), *args)
143
+ end
144
+
145
+ def core_keyword(name, *args)
146
+ read_or_write(core_keywords, CORE_KEYWORDS.fetch(name), *args)
147
+ end
148
+
149
+ def read_or_write(store, keyword, *args)
150
+ return store[keyword] if args.empty?
151
+
152
+ store[keyword] = args.first
153
+ end
154
+ end
155
+
156
+ def initialize(name = nil, description: nil)
157
+ @name = name || self.class.name || "Schema"
158
+ @description = description
159
+ end
160
+
161
+ def validate!
162
+ self.class.validate!
163
+ end
164
+
165
+ def valid?
166
+ self.class.valid?
167
+ end
168
+
169
+ def method_missing(method_name, ...)
170
+ if respond_to_missing?(method_name)
171
+ self.class.send(method_name, ...)
172
+ else
173
+ super
174
+ end
175
+ end
176
+
177
+ def respond_to_missing?(method_name, include_private = false)
178
+ %i[string number integer boolean array tuple object any_of one_of all_of none_of raw null].include?(method_name) || super
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ class Validator
5
+ # Node states for DFS-based topological sort
6
+ WHITE = :white # No mark (unvisited)
7
+ GRAY = :gray # Temporary mark (currently being processed)
8
+ BLACK = :black # Permanent mark (completely processed)
9
+
10
+ def initialize(schema_class)
11
+ @schema_class = schema_class
12
+ end
13
+
14
+ def validate!
15
+ validate_circular_references!
16
+ # Future validations can be added here
17
+ end
18
+
19
+ def valid?
20
+ validate!
21
+ true
22
+ rescue ValidationError
23
+ false
24
+ end
25
+
26
+ private
27
+
28
+ def validate_circular_references!
29
+ definitions = @schema_class.definitions
30
+ return if definitions.empty?
31
+
32
+ # Initialize all nodes as WHITE (no mark)
33
+ marks = Hash.new { WHITE }
34
+
35
+ # Visit each unmarked node
36
+ definitions.each_key do |node|
37
+ visit(node, definitions, marks) if marks[node] == WHITE
38
+ end
39
+ end
40
+
41
+ # DFS visit function
42
+ def visit(node, definitions, marks)
43
+ # If node has a permanent mark, return
44
+ return if marks[node] == BLACK
45
+
46
+ # If node has a temporary mark, we found a cycle
47
+ raise ValidationError, "Circular reference detected involving '#{node}'" if marks[node] == GRAY
48
+
49
+ # Mark node with temporary mark
50
+ marks[node] = GRAY
51
+
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)
56
+ end
57
+
58
+ # Mark node with permanent mark
59
+ marks[node] = BLACK
60
+ end
61
+
62
+ def extract_references(property)
63
+ references = []
64
+
65
+ case property
66
+ when Hash
67
+ if property["$ref"]
68
+ # Extract definition name from reference like "#/$defs/user"
69
+ ref_name = property["$ref"].split("/").last&.to_sym
70
+ references << ref_name if ref_name
71
+ else
72
+ # Recursively check nested properties
73
+ property.each_value do |value|
74
+ references.concat(extract_references(value))
75
+ end
76
+ end
77
+ when Array
78
+ property.each do |item|
79
+ references.concat(extract_references(item))
80
+ end
81
+ end
82
+
83
+ references
84
+ end
85
+ end
86
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Schematist
4
- VERSION = '0.1.0'
4
+ VERSION = '1.1.0'
5
5
  end
data/lib/schematist.rb CHANGED
@@ -1,4 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'schematist/version'
4
- require 'ruby_llm/schema'
3
+ require "json"
4
+
5
+ require_relative "schematist/version"
6
+ require_relative "schematist/errors"
7
+
8
+ module Schematist
9
+ PRIMITIVE_TYPES = %i[string number integer boolean null].freeze
10
+
11
+ # Annotations describe a schema for humans and tools. They carry no validation weight.
12
+ ANNOTATIONS = {
13
+ title: :title,
14
+ description: :description,
15
+ default: :default,
16
+ examples: :examples,
17
+ deprecated: :deprecated,
18
+ read_only: :readOnly,
19
+ write_only: :writeOnly
20
+ }.freeze
21
+
22
+ # Core keywords identify a schema and point at other schemas.
23
+ CORE_KEYWORDS = {
24
+ id: "$id",
25
+ anchor: "$anchor",
26
+ comment: "$comment",
27
+ dynamic_anchor: "$dynamicAnchor",
28
+ dynamic_ref: "$dynamicRef",
29
+ vocabulary: "$vocabulary"
30
+ }.freeze
31
+ end
32
+
33
+ # Every file defines the constant its path implies. The DSL modules come before dsl.rb,
34
+ # which includes them, and the DSL before schema.rb, which extends it.
35
+ require_relative "schematist/dsl/schema_builders"
36
+ require_relative "schematist/dsl/primitive_types"
37
+ require_relative "schematist/dsl/complex_types"
38
+ require_relative "schematist/dsl/conditional_builder"
39
+ require_relative "schematist/dsl/conditional_context"
40
+ require_relative "schematist/dsl/conditionals"
41
+ require_relative "schematist/dsl/utilities"
42
+ require_relative "schematist/dsl"
43
+ require_relative "schematist/json_output"
44
+ require_relative "schematist/validator"
45
+ require_relative "schematist/helpers"
46
+ require_relative "schematist/schema"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :release do
4
+ desc 'Prepare for release'
5
+ task :prepare do
6
+ sh 'overcommit --run'
7
+ end
8
+ end
metadata CHANGED
@@ -1,32 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schematist
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
+ - Daniel Friis
7
8
  - Carmine Paolino
8
9
  bindir: bin
9
10
  cert_chain: []
10
11
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: ruby_llm-schema
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
26
- description: Schematist is the next home of ruby_llm-schema. Installing schematist
27
- installs ruby_llm-schema and requiring schematist loads it. The expanded JSON Schema
28
- (draft 2020-12) toolkit lands here in a future release.
12
+ dependencies: []
13
+ description: A compact Ruby DSL for building standards-oriented JSON Schema documents
14
+ from Ruby.
29
15
  email:
16
+ - d@friis.me
30
17
  - carmine@paolino.me
31
18
  executables: []
32
19
  extensions: []
@@ -35,13 +22,29 @@ files:
35
22
  - LICENSE
36
23
  - README.md
37
24
  - lib/schematist.rb
25
+ - lib/schematist/dsl.rb
26
+ - lib/schematist/dsl/complex_types.rb
27
+ - lib/schematist/dsl/conditional_builder.rb
28
+ - lib/schematist/dsl/conditional_context.rb
29
+ - lib/schematist/dsl/conditionals.rb
30
+ - lib/schematist/dsl/primitive_types.rb
31
+ - lib/schematist/dsl/schema_builders.rb
32
+ - lib/schematist/dsl/utilities.rb
33
+ - lib/schematist/errors.rb
34
+ - lib/schematist/helpers.rb
35
+ - lib/schematist/json_output.rb
36
+ - lib/schematist/schema.rb
37
+ - lib/schematist/validator.rb
38
38
  - lib/schematist/version.rb
39
- homepage: https://github.com/crmne/ruby_llm-schema
39
+ - lib/tasks/release.rake
40
+ homepage: https://github.com/crmne/schematist#readme
40
41
  licenses:
41
42
  - MIT
42
43
  metadata:
43
- homepage_uri: https://github.com/crmne/ruby_llm-schema
44
- source_code_uri: https://github.com/crmne/ruby_llm-schema
44
+ homepage_uri: https://github.com/crmne/schematist#readme
45
+ source_code_uri: https://github.com/crmne/schematist
46
+ changelog_uri: https://github.com/crmne/schematist/releases
47
+ bug_tracker_uri: https://github.com/crmne/schematist/issues
45
48
  rubygems_mfa_required: 'true'
46
49
  rdoc_options: []
47
50
  require_paths:
@@ -59,5 +62,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
59
62
  requirements: []
60
63
  rubygems_version: 4.0.16
61
64
  specification_version: 4
62
- summary: A Ruby DSL for building JSON Schema documents.
65
+ summary: A simple Ruby DSL for creating JSON schemas.
63
66
  test_files: []