├── .eslintignore ├── .npmignore ├── .gitignore ├── .github └── workflows │ └── ci-module.yml ├── test ├── index.ts ├── helper.js └── index.js ├── README.md ├── package.json ├── lib ├── index.d.ts └── index.js ├── API.md └── LICENSE.md /.eslintignore: -------------------------------------------------------------------------------- 1 | sandbox.js 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !lib/** 3 | !.npmignore 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/package-lock.json 3 | 4 | coverage.* 5 | 6 | **/.DS_Store 7 | **/._* 8 | 9 | **/*.pem 10 | 11 | **/.vs 12 | **/.vscode 13 | **/.idea 14 | -------------------------------------------------------------------------------- /.github/workflows/ci-module.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read # for actions/checkout 12 | 13 | jobs: 14 | test: 15 | uses: hapijs/.github/.github/workflows/ci-module.yml@master 16 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as BaseJoi from 'joi'; 3 | import JoiDate from '..'; 4 | import * as Lab from '@hapi/lab'; 5 | 6 | 7 | const { expect } = Lab.types; 8 | 9 | const Joi = BaseJoi.extend(JoiDate) as BaseJoi.Root; 10 | 11 | Joi.date().format('YYYY-MM-DD HH:mm'); 12 | Joi.date().format(['YYYY/MM/DD', 'DD-MM-YYYY']); 13 | expect.error(Joi.date().format()); 14 | 15 | Joi.date().utc(); 16 | expect.error(Joi.date().utc(true)); 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @joi/date 2 | 3 | #### Extensions for advance date rules. 4 | 5 | ### Visit the [joi.dev](https://joi.dev) Developer Portal for tutorials, documentation, and support 6 | 7 | ## Useful resources 8 | 9 | - [Documentation and API](https://joi.dev/module/joi-date/) 10 | - [Versions status](https://joi.dev/resources/status/#joi-date) 11 | - [Changelog](https://joi.dev/module/joi-date/changelog/) 12 | - [Project policies](https://joi.dev/policies/) 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@joi/date", 3 | "description": "Joi extension for dates", 4 | "version": "2.1.1", 5 | "repository": "git://github.com/sideway/joi-date", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "keywords": [ 9 | "schema", 10 | "validation", 11 | "date", 12 | "moment", 13 | "joi", 14 | "extension" 15 | ], 16 | "dependencies": { 17 | "moment": "2.x.x" 18 | }, 19 | "devDependencies": { 20 | "@hapi/code": "8.x.x", 21 | "@hapi/lab": "24.x.x", 22 | "@types/node": "^14.18.63", 23 | "joi": "^17.2.0", 24 | "typescript": "4.0.x" 25 | }, 26 | "scripts": { 27 | "test": "lab -t 100 -a @hapi/code -L -Y", 28 | "test-cov-html": "lab -r html -o coverage.html -a @hapi/code" 29 | }, 30 | "license": "BSD-3-Clause" 31 | } 32 | -------------------------------------------------------------------------------- /lib/index.d.ts: -------------------------------------------------------------------------------- 1 | 2 | import { DateSchema, Extension, Root } from 'joi'; 3 | 4 | declare module 'joi' { 5 | 6 | interface DateSchema { 7 | 8 | /** 9 | * Specifies the allowed date format. 10 | * 11 | * @param format - string or array of strings that follow the moment.js format. 12 | */ 13 | format(format: string | string[]): this; 14 | 15 | /** 16 | * Dates will be parsed as UTC instead of using the machine's local timezone. 17 | */ 18 | utc(): this; 19 | } 20 | } 21 | 22 | interface DateExtension extends Extension { 23 | type: 'date'; 24 | base: DateSchema; 25 | } 26 | 27 | interface JoiDateFactory { 28 | (joi: Root): DateExtension; 29 | default: (joi: Root) => DateExtension; 30 | } 31 | declare const factory: JoiDateFactory; 32 | export = factory; 33 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | ## Compatibility 2 | 3 | This version requires **joi** v17 or newer. 4 | 5 | ## Usage 6 | 7 | ```js 8 | const Joi = require('joi') 9 | .extend(require('@joi/date')); 10 | 11 | const schema = Joi.date().format('YYYY-MM-DD').utc(); 12 | ``` 13 | 14 | ## Rules 15 | 16 | ### `date.format(format)` 17 | 18 | Specifies the allowed date format: 19 | - `format` - string or array of strings that follow the `moment.js` [format](http://momentjs.com/docs/#/parsing/string-format/). 20 | 21 | ```js 22 | const schema = Joi.date().format(['YYYY/MM/DD', 'DD-MM-YYYY']); 23 | ``` 24 | ```js 25 | const schema = Joi.date().format('YYYY-MM-DD HH:mm'); 26 | ``` 27 | 28 | ### `date.utc()` 29 | 30 | Dates will be parsed as UTC instead of using the machine's local timezone. 31 | 32 | ```js 33 | const schema = Joi.date().utc().format(['YYYY/MM/DD', 'DD-MM-YYYY']); 34 | ``` 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2020, Sideway Inc, and project contributors 2 | Copyright (c) 2012-2014, Walmart. 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | * The names of any contributors may not be used to endorse or promote products derived from this software without specific prior written permission. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS OFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 11 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Moment = require('moment'); 4 | 5 | 6 | const internals = {}; 7 | 8 | 9 | module.exports = (joi) => { 10 | 11 | const args = { 12 | format: joi.alternatives([ 13 | joi.string(), 14 | joi.array().items(joi.string().invalid('iso', 'javascript', 'unix')) 15 | ]) 16 | }; 17 | 18 | return { 19 | 20 | type: 'date', 21 | base: joi.date(), 22 | 23 | coerce: { 24 | from: 'string', 25 | method: function (value, { schema }) { 26 | 27 | const format = schema.$_getFlag('format'); 28 | if (!format) { 29 | return; 30 | } 31 | 32 | const date = schema.$_getFlag('utc') ? Moment.utc(value, format, true) : Moment(value, format, true); 33 | if (date.isValid()) { 34 | return { value: date.toDate() }; 35 | } 36 | } 37 | }, 38 | 39 | rules: { 40 | utc: { 41 | method: function (enabled = true) { 42 | 43 | return this.$_setFlag('utc', enabled); 44 | } 45 | } 46 | }, 47 | 48 | overrides: { 49 | format: function (format) { 50 | 51 | joi.attempt(format, args.format, 'Invalid format'); 52 | 53 | if (['iso', 'javascript', 'unix'].includes(format)) { 54 | return this.$_super.format(format); 55 | } 56 | 57 | return this.$_setFlag('format', format); 58 | } 59 | } 60 | }; 61 | }; 62 | 63 | // Default export for TypeScript module interop 64 | 65 | module.exports.default = module.exports; 66 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | 5 | 6 | const internals = {}; 7 | 8 | 9 | const { expect } = Code; 10 | 11 | 12 | exports.skip = Symbol('skip'); 13 | 14 | 15 | exports.equal = function (a, b) { 16 | 17 | try { 18 | expect(a).to.equal(b, { deepFunction: true, skip: ['$_temp'] }); 19 | } 20 | catch (err) { 21 | console.error(err.stack); 22 | err.at = internals.thrownAt(); // Adjust error location to test 23 | throw err; 24 | } 25 | }; 26 | 27 | 28 | exports.validate = function (schema, prefs, tests) { 29 | 30 | if (!tests) { 31 | tests = prefs; 32 | prefs = null; 33 | } 34 | 35 | try { 36 | expect(schema.$_root.build(schema.describe())).to.equal(schema, { deepFunction: true, skip: ['$_temp'] }); 37 | 38 | for (const test of tests) { 39 | const [input, pass, expected] = test; 40 | if (!pass) { 41 | expect(expected, 'Failing tests messages must be tested').to.exist(); 42 | } 43 | 44 | const { error: errord, value: valued } = schema.validate(input, Object.assign({ debug: true }, prefs)); 45 | const { error, value } = schema.validate(input, prefs); 46 | 47 | expect(error).to.equal(errord); 48 | expect(value).to.equal(valued); 49 | 50 | if (error && 51 | pass) { 52 | 53 | console.log(error); 54 | } 55 | 56 | if (!error && 57 | !pass) { 58 | 59 | console.log(input); 60 | } 61 | 62 | expect(!error).to.equal(pass); 63 | 64 | if (test.length === 2) { 65 | if (pass) { 66 | expect(input).to.equal(value); 67 | } 68 | 69 | continue; 70 | } 71 | 72 | if (pass) { 73 | if (expected !== exports.skip) { 74 | expect(value).to.equal(expected); 75 | } 76 | 77 | continue; 78 | } 79 | 80 | if (typeof expected === 'string') { 81 | expect(error.message).to.equal(expected); 82 | continue; 83 | } 84 | 85 | if (schema._preferences && schema._preferences.abortEarly === false || 86 | prefs && prefs.abortEarly === false) { 87 | 88 | expect(error.message).to.equal(expected.message); 89 | expect(error.details).to.equal(expected.details); 90 | } 91 | else { 92 | expect(error.details).to.have.length(1); 93 | expect(error.message).to.equal(error.details[0].message); 94 | expect(error.details[0]).to.equal(expected); 95 | } 96 | } 97 | } 98 | catch (err) { 99 | console.error(err.stack); 100 | err.at = internals.thrownAt(); // Adjust error location to test 101 | throw err; 102 | } 103 | }; 104 | 105 | 106 | internals.thrownAt = function () { 107 | 108 | const error = new Error(); 109 | const frame = error.stack.replace(error.toString(), '').split('\n').slice(1).filter((line) => !line.includes(__filename))[0]; 110 | const at = frame.match(/^\s*at \(?(.+)\:(\d+)\:(\d+)\)?$/); 111 | return { 112 | filename: at[1], 113 | line: at[2], 114 | column: at[3] 115 | }; 116 | }; 117 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Joi = require('joi'); 5 | const JoiDate = require('..'); 6 | const Lab = require('@hapi/lab'); 7 | const Moment = require('moment'); 8 | 9 | const Helper = require('./helper'); 10 | 11 | 12 | const internals = {}; 13 | 14 | 15 | const { describe, it } = exports.lab = Lab.script(); 16 | const expect = Code.expect; 17 | 18 | 19 | describe('date', () => { 20 | 21 | const custom = Joi.extend(JoiDate); 22 | 23 | describe('format()', () => { 24 | 25 | it('validates an empty date', () => { 26 | 27 | const schema = custom.date().format('YYYY-MM-DD'); 28 | expect(schema.validate(undefined).error).to.not.exist(); 29 | }); 30 | 31 | it('validates date', () => { 32 | 33 | const now = Date.now(); 34 | const date = new Date(); 35 | 36 | Helper.validate(custom.date(), [ 37 | [now, true, new Date(now)], 38 | [date, true], 39 | ['xxx', false, { 40 | message: '"value" must be a valid date', 41 | path: [], 42 | type: 'date.base', 43 | context: { value: 'xxx', label: 'value' } 44 | }] 45 | ]); 46 | 47 | Helper.validate(custom.date().format('YYYY-MM-DD'), [ 48 | [now, true, new Date(now)], 49 | [date, true], 50 | [new Date(NaN), false, { 51 | message: '"value" must be a valid date', 52 | path: [], 53 | type: 'date.base', 54 | context: { value: new Date(NaN), label: 'value' } 55 | }], 56 | ['xxx', false, { 57 | message: '"value" must be in YYYY-MM-DD format', 58 | path: [], 59 | type: 'date.format', 60 | context: { value: 'xxx', label: 'value', format: 'YYYY-MM-DD' } 61 | }] 62 | ]); 63 | }); 64 | 65 | it('validates base formats', () => { 66 | 67 | Helper.validate(custom.date().format('iso'), [ 68 | ['+002013-06-07T14:21:46.295Z', true, new Date('+002013-06-07T14:21:46.295Z')], 69 | ['-002013-06-07T14:21:46.295Z', true, new Date('-002013-06-07T14:21:46.295Z')], 70 | ['002013-06-07T14:21:46.295Z', false, { 71 | message: '"value" must be in ISO 8601 date format', 72 | path: [], 73 | type: 'date.format', 74 | context: { label: 'value', value: '002013-06-07T14:21:46.295Z', format: 'iso' } 75 | }] 76 | ]); 77 | }); 78 | 79 | it('errors without convert enabled', () => { 80 | 81 | Helper.validate(custom.date().format('YYYY-MM-DD').options({ convert: false }), [ 82 | ['2000-01-01', false, { 83 | message: '"value" must be a valid date', 84 | path: [], 85 | type: 'date.base', 86 | context: { value: '2000-01-01', label: 'value' } 87 | }] 88 | ]); 89 | 90 | Helper.validate(custom.date().options({ convert: false }), [ 91 | ['2000-01-01', false, { 92 | message: '"value" must be a valid date', 93 | path: [], 94 | type: 'date.base', 95 | context: { value: '2000-01-01', label: 'value' } 96 | }] 97 | ]); 98 | }); 99 | 100 | it('validates custom format', () => { 101 | 102 | Helper.validate(custom.date().format('DD#YYYY$MM'), [ 103 | ['07#2013$06', true, Moment('07#2013$06', 'DD#YYYY$MM', true).toDate()], 104 | ['2013-06-07', false, { 105 | message: '"value" must be in DD#YYYY$MM format', 106 | path: [], 107 | type: 'date.format', 108 | context: { value: '2013-06-07', label: 'value', format: 'DD#YYYY$MM' } 109 | }] 110 | ]); 111 | }); 112 | 113 | it('enforces format', () => { 114 | 115 | const schema = custom.date().format('YYYY-MM-DD'); 116 | 117 | Helper.validate(schema, [ 118 | ['1', false, '"value" must be in YYYY-MM-DD format'], 119 | ['10', false, '"value" must be in YYYY-MM-DD format'], 120 | ['1000', false, '"value" must be in YYYY-MM-DD format'], 121 | ['100x', false, '"value" must be in YYYY-MM-DD format'], 122 | ['1-1', false, '"value" must be in YYYY-MM-DD format'] 123 | ]); 124 | }); 125 | 126 | it('validates several custom formats', () => { 127 | 128 | const schema = custom.date() 129 | .format(['DD#YYYY$MM', 'YY|DD|MM']); 130 | 131 | Helper.validate(schema, [ 132 | ['13|07|06', true, Moment('13|07|06', 'YY|DD|MM', true).toDate()], 133 | ['2013-06-07', false, { 134 | message: '"value" must be in [DD#YYYY$MM, YY|DD|MM] format', 135 | path: [], 136 | type: 'date.format', 137 | context: { value: '2013-06-07', label: 'value', format: ['DD#YYYY$MM', 'YY|DD|MM'] } 138 | }] 139 | ]); 140 | }); 141 | 142 | it('supports utc mode', () => { 143 | 144 | Helper.validate(custom.date().utc().format('YYYY-MM-DD'), [ 145 | ['2018-01-01', true, new Date('2018-01-01:00:00:00.000Z')] 146 | ]); 147 | }); 148 | 149 | it('fails with bad formats', () => { 150 | 151 | expect(() => custom.date().format(true)).to.throw('Invalid format "value" must be one of [string, array]'); 152 | expect(() => custom.date().format([true])).to.throw('Invalid format "[0]" must be a string'); 153 | }); 154 | 155 | it('fails without convert', () => { 156 | 157 | const schema = custom.date().format('YYYY-MM-DD'); 158 | expect(schema.validate('foo', { convert: false }).error).to.be.an.error('"value" must be a valid date'); 159 | }); 160 | 161 | it('fails with overflow dates', () => { 162 | 163 | Helper.validate(custom.date().format('YYYY-MM-DD'), [ 164 | ['1999-02-31', false, { 165 | message: '"value" must be in YYYY-MM-DD format', 166 | path: [], 167 | type: 'date.format', 168 | context: { value: '1999-02-31', label: 'value', format: 'YYYY-MM-DD' } 169 | }], 170 | ['2005-13-01', false, { 171 | message: '"value" must be in YYYY-MM-DD format', 172 | path: [], 173 | type: 'date.format', 174 | context: { value: '2005-13-01', label: 'value', format: 'YYYY-MM-DD' } 175 | }], 176 | ['2010-01-32', false, { 177 | message: '"value" must be in YYYY-MM-DD format', 178 | path: [], 179 | type: 'date.format', 180 | context: { value: '2010-01-32', label: 'value', format: 'YYYY-MM-DD' } 181 | }] 182 | ]); 183 | }); 184 | 185 | it('should support .allow()', () => { 186 | 187 | const schema = custom.date().format('YYYY-MM-DD').allow('epoch'); 188 | expect(schema.validate('epoch')).to.equal({ value: 'epoch' }); 189 | }); 190 | 191 | describe('describe()', () => { 192 | 193 | it('describes custom formats', () => { 194 | 195 | const schema = custom.date().format(['DD#YYYY$MM', 'YY|DD|MM']); 196 | expect(schema.describe()).to.equal({ 197 | type: 'date', 198 | flags: { 199 | format: ['DD#YYYY$MM', 'YY|DD|MM'] 200 | } 201 | }); 202 | }); 203 | 204 | it('describes utc mode', () => { 205 | 206 | const schema = custom.date().utc().format(['DD#YYYY$MM', 'YY|DD|MM']); 207 | expect(schema.describe()).to.equal({ 208 | type: 'date', 209 | flags: { 210 | format: ['DD#YYYY$MM', 'YY|DD|MM'], 211 | utc: true 212 | } 213 | }); 214 | }); 215 | }); 216 | }); 217 | }); 218 | --------------------------------------------------------------------------------