Skip to content

Commit 1c120dd

Browse files
fix: date/time ajv validation (#441)
1 parent 47a0673 commit 1c120dd

File tree

2 files changed

+130
-19
lines changed

2 files changed

+130
-19
lines changed

index.js

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,29 @@ function build (schema, options) {
233233
ajvInstance = new Ajv({ ...options.ajv, strictSchema: false, uriResolver: fastUri })
234234
ajvFormats(ajvInstance)
235235

236+
const validateDateTimeFormat = ajvFormats.get('date-time').validate
237+
const validateDateFormat = ajvFormats.get('date').validate
238+
const validateTimeFormat = ajvFormats.get('time').validate
239+
240+
ajvInstance.addKeyword({
241+
keyword: 'fjs_date_type',
242+
validate: (schema, date) => {
243+
if (date instanceof Date) {
244+
return true
245+
}
246+
if (schema === 'date-time') {
247+
return validateDateTimeFormat(date)
248+
}
249+
if (schema === 'date') {
250+
return validateDateFormat(date)
251+
}
252+
if (schema === 'time') {
253+
return validateTimeFormat(date)
254+
}
255+
return false
256+
}
257+
})
258+
236259
isValidSchema(schema)
237260
if (options.schema) {
238261
// eslint-disable-next-line
@@ -367,12 +390,6 @@ function inferTypeByKeyword (schema) {
367390
return schema.type
368391
}
369392

370-
const stringSerializerMap = {
371-
'date-time': 'serializer.asDatetime.bind(serializer)',
372-
date: 'serializer.asDate.bind(serializer)',
373-
time: 'serializer.asTime.bind(serializer)'
374-
}
375-
376393
function getStringSerializer (format, nullable) {
377394
switch (format) {
378395
case 'date-time': return nullable ? 'serializer.asDatetimeNullable.bind(serializer)' : 'serializer.asDatetime.bind(serializer)'
@@ -382,10 +399,6 @@ function getStringSerializer (format, nullable) {
382399
}
383400
}
384401

385-
function getTestSerializer (format) {
386-
return stringSerializerMap[format]
387-
}
388-
389402
function addPatternProperties (location) {
390403
const schema = location.schema
391404
const pp = schema.patternProperties
@@ -1150,25 +1163,25 @@ function buildValue (laterCode, locationPath, input, location, isArray) {
11501163
const locations = dereferenceOfRefs(location, schema.anyOf ? 'anyOf' : 'oneOf')
11511164
locations.forEach((location, index) => {
11521165
const nestedResult = buildValue(laterCode, locationPath + 'i' + index, input, location, isArray)
1153-
// We need a test serializer as the String serializer will not work with
1154-
// date/time ajv validations
1155-
// see: https://github.com/fastify/fast-json-stringify/issues/325
1156-
const testSerializer = getTestSerializer(location.schema.format)
1157-
const testValue = testSerializer !== undefined ? `${testSerializer}(${input}, true)` : `${input}`
1158-
11591166
// Since we are only passing the relevant schema to ajv.validate, it needs to be full dereferenced
11601167
// otherwise any $ref pointing to an external schema would result in an error.
11611168
// Full dereference of the schema happens as side effect of two functions:
11621169
// 1. `dereferenceOfRefs` loops through the `schema.anyOf`` array and replaces any top level reference
11631170
// with the actual schema
1164-
// 2. `nested`, through `buildCode`, replaces any reference in object properties with the actual schema
1171+
// 2. `buildValue`, through `buildCode`, replaces any reference in object properties with the actual schema
11651172
// (see https://github.com/fastify/fast-json-stringify/blob/6da3b3e8ac24b1ca5578223adedb4083b7adf8db/index.js#L631)
11661173

1174+
// Ajv does not support js date format. In order to properly validate objects containing a date,
1175+
// it needs to replace all occurrences of the string date format with a custom keyword fjs_date_type.
1176+
// (see https://github.com/fastify/fast-json-stringify/pull/441)
1177+
const extendedSchema = clone(location.schema)
1178+
extendDateTimeType(extendedSchema)
1179+
11671180
const schemaKey = location.schema.$id || randomUUID()
1168-
ajvInstance.addSchema(location.schema, schemaKey)
1181+
ajvInstance.addSchema(extendedSchema, schemaKey)
11691182

11701183
code += `
1171-
${index === 0 ? 'if' : 'else if'}(ajv.validate("${schemaKey}", ${testValue}))
1184+
${index === 0 ? 'if' : 'else if'}(ajv.validate("${schemaKey}", ${input}))
11721185
${nestedResult.code}
11731186
`
11741187
laterCode = nestedResult.laterCode
@@ -1261,6 +1274,19 @@ function buildValue (laterCode, locationPath, input, location, isArray) {
12611274
return { code, laterCode }
12621275
}
12631276

1277+
function extendDateTimeType (schema) {
1278+
if (schema.type === 'string' && ['date-time', 'date', 'time'].includes(schema.format)) {
1279+
schema.fjs_date_type = schema.format
1280+
delete schema.type
1281+
delete schema.format
1282+
}
1283+
for (const property in schema) {
1284+
if (typeof schema[property] === 'object') {
1285+
extendDateTimeType(schema[property])
1286+
}
1287+
}
1288+
}
1289+
12641290
function isEmpty (schema) {
12651291
// eslint-disable-next-line
12661292
for (var key in schema) {

test/anyof.test.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict'
22

3+
const { DateTime } = require('luxon')
34
const { test } = require('tap')
45
const build = require('..')
56

@@ -468,6 +469,90 @@ test('anyOf object with field date-time of type string with format or null', (t)
468469
}), `{"prop":"${toStringify.toISOString()}"}`)
469470
})
470471

472+
test('anyOf object with nested field date-time of type string with format or null', (t) => {
473+
t.plan(1)
474+
const withOneOfSchema = {
475+
type: 'object',
476+
properties: {
477+
prop: {
478+
anyOf: [{
479+
type: 'object',
480+
properties: {
481+
nestedProp: {
482+
type: 'string',
483+
format: 'date-time'
484+
}
485+
}
486+
}]
487+
}
488+
}
489+
}
490+
491+
const withOneOfStringify = build(withOneOfSchema)
492+
493+
const data = {
494+
prop: { nestedProp: new Date() }
495+
}
496+
497+
t.equal(withOneOfStringify(data), JSON.stringify(data))
498+
})
499+
500+
test('anyOf object with nested field date of type string with format or null', (t) => {
501+
t.plan(1)
502+
const withOneOfSchema = {
503+
type: 'object',
504+
properties: {
505+
prop: {
506+
anyOf: [{
507+
type: 'object',
508+
properties: {
509+
nestedProp: {
510+
type: 'string',
511+
format: 'date'
512+
}
513+
}
514+
}]
515+
}
516+
}
517+
}
518+
519+
const withOneOfStringify = build(withOneOfSchema)
520+
521+
const data = {
522+
prop: { nestedProp: new Date() }
523+
}
524+
525+
t.equal(withOneOfStringify(data), `{"prop":{"nestedProp":"${DateTime.fromJSDate(data.prop.nestedProp).toISODate()}"}}`)
526+
})
527+
528+
test('anyOf object with nested field time of type string with format or null', (t) => {
529+
t.plan(1)
530+
const withOneOfSchema = {
531+
type: 'object',
532+
properties: {
533+
prop: {
534+
anyOf: [{
535+
type: 'object',
536+
properties: {
537+
nestedProp: {
538+
type: 'string',
539+
format: 'time'
540+
}
541+
}
542+
}]
543+
}
544+
}
545+
}
546+
547+
const withOneOfStringify = build(withOneOfSchema)
548+
549+
const data = {
550+
prop: { nestedProp: new Date() }
551+
}
552+
553+
t.equal(withOneOfStringify(data), `{"prop":{"nestedProp":"${DateTime.fromJSDate(data.prop.nestedProp).toFormat('HH:mm:ss')}"}}`)
554+
})
555+
471556
test('anyOf object with field date of type string with format or null', (t) => {
472557
t.plan(1)
473558
const toStringify = '2011-01-01'

0 commit comments

Comments
 (0)