From 1c15eb183e53c2b6f36c1cee7ec383bad4e1dec5 Mon Sep 17 00:00:00 2001 From: Miracle Nnaji Date: Fri, 24 Apr 2026 13:11:59 +0100 Subject: [PATCH] feat: add uuid v4/v7 generator api endpoint Implement GET /api/routes-f/uuid with support for: - UUID v4 (random) generation using crypto.getRandomValues - UUID v7 (time-ordered) with timestamp in first 48 bits - Query params: version (v4|v7, default v4), count (1-100, default 1) - Strict validation returning 400 for invalid inputs - Edge runtime configuration Add comprehensive Jest tests covering valid/invalid cases. --- app/api/routes-f/uuid/**tests**/route.test.ts | 138 ++++++++++++++++++ app/api/routes-f/uuid/_lib/uuid.ts | 46 ++++++ app/api/routes-f/uuid/route.ts | 36 +++++ 3 files changed, 220 insertions(+) create mode 100644 app/api/routes-f/uuid/**tests**/route.test.ts create mode 100644 app/api/routes-f/uuid/_lib/uuid.ts create mode 100644 app/api/routes-f/uuid/route.ts diff --git a/app/api/routes-f/uuid/**tests**/route.test.ts b/app/api/routes-f/uuid/**tests**/route.test.ts new file mode 100644 index 00000000..dedc8ca1 --- /dev/null +++ b/app/api/routes-f/uuid/**tests**/route.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from '@jest/globals'; +import { generateUUID, generateUUIDs } from '../_lib/uuid'; +import { GET } from '../route'; + +describe('UUID Generator', () => { + describe('generateUUID', () => { + it('generates valid v4 UUIDs', () => { + const uuid = generateUUID('v4'); + expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + + it('generates valid v7 UUIDs', () => { + const uuid = generateUUID('v7'); + expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + + it('generates unique UUIDs for v4', () => { + const uuids = new Set(); + for (let i = 0; i < 100; i++) { + uuids.add(generateUUID('v4')); + } + expect(uuids.size).toBe(100); + }); + + it('generates unique UUIDs for v7', () => { + const uuids = new Set(); + for (let i = 0; i < 100; i++) { + uuids.add(generateUUID('v7')); + } + expect(uuids.size).toBe(100); + }); + + it('defaults to v4 when no version specified', () => { + const uuid = generateUUID(); + expect(uuid[14]).toBe('4'); + }); + }); + + describe('generateUUIDs', () => { + it('generates correct count of UUIDs for v4', () => { + expect(generateUUIDs('v4', 3)).toHaveLength(3); + expect(generateUUIDs('v4', 1)).toHaveLength(1); + expect(generateUUIDs('v4', 5)).toHaveLength(5); + }); + + it('generates correct count of UUIDs for v7', () => { + expect(generateUUIDs('v7', 2)).toHaveLength(2); + expect(generateUUIDs('v7', 10)).toHaveLength(10); + }); + + it('generates empty array for count of 0', () => { + expect(generateUUIDs('v4', 0)).toHaveLength(0); + }); + }); + + describe('UUID format validation', () => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + it('v4 UUIDs follow correct format', () => { + const uuid = generateUUID('v4'); + expect(uuid).toMatch(uuidRegex); + expect(uuid[14]).toBe('4'); + }); + + it('v7 UUIDs follow correct format', () => { + const uuid = generateUUID('v7'); + expect(uuid).toMatch(uuidRegex); + expect(uuid[14]).toBe('7'); + }); + }); +}); + +describe('GET /api/routes-f/uuid', () => { + const makeRequest = (search: string = '') => + new Request(`http://localhost/api/routes-f/uuid?${search}`); + + it('default request returns 1 v4 UUID', async () => { + const res = await GET(makeRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('uuids'); + expect(Array.isArray(body.uuids)).toBe(true); + expect(body.uuids).toHaveLength(1); + expect(body.uuids[0]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + + it('version=v4&count=3 returns 3 v4 UUIDs', async () => { + const res = await GET(makeRequest('version=v4&count=3')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.uuids).toHaveLength(3); + body.uuids.forEach((uuid: string) => { + expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + }); + + it('version=v7&count=2 returns 2 v7 UUIDs', async () => { + const res = await GET(makeRequest('version=v7&count=2')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.uuids).toHaveLength(2); + body.uuids.forEach((uuid: string) => { + expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + }); + + it('invalid version returns 400', async () => { + const res = await GET(makeRequest('version=v9')); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body).toHaveProperty('error'); + }); + + it('count=101 returns 400', async () => { + const res = await GET(makeRequest('count=101')); + expect(res.status).toBe(400); + }); + + it('count=0 returns 400', async () => { + const res = await GET(makeRequest('count=0')); + expect(res.status).toBe(400); + }); + + it('count=NaN returns 400', async () => { + const res = await GET(makeRequest('count=NaN')); + expect(res.status).toBe(400); + }); + + it('negative count returns 400', async () => { + const res = await GET(makeRequest('count=-5')); + expect(res.status).toBe(400); + }); + + it('count as non-integer returns 400', async () => { + const res = await GET(makeRequest('count=2.5')); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/uuid/_lib/uuid.ts b/app/api/routes-f/uuid/_lib/uuid.ts new file mode 100644 index 00000000..1353c05d --- /dev/null +++ b/app/api/routes-f/uuid/_lib/uuid.ts @@ -0,0 +1,46 @@ +type UUIDVersion = 'v4' | 'v7'; + +function generateRandomBytes(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)); +} + +function setVersionBits(bytes: Uint8Array, version: number): void { + bytes[6] = (bytes[6] & 0x0f) | (version << 4); +} + +function setVariantBits(bytes: Uint8Array): void { + bytes[8] = (bytes[8] & 0x3f) | 0x80; +} + +function bytesToUUID(bytes: Uint8Array): string { + const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export function generateUUID(version: UUIDVersion = 'v4'): string { + const bytes = generateRandomBytes(16); + + if (version === 'v4') { + setVersionBits(bytes, 4); + } else if (version === 'v7') { + const timestamp = Date.now(); + bytes[0] = (timestamp >> 40) & 0xff; + bytes[1] = (timestamp >> 32) & 0xff; + bytes[2] = (timestamp >> 24) & 0xff; + bytes[3] = (timestamp >> 16) & 0xff; + bytes[4] = (timestamp >> 8) & 0xff; + bytes[5] = timestamp & 0xff; + setVersionBits(bytes, 7); + } + + setVariantBits(bytes); + return bytesToUUID(bytes); +} + +export function generateUUIDs(version: UUIDVersion, count: number): string[] { + const uuids: string[] = []; + for (let i = 0; i < count; i++) { + uuids.push(generateUUID(version)); + } + return uuids; +} diff --git a/app/api/routes-f/uuid/route.ts b/app/api/routes-f/uuid/route.ts new file mode 100644 index 00000000..05dd61ce --- /dev/null +++ b/app/api/routes-f/uuid/route.ts @@ -0,0 +1,36 @@ +import { generateUUIDs } from './_lib/uuid'; + +export const runtime = 'edge'; + +export async function GET(request: Request) { + const url = new URL(request.url); + const versionParam = url.searchParams.get('version') || 'v4'; + const countParam = url.searchParams.get('count') || '1'; + + const validVersions = ['v4', 'v7']; + if (!validVersions.includes(versionParam)) { + return new Response(JSON.stringify({ error: 'Invalid version. Must be v4 or v7.' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const count = Number(countParam); + if (!Number.isInteger(count) || count < 1 || count > 100) { + return new Response( + JSON.stringify({ error: 'Count must be an integer between 1 and 100.' }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + const version = versionParam as 'v4' | 'v7'; + const uuids = generateUUIDs(version, count); + + return new Response(JSON.stringify({ uuids }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +}