ata-validator 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.
@@ -0,0 +1,744 @@
1
+ #include <napi.h>
2
+
3
+ #include <cmath>
4
+ #include <regex>
5
+ #include <set>
6
+ #include <string>
7
+ #include <vector>
8
+
9
+ #include "ata.h"
10
+
11
+ // ============================================================================
12
+ // V8 Direct Object Traversal Engine
13
+ // Validates Napi::Value directly without JSON.stringify + simdjson parse
14
+ // ============================================================================
15
+
16
+ // Forward declare - we need access to compiled schema internals
17
+ // We include the schema_node definition here to avoid modifying ata.h
18
+ struct schema_node;
19
+ using schema_node_ptr = std::shared_ptr<schema_node>;
20
+
21
+ // MUST match layout in src/ata.cpp exactly (reinterpret_cast)
22
+ struct schema_node {
23
+ std::vector<std::string> types;
24
+
25
+ std::optional<double> minimum;
26
+ std::optional<double> maximum;
27
+ std::optional<double> exclusive_minimum;
28
+ std::optional<double> exclusive_maximum;
29
+ std::optional<double> multiple_of;
30
+
31
+ std::optional<uint64_t> min_length;
32
+ std::optional<uint64_t> max_length;
33
+ std::optional<std::string> pattern;
34
+ std::shared_ptr<std::regex> compiled_pattern;
35
+
36
+ std::optional<uint64_t> min_items;
37
+ std::optional<uint64_t> max_items;
38
+ bool unique_items = false;
39
+ schema_node_ptr items_schema;
40
+ std::vector<schema_node_ptr> prefix_items;
41
+
42
+ std::unordered_map<std::string, schema_node_ptr> properties;
43
+ std::vector<std::string> required;
44
+ std::optional<bool> additional_properties_bool;
45
+ schema_node_ptr additional_properties_schema;
46
+ std::optional<uint64_t> min_properties;
47
+ std::optional<uint64_t> max_properties;
48
+
49
+ std::vector<std::pair<std::string, schema_node_ptr>> pattern_properties;
50
+
51
+ std::optional<std::string> enum_values_raw;
52
+ std::vector<std::string> enum_values_minified;
53
+ std::optional<std::string> const_value_raw;
54
+
55
+ std::optional<std::string> format;
56
+
57
+ std::vector<schema_node_ptr> all_of;
58
+ std::vector<schema_node_ptr> any_of;
59
+ std::vector<schema_node_ptr> one_of;
60
+ schema_node_ptr not_schema;
61
+
62
+ schema_node_ptr if_schema;
63
+ schema_node_ptr then_schema;
64
+ schema_node_ptr else_schema;
65
+
66
+ std::string ref;
67
+
68
+ std::optional<bool> boolean_schema;
69
+ };
70
+
71
+ struct compiled_schema_internal {
72
+ schema_node_ptr root;
73
+ std::unordered_map<std::string, schema_node_ptr> defs;
74
+ };
75
+
76
+ // --- V8 Direct Validator ---
77
+
78
+ static std::string napi_type_of(Napi::Value val) {
79
+ if (val.IsNull()) return "null";
80
+ if (val.IsBoolean()) return "boolean";
81
+ if (val.IsNumber()) {
82
+ double d = val.As<Napi::Number>().DoubleValue();
83
+ if (std::isfinite(d) && d == std::floor(d) &&
84
+ std::abs(d) <= 9007199254740991.0) {
85
+ return "integer";
86
+ }
87
+ return "number";
88
+ }
89
+ if (val.IsString()) return "string";
90
+ if (val.IsArray()) return "array";
91
+ if (val.IsObject()) return "object";
92
+ return "unknown";
93
+ }
94
+
95
+ static bool napi_type_matches(Napi::Value val, const std::string& type) {
96
+ auto actual = napi_type_of(val);
97
+ if (actual == type) return true;
98
+ if (type == "number" && (actual == "integer" || actual == "number"))
99
+ return true;
100
+ return false;
101
+ }
102
+
103
+ static uint64_t utf8_codepoint_length(const std::string& s) {
104
+ uint64_t len = 0;
105
+ for (size_t i = 0; i < s.size();) {
106
+ unsigned char c = static_cast<unsigned char>(s[i]);
107
+ if (c < 0x80) i += 1;
108
+ else if ((c >> 5) == 0x06) i += 2;
109
+ else if ((c >> 4) == 0x0E) i += 3;
110
+ else if ((c >> 3) == 0x1E) i += 4;
111
+ else i += 1;
112
+ ++len;
113
+ }
114
+ return len;
115
+ }
116
+
117
+ // Serialize a Napi::Value to a minified JSON string (for enum/const comparison)
118
+ static std::string napi_to_json(Napi::Env env, Napi::Value val) {
119
+ auto json = env.Global().Get("JSON").As<Napi::Object>();
120
+ auto stringify = json.Get("stringify").As<Napi::Function>();
121
+ auto result = stringify.Call(json, {val});
122
+ if (result.IsString()) {
123
+ return result.As<Napi::String>().Utf8Value();
124
+ }
125
+ if (val.IsUndefined()) return "null";
126
+ return "null";
127
+ }
128
+
129
+ static void validate_napi(const schema_node_ptr& node,
130
+ Napi::Value value,
131
+ Napi::Env env,
132
+ const std::string& path,
133
+ const compiled_schema_internal& ctx,
134
+ std::vector<ata::validation_error>& errors);
135
+
136
+ static void validate_napi(const schema_node_ptr& node,
137
+ Napi::Value value,
138
+ Napi::Env env,
139
+ const std::string& path,
140
+ const compiled_schema_internal& ctx,
141
+ std::vector<ata::validation_error>& errors) {
142
+ if (!node) return;
143
+
144
+ // Boolean schema
145
+ if (node->boolean_schema.has_value()) {
146
+ if (!node->boolean_schema.value()) {
147
+ errors.push_back({ata::error_code::type_mismatch, path,
148
+ "schema is false, no value is valid"});
149
+ }
150
+ return;
151
+ }
152
+
153
+ // $ref
154
+ if (!node->ref.empty()) {
155
+ // First check defs map
156
+ auto it = ctx.defs.find(node->ref);
157
+ if (it != ctx.defs.end()) {
158
+ validate_napi(it->second, value, env, path, ctx, errors);
159
+ return;
160
+ }
161
+ // JSON Pointer resolution from root
162
+ if (node->ref.size() > 1 && node->ref[0] == '#' &&
163
+ node->ref[1] == '/') {
164
+ std::string pointer = node->ref.substr(2);
165
+ schema_node_ptr current = ctx.root;
166
+ bool resolved = true;
167
+ size_t pos = 0;
168
+ while (pos < pointer.size() && current) {
169
+ size_t next = pointer.find('/', pos);
170
+ std::string segment =
171
+ pointer.substr(pos, next == std::string::npos ? next : next - pos);
172
+ std::string key;
173
+ for (size_t i = 0; i < segment.size(); ++i) {
174
+ if (segment[i] == '~' && i + 1 < segment.size()) {
175
+ if (segment[i + 1] == '1') { key += '/'; ++i; }
176
+ else if (segment[i + 1] == '0') { key += '~'; ++i; }
177
+ else key += segment[i];
178
+ } else {
179
+ key += segment[i];
180
+ }
181
+ }
182
+ if (key == "properties" && !current->properties.empty()) {
183
+ pos = (next == std::string::npos) ? pointer.size() : next + 1;
184
+ next = pointer.find('/', pos);
185
+ std::string prop = pointer.substr(
186
+ pos, next == std::string::npos ? next : next - pos);
187
+ auto pit = current->properties.find(prop);
188
+ if (pit != current->properties.end()) current = pit->second;
189
+ else { resolved = false; break; }
190
+ } else if (key == "items" && current->items_schema) {
191
+ current = current->items_schema;
192
+ } else if (key == "$defs" || key == "definitions") {
193
+ pos = (next == std::string::npos) ? pointer.size() : next + 1;
194
+ next = pointer.find('/', pos);
195
+ std::string def = pointer.substr(
196
+ pos, next == std::string::npos ? next : next - pos);
197
+ auto dit = ctx.defs.find("#/" + key + "/" + def);
198
+ if (dit != ctx.defs.end()) current = dit->second;
199
+ else { resolved = false; break; }
200
+ } else if (key == "allOf" || key == "anyOf" || key == "oneOf") {
201
+ pos = (next == std::string::npos) ? pointer.size() : next + 1;
202
+ next = pointer.find('/', pos);
203
+ std::string idx_s = pointer.substr(
204
+ pos, next == std::string::npos ? next : next - pos);
205
+ size_t idx = std::stoul(idx_s);
206
+ auto& vec = (key == "allOf") ? current->all_of
207
+ : (key == "anyOf") ? current->any_of : current->one_of;
208
+ if (idx < vec.size()) current = vec[idx];
209
+ else { resolved = false; break; }
210
+ } else if (key == "not" && current->not_schema) {
211
+ current = current->not_schema;
212
+ } else if (key == "if" && current->if_schema) {
213
+ current = current->if_schema;
214
+ } else if (key == "then" && current->then_schema) {
215
+ current = current->then_schema;
216
+ } else if (key == "else" && current->else_schema) {
217
+ current = current->else_schema;
218
+ } else if (key == "additionalProperties" &&
219
+ current->additional_properties_schema) {
220
+ current = current->additional_properties_schema;
221
+ } else if (key == "prefixItems") {
222
+ pos = (next == std::string::npos) ? pointer.size() : next + 1;
223
+ next = pointer.find('/', pos);
224
+ std::string idx_s = pointer.substr(
225
+ pos, next == std::string::npos ? next : next - pos);
226
+ size_t idx = std::stoul(idx_s);
227
+ if (idx < current->prefix_items.size()) current = current->prefix_items[idx];
228
+ else { resolved = false; break; }
229
+ } else { resolved = false; break; }
230
+ pos = (next == std::string::npos) ? pointer.size() : next + 1;
231
+ }
232
+ if (resolved && current) {
233
+ validate_napi(current, value, env, path, ctx, errors);
234
+ return;
235
+ }
236
+ }
237
+ if (node->ref == "#" && ctx.root) {
238
+ validate_napi(ctx.root, value, env, path, ctx, errors);
239
+ return;
240
+ }
241
+ errors.push_back({ata::error_code::ref_not_found, path,
242
+ "cannot resolve $ref: " + node->ref});
243
+ return;
244
+ }
245
+
246
+ auto actual_type = napi_type_of(value);
247
+
248
+ // type
249
+ if (!node->types.empty()) {
250
+ bool match = false;
251
+ for (const auto& t : node->types) {
252
+ if (napi_type_matches(value, t)) {
253
+ match = true;
254
+ break;
255
+ }
256
+ }
257
+ if (!match) {
258
+ std::string expected;
259
+ for (size_t i = 0; i < node->types.size(); ++i) {
260
+ if (i > 0) expected += ", ";
261
+ expected += node->types[i];
262
+ }
263
+ errors.push_back({ata::error_code::type_mismatch, path,
264
+ "expected type " + expected + ", got " + actual_type});
265
+ }
266
+ }
267
+
268
+ // enum
269
+ if (node->enum_values_raw.has_value()) {
270
+ std::string val_json = napi_to_json(env, value);
271
+ // Parse enum from raw and compare
272
+ bool found = false;
273
+ // We need to compare against each element in the enum array
274
+ // The enum_values_raw is a JSON array string like [1,2,3]
275
+ // We'll use JSON.parse in JS to handle this
276
+ auto json_obj = env.Global().Get("JSON").As<Napi::Object>();
277
+ auto parse_fn = json_obj.Get("parse").As<Napi::Function>();
278
+ auto enum_arr = parse_fn.Call(json_obj,
279
+ {Napi::String::New(env, node->enum_values_raw.value())});
280
+ if (enum_arr.IsArray()) {
281
+ auto arr = enum_arr.As<Napi::Array>();
282
+ for (uint32_t i = 0; i < arr.Length(); ++i) {
283
+ std::string elem_json = napi_to_json(env, arr.Get(i));
284
+ if (elem_json == val_json) {
285
+ found = true;
286
+ break;
287
+ }
288
+ }
289
+ }
290
+ if (!found) {
291
+ errors.push_back({ata::error_code::enum_mismatch, path,
292
+ "value not in enum"});
293
+ }
294
+ }
295
+
296
+ // const
297
+ if (node->const_value_raw.has_value()) {
298
+ std::string val_json = napi_to_json(env, value);
299
+ if (val_json != node->const_value_raw.value()) {
300
+ errors.push_back({ata::error_code::const_mismatch, path,
301
+ "value does not match const"});
302
+ }
303
+ }
304
+
305
+ // Numeric validations
306
+ if (actual_type == "integer" || actual_type == "number") {
307
+ double v = value.As<Napi::Number>().DoubleValue();
308
+ if (node->minimum.has_value() && v < node->minimum.value()) {
309
+ errors.push_back({ata::error_code::minimum_violation, path,
310
+ "value " + std::to_string(v) + " < minimum " +
311
+ std::to_string(node->minimum.value())});
312
+ }
313
+ if (node->maximum.has_value() && v > node->maximum.value()) {
314
+ errors.push_back({ata::error_code::maximum_violation, path,
315
+ "value " + std::to_string(v) + " > maximum " +
316
+ std::to_string(node->maximum.value())});
317
+ }
318
+ if (node->exclusive_minimum.has_value() &&
319
+ v <= node->exclusive_minimum.value()) {
320
+ errors.push_back({ata::error_code::exclusive_minimum_violation, path,
321
+ "value must be > " +
322
+ std::to_string(node->exclusive_minimum.value())});
323
+ }
324
+ if (node->exclusive_maximum.has_value() &&
325
+ v >= node->exclusive_maximum.value()) {
326
+ errors.push_back({ata::error_code::exclusive_maximum_violation, path,
327
+ "value must be < " +
328
+ std::to_string(node->exclusive_maximum.value())});
329
+ }
330
+ if (node->multiple_of.has_value()) {
331
+ double divisor = node->multiple_of.value();
332
+ double rem = std::fmod(v, divisor);
333
+ if (std::abs(rem) > 1e-8 && std::abs(rem - divisor) > 1e-8) {
334
+ errors.push_back({ata::error_code::multiple_of_violation, path,
335
+ "value not a multiple of " +
336
+ std::to_string(node->multiple_of.value())});
337
+ }
338
+ }
339
+ }
340
+
341
+ // String validations
342
+ if (actual_type == "string") {
343
+ std::string sv = value.As<Napi::String>().Utf8Value();
344
+ uint64_t len = utf8_codepoint_length(sv);
345
+
346
+ if (node->min_length.has_value() && len < node->min_length.value()) {
347
+ errors.push_back({ata::error_code::min_length_violation, path,
348
+ "string length " + std::to_string(len) +
349
+ " < minLength " +
350
+ std::to_string(node->min_length.value())});
351
+ }
352
+ if (node->max_length.has_value() && len > node->max_length.value()) {
353
+ errors.push_back({ata::error_code::max_length_violation, path,
354
+ "string length " + std::to_string(len) +
355
+ " > maxLength " +
356
+ std::to_string(node->max_length.value())});
357
+ }
358
+ if (node->compiled_pattern) {
359
+ if (!std::regex_search(sv, *node->compiled_pattern)) {
360
+ errors.push_back({ata::error_code::pattern_mismatch, path,
361
+ "string does not match pattern: " +
362
+ node->pattern.value()});
363
+ }
364
+ }
365
+ if (node->format.has_value()) {
366
+ const auto& fmt = node->format.value();
367
+ bool format_ok = true;
368
+ if (fmt == "email") {
369
+ static const std::regex email_re(
370
+ R"([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})");
371
+ format_ok = std::regex_match(sv, email_re);
372
+ } else if (fmt == "uri" || fmt == "uri-reference") {
373
+ static const std::regex uri_re(R"([a-zA-Z][a-zA-Z0-9+\-.]*:.+)");
374
+ format_ok = std::regex_match(sv, uri_re);
375
+ } else if (fmt == "date") {
376
+ static const std::regex date_re(R"(\d{4}-\d{2}-\d{2})");
377
+ format_ok = std::regex_match(sv, date_re);
378
+ } else if (fmt == "date-time") {
379
+ static const std::regex dt_re(
380
+ R"(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+\-]\d{2}:\d{2})?)");
381
+ format_ok = std::regex_match(sv, dt_re);
382
+ } else if (fmt == "time") {
383
+ static const std::regex time_re(
384
+ R"(\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+\-]\d{2}:\d{2})?)");
385
+ format_ok = std::regex_match(sv, time_re);
386
+ } else if (fmt == "ipv4") {
387
+ static const std::regex ipv4_re(
388
+ R"((\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3}))");
389
+ format_ok = std::regex_match(sv, ipv4_re);
390
+ } else if (fmt == "ipv6") {
391
+ format_ok = sv.find(':') != std::string::npos;
392
+ } else if (fmt == "uuid") {
393
+ static const std::regex uuid_re(
394
+ R"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})");
395
+ format_ok = std::regex_match(sv, uuid_re);
396
+ } else if (fmt == "hostname") {
397
+ static const std::regex host_re(
398
+ R"([a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*)");
399
+ format_ok = std::regex_match(sv, host_re);
400
+ }
401
+ if (!format_ok) {
402
+ errors.push_back({ata::error_code::format_mismatch, path,
403
+ "string does not match format: " + fmt});
404
+ }
405
+ }
406
+ }
407
+
408
+ // Array validations
409
+ if (actual_type == "array" && value.IsArray()) {
410
+ auto arr = value.As<Napi::Array>();
411
+ uint32_t arr_size = arr.Length();
412
+
413
+ if (node->min_items.has_value() && arr_size < node->min_items.value()) {
414
+ errors.push_back({ata::error_code::min_items_violation, path,
415
+ "array has " + std::to_string(arr_size) +
416
+ " items, minimum " +
417
+ std::to_string(node->min_items.value())});
418
+ }
419
+ if (node->max_items.has_value() && arr_size > node->max_items.value()) {
420
+ errors.push_back({ata::error_code::max_items_violation, path,
421
+ "array has " + std::to_string(arr_size) +
422
+ " items, maximum " +
423
+ std::to_string(node->max_items.value())});
424
+ }
425
+
426
+ if (node->unique_items) {
427
+ std::set<std::string> seen;
428
+ bool has_dup = false;
429
+ for (uint32_t i = 0; i < arr_size; ++i) {
430
+ auto s = napi_to_json(env, arr.Get(i));
431
+ if (!seen.insert(s).second) {
432
+ has_dup = true;
433
+ break;
434
+ }
435
+ }
436
+ if (has_dup) {
437
+ errors.push_back({ata::error_code::unique_items_violation, path,
438
+ "array contains duplicate items"});
439
+ }
440
+ }
441
+
442
+ // prefixItems + items (Draft 2020-12 semantics)
443
+ for (uint32_t i = 0; i < arr_size; ++i) {
444
+ if (i < node->prefix_items.size()) {
445
+ validate_napi(node->prefix_items[i], arr.Get(i), env,
446
+ path + "/" + std::to_string(i), ctx, errors);
447
+ } else if (node->items_schema) {
448
+ validate_napi(node->items_schema, arr.Get(i), env,
449
+ path + "/" + std::to_string(i), ctx, errors);
450
+ }
451
+ }
452
+ }
453
+
454
+ // Object validations
455
+ if (actual_type == "object" && value.IsObject() && !value.IsArray()) {
456
+ auto obj = value.As<Napi::Object>();
457
+ auto keys = obj.GetPropertyNames();
458
+ uint32_t prop_count = keys.Length();
459
+
460
+ if (node->min_properties.has_value() &&
461
+ prop_count < node->min_properties.value()) {
462
+ errors.push_back({ata::error_code::min_properties_violation, path,
463
+ "object has " + std::to_string(prop_count) +
464
+ " properties, minimum " +
465
+ std::to_string(node->min_properties.value())});
466
+ }
467
+ if (node->max_properties.has_value() &&
468
+ prop_count > node->max_properties.value()) {
469
+ errors.push_back({ata::error_code::max_properties_violation, path,
470
+ "object has " + std::to_string(prop_count) +
471
+ " properties, maximum " +
472
+ std::to_string(node->max_properties.value())});
473
+ }
474
+
475
+ // required — use HasOwnProperty to avoid prototype pollution
476
+ for (const auto& req : node->required) {
477
+ bool has = obj.HasOwnProperty(req);
478
+ if (!has) {
479
+ errors.push_back({ata::error_code::required_property_missing, path,
480
+ "missing required property: " + req});
481
+ }
482
+ }
483
+
484
+ // properties + patternProperties + additionalProperties
485
+ for (uint32_t i = 0; i < prop_count; ++i) {
486
+ std::string key_str = keys.Get(i).As<Napi::String>().Utf8Value();
487
+ Napi::Value val = obj.Get(key_str);
488
+ bool matched = false;
489
+
490
+ auto it = node->properties.find(key_str);
491
+ if (it != node->properties.end()) {
492
+ validate_napi(it->second, val, env, path + "/" + key_str, ctx, errors);
493
+ matched = true;
494
+ }
495
+
496
+ for (const auto& [pat, pat_schema] : node->pattern_properties) {
497
+ try {
498
+ std::regex re(pat);
499
+ if (std::regex_search(key_str, re)) {
500
+ validate_napi(pat_schema, val, env, path + "/" + key_str, ctx,
501
+ errors);
502
+ matched = true;
503
+ }
504
+ } catch (...) {
505
+ }
506
+ }
507
+
508
+ if (!matched) {
509
+ if (node->additional_properties_bool.has_value() &&
510
+ !node->additional_properties_bool.value()) {
511
+ errors.push_back(
512
+ {ata::error_code::additional_property_not_allowed, path,
513
+ "additional property not allowed: " + key_str});
514
+ } else if (node->additional_properties_schema) {
515
+ validate_napi(node->additional_properties_schema, val, env,
516
+ path + "/" + key_str, ctx, errors);
517
+ }
518
+ }
519
+ }
520
+ }
521
+
522
+ // allOf
523
+ if (!node->all_of.empty()) {
524
+ for (const auto& sub : node->all_of) {
525
+ std::vector<ata::validation_error> sub_errors;
526
+ validate_napi(sub, value, env, path, ctx, sub_errors);
527
+ if (!sub_errors.empty()) {
528
+ errors.push_back({ata::error_code::all_of_failed, path,
529
+ "allOf subschema failed"});
530
+ errors.insert(errors.end(), sub_errors.begin(), sub_errors.end());
531
+ }
532
+ }
533
+ }
534
+
535
+ // anyOf
536
+ if (!node->any_of.empty()) {
537
+ bool any_valid = false;
538
+ for (const auto& sub : node->any_of) {
539
+ std::vector<ata::validation_error> sub_errors;
540
+ validate_napi(sub, value, env, path, ctx, sub_errors);
541
+ if (sub_errors.empty()) {
542
+ any_valid = true;
543
+ break;
544
+ }
545
+ }
546
+ if (!any_valid) {
547
+ errors.push_back({ata::error_code::any_of_failed, path,
548
+ "no anyOf subschema matched"});
549
+ }
550
+ }
551
+
552
+ // oneOf
553
+ if (!node->one_of.empty()) {
554
+ int match_count = 0;
555
+ for (const auto& sub : node->one_of) {
556
+ std::vector<ata::validation_error> sub_errors;
557
+ validate_napi(sub, value, env, path, ctx, sub_errors);
558
+ if (sub_errors.empty()) ++match_count;
559
+ }
560
+ if (match_count != 1) {
561
+ errors.push_back({ata::error_code::one_of_failed, path,
562
+ "expected exactly one oneOf match, got " +
563
+ std::to_string(match_count)});
564
+ }
565
+ }
566
+
567
+ // not
568
+ if (node->not_schema) {
569
+ std::vector<ata::validation_error> sub_errors;
570
+ validate_napi(node->not_schema, value, env, path, ctx, sub_errors);
571
+ if (sub_errors.empty()) {
572
+ errors.push_back({ata::error_code::not_failed, path,
573
+ "value should not match 'not' schema"});
574
+ }
575
+ }
576
+
577
+ // if/then/else
578
+ if (node->if_schema) {
579
+ std::vector<ata::validation_error> if_errors;
580
+ validate_napi(node->if_schema, value, env, path, ctx, if_errors);
581
+ if (if_errors.empty()) {
582
+ if (node->then_schema) {
583
+ validate_napi(node->then_schema, value, env, path, ctx, errors);
584
+ }
585
+ } else {
586
+ if (node->else_schema) {
587
+ validate_napi(node->else_schema, value, env, path, ctx, errors);
588
+ }
589
+ }
590
+ }
591
+ }
592
+
593
+ // ============================================================================
594
+ // N-API Binding
595
+ // ============================================================================
596
+
597
+ static Napi::Object make_result(Napi::Env env,
598
+ const ata::validation_result& result) {
599
+ Napi::Object obj = Napi::Object::New(env);
600
+ obj.Set("valid", Napi::Boolean::New(env, result.valid));
601
+ Napi::Array errors = Napi::Array::New(env, result.errors.size());
602
+ for (size_t i = 0; i < result.errors.size(); ++i) {
603
+ Napi::Object err = Napi::Object::New(env);
604
+ err.Set("code",
605
+ Napi::Number::New(env, static_cast<int>(result.errors[i].code)));
606
+ err.Set("path", Napi::String::New(env, result.errors[i].path));
607
+ err.Set("message", Napi::String::New(env, result.errors[i].message));
608
+ errors[i] = err;
609
+ }
610
+ obj.Set("errors", errors);
611
+ return obj;
612
+ }
613
+
614
+ class CompiledSchema : public Napi::ObjectWrap<CompiledSchema> {
615
+ public:
616
+ static Napi::Object Init(Napi::Env env, Napi::Object exports) {
617
+ Napi::Function func = DefineClass(
618
+ env, "CompiledSchema",
619
+ {InstanceMethod("validate", &CompiledSchema::Validate),
620
+ InstanceMethod("validateJSON", &CompiledSchema::ValidateJSON),
621
+ InstanceMethod("validateDirect", &CompiledSchema::ValidateDirect)});
622
+ auto* constructor = new Napi::FunctionReference();
623
+ *constructor = Napi::Persistent(func);
624
+ env.SetInstanceData(constructor);
625
+ exports.Set("CompiledSchema", func);
626
+ return exports;
627
+ }
628
+
629
+ CompiledSchema(const Napi::CallbackInfo& info)
630
+ : Napi::ObjectWrap<CompiledSchema>(info) {
631
+ Napi::Env env = info.Env();
632
+ if (info.Length() < 1 || !info[0].IsString()) {
633
+ Napi::TypeError::New(env, "Schema JSON string expected")
634
+ .ThrowAsJavaScriptException();
635
+ return;
636
+ }
637
+ std::string schema_json = info[0].As<Napi::String>().Utf8Value();
638
+ schema_ = ata::compile(schema_json);
639
+ if (!schema_) {
640
+ Napi::Error::New(env, "Failed to compile schema")
641
+ .ThrowAsJavaScriptException();
642
+ return;
643
+ }
644
+ // Store internal pointers for direct validation
645
+ auto* impl = reinterpret_cast<compiled_schema_internal*>(schema_.impl.get());
646
+ internal_root_ = impl->root;
647
+ internal_defs_ = &impl->defs;
648
+ }
649
+
650
+ // Validate any JS value directly via V8 traversal (no stringify needed)
651
+ Napi::Value Validate(const Napi::CallbackInfo& info) {
652
+ Napi::Env env = info.Env();
653
+ if (info.Length() < 1) {
654
+ Napi::TypeError::New(env, "Argument expected")
655
+ .ThrowAsJavaScriptException();
656
+ return env.Undefined();
657
+ }
658
+ return ValidateDirectImpl(env, info[0]);
659
+ }
660
+
661
+ // Validate via JSON string (simdjson parse path)
662
+ Napi::Value ValidateJSON(const Napi::CallbackInfo& info) {
663
+ Napi::Env env = info.Env();
664
+ if (info.Length() < 1 || !info[0].IsString()) {
665
+ Napi::TypeError::New(env, "JSON string expected")
666
+ .ThrowAsJavaScriptException();
667
+ return env.Undefined();
668
+ }
669
+ std::string json = info[0].As<Napi::String>().Utf8Value();
670
+ auto result = ata::validate(schema_, json);
671
+ return make_result(env, result);
672
+ }
673
+
674
+ // Explicit direct validation (always V8 traversal, never stringify)
675
+ Napi::Value ValidateDirect(const Napi::CallbackInfo& info) {
676
+ Napi::Env env = info.Env();
677
+ if (info.Length() < 1) {
678
+ Napi::TypeError::New(env, "Argument expected")
679
+ .ThrowAsJavaScriptException();
680
+ return env.Undefined();
681
+ }
682
+ return ValidateDirectImpl(env, info[0]);
683
+ }
684
+
685
+ private:
686
+ Napi::Value ValidateDirectImpl(Napi::Env env, Napi::Value value) {
687
+ compiled_schema_internal ctx;
688
+ ctx.root = internal_root_;
689
+ ctx.defs = *internal_defs_;
690
+
691
+ std::vector<ata::validation_error> errors;
692
+ validate_napi(internal_root_, value, env, "", ctx, errors);
693
+
694
+ ata::validation_result result{errors.empty(), std::move(errors)};
695
+ return make_result(env, result);
696
+ }
697
+
698
+ ata::schema_ref schema_;
699
+ schema_node_ptr internal_root_;
700
+ const std::unordered_map<std::string, schema_node_ptr>* internal_defs_ =
701
+ nullptr;
702
+ };
703
+
704
+ // One-shot validate function (always V8 direct path)
705
+ Napi::Value ValidateOneShot(const Napi::CallbackInfo& info) {
706
+ Napi::Env env = info.Env();
707
+ if (info.Length() < 2 || !info[0].IsString()) {
708
+ Napi::TypeError::New(env, "Expected (schemaJson, data)")
709
+ .ThrowAsJavaScriptException();
710
+ return env.Undefined();
711
+ }
712
+ std::string schema_json = info[0].As<Napi::String>().Utf8Value();
713
+
714
+ auto schema = ata::compile(schema_json);
715
+ if (!schema) {
716
+ Napi::Error::New(env, "Failed to compile schema")
717
+ .ThrowAsJavaScriptException();
718
+ return env.Undefined();
719
+ }
720
+ auto* impl =
721
+ reinterpret_cast<compiled_schema_internal*>(schema.impl.get());
722
+ compiled_schema_internal ctx;
723
+ ctx.root = impl->root;
724
+ ctx.defs = impl->defs;
725
+
726
+ std::vector<ata::validation_error> errors;
727
+ validate_napi(impl->root, info[1], env, "", ctx, errors);
728
+
729
+ ata::validation_result result{errors.empty(), std::move(errors)};
730
+ return make_result(env, result);
731
+ }
732
+
733
+ Napi::Value GetVersion(const Napi::CallbackInfo& info) {
734
+ return Napi::String::New(info.Env(), std::string(ata::version()));
735
+ }
736
+
737
+ Napi::Object Init(Napi::Env env, Napi::Object exports) {
738
+ CompiledSchema::Init(env, exports);
739
+ exports.Set("validate", Napi::Function::New(env, ValidateOneShot));
740
+ exports.Set("version", Napi::Function::New(env, GetVersion));
741
+ return exports;
742
+ }
743
+
744
+ NODE_API_MODULE(ata, Init)