diff --git a/.changeset/quiet-llamas-worry.md b/.changeset/quiet-llamas-worry.md new file mode 100644 index 000000000..2621838ec --- /dev/null +++ b/.changeset/quiet-llamas-worry.md @@ -0,0 +1,5 @@ +--- +'graphql-scalars': minor +--- + +New `LocalDateTimeString` scalar diff --git a/src/index.ts b/src/index.ts index 8b67fe36d..03bf8cea0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ import { GraphQLLatitude, GraphQLLCCSubclass, GraphQLLocalDate, + GraphQLLocalDateTime, GraphQLLocale, GraphQLLocalEndTime, GraphQLLocalTime, @@ -76,6 +77,7 @@ export { UtcOffset as UtcOffsetTypeDefinition, LocalDate as LocalDateTypeDefinition, LocalTime as LocalTimeTypeDefinition, + LocalDateTime as LocalDateTimeTypeDefinition, LocalEndTime as LocalEndTimeTypeDefinition, EmailAddress as EmailAddressTypeDefinition, NegativeFloat as NegativeFloatTypeDefinition, @@ -146,6 +148,7 @@ export { GraphQLISO8601Duration as ISO8601DurationResolver, GraphQLLocalDate as LocalDateResolver, GraphQLLocalTime as LocalTimeResolver, + GraphQLLocalDateTime as LocalDateTimeResolver, GraphQLLocalEndTime as LocalEndTimeResolver, GraphQLEmailAddress as EmailAddressResolver, GraphQLNegativeFloat as NegativeFloatResolver, @@ -212,6 +215,7 @@ export const resolvers: Record = { ISO8601Duration: GraphQLISO8601Duration, LocalDate: GraphQLLocalDate, LocalTime: GraphQLLocalTime, + LocalDateTime: GraphQLLocalDateTime, LocalEndTime: GraphQLLocalEndTime, EmailAddress: GraphQLEmailAddress, NegativeFloat: GraphQLNegativeFloat, @@ -279,6 +283,7 @@ export { UtcOffset as UtcOffsetMock, LocalDate as LocalDateMock, LocalTime as LocalTimeMock, + LocalDateTime as LocalDateTimeMock, LocalEndTime as LocalEndTimeMock, EmailAddress as EmailAddressMock, NegativeFloat as NegativeFloatMock, @@ -354,6 +359,7 @@ export { GraphQLISO8601Duration, GraphQLLocalDate, GraphQLLocalTime, + GraphQLLocalDateTime, GraphQLLocalEndTime, GraphQLEmailAddress, GraphQLNegativeFloat, diff --git a/src/mocks.ts b/src/mocks.ts index 0eee37f46..d4856d691 100644 --- a/src/mocks.ts +++ b/src/mocks.ts @@ -9,6 +9,7 @@ export const UtcOffset = () => '+03:00'; export const Duration = () => 'P3Y6M4DT12H30M5S'; export const LocalDate = () => '2020-07-19'; export const LocalTime = () => '08:45:59'; +export const LocalDateTime = () => '2020-07-19T08:45:59'; export const LocalEndTime = () => '24:00:00'; export const EmailAddress = () => 'test@test.com'; export const NegativeFloat = () => -123.45; @@ -47,7 +48,8 @@ const randomVal = (min: number, max: number) => { }; // https://codepen.io/meowwwls/pen/jbEJRp export const HSL = () => `hsl(${randomVal(0, 360)}, ${randomVal(30, 95)}%, ${randomVal(30, 80)}%)`; -export const HSLA = () => `hsla(${randomVal(0, 360)}, ${randomVal(30, 95)}%, ${randomVal(30, 80)}%, ${Math.random()})`; +export const HSLA = () => + `hsla(${randomVal(0, 360)}, ${randomVal(30, 95)}%, ${randomVal(30, 80)}%, ${Math.random()})`; export const IP = () => '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; // https://stackoverflow.com/questions/43464519/creating-fake-ip-address-using-javascript @@ -63,10 +65,13 @@ export const IPv4 = () => export const IPv6 = () => '2001:0db8:85a3:0000:0000:8a2e:0370:7334'; // http://jsfiddle.net/guest271314/qhbC9/ export const MAC = () => - 'XX:XX:XX:XX:XX:XX'.replace(/X/g, () => '0123456789ABCDEF'.charAt(Math.floor(Math.random() * 16))); + 'XX:XX:XX:XX:XX:XX'.replace(/X/g, () => + '0123456789ABCDEF'.charAt(Math.floor(Math.random() * 16)), + ); export const Port = () => randomVal(0, 65535); export const RGB = () => `rgb(${randomVal(0, 255)}, ${randomVal(0, 255)}, ${randomVal(0, 255)})`; -export const RGBA = () => `rgba(${randomVal(0, 255)}, ${randomVal(0, 255)}, ${randomVal(0, 255)}, ${Math.random()})`; +export const RGBA = () => + `rgba(${randomVal(0, 255)}, ${randomVal(0, 255)}, ${randomVal(0, 255)}, ${Math.random()})`; export const ISBN = () => `978-3-16-148410-0`; export const JWT = () => { // HEADER: { diff --git a/src/scalars/LocalDateTime.ts b/src/scalars/LocalDateTime.ts new file mode 100644 index 000000000..5dde9f6dd --- /dev/null +++ b/src/scalars/LocalDateTime.ts @@ -0,0 +1,76 @@ +import { ASTNode, GraphQLScalarType, GraphQLScalarTypeConfig, Kind } from 'graphql'; +import { createGraphQLError } from '../error.js'; + +const LOCAL_DATE_TIME_REGEX = + /^((?:(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}(?:\.\d+)?))(Z|[\+-]\d{2}:\d{2})?)$/; + +function validateLocalDateTime(value: any, ast?: ASTNode): string { + if (typeof value !== 'string') { + throw createGraphQLError( + `Value is not string: ${value}`, + ast + ? { + nodes: [ast], + } + : undefined, + ); + } + if (!LOCAL_DATE_TIME_REGEX.test(value.toUpperCase())) { + throw createGraphQLError( + `LocalDateTime cannot represent an invalid local date-time-string ${value}.`, + ast + ? { + nodes: [ast], + } + : undefined, + ); + } + const valueAsDate = new Date(value); + const isValidDate = !isNaN(valueAsDate.getTime()); + if (!isValidDate) { + throw createGraphQLError( + `Value is not a valid LocalDateTime: ${value}`, + ast + ? { + nodes: [ast], + } + : undefined, + ); + } + return value; +} + +export const LocalDateTimeConfig: GraphQLScalarTypeConfig = /*#__PURE__*/ { + name: 'LocalDateTime', + description: + 'A local date-time string (i.e., with no associated timezone) in `YYYY-MM-DDTHH:mm:ss` format, e.g. `2020-01-01T00:00:00`.', + serialize(value) { + return validateLocalDateTime(value); + }, + parseValue(value) { + return validateLocalDateTime(value); + }, + parseLiteral(ast) { + if (ast.kind !== Kind.STRING) { + throw createGraphQLError( + `Can only validate strings as local date-times but got a: ${ast.kind}`, + { + nodes: [ast], + }, + ); + } + return validateLocalDateTime(ast.value, ast); + }, + extensions: { + codegenScalarType: + // eslint-disable-next-line no-template-curly-in-string + '`${number}${number}${number}${number}-${number}${number}-${number}${number}T${number}${number}:${number}${number}:${number}${number}`', + jsonSchema: { + title: 'LocalDateTime', + type: 'string', + format: 'date-time', + }, + }, +}; + +export const GraphQLLocalDateTime = /*#__PURE__*/ new GraphQLScalarType(LocalDateTimeConfig); diff --git a/src/scalars/index.ts b/src/scalars/index.ts index 4c2f538ff..821e74a70 100644 --- a/src/scalars/index.ts +++ b/src/scalars/index.ts @@ -7,6 +7,7 @@ export { GraphQLUtcOffset } from './UtcOffset.js'; export { GraphQLISO8601Duration } from './iso-date/Duration.js'; export { GraphQLLocalDate } from './LocalDate.js'; export { GraphQLLocalTime } from './LocalTime.js'; +export { GraphQLLocalDateTime } from './LocalDateTime.js'; export { GraphQLLocalEndTime } from './LocalEndTime.js'; export { GraphQLEmailAddress } from './EmailAddress.js'; export { GraphQLNegativeFloat } from './NegativeFloat.js'; diff --git a/src/typeDefs.ts b/src/typeDefs.ts index cbdf61231..84a03b9f2 100644 --- a/src/typeDefs.ts +++ b/src/typeDefs.ts @@ -10,6 +10,7 @@ export const Duration = 'scalar Duration'; export const ISO8601Duration = 'scalar ISO8601Duration'; export const LocalDate = 'scalar LocalDate'; export const LocalTime = 'scalar LocalTime'; +export const LocalDateTime = 'scalar LocalDateTime'; export const LocalEndTime = 'scalar LocalEndTime'; export const EmailAddress = 'scalar EmailAddress'; export const UUID = `scalar UUID`; @@ -78,6 +79,7 @@ export const typeDefs = [ ISO8601Duration, LocalDate, LocalTime, + LocalDateTime, LocalEndTime, EmailAddress, NegativeFloat, diff --git a/tests/LocalDate.test.ts b/tests/LocalDate.test.ts index b8b990500..f00c70066 100644 --- a/tests/LocalDate.test.ts +++ b/tests/LocalDate.test.ts @@ -1,5 +1,5 @@ -const { Kind } = require('graphql/language'); -const { GraphQLLocalDate } = require('../src/scalars/LocalDate'); +import { Kind } from 'graphql'; +import { GraphQLLocalDate } from '../src/scalars/LocalDate'; const VALID_LOCAL_DATES = [ '2020-01-01', @@ -21,19 +21,19 @@ const INVALID_LOCAL_DATES = [ describe(`LocalDate`, () => { describe(`valid`, () => { it(`serialize`, () => { - VALID_LOCAL_DATES.forEach((date) => { + VALID_LOCAL_DATES.forEach(date => { expect(GraphQLLocalDate.serialize(date)).toEqual(date); }); }); it(`parseValue`, () => { - VALID_LOCAL_DATES.forEach((date) => { + VALID_LOCAL_DATES.forEach(date => { expect(GraphQLLocalDate.parseValue(date)).toEqual(date); }); }); it(`parseLiteral`, () => { - VALID_LOCAL_DATES.forEach((testValue) => { + VALID_LOCAL_DATES.forEach(testValue => { expect( GraphQLLocalDate.parseLiteral( { @@ -50,14 +50,10 @@ describe(`LocalDate`, () => { describe(`invalid`, () => { describe(`not a valid LocalDate`, () => { it(`serialize`, () => { - expect(() => GraphQLLocalDate.serialize(123)).toThrow( - /Value is not string/, - ); + expect(() => GraphQLLocalDate.serialize(123)).toThrow(/Value is not string/); - expect(() => GraphQLLocalDate.serialize(false)).toThrow( - /Value is not string/, - ); - INVALID_LOCAL_DATES.forEach((testValue) => { + expect(() => GraphQLLocalDate.serialize(false)).toThrow(/Value is not string/); + INVALID_LOCAL_DATES.forEach(testValue => { expect(() => GraphQLLocalDate.serialize(testValue)).toThrow( /Value is not a valid LocalDate/, ); @@ -65,37 +61,25 @@ describe(`LocalDate`, () => { }); it(`parseValue`, () => { - expect(() => GraphQLLocalDate.parseValue(123)).toThrow( - /Value is not string/, - ); + expect(() => GraphQLLocalDate.parseValue(123)).toThrow(/Value is not string/); - expect(() => GraphQLLocalDate.parseValue(false)).toThrow( - /Value is not string/, - ); - INVALID_LOCAL_DATES.forEach((date) => { - expect(() => GraphQLLocalDate.parseValue(date)).toThrow( - /Value is not a valid LocalDate/, - ); + expect(() => GraphQLLocalDate.parseValue(false)).toThrow(/Value is not string/); + INVALID_LOCAL_DATES.forEach(date => { + expect(() => GraphQLLocalDate.parseValue(date)).toThrow(/Value is not a valid LocalDate/); }); }); it(`parseLiteral`, () => { expect(() => - GraphQLLocalDate.parseLiteral({ value: 123, kind: Kind.INT }, {}), + GraphQLLocalDate.parseLiteral({ value: 123 as any, kind: Kind.INT }, {}), ).toThrow(/Can only validate strings as local dates but got a/); expect(() => - GraphQLLocalDate.parseLiteral( - { value: false, kind: Kind.BOOLEAN }, - {}, - ), + GraphQLLocalDate.parseLiteral({ value: false, kind: Kind.BOOLEAN }, {}), ).toThrow(/Can only validate strings as local dates but got a/); - INVALID_LOCAL_DATES.forEach((testValue) => { + INVALID_LOCAL_DATES.forEach(testValue => { expect(() => - GraphQLLocalDate.parseLiteral( - { value: testValue, kind: Kind.STRING }, - {}, - ), + GraphQLLocalDate.parseLiteral({ value: testValue, kind: Kind.STRING }, {}), ).toThrow(/Value is not a valid LocalDate/); }); }); diff --git a/tests/LocalDateTime.test.ts b/tests/LocalDateTime.test.ts new file mode 100644 index 000000000..6ec348d64 --- /dev/null +++ b/tests/LocalDateTime.test.ts @@ -0,0 +1,101 @@ +import { Kind } from 'graphql'; +import { GraphQLLocalDateTime } from '../src'; + +const VALID_LOCAL_DATE_TIMES = [ + '2016-02-01T00:00:15Z', + '2016-02-01T00:00:00-11:00', + '2017-01-07T11:25:00+01:00', + '2017-01-07T00:00:00+01:20', + '2016-02-01T00:00:00.1Z', + '2016-02-01T00:00:00.000Z', + '2016-02-01T00:00:00.990Z', + '2016-02-01T00:00:00.23498Z', + '2017-01-07T11:25:00.450+01:00', + '2016-02-01t00:00:00.000z', +]; + +describe(`LocalDateTime`, () => { + describe('valid', () => { + it('serialize', () => { + VALID_LOCAL_DATE_TIMES.forEach(value => { + expect(GraphQLLocalDateTime.serialize(value)).toEqual(value); + }); + }); + + it('parseValue', () => { + VALID_LOCAL_DATE_TIMES.forEach(value => { + expect(GraphQLLocalDateTime.parseValue(value)).toEqual(value); + }); + }); + + it('parseLiteral', () => { + VALID_LOCAL_DATE_TIMES.forEach(value => { + expect( + GraphQLLocalDateTime.parseLiteral( + { + value, + kind: Kind.STRING, + }, + {}, + ), + ).toEqual(value); + }); + }); + }); + describe('invalid', () => { + describe('not string', () => { + it('serialize', () => { + expect(() => GraphQLLocalDateTime.serialize(123)).toThrow(/Value is not string/); + expect(() => GraphQLLocalDateTime.serialize(false)).toThrow(/Value is not string/); + }); + it('parseValue', () => { + expect(() => GraphQLLocalDateTime.parseValue(123)).toThrow(/Value is not string/); + expect(() => GraphQLLocalDateTime.parseValue(false)).toThrow(/Value is not string/); + }); + it('parseLiteral', () => { + expect(() => + GraphQLLocalDateTime.parseLiteral({ value: 123 as any, kind: Kind.INT }, {}), + ).toThrow(/Can only validate strings as local date-times but got a: IntValue/); + expect(() => + GraphQLLocalDateTime.parseLiteral({ value: false, kind: Kind.BOOLEAN }, {}), + ).toThrow(/Can only validate strings as local date-times but got a: BooleanValue/); + }); + }); + describe('not a valid local date time string', () => { + const invalidDateTime = '2015-02-24T00:00:00.000+0100'; + it('serialize', () => { + expect(() => GraphQLLocalDateTime.serialize(invalidDateTime)).toThrow( + /LocalDateTime cannot represent an invalid local date-time-string/, + ); + }); + it('parseValue', () => { + expect(() => GraphQLLocalDateTime.parseValue(invalidDateTime)).toThrow( + /LocalDateTime cannot represent an invalid local date-time-string/, + ); + }); + it('parseLiteral', () => { + expect(() => + GraphQLLocalDateTime.parseLiteral({ value: invalidDateTime, kind: Kind.STRING }, {}), + ).toThrow(/LocalDateTime cannot represent an invalid local date-time-string/); + }); + }); + describe('invalid date', () => { + const invalidDateTime = '2017-13-46T11:25:00.450+01:00'; + it('serialize', () => { + expect(() => GraphQLLocalDateTime.serialize(invalidDateTime)).toThrow( + /Value is not a valid LocalDateTime/, + ); + }); + it('parseValue', () => { + expect(() => GraphQLLocalDateTime.parseValue(invalidDateTime)).toThrow( + /Value is not a valid LocalDateTime/, + ); + }); + it('parseLiteral', () => { + expect(() => + GraphQLLocalDateTime.parseLiteral({ value: invalidDateTime, kind: Kind.STRING }, {}), + ).toThrow(/Value is not a valid LocalDateTime/); + }); + }); + }); +});