schematist 0.1.0 → 1.0.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 +4 -4
- data/LICENSE +6 -6
- data/README.md +754 -4
- data/lib/schematist/dsl/complex_types.rb +47 -0
- data/lib/schematist/dsl/conditional_builder.rb +72 -0
- data/lib/schematist/dsl/conditional_context.rb +24 -0
- data/lib/schematist/dsl/conditionals.rb +81 -0
- data/lib/schematist/dsl/primitive_types.rb +27 -0
- data/lib/schematist/dsl/schema_builders.rb +306 -0
- data/lib/schematist/dsl/utilities.rb +88 -0
- data/lib/schematist/dsl.rb +11 -0
- data/lib/schematist/errors.rb +28 -0
- data/lib/schematist/helpers.rb +10 -0
- data/lib/schematist/json_output.rb +73 -0
- data/lib/schematist/schema.rb +152 -0
- data/lib/schematist/validator.rb +91 -0
- data/lib/schematist/version.rb +1 -1
- data/lib/schematist.rb +44 -2
- data/lib/tasks/release.rake +8 -0
- metadata +26 -23
|
@@ -0,0 +1,73 @@
|
|
|
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 = {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: self.class.properties,
|
|
33
|
+
required: self.class.required_properties,
|
|
34
|
+
additionalProperties: self.class.additional_properties
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Only include $defs if there are definitions
|
|
38
|
+
schema_hash["$defs"] = self.class.definitions unless self.class.definitions.empty?
|
|
39
|
+
|
|
40
|
+
self.class.send(:merge_schema_keywords, schema_hash, self.class)
|
|
41
|
+
|
|
42
|
+
schema_hash
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# JSON Schema documents are string-keyed. Symbols survive a Ruby comparison but not a JSON round trip.
|
|
46
|
+
def json_compatible(value)
|
|
47
|
+
case value
|
|
48
|
+
when Hash
|
|
49
|
+
value.to_h { |key, nested_value| [key.to_s, json_compatible(nested_value)] }
|
|
50
|
+
when Array
|
|
51
|
+
value.map { |nested_value| json_compatible(nested_value) }
|
|
52
|
+
when Symbol
|
|
53
|
+
value.to_s
|
|
54
|
+
else
|
|
55
|
+
value
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Values declared as procs are resolved here, so one schema class can render differently per instance
|
|
60
|
+
def resolve_runtime_values(value)
|
|
61
|
+
case value
|
|
62
|
+
when Proc
|
|
63
|
+
resolve_runtime_values(value.arity.zero? ? instance_exec(&value) : value.call(self))
|
|
64
|
+
when Hash
|
|
65
|
+
value.transform_values { |nested_value| resolve_runtime_values(nested_value) }
|
|
66
|
+
when Array
|
|
67
|
+
value.map { |nested_value| resolve_runtime_values(nested_value) }
|
|
68
|
+
else
|
|
69
|
+
value
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
def additional_properties(value = nil)
|
|
95
|
+
return @additional_properties ||= false if value.nil?
|
|
96
|
+
|
|
97
|
+
@additional_properties = value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate!
|
|
101
|
+
validator = Validator.new(self)
|
|
102
|
+
validator.validate!
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def valid?
|
|
106
|
+
validator = Validator.new(self)
|
|
107
|
+
validator.valid?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def annotation(name, *args)
|
|
113
|
+
read_or_write(annotations, ANNOTATIONS.fetch(name), *args)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def core_keyword(name, *args)
|
|
117
|
+
read_or_write(core_keywords, CORE_KEYWORDS.fetch(name), *args)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def read_or_write(store, keyword, *args)
|
|
121
|
+
return store[keyword] if args.empty?
|
|
122
|
+
|
|
123
|
+
store[keyword] = args.first
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def initialize(name = nil, description: nil)
|
|
128
|
+
@name = name || self.class.name || "Schema"
|
|
129
|
+
@description = description
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def validate!
|
|
133
|
+
self.class.validate!
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def valid?
|
|
137
|
+
self.class.valid?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def method_missing(method_name, ...)
|
|
141
|
+
if respond_to_missing?(method_name)
|
|
142
|
+
self.class.send(method_name, ...)
|
|
143
|
+
else
|
|
144
|
+
super
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
149
|
+
%i[string number integer boolean array tuple object any_of one_of all_of none_of raw null].include?(method_name) || super
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
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)
|
|
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
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Mark node with permanent mark
|
|
64
|
+
marks[node] = BLACK
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract_references(property)
|
|
68
|
+
references = []
|
|
69
|
+
|
|
70
|
+
case property
|
|
71
|
+
when Hash
|
|
72
|
+
if property["$ref"]
|
|
73
|
+
# Extract definition name from reference like "#/$defs/user"
|
|
74
|
+
ref_name = property["$ref"].split("/").last&.to_sym
|
|
75
|
+
references << ref_name if ref_name
|
|
76
|
+
else
|
|
77
|
+
# Recursively check nested properties
|
|
78
|
+
property.each_value do |value|
|
|
79
|
+
references.concat(extract_references(value))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
when Array
|
|
83
|
+
property.each do |item|
|
|
84
|
+
references.concat(extract_references(item))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
references
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/schematist/version.rb
CHANGED
data/lib/schematist.rb
CHANGED
|
@@ -1,4 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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"
|
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:
|
|
4
|
+
version: 1.0.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
|
-
-
|
|
13
|
-
|
|
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
|
-
|
|
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/
|
|
44
|
-
source_code_uri: https://github.com/crmne/
|
|
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
|
|
65
|
+
summary: A simple Ruby DSL for creating JSON schemas.
|
|
63
66
|
test_files: []
|