Skip to content

Commit 967bacf

Browse files
fix: string date format validation (#484)
* fix: string date format validation * Add nullable date test
1 parent 0718aba commit 967bacf

File tree

4 files changed

+178
-62
lines changed

4 files changed

+178
-62
lines changed

ajv.js

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,22 @@ const ajvFormats = require('ajv-formats')
77
module.exports = buildAjv
88

99
function buildAjv (options) {
10-
const ajvInstance = new Ajv({ ...options, strictSchema: false, validateSchema: false, uriResolver: fastUri })
11-
ajvFormats(ajvInstance)
10+
const ajvInstance = new Ajv({
11+
...options,
12+
strictSchema: false,
13+
validateSchema: false,
14+
allowUnionTypes: true,
15+
uriResolver: fastUri
16+
})
1217

13-
const validateDateTimeFormat = ajvFormats.get('date-time').validate
14-
const validateDateFormat = ajvFormats.get('date').validate
15-
const validateTimeFormat = ajvFormats.get('time').validate
18+
ajvFormats(ajvInstance)
1619

1720
ajvInstance.addKeyword({
18-
keyword: 'fjs_date_type',
19-
validate: (schema, date) => {
20-
if (date instanceof Date) {
21-
return true
22-
}
23-
if (schema === 'date-time') {
24-
return validateDateTimeFormat(date)
25-
}
26-
if (schema === 'date') {
27-
return validateDateFormat(date)
28-
}
29-
if (schema === 'time') {
30-
return validateTimeFormat(date)
31-
}
32-
return false
21+
keyword: 'fjs_type',
22+
type: 'object',
23+
errors: false,
24+
validate: (type, date) => {
25+
return date instanceof Date
3326
}
3427
})
3528

index.js

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -243,15 +243,6 @@ function inferTypeByKeyword (schema) {
243243
return schema.type
244244
}
245245

246-
function getStringSerializer (format, nullable) {
247-
switch (format) {
248-
case 'date-time': return nullable ? 'serializer.asDatetimeNullable.bind(serializer)' : 'serializer.asDatetime.bind(serializer)'
249-
case 'date': return nullable ? 'serializer.asDateNullable.bind(serializer)' : 'serializer.asDate.bind(serializer)'
250-
case 'time': return nullable ? 'serializer.asTimeNullable.bind(serializer)' : 'serializer.asTime.bind(serializer)'
251-
default: return nullable ? 'serializer.asStringNullable.bind(serializer)' : 'serializer.asString.bind(serializer)'
252-
}
253-
}
254-
255246
function addPatternProperties (location) {
256247
const schema = location.schema
257248
const pp = schema.patternProperties
@@ -484,6 +475,16 @@ function mergeAllOfSchema (location, schema, mergedSchema) {
484475
mergedSchema.anyOf.push(...allOfSchema.anyOf)
485476
}
486477

478+
if (allOfSchema.fjs_type !== undefined) {
479+
if (
480+
mergedSchema.fjs_type !== undefined &&
481+
mergedSchema.fjs_type !== allOfSchema.fjs_type
482+
) {
483+
throw new Error('allOf schemas have different fjs_type values')
484+
}
485+
mergedSchema.fjs_type = allOfSchema.fjs_type
486+
}
487+
487488
if (allOfSchema.allOf !== undefined) {
488489
mergeAllOfSchema(location, allOfSchema, mergedSchema)
489490
}
@@ -790,20 +791,22 @@ function buildValue (location, input) {
790791
location.schema = mergedSchema
791792
}
792793

793-
const type = schema.type
794+
let type = schema.type
794795
const nullable = schema.nullable === true
795796

796797
let code = ''
797798
let funcName
798799

800+
if (schema.fjs_type === 'string' && schema.format === undefined && Array.isArray(schema.type) && schema.type.length === 2) {
801+
type = 'string'
802+
}
803+
799804
switch (type) {
800805
case 'null':
801-
code += `
802-
json += serializer.asNull()
803-
`
806+
code += 'json += serializer.asNull()'
804807
break
805808
case 'string': {
806-
funcName = getStringSerializer(schema.format, nullable)
809+
funcName = nullable ? 'serializer.asStringNullable.bind(serializer)' : 'serializer.asString.bind(serializer)'
807810
code += `json += ${funcName}(${input})`
808811
break
809812
}
@@ -820,19 +823,23 @@ function buildValue (location, input) {
820823
code += `json += ${funcName}(${input})`
821824
break
822825
case 'object':
823-
funcName = buildObject(location)
826+
if (schema.format === 'date-time') {
827+
funcName = nullable ? 'serializer.asDateTimeNullable.bind(serializer)' : 'serializer.asDateTime.bind(serializer)'
828+
} else if (schema.format === 'date') {
829+
funcName = nullable ? 'serializer.asDateNullable.bind(serializer)' : 'serializer.asDate.bind(serializer)'
830+
} else if (schema.format === 'time') {
831+
funcName = nullable ? 'serializer.asTimeNullable.bind(serializer)' : 'serializer.asTime.bind(serializer)'
832+
} else {
833+
funcName = buildObject(location)
834+
}
824835
code += `json += ${funcName}(${input})`
825836
break
826837
case 'array':
827838
funcName = buildArray(location)
828839
code += `json += ${funcName}(${input})`
829840
break
830841
case undefined:
831-
if (schema.fjs_date_type) {
832-
funcName = getStringSerializer(schema.fjs_date_type, nullable)
833-
code += `json += ${funcName}(${input})`
834-
break
835-
} else if (schema.anyOf || schema.oneOf) {
842+
if (schema.anyOf || schema.oneOf) {
836843
// beware: dereferenceOfRefs has side effects and changes schema.anyOf
837844
const type = schema.anyOf ? 'anyOf' : 'oneOf'
838845
const anyOfLocation = mergeLocation(location, type)
@@ -890,7 +897,7 @@ function buildValue (location, input) {
890897
switch (type) {
891898
case 'string': {
892899
code += `
893-
${statement}(${input} === null || typeof ${input} === "${type}" || ${input} instanceof Date || ${input} instanceof RegExp || (typeof ${input} === "object" && Object.hasOwnProperty.call(${input}, "toString")))
900+
${statement}(${input} === null || typeof ${input} === "${type}" || ${input} instanceof RegExp || (typeof ${input} === "object" && Object.hasOwnProperty.call(${input}, "toString")))
894901
${nestedResult}
895902
`
896903
break
@@ -909,6 +916,20 @@ function buildValue (location, input) {
909916
`
910917
break
911918
}
919+
case 'object': {
920+
if (schema.fjs_type) {
921+
code += `
922+
${statement}(${input} instanceof Date || ${input} === null)
923+
${nestedResult}
924+
`
925+
} else {
926+
code += `
927+
${statement}(typeof ${input} === "object" || ${input} === null)
928+
${nestedResult}
929+
`
930+
}
931+
break
932+
}
912933
default: {
913934
code += `
914935
${statement}(typeof ${input} === "${type}" || ${input} === null)
@@ -936,15 +957,21 @@ function buildValue (location, input) {
936957
}
937958

938959
// Ajv does not support js date format. In order to properly validate objects containing a date,
939-
// it needs to replace all occurrences of the string date format with a custom keyword fjs_date_type.
960+
// it needs to replace all occurrences of the string date format with a custom keyword fjs_type.
940961
// (see https://github.com/fastify/fast-json-stringify/pull/441)
941962
function extendDateTimeType (schema) {
942963
if (schema === null) return
943964

944-
if (schema.type === 'string' && ['date-time', 'date', 'time'].includes(schema.format)) {
945-
schema.fjs_date_type = schema.format
946-
delete schema.type
947-
delete schema.format
965+
if (schema.type === 'string') {
966+
schema.fjs_type = 'string'
967+
schema.type = ['string', 'object']
968+
} else if (
969+
Array.isArray(schema.type) &&
970+
schema.type.includes('string') &&
971+
!schema.type.includes('object')
972+
) {
973+
schema.fjs_type = 'string'
974+
schema.type.push('object')
948975
}
949976
for (const property in schema) {
950977
if (typeof schema[property] === 'object') {

serializer.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,36 +69,36 @@ module.exports = class Serializer {
6969
return bool === null ? 'null' : this.asBoolean(bool)
7070
}
7171

72-
asDatetime (date) {
73-
const quotes = '"'
72+
asDateTime (date) {
73+
if (date === null) return '""'
7474
if (date instanceof Date) {
75-
return quotes + date.toISOString() + quotes
75+
return '"' + date.toISOString() + '"'
7676
}
77-
return this.asString(date)
77+
throw new Error(`The value "${date}" cannot be converted to a date-time.`)
7878
}
7979

80-
asDatetimeNullable (date) {
81-
return date === null ? 'null' : this.asDatetime(date)
80+
asDateTimeNullable (date) {
81+
return date === null ? 'null' : this.asDateTime(date)
8282
}
8383

8484
asDate (date) {
85-
const quotes = '"'
85+
if (date === null) return '""'
8686
if (date instanceof Date) {
87-
return quotes + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + quotes
87+
return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + '"'
8888
}
89-
return this.asString(date)
89+
throw new Error(`The value "${date}" cannot be converted to a date.`)
9090
}
9191

9292
asDateNullable (date) {
9393
return date === null ? 'null' : this.asDate(date)
9494
}
9595

9696
asTime (date) {
97-
const quotes = '"'
97+
if (date === null) return '""'
9898
if (date instanceof Date) {
99-
return quotes + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + quotes
99+
return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + '"'
100100
}
101-
return this.asString(date)
101+
throw new Error(`The value "${date}" cannot be converted to a time.`)
102102
}
103103

104104
asTimeNullable (date) {

test/date.test.js

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ test('serializing null value', t => {
308308
})
309309

310310
t.test('type::array', t => {
311-
t.plan(3)
311+
t.plan(6)
312312

313313
t.test('format::date-time', t => {
314314
t.plan(2)
@@ -348,13 +348,13 @@ test('serializing null value', t => {
348348
t.notOk(validate(JSON.parse(output)), 'an empty string is not a date format')
349349
})
350350

351-
t.test('format::time', t => {
351+
t.test('format::date', t => {
352352
t.plan(2)
353353

354354
const prop = {
355355
updatedAt: {
356356
type: ['string'],
357-
format: 'time'
357+
format: 'date'
358358
}
359359
}
360360

@@ -364,7 +364,66 @@ test('serializing null value', t => {
364364
} = serialize(createSchema(prop), input)
365365

366366
t.equal(output, '{"updatedAt":""}')
367-
t.notOk(validate(JSON.parse(output)), 'an empty string is not a time format')
367+
t.notOk(validate(JSON.parse(output)), 'an empty string is not a date format')
368+
})
369+
370+
t.test('format::time, Date object', t => {
371+
t.plan(1)
372+
373+
const schema = {
374+
oneOf: [
375+
{
376+
type: 'object',
377+
properties: {
378+
updatedAt: {
379+
type: ['string', 'number'],
380+
format: 'time'
381+
}
382+
}
383+
}
384+
]
385+
}
386+
387+
const date = new Date()
388+
const input = { updatedAt: date }
389+
const { output } = serialize(schema, input)
390+
391+
t.equal(output, JSON.stringify({ updatedAt: DateTime.fromJSDate(date).toFormat('HH:mm:ss') }))
392+
})
393+
394+
t.test('format::time, Date object', t => {
395+
t.plan(1)
396+
397+
const schema = {
398+
oneOf: [
399+
{
400+
type: ['string', 'number'],
401+
format: 'time'
402+
}
403+
]
404+
}
405+
406+
const date = new Date()
407+
const { output } = serialize(schema, date)
408+
409+
t.equal(output, `"${DateTime.fromJSDate(date).toFormat('HH:mm:ss')}"`)
410+
})
411+
412+
t.test('format::time, Date object', t => {
413+
t.plan(1)
414+
415+
const schema = {
416+
oneOf: [
417+
{
418+
type: ['string', 'number'],
419+
format: 'time'
420+
}
421+
]
422+
}
423+
424+
const { output } = serialize(schema, 42)
425+
426+
t.equal(output, JSON.stringify(42))
368427
})
369428
})
370429

@@ -429,3 +488,40 @@ test('serializing null value', t => {
429488
})
430489
})
431490
})
491+
492+
test('Validate Date object as string type', (t) => {
493+
t.plan(1)
494+
495+
const schema = {
496+
oneOf: [
497+
{ type: 'string' }
498+
]
499+
}
500+
const toStringify = new Date()
501+
502+
const stringify = build(schema)
503+
const output = stringify(toStringify)
504+
505+
t.equal(output, JSON.stringify(toStringify))
506+
})
507+
508+
test('nullable date', (t) => {
509+
t.plan(1)
510+
511+
const schema = {
512+
anyOf: [
513+
{
514+
format: 'date',
515+
type: 'string',
516+
nullable: true
517+
}
518+
]
519+
}
520+
521+
const stringify = build(schema)
522+
523+
const data = new Date()
524+
const result = stringify(data)
525+
526+
t.same(result, `"${DateTime.fromJSDate(data).toISODate()}"`)
527+
})

0 commit comments

Comments
 (0)