fastify-ata 0.7.0 → 0.8.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 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: true, // convert "42" -> 42 for integer fields
110
- removeAdditional: true, // strip properties not in schema
111
- abortEarly: true, // skip detailed error collection (faster invalid path)
112
- prettyErrors: true, // 400 message carries the ATA code + a did-you-mean
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** - 98.5% compliance on the official JSON Schema Test Suite
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
+ **181 of 187 tests pass** (Fastify v5.8.4 checkout, ata-validator 1.2.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
- validate.errors = firstErrorOnly ? [result.errors[0]] : result.errors
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
- const hasCoercion = !!(opts.coerceTypes || opts.removeAdditional)
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: opts.coerceTypes || false,
23
- removeAdditional: opts.removeAdditional || false,
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.7.0",
3
+ "version": "0.8.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.0.0",
29
+ "ata-validator": "^1.2.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
- assert(validate.errors && validate.errors[0].code, `compiler: rich errors present (got ${JSON.stringify(validate.errors && validate.errors[0])})`)
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: 123 },
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: 123 },
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({ ok: true }))
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 === 400, `additionalProperties: extra rejected (got ${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: 123 },
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