fastify / fastify-type-provider-typebox

A Type Provider for Typebox
MIT License
153 stars 26 forks source link

Type.String({ format: "date" }) doesn't work in body validator #84

Open benevbright opened 1 year ago

benevbright commented 1 year ago

Prerequisites

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

benevbright commented 1 year ago

@sinclairzx81 could you give some help here?

sinclairzx81 commented 1 year ago

@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

benevbright commented 1 year ago

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.

mcollina commented 1 year ago

@benevbright Would you like to send a Pull Request to address this issue? Remember to add unit tests.

johanehr commented 1 year ago

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'"
}
benevbright commented 1 year ago

@johanehr What's the value of your start param?

johanehr commented 1 year ago

@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).