Skip to content
Permalink
Browse files

feat: joi typed schemas

BREAKING CHANGE:
arraySchema is a function now (was: a variable)
  • Loading branch information...
kirillgroshkov committed May 18, 2019
1 parent 18b6a73 commit 51450826585b4790773d8677712a26e1cea57a3e
@@ -93,7 +93,7 @@ exports[`should fail on invalid values 2`] = `
\\"value\\" [1]: -- missing --
}

[1] \\"value\\" must be an object"
[1] \\"value\\" is required"
`;

exports[`should fail on invalid values 3`] = `"\\"value\\" must be an object"`;
@@ -0,0 +1,80 @@
import { Extension, State, ValidationOptions } from '@hapi/joi'
import * as JoiLib from '@hapi/joi'
import { DateTime } from 'luxon'
import { LUXON_ISO_DATE_FORMAT } from '../../util/time.util'

export interface DateStringExtension {
dateString (min?: string, max?: string): this
}

export interface DateStringParams {
min?: string
max?: string
}

export function dateStringExtension (joi: typeof JoiLib): Extension {
return {
base: joi.string(),
name: 'string',
language: {
dateString: 'needs to be a date string (yyyy-mm-dd)',
dateStringMin: 'needs to be not earlier than {{min}}',
dateStringMax: 'needs to be not later than {{max}}',
dateStringCalendarAccuracy: 'needs to be a calendar accurate date',
},
rules: [
{
name: 'dateString',
params: {
min: joi.string().optional(),
max: joi.string().optional(),
},
validate (params: DateStringParams, v: any, state: State, options: ValidationOptions) {
let err: string | undefined
let min = params.min
let max = params.max

// Today allows +-14 hours gap to account for different timezones
if (max === 'today') {
max = DateTime.utc()
.plus({ hours: 14 })
.toFormat(LUXON_ISO_DATE_FORMAT)
}
if (min === 'today') {
min = DateTime.utc()
.minus({ hours: 14 })
.toFormat(LUXON_ISO_DATE_FORMAT)
}
// console.log('min/max', min, max)

const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (!m || m.length <= 1) {
err = 'string.dateString'
} else if (min && v < min) {
err = 'string.dateStringMin'
} else if (max && v > max) {
err = 'string.dateStringMax'
} else if (!DateTime.fromFormat(v, LUXON_ISO_DATE_FORMAT).isValid) {
err = 'string.dateStringCalendarAccuracy'
}

if (err) {
// tslint:disable-next-line:no-invalid-this
return this.createError(
err,
{
v,
min,
max,
},
state,
options,
)
}

return v // validation passed
},
},
],
}
}
@@ -0,0 +1,47 @@
import { Extension, State, ValidationOptions } from '@hapi/joi'
import * as JoiLib from '@hapi/joi'

export interface DividableExtension {
dividable (q: number): this
}

export interface DividableParams {
q: number
}

export function dividableExtension (joi: typeof JoiLib): Extension {
return {
base: joi.number(),
name: 'number',
language: {
dividable: 'needs to be dividable by {{q}}',
},
rules: [
{
name: 'dividable',
params: {
q: joi
.number()
.integer()
.positive(),
},
validate (params: DividableParams, v: any, state: State, options: ValidationOptions) {
if (v % params.q !== 0) {
// tslint:disable-next-line:no-invalid-this
return this.createError(
'number.dividable',
{
v,
q: params.q,
},
state,
options,
)
}

return v
},
},
],
}
}
@@ -1,15 +1,20 @@
import { Extension, State, StringSchema, ValidationOptions } from '@hapi/joi'
import { NumberSchema, StringSchema } from '@hapi/joi'
import * as JoiLib from '@hapi/joi'
import { DateTime } from 'luxon'
import { LUXON_ISO_DATE_FORMAT } from '../../util/time.util'
import { DateStringExtension, dateStringExtension } from './dateString.extension'
import { DividableExtension, dividableExtension } from './dividable.extension'
import { AnySchemaT } from './joi.model'

export const Joi: ExtendedJoi = JoiLib.defaults(schema => {
// hack to prevent infinite recursion due to .empty('') where '' is a stringSchema itself
if (schema.schemaType === 'string') {
// trim all strings by default!
return (schema as StringSchema).trim()
return (schema as StringSchema)
.trim() // trim all strings by default
.empty([schema.valid('')]) // treat '' as empty (undefined, will be stripped out)
}

return schema
// Treat `null` as undefined for all schema types
// undefined values will be stripped by default from object values
return schema.empty(null)
})
.extend((joi: typeof JoiLib) => dateStringExtension(joi))
.extend((joi: typeof JoiLib) => dividableExtension(joi))
@@ -19,123 +24,12 @@ export interface ExtendedJoi extends JoiLib.Root {
number (): ExtendedNumberSchema
}

export interface ExtendedStringSchema extends JoiLib.StringSchema {
dateString (min?: string, max?: string): this
}

export interface ExtendedNumberSchema extends JoiLib.NumberSchema {
dividable (q: number): this
}

interface DateStringParams {
min?: string
max?: string
}

function dateStringExtension (joi: typeof JoiLib): Extension {
return {
base: joi.string(),
name: 'string',
language: {
dateString: 'needs to be a date string (yyyy-mm-dd)',
dateStringMin: 'needs to be not earlier than {{min}}',
dateStringMax: 'needs to be not later than {{max}}',
dateStringCalendarAccuracy: 'needs to be a calendar accurate date',
},
rules: [
{
name: 'dateString',
params: {
min: joi.string().optional(),
max: joi.string().optional(),
},
validate (params: DateStringParams, v: any, state: State, options: ValidationOptions) {
let err: string | undefined
let min = params.min
let max = params.max

// Today allows +-14 hours gap to account for different timezones
if (max === 'today') {
max = DateTime.utc()
.plus({ hours: 14 })
.toFormat(LUXON_ISO_DATE_FORMAT)
}
if (min === 'today') {
min = DateTime.utc()
.minus({ hours: 14 })
.toFormat(LUXON_ISO_DATE_FORMAT)
}
// console.log('min/max', min, max)

const m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (!m || m.length <= 1) {
err = 'string.dateString'
} else if (min && v < min) {
err = 'string.dateStringMin'
} else if (max && v > max) {
err = 'string.dateStringMax'
} else if (!DateTime.fromFormat(v, LUXON_ISO_DATE_FORMAT).isValid) {
err = 'string.dateStringCalendarAccuracy'
}

if (err) {
// tslint:disable-next-line:no-invalid-this
return this.createError(
err,
{
v,
min,
max,
},
state,
options,
)
}
export interface ExtendedStringSchema
extends StringSchema,
DateStringExtension,
AnySchemaT<string> {}

return v // validation passed
},
},
],
}
}

interface DividableParams {
q: number
}

function dividableExtension (joi: typeof JoiLib): Extension {
return {
base: joi.number(),
name: 'number',
language: {
dividable: 'needs to be dividable by {{q}}',
},
rules: [
{
name: 'dividable',
params: {
q: joi
.number()
.integer()
.positive(),
},
validate (params: DividableParams, v: any, state: State, options: ValidationOptions) {
if (v % params.q !== 0) {
// tslint:disable-next-line:no-invalid-this
return this.createError(
'number.dividable',
{
v,
q: params.q,
},
state,
options,
)
}

return v
},
},
],
}
}
export interface ExtendedNumberSchema
extends NumberSchema,
DividableExtension,
AnySchemaT<number> {}
@@ -0,0 +1,39 @@
import {
AlternativesSchema,
AnySchema,
ArraySchema,
BinarySchema,
BooleanSchema,
DateSchema,
FunctionSchema,
LazySchema,
NumberSchema,
ObjectSchema,
StringSchema,
} from '@hapi/joi'

export type SchemaTyped<T> =
| AnySchemaT<T>
| ArraySchemaTyped<T>
| AlternativesSchemaTyped<T>
| BinarySchemaTyped
| BooleanSchemaTyped
| DateSchemaTyped<T>
| FunctionSchemaTyped<T>
| NumberSchemaTyped
| ObjectSchemaTyped<T>
| StringSchemaTyped
| LazySchemaTyped<T>

export interface AnySchemaT<T> extends AnySchema {}

export interface ArraySchemaTyped<T> extends ArraySchema, AnySchemaT<T[]> {}
export interface AlternativesSchemaTyped<T> extends AlternativesSchema {}
export interface BinarySchemaTyped extends BinarySchema, AnySchemaT<Buffer> {}
export interface BooleanSchemaTyped extends BooleanSchema, AnySchemaT<boolean> {}
export interface DateSchemaTyped<T> extends DateSchema {}
export interface FunctionSchemaTyped<T> extends FunctionSchema {}
export interface NumberSchemaTyped extends NumberSchema, AnySchemaT<number> {}
export interface ObjectSchemaTyped<T> extends ObjectSchema, AnySchemaT<T> {}
export interface StringSchemaTyped extends StringSchema, AnySchemaT<string> {}
export interface LazySchemaTyped<T> extends LazySchema {}
@@ -1,15 +1,23 @@
import { SchemaMap } from '@hapi/joi'
import { Joi } from './joi.extensions'
import { AnySchemaT, ArraySchemaTyped, BooleanSchemaTyped, ObjectSchemaTyped } from './joi.model'

// Should all booleans be optional as a convention? So undefined will be just treated as false?
export const booleanSchema = Joi.boolean()
export const booleanSchema = Joi.boolean() as BooleanSchemaTyped
export const stringSchema = Joi.string()
export const numberSchema = Joi.number()
export const integerSchema = Joi.number().integer()
export const dateStringSchema = stringSchema.dateString()
export const arraySchema = Joi.array()
export const binarySchema = Joi.binary()
export const objectSchema = (schema?: SchemaMap) => Joi.object(schema)

export function arraySchema<T> (items?: AnySchemaT<T>): ArraySchemaTyped<T> {
return items ? Joi.array().items(items) : Joi.array()
}

export function objectSchema<T> (
schema?: { [key in keyof T]: AnySchemaT<T[key]> },
): ObjectSchemaTyped<T> {
return Joi.object(schema)
}

export const anySchema = Joi.any()
export const anyObjectSchema = Joi.object().options({ stripUnknown: false })

0 comments on commit 5145082

Please sign in to comment.
You can’t perform that action at this time.