fast-json-stringify 7.1.3 → 7.1.5

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/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  /* eslint no-prototype-builtins: 0 */
4
4
 
5
5
  const { RefResolver } = require('json-schema-ref-resolver')
6
+ const { MergeError } = require('@fastify/merge-json-schemas')
6
7
 
7
8
  const Serializer = require('./lib/serializer')
8
9
  const Validator = require('./lib/validator')
@@ -87,6 +88,19 @@ const validLargeArrayMechanisms = new Set([
87
88
  'json-stringify'
88
89
  ])
89
90
 
91
+ // Keywords that do not change the output, so a $ref carrying only these
92
+ // can still be dereferenced directly instead of merged with its target.
93
+ const IGNORED_REF_SIBLING_KEYWORDS = new Set([
94
+ '$ref',
95
+ '$comment',
96
+ 'title',
97
+ 'description',
98
+ 'examples',
99
+ 'deprecated',
100
+ 'readOnly',
101
+ 'writeOnly'
102
+ ])
103
+
90
104
  let schemaIdCounter = 0
91
105
 
92
106
  function getMaxDepth (options) {
@@ -223,10 +237,12 @@ function isValidSchema (schema, name) {
223
237
 
224
238
  function resolveRef (context, location) {
225
239
  const seen = new Set()
240
+ const siblingLocations = []
226
241
  let depth = 0
227
242
 
228
243
  while (location.schema.$ref !== undefined) {
229
- const ref = location.schema.$ref
244
+ const refSchema = location.schema
245
+ const ref = refSchema.$ref
230
246
  const locationRef = location.getSchemaRef()
231
247
 
232
248
  if (seen.has(locationRef)) {
@@ -251,10 +267,45 @@ function resolveRef (context, location) {
251
267
  throw new Error(`Cannot find reference "${ref}"`)
252
268
  }
253
269
 
270
+ const siblingSchema = {}
271
+ for (const key in refSchema) {
272
+ if (!IGNORED_REF_SIBLING_KEYWORDS.has(key)) {
273
+ siblingSchema[key] = refSchema[key]
274
+ }
275
+ }
276
+
277
+ if (Object.keys(siblingSchema).length !== 0) {
278
+ siblingLocations.unshift(new Location(
279
+ cloneOriginSchema(context, siblingSchema, location.schemaId),
280
+ location.schemaId,
281
+ location.jsonPointer
282
+ ))
283
+ }
284
+
254
285
  location = new Location(schema, schemaId, jsonPointer)
255
286
  }
256
287
 
257
- return location
288
+ if (siblingLocations.length === 0) {
289
+ return location
290
+ }
291
+
292
+ // Keyed by content, not by identity: a merged schema is cloned on every
293
+ // merge, so a recursive $ref would otherwise be merged again on each level.
294
+ const mergedSchemaKey = location.getSchemaRef() + JSON.stringify(siblingLocations.map((l) => l.schema))
295
+
296
+ let mergedSchemaId = context.mergedRefsIds.get(mergedSchemaKey)
297
+ if (mergedSchemaId === undefined) {
298
+ mergedSchemaId = `__fjs_merged_${schemaIdCounter++}`
299
+ try {
300
+ mergeLocations(context, mergedSchemaId, [location, ...siblingLocations])
301
+ } catch (err) {
302
+ if (!(err instanceof MergeError)) throw err
303
+ return location
304
+ }
305
+ context.mergedRefsIds.set(mergedSchemaKey, mergedSchemaId)
306
+ }
307
+
308
+ return getMergedLocation(context, mergedSchemaId)
258
309
  }
259
310
 
260
311
  function getSchemaDependencies (refResolver, schemaId) {
@@ -375,6 +426,7 @@ function build (schema, options) {
375
426
  validatorSchemasIds: new Set(),
376
427
  validatorSchemaRefs: new Set(),
377
428
  mergedSchemasIds: new Map(),
429
+ mergedRefsIds: new Map(),
378
430
  maxDepth,
379
431
  recursiveSchemas: new Set(),
380
432
  recursivePaths: new Set(),
package/lib/validator.js CHANGED
@@ -6,6 +6,18 @@ const fastUri = require('fast-uri')
6
6
  const ajvFormats = require('ajv-formats')
7
7
  const clone = require('rfdc')({ proto: true })
8
8
 
9
+ // Keywords whose value is a map of <name> -> schema rather than a schema.
10
+ // Their keys are user-defined, so an entry named "propertyNames" is a
11
+ // regular schema and must still be converted.
12
+ const SCHEMA_MAP_KEYWORDS = new Set([
13
+ 'properties',
14
+ 'patternProperties',
15
+ 'definitions',
16
+ '$defs',
17
+ 'dependentSchemas',
18
+ 'dependencies'
19
+ ])
20
+
9
21
  class Validator {
10
22
  constructor (ajvOptions, inlineValidators) {
11
23
  const codeOptions = inlineValidators
@@ -90,7 +102,16 @@ class Validator {
90
102
  schema.type.push('object')
91
103
  }
92
104
  for (const property in schema) {
93
- if (typeof schema[property] === 'object') {
105
+ if (typeof schema[property] !== 'object') continue
106
+ // propertyNames validates object keys, which are always strings:
107
+ // adding "object" + fjs_type there only triggers Ajv strictTypes warnings
108
+ if (property === 'propertyNames') continue
109
+
110
+ if (SCHEMA_MAP_KEYWORDS.has(property)) {
111
+ for (const key in schema[property]) {
112
+ this.convertSchemaToAjvFormat(schema[property][key])
113
+ }
114
+ } else {
94
115
  this.convertSchemaToAjvFormat(schema[property])
95
116
  }
96
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fast-json-stringify",
3
- "version": "7.1.3",
3
+ "version": "7.1.5",
4
4
  "description": "Stringify your JSON at max speed",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -852,3 +852,105 @@ test('invalid anyOf schema', (t) => {
852
852
  t.assert.ok(err.message.includes('schema is invalid'))
853
853
  }
854
854
  })
855
+
856
+ test('anyOf branch with propertyNames does not emit Ajv strictTypes warnings', (t) => {
857
+ t.plan(7)
858
+
859
+ const warnMock = t.mock.method(console, 'warn')
860
+
861
+ const stringify = build({
862
+ type: 'object',
863
+ definitions: {
864
+ KeySchema: { type: 'string', minLength: 1 }
865
+ },
866
+ properties: {
867
+ fields: {
868
+ anyOf: [
869
+ { type: 'object', propertyNames: { type: 'string' }, additionalProperties: { type: 'string' } },
870
+ { type: 'null' }
871
+ ]
872
+ },
873
+ nestedCombinators: {
874
+ anyOf: [
875
+ {
876
+ type: 'object',
877
+ propertyNames: {
878
+ allOf: [{ type: 'string', minLength: 1 }, { $ref: '#/definitions/KeySchema' }],
879
+ anyOf: [{ type: 'string', pattern: '^[a-z]+$' }],
880
+ oneOf: [{ type: 'string' }],
881
+ not: { type: 'string', enum: ['forbidden'] }
882
+ },
883
+ additionalProperties: { type: 'string' }
884
+ },
885
+ { type: 'null' }
886
+ ]
887
+ }
888
+ }
889
+ })
890
+
891
+ t.assert.equal(stringify({ fields: { a: 'b' }, nestedCombinators: { abc: 'x' } }), '{"fields":{"a":"b"},"nestedCombinators":{"abc":"x"}}')
892
+ t.assert.equal(stringify({ fields: { a: new Date('2026-01-01T00:00:00.000Z') }, nestedCombinators: null }), '{"fields":{"a":"2026-01-01T00:00:00.000Z"},"nestedCombinators":null}')
893
+ t.assert.equal(stringify({ fields: null, nestedCombinators: null }), '{"fields":null,"nestedCombinators":null}')
894
+ t.assert.throws(() => stringify({ fields: { a: 'b' }, nestedCombinators: { forbidden: 'x' } }))
895
+ t.assert.throws(() => stringify({ fields: { a: 'b' }, nestedCombinators: { 123: 'x' } }))
896
+ t.assert.throws(() => stringify({ fields: 123, nestedCombinators: null }))
897
+ t.assert.equal(warnMock.mock.callCount(), 0)
898
+ })
899
+
900
+ test('anyOf branch with user-defined property named propertyNames preserves Date serialization', (t) => {
901
+ t.plan(3)
902
+
903
+ const warnMock = t.mock.method(console, 'warn')
904
+
905
+ const stringify = build({
906
+ type: 'object',
907
+ properties: {
908
+ data: {
909
+ anyOf: [
910
+ {
911
+ type: 'object',
912
+ properties: {
913
+ propertyNames: { type: 'string', format: 'date-time' }
914
+ },
915
+ required: ['propertyNames']
916
+ },
917
+ { type: 'null' }
918
+ ]
919
+ }
920
+ }
921
+ })
922
+
923
+ t.assert.equal(
924
+ stringify({ data: { propertyNames: new Date('2026-01-01T00:00:00.000Z') } }),
925
+ '{"data":{"propertyNames":"2026-01-01T00:00:00.000Z"}}'
926
+ )
927
+ t.assert.equal(stringify({ data: null }), '{"data":null}')
928
+ t.assert.equal(warnMock.mock.callCount(), 0)
929
+ })
930
+
931
+ test('$defs entry named propertyNames is still converted (Date serialization)', (t) => {
932
+ t.plan(2)
933
+
934
+ const warnMock = t.mock.method(console, 'warn')
935
+
936
+ const stringify = build({
937
+ type: 'object',
938
+ $defs: {
939
+ propertyNames: { type: 'string', format: 'date-time' }
940
+ },
941
+ properties: {
942
+ data: {
943
+ anyOf: [
944
+ { $ref: '#/$defs/propertyNames' },
945
+ { type: 'null' }
946
+ ]
947
+ }
948
+ }
949
+ })
950
+
951
+ t.assert.equal(
952
+ stringify({ data: new Date('2026-01-01T00:00:00.000Z') }),
953
+ '{"data":"2026-01-01T00:00:00.000Z"}'
954
+ )
955
+ t.assert.equal(warnMock.mock.callCount(), 0)
956
+ })
package/test/ref.test.js CHANGED
@@ -2075,3 +2075,230 @@ test('ref nested', (t) => {
2075
2075
  t.assert.doesNotThrow(() => JSON.parse(output))
2076
2076
  t.assert.equal(output, '{"str":"test"}')
2077
2077
  })
2078
+
2079
+ test('ref internal - sibling keywords', (t) => {
2080
+ t.plan(3)
2081
+
2082
+ const schema = {
2083
+ definitions: {
2084
+ def: {
2085
+ type: 'object',
2086
+ properties: {
2087
+ str: {
2088
+ type: 'string'
2089
+ },
2090
+ num: {
2091
+ type: 'integer'
2092
+ }
2093
+ },
2094
+ required: ['str']
2095
+ }
2096
+ },
2097
+ type: 'object',
2098
+ properties: {
2099
+ obj: {
2100
+ $ref: '#/definitions/def',
2101
+ required: ['num']
2102
+ }
2103
+ }
2104
+ }
2105
+
2106
+ const object = {
2107
+ obj: {
2108
+ str: 'test',
2109
+ num: 42
2110
+ }
2111
+ }
2112
+
2113
+ const stringify = build(schema)
2114
+ const output = stringify(object)
2115
+
2116
+ t.assert.doesNotThrow(() => JSON.parse(output))
2117
+ t.assert.equal(output, '{"obj":{"str":"test","num":42}}')
2118
+
2119
+ t.assert.throws(() => {
2120
+ stringify({
2121
+ obj: {
2122
+ str: 'test'
2123
+ }
2124
+ })
2125
+ }, { message: '"num" is required!' })
2126
+ })
2127
+
2128
+ test('ref external - sibling keywords', (t) => {
2129
+ t.plan(3)
2130
+
2131
+ const externalSchema = {
2132
+ external: {
2133
+ definitions: {
2134
+ def: {
2135
+ type: 'object',
2136
+ properties: {
2137
+ str: {
2138
+ type: 'string'
2139
+ },
2140
+ num: {
2141
+ type: 'integer'
2142
+ }
2143
+ },
2144
+ required: ['str']
2145
+ }
2146
+ }
2147
+ }
2148
+ }
2149
+
2150
+ const schema = {
2151
+ type: 'object',
2152
+ properties: {
2153
+ obj: {
2154
+ $ref: 'external#/definitions/def',
2155
+ required: ['num']
2156
+ }
2157
+ }
2158
+ }
2159
+
2160
+ const object = {
2161
+ obj: {
2162
+ str: 'test',
2163
+ num: 42
2164
+ }
2165
+ }
2166
+
2167
+ const stringify = build(schema, { schema: externalSchema })
2168
+ const output = stringify(object)
2169
+
2170
+ t.assert.doesNotThrow(() => JSON.parse(output))
2171
+ t.assert.equal(output, '{"obj":{"str":"test","num":42}}')
2172
+
2173
+ t.assert.throws(() => {
2174
+ stringify({
2175
+ obj: {
2176
+ str: 'test'
2177
+ }
2178
+ })
2179
+ }, { message: '"num" is required!' })
2180
+ })
2181
+
2182
+ test('ref external - recursive sibling keywords', (t) => {
2183
+ t.plan(3)
2184
+
2185
+ const externalSchema = {
2186
+ node: {
2187
+ $id: 'node',
2188
+ type: 'object',
2189
+ properties: {
2190
+ str: {
2191
+ type: 'string'
2192
+ },
2193
+ next: {
2194
+ $ref: 'node#',
2195
+ required: ['str']
2196
+ }
2197
+ }
2198
+ }
2199
+ }
2200
+
2201
+ const schema = {
2202
+ type: 'object',
2203
+ properties: {
2204
+ root: {
2205
+ $ref: 'node#',
2206
+ required: ['str']
2207
+ }
2208
+ }
2209
+ }
2210
+
2211
+ const object = {
2212
+ root: {
2213
+ str: 'test',
2214
+ next: {
2215
+ str: 'nested'
2216
+ }
2217
+ }
2218
+ }
2219
+
2220
+ const stringify = build(schema, { schema: externalSchema })
2221
+ const output = stringify(object)
2222
+
2223
+ t.assert.doesNotThrow(() => JSON.parse(output))
2224
+ t.assert.equal(output, '{"root":{"str":"test","next":{"str":"nested"}}}')
2225
+
2226
+ t.assert.throws(() => {
2227
+ stringify({
2228
+ root: {
2229
+ str: 'test',
2230
+ next: {}
2231
+ }
2232
+ })
2233
+ }, { message: '"str" is required!' })
2234
+ })
2235
+
2236
+ test('ref internal - conflicting sibling keywords', (t) => {
2237
+ t.plan(2)
2238
+
2239
+ const schema = {
2240
+ definitions: {
2241
+ def: {
2242
+ type: 'string'
2243
+ }
2244
+ },
2245
+ type: 'object',
2246
+ properties: {
2247
+ value: {
2248
+ $ref: '#/definitions/def',
2249
+ type: 'integer'
2250
+ }
2251
+ }
2252
+ }
2253
+
2254
+ const object = {
2255
+ value: 42
2256
+ }
2257
+
2258
+ const stringify = build(schema)
2259
+ const output = stringify(object)
2260
+
2261
+ t.assert.doesNotThrow(() => JSON.parse(output))
2262
+ t.assert.equal(output, '{"value":"42"}')
2263
+ })
2264
+
2265
+ test('ref external - sibling keywords with a duplicated anchor', (t) => {
2266
+ t.plan(1)
2267
+
2268
+ const externalSchema = {
2269
+ external: {
2270
+ $id: 'external',
2271
+ definitions: {
2272
+ def: {
2273
+ type: 'object',
2274
+ properties: {
2275
+ str: {
2276
+ $id: '#anchor',
2277
+ type: 'string'
2278
+ }
2279
+ }
2280
+ }
2281
+ }
2282
+ }
2283
+ }
2284
+
2285
+ const schema = {
2286
+ type: 'object',
2287
+ properties: {
2288
+ obj: {
2289
+ $ref: 'external#/definitions/def',
2290
+ properties: {
2291
+ num: {
2292
+ $id: '#anchor',
2293
+ type: 'integer'
2294
+ }
2295
+ }
2296
+ }
2297
+ }
2298
+ }
2299
+
2300
+ t.assert.throws(
2301
+ () => build(schema, { schema: externalSchema }),
2302
+ { message: /There is already another anchor "#anchor"/ }
2303
+ )
2304
+ })