senko 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: 746b5439c7763d09f66b22bac6b9f924423a2c62c6524301dd1607c4ce9d8bfa
4
+ data.tar.gz: d6fa6a836ab232d508feed3dc603a066b762f81e6ee1b1cce9c46ebf638df19e
5
+ SHA512:
6
+ metadata.gz: 0caebf41b4dfaffbafcf8b0bde2ba6e3c3eaa98c8c8dd13eec8e1221290669fd1a6cb2c18b404f7ec7cac4766ef25957970643148b93cd54164472d644ce1611
7
+ data.tar.gz: c2f482f18118b8b173be18c64853d848deedfa1c6d208444ffbbd619a9eb08642772cbede0743a114470e8b88c5dce8b4206202cedcc356bff8a9a1768744a90
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ group :development, :test do
8
+ gem 'benchmark-ips'
9
+ gem 'json_schemer'
10
+ gem 'minitest'
11
+ gem 'rake'
12
+ gem 'rake-compiler'
13
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yudai Takada
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,235 @@
1
+ # Senko
2
+
3
+ Senko is a fast JSON Schema validator for Ruby. It compiles schemas into a
4
+ reusable representation, uses generated Ruby code for simple boolean
5
+ validation, and falls back to a full interpreter for detailed errors and
6
+ advanced JSON Schema semantics.
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem 'senko'
14
+ ```
15
+
16
+ And then execute:
17
+
18
+ ```sh
19
+ bundle install
20
+ ```
21
+
22
+ Or install it yourself as:
23
+
24
+ ```sh
25
+ gem install senko
26
+ ```
27
+
28
+ Senko includes a native extension for validation hot paths. If the extension
29
+ cannot be loaded, validation continues through the pure Ruby fallback.
30
+
31
+ ## Usage
32
+
33
+ Compile a schema once and reuse it:
34
+
35
+ ```ruby
36
+ require 'senko'
37
+
38
+ schema = Senko.compile(
39
+ 'type' => 'object',
40
+ 'required' => ['name'],
41
+ 'properties' => {
42
+ 'name' => { 'type' => 'string', 'minLength' => 1 }
43
+ },
44
+ 'additionalProperties' => false
45
+ )
46
+
47
+ schema.valid?({ 'name' => 'senko' })
48
+ # => true
49
+
50
+ schema.valid?({ 'name' => '' })
51
+ # => false
52
+ ```
53
+
54
+ `Senko.compile` accepts either a schema hash or schema keywords directly.
55
+
56
+ ```ruby
57
+ Senko.compile({ 'type' => 'string' })
58
+ Senko.compile('type' => 'string')
59
+ ```
60
+
61
+ ### Validation
62
+
63
+ ```ruby
64
+ schema.valid?(data) # boolean-only validation
65
+ schema.validate(data) # returns Senko::Result
66
+ schema.validate!(data) # returns data or raises Senko::ValidationError
67
+ schema.valid_json?(json) # parses and validates a JSON string
68
+ schema.validate_json(json) # parses JSON and returns Senko::Result
69
+ ```
70
+
71
+ One-shot helpers are also available:
72
+
73
+ ```ruby
74
+ Senko.valid?(schema_hash, data)
75
+ Senko.validate(schema_hash, data)
76
+ Senko.valid_json?(schema_hash, '[1, 2, 3]')
77
+ Senko.validate_json(schema_hash, '[1, 2, 3]')
78
+ ```
79
+
80
+ Compile from a JSON file:
81
+
82
+ ```ruby
83
+ schema = Senko.compile_file('schema.json')
84
+ ```
85
+
86
+ ### Errors and Output
87
+
88
+ `validate` returns a `Senko::Result`.
89
+
90
+ ```ruby
91
+ result = schema.validate({ 'name' => '' })
92
+
93
+ result.valid?
94
+ # => false
95
+
96
+ result.errors.map(&:to_h)
97
+ # => [
98
+ # {
99
+ # 'keywordLocation' => '/properties/name/minLength',
100
+ # 'instanceLocation' => '/name',
101
+ # 'error' => 'string length must be >= 1'
102
+ # }
103
+ # ]
104
+ ```
105
+
106
+ Result objects can be rendered in JSON Schema output-style shapes:
107
+
108
+ ```ruby
109
+ result.to_basic
110
+ result.to_detailed
111
+ result.to_verbose
112
+ ```
113
+
114
+ ### Options
115
+
116
+ Options can be passed to `compile` or to the one-shot helpers.
117
+
118
+ ```ruby
119
+ schema = Senko.compile(
120
+ schema_hash,
121
+ format: :assertion,
122
+ fail_fast: true,
123
+ validate_meta_schema: true,
124
+ codegen: :auto
125
+ )
126
+ ```
127
+
128
+ Common options:
129
+
130
+ - `format: :annotation` records format annotations without failing validation.
131
+ - `format: :assertion` makes supported formats validation errors.
132
+ - `fail_fast: true` stops detailed validation after the first error.
133
+ - `validate_meta_schema: true` checks schema shape before compilation.
134
+ - `codegen: false` disables the generated boolean fast path.
135
+ - `messages: { type: 'custom message' }` customizes built-in error messages.
136
+ - `schemas: { uri => schema_hash }` registers external schemas for `$ref`.
137
+
138
+ ### Custom Formats and Keywords
139
+
140
+ Register process-wide extensions:
141
+
142
+ ```ruby
143
+ Senko.register_format('starts-with-x') do |value|
144
+ value.start_with?('x-')
145
+ end
146
+
147
+ Senko.register_keyword('even') do |data, enabled|
148
+ !enabled || data.even?
149
+ end
150
+
151
+ Senko.clear_registry!
152
+ ```
153
+
154
+ Or pass extensions to a single compiled schema:
155
+
156
+ ```ruby
157
+ schema = Senko.compile(
158
+ { 'format' => 'starts-with-x' },
159
+ format: :assertion,
160
+ custom_formats: {
161
+ 'starts-with-x' => ->(value) { value.start_with?('x-') }
162
+ }
163
+ )
164
+ ```
165
+
166
+ ### Drafts and OpenAPI
167
+
168
+ Senko detects `$schema` when present and defaults to Draft 2020-12 otherwise.
169
+ It supports compatibility paths for Draft 2019-09, Draft 7, Draft 6, and
170
+ Draft 4.
171
+
172
+ Legacy and OpenAPI forms are normalized where possible:
173
+
174
+ - `definitions`
175
+ - tuple `items` and `additionalItems`
176
+ - `dependencies`
177
+ - legacy `id`
178
+ - Draft 4 boolean exclusive numeric bounds
179
+ - OpenAPI 3.0 `nullable: true`
180
+
181
+ OpenAPI-style `oneOf` and `anyOf` schemas with a required const discriminator
182
+ property, or an explicit `discriminator.mapping`, are compiled into direct
183
+ discriminator dispatch.
184
+
185
+ ### Performance
186
+
187
+ `valid?` uses generated Ruby code when the schema is supported by the code
188
+ generator. Schemas that require full JSON Schema semantics use the interpreter.
189
+ `validate` always uses the interpreter so detailed errors and annotations remain
190
+ available.
191
+
192
+ Run the local performance gate:
193
+
194
+ ```sh
195
+ bundle exec rake benchmark:check
196
+ ```
197
+
198
+ The benchmark compares Senko against `json_schemer` across codegen,
199
+ interpreter, `$ref`, `oneOf`, and `unevaluatedProperties` scenarios.
200
+
201
+ ## Development
202
+
203
+ After checking out the repo, install dependencies:
204
+
205
+ ```sh
206
+ bundle install
207
+ ```
208
+
209
+ `Gemfile.lock` is intentionally ignored for this gem.
210
+
211
+ Useful commands:
212
+
213
+ ```sh
214
+ bundle exec rake native:compile
215
+ bundle exec rake test
216
+ bundle exec rake suite:install
217
+ bundle exec rake suite:optional
218
+ bundle exec rake benchmark:check
219
+ bundle exec rake ci
220
+ ```
221
+
222
+ `rake ci` compiles the native extension, runs the unit tests, runs the optional
223
+ JSON Schema Test Suite, checks performance, and builds the gem.
224
+
225
+ ## Contributing
226
+
227
+ Bug reports and pull requests are welcome. Before opening a pull request, run:
228
+
229
+ ```sh
230
+ bundle exec rake ci
231
+ ```
232
+
233
+ ## License
234
+
235
+ The gem is available as open source under the terms of the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rbconfig'
5
+ require 'rake/testtask'
6
+
7
+ Rake::TestTask.new(:test) do |task|
8
+ task.libs << 'test'
9
+ task.test_files = FileList['test/**/*_test.rb']
10
+ task.warning = false
11
+ end
12
+
13
+ task default: :test
14
+
15
+ namespace :suite do
16
+ desc 'Install JSON-Schema-Test-Suite into test/suite'
17
+ task :install do
18
+ suite_dir = File.expand_path('test/suite', __dir__)
19
+ if Dir.exist?(suite_dir)
20
+ puts 'test/suite already exists'
21
+ else
22
+ sh 'git', 'clone', 'https://github.com/json-schema-org/JSON-Schema-Test-Suite.git', suite_dir
23
+ end
24
+ end
25
+
26
+ desc 'Run the JSON-Schema-Test-Suite including optional tests'
27
+ task :optional do
28
+ sh({ 'SENKO_OPTIONAL_SUITE' => '1' }, RbConfig.ruby, '-Itest', '-Ilib', 'test/suite_runner_test.rb')
29
+ end
30
+ end
31
+
32
+ namespace :native do
33
+ desc 'Build the native extension in ext/senko'
34
+ task :compile do
35
+ Dir.chdir(File.expand_path('ext/senko', __dir__)) do
36
+ sh RbConfig.ruby, 'extconf.rb'
37
+ sh 'make', 'clean'
38
+ sh 'make'
39
+ end
40
+ end
41
+ end
42
+
43
+ namespace :benchmark do
44
+ desc 'Compare Senko validation speed against json_schemer'
45
+ task :compare do
46
+ sh RbConfig.ruby, 'test/benchmark/bench_compare.rb'
47
+ end
48
+
49
+ desc 'Fail if Senko misses the configured json_schemer performance target'
50
+ task :check do
51
+ sh RbConfig.ruby, 'test/benchmark/performance_check.rb'
52
+ end
53
+ end
54
+
55
+ desc 'Run the local CI checks'
56
+ task ci: ['suite:install', 'native:compile', :test, 'suite:optional', 'benchmark:check', :build]
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mkmf'
4
+
5
+ create_makefile('senko/senko')
@@ -0,0 +1 @@
1
+ #include "instructions.h"
@@ -0,0 +1,12 @@
1
+ #ifndef SENKO_INSTRUCTIONS_H
2
+ #define SENKO_INSTRUCTIONS_H 1
3
+
4
+ #define SENKO_TYPE_NULL 1
5
+ #define SENKO_TYPE_BOOLEAN 2
6
+ #define SENKO_TYPE_INTEGER 4
7
+ #define SENKO_TYPE_NUMBER 12
8
+ #define SENKO_TYPE_STRING 16
9
+ #define SENKO_TYPE_ARRAY 32
10
+ #define SENKO_TYPE_OBJECT 64
11
+
12
+ #endif
data/ext/senko/senko.c ADDED
@@ -0,0 +1,17 @@
1
+ #include "ruby.h"
2
+ #include "validator.h"
3
+
4
+ void
5
+ Init_senko(void)
6
+ {
7
+ VALUE mSenko = rb_define_module("Senko");
8
+ VALUE mNative = rb_define_module_under(mSenko, "Native");
9
+
10
+ rb_define_singleton_method(mNative, "type_mask", senko_native_type_mask, 1);
11
+ rb_define_singleton_method(mNative, "string_length", senko_native_string_length, 1);
12
+ rb_define_singleton_method(mNative, "numeric_lte?", senko_native_numeric_lte, 2);
13
+ rb_define_singleton_method(mNative, "numeric_lt?", senko_native_numeric_lt, 2);
14
+ rb_define_singleton_method(mNative, "numeric_gte?", senko_native_numeric_gte, 2);
15
+ rb_define_singleton_method(mNative, "numeric_gt?", senko_native_numeric_gt, 2);
16
+ rb_define_singleton_method(mNative, "unique_array?", senko_native_unique_array_p, 1);
17
+ }
@@ -0,0 +1,107 @@
1
+ #include "ruby.h"
2
+ #include <math.h>
3
+ #include "validator.h"
4
+ #include "instructions.h"
5
+
6
+ static int
7
+ senko_integer_number_p(VALUE value)
8
+ {
9
+ switch (TYPE(value)) {
10
+ case T_FIXNUM:
11
+ case T_BIGNUM:
12
+ return 1;
13
+ case T_FLOAT: {
14
+ double number = RFLOAT_VALUE(value);
15
+ return isfinite(number) && floor(number) == number;
16
+ }
17
+ default:
18
+ if (rb_obj_is_kind_of(value, rb_path2class("BigDecimal")) == Qtrue) {
19
+ VALUE integer = rb_funcall(value, rb_intern2("to_i", 4), 0);
20
+ return RTEST(rb_funcall(value, rb_intern2("==", 2), 1, integer));
21
+ }
22
+ return 0;
23
+ }
24
+ }
25
+
26
+ VALUE
27
+ senko_native_type_mask(VALUE self, VALUE value)
28
+ {
29
+ switch (TYPE(value)) {
30
+ case T_NIL:
31
+ return INT2NUM(SENKO_TYPE_NULL);
32
+ case T_TRUE:
33
+ case T_FALSE:
34
+ return INT2NUM(SENKO_TYPE_BOOLEAN);
35
+ case T_FIXNUM:
36
+ case T_BIGNUM:
37
+ return INT2NUM(SENKO_TYPE_INTEGER);
38
+ case T_FLOAT:
39
+ return INT2NUM(senko_integer_number_p(value) ? SENKO_TYPE_INTEGER : (SENKO_TYPE_NUMBER & ~SENKO_TYPE_INTEGER));
40
+ case T_STRING:
41
+ return INT2NUM(SENKO_TYPE_STRING);
42
+ case T_ARRAY:
43
+ return INT2NUM(SENKO_TYPE_ARRAY);
44
+ case T_HASH:
45
+ return INT2NUM(SENKO_TYPE_OBJECT);
46
+ default:
47
+ if (rb_obj_is_kind_of(value, rb_path2class("BigDecimal")) == Qtrue)
48
+ return INT2NUM(senko_integer_number_p(value) ? SENKO_TYPE_INTEGER : (SENKO_TYPE_NUMBER & ~SENKO_TYPE_INTEGER));
49
+ return INT2NUM(0);
50
+ }
51
+ }
52
+
53
+ VALUE
54
+ senko_native_string_length(VALUE self, VALUE value)
55
+ {
56
+ Check_Type(value, T_STRING);
57
+ ID id_length = rb_intern2("length", 6);
58
+ return rb_funcall(value, id_length, 0);
59
+ }
60
+
61
+ VALUE
62
+ senko_native_numeric_lte(VALUE self, VALUE left, VALUE right)
63
+ {
64
+ ID id_lte = rb_intern2("<=", 2);
65
+ return RTEST(rb_funcall(left, id_lte, 1, right)) ? Qtrue : Qfalse;
66
+ }
67
+
68
+ VALUE
69
+ senko_native_numeric_lt(VALUE self, VALUE left, VALUE right)
70
+ {
71
+ ID id_lt = rb_intern2("<", 1);
72
+ return RTEST(rb_funcall(left, id_lt, 1, right)) ? Qtrue : Qfalse;
73
+ }
74
+
75
+ VALUE
76
+ senko_native_numeric_gte(VALUE self, VALUE left, VALUE right)
77
+ {
78
+ ID id_gte = rb_intern2(">=", 2);
79
+ return RTEST(rb_funcall(left, id_gte, 1, right)) ? Qtrue : Qfalse;
80
+ }
81
+
82
+ VALUE
83
+ senko_native_numeric_gt(VALUE self, VALUE left, VALUE right)
84
+ {
85
+ ID id_gt = rb_intern2(">", 1);
86
+ return RTEST(rb_funcall(left, id_gt, 1, right)) ? Qtrue : Qfalse;
87
+ }
88
+
89
+ VALUE
90
+ senko_native_unique_array_p(VALUE self, VALUE array)
91
+ {
92
+ long i, j, length;
93
+ ID id_eq;
94
+
95
+ Check_Type(array, T_ARRAY);
96
+ length = RARRAY_LEN(array);
97
+ id_eq = rb_intern2("==", 2);
98
+
99
+ for (i = 0; i < length; i++) {
100
+ for (j = i + 1; j < length; j++) {
101
+ if (RTEST(rb_funcall(rb_ary_entry(array, i), id_eq, 1, rb_ary_entry(array, j))))
102
+ return Qfalse;
103
+ }
104
+ }
105
+
106
+ return Qtrue;
107
+ }
@@ -0,0 +1,14 @@
1
+ #ifndef SENKO_VALIDATOR_H
2
+ #define SENKO_VALIDATOR_H 1
3
+
4
+ #include "ruby.h"
5
+
6
+ VALUE senko_native_type_mask(VALUE self, VALUE value);
7
+ VALUE senko_native_string_length(VALUE self, VALUE value);
8
+ VALUE senko_native_numeric_lte(VALUE self, VALUE left, VALUE right);
9
+ VALUE senko_native_numeric_lt(VALUE self, VALUE left, VALUE right);
10
+ VALUE senko_native_numeric_gte(VALUE self, VALUE left, VALUE right);
11
+ VALUE senko_native_numeric_gt(VALUE self, VALUE left, VALUE right);
12
+ VALUE senko_native_unique_array_p(VALUE self, VALUE array);
13
+
14
+ #endif
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senko
4
+ class Cache
5
+ def initialize(max_size: 256)
6
+ @store = {}
7
+ @max_size = max_size
8
+ @access_order = []
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def get(key)
13
+ @mutex.synchronize do
14
+ value = @store[key]
15
+ touch(key) if value
16
+ value
17
+ end
18
+ end
19
+
20
+ def put(key, value)
21
+ @mutex.synchronize do
22
+ evict_lru if @store.size >= @max_size && !@store.key?(key)
23
+ @store[key] = value
24
+ touch(key)
25
+ end
26
+ value
27
+ end
28
+
29
+ def fetch(key)
30
+ cached = get(key)
31
+ return cached if cached
32
+
33
+ put(key, yield)
34
+ end
35
+
36
+ def clear
37
+ @mutex.synchronize do
38
+ @store.clear
39
+ @access_order.clear
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def touch(key)
46
+ @access_order.delete(key)
47
+ @access_order << key
48
+ end
49
+
50
+ def evict_lru(count = [@max_size / 4, 1].max)
51
+ count.times do
52
+ key = @access_order.shift
53
+ @store.delete(key) if key
54
+ end
55
+ end
56
+ end
57
+ end