Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
2 tasks done
benevbright opened this issue May 23, 2023 · 7 comments
Open
2 tasks done

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

benevbright opened this issue May 23, 2023 · 7 comments
Labels
enhancement New feature or request good first issue Good for newcomers help wanted Extra attention is needed

Comments

@benevbright
Copy link

benevbright commented May 23, 2023

Prerequisites

  • I have written a descriptive issue title
  • 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

@benevbright
Copy link
Author

@sinclairzx81 could you give some help here?

@sinclairzx81
Copy link
Contributor

sinclairzx81 commented May 24, 2023

@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
Copy link
Author

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
Copy link
Member

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

@johanehr
Copy link

johanehr commented Aug 9, 2023

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
Copy link
Author

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

@johanehr
Copy link

johanehr commented Aug 9, 2023

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

@mcollina mcollina added good first issue Good for newcomers enhancement New feature or request help wanted Extra attention is needed labels Aug 10, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request good first issue Good for newcomers help wanted Extra attention is needed
Projects
None yet
Development

No branches or pull requests

4 participants