diff --git a/src/index.js b/src/index.js index 8e59450f21..6c768638da 100644 --- a/src/index.js +++ b/src/index.js @@ -65,6 +65,7 @@ export { GraphQLString, GraphQLBoolean, GraphQLID, + GraphQLDateTime, // Built-in Directives defined by the Spec specifiedDirectives, diff --git a/src/type/__tests__/serialization-test.js b/src/type/__tests__/serialization-test.js index 7cfaa6166e..8d97fa87a4 100644 --- a/src/type/__tests__/serialization-test.js +++ b/src/type/__tests__/serialization-test.js @@ -11,12 +11,60 @@ import { GraphQLInt, GraphQLFloat, GraphQLString, - GraphQLBoolean + GraphQLBoolean, + GraphQLDateTime, } from '../'; import { describe, it } from 'mocha'; import { expect } from 'chai'; +import * as Kind from '../../language/kinds'; +const invalidDateTime = [ + 'Invalid date', + // invalid structure + '2016-02-01T00Z', + // omission of seconds + '2016-02-01T00:00Z', + // omission of colon + '2016-02-01T000059Z', + // omission of time-offset + '2016-02-01T00:00:00', + // seconds should be two characters + '2016-02-01T00:00:0Z', + // nonexistent date + '2015-02-29T00:00:00Z', + // hour 24 is not allowed in RFC 3339 + '2016-01-01T24:00:00Z', + // nonexistent date + '2016-04-31T00:00:00Z', + // nonexistent date + '2016-06-31T00:00:00Z', + // nonexistent date + '2016-09-31T00:00:00Z', + // nonexistent date + '2016-11-31T00:00:00Z', + // month ranges from 01-12 + '2016-13-01T00:00:00Z', + // minute ranges from 00-59 + '2016-01-01T00:60:00Z', + // According to RFC 3339 2016-02-01T00:00:60Z is a valid date-time string. + // However, it is considered invalid when parsed by the javascript + // Date class because it ignores leap seconds. + // Therefore, this implementation also ignores leap seconds. + '2016-02-01T00:00:60Z', + // must specify a fractional second + '2015-02-26T00:00:00.Z', + // must add colon in time-offset + '2017-01-07T11:25:00+0100', + // omission of minute in time-offset + '2017-01-07T11:25:00+01', + // omission of minute in time-offset + '2017-01-07T11:25:00+', + // hour ranges from 00-23 + '2017-01-01T00:00:00+24:00', + // minute ranges from 00-59 + '2017-01-01T00:00:00+00:60' +]; describe('Type System: Scalar coercion', () => { it('serializes output int', () => { @@ -177,4 +225,259 @@ describe('Type System: Scalar coercion', () => { GraphQLBoolean.serialize(false) ).to.equal(false); }); + + describe('serializes output DateTime', () => { + + [ + {}, + [], + null, + undefined, + true, + ].forEach(invalidInput => { + it(`throws serializing ${invalidInput}`, () => { + expect(() => + GraphQLDateTime.serialize(invalidInput) + ).to.throw( + 'DateTime cannot be serialized from a non string, ' + + 'non numeric or non Date type ' + invalidInput + ); + }); + }); + + [ + [ + new Date(Date.UTC(2016, 0, 1)), + '2016-01-01T00:00:00.000Z' + ], + [ + new Date(Date.UTC(2016, 0, 1, 14, 48, 10, 3)), + '2016-01-01T14:48:10.003Z' + ], + [ + new Date(Date.UTC(2016, 0, 1, 24, 0)), + '2016-01-02T00:00:00.000Z' + ] + ].forEach(([ value, expected ]) => { + it(`serializes Date ${value} into date-time string ${expected}`, () => { + expect( + GraphQLDateTime.serialize(value) + ).to.equal(expected); + }); + }); + + it('throws serializing an invalid Date', () => { + expect(() => + GraphQLDateTime.serialize(new Date('invalid date')) + ).to.throw( + 'DateTime cannot represent an invalid Date instance' + ); + }); + + [ + '2016-02-01T00:00:00Z', + '2016-02-01T00:00:59Z', + '2016-02-01T00:00:00-11:00', + '2017-01-07T11:25:00+01:00', + '2017-01-07T00:00:00+01:00', + '2017-01-07T00:00:00.0Z', + '2017-01-01T00:00:00.0+01:00', + '2016-02-01T00:00:00.450Z', + '2017-01-01T10:23:11.45686664Z', + '2017-01-01T10:23:11.23545654+01:00' + ].forEach(value => { + it(`serializes date-time string ${value}`, () => { + expect( + GraphQLDateTime.serialize(value) + ).to.equal(value); + }); + }); + + invalidDateTime.forEach(dateString => { + it(`throws serializing invalid date-time string ${dateString}`, () => { + expect(() => + GraphQLDateTime.serialize(dateString) + ).to.throw( + 'DateTime cannot represent an invalid ' + + 'date-time string ' + dateString + ); + }); + }); + + [ + [ 854325678, '1997-01-27T00:41:18.000Z' ], + [ 876535, '1970-01-11T03:28:55.000Z' ], + [ 876535.8, '1970-01-11T03:28:55.800Z' ], + [ 876535.8321, '1970-01-11T03:28:55.832Z' ], + [ -876535.8, '1969-12-21T20:31:04.200Z' ], + // The maximum representable unix timestamp + [ 2147483647, '2038-01-19T03:14:07.000Z' ], + // The minimum representable unit timestamp + [ -2147483648, '1901-12-13T20:45:52.000Z' ], + ].forEach(([ value, expected ]) => { + it( + `serializes unix timestamp ${value} into date-time string ${expected}` + , () => { + expect( + GraphQLDateTime.serialize(value) + ).to.equal(expected); + }); + }); + + [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + // assume Unix timestamp are 32-bit + 2147483648, + -2147483649 + ].forEach(value => { + it(`throws serializing invalid unix timestamp ${value}`, () => { + expect(() => + GraphQLDateTime.serialize(value) + ).to.throw( + 'DateTime cannot represent an invalid Unix timestamp ' + value + ); + }); + }); + }); + + describe('parses input DateTime', () => { + + [ + [ + '2016-02-01T00:00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 0)) ], + [ '2016-02-01T00:00:15Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 15)) ], + [ '2016-02-01T00:00:59Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 59)) ], + [ '2016-02-01T00:00:00-11:00', new Date(Date.UTC(2016, 1, 1, 11)) ], + [ '2017-01-07T11:25:00+01:00', new Date(Date.UTC(2017, 0, 7, 10, 25)) ], + [ '2017-01-07T00:00:00+01:00', new Date(Date.UTC(2017, 0, 6, 23)) ], + [ + '2016-02-01T00:00:00.12Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 120)) + ], + [ + '2016-02-01T00:00:00.123456Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 123)) + ], + [ + '2016-02-01T00:00:00.12399Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 123)) + ], + [ + '2016-02-01T00:00:00.000Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 0)) + ], + [ + '2016-02-01T00:00:00.993Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 993)) + ], + [ + '2017-01-07T11:25:00.450+01:00', + new Date(Date.UTC(2017, 0, 7, 10, 25, 0, 450)) + ], + [ + // eslint-disable-next-line no-new-wrappers + new String('2017-01-07T11:25:00.450+01:00'), + new Date(Date.UTC(2017, 0, 7, 10, 25, 0, 450)) + ] + ].forEach(([ value, expected ]) => { + it(`parses date-time string ${value} into Date ${expected}`, () => { + expect( + GraphQLDateTime.parseValue(value).toISOString() + ).to.equal(expected.toISOString()); + }); + }); + + [ + null, + undefined, + 4566, + {}, + [], + true, + ].forEach(invalidInput => { + it(`throws parsing ${String(invalidInput)}`, () => { + expect(() => + GraphQLDateTime.parseValue(invalidInput) + ).to.throw( + 'DateTime cannot represent non string type ' + invalidInput + ); + }); + }); + + invalidDateTime.forEach(dateString => { + it(`throws parsing invalid date-time string ${dateString}`, () => { + expect(() => + GraphQLDateTime.parseValue(dateString) + ).to.throw( + 'DateTime cannot represent an invalid ' + + 'date-time string ' + dateString + ); + }); + }); + }); + + describe('parses literal DateTime', () => { + + [ + [ + '2016-02-01T00:00:00Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 0)) ], + [ '2016-02-01T00:00:59Z', new Date(Date.UTC(2016, 1, 1, 0, 0, 59)) ], + [ '2016-02-01T00:00:00-11:00', new Date(Date.UTC(2016, 1, 1, 11)) ], + [ '2017-01-07T11:25:00+01:00', new Date(Date.UTC(2017, 0, 7, 10, 25)) ], + [ '2017-01-07T00:00:00+01:00', new Date(Date.UTC(2017, 0, 6, 23)) ], + [ + '2016-02-01T00:00:00.12Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 120)) + ], + [ + // rounds down the fractional seconds to 3 decimal places. + '2016-02-01T00:00:00.123456Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 123)) + ], + [ + // rounds down the fractional seconds to 3 decimal places. + '2016-02-01T00:00:00.12399Z', + new Date(Date.UTC(2016, 1, 1, 0, 0, 0, 123)) + ], + [ + '2017-01-07T11:25:00.450+01:00', + new Date(Date.UTC(2017, 0, 7, 10, 25, 0, 450)) + ] + ].forEach(([ value, expected ]) => { + const literal = { + kind: Kind.STRING, + value + }; + + it( + `parses literal ${JSON.stringify(literal)} into Date ${expected}`, + () => { + const parsed = GraphQLDateTime.parseLiteral({ + kind: Kind.STRING, value + }); + expect(parsed.getTime()).to.equal(expected.getTime()); + }); + }); + + invalidDateTime.forEach(value => { + const literal = { + kind: Kind.STRING, value + }; + it(`returns null for invalid literal ${JSON.stringify(literal)}`, () => { + expect( + GraphQLDateTime.parseLiteral(literal) + ).to.equal(null); + }); + }); + + it('returns null for invalid kind', () => { + expect( + GraphQLDateTime.parseLiteral({ + kind: Kind.FLOAT, value: 5 + }) + ).to.equal(null); + }); + }); }); diff --git a/src/type/index.js b/src/type/index.js index 6747751068..e1a9651dfd 100644 --- a/src/type/index.js +++ b/src/type/index.js @@ -59,6 +59,7 @@ export { GraphQLString, GraphQLBoolean, GraphQLID, + GraphQLDateTime, } from './scalars'; export { diff --git a/src/type/scalars.js b/src/type/scalars.js index 5c44d1dae8..56b25bf617 100644 --- a/src/type/scalars.js +++ b/src/type/scalars.js @@ -121,3 +121,142 @@ export const GraphQLID = new GraphQLScalarType({ null; } }); + +/** + * Function that validates whether a date-time string + * is valid according to the RFC 3339 specification. + */ +function isValidDate(dateTime: string): boolean { + /* eslint-disable max-len*/ + const RFC_3339_REGEX = /^(\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60))(\.\d{1,})?(([Z])|([+|-]([01][0-9]|2[0-3]):[0-5][0-9]))$/; + + if (!RFC_3339_REGEX.test(dateTime)) { + return false; + } + // Check if it is a valid Date. + // Note, according to RFC 3339 2016-02-01T00:00:60Z is a valid date-time string. + // However, it is considered invalid when parsed by the javascript + // Date class because it ignores leap seconds. + // Therefore, this implementation also ignores leap seconds. + const time = Date.parse(dateTime); + if (time !== time) { + return false; + } + + // Check whether a certain year is a leap year. + // + // Every year that is exactly divisible by four + // is a leap year, except for years that are exactly + // divisible by 100, but these centurial years are + // leap years if they are exactly divisible by 400. + // For example, the years 1700, 1800, and 1900 are not leap years, + // but the years 1600 and 2000 are. + const leapYear = year => { + return ((year % 4 === 0) && (year % 100 !== 0)) || (year % 400 === 0); + }; + + const year = Number(dateTime.substr(0, 4)); + const month = Number(dateTime.substr(5, 2)); + const day = Number(dateTime.substr(8, 2)); + + // Month Number Month/Year Maximum value of date-mday + // ------------ ---------- -------------------------- + // 01 January 31 + // 02 February, normal 28 + // 02 February, leap year 29 + // 03 March 31 + // 04 April 30 + // 05 May 31 + // 06 June 30 + // 07 July 31 + // 08 August 31 + // 09 September 30 + // 10 October 31 + // 11 November 30 + // 12 December 31 + switch (month) { + case 2: // February + if (leapYear(year) && day > 29) { + return false; + } else if (!leapYear(year) && day > 28) { + return false; + } + return true; + case 4: // April + case 6: // June + case 9: // September + case 11: // November + if (day > 30) { + return false; + } + break; + } + return true; +} + +export const GraphQLDateTime = new GraphQLScalarType({ + name: 'DateTime', + description: + 'The `DateTime` scalar represents a timestamp, ' + + 'represented as a string serialized date-time conforming to the '+ + 'RFC 3339(https://www.ietf.org/rfc/rfc3339.txt) profile of the ' + + 'ISO 8601 standard for representation of dates and times using the ' + + 'Gregorian calendar.', + serialize(value: mixed): string { + if (value instanceof Date) { + const time = value.getTime(); + if (time === time) { + return value.toISOString(); + } + throw new TypeError('DateTime cannot represent an invalid Date instance'); + } else if (typeof value === 'string' || value instanceof String) { + if (isValidDate(value)) { + return value; + } + throw new TypeError( + 'DateTime cannot represent an invalid date-time string ' + value + ); + } else if (typeof value === 'number' || value instanceof Number) { + // Serialize from Unix timestamp: the number of + // seconds since 1st Jan 1970. + + // Unix timestamp are 32-bit signed integers + if (value === value && value <= MAX_INT && value >= MIN_INT) { + // Date represents unix time as the number of + // milliseconds since 1st Jan 1970 therefore we + // need to perform a conversion. + const date = new Date(value * 1000); + return date.toISOString(); + } + throw new TypeError( + 'DateTime cannot represent an invalid Unix timestamp ' + value + ); + } else { + throw new TypeError( + 'DateTime cannot be serialized from a non string, ' + + 'non numeric or non Date type ' + String(value) + ); + } + }, + parseValue(value: mixed): Date { + if (!(typeof value === 'string' || value instanceof String)) { + throw new TypeError( + 'DateTime cannot represent non string type ' + String(value) + ); + } + if (isValidDate(value)) { + return new Date(value); + } + throw new TypeError( + 'DateTime cannot represent an invalid date-time string ' + value + ); + }, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + if (isValidDate(ast.value)) { + return new Date(ast.value); + } + } + return null; + } +});