fastify-type-provider-typebox
fastify-type-provider-typebox copied to clipboard
Type.String({ format: "date" }) doesn't work in body validator
Prerequisites
- [X] I have written a descriptive issue title
- [X] I have searched existing issues to ensure the bug has not already been reported
Fastify version
4.15.0
Plugin version
3.2.0
Node.js version
18
Operating system
macOS
Operating system version (i.e. 20.04, 11.3, 10)
13.3.1
Description
Type.String({ format: "date" }) doesn't work in body schema validator
Validation error in request to: POST /blah/blah
err: {
"type": "Error",
"message": "body/periodStart Unknown string format 'date'",
"stack":
Error: body/periodStart Unknown string format 'date'
...
"statusCode": 400,
"validation": [
{
"message": "Unknown string format 'date'",
"instancePath": "/periodStart"
}
],
"validationContext": "body"
}
This works fine with ajv validator but as soon as I enable TypeBoxValidatorCompiler, It fails to receive request with the error above.
Steps to Reproduce
use Type.String({ format: "date" }) in body schema with TypeBoxValidatorCompiler enabled
Expected Behavior
No response
@sinclairzx81 could you give some help here?
@benevbright Hi, TypeBox doesn't implement string formats by default, so you will need to specify these yourself. This is somewhat similar to Ajv where formats are typically loaded via the auxiliary ajv-formats package (Which I think Fastify defaultly configures Ajv with)
To get the common formats available to the TypeBox Compiler, add the following script to your project. This script lifts the formats from ajv-formats and makes them available to TypeBox, each is registered via the FormatRegistry.Set function. You should import this script with import './formats'
import { FormatRegistry } from '@sinclair/typebox'
// -------------------------------------------------------------------------------------------
// Format Registration
// -------------------------------------------------------------------------------------------
FormatRegistry.Set('date-time', (value) => IsDateTime(value, true))
FormatRegistry.Set('date', (value) => IsDate(value))
FormatRegistry.Set('time', (value) => IsTime(value))
FormatRegistry.Set('email', (value) => IsEmail(value))
FormatRegistry.Set('uuid', (value) => IsUuid(value))
FormatRegistry.Set('url', (value) => IsUrl(value))
FormatRegistry.Set('ipv6', (value) => IsIPv6(value))
FormatRegistry.Set('ipv4', (value) => IsIPv4(value))
// -------------------------------------------------------------------------------------------
// https://github.com/ajv-validator/ajv-formats/blob/master/src/formats.ts
// -------------------------------------------------------------------------------------------
const UUID = /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i
const DATE_TIME_SEPARATOR = /t|\s/i
const TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i
const DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/
const DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
const IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/
const IPV6 = /^((([0-9a-f]{1,4}:){7}([0-9a-f]{1,4}|:))|(([0-9a-f]{1,4}:){6}(:[0-9a-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){5}(((:[0-9a-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){4}(((:[0-9a-f]{1,4}){1,3})|((:[0-9a-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){3}(((:[0-9a-f]{1,4}){1,4})|((:[0-9a-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){2}(((:[0-9a-f]{1,4}){1,5})|((:[0-9a-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){1}(((:[0-9a-f]{1,4}){1,6})|((:[0-9a-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-f]{1,4}){1,7})|((:[0-9a-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))$/i
const URL = /^(?:https?|wss?|ftp):\/\/(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u{00a1}-\u{ffff}]+-)*[a-z0-9\u{00a1}-\u{ffff}]+)(?:\.(?:[a-z0-9\u{00a1}-\u{ffff}]+-)*[a-z0-9\u{00a1}-\u{ffff}]+)*(?:\.(?:[a-z\u{00a1}-\u{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/iu
const EMAIL = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i
function IsLeapYear(year: number): boolean {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
}
function IsDate(str: string): boolean {
const matches: string[] | null = DATE.exec(str)
if (!matches) return false
const year: number = +matches[1]
const month: number = +matches[2]
const day: number = +matches[3]
return month >= 1 && month <= 12 && day >= 1 && day <= (month === 2 && IsLeapYear(year) ? 29 : DAYS[month])
}
function IsTime(str: string, strictTimeZone?: boolean): boolean {
const matches: string[] | null = TIME.exec(str)
if (!matches) return false
const hr: number = +matches[1]
const min: number = +matches[2]
const sec: number = +matches[3]
const tz: string | undefined = matches[4]
const tzSign: number = matches[5] === '-' ? -1 : 1
const tzH: number = +(matches[6] || 0)
const tzM: number = +(matches[7] || 0)
if (tzH > 23 || tzM > 59 || (strictTimeZone && !tz)) return false
if (hr <= 23 && min <= 59 && sec < 60) return true
const utcMin = min - tzM * tzSign
const utcHr = hr - tzH * tzSign - (utcMin < 0 ? 1 : 0)
return (utcHr === 23 || utcHr === -1) && (utcMin === 59 || utcMin === -1) && sec < 61
}
function IsDateTime(value: string, strictTimeZone?: boolean): boolean {
const dateTime: string[] = value.split(DATE_TIME_SEPARATOR)
return dateTime.length === 2 && IsDate(dateTime[0]) && IsTime(dateTime[1], strictTimeZone)
}
function IsEmail(value: string) {
return EMAIL.test(value)
}
function IsUuid(value: string) {
return UUID.test(value)
}
function IsUrl(value: string) {
return URL.test(value)
}
function IsIPv6(value: string) {
return IPV6.test(value)
}
function IsIPv4(value: string) {
return IPV4.test(value)
}
Additional Information on Format and Type registration can be found https://github.com/sinclairzx81/typebox#typesystem.
It might be a nice idea to include these format configurations by default within this package.
Hope this helps! S
hi @sinclairzx81 Thanks a lot always!
It might be a nice idea to include these format configurations by default within this package.
That's good idea. Since with ajv, it works out of the box.
@benevbright Would you like to send a Pull Request to address this issue? Remember to add unit tests.
Hi! I experienced the same issue with 'date-time', and this would be a very welcome addition!
I was able to get it working with the script above in my app. This post is edited, since I made the simple mistake of using an invalid string when testing. I've left this code as an example of how it can be done instead :)
Installed dependencies:
"@fastify/type-provider-typebox": "^3.4.0",
"@sinclair/typebox": "^0.28.15",
"fastify": "^4.17.0",
App:
import './routes/typebox-formats-hack' // Includes relevant parts from above for 'date-time'
...
const f = fastify({
// some custom options
})
.setValidatorCompiler(TypeBoxValidatorCompiler)
.withTypeProvider<TypeBoxTypeProvider>()
await fastify.register(myRouter, { prefix: '/my-prefix/:customParam' }) // Actually using a few layers of FastifyPluginAsync
Route schemas:
const myQuerySchema = Type.Object({
start: Type.Optional(Type.String({ format: 'date-time' } )),
end: Type.Optional(Type.String({ format: 'date-time' }))
}, { additionalProperties: false })
export const myFullSchema = {
querystring: myQuerySchema,
params: myParamsSchema, // Omitted for brevity
response: {
200: myResponseSchema // Omitted for brevity
},
}
// Type checking in the handler doesn't work without this
export interface MyRoute extends RouteGenericInterface {
Querystring: Static<typeof myQuerySchema>
Params: Static<typeof myParamsSchema>
Reply: Static<typeof myResponseSchema>
}
export const myRouteOptions: RouteShorthandOptions = {
schema: myFullSchema
}
Route handler:
// From example here: https://github.com/fastify/fastify-type-provider-typebox
export type FastifyRequestTypebox<TSchema extends FastifySchema> = FastifyRequest<
RouteGenericInterface,
RawServerDefault,
RawRequestDefaultExpression<RawServerDefault>,
TSchema,
TypeBoxTypeProvider
>;
export type FastifyReplyTypebox<TSchema extends FastifySchema> = FastifyReply<
RawServerDefault,
RawRequestDefaultExpression,
RawReplyDefaultExpression,
RouteGenericInterface,
ContextConfigDefault,
TSchema,
TypeBoxTypeProvider
>
export const tokensRouter: FastifyPluginAsync = async (fastify) => {
fastify.get<MyRoute>('/my-endpoint', myRouteOptions, myHandler)
}
export const myHandler: RouteHandler<MyRoute> = async function (req: FastifyRequestTypebox<typeof myFullSchema>, reply: FastifyReplyTypebox<typeof myFullSchema>) {
// Custom endpoint logic here
}
Response when calling GET on /my-endpoint with an invalid string, e.g. "2023-07-01" (just a date):
{
"name": "ValidationError",
"message": "querystring/start Expected string to match format 'date-time'"
}
@johanehr What's the value of your start param?
@benevbright, thanks for the quick response!
I should have looked at it with fresh eyes - I was actually sending in an invalid string, that would be parsed correctly by luxon DateTime ("2023-07-01"). It turns out that it does work after all with e.g. "2023-07-01T01:23:45+01:00"!
Sorry for the confusion (I'll update my comment accordingly, as it could still be a useful example for someone).