diff --git a/packages/cma-client/__tests__/idUtils.test.ts b/packages/cma-client/__tests__/idUtils.test.ts new file mode 100644 index 0000000..e3846e3 --- /dev/null +++ b/packages/cma-client/__tests__/idUtils.test.ts @@ -0,0 +1,15 @@ +import { generateId, isValidId } from '../src/idUtils'; + +describe('generateId', () => { + it('expects an ID from generateId() to validate as a DatoCMS ID', () => { + expect(isValidId(generateId())).toBe(true); + }); +}); + +describe('isValidId', () => { + it('checks if passed string is a valid DatoCMS ID', () => { + expect(isValidId('')).toBe(false); + expect(isValidId('foobar')).toBe(false); + expect(isValidId('WTyssHtyTzu9_EbszSVhPw')).toBe(true); + }); +}); diff --git a/packages/cma-client/src/generateId.ts b/packages/cma-client/src/generateId.ts deleted file mode 100644 index 85a8043..0000000 --- a/packages/cma-client/src/generateId.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { v4 } from 'uuid'; -const decoder = new TextDecoder('utf8'); - -function toBase64(bytes: Uint8Array) { - if (typeof Buffer !== 'undefined') { - return Buffer.from(bytes).toString('base64'); - } - - return btoa(decoder.decode(bytes)); -} - -export function generateId() { - const bytes = v4(null, new Uint8Array(16)); - - // unset first bit to ensure [A-Za-f] first char - bytes[0] = bytes[0]! & 0x7f; - - const base64 = toBase64(bytes); - - return ( - base64 - // Replace + with - (see RFC 4648, sec. 5) - .replace(/\+/g, '-') - // Replace / with _ (see RFC 4648, sec. 5) - .replace(/\//g, '_') - // Drop '==' padding - .substring(0, 22) - ); -} diff --git a/packages/cma-client/src/idUtils.ts b/packages/cma-client/src/idUtils.ts new file mode 100644 index 0000000..7ee1650 --- /dev/null +++ b/packages/cma-client/src/idUtils.ts @@ -0,0 +1,83 @@ +import { v4 } from 'uuid'; + +const decoder = new TextDecoder('utf8'); + +function fromUint8ArrayToUrlSafeBase64(bytes: Uint8Array) { + const base64 = + typeof Buffer === 'undefined' + ? btoa(decoder.decode(bytes)) + : Buffer.from(bytes).toString('base64'); + + // Convert to URL-safe format (see RFC 4648, sec. 5) + return ( + base64 + // Replace + with - + .replace(/\+/g, '-') + // Replace / with _ + .replace(/\//g, '_') + // Drop '==' padding + .substring(0, 22) + ); +} + +function fromUrlSafeBase64toUint8Array(urlSafeBase64: string): Uint8Array { + // Convert from URL-safe format (see RFC 4648, sec. 5) + const base64 = urlSafeBase64 + // Replace - with + + .replace(/-/g, '+') + // Replace _ with / + .replace(/_/g, '/'); + + return typeof Buffer === 'undefined' + ? Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) + : new Uint8Array(Buffer.from(base64, 'base64')); +} + +export function isValidId(id: string) { + const bytes = fromUrlSafeBase64toUint8Array(id); + + // UUIDs are 16 bytes + if (bytes.length !== 16) { + return false; + } + + // The variant field determines the layout of the UUID + // (see RFC 4122, sec. 4.1.1) + + const variant = bytes.at(8)!; + + // Variant must be the one described in RFC 4122 + if ((variant & 0b11000000) !== 0b10000000) { + return false; + } + + // The version number is in the most significant 4 bits + // of the time stamp (see RFC 4122, sec. 4.1.3) + + const version = bytes.at(6)! >> 4; + + // Version number must be 4 (randomly generated) + if (version !== 0x4) { + return false; + } + + return true; +} + +export function generateId() { + const bytes = v4(null, new Uint8Array(16)); + + // Here we unset the first bit to ensure [A-Za-f] as the first char. + // + // If we didn't do this, we would generate IDs that, once encoded + // in base64, could start with a '+' or a '/'. This makes them less + // easy to copy/paste, with bad DX. + + // This choice is purely aesthetic: definitely non-mandatory! + + bytes[0] = bytes[0]! & 0x7f; + + const base64 = fromUint8ArrayToUrlSafeBase64(bytes); + + return base64; +} diff --git a/packages/cma-client/src/index.ts b/packages/cma-client/src/index.ts index 03c637d..1d4af33 100644 --- a/packages/cma-client/src/index.ts +++ b/packages/cma-client/src/index.ts @@ -1,9 +1,9 @@ -export { ApiError, TimeoutError, LogLevel } from '@datocms/rest-client-utils'; +export { ApiError, LogLevel, TimeoutError } from '@datocms/rest-client-utils'; +export * from './buildBlockRecord'; +export * from './buildClient'; export { Client } from './generated/Client'; -export * as Resources from './generated/resources'; export type { ClientConfigOptions } from './generated/Client'; -export * from './buildClient'; -export * from './buildBlockRecord'; -export * from './generateId'; +export * as Resources from './generated/resources'; export * as SchemaTypes from './generated/SchemaTypes'; export * as SimpleSchemaTypes from './generated/SimpleSchemaTypes'; +export * from './idUtils';