diff --git a/src/index.ts b/src/index.ts index 58074031e..59cb770c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { GraphQLCountryCode, GraphQLCountryName, GraphQLCuid, + GraphQLCuid2, GraphQLCurrency, GraphQLDate, GraphQLDateTime, @@ -219,6 +220,7 @@ export const resolvers: Record = { CountryCode: GraphQLCountryCode, CountryName: GraphQLCountryName, Cuid: GraphQLCuid, + Cuid2: GraphQLCuid2, Currency: GraphQLCurrency, Date: GraphQLDate, DateTime: GraphQLDateTime, @@ -434,9 +436,13 @@ export { export { GeoJSON as GeoJSONTypeDefinition } from './typeDefs.js'; export { CountryName as CountryNameTypeDefinition } from './typeDefs.js'; +export { Cuid2 as Cuid2TypeDefinition } from './typeDefs.js'; export { GraphQLCountryName as CountryNameResolver }; export { GraphQLCountryName }; export { CountryName as CountryNameMock } from './mocks.js'; export { GraphQLGeoJSON as GeoJSONResolver }; +export { GraphQLCuid2 as Cuid2Resolver }; export { GraphQLGeoJSON }; +export { GraphQLCuid2 }; export { GeoJSON as GeoJSONMock } from './mocks.js'; +export { Cuid2 as Cuid2Mock } from './mocks.js'; diff --git a/src/mocks.ts b/src/mocks.ts index 3b7c68687..3d62e6577 100644 --- a/src/mocks.ts +++ b/src/mocks.ts @@ -1,3 +1,4 @@ +export const Cuid2 = () => 'dp71y53f6eykvl5g1393rmhl'; export const GeoJSON = () => 'Example GeoJSON'; export const CountryName = () => 'Example CountryName'; const BigIntMock = () => BigInt(Number.MAX_SAFE_INTEGER); diff --git a/src/scalars/Cuid2.ts b/src/scalars/Cuid2.ts new file mode 100644 index 000000000..96b966e02 --- /dev/null +++ b/src/scalars/Cuid2.ts @@ -0,0 +1,49 @@ +import { GraphQLScalarType, Kind, ValueNode } from 'graphql'; +import { createGraphQLError } from '../error.js'; + +const CUID2_REGEX = /^[a-z][a-z0-9]{1,31}$/; + +const validateCuid2 = (value: any, ast?: ValueNode) => { + if (typeof value !== 'string') { + throw createGraphQLError(`Value is not string: ${value}`, ast ? { nodes: ast } : undefined); + } + + if (!CUID2_REGEX.test(value)) { + throw createGraphQLError( + `Value is not a valid cuid2: ${value}`, + ast ? { nodes: ast } : undefined, + ); + } + + return value; +}; + +const specifiedByURL = 'https://github.com/paralleldrive/cuid2'; + +export const GraphQLCuid2 = /*#__PURE__*/ new GraphQLScalarType({ + name: 'Cuid2', + description: `A field whose value conforms to the cuid2 format, as specified in ${specifiedByURL}`, + + serialize: validateCuid2, + parseValue: validateCuid2, + + parseLiteral(ast) { + if (ast.kind !== Kind.STRING) { + throw createGraphQLError(`Can only validate strings as cuid2 but got: ${ast.kind}`, { + nodes: [ast], + }); + } + + return validateCuid2(ast.value, ast); + }, + + specifiedByURL, + extensions: { + codegenScalarType: 'string', + jsonSchema: { + title: 'Cuid2', + type: 'string', + pattern: CUID2_REGEX.source, + }, + }, +}); diff --git a/src/scalars/index.ts b/src/scalars/index.ts index a2886ff1e..d57019be0 100644 --- a/src/scalars/index.ts +++ b/src/scalars/index.ts @@ -67,3 +67,4 @@ export { GraphQLSESSN } from './ssn/SE.js'; export { GraphQLDeweyDecimal } from './library/DeweyDecimal.js'; export { GraphQLLCCSubclass } from './library/LCCSubclass.js'; export { GraphQLIPCPatent } from './patent/IPCPatent.js'; +export { GraphQLCuid2 } from './Cuid2.js'; diff --git a/src/typeDefs.ts b/src/typeDefs.ts index 3442b5d74..df13807b6 100644 --- a/src/typeDefs.ts +++ b/src/typeDefs.ts @@ -1,3 +1,4 @@ +export const Cuid2 = 'scalar Cuid2'; export const GeoJSON = 'scalar GeoJSON'; export const CountryName = 'scalar CountryName'; export const BigInt = 'scalar BigInt'; @@ -79,6 +80,7 @@ export const typeDefs = [ CountryCode, CountryName, Cuid, + Cuid2, Currency, Date, DateTime, diff --git a/tests/Cuid2.test.ts b/tests/Cuid2.test.ts new file mode 100644 index 000000000..c677fd9f6 --- /dev/null +++ b/tests/Cuid2.test.ts @@ -0,0 +1,104 @@ +/* global describe, test, expect */ + +import { Kind } from 'graphql/language'; +import { GraphQLCuid2 } from '../src/scalars/Cuid2.js'; + +describe('Cuid2', () => { + describe('valid', () => { + const validCuid2 = 'dp71y53f6eykvl5g1393rmhl'; + + test('serialize', () => { + expect(GraphQLCuid2.serialize(validCuid2)).toBe(validCuid2); + }); + + test('parseValue', () => { + expect(GraphQLCuid2.parseValue(validCuid2)).toBe(validCuid2); + }); + + test('parseLiteral', () => { + expect( + GraphQLCuid2.parseLiteral( + { + value: validCuid2, + kind: Kind.STRING, + }, + {}, + ), + ).toBe(validCuid2); + }); + }); + + describe('invalid', () => { + describe('not a cuid2', () => { + test('serialize', () => { + expect(() => GraphQLCuid2.serialize('not-a-valid-cuid2')).toThrow( + /Value is not a valid cuid2/, + ); + }); + + test('parseValue', () => { + expect(() => GraphQLCuid2.parseValue('not-a-valid-cuid2')).toThrow( + /Value is not a valid cuid2/, + ); + }); + + test('parseLiteral', () => { + expect(() => + GraphQLCuid2.parseLiteral( + { + value: 'not-a-valid-cuid2', + kind: Kind.STRING, + }, + {}, + ), + ).toThrow(/Value is not a valid cuid2/); + }); + }); + + describe('not a string', () => { + test('serialize', () => { + expect(() => GraphQLCuid2.serialize(123)).toThrow(/Value is not string/); + }); + + test('parseValue', () => { + expect(() => GraphQLCuid2.parseValue(123)).toThrow(/Value is not string/); + }); + + test('parseLiteral', () => { + expect(() => GraphQLCuid2.parseLiteral({ value: '123', kind: Kind.INT }, {})).toThrow( + /Can only validate strings as cuid2 but got/, + ); + }); + }); + + describe('boundary cases', () => { + test('minimum length (2 characters)', () => { + const minCuid2 = 'a1'; + expect(GraphQLCuid2.serialize(minCuid2)).toBe(minCuid2); + expect(GraphQLCuid2.parseValue(minCuid2)).toBe(minCuid2); + expect(GraphQLCuid2.parseLiteral({ value: minCuid2, kind: Kind.STRING }, {})).toBe( + minCuid2, + ); + }); + + test('maximum length (32 characters)', () => { + const maxCuid2 = 'a123456789abcdef123456789abcdef1'; // starts with a letter + expect(GraphQLCuid2.serialize(maxCuid2)).toBe(maxCuid2); + expect(GraphQLCuid2.parseValue(maxCuid2)).toBe(maxCuid2); + expect(GraphQLCuid2.parseLiteral({ value: maxCuid2, kind: Kind.STRING }, {})).toBe( + maxCuid2, + ); + }); + + test('too short (1 character)', () => { + const tooShort = 'a'; + expect(() => GraphQLCuid2.serialize(tooShort)).toThrow(/Value is not a valid cuid2/); + }); + + test('too long (33 characters)', () => { + const tooLong = 'a1234567890abcdef1234567890abcdef1'; + expect(() => GraphQLCuid2.serialize(tooLong)).toThrow(/Value is not a valid cuid2/); + }); + }); + }); +}); diff --git a/website/src/content/scalars/_meta.ts b/website/src/content/scalars/_meta.ts index ca71b5e45..52c421867 100644 --- a/website/src/content/scalars/_meta.ts +++ b/website/src/content/scalars/_meta.ts @@ -5,6 +5,7 @@ export default { 'country-code': 'CountryCode', 'country-name': 'CountryName', cuid: 'Cuid', + cuid2: 'Cuid2', currency: 'Currency', date: 'Date', 'date-time': 'DateTime', diff --git a/website/src/content/scalars/cuid2.mdx b/website/src/content/scalars/cuid2.mdx new file mode 100644 index 000000000..e2be6f95c --- /dev/null +++ b/website/src/content/scalars/cuid2.mdx @@ -0,0 +1,4 @@ +# Cuid2 + +A field whose value conforms to the `cuid2` format as specified in +[ParallelDrive/cuid2](https://github.com/paralleldrive/cuid2).