Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1151,7 +1151,7 @@ function buildOneOf (context, location, input) {
}

const nestedResult = buildValue(context, mergedLocation, input)
const schemaRef = optionLocation.getSchemaRef()
const schemaRef = Validator.encodeRefFragment(optionLocation.getSchemaRef())

code += `
${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) {
Expand Down Expand Up @@ -1184,7 +1184,7 @@ function buildIfThenElse (context, location, input) {
)

const ifLocation = location.getPropertyLocation('if')
const ifSchemaRef = ifLocation.getSchemaRef()
const ifSchemaRef = Validator.encodeRefFragment(ifLocation.getSchemaRef())

const thenLocation = location.getPropertyLocation('then')
let thenMergedSchemaId = context.mergedSchemasIds.get(thenSchema)
Expand Down
17 changes: 16 additions & 1 deletion lib/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ const ajvFormats = require('ajv-formats')
const clone = require('rfdc')({ proto: true })

class Validator {
// Ajv percent-decodes JSON Pointer URI fragments before looking up schema
// keys, while fast-json-stringify's ref resolver and code generator treat
// schema keys literally. Escaping '%' as '%25' in the fragment makes Ajv's
// decoding return the literal key, so both sides resolve the same schema.
// See https://github.com/fastify/fast-json-stringify/issues/740
static encodeRefFragment (ref) {
const hashIndex = ref.indexOf('#')
if (hashIndex === -1) return ref
return ref.slice(0, hashIndex) + ref.slice(hashIndex).replace(/%/g, '%25')
}

constructor (ajvOptions) {
this.ajv = new Ajv({
...ajvOptions,
Expand Down Expand Up @@ -43,7 +54,7 @@ class Validator {
const ajvSchema = clone(schema)
this.convertSchemaToAjvFormat(ajvSchema)
this.ajv.addSchema(ajvSchema, schemaKey)
this._ajvSchemas[schemaKey] = schema
this._ajvSchemas[schemaKey] = ajvSchema
}
}

Expand All @@ -59,6 +70,10 @@ class Validator {
convertSchemaToAjvFormat (schema) {
if (schema === null) return

if (typeof schema.$ref === 'string' && schema.$ref.includes('%')) {
schema.$ref = Validator.encodeRefFragment(schema.$ref)
}

if (schema.type === 'string') {
schema.fjs_type = 'string'
schema.type = ['string', 'object']
Expand Down
127 changes: 127 additions & 0 deletions test/issue-740.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
'use strict'

const { test } = require('node:test')
const fs = require('fs')
const os = require('os')
const path = require('path')
const build = require('..')

// https://github.com/fastify/fast-json-stringify/issues/740
// Schema keys containing percent-encoded sequences (e.g. '%3C') are resolved
// literally by fast-json-stringify, but the refs handed to ajv were not
// re-encoded, so ajv percent-decoded them and failed to find the keys.
Comment on lines +9 to +12

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to validate this

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the Ajv behavior behind that note. The schema key is literal (Some%3Cloremipsum%3E), but Ajv decodes the ref fragment while resolving it, so %3C/%3E can become </> and miss the literal key.

That’s why the fix goes through encodeRefFragment: it changes % to %25 before Ajv sees the ref, then Ajv’s decode step lands back on the literal % key. The focused issue/standalone run passes 10/10, covering oneOf, anyOf, if/then/else, and generated standalone output.


function buildIssueSchema (composition) {
return {
title: 'object with $ref',
definitions: {
'Some%3Cloremipsum%3E': {
additionalProperties: composition,
type: 'object'
}
},
type: 'object',
properties: {
obj: {
$ref: '#/definitions/Some%3Cloremipsum%3E'
}
}
}
}

const oneOfComposition = {
oneOf: [
{ type: 'string' },
{ type: 'number' },
{ type: 'object' },
{ type: 'null' }
]
}

test('ref to a percent-encoded definition key with oneOf', (t) => {
t.plan(1)

const stringify = build(buildIssueSchema(oneOfComposition))
const output = stringify({ obj: { str: 'test' } })

t.assert.equal(output, '{"obj":{"str":"test"}}')
})

test('ref to a percent-encoded definition key with anyOf', (t) => {
t.plan(1)

const stringify = build(buildIssueSchema({
anyOf: [
{ type: 'string' },
{ type: 'number' }
]
}))
const output = stringify({ obj: { str: 'test' } })

t.assert.equal(output, '{"obj":{"str":"test"}}')
})

test('ref to a percent-encoded definition key with if/then/else', (t) => {
t.plan(2)

const stringify = build({
definitions: {
'Some%3Cloremipsum%3E': {
type: 'object',
properties: { kind: { type: 'string' } },
if: { type: 'object', properties: { kind: { const: 'a' } } },
then: { type: 'object', properties: { kind: { type: 'string' }, a: { type: 'string' } } },
else: { type: 'object', properties: { kind: { type: 'string' }, b: { type: 'string' } } }
}
},
type: 'object',
properties: {
obj: { $ref: '#/definitions/Some%3Cloremipsum%3E' }
}
})

t.assert.equal(stringify({ obj: { kind: 'a', a: 'x' } }), '{"obj":{"kind":"a","a":"x"}}')
t.assert.equal(stringify({ obj: { kind: 'c', b: 'y' } }), '{"obj":{"kind":"c","b":"y"}}')
})

test('property name containing a percent character with oneOf', (t) => {
t.plan(2)

const stringify = build({
type: 'object',
properties: {
'weird%name': {
oneOf: [
{ type: 'string' },
{ type: 'number' }
]
}
}
})

t.assert.equal(stringify({ 'weird%name': 'value' }), '{"weird%name":"value"}')
t.assert.equal(stringify({ 'weird%name': 42 }), '{"weird%name":42}')
})

test('ref to a percent-encoded definition key with oneOf in standalone mode', async (t) => {
t.plan(1)

const code = build(buildIssueSchema(oneOfComposition), { mode: 'standalone' })

// The standalone output does `require('fast-json-stringify/lib/...')`, so the
// generated file must live where that package resolves. Use a per-run temp
// dir and link node_modules into it so nothing is written into the repo tree.
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'fjs-issue-740-'))

t.after(async () => {
await fs.promises.rm(dir, { recursive: true, force: true })
})

await fs.promises.symlink(path.resolve(__dirname, '..', 'node_modules'), path.join(dir, 'node_modules'), 'junction')
const destination = path.join(dir, 'standalone-issue-740.js')

await fs.promises.writeFile(destination, code)
const standalone = require(destination)

t.assert.equal(standalone({ obj: { str: 'test' } }), '{"obj":{"str":"test"}}')
})
30 changes: 30 additions & 0 deletions test/standalone-mode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,33 @@ test('no need to keep external schemas once compiled - with oneOf validator', as
t.assert.equal(stringify({ oneOfSchema: { baz: 5 } }), '{"oneOfSchema":{"baz":5}}')
t.assert.equal(stringify({ oneOfSchema: { bar: 'foo' } }), '{"oneOfSchema":{"bar":"foo"}}')
})

test('standalone serializer validates toJSON objects like the inline serializer', async (t) => {
t.plan(1)

const schema = {
type: 'object',
properties: {
val: {
oneOf: [
{ type: 'string', format: 'date-time' },
{ type: 'number' }
]
}
}
}

const code = fjs(schema, { mode: 'standalone' })

const destination = path.resolve(tmpDir, 'standalone-tojson-oneof.js')

t.after(async () => {
await fs.promises.rm(destination, { force: true })
})

await fs.promises.writeFile(destination, code)
const stringify = require(destination)

const date = new Date(1749556800000)
t.assert.equal(stringify({ val: date }), fjs(schema)({ val: date }))
})