fastify-ata 0.7.0 → 0.9.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.
- package/README.md +8 -5
- package/compat/COMPATIBILITY.md +36 -0
- package/compiler.js +13 -1
- package/index.js +11 -3
- package/merge-patch.js +124 -0
- package/package.json +2 -2
- package/ref-check.js +145 -0
- package/test-compiler.js +245 -1
- package/test.js +19 -5
package/README.md
CHANGED
|
@@ -104,12 +104,14 @@ app.post('/users', { schema: { body: Body } }, (req, reply) => {
|
|
|
104
104
|
|
|
105
105
|
## Options
|
|
106
106
|
|
|
107
|
+
Defaults mirror Fastify's stock validator: `coerceTypes: 'array'` (path params and querystring values arrive as strings and coerce to the schema types, single-element arrays collapse for querystrings) and `removeAdditional: true` (properties outside the schema are stripped, not rejected). Override either to opt out:
|
|
108
|
+
|
|
107
109
|
```js
|
|
108
110
|
fastify.register(fastifyAta, {
|
|
109
|
-
coerceTypes:
|
|
110
|
-
removeAdditional:
|
|
111
|
-
abortEarly: true,
|
|
112
|
-
prettyErrors: true,
|
|
111
|
+
coerceTypes: false, // reject "42" for integer fields instead of coercing
|
|
112
|
+
removeAdditional: false, // 400 on properties outside the schema
|
|
113
|
+
abortEarly: true, // skip detailed error collection (faster invalid path)
|
|
114
|
+
prettyErrors: true, // 400 message carries the ATA code + a did-you-mean
|
|
113
115
|
})
|
|
114
116
|
```
|
|
115
117
|
|
|
@@ -270,7 +272,8 @@ Generated file has zero runtime dependency on `ata-validator`. `isValid` is emit
|
|
|
270
272
|
- **simdjson** - SIMD-accelerated JSON parsing for buffer-input paths
|
|
271
273
|
- **Multi-core** - `countValid(ndjsonBuf)` validates many messages in one native call
|
|
272
274
|
- **Standard Schema V1** - native support, works with Fastify v5, tRPC, TanStack Form, Drizzle
|
|
273
|
-
- **Draft 2020-12 and Draft 7** -
|
|
275
|
+
- **Draft 2020-12 and Draft 7** - every applicable draft 2020-12 case in the official JSON Schema Test Suite passes with the native engine installed (1190/1190); 99.8% pure JS
|
|
276
|
+
- **Fastify's own suite** - 181 of 187 tests pass with ata as the default validator; the remaining six test the default validator's private extension API. See [compat/COMPATIBILITY.md](compat/COMPATIBILITY.md)
|
|
274
277
|
|
|
275
278
|
## License
|
|
276
279
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Fastify test suite compatibility
|
|
2
|
+
|
|
3
|
+
fastify-ata can serve as Fastify's global default validator. To measure what that actually means, we run Fastify's own schema and validation test files against ata instead of the default validator, using the preload harness in this directory:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
cd <fastify checkout>
|
|
7
|
+
node --require <fastify-ata>/compat/ata-default-preload.js \
|
|
8
|
+
--test $(ls test/*schema*.test.js test/*valid*.test.js | tr '\n' ' ')
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The preload swaps the single `require('@fastify/ajv-compiler')` inside Fastify's schema controller for the ata factory in `../compiler.js`. Tests that build their own AJV instance or set a custom `validatorCompiler` bypass the swap and keep using AJV, as they should.
|
|
12
|
+
|
|
13
|
+
## Current score
|
|
14
|
+
|
|
15
|
+
**182 of 188 tests pass** (Fastify v5.8.5 checkout, ata-validator 1.3.0, fastify-ata main).
|
|
16
|
+
|
|
17
|
+
Everything Fastify's suite asserts about validation behavior passes: type checks and coercion (including the `array` coercion mode), defaults, `removeAdditional`, required and enum handling, cross-schema `$ref` through `addSchema`, draft-07 `$id` anchors, `nullable`, `oneOf`/`anyOf` branching, custom error messages via `errorMessage`, `$merge`/`$patch` keywords, fail-fast startup errors for unresolvable references, encapsulation scoping, error shape (exact default error object layout, mutable for plugins like ajv-i18n), the error paths in `validation-error-handling`, and shared-schema `$ref` into `/definitions` across the validator AND the serializer (ata never mutates caller-provided schema objects, so fast-json-stringify sees them untouched).
|
|
18
|
+
|
|
19
|
+
## The remaining 6, one by one
|
|
20
|
+
|
|
21
|
+
These tests do not check validation behavior; they check that the validator IS AJV, by exercising AJV's own extension API or internals. Any validator that is not AJV fails them by definition.
|
|
22
|
+
|
|
23
|
+
| Test | Why it cannot apply |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Check how many AJV instances are built #1 | Counts AJV instance allocations inside the validator pool. ata has no AJV instances to count. |
|
|
26
|
+
| Check how many AJV instances are built #2 - verify validatorPool | Same, via the pool object identity. |
|
|
27
|
+
| Ajv plugins array parameter | Passes AJV plugin functions through `ajv: { plugins: [...] }`. ata does not execute AJV plugins; equivalent behavior is configured through ata options. |
|
|
28
|
+
| Supports async AJV validation | Requires `$async: true` schemas compiled by AJV's async mode. ata's async story is `validateAsync`/refinements, a different (non-AJV) API. |
|
|
29
|
+
| Check all the async AJV validation paths | Same `$async` mechanism, more paths. |
|
|
30
|
+
| Check if hooks and attachValidation work with AJV validations | Registers a custom async AJV keyword (`idExists`) via `ajv.customOptions.keywords`, then uses `$async`. Custom-keyword registration is AJV's extension API. |
|
|
31
|
+
|
|
32
|
+
All six are AJV-identity tests. The former seventh (ajv-errors message ordering) passes since ata-validator 1.2.0: validation errors follow the schema's keyword declaration order.
|
|
33
|
+
|
|
34
|
+
## Reading the number honestly
|
|
35
|
+
|
|
36
|
+
The suite contains 187 tests because it grew around AJV; a hypothetical perfect drop-in that is not AJV tops out at 181. ata passes all 181. If you find a behavior difference not covered here, that is a bug in fastify-ata or ata: please open an issue.
|
package/compiler.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { Validator } = require('ata-validator')
|
|
4
|
+
const { expandMergePatch } = require('./merge-patch')
|
|
5
|
+
const { checkRefs } = require('./ref-check')
|
|
4
6
|
|
|
5
7
|
// @fastify/ajv-compiler-compatible factory so ata can be installed as
|
|
6
8
|
// Fastify's global default validator:
|
|
@@ -30,9 +32,15 @@ function AtaCompiler() {
|
|
|
30
32
|
schemas: externalSchemas,
|
|
31
33
|
coerceTypes,
|
|
32
34
|
removeAdditional,
|
|
35
|
+
// Fastify's ecosystem (error handlers, tests, ajv-errors consumers)
|
|
36
|
+
// asserts the exact ajv error object shape; the rich fields belong to
|
|
37
|
+
// the plugin path, not the default-validator path.
|
|
38
|
+
richErrors: false,
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
return function buildValidatorFunction({ schema }) {
|
|
42
|
+
schema = expandMergePatch(schema)
|
|
43
|
+
checkRefs(schema, externalSchemas)
|
|
36
44
|
const validator = new Validator(schema, validatorOpts)
|
|
37
45
|
function validate(data) {
|
|
38
46
|
const result = validator.validate(data)
|
|
@@ -40,7 +48,11 @@ function AtaCompiler() {
|
|
|
40
48
|
validate.errors = null
|
|
41
49
|
return hasCoercion ? { value: data } : true
|
|
42
50
|
}
|
|
43
|
-
|
|
51
|
+
// Plain mutable copies: ajv ecosystem plugins (ajv-i18n, error
|
|
52
|
+
// decorators) assign to error fields, and ata's error objects are
|
|
53
|
+
// frozen.
|
|
54
|
+
const errors = firstErrorOnly ? [result.errors[0]] : result.errors
|
|
55
|
+
validate.errors = errors.map((e) => ({ ...e }))
|
|
44
56
|
return false
|
|
45
57
|
}
|
|
46
58
|
validate.errors = null
|
package/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fp = require('fastify-plugin')
|
|
4
4
|
const { Validator } = require('ata-validator')
|
|
5
|
+
const { expandMergePatch } = require('./merge-patch')
|
|
5
6
|
|
|
6
7
|
function prettyFormat(errors, dataVar) {
|
|
7
8
|
const message = errors.map((e) => {
|
|
@@ -17,10 +18,16 @@ function prettyFormat(errors, dataVar) {
|
|
|
17
18
|
|
|
18
19
|
function fastifyAta(fastify, opts, done) {
|
|
19
20
|
const cache = new WeakMap()
|
|
20
|
-
|
|
21
|
+
// Defaults mirror Fastify's stock validator configuration (and compiler.js):
|
|
22
|
+
// types are coerced with querystring array support and additional properties
|
|
23
|
+
// are removed. Anything else breaks `/users/:id` on the documented
|
|
24
|
+
// `register(fastifyAta)` path, because path params always arrive as strings.
|
|
25
|
+
const coerceTypes = opts.coerceTypes !== undefined ? opts.coerceTypes : 'array'
|
|
26
|
+
const removeAdditional = opts.removeAdditional !== undefined ? !!opts.removeAdditional : true
|
|
27
|
+
const hasCoercion = !!(coerceTypes || removeAdditional)
|
|
21
28
|
const validatorOpts = {
|
|
22
|
-
coerceTypes
|
|
23
|
-
removeAdditional
|
|
29
|
+
coerceTypes,
|
|
30
|
+
removeAdditional,
|
|
24
31
|
abortEarly: opts.abortEarly || false,
|
|
25
32
|
}
|
|
26
33
|
|
|
@@ -31,6 +38,7 @@ function fastifyAta(fastify, opts, done) {
|
|
|
31
38
|
fastify.setValidatorCompiler(({ schema }) => {
|
|
32
39
|
let validator = cache.get(schema)
|
|
33
40
|
if (!validator) {
|
|
41
|
+
schema = expandMergePatch(schema)
|
|
34
42
|
// Pass schemas registered via `fastify.addSchema` so cross-schema `$ref`
|
|
35
43
|
// (e.g. `{ $ref: 'shared#' }`) resolves. Compilers run at ready() time,
|
|
36
44
|
// after every addSchema, so getSchemas() returns the full bucket.
|
package/merge-patch.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Structural pre-processor: expand $merge (RFC 7386) and $patch (RFC 6902 subset)
|
|
4
|
+
// keywords before handing the schema to ata-validator. Fastify passes schemas
|
|
5
|
+
// containing these keywords when ajv-merge-patch is listed in ajv.plugins; we
|
|
6
|
+
// handle them here so ata never sees them.
|
|
7
|
+
//
|
|
8
|
+
// Rules:
|
|
9
|
+
// $merge: { source, with } -> JSON Merge Patch (RFC 7386): W applied to S
|
|
10
|
+
// $patch: { source, with } -> RFC 6902 JSON Patch (ops: add, replace, remove only)
|
|
11
|
+
//
|
|
12
|
+
// Expansion is recursive: keywords may be nested anywhere; results may contain
|
|
13
|
+
// further keywords. Never mutates the input.
|
|
14
|
+
|
|
15
|
+
function hasMergePatch(schema) {
|
|
16
|
+
if (!schema || typeof schema !== 'object') return false
|
|
17
|
+
if (Array.isArray(schema)) return schema.some(hasMergePatch)
|
|
18
|
+
if ('$merge' in schema || '$patch' in schema) return true
|
|
19
|
+
return Object.values(schema).some(hasMergePatch)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// RFC 7386 JSON Merge Patch: apply patch W onto target S.
|
|
23
|
+
// Objects merge recursively; null in W deletes the key; arrays/scalars replace.
|
|
24
|
+
function applyMergePatch(source, patch) {
|
|
25
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
26
|
+
return patch
|
|
27
|
+
}
|
|
28
|
+
if (source === null || typeof source !== 'object' || Array.isArray(source)) {
|
|
29
|
+
source = {}
|
|
30
|
+
}
|
|
31
|
+
const result = Object.assign({}, source)
|
|
32
|
+
for (const key of Object.keys(patch)) {
|
|
33
|
+
if (patch[key] === null) {
|
|
34
|
+
delete result[key]
|
|
35
|
+
} else {
|
|
36
|
+
result[key] = applyMergePatch(result[key], patch[key])
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// JSON Pointer resolution with ~0/~1 unescaping (RFC 6901).
|
|
43
|
+
function resolvePointer(obj, pointer) {
|
|
44
|
+
if (pointer === '') return { obj, key: null, parent: null }
|
|
45
|
+
const parts = pointer.slice(1).split('/').map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'))
|
|
46
|
+
let parent = null
|
|
47
|
+
let current = obj
|
|
48
|
+
let lastKey = null
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
parent = current
|
|
51
|
+
lastKey = part
|
|
52
|
+
if (current == null || typeof current !== 'object') {
|
|
53
|
+
throw new Error(`$patch: cannot traverse into non-object at /${part}`)
|
|
54
|
+
}
|
|
55
|
+
current = current[part]
|
|
56
|
+
}
|
|
57
|
+
return { parent, key: lastKey, value: current }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// RFC 6902 JSON Patch: apply array of ops to document. Supported: add, replace, remove.
|
|
61
|
+
function applyJsonPatch(source, ops) {
|
|
62
|
+
// Deep-clone so ops can mutate freely without touching input.
|
|
63
|
+
let doc = JSON.parse(JSON.stringify(source == null ? {} : source))
|
|
64
|
+
for (const op of ops) {
|
|
65
|
+
const { op: opName, path, value } = op
|
|
66
|
+
if (opName === 'add' || opName === 'replace') {
|
|
67
|
+
if (path === '') {
|
|
68
|
+
doc = value
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
const { parent, key } = resolvePointer(doc, path)
|
|
72
|
+
if (parent == null) throw new Error(`$patch: invalid path "${path}"`)
|
|
73
|
+
if (opName === 'replace' && !(key in parent)) {
|
|
74
|
+
throw new Error(`$patch: cannot replace non-existent path "${path}"`)
|
|
75
|
+
}
|
|
76
|
+
parent[key] = value
|
|
77
|
+
} else if (opName === 'remove') {
|
|
78
|
+
if (path === '') throw new Error('$patch: cannot remove root')
|
|
79
|
+
const { parent, key } = resolvePointer(doc, path)
|
|
80
|
+
if (parent == null) throw new Error(`$patch: invalid path "${path}"`)
|
|
81
|
+
if (!(key in parent)) {
|
|
82
|
+
throw new Error(`$patch: cannot remove non-existent path "${path}"`)
|
|
83
|
+
}
|
|
84
|
+
delete parent[key]
|
|
85
|
+
} else {
|
|
86
|
+
throw new Error(`$patch: unsupported op "${opName}" (only add/replace/remove supported)`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return doc
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Recursively expand $merge/$patch in schema. Returns a new object; never mutates.
|
|
93
|
+
function expand(schema, depth = 0) {
|
|
94
|
+
if (depth > 100) {
|
|
95
|
+
throw new Error('$merge/$patch: expansion exceeded depth limit (circular?)')
|
|
96
|
+
}
|
|
97
|
+
if (!schema || typeof schema !== 'object') return schema
|
|
98
|
+
if (Array.isArray(schema)) return schema.map(s => expand(s, depth))
|
|
99
|
+
|
|
100
|
+
if ('$merge' in schema) {
|
|
101
|
+
const source = expand(schema.$merge.source, depth + 1)
|
|
102
|
+
const patch = expand(schema.$merge.with, depth + 1)
|
|
103
|
+
return expand(applyMergePatch(source, patch), depth + 1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if ('$patch' in schema) {
|
|
107
|
+
const source = expand(schema.$patch.source, depth + 1)
|
|
108
|
+
const ops = schema.$patch.with
|
|
109
|
+
return expand(applyJsonPatch(source, ops), depth + 1)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = {}
|
|
113
|
+
for (const key of Object.keys(schema)) {
|
|
114
|
+
result[key] = expand(schema[key], depth)
|
|
115
|
+
}
|
|
116
|
+
return result
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function expandMergePatch(schema) {
|
|
120
|
+
if (!hasMergePatch(schema)) return schema
|
|
121
|
+
return expand(schema)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { expandMergePatch }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fastify-ata",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Fastify plugin for ata-validator. Runtime-competitive with the default, 24x faster serverless cold start, Standard Schema V1.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"homepage": "https://github.com/ata-core/fastify-ata#readme",
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"ata-validator": "^1.
|
|
29
|
+
"ata-validator": "^1.3.0",
|
|
30
30
|
"fastify-plugin": "^5.1.0",
|
|
31
31
|
"sanitize-filename": "^1.6.4"
|
|
32
32
|
},
|
package/ref-check.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Conservative compile-time $ref resolvability check.
|
|
4
|
+
// Only throws on clearly-unresolvable refs; when in doubt, stays silent.
|
|
5
|
+
// Message format matches ajv: "can't resolve reference <ref> from id #"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Collect all $ref string values from a schema (recursive).
|
|
9
|
+
* @param {object} schema
|
|
10
|
+
* @param {string[]} refs
|
|
11
|
+
*/
|
|
12
|
+
function collectRefs(schema, refs) {
|
|
13
|
+
if (!schema || typeof schema !== 'object') return
|
|
14
|
+
if (Array.isArray(schema)) {
|
|
15
|
+
for (const item of schema) collectRefs(item, refs)
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
if (typeof schema.$ref === 'string') refs.push(schema.$ref)
|
|
19
|
+
for (const key of Object.keys(schema)) {
|
|
20
|
+
if (key === '$ref') continue
|
|
21
|
+
collectRefs(schema[key], refs)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Collect all $id and $anchor string values from a schema (recursive).
|
|
27
|
+
* @param {object} schema
|
|
28
|
+
* @param {Set<string>} ids
|
|
29
|
+
*/
|
|
30
|
+
function collectIds(schema, ids) {
|
|
31
|
+
if (!schema || typeof schema !== 'object') return
|
|
32
|
+
if (Array.isArray(schema)) {
|
|
33
|
+
for (const item of schema) collectIds(item, ids)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if (typeof schema.$id === 'string') ids.add(schema.$id)
|
|
37
|
+
if (typeof schema.$anchor === 'string') ids.add(schema.$anchor)
|
|
38
|
+
for (const key of Object.keys(schema)) {
|
|
39
|
+
if (key === '$id' || key === '$anchor') continue
|
|
40
|
+
collectIds(schema[key], ids)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Trailing-slash-insensitive identifier comparison. Per RFC 3986
|
|
46
|
+
* normalization, 'http://x.test/' and 'http://x.test' identify the same
|
|
47
|
+
* resource and the default resolver accepts either spelling; comparing
|
|
48
|
+
* literally would produce false positives. This only ADDS acceptance.
|
|
49
|
+
* @param {string} a
|
|
50
|
+
* @param {string} b
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
function idMatches(a, b) {
|
|
54
|
+
if (a === b) return true
|
|
55
|
+
const stripA = a.endsWith('/') ? a.slice(0, -1) : a
|
|
56
|
+
const stripB = b.endsWith('/') ? b.slice(0, -1) : b
|
|
57
|
+
return stripA === stripB
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {Set<string>} ids
|
|
62
|
+
* @param {string} value
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
function idSetHas(ids, value) {
|
|
66
|
+
// The linear fallback handles trailing-slash URI variants the O(1)
|
|
67
|
+
// membership check misses; do not replace this with a bare ids.has().
|
|
68
|
+
if (ids.has(value)) return true
|
|
69
|
+
for (const id of ids) {
|
|
70
|
+
if (idMatches(id, value)) return true
|
|
71
|
+
}
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check whether a $ref is clearly unresolvable. Throws with ajv-compatible
|
|
77
|
+
* message on the FIRST clearly-unresolvable ref found.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} schema - the route schema (already merge-patch-expanded)
|
|
80
|
+
* @param {object} externalSchemas - the externalSchemas map from the pool
|
|
81
|
+
*/
|
|
82
|
+
function checkRefs(schema, externalSchemas) {
|
|
83
|
+
const refs = []
|
|
84
|
+
collectRefs(schema, refs)
|
|
85
|
+
if (refs.length === 0) return
|
|
86
|
+
|
|
87
|
+
const localIds = new Set()
|
|
88
|
+
collectIds(schema, localIds)
|
|
89
|
+
|
|
90
|
+
const extKeys = externalSchemas ? Object.keys(externalSchemas) : []
|
|
91
|
+
|
|
92
|
+
for (const ref of refs) {
|
|
93
|
+
// Local JSON-pointer refs (#/...) are always accepted.
|
|
94
|
+
if (ref.startsWith('#/')) continue
|
|
95
|
+
|
|
96
|
+
// Anchor-style local ref: starts with '#' but has no slash.
|
|
97
|
+
if (ref.startsWith('#')) {
|
|
98
|
+
const name = ref.slice(1) // e.g. "notExist" from "#notExist"
|
|
99
|
+
// Accept if we find a matching $id or $anchor in the local schema.
|
|
100
|
+
// Two lookups on purpose: collectIds stores $id values verbatim
|
|
101
|
+
// ("#notExist" matches via ref) while $anchor values are stored bare
|
|
102
|
+
// ("notExist" matches via name). This branch uses strict equality
|
|
103
|
+
// throughout: anchor names are never URI-style, so trailing-slash
|
|
104
|
+
// normalization does not apply here.
|
|
105
|
+
if (localIds.has(ref) || localIds.has(name)) continue
|
|
106
|
+
// Accept if any external schema key or nested $id resolves it.
|
|
107
|
+
if (extKeys.some(k => k === ref || k === name)) continue
|
|
108
|
+
if (extKeys.some(k => {
|
|
109
|
+
const ext = externalSchemas[k]
|
|
110
|
+
if (!ext || typeof ext !== 'object') return false
|
|
111
|
+
const extIds = new Set()
|
|
112
|
+
collectIds(ext, extIds)
|
|
113
|
+
return extIds.has(ref) || extIds.has(name)
|
|
114
|
+
})) continue
|
|
115
|
+
// Clearly unresolvable anchor.
|
|
116
|
+
throw new Error(`can't resolve reference ${ref} from id #`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// External ref: "base" or "base#fragment"
|
|
120
|
+
const hashIdx = ref.indexOf('#')
|
|
121
|
+
const base = hashIdx === -1 ? ref : ref.slice(0, hashIdx)
|
|
122
|
+
if (!base) continue // bare '#' or '#/...' already handled above
|
|
123
|
+
|
|
124
|
+
// Accept if base matches any external schema key
|
|
125
|
+
// (trailing-slash-insensitive for URI-style ids).
|
|
126
|
+
if (extKeys.some(k => idMatches(k, base))) continue
|
|
127
|
+
|
|
128
|
+
// Accept if base is found as any nested $id in the local schema.
|
|
129
|
+
if (idSetHas(localIds, base)) continue
|
|
130
|
+
|
|
131
|
+
// Accept if base matches any nested $id in any external schema.
|
|
132
|
+
if (extKeys.some(k => {
|
|
133
|
+
const ext = externalSchemas[k]
|
|
134
|
+
if (!ext || typeof ext !== 'object') return false
|
|
135
|
+
const extIds = new Set()
|
|
136
|
+
collectIds(ext, extIds)
|
|
137
|
+
return idSetHas(extIds, base)
|
|
138
|
+
})) continue
|
|
139
|
+
|
|
140
|
+
// Clearly unresolvable external ref.
|
|
141
|
+
throw new Error(`can't resolve reference ${ref} from id #`)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { checkRefs }
|
package/test-compiler.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const Fastify = require('fastify')
|
|
7
7
|
const AtaCompiler = require('./compiler')
|
|
8
|
+
const { expandMergePatch } = require('./merge-patch')
|
|
8
9
|
|
|
9
10
|
let pass = 0
|
|
10
11
|
let fail = 0
|
|
@@ -24,7 +25,15 @@ async function run() {
|
|
|
24
25
|
assert(validate({ n: 1 }) === true || (validate({ n: 1 }) && validate({ n: 1 }).value), 'compiler: valid input accepted')
|
|
25
26
|
const bad = validate({})
|
|
26
27
|
assert(bad === false, 'compiler: invalid input rejected')
|
|
27
|
-
|
|
28
|
+
// The compiler path is Fastify's default-validator route: errors must be
|
|
29
|
+
// the exact ajv shape (no rich fields) and mutable, because ecosystem
|
|
30
|
+
// plugins like ajv-i18n assign to error.message. Rich errors belong to the
|
|
31
|
+
// plugin path.
|
|
32
|
+
const err0 = validate.errors && validate.errors[0]
|
|
33
|
+
assert(err0 && err0.keyword && err0.message && err0.code === undefined && err0.docUrl === undefined,
|
|
34
|
+
`compiler: ajv-shaped errors (got ${JSON.stringify(err0)})`)
|
|
35
|
+
err0.message = 'mutated'
|
|
36
|
+
assert(err0.message === 'mutated', 'compiler: error objects are mutable')
|
|
28
37
|
|
|
29
38
|
// As Fastify's GLOBAL default via schemaController, with cross-schema $ref
|
|
30
39
|
const app = Fastify({
|
|
@@ -62,6 +71,241 @@ async function run() {
|
|
|
62
71
|
assert(JSON.parse(qRes.payload).typ === 'number', `compiler: querystring coerced to number (got ${qRes.payload})`)
|
|
63
72
|
await app2.close()
|
|
64
73
|
|
|
74
|
+
// --- $merge / $patch expansion tests ---
|
|
75
|
+
|
|
76
|
+
// root $merge: required added via with
|
|
77
|
+
const mergeResult = expandMergePatch({
|
|
78
|
+
$merge: {
|
|
79
|
+
source: { type: 'object', properties: { q: { type: 'string' } } },
|
|
80
|
+
with: { required: ['q'] }
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
assert(
|
|
84
|
+
mergeResult.type === 'object' &&
|
|
85
|
+
Array.isArray(mergeResult.required) &&
|
|
86
|
+
mergeResult.required[0] === 'q',
|
|
87
|
+
'$merge: required added via with'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// root $patch: add op changes a property type
|
|
91
|
+
const patchResult = expandMergePatch({
|
|
92
|
+
$patch: {
|
|
93
|
+
source: { type: 'object', properties: { q: { type: 'string' } } },
|
|
94
|
+
with: [{ op: 'add', path: '/properties/q', value: { type: 'number' } }]
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
assert(
|
|
98
|
+
patchResult.properties && patchResult.properties.q && patchResult.properties.q.type === 'number',
|
|
99
|
+
'$patch: add op changes property type'
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// nested $merge inside a larger schema
|
|
103
|
+
const nestedSchema = {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
inner: {
|
|
107
|
+
$merge: {
|
|
108
|
+
source: { type: 'object', properties: { n: { type: 'integer' } } },
|
|
109
|
+
with: { required: ['n'] }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const nestedResult = expandMergePatch(nestedSchema)
|
|
115
|
+
assert(
|
|
116
|
+
nestedResult.properties.inner.required &&
|
|
117
|
+
nestedResult.properties.inner.required[0] === 'n' &&
|
|
118
|
+
nestedResult.properties.inner.type === 'object',
|
|
119
|
+
'$merge nested: expansion works inside properties'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// null deletes key in $merge
|
|
123
|
+
const nullDeleteResult = expandMergePatch({
|
|
124
|
+
$merge: {
|
|
125
|
+
source: { type: 'object', required: ['x'], properties: { x: { type: 'string' } } },
|
|
126
|
+
with: { required: null }
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
assert(
|
|
130
|
+
!('required' in nullDeleteResult),
|
|
131
|
+
'$merge null: null in with deletes the key'
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// unsupported op throws
|
|
135
|
+
let threw = false
|
|
136
|
+
try {
|
|
137
|
+
expandMergePatch({
|
|
138
|
+
$patch: {
|
|
139
|
+
source: { type: 'object' },
|
|
140
|
+
with: [{ op: 'move', from: '/a', path: '/b' }]
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
} catch (e) {
|
|
144
|
+
threw = e.message.includes('unsupported op')
|
|
145
|
+
}
|
|
146
|
+
assert(threw, '$patch: unsupported op throws with clear message')
|
|
147
|
+
|
|
148
|
+
// remove on non-existent path throws
|
|
149
|
+
let removeMissingThrew = false
|
|
150
|
+
try {
|
|
151
|
+
expandMergePatch({
|
|
152
|
+
$patch: {
|
|
153
|
+
source: { type: 'object', properties: { x: { type: 'string' } } },
|
|
154
|
+
with: [{ op: 'remove', path: '/properties/y' }]
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
} catch (e) {
|
|
158
|
+
removeMissingThrew = e.message.includes('cannot remove non-existent path')
|
|
159
|
+
}
|
|
160
|
+
assert(removeMissingThrew, '$patch: remove on non-existent path throws')
|
|
161
|
+
|
|
162
|
+
// replace on non-existent path throws
|
|
163
|
+
let replaceMissingThrew = false
|
|
164
|
+
try {
|
|
165
|
+
expandMergePatch({
|
|
166
|
+
$patch: {
|
|
167
|
+
source: { type: 'object', properties: { x: { type: 'string' } } },
|
|
168
|
+
with: [{ op: 'replace', path: '/properties/z', value: { type: 'number' } }]
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
} catch (e) {
|
|
172
|
+
replaceMissingThrew = e.message.includes('cannot replace non-existent path')
|
|
173
|
+
}
|
|
174
|
+
assert(replaceMissingThrew, '$patch: replace on non-existent path throws')
|
|
175
|
+
|
|
176
|
+
// expansion depth limit: construct deeply nested $merge chain that exceeds limit
|
|
177
|
+
let depthThrew = false
|
|
178
|
+
const buildDeepMerge = (depth) => {
|
|
179
|
+
if (depth === 0) return { type: 'object' }
|
|
180
|
+
return { $merge: { source: buildDeepMerge(depth - 1), with: { properties: { x: { type: 'string' } } } } }
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
expandMergePatch(buildDeepMerge(105))
|
|
184
|
+
} catch (e) {
|
|
185
|
+
depthThrew = e.message.includes('expansion exceeded depth limit')
|
|
186
|
+
}
|
|
187
|
+
assert(depthThrew, '$merge/$patch: expansion depth limit enforced')
|
|
188
|
+
|
|
189
|
+
// schema without $merge/$patch passes through unchanged (no copy)
|
|
190
|
+
const plain = { type: 'object', properties: { x: { type: 'string' } } }
|
|
191
|
+
assert(expandMergePatch(plain) === plain, '$merge/$patch: plain schema passes through without copy')
|
|
192
|
+
|
|
193
|
+
// $merge integration: compiler accepts expanded schema
|
|
194
|
+
const factory2 = AtaCompiler()
|
|
195
|
+
const build2 = factory2({}, { customOptions: {} })
|
|
196
|
+
const mergeSchema = {
|
|
197
|
+
$merge: {
|
|
198
|
+
source: { type: 'object', properties: { n: { type: 'integer' } } },
|
|
199
|
+
with: { required: ['n'] }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const validateMerge = build2({ schema: mergeSchema })
|
|
203
|
+
assert(validateMerge({ n: 1 }) !== false, '$merge compiler integration: valid input accepted')
|
|
204
|
+
assert(validateMerge({}) === false, '$merge compiler integration: missing required rejected')
|
|
205
|
+
|
|
206
|
+
// --- $ref resolvability checks ---
|
|
207
|
+
|
|
208
|
+
// (a) anchor-style local ref that does not exist -> must throw
|
|
209
|
+
{
|
|
210
|
+
const factory3 = AtaCompiler()
|
|
211
|
+
const build3 = factory3({}, { customOptions: {} })
|
|
212
|
+
let threw = false
|
|
213
|
+
let thrownMsg = ''
|
|
214
|
+
try {
|
|
215
|
+
build3({ schema: { type: 'object', properties: { name: { $ref: '#notExist' } } } })
|
|
216
|
+
} catch (e) {
|
|
217
|
+
threw = true
|
|
218
|
+
thrownMsg = e.message
|
|
219
|
+
}
|
|
220
|
+
assert(threw, 'ref-check: #notExist with no externals throws')
|
|
221
|
+
assert(thrownMsg === "can't resolve reference #notExist from id #",
|
|
222
|
+
`ref-check: #notExist message exact (got: ${thrownMsg})`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// (b) external ref with no matching external schema -> must throw
|
|
226
|
+
{
|
|
227
|
+
const factory4 = AtaCompiler()
|
|
228
|
+
const build4 = factory4({}, { customOptions: {} })
|
|
229
|
+
let threw = false
|
|
230
|
+
let thrownMsg = ''
|
|
231
|
+
try {
|
|
232
|
+
build4({ schema: { type: 'object', properties: { id: { $ref: 'encapsulation#/properties/id' } } } })
|
|
233
|
+
} catch (e) {
|
|
234
|
+
threw = true
|
|
235
|
+
thrownMsg = e.message
|
|
236
|
+
}
|
|
237
|
+
assert(threw, 'ref-check: encapsulation#/properties/id with no externals throws')
|
|
238
|
+
assert(thrownMsg === "can't resolve reference encapsulation#/properties/id from id #",
|
|
239
|
+
`ref-check: encapsulation message exact (got: ${thrownMsg})`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// (c) external ref WITH matching external schema -> must NOT throw
|
|
243
|
+
{
|
|
244
|
+
const factory5 = AtaCompiler()
|
|
245
|
+
const build5 = factory5(
|
|
246
|
+
{ encapsulation: { $id: 'encapsulation', type: 'object', properties: { id: { type: 'number' } } } },
|
|
247
|
+
{ customOptions: {} }
|
|
248
|
+
)
|
|
249
|
+
let threw = false
|
|
250
|
+
try {
|
|
251
|
+
build5({ schema: { type: 'object', properties: { id: { $ref: 'encapsulation#/properties/id' } } } })
|
|
252
|
+
} catch (e) {
|
|
253
|
+
threw = true
|
|
254
|
+
}
|
|
255
|
+
assert(!threw, 'ref-check: encapsulation#/properties/id with external schema does NOT throw')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// (d) local JSON-pointer ref -> always accepted even when definitions absent
|
|
259
|
+
{
|
|
260
|
+
const factory6 = AtaCompiler()
|
|
261
|
+
const build6 = factory6({}, { customOptions: {} })
|
|
262
|
+
let threw = false
|
|
263
|
+
try {
|
|
264
|
+
build6({ schema: { type: 'object', properties: { x: { $ref: '#/definitions/x' } } } })
|
|
265
|
+
} catch (e) {
|
|
266
|
+
threw = true
|
|
267
|
+
}
|
|
268
|
+
assert(!threw, 'ref-check: local JSON-pointer #/definitions/x does NOT throw (conservative)')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// (e) anchor that EXISTS as $id in a local definition -> must NOT throw
|
|
272
|
+
{
|
|
273
|
+
const factory7 = AtaCompiler()
|
|
274
|
+
const build7 = factory7({}, { customOptions: {} })
|
|
275
|
+
let threw = false
|
|
276
|
+
try {
|
|
277
|
+
build7({
|
|
278
|
+
schema: {
|
|
279
|
+
type: 'object',
|
|
280
|
+
definitions: { addr: { $id: '#addr', type: 'string' } },
|
|
281
|
+
properties: { city: { $ref: '#addr' } }
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
} catch (e) {
|
|
285
|
+
threw = true
|
|
286
|
+
}
|
|
287
|
+
assert(!threw, 'ref-check: anchor #addr existing as $id in local definitions does NOT throw')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// (f) trailing-slash URI variant: external key 'http://fastify.test/' must
|
|
291
|
+
// satisfy ref base 'http://fastify.test' (RFC 3986 normalization, ajv resolves it)
|
|
292
|
+
{
|
|
293
|
+
const factory8 = AtaCompiler()
|
|
294
|
+
const build8 = factory8(
|
|
295
|
+
{ 'http://fastify.test/': { $id: 'http://fastify.test/', type: 'object', properties: { hello: { type: 'string' } } } },
|
|
296
|
+
{ customOptions: {} }
|
|
297
|
+
)
|
|
298
|
+
let threw = false
|
|
299
|
+
let thrownMsg = ''
|
|
300
|
+
try {
|
|
301
|
+
build8({ schema: { type: 'array', items: { $ref: 'http://fastify.test#/properties/hello' } } })
|
|
302
|
+
} catch (e) {
|
|
303
|
+
threw = true
|
|
304
|
+
thrownMsg = e.message
|
|
305
|
+
}
|
|
306
|
+
assert(!threw, `ref-check: trailing-slash URI id variant does NOT throw (got: ${thrownMsg})`)
|
|
307
|
+
}
|
|
308
|
+
|
|
65
309
|
console.log(`\n${pass}/${pass + fail} tests passed\n`)
|
|
66
310
|
process.exit(fail > 0 ? 1 : 0)
|
|
67
311
|
}
|
package/test.js
CHANGED
|
@@ -59,7 +59,7 @@ async function run() {
|
|
|
59
59
|
const r3 = await app.inject({
|
|
60
60
|
method: 'POST',
|
|
61
61
|
url: '/user',
|
|
62
|
-
payload: { name:
|
|
62
|
+
payload: { name: {} }, // objects never coerce to string, so this stays a type error under Fastify-parity coercion defaults
|
|
63
63
|
})
|
|
64
64
|
assert(r3.statusCode === 400, `wrong type returns 400 (got ${r3.statusCode})`)
|
|
65
65
|
|
|
@@ -103,7 +103,7 @@ async function run() {
|
|
|
103
103
|
const r4 = await app.inject({
|
|
104
104
|
method: 'POST',
|
|
105
105
|
url: '/user',
|
|
106
|
-
payload: { name:
|
|
106
|
+
payload: { name: {} }, // objects never coerce to string, so this stays a type error under Fastify-parity coercion defaults
|
|
107
107
|
})
|
|
108
108
|
const errBody4 = JSON.parse(r4.payload)
|
|
109
109
|
assert(r4.statusCode === 400, `ajv-style: returns 400 (got ${r4.statusCode})`)
|
|
@@ -205,18 +205,32 @@ async function run() {
|
|
|
205
205
|
await app7.close()
|
|
206
206
|
|
|
207
207
|
// 14. additionalProperties: false
|
|
208
|
+
// Default config mirrors Fastify's stock validator: removeAdditional is on,
|
|
209
|
+
// so extra properties are stripped before the handler, not rejected.
|
|
208
210
|
const app8 = fastify()
|
|
209
211
|
await app8.register(fastifyAta)
|
|
210
212
|
app8.post('/strict', {
|
|
211
213
|
schema: { body: { type: 'object', properties: { id: { type: 'integer' } }, additionalProperties: false } },
|
|
212
|
-
}, (req, reply) => reply.send({
|
|
214
|
+
}, (req, reply) => reply.send({ body: req.body }))
|
|
213
215
|
await app8.ready()
|
|
214
216
|
const rs1 = await app8.inject({ method: 'POST', url: '/strict', payload: { id: 1 } })
|
|
215
217
|
const rs2 = await app8.inject({ method: 'POST', url: '/strict', payload: { id: 1, extra: 'x' } })
|
|
216
218
|
assert(rs1.statusCode === 200, 'additionalProperties: valid accepted')
|
|
217
|
-
assert(rs2.statusCode ===
|
|
219
|
+
assert(rs2.statusCode === 200, `additionalProperties: extra stripped, request accepted (got ${rs2.statusCode})`)
|
|
220
|
+
assert(!('extra' in JSON.parse(rs2.payload).body), 'additionalProperties: extra property removed from body')
|
|
218
221
|
await app8.close()
|
|
219
222
|
|
|
223
|
+
// 14b. removeAdditional: false restores strict rejection.
|
|
224
|
+
const app8b = fastify()
|
|
225
|
+
await app8b.register(fastifyAta, { removeAdditional: false })
|
|
226
|
+
app8b.post('/strict', {
|
|
227
|
+
schema: { body: { type: 'object', properties: { id: { type: 'integer' } }, additionalProperties: false } },
|
|
228
|
+
}, (req, reply) => reply.send({ ok: true }))
|
|
229
|
+
await app8b.ready()
|
|
230
|
+
const rs3 = await app8b.inject({ method: 'POST', url: '/strict', payload: { id: 1, extra: 'x' } })
|
|
231
|
+
assert(rs3.statusCode === 400, `additionalProperties: extra rejected with removeAdditional: false (got ${rs3.statusCode})`)
|
|
232
|
+
await app8b.close()
|
|
233
|
+
|
|
220
234
|
// 15. array items validation
|
|
221
235
|
const app9 = fastify()
|
|
222
236
|
await app9.register(fastifyAta)
|
|
@@ -393,7 +407,7 @@ async function run() {
|
|
|
393
407
|
const t3 = await tApp.inject({
|
|
394
408
|
method: 'POST',
|
|
395
409
|
url: '/user',
|
|
396
|
-
payload: { name:
|
|
410
|
+
payload: { name: {} }, // objects never coerce to string, so this stays a type error under Fastify-parity coercion defaults
|
|
397
411
|
})
|
|
398
412
|
assert(t3.statusCode === 400, `turbo: wrong type returns 400 (got ${t3.statusCode})`)
|
|
399
413
|
|