castrum 0.9.2 → 0.9.4
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.
- package/Cargo.lock +2259 -0
- package/Cargo.toml +138 -0
- package/README.md +6 -0
- package/build.rs +5 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1348 -1295
- package/dist/index.js +4307 -3776
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +16 -0
- package/docs/FFI_BUN_GUIDE.md +89 -6
- package/docs/REPO_MAP.md +2 -2
- package/index.ts +19 -25
- package/package.json +9 -1
- package/rust/crypto/aead.rs +378 -0
- package/rust/crypto/argon2.rs +265 -0
- package/rust/crypto/base64.rs +420 -0
- package/rust/crypto/bcrypt.rs +89 -0
- package/rust/crypto/cookie_sign.rs +169 -0
- package/rust/crypto/csrf.rs +106 -0
- package/rust/crypto/ed25519.rs +215 -0
- package/rust/crypto/hashing.rs +140 -0
- package/rust/crypto/hmac_sha256.rs +88 -0
- package/rust/crypto/jwt/api.rs +286 -0
- package/rust/crypto/jwt/mod.rs +22 -0
- package/rust/crypto/jwt/tests.rs +312 -0
- package/rust/crypto/jwt/token.rs +360 -0
- package/rust/crypto/mod.rs +27 -0
- package/rust/crypto/pbkdf2.rs +96 -0
- package/rust/crypto/random_token.rs +85 -0
- package/rust/crypto/session.rs +222 -0
- package/rust/ffi/crypto.rs +708 -0
- package/rust/ffi/hashing.rs +285 -0
- package/rust/ffi/http.rs +752 -0
- package/rust/ffi/ingress.rs +303 -0
- package/rust/ffi/json.rs +517 -0
- package/rust/ffi/jwt.rs +419 -0
- package/rust/ffi/metrics.rs +373 -0
- package/rust/ffi/mod.rs +68 -0
- package/rust/ffi/payload.rs +275 -0
- package/rust/ffi/probe.rs +60 -0
- package/rust/ffi/rate_limit.rs +77 -0
- package/rust/ffi/route.rs +85 -0
- package/rust/ffi/tests.rs +2333 -0
- package/rust/ffi/util.rs +152 -0
- package/rust/ffi/validators.rs +123 -0
- package/rust/http/accept.rs +606 -0
- package/rust/http/cookie_parser.rs +140 -0
- package/rust/http/etag.rs +289 -0
- package/rust/http/form.rs +132 -0
- package/rust/http/headers.rs +267 -0
- package/rust/http/http_date.rs +286 -0
- package/rust/http/http_parser.rs +252 -0
- package/rust/http/media_type.rs +355 -0
- package/rust/http/method.rs +200 -0
- package/rust/http/mime_lookup.rs +130 -0
- package/rust/http/mod.rs +32 -0
- package/rust/http/multipart.rs +685 -0
- package/rust/http/query_parser.rs +180 -0
- package/rust/http/url_codec.rs +296 -0
- package/rust/http/url_join.rs +324 -0
- package/rust/ingress/api.rs +355 -0
- package/rust/ingress/cors.rs +403 -0
- package/rust/ingress/ingress_constants.rs +209 -0
- package/rust/ingress/ip_trust.rs +312 -0
- package/rust/ingress/mod.rs +48 -0
- package/rust/ingress/native_route.rs +779 -0
- package/rust/ingress/options.rs +176 -0
- package/rust/ingress/output.rs +406 -0
- package/rust/ingress/packed.rs +191 -0
- package/rust/ingress/pipeline.rs +534 -0
- package/rust/ingress/proxy.rs +138 -0
- package/rust/ingress/rate_limit.rs +538 -0
- package/rust/ingress/terminal.rs +114 -0
- package/rust/ingress/tests.rs +566 -0
- package/rust/ingress/time.rs +70 -0
- package/rust/json/fast_schema/capture.rs +336 -0
- package/rust/json/fast_schema/compile.rs +556 -0
- package/rust/json/fast_schema/cursor.rs +368 -0
- package/rust/json/fast_schema/email.rs +282 -0
- package/rust/json/fast_schema/errors.rs +247 -0
- package/rust/json/fast_schema/mod.rs +57 -0
- package/rust/json/fast_schema/tests.rs +1143 -0
- package/rust/json/fast_schema/types.rs +140 -0
- package/rust/json/fast_schema/validate.rs +931 -0
- package/rust/json/json_ops.rs +121 -0
- package/rust/json/json_schema.rs +837 -0
- package/rust/json/json_ser.rs +881 -0
- package/rust/json/mod.rs +19 -0
- package/rust/json/napi_marshal.rs +318 -0
- package/rust/json/patch/api.rs +60 -0
- package/rust/json/patch/engine.rs +153 -0
- package/rust/json/patch/mod.rs +47 -0
- package/rust/json/patch/ops.rs +270 -0
- package/rust/json/patch/pointer.rs +49 -0
- package/rust/json/patch/tests.rs +499 -0
- package/rust/lib.rs +72 -0
- package/rust/metrics/api.rs +148 -0
- package/rust/metrics/mod.rs +21 -0
- package/rust/metrics/registry.rs +961 -0
- package/rust/panic_safety.rs +40 -0
- package/rust/payload/compress.rs +512 -0
- package/rust/payload/mod.rs +13 -0
- package/rust/payload/sse.rs +214 -0
- package/rust/payload/template.rs +253 -0
- package/rust/payload/websocket.rs +69 -0
- package/rust/payload/ws_frames.rs +354 -0
- package/rust/proptest_suite.rs +139 -0
- package/rust/selection.rs +95 -0
- package/rust/src/bin/native-bench.rs +495 -0
- package/rust/test_support.rs +71 -0
- package/rust/util/batch/api.rs +266 -0
- package/rust/util/batch/core.rs +473 -0
- package/rust/util/batch/mod.rs +23 -0
- package/rust/util/batch/tests.rs +445 -0
- package/rust/util/batch_core.rs +184 -0
- package/rust/util/bytes.rs +407 -0
- package/rust/util/mod.rs +34 -0
- package/rust/util/packed.rs +473 -0
- package/rust/util/text.rs +130 -0
- package/rust/util/threadpool.rs +128 -0
- package/rust/util/validation.rs +419 -0
- package/rust-toolchain.toml +17 -0
- package/scripts/postinstall.mjs +199 -0
- package/src/ingress/constants.ts +1 -1
- package/src/ingress/context.ts +2 -2
- package/src/ingress/decode/baked-result.ts +13 -13
- package/src/ingress/decode/fast-result.ts +17 -17
- package/src/ingress/errors.ts +7 -7
- package/src/ingress/headers/baked-templates.ts +1 -1
- package/src/ingress/headers/fast-templates.ts +3 -3
- package/src/ingress/index.ts +1 -1
- package/src/ingress/native-route.ts +9 -5
- package/src/ingress/options.ts +1 -1
- package/src/ingress/packing/gather-raw-headers.ts +1 -1
- package/src/ingress/packing/route-wire.ts +1 -5
- package/src/ingress/response/error-bodies.ts +5 -5
- package/src/ingress/response/terminal.ts +1 -1
- package/src/ingress/routes/echo.ts +4 -4
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +2 -2
- package/src/ingress/routes/native.ts +2 -1
- package/src/ingress/routes/options.ts +1 -1
- package/src/ingress/routes/read.ts +2 -2
- package/src/ingress/server.ts +10 -6
- package/src/ingress/status.ts +7 -7
- package/src/integration/batch.ts +1 -1
- package/src/integration/index.ts +9 -9
- package/src/integration/pipeline.ts +6 -6
- package/src/integration/websocket.ts +2 -2
- package/src/loader/index.ts +1 -5
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs.ts +104 -8
- package/src/native/ffi/build/compress.ts +14 -10
- package/src/native/ffi/build/instances.ts +40 -3
- package/src/native/ffi/build/metrics.ts +224 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/util.ts +14 -1
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +10 -3
- package/src/native/ffi/types.ts +111 -9
- package/src/native/ffi.ts +87 -1
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +2 -2
- package/src/native/types/instances.ts +34 -0
- package/src/native/types.ts +11 -0
- package/src/runtime/codec.ts +53 -9
- package/src/runtime/types.ts +7 -0
- package/src/rust-ffi/batch/build.ts +16 -9
- package/src/rust-ffi/index.ts +7 -6
- package/src/rust-ffi/options.ts +1 -3
- package/src/rust-ffi/proven.ts +3 -3
- package/src/rust-ffi/scalar/crypto.ts +1 -1
- package/src/rust-ffi/scalar/factories.ts +73 -0
- package/src/rust-ffi/scalar/hashing.ts +1 -1
- package/src/rust-ffi/scalar/http.ts +17 -11
- package/src/rust-ffi/scalar/index.ts +2 -0
- package/src/rust-ffi/scalar/interface.ts +20 -0
- package/src/rust-ffi/scalar/payload.ts +11 -3
- package/src/rust-ffi/scalar/validation.ts +121 -0
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +5 -4
- package/src/shared/codec.ts +18 -3
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
// rust/json/json_schema.rs — SchemaValidator napi class (fast + DOM fallback + batch).
|
|
2
|
+
|
|
3
|
+
use napi::bindgen_prelude::*;
|
|
4
|
+
use napi_derive::napi;
|
|
5
|
+
use serde_json::Value;
|
|
6
|
+
use std::sync::Arc;
|
|
7
|
+
|
|
8
|
+
use crate::json::fast_schema;
|
|
9
|
+
use crate::util::packed::PackedIter;
|
|
10
|
+
use crate::util::should_parallelize;
|
|
11
|
+
|
|
12
|
+
#[napi]
|
|
13
|
+
pub struct SchemaValidator {
|
|
14
|
+
schema: Arc<jsonschema::Validator>,
|
|
15
|
+
/// Zero-DOM fast path, engaged when the schema only uses the supported
|
|
16
|
+
/// keyword subset; `None` falls back to the `jsonschema` crate DOM path.
|
|
17
|
+
fast: Option<Arc<fast_schema::FastNode>>,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// C-ABI support: validate a document against the compiled schema.
|
|
21
|
+
///
|
|
22
|
+
/// # Safety
|
|
23
|
+
/// `p` must be a valid `*const SchemaValidator` from `inner_ptr`, alive for
|
|
24
|
+
/// the call (the JS wrapper holds the napi instance).
|
|
25
|
+
pub(crate) unsafe fn schema_validator_validate_core(p: *const SchemaValidator, doc: &[u8]) -> bool {
|
|
26
|
+
let this = &*p;
|
|
27
|
+
this.validate_doc(doc)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Fused wire-level validation: parse a RAW query string into JSON (the same
|
|
31
|
+
/// `query_to_json_into_slice` the ingress hot path uses — `+`/`%XX` decoded,
|
|
32
|
+
/// duplicate keys last-wins) and draft-07-validate it against the compiled
|
|
33
|
+
/// schema in ONE call. `false` = malformed query OR schema violation; callers
|
|
34
|
+
/// fall back to the detailed path for exact errors on failure.
|
|
35
|
+
///
|
|
36
|
+
/// # Safety
|
|
37
|
+
/// `p` must be a valid `*const SchemaValidator` from `inner_ptr`, alive for
|
|
38
|
+
/// the call.
|
|
39
|
+
pub(crate) unsafe fn schema_validate_query_core(p: *const SchemaValidator, qs: &[u8]) -> bool {
|
|
40
|
+
let this = &*p;
|
|
41
|
+
// json_ser's JSON writers need headroom beyond the input length (escape
|
|
42
|
+
// expansion + per-pair overhead); queries are tiny so this is cheap.
|
|
43
|
+
let cap = qs.len() * 12 + 256;
|
|
44
|
+
let mut json = vec![0u8; cap];
|
|
45
|
+
match crate::json::json_ser::query_to_json_into_slice(qs, &mut json, 512) {
|
|
46
|
+
Ok(w) => this.validate_doc(&json[..w]),
|
|
47
|
+
Err(_) => false,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Fused cookie-header variant: parse (trim + DQUOTE-unwrap, NO percent-decode
|
|
52
|
+
/// — cookie semantics) into JSON and validate. Same verdict contract.
|
|
53
|
+
///
|
|
54
|
+
/// # Safety
|
|
55
|
+
/// See [`schema_validate_query_core`].
|
|
56
|
+
pub(crate) unsafe fn schema_validate_cookie_core(p: *const SchemaValidator, header: &[u8]) -> bool {
|
|
57
|
+
let this = &*p;
|
|
58
|
+
let cap = header.len() * 12 + 256;
|
|
59
|
+
let mut json = vec![0u8; cap];
|
|
60
|
+
match crate::json::json_ser::cookie_json_into_slice(header, &mut json, 128) {
|
|
61
|
+
Ok(w) => this.validate_doc(&json[..w]),
|
|
62
|
+
Err(_) => false,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Total payload bytes in a packed buffer (`[u32 count] {[u32 len][bytes]}`),
|
|
67
|
+
/// computed in O(1) from the buffer length + header count. Used only as the
|
|
68
|
+
/// rayon-parallelism heuristic input, so trailing bytes just over-count harmlessly.
|
|
69
|
+
#[inline(always)]
|
|
70
|
+
fn packed_payload_bytes(data: &[u8], count: usize) -> usize {
|
|
71
|
+
data.len().saturating_sub(4).saturating_sub(count * 4)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// A single JSON Schema validation error (fast path + DOM fallback).
|
|
75
|
+
#[napi(object)]
|
|
76
|
+
pub struct SchemaError {
|
|
77
|
+
/// RFC 6901 JSON pointer to the failing instance value ("" = root).
|
|
78
|
+
pub instance_path: String,
|
|
79
|
+
/// JSON pointer into the schema at the failing keyword.
|
|
80
|
+
pub schema_path: String,
|
|
81
|
+
/// The failing keyword (e.g. "type", "pattern", "required").
|
|
82
|
+
pub keyword: String,
|
|
83
|
+
/// Human-readable failure message.
|
|
84
|
+
pub message: String,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
impl From<crate::json::fast_schema::SchemaError> for SchemaError {
|
|
88
|
+
fn from(e: crate::json::fast_schema::SchemaError) -> Self {
|
|
89
|
+
Self {
|
|
90
|
+
instance_path: e.instance_path,
|
|
91
|
+
schema_path: e.schema_path,
|
|
92
|
+
keyword: e.keyword,
|
|
93
|
+
message: e.message,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// A single derived value captured during one-pass validation.
|
|
99
|
+
#[napi(object)]
|
|
100
|
+
pub struct JsonDeriveValue {
|
|
101
|
+
/// `"int" | "number" | "string" | "bool" | "null"`.
|
|
102
|
+
pub kind: String,
|
|
103
|
+
pub int: Option<i64>,
|
|
104
|
+
pub number: Option<f64>,
|
|
105
|
+
pub text: Option<String>,
|
|
106
|
+
pub boolean: Option<bool>,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// Result of a one-pass `validate + derive`.
|
|
110
|
+
#[napi(object)]
|
|
111
|
+
pub struct JsonDeriveResult {
|
|
112
|
+
/// `true` when the document is schema-valid; `false` → caller rejects.
|
|
113
|
+
pub ok: bool,
|
|
114
|
+
/// One entry per requested path (`None` = path absent from the document).
|
|
115
|
+
pub values: Vec<Option<JsonDeriveValue>>,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#[napi]
|
|
119
|
+
impl SchemaValidator {
|
|
120
|
+
#[napi(constructor)]
|
|
121
|
+
pub fn new(schema_bytes: Uint8Array) -> Result<Self> {
|
|
122
|
+
let schema_value: Value = sonic_rs::from_slice(schema_bytes.as_ref())
|
|
123
|
+
.map_err(|e| Error::new(Status::InvalidArg, format!("Schema JSON error: {}", e)))?;
|
|
124
|
+
|
|
125
|
+
// `should_validate_formats(true)`: jsonschema 0.48 disables format
|
|
126
|
+
// validation by default (validator_for uses default options), which
|
|
127
|
+
// silently ignored `format:` keywords (e.g. the ingress USER_SCHEMA's
|
|
128
|
+
// `format: "email"`). The zero-DOM fast path now honors `format:
|
|
129
|
+
// "email"`, so the DOM fallback must validate formats too — otherwise
|
|
130
|
+
// the two diverge on every format-using schema.
|
|
131
|
+
//
|
|
132
|
+
// `.with_draft(Draft7)`: the fast path implements draft-07 semantics,
|
|
133
|
+
// and jsonschema 0.48's DEFAULT draft (no `$schema`) is 2020-12. Pin
|
|
134
|
+
// the authoritative DOM validator to draft-07 so schemas without a
|
|
135
|
+
// `$schema` behave identically on both paths. Schemas that DO declare a
|
|
136
|
+
// different `$schema` still honor it (the document overrides the
|
|
137
|
+
// default), and the fast path falls back for those.
|
|
138
|
+
let compiled = jsonschema::options()
|
|
139
|
+
.with_draft(jsonschema::Draft::Draft7)
|
|
140
|
+
.should_validate_formats(true)
|
|
141
|
+
.build(&schema_value)
|
|
142
|
+
.map_err(|e| {
|
|
143
|
+
Error::new(
|
|
144
|
+
Status::InvalidArg,
|
|
145
|
+
format!("Schema compilation error: {}", e),
|
|
146
|
+
)
|
|
147
|
+
})?;
|
|
148
|
+
|
|
149
|
+
// The zero-DOM fast path engages only for schemas using the supported
|
|
150
|
+
// keyword subset; anything else falls back to the compiled crate.
|
|
151
|
+
let fast = fast_schema::compile(&schema_value).ok().map(Arc::new);
|
|
152
|
+
|
|
153
|
+
Ok(Self {
|
|
154
|
+
schema: Arc::new(compiled),
|
|
155
|
+
fast,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[inline]
|
|
160
|
+
fn validate_doc(&self, bytes: &[u8]) -> bool {
|
|
161
|
+
if let Some(fast) = &self.fast {
|
|
162
|
+
return fast.is_valid_bytes(bytes);
|
|
163
|
+
}
|
|
164
|
+
match sonic_rs::from_slice::<Value>(bytes) {
|
|
165
|
+
Ok(value) => self.schema.is_valid(&value),
|
|
166
|
+
Err(_) => false,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Validate a single JSON document against the schema.
|
|
171
|
+
#[napi]
|
|
172
|
+
pub fn validate(&self, input: Uint8Array) -> bool {
|
|
173
|
+
self.validate_doc(input.as_ref())
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Opaque handle to the compiled schema, for the `bun:ffi` C-ABI fast path
|
|
177
|
+
/// (`castrum_schema_validator_validate` in rust/ffi.rs). Only valid while
|
|
178
|
+
/// THIS instance is alive; the JS wrapper holds the instance.
|
|
179
|
+
#[napi]
|
|
180
|
+
pub fn inner_ptr(&self) -> u64 {
|
|
181
|
+
self as *const SchemaValidator as u64
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// One-pass validate + extract: validate `input` against the schema and
|
|
185
|
+
/// capture scalar values / array lengths at `paths` during the SAME walk
|
|
186
|
+
/// (no `JSON.parse`, no DOM). For "derive" routes (response built from a
|
|
187
|
+
/// handful of body fields) this replaces `JSON.parse` + Ajv on the happy
|
|
188
|
+
/// path and rejects invalid bodies with zero DOM/GC.
|
|
189
|
+
///
|
|
190
|
+
/// `paths` are RFC 6901 JSON pointers of OBJECT KEYS; a trailing `/-`
|
|
191
|
+
/// captures the ARRAY LENGTH at that path (e.g. `"/totalCents"`,
|
|
192
|
+
/// `"/lineItems/-"`). Array-index steps are not supported.
|
|
193
|
+
#[napi]
|
|
194
|
+
pub fn derive(&self, input: Uint8Array, paths: Vec<String>) -> Result<JsonDeriveResult> {
|
|
195
|
+
let targets = parse_derive_targets(&paths)?;
|
|
196
|
+
if let Some(fast) = &self.fast {
|
|
197
|
+
let (ok, cap) = fast.validate_with_capture(input.as_ref(), targets.clone());
|
|
198
|
+
Ok(JsonDeriveResult {
|
|
199
|
+
ok,
|
|
200
|
+
values: derive_values(&cap, ok, &targets, input.as_ref()),
|
|
201
|
+
})
|
|
202
|
+
} else {
|
|
203
|
+
self.derive_dom(input.as_ref(), &targets)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// Validate a single JSON document and return detailed errors (empty = valid).
|
|
208
|
+
///
|
|
209
|
+
/// On the zero-DOM fast path this returns every collected error; on the DOM
|
|
210
|
+
/// fallback path only the first validation error is produced.
|
|
211
|
+
#[napi]
|
|
212
|
+
pub fn validate_detailed(&self, input: Uint8Array) -> Vec<SchemaError> {
|
|
213
|
+
if let Some(fast) = &self.fast {
|
|
214
|
+
return fast
|
|
215
|
+
.validate_errors(input.as_ref(), usize::MAX)
|
|
216
|
+
.into_iter()
|
|
217
|
+
.map(SchemaError::from)
|
|
218
|
+
.collect();
|
|
219
|
+
}
|
|
220
|
+
match sonic_rs::from_slice::<Value>(input.as_ref()) {
|
|
221
|
+
Ok(value) => match self.schema.validate(&value) {
|
|
222
|
+
Ok(()) => Vec::new(),
|
|
223
|
+
Err(e) => vec![SchemaError {
|
|
224
|
+
instance_path: e.instance_path().to_string(),
|
|
225
|
+
schema_path: e.schema_path().to_string(),
|
|
226
|
+
keyword: format!("{:?}", e.kind()),
|
|
227
|
+
message: e.to_string(),
|
|
228
|
+
}],
|
|
229
|
+
},
|
|
230
|
+
Err(_) => vec![SchemaError {
|
|
231
|
+
instance_path: String::new(),
|
|
232
|
+
schema_path: String::new(),
|
|
233
|
+
keyword: "parse".to_string(),
|
|
234
|
+
message: "document is not valid JSON".to_string(),
|
|
235
|
+
}],
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/// Validate a single JSON document and return only the first error (if any).
|
|
240
|
+
#[napi]
|
|
241
|
+
pub fn validate_first_error(&self, input: Uint8Array) -> Option<SchemaError> {
|
|
242
|
+
if let Some(fast) = &self.fast {
|
|
243
|
+
return fast
|
|
244
|
+
.validate_errors(input.as_ref(), 1)
|
|
245
|
+
.into_iter()
|
|
246
|
+
.next()
|
|
247
|
+
.map(SchemaError::from);
|
|
248
|
+
}
|
|
249
|
+
match sonic_rs::from_slice::<Value>(input.as_ref()) {
|
|
250
|
+
Ok(value) => match self.schema.validate(&value) {
|
|
251
|
+
Ok(()) => None,
|
|
252
|
+
Err(e) => Some(SchemaError {
|
|
253
|
+
instance_path: e.instance_path().to_string(),
|
|
254
|
+
schema_path: e.schema_path().to_string(),
|
|
255
|
+
keyword: format!("{:?}", e.kind()),
|
|
256
|
+
message: e.to_string(),
|
|
257
|
+
}),
|
|
258
|
+
},
|
|
259
|
+
Err(_) => Some(SchemaError {
|
|
260
|
+
instance_path: String::new(),
|
|
261
|
+
schema_path: String::new(),
|
|
262
|
+
keyword: "parse".to_string(),
|
|
263
|
+
message: "document is not valid JSON".to_string(),
|
|
264
|
+
}),
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Validate a packed batch of individual JSON documents.
|
|
269
|
+
///
|
|
270
|
+
/// Input format:
|
|
271
|
+
///
|
|
272
|
+
/// [u32 count]
|
|
273
|
+
/// repeated:
|
|
274
|
+
/// [u32 doc_len]
|
|
275
|
+
/// [doc bytes]
|
|
276
|
+
///
|
|
277
|
+
/// This avoids materializing the entire batch into one DOM array.
|
|
278
|
+
#[napi]
|
|
279
|
+
pub fn validate_batch_packed_count(&self, packed: Uint8Array) -> Result<u32> {
|
|
280
|
+
let data = packed.as_ref();
|
|
281
|
+
let iter = PackedIter::new(data)?;
|
|
282
|
+
let count = iter.len();
|
|
283
|
+
let payload = packed_payload_bytes(data, count);
|
|
284
|
+
|
|
285
|
+
// Serial path is zero-alloc (PackedIter); only very large batches pay the
|
|
286
|
+
// one-time slice collect to enable rayon parallelism.
|
|
287
|
+
let valid: usize = if should_parallelize(count, payload) {
|
|
288
|
+
use rayon::prelude::*;
|
|
289
|
+
let items: Vec<&[u8]> = PackedIter::new(data)?.collect();
|
|
290
|
+
items
|
|
291
|
+
.par_iter()
|
|
292
|
+
.filter(|item| self.validate_doc(item))
|
|
293
|
+
.count()
|
|
294
|
+
} else {
|
|
295
|
+
PackedIter::new(data)?
|
|
296
|
+
.filter(|item| self.validate_doc(item))
|
|
297
|
+
.count()
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
Ok(valid as u32)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/// Validate a packed batch and return a packed bitset.
|
|
304
|
+
///
|
|
305
|
+
/// Output format:
|
|
306
|
+
///
|
|
307
|
+
/// [u32 count]
|
|
308
|
+
/// [ceil(count / 8) bytes]
|
|
309
|
+
#[napi]
|
|
310
|
+
pub fn validate_batch_packed_bitset(&self, packed: Uint8Array) -> Result<Buffer> {
|
|
311
|
+
let data = packed.as_ref();
|
|
312
|
+
let iter = PackedIter::new(data)?;
|
|
313
|
+
let count = iter.len();
|
|
314
|
+
let payload = packed_payload_bytes(data, count);
|
|
315
|
+
|
|
316
|
+
let bitset_len = count.div_ceil(8);
|
|
317
|
+
let mut out = Vec::with_capacity(4 + bitset_len);
|
|
318
|
+
out.extend_from_slice(&(count as u32).to_le_bytes());
|
|
319
|
+
out.resize(4 + bitset_len, 0);
|
|
320
|
+
|
|
321
|
+
if count == 0 {
|
|
322
|
+
return Ok(Buffer::from(out));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if should_parallelize(count, payload) {
|
|
326
|
+
use rayon::prelude::*;
|
|
327
|
+
let items: Vec<&[u8]> = PackedIter::new(data)?.collect();
|
|
328
|
+
let chunk_items = 256usize;
|
|
329
|
+
let chunk_bytes = chunk_items.div_ceil(8);
|
|
330
|
+
let bits = &mut out[4..];
|
|
331
|
+
bits.par_chunks_mut(chunk_bytes)
|
|
332
|
+
.enumerate()
|
|
333
|
+
.for_each(|(chunk_idx, chunk)| {
|
|
334
|
+
let start_item = chunk_idx * chunk_bytes * 8;
|
|
335
|
+
let end_item = (start_item + chunk.len() * 8).min(count);
|
|
336
|
+
let start_byte = start_item / 8;
|
|
337
|
+
for i in start_item..end_item {
|
|
338
|
+
if self.validate_doc(items[i]) {
|
|
339
|
+
chunk[(i / 8) - start_byte] |= 1 << (i & 7);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
} else {
|
|
344
|
+
for (i, item) in PackedIter::new(data)?.enumerate() {
|
|
345
|
+
if self.validate_doc(item) {
|
|
346
|
+
out[4 + (i >> 3)] |= 1 << (i & 7);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
Ok(Buffer::from(out))
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fn parse_derive_targets(paths: &[String]) -> Result<Vec<fast_schema::TargetPath>> {
|
|
356
|
+
let mut targets = Vec::with_capacity(paths.len());
|
|
357
|
+
for p in paths {
|
|
358
|
+
match fast_schema::parse_target(p) {
|
|
359
|
+
Some(t) => targets.push(t),
|
|
360
|
+
None => {
|
|
361
|
+
return Err(Error::new(
|
|
362
|
+
Status::InvalidArg,
|
|
363
|
+
format!(
|
|
364
|
+
"derive path \"{p}\" must be a JSON pointer of object keys (e.g. \"/totalCents\", \"/lineItems/-\")"
|
|
365
|
+
),
|
|
366
|
+
));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
Ok(targets)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/// Turn a completed capture into the napi result values (fast path).
|
|
374
|
+
fn derive_values(
|
|
375
|
+
cap: &fast_schema::Capture,
|
|
376
|
+
ok: bool,
|
|
377
|
+
targets: &[fast_schema::TargetPath],
|
|
378
|
+
input: &[u8],
|
|
379
|
+
) -> Vec<Option<JsonDeriveValue>> {
|
|
380
|
+
targets
|
|
381
|
+
.iter()
|
|
382
|
+
.enumerate()
|
|
383
|
+
.map(|(i, t)| {
|
|
384
|
+
if !ok {
|
|
385
|
+
return None;
|
|
386
|
+
}
|
|
387
|
+
match t.kind {
|
|
388
|
+
fast_schema::CaptureKind::Length => cap.length(i).map(|n| JsonDeriveValue {
|
|
389
|
+
kind: "int".to_string(),
|
|
390
|
+
int: Some(n as i64),
|
|
391
|
+
number: None,
|
|
392
|
+
text: None,
|
|
393
|
+
boolean: None,
|
|
394
|
+
}),
|
|
395
|
+
fast_schema::CaptureKind::Value => cap.value_range(i).and_then(|(s, e)| {
|
|
396
|
+
if e <= s || e > input.len() {
|
|
397
|
+
None
|
|
398
|
+
} else {
|
|
399
|
+
Some(scalar_value(&input[s..e]))
|
|
400
|
+
}
|
|
401
|
+
}),
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
.collect()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/// Classify a captured raw scalar value (bytes are exactly the JSON value).
|
|
408
|
+
fn scalar_value(raw: &[u8]) -> JsonDeriveValue {
|
|
409
|
+
let null_value = JsonDeriveValue {
|
|
410
|
+
kind: "null".to_string(),
|
|
411
|
+
int: None,
|
|
412
|
+
number: None,
|
|
413
|
+
text: None,
|
|
414
|
+
boolean: None,
|
|
415
|
+
};
|
|
416
|
+
let Some(first) = raw.first() else {
|
|
417
|
+
return null_value;
|
|
418
|
+
};
|
|
419
|
+
match first {
|
|
420
|
+
b'"' => match serde_json::from_slice::<String>(raw) {
|
|
421
|
+
Ok(s) => JsonDeriveValue {
|
|
422
|
+
kind: "string".to_string(),
|
|
423
|
+
int: None,
|
|
424
|
+
number: None,
|
|
425
|
+
text: Some(s),
|
|
426
|
+
boolean: None,
|
|
427
|
+
},
|
|
428
|
+
Err(_) => null_value,
|
|
429
|
+
},
|
|
430
|
+
b't' | b'f' => JsonDeriveValue {
|
|
431
|
+
kind: "bool".to_string(),
|
|
432
|
+
int: None,
|
|
433
|
+
number: None,
|
|
434
|
+
text: None,
|
|
435
|
+
boolean: Some(*first == b't'),
|
|
436
|
+
},
|
|
437
|
+
b'n' => null_value,
|
|
438
|
+
_ => {
|
|
439
|
+
let s = std::str::from_utf8(raw).unwrap_or("");
|
|
440
|
+
if let Ok(i) = s.parse::<i64>() {
|
|
441
|
+
JsonDeriveValue {
|
|
442
|
+
kind: "int".to_string(),
|
|
443
|
+
int: Some(i),
|
|
444
|
+
number: None,
|
|
445
|
+
text: None,
|
|
446
|
+
boolean: None,
|
|
447
|
+
}
|
|
448
|
+
} else if let Ok(f) = s.parse::<f64>() {
|
|
449
|
+
JsonDeriveValue {
|
|
450
|
+
kind: "number".to_string(),
|
|
451
|
+
int: None,
|
|
452
|
+
number: Some(f),
|
|
453
|
+
text: None,
|
|
454
|
+
boolean: None,
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
null_value
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/// JSON pointer string for a target path (for the DOM-fallback extraction).
|
|
464
|
+
fn pointer_for(t: &fast_schema::TargetPath) -> String {
|
|
465
|
+
let mut out = String::new();
|
|
466
|
+
for step in &t.steps {
|
|
467
|
+
out.push('/');
|
|
468
|
+
for &b in step.iter() {
|
|
469
|
+
match b {
|
|
470
|
+
b'~' => out.push_str("~0"),
|
|
471
|
+
b'/' => out.push_str("~1"),
|
|
472
|
+
_ => out.push(b as char),
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
out
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
fn dom_scalar(v: &Value) -> JsonDeriveValue {
|
|
480
|
+
match v {
|
|
481
|
+
Value::Null => JsonDeriveValue {
|
|
482
|
+
kind: "null".to_string(),
|
|
483
|
+
int: None,
|
|
484
|
+
number: None,
|
|
485
|
+
text: None,
|
|
486
|
+
boolean: None,
|
|
487
|
+
},
|
|
488
|
+
Value::Bool(b) => JsonDeriveValue {
|
|
489
|
+
kind: "bool".to_string(),
|
|
490
|
+
int: None,
|
|
491
|
+
number: None,
|
|
492
|
+
text: None,
|
|
493
|
+
boolean: Some(*b),
|
|
494
|
+
},
|
|
495
|
+
Value::Number(n) => {
|
|
496
|
+
if let Some(i) = n.as_i64() {
|
|
497
|
+
JsonDeriveValue {
|
|
498
|
+
kind: "int".to_string(),
|
|
499
|
+
int: Some(i),
|
|
500
|
+
number: None,
|
|
501
|
+
text: None,
|
|
502
|
+
boolean: None,
|
|
503
|
+
}
|
|
504
|
+
} else if let Some(f) = n.as_f64() {
|
|
505
|
+
JsonDeriveValue {
|
|
506
|
+
kind: "number".to_string(),
|
|
507
|
+
int: None,
|
|
508
|
+
number: Some(f),
|
|
509
|
+
text: None,
|
|
510
|
+
boolean: None,
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
JsonDeriveValue {
|
|
514
|
+
kind: "number".to_string(),
|
|
515
|
+
int: None,
|
|
516
|
+
number: None,
|
|
517
|
+
text: None,
|
|
518
|
+
boolean: None,
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
Value::String(s) => JsonDeriveValue {
|
|
523
|
+
kind: "string".to_string(),
|
|
524
|
+
int: None,
|
|
525
|
+
number: None,
|
|
526
|
+
text: Some(s.clone()),
|
|
527
|
+
boolean: None,
|
|
528
|
+
},
|
|
529
|
+
Value::Array(_) | Value::Object(_) => JsonDeriveValue {
|
|
530
|
+
kind: "null".to_string(),
|
|
531
|
+
int: None,
|
|
532
|
+
number: None,
|
|
533
|
+
text: None,
|
|
534
|
+
boolean: None,
|
|
535
|
+
},
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
impl SchemaValidator {
|
|
540
|
+
/// DOM-fallback derive (schemas outside the fast subset): validate then
|
|
541
|
+
/// extract with serde_json pointers. Correctness matters; perf is fine
|
|
542
|
+
/// (this is the rare non-fast-schema case).
|
|
543
|
+
fn derive_dom(
|
|
544
|
+
&self,
|
|
545
|
+
bytes: &[u8],
|
|
546
|
+
targets: &[fast_schema::TargetPath],
|
|
547
|
+
) -> Result<JsonDeriveResult> {
|
|
548
|
+
let value: Value = match sonic_rs::from_slice(bytes) {
|
|
549
|
+
Ok(v) => v,
|
|
550
|
+
Err(_) => {
|
|
551
|
+
return Ok(JsonDeriveResult {
|
|
552
|
+
ok: false,
|
|
553
|
+
values: (0..targets.len()).map(|_| None).collect(),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
if !self.schema.is_valid(&value) {
|
|
558
|
+
return Ok(JsonDeriveResult {
|
|
559
|
+
ok: false,
|
|
560
|
+
values: (0..targets.len()).map(|_| None).collect(),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
let values = targets
|
|
564
|
+
.iter()
|
|
565
|
+
.map(|t| {
|
|
566
|
+
let ptr = pointer_for(t);
|
|
567
|
+
match t.kind {
|
|
568
|
+
fast_schema::CaptureKind::Length => value
|
|
569
|
+
.pointer(&ptr)
|
|
570
|
+
.and_then(|v| v.as_array())
|
|
571
|
+
.map(|a| JsonDeriveValue {
|
|
572
|
+
kind: "int".to_string(),
|
|
573
|
+
int: Some(a.len() as i64),
|
|
574
|
+
number: None,
|
|
575
|
+
text: None,
|
|
576
|
+
boolean: None,
|
|
577
|
+
}),
|
|
578
|
+
fast_schema::CaptureKind::Value => value.pointer(&ptr).map(dom_scalar),
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
.collect();
|
|
582
|
+
Ok(JsonDeriveResult { ok: true, values })
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
#[cfg(test)]
|
|
587
|
+
mod tests {
|
|
588
|
+
use super::*;
|
|
589
|
+
|
|
590
|
+
const SCHEMA: &str = r#"{
|
|
591
|
+
"type": "object",
|
|
592
|
+
"required": ["id", "name"],
|
|
593
|
+
"properties": {
|
|
594
|
+
"id": { "type": "number" },
|
|
595
|
+
"name": { "type": "string", "minLength": 1 }
|
|
596
|
+
},
|
|
597
|
+
"additionalProperties": false
|
|
598
|
+
}"#;
|
|
599
|
+
|
|
600
|
+
fn validator() -> SchemaValidator {
|
|
601
|
+
SchemaValidator::new(Uint8Array::new(SCHEMA.as_bytes().to_vec())).unwrap()
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
#[test]
|
|
605
|
+
fn validate_accepts_valid_doc() {
|
|
606
|
+
let v = validator();
|
|
607
|
+
assert!(v.validate_doc(br#"{"id":1,"name":"alice"}"#));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
#[test]
|
|
611
|
+
fn validate_rejects_invalid_doc() {
|
|
612
|
+
let v = validator();
|
|
613
|
+
// Wrong types + extra properties (additionalProperties: false).
|
|
614
|
+
assert!(!v.validate_doc(br#"{"id":"x","name":"alice"}"#));
|
|
615
|
+
assert!(!v.validate_doc(br#"{"id":1,"name":42}"#));
|
|
616
|
+
assert!(!v.validate_doc(br#"{"id":1,"name":"alice","extra":true}"#));
|
|
617
|
+
// Malformed JSON is not valid either.
|
|
618
|
+
assert!(!v.validate_doc(b"{nope"));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
#[test]
|
|
622
|
+
fn validate_batch_count_matches() {
|
|
623
|
+
let v = validator();
|
|
624
|
+
let docs: [&[u8]; 3] = [
|
|
625
|
+
br#"{"id":1,"name":"a"}"#,
|
|
626
|
+
br#"{"id":2,"name":"b"}"#,
|
|
627
|
+
br#"{"id":"bad"}"#,
|
|
628
|
+
];
|
|
629
|
+
// Build packed [u32 count]{[u32 len][doc]} by hand.
|
|
630
|
+
let mut packed = Vec::new();
|
|
631
|
+
packed.extend_from_slice(&(docs.len() as u32).to_le_bytes());
|
|
632
|
+
for doc in docs {
|
|
633
|
+
packed.extend_from_slice(&(doc.len() as u32).to_le_bytes());
|
|
634
|
+
packed.extend_from_slice(doc);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let count = v
|
|
638
|
+
.validate_batch_packed_count(Uint8Array::new(packed))
|
|
639
|
+
.unwrap();
|
|
640
|
+
assert_eq!(count, 2);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
#[test]
|
|
644
|
+
fn batch_paths_agree() {
|
|
645
|
+
let v = validator();
|
|
646
|
+
let docs: [&[u8]; 5] = [
|
|
647
|
+
br#"{"id":1,"name":"a"}"#,
|
|
648
|
+
br#"{"id":2,"name":"b"}"#,
|
|
649
|
+
br#"{"id":"bad"}"#,
|
|
650
|
+
br#"{"id":3,"name":42}"#,
|
|
651
|
+
br#"{"id":4,"name":"c"}"#,
|
|
652
|
+
];
|
|
653
|
+
|
|
654
|
+
// Packed batch: [u32 count]{[u32 len][doc]}.
|
|
655
|
+
let mut packed = Vec::new();
|
|
656
|
+
packed.extend_from_slice(&(docs.len() as u32).to_le_bytes());
|
|
657
|
+
for doc in docs {
|
|
658
|
+
packed.extend_from_slice(&(doc.len() as u32).to_le_bytes());
|
|
659
|
+
packed.extend_from_slice(doc);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
let count = v
|
|
663
|
+
.validate_batch_packed_count(Uint8Array::new(packed.clone()))
|
|
664
|
+
.unwrap();
|
|
665
|
+
let bitset = v
|
|
666
|
+
.validate_batch_packed_bitset(Uint8Array::new(packed))
|
|
667
|
+
.unwrap();
|
|
668
|
+
|
|
669
|
+
let bitset_count = u32::from_le_bytes([bitset[0], bitset[1], bitset[2], bitset[3]]);
|
|
670
|
+
let valid_from_bitset = (0..docs.len())
|
|
671
|
+
.filter(|&i| (bitset[4 + (i >> 3)] >> (i & 7)) & 1 == 1)
|
|
672
|
+
.count();
|
|
673
|
+
|
|
674
|
+
assert_eq!(count, 3);
|
|
675
|
+
assert_eq!(bitset_count as usize, docs.len());
|
|
676
|
+
assert_eq!(valid_from_bitset, 3);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/// Diagnostic: splits per-document cost into DOM build vs schema walk, to
|
|
680
|
+
/// confirm where the schema-validation budget goes. No timing assertion
|
|
681
|
+
/// (would be flaky in CI); print with `cargo test -- --nocapture`.
|
|
682
|
+
#[test]
|
|
683
|
+
fn dom_build_vs_validate_cost() {
|
|
684
|
+
let v = validator();
|
|
685
|
+
let doc: &[u8] = br#"{"id":1,"name":"user_1","active":true,"score":1.25,"tags":["alpha","beta"],"nested":{"version":1,"createdAt":"2026-01-01T00:00:00Z"}}"#;
|
|
686
|
+
const N: u32 = 20_000;
|
|
687
|
+
|
|
688
|
+
// Warm both paths.
|
|
689
|
+
for _ in 0..1_000 {
|
|
690
|
+
let _ = sonic_rs::from_slice::<Value>(doc).unwrap();
|
|
691
|
+
}
|
|
692
|
+
let parsed: Value = sonic_rs::from_slice(doc).unwrap();
|
|
693
|
+
for _ in 0..1_000 {
|
|
694
|
+
let _ = v.schema.is_valid(&parsed);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
let t0 = std::time::Instant::now();
|
|
698
|
+
for _ in 0..N {
|
|
699
|
+
let _ = sonic_rs::from_slice::<Value>(doc).unwrap();
|
|
700
|
+
}
|
|
701
|
+
let parse_ns = t0.elapsed().as_nanos() as f64 / N as f64;
|
|
702
|
+
|
|
703
|
+
let t1 = std::time::Instant::now();
|
|
704
|
+
for _ in 0..N {
|
|
705
|
+
let _ = v.schema.is_valid(&parsed);
|
|
706
|
+
}
|
|
707
|
+
let validate_ns = t1.elapsed().as_nanos() as f64 / N as f64;
|
|
708
|
+
|
|
709
|
+
let total = parse_ns + validate_ns;
|
|
710
|
+
println!(
|
|
711
|
+
"schema micro-bench: dom-build {parse_ns:.0}ns validate {validate_ns:.0}ns dom-share {:.0}%",
|
|
712
|
+
parse_ns / total * 100.0
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
// Correctness sanity: a doc matching the test SCHEMA (id+name only,
|
|
716
|
+
// additionalProperties:false) must pass; the larger fixture-like doc has
|
|
717
|
+
// extra properties and is correctly rejected by that schema.
|
|
718
|
+
assert!(v.validate_doc(br#"{"id":1,"name":"a"}"#));
|
|
719
|
+
assert!(!v.validate_doc(doc));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// ── one-pass derive (validate + extract) ────────────────────────────────
|
|
723
|
+
|
|
724
|
+
const ORDERS_SCHEMA: &str = r#"{
|
|
725
|
+
"type": "object",
|
|
726
|
+
"required": ["orderId","lineItems","totalCents","customer"],
|
|
727
|
+
"properties": {
|
|
728
|
+
"orderId": { "type": "string" },
|
|
729
|
+
"customer": {
|
|
730
|
+
"type": "object",
|
|
731
|
+
"required": ["id","email"],
|
|
732
|
+
"properties": { "id": {"type":"string"}, "email": {"type":"string"} }
|
|
733
|
+
},
|
|
734
|
+
"lineItems": {
|
|
735
|
+
"type": "array",
|
|
736
|
+
"items": {
|
|
737
|
+
"type": "object",
|
|
738
|
+
"required": ["sku","quantity"],
|
|
739
|
+
"properties": {
|
|
740
|
+
"sku": {"type":"string"},
|
|
741
|
+
"quantity": {"type":"integer","minimum":1}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
"totalCents": { "type": "integer" }
|
|
746
|
+
},
|
|
747
|
+
"additionalProperties": false
|
|
748
|
+
}"#;
|
|
749
|
+
|
|
750
|
+
const ORDERS_DOC: &str = r#"{
|
|
751
|
+
"orderId": "ord_1",
|
|
752
|
+
"customer": {"id":"cus_1","email":"a@b.c"},
|
|
753
|
+
"lineItems": [
|
|
754
|
+
{"sku":"A","quantity":2},
|
|
755
|
+
{"sku":"B","quantity":3},
|
|
756
|
+
{"sku":"C","quantity":1}
|
|
757
|
+
],
|
|
758
|
+
"totalCents": 108000
|
|
759
|
+
}"#;
|
|
760
|
+
|
|
761
|
+
fn orders_validator() -> SchemaValidator {
|
|
762
|
+
SchemaValidator::new(Uint8Array::new(ORDERS_SCHEMA.as_bytes().to_vec())).unwrap()
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
fn derive(v: &SchemaValidator, doc: &[u8], paths: &[&str]) -> JsonDeriveResult {
|
|
766
|
+
let p: Vec<String> = paths.iter().map(|s| s.to_string()).collect();
|
|
767
|
+
v.derive(Uint8Array::new(doc.to_vec()), p).unwrap()
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
#[test]
|
|
771
|
+
fn derive_extracts_count_and_int() {
|
|
772
|
+
let v = orders_validator();
|
|
773
|
+
let r = derive(&v, ORDERS_DOC.as_bytes(), &["/lineItems/-", "/totalCents"]);
|
|
774
|
+
assert!(r.ok);
|
|
775
|
+
assert_eq!(r.values.len(), 2);
|
|
776
|
+
let count = r.values[0].as_ref().unwrap();
|
|
777
|
+
assert_eq!(count.kind, "int");
|
|
778
|
+
assert_eq!(count.int, Some(3));
|
|
779
|
+
let total = r.values[1].as_ref().unwrap();
|
|
780
|
+
assert_eq!(total.kind, "int");
|
|
781
|
+
assert_eq!(total.int, Some(108000));
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
#[test]
|
|
785
|
+
fn derive_extracts_string() {
|
|
786
|
+
let v = orders_validator();
|
|
787
|
+
let r = derive(&v, ORDERS_DOC.as_bytes(), &["/customer/email"]);
|
|
788
|
+
assert!(r.ok);
|
|
789
|
+
let email = r.values[0].as_ref().unwrap();
|
|
790
|
+
assert_eq!(email.kind, "string");
|
|
791
|
+
assert_eq!(email.text.as_deref(), Some("a@b.c"));
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
#[test]
|
|
795
|
+
fn derive_missing_path_is_none() {
|
|
796
|
+
let v = orders_validator();
|
|
797
|
+
let r = derive(&v, ORDERS_DOC.as_bytes(), &["/nope", "/lineItems/-"]);
|
|
798
|
+
assert!(r.ok);
|
|
799
|
+
assert!(r.values[0].is_none());
|
|
800
|
+
assert_eq!(r.values[1].as_ref().unwrap().int, Some(3));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
#[test]
|
|
804
|
+
fn derive_invalid_doc_is_not_ok() {
|
|
805
|
+
let v = orders_validator();
|
|
806
|
+
// bad sku type at lineItems[0]
|
|
807
|
+
let bad: &[u8] = br#"{"orderId":"o","customer":{"id":"c","email":"e@x"},
|
|
808
|
+
"lineItems":[{"sku":123,"quantity":2}],"totalCents":1}"#;
|
|
809
|
+
let r = derive(&v, bad, &["/lineItems/-", "/totalCents"]);
|
|
810
|
+
assert!(!r.ok);
|
|
811
|
+
assert!(r.values[0].is_none());
|
|
812
|
+
// malformed JSON
|
|
813
|
+
let r2 = derive(&v, b"{oops", &["/totalCents"]);
|
|
814
|
+
assert!(!r2.ok);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
#[test]
|
|
818
|
+
fn derive_matches_plain_validate() {
|
|
819
|
+
// For a batch of docs, derive.ok must equal validate_doc exactly.
|
|
820
|
+
let v = orders_validator();
|
|
821
|
+
let docs: [&[u8]; 5] = [
|
|
822
|
+
ORDERS_DOC.as_bytes(),
|
|
823
|
+
br#"{"orderId":"o","customer":{"id":"c","email":"e"},
|
|
824
|
+
"lineItems":[],"totalCents":0}"#,
|
|
825
|
+
br#"{"orderId":"o","customer":{"id":"c","email":"e"},
|
|
826
|
+
"lineItems":[{"sku":"a","quantity":0}],"totalCents":1}"#,
|
|
827
|
+
br#"{"orderId":"o","customer":{"id":"c","email":"e"},
|
|
828
|
+
"lineItems":[{"sku":"a","quantity":2}],"totalCents":"x"}"#,
|
|
829
|
+
b"not json at all",
|
|
830
|
+
];
|
|
831
|
+
for doc in docs {
|
|
832
|
+
let expected = v.validate_doc(doc);
|
|
833
|
+
let r = derive(&v, doc, &["/totalCents", "/lineItems/-"]);
|
|
834
|
+
assert_eq!(r.ok, expected, "derive.ok must equal validate_doc");
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|