diff --git a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json new file mode 100644 index 0000000..50c3744 --- /dev/null +++ b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json @@ -0,0 +1,26 @@ +{ + "branch": "fix/vite-open-api-server-thy-integrate-loaders", + "bump": "minor", + "environments": [ + "production" + ], + "packages": [ + "@websublime/vite-plugin-open-api-server" + ], + "changes": [ + "91148f2bde45577e312148cf101b45bcc311b9bb", + "30771708ee15438b1e4836c530c6ee6cc90c3572", + "ff531c3d38181424e432c5ba160a9ee70ef7cab4", + "2faaa947da39ca6d471ae082fd2778e538db8252", + "d8646e6007b8045a9acd794d9582f2b9403515ed", + "af38aa4726904360d4fe8a3c57ed457143a08007", + "65ad2332f3bd3d07cdeef174ddba746ee3b0c318", + "8cfaad41dcabc9307b25384e8b88e1aff0dbdc50", + "84832906c83354f68c36d98e344e788977136b2f", + "0212c02a5d4af05e057428cceb69b33710e9f7d1", + "befa1a8ed8542ce053e266b4046192db8ccb60e1", + "645ef3a4c6d7c1abf458603aebf1d97e07872cef" + ], + "created_at": "2026-01-16T12:17:16.928160Z", + "updated_at": "2026-01-16T14:34:50.901255Z" +} \ No newline at end of file diff --git a/packages/vite-plugin-open-api-server/package.json b/packages/vite-plugin-open-api-server/package.json index 5aa25c9..4bd06ba 100644 --- a/packages/vite-plugin-open-api-server/package.json +++ b/packages/vite-plugin-open-api-server/package.json @@ -85,4 +85,4 @@ }, "./package.json": "./package.json" } -} \ No newline at end of file +} diff --git a/packages/vite-plugin-open-api-server/src/enhancer/__tests__/document-enhancer.test.ts b/packages/vite-plugin-open-api-server/src/enhancer/__tests__/document-enhancer.test.ts index 3432684..52ce7fb 100644 --- a/packages/vite-plugin-open-api-server/src/enhancer/__tests__/document-enhancer.test.ts +++ b/packages/vite-plugin-open-api-server/src/enhancer/__tests__/document-enhancer.test.ts @@ -9,8 +9,8 @@ import type { OpenAPIV3_1 } from 'openapi-types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { HandlerCodeGenerator } from '../../types/handlers.js'; -import type { SeedCodeGenerator } from '../../types/seeds.js'; +import type { HandlerCodeContext, HandlerValue } from '../../types/handlers.js'; +import type { SeedCodeContext, SeedValue } from '../../types/seeds.js'; import { cloneDocument, enhanceDocument, @@ -36,9 +36,9 @@ function createMockLogger() { } /** - * Create a minimal valid OpenAPI 3.1 document for testing. + * Create a test OpenAPI spec. */ -function createTestSpec(overrides?: Partial): OpenAPIV3_1.Document { +function createTestSpec(): OpenAPIV3_1.Document { return { openapi: '3.1.0', info: { @@ -119,34 +119,16 @@ function createTestSpec(overrides?: Partial): OpenAPIV3_1. }, }, }, - ...overrides, }; } -/** - * Create a mock handler function. - */ -function createMockHandler(): HandlerCodeGenerator { - return vi.fn().mockResolvedValue({ status: 200, body: {} }); -} - -/** - * Create a mock seed function. - */ -function createMockSeed(): SeedCodeGenerator { - return vi.fn().mockResolvedValue([]); -} - describe('Document Enhancer', () => { describe('cloneDocument', () => { it('should create a deep copy of the document', () => { const original = createTestSpec(); const cloned = cloneDocument(original); - // Should be equal in structure expect(cloned).toEqual(original); - - // Should not be the same reference expect(cloned).not.toBe(original); }); @@ -155,29 +137,26 @@ describe('Document Enhancer', () => { const cloned = cloneDocument(original); // Modify the clone - cloned.info.title = 'Modified Title'; if (cloned.paths?.['/pets']?.get) { - cloned.paths['/pets'].get.summary = 'Modified summary'; + cloned.paths['/pets'].get.operationId = 'modified'; } // Original should be unchanged - expect(original.info.title).toBe('Test API'); - expect(original.paths?.['/pets']?.get?.summary).toBe('List all pets'); + expect(original.paths?.['/pets']?.get?.operationId).toBe('listPets'); }); it('should handle nested objects', () => { const original = createTestSpec(); const cloned = cloneDocument(original); - // Components should also be cloned - expect(cloned.components).not.toBe(original.components); - expect(cloned.components?.schemas).not.toBe(original.components?.schemas); + expect(cloned.components?.schemas?.Pet).toEqual(original.components?.schemas?.Pet); + expect(cloned.components?.schemas?.Pet).not.toBe(original.components?.schemas?.Pet); }); it('should handle empty spec', () => { const original: OpenAPIV3_1.Document = { openapi: '3.1.0', - info: { title: 'Empty', version: '1.0.0' }, + info: { title: 'Test', version: '1.0.0' }, }; const cloned = cloneDocument(original); @@ -215,7 +194,7 @@ describe('Document Enhancer', () => { it('should return null for non-existent operationId', () => { const spec = createTestSpec(); - const result = findOperationById(spec, 'nonExistentOperation'); + const result = findOperationById(spec, 'nonExistent'); expect(result).toBeNull(); }); @@ -223,16 +202,16 @@ describe('Document Enhancer', () => { it('should return null when paths is undefined', () => { const spec: OpenAPIV3_1.Document = { openapi: '3.1.0', - info: { title: 'No Paths', version: '1.0.0' }, + info: { title: 'Test', version: '1.0.0' }, }; - const result = findOperationById(spec, 'anyOperation'); + const result = findOperationById(spec, 'getPetById'); expect(result).toBeNull(); }); it('should return null when paths is empty', () => { - const spec = createTestSpec({ paths: {} }); - const result = findOperationById(spec, 'anyOperation'); + const spec = { ...createTestSpec(), paths: {} }; + const result = findOperationById(spec, 'getPetById'); expect(result).toBeNull(); }); @@ -253,14 +232,14 @@ describe('Document Enhancer', () => { }, }; - const result = findOperationById(spec, 'anyId'); + const result = findOperationById(spec, 'getPetById'); expect(result).toBeNull(); }); }); describe('hasExtension', () => { it('should return true when extension exists', () => { - const obj = { 'x-handler': () => {} }; + const obj = { 'x-handler': 'some code' }; expect(hasExtension(obj, 'x-handler')).toBe(true); }); @@ -283,30 +262,28 @@ describe('Document Enhancer', () => { describe('setExtension / getExtension', () => { it('should set and get extension value', () => { const obj: Record = {}; - const value = { test: true }; + const value = 'return store.get("Pet", req.params.petId);'; setExtension(obj, 'x-custom', value); expect(getExtension(obj, 'x-custom')).toBe(value); }); it('should overwrite existing extension', () => { - const obj = { 'x-custom': 'old' }; - - setExtension(obj, 'x-custom', 'new'); - expect(getExtension(obj, 'x-custom')).toBe('new'); + const obj = { 'x-custom': 'old value' }; + setExtension(obj, 'x-custom', 'new value'); + expect(getExtension(obj, 'x-custom')).toBe('new value'); }); it('should return undefined for non-existent extension', () => { const obj = {}; - expect(getExtension(obj, 'x-nonexistent')).toBeUndefined(); + expect(getExtension(obj, 'x-missing')).toBeUndefined(); }); - it('should handle function values', () => { + it('should handle string values', () => { const obj: Record = {}; - const fn = () => 'test'; - - setExtension(obj, 'x-handler', fn); - expect(getExtension<() => string>(obj, 'x-handler')).toBe(fn); + const code = 'return store.list("Pet");'; + setExtension(obj, 'x-handler', code); + expect(getExtension(obj, 'x-handler')).toBe(code); }); }); @@ -317,203 +294,308 @@ describe('Document Enhancer', () => { mockLogger = createMockLogger(); }); - describe('handler injection', () => { - it('should inject x-handler into matching operations', () => { + describe('static handler injection', () => { + it('should inject static x-handler code into matching operations', async () => { const spec = createTestSpec(); - const handler = createMockHandler(); - const handlers = new Map([['getPetById', handler]]); - const seeds = new Map(); + const handlerCode = 'return store.get("Pet", req.params.petId);'; + const handlers = new Map([['getPetById', handlerCode]]); + const seeds = new Map(); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); - // Find the operation in the enhanced document const operationInfo = findOperationById(result.document, 'getPetById'); expect(operationInfo).not.toBeNull(); - expect(getExtension(operationInfo?.operation as object, 'x-handler')).toBe(handler); + if (!operationInfo) throw new Error('Operation getPetById not found'); + expect(getExtension(operationInfo.operation, 'x-handler')).toBe(handlerCode); }); - it('should inject multiple handlers', () => { + it('should inject multiple static handlers', async () => { const spec = createTestSpec(); - const handler1 = createMockHandler(); - const handler2 = createMockHandler(); - const handlers = new Map([ - ['listPets', handler1], - ['createPet', handler2], + const handlers = new Map([ + ['listPets', 'return store.list("Pet");'], + ['createPet', 'return store.create("Pet", req.body);'], ]); - const seeds = new Map(); + const seeds = new Map(); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); expect(result.handlerCount).toBe(2); const listPetsOp = findOperationById(result.document, 'listPets'); - expect(getExtension(listPetsOp?.operation as object, 'x-handler')).toBe(handler1); + expect(listPetsOp).not.toBeNull(); + if (!listPetsOp) throw new Error('Operation listPets not found'); + expect(getExtension(listPetsOp.operation, 'x-handler')).toBe('return store.list("Pet");'); const createPetOp = findOperationById(result.document, 'createPet'); - expect(getExtension(createPetOp?.operation as object, 'x-handler')).toBe(handler2); + expect(createPetOp).not.toBeNull(); + if (!createPetOp) throw new Error('Operation createPet not found'); + expect(getExtension(createPetOp.operation, 'x-handler')).toBe( + 'return store.create("Pet", req.body);', + ); }); + }); - it('should skip handlers for non-existent operations', () => { + describe('dynamic handler injection', () => { + it('should resolve and inject dynamic handler code', async () => { const spec = createTestSpec(); - const handler = createMockHandler(); - const handlers = new Map([['nonExistentOperation', handler]]); - const seeds = new Map(); + const dynamicHandler: HandlerValue = (ctx: HandlerCodeContext) => { + const has404 = ctx.operation?.responses?.['404']; + return ` + const pet = store.get("Pet", req.params.petId); + ${has404 ? 'if (!pet) return res["404"];' : ''} + return pet; + `; + }; + const handlers = new Map([['getPetById', dynamicHandler]]); + const seeds = new Map(); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); - expect(result.handlerCount).toBe(0); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Skipped handler "nonExistentOperation"'), - expect.any(Object), - ); + const operationInfo = findOperationById(result.document, 'getPetById'); + expect(operationInfo).not.toBeNull(); + if (!operationInfo) throw new Error('Operation getPetById not found'); + const injectedCode = getExtension(operationInfo.operation, 'x-handler'); + + expect(injectedCode).toContain('store.get("Pet"'); + expect(injectedCode).toContain('res["404"]'); // Should include 404 handling }); - it('should log info for each injected handler', () => { + it('should pass correct context to dynamic handlers', async () => { const spec = createTestSpec(); - const handlers = new Map([['getPetById', createMockHandler()]]); - const seeds = new Map(); + const contextSpy = vi.fn().mockReturnValue('return "test";'); - enhanceDocument(spec, handlers, seeds, mockLogger); + const handlers = new Map([['getPetById', contextSpy]]); + const seeds = new Map(); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Injected x-handler into GET /pets/{petId} (getPetById)'), - expect.any(Object), + await enhanceDocument(spec, handlers, seeds, mockLogger); + + expect(contextSpy).toHaveBeenCalledTimes(1); + if (contextSpy.mock.calls.length === 0) throw new Error('Context spy was not called'); + const context = contextSpy.mock.calls[0][0] as HandlerCodeContext; + + expect(context.operationId).toBe('getPetById'); + expect(context.path).toBe('/pets/{petId}'); + expect(context.method).toBe('get'); + expect(context.operation).toBeDefined(); + expect(context.operation.operationId).toBe('getPetById'); + expect(context.document).toBeDefined(); + }); + + it('should handle async dynamic handlers', async () => { + const spec = createTestSpec(); + const asyncHandler: HandlerValue = async (_ctx: HandlerCodeContext) => { + // Simulate async operation + await Promise.resolve(); + return 'return store.list("Pet");'; + }; + + const handlers = new Map([['listPets', asyncHandler]]); + const seeds = new Map(); + + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + + const operationInfo = findOperationById(result.document, 'listPets'); + expect(operationInfo).not.toBeNull(); + if (!operationInfo) throw new Error('Operation listPets not found'); + expect(getExtension(operationInfo.operation, 'x-handler')).toBe( + 'return store.list("Pet");', ); }); }); - describe('seed injection', () => { - it('should inject x-seed into matching schemas', () => { + describe('static seed injection', () => { + it('should inject static x-seed code into matching schemas', async () => { const spec = createTestSpec(); - const seed = createMockSeed(); - const handlers = new Map(); - const seeds = new Map([['Pet', seed]]); + const seedCode = `seed.count(15, () => ({ id: faker.number.int(), name: faker.animal.dog() }))`; + const handlers = new Map(); + const seeds = new Map([['Pet', seedCode]]); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); const petSchema = result.document.components?.schemas?.Pet; expect(petSchema).toBeDefined(); - expect(getExtension(petSchema as object, 'x-seed')).toBe(seed); + expect(getExtension(petSchema as object, 'x-seed')).toBe(seedCode); }); - it('should inject multiple seeds', () => { + it('should inject multiple static seeds', async () => { const spec = createTestSpec(); - const seed1 = createMockSeed(); - const seed2 = createMockSeed(); - const handlers = new Map(); - const seeds = new Map([ - ['Pet', seed1], - ['Order', seed2], + const handlers = new Map(); + const seeds = new Map([ + ['Pet', 'seed.count(15, () => ({ name: faker.animal.dog() }))'], + ['Order', 'seed.count(20, () => ({ id: faker.number.int() }))'], ]); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); expect(result.seedCount).toBe(2); const petSchema = result.document.components?.schemas?.Pet; - expect(getExtension(petSchema as object, 'x-seed')).toBe(seed1); + expect(getExtension(petSchema as object, 'x-seed')).toContain('faker.animal.dog'); const orderSchema = result.document.components?.schemas?.Order; - expect(getExtension(orderSchema as object, 'x-seed')).toBe(seed2); + expect(getExtension(orderSchema as object, 'x-seed')).toContain('faker.number.int'); }); + }); - it('should skip seeds for non-existent schemas', () => { + describe('dynamic seed injection', () => { + it('should resolve and inject dynamic seed code', async () => { const spec = createTestSpec(); - const seed = createMockSeed(); - const handlers = new Map(); - const seeds = new Map([['NonExistentSchema', seed]]); + const dynamicSeed: SeedValue = (ctx: SeedCodeContext) => { + const hasStatus = ctx.schema?.properties?.status; + return ` + seed.count(15, () => ({ + id: faker.number.int(), + name: faker.animal.dog(), + ${hasStatus ? "status: faker.helpers.arrayElement(['available', 'pending', 'sold'])," : ''} + })) + `; + }; - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const handlers = new Map(); + const seeds = new Map([['Pet', dynamicSeed]]); - expect(result.seedCount).toBe(0); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + + const petSchema = result.document.components?.schemas?.Pet; + const injectedCode = getExtension(petSchema as object, 'x-seed'); + + expect(injectedCode).toContain('seed.count'); + expect(injectedCode).toContain('faker.helpers.arrayElement'); // Should include status + }); + + it('should pass correct context to dynamic seeds', async () => { + const spec = createTestSpec(); + const contextSpy = vi.fn().mockReturnValue('seed([])'); + + const handlers = new Map(); + const seeds = new Map([['Pet', contextSpy]]); + + await enhanceDocument(spec, handlers, seeds, mockLogger); + + expect(contextSpy).toHaveBeenCalledTimes(1); + const context = contextSpy.mock.calls[0][0] as SeedCodeContext; + + expect(context.schemaName).toBe('Pet'); + expect(context.schema).toBeDefined(); + expect(context.schema.type).toBe('object'); + expect(context.document).toBeDefined(); + expect(context.schemas).toBeDefined(); + expect(context.schemas.Pet).toBeDefined(); + expect(context.schemas.Order).toBeDefined(); + }); + + it('should handle async dynamic seeds', async () => { + const spec = createTestSpec(); + const asyncSeed: SeedValue = async (_ctx: SeedCodeContext) => { + await Promise.resolve(); + return 'seed.count(10, () => ({ id: faker.number.int() }))'; + }; + + const handlers = new Map(); + const seeds = new Map([['Pet', asyncSeed]]); + + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + + const petSchema = result.document.components?.schemas?.Pet; + expect(getExtension(petSchema as object, 'x-seed')).toContain('seed.count(10'); + }); + }); + + describe('skip and warning behavior', () => { + it('should skip handlers for non-existent operations', async () => { + const spec = createTestSpec(); + const handlers = new Map([['nonExistentOperation', 'return null;']]); + const seeds = new Map(); + + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + + expect(result.handlerCount).toBe(0); expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Skipped seed "NonExistentSchema"'), + expect.stringContaining('Skipped handler "nonExistentOperation"'), expect.any(Object), ); }); - it('should log info for each injected seed', () => { + it('should skip seeds for non-existent schemas', async () => { const spec = createTestSpec(); - const handlers = new Map(); - const seeds = new Map([['Pet', createMockSeed()]]); + const handlers = new Map(); + const seeds = new Map([['NonExistentSchema', 'seed([])']]); - enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + expect(result.seedCount).toBe(0); expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Injected x-seed into schema Pet'), + expect.stringContaining('Skipped seed "NonExistentSchema"'), expect.any(Object), ); }); - it('should handle spec without components.schemas', () => { + it('should handle spec without components.schemas', async () => { const spec: OpenAPIV3_1.Document = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: {}, }; - const handlers = new Map(); - const seeds = new Map([['Pet', createMockSeed()]]); + const handlers = new Map(); + const seeds = new Map([['Pet', 'seed([])']]); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); expect(result.seedCount).toBe(0); }); }); describe('preservation checks (override behavior)', () => { - it('should warn when overriding existing x-handler', () => { + it('should warn when overriding existing x-handler', async () => { const spec = createTestSpec(); - // Pre-add x-handler to the operation const operation = spec.paths?.['/pets/{petId}']?.get; if (operation) { - (operation as Record)['x-handler'] = 'existingHandler'; + (operation as Record)['x-handler'] = 'existing handler'; } - const handler = createMockHandler(); - const handlers = new Map([['getPetById', handler]]); - const seeds = new Map(); + const handlers = new Map([ + ['getPetById', 'return store.get("Pet");'], + ]); + const seeds = new Map(); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + expect(result.overrideCount).toBe(1); expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Overriding existing x-handler for getPetById'), + expect.stringContaining('Overriding existing x-handler'), expect.any(Object), ); - expect(result.overrideCount).toBe(1); - // Should still inject the new handler const operationInfo = findOperationById(result.document, 'getPetById'); - expect(getExtension(operationInfo?.operation as object, 'x-handler')).toBe(handler); + expect(operationInfo).not.toBeNull(); + if (!operationInfo) throw new Error('Operation getPetById not found'); + expect(getExtension(operationInfo.operation, 'x-handler')).toBe('return store.get("Pet");'); }); - it('should warn when overriding existing x-seed', () => { + it('should warn when overriding existing x-seed', async () => { const spec = createTestSpec(); - // Pre-add x-seed to the schema const petSchema = spec.components?.schemas?.Pet; if (petSchema) { - (petSchema as Record)['x-seed'] = 'existingSeed'; + (petSchema as Record)['x-seed'] = 'existing seed'; } - const seed = createMockSeed(); - const handlers = new Map(); - const seeds = new Map([['Pet', seed]]); + const handlers = new Map(); + const seeds = new Map([['Pet', 'seed.count(10, () => ({}))']]); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + expect(result.overrideCount).toBe(1); expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Overriding existing x-seed for Pet'), + expect.stringContaining('Overriding existing x-seed'), expect.any(Object), ); - expect(result.overrideCount).toBe(1); - // Should still inject the new seed const schema = result.document.components?.schemas?.Pet; - expect(getExtension(schema as object, 'x-seed')).toBe(seed); + expect(getExtension(schema as object, 'x-seed')).toBe('seed.count(10, () => ({}))'); }); - it('should count multiple overrides', () => { + it('should count multiple overrides', async () => { const spec = createTestSpec(); - // Pre-add extensions const operation = spec.paths?.['/pets/{petId}']?.get; if (operation) { (operation as Record)['x-handler'] = 'existing'; @@ -523,44 +605,44 @@ describe('Document Enhancer', () => { (schema as Record)['x-seed'] = 'existing'; } - const handlers = new Map([['getPetById', createMockHandler()]]); - const seeds = new Map([['Pet', createMockSeed()]]); + const handlers = new Map([['getPetById', 'return null;']]); + const seeds = new Map([['Pet', 'seed([])']]); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); expect(result.overrideCount).toBe(2); }); }); describe('empty maps', () => { - it('should handle empty handlers map', () => { + it('should handle empty handlers map', async () => { const spec = createTestSpec(); - const handlers = new Map(); - const seeds = new Map([['Pet', createMockSeed()]]); + const handlers = new Map(); + const seeds = new Map([['Pet', 'seed([])']]); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); expect(result.handlerCount).toBe(0); expect(result.seedCount).toBe(1); }); - it('should handle empty seeds map', () => { + it('should handle empty seeds map', async () => { const spec = createTestSpec(); - const handlers = new Map([['getPetById', createMockHandler()]]); - const seeds = new Map(); + const handlers = new Map([['getPetById', 'return null;']]); + const seeds = new Map(); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); expect(result.handlerCount).toBe(1); expect(result.seedCount).toBe(0); }); - it('should handle both maps empty', () => { + it('should handle both maps empty', async () => { const spec = createTestSpec(); - const handlers = new Map(); - const seeds = new Map(); + const handlers = new Map(); + const seeds = new Map(); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); expect(result.handlerCount).toBe(0); expect(result.seedCount).toBe(0); @@ -569,89 +651,87 @@ describe('Document Enhancer', () => { }); describe('original spec preservation', () => { - it('should not modify the original spec', () => { + it('should not modify the original spec', async () => { const spec = createTestSpec(); const originalSpecString = JSON.stringify(spec); - const handlers = new Map([['getPetById', createMockHandler()]]); - const seeds = new Map([['Pet', createMockSeed()]]); + const handlers = new Map([['getPetById', 'return null;']]); + const seeds = new Map([['Pet', 'seed([])']]); - enhanceDocument(spec, handlers, seeds, mockLogger); + await enhanceDocument(spec, handlers, seeds, mockLogger); - // Original spec should be unchanged expect(JSON.stringify(spec)).toBe(originalSpecString); }); - it('should return a new document object', () => { + it('should return a new document object', async () => { const spec = createTestSpec(); - const handlers = new Map(); - const seeds = new Map(); + const handlers = new Map(); + const seeds = new Map(); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); expect(result.document).not.toBe(spec); }); }); describe('summary logging', () => { - it('should log summary with handler and seed counts', () => { + it('should log summary with handler and seed counts', async () => { const spec = createTestSpec(); - const handlers = new Map([ - ['getPetById', createMockHandler()], - ['listPets', createMockHandler()], + const handlers = new Map([ + ['listPets', 'return store.list("Pet");'], + ['getPetById', 'return store.get("Pet");'], ]); - const seeds = new Map([['Pet', createMockSeed()]]); + const seeds = new Map([['Pet', 'seed([])']]); - enhanceDocument(spec, handlers, seeds, mockLogger); + await enhanceDocument(spec, handlers, seeds, mockLogger); expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('Enhanced document: 2 handler(s), 1 seed(s)'), + expect.stringContaining('Enhanced document:'), expect.any(Object), ); }); - it('should include override count in summary when present', () => { + it('should include override count in summary when present', async () => { const spec = createTestSpec(); - // Pre-add extension to trigger override const operation = spec.paths?.['/pets/{petId}']?.get; if (operation) { (operation as Record)['x-handler'] = 'existing'; } - const handlers = new Map([['getPetById', createMockHandler()]]); - const seeds = new Map(); + const handlers = new Map([['getPetById', 'return null;']]); + const seeds = new Map(); - enhanceDocument(spec, handlers, seeds, mockLogger); + await enhanceDocument(spec, handlers, seeds, mockLogger); - expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringContaining('1 override(s)'), - expect.any(Object), + const summaryCalls = mockLogger.info.mock.calls.filter((call) => + call[0].includes('Enhanced document:'), ); + expect(summaryCalls.length).toBeGreaterThan(0); + expect(summaryCalls[0][0]).toContain('override'); }); - it('should not include override count when zero', () => { + it('should not include override count when zero', async () => { const spec = createTestSpec(); - const handlers = new Map([['getPetById', createMockHandler()]]); - const seeds = new Map(); + const handlers = new Map([['getPetById', 'return null;']]); + const seeds = new Map(); - enhanceDocument(spec, handlers, seeds, mockLogger); + await enhanceDocument(spec, handlers, seeds, mockLogger); - // The summary should not mention overrides const summaryCalls = mockLogger.info.mock.calls.filter((call) => call[0].includes('Enhanced document:'), ); - expect(summaryCalls.length).toBe(1); + expect(summaryCalls.length).toBeGreaterThan(0); expect(summaryCalls[0][0]).not.toContain('override'); }); }); describe('result object', () => { - it('should return correct result structure', () => { + it('should return correct result structure', async () => { const spec = createTestSpec(); - const handlers = new Map([['getPetById', createMockHandler()]]); - const seeds = new Map([['Pet', createMockSeed()]]); + const handlers = new Map([['getPetById', 'return null;']]); + const seeds = new Map([['Pet', 'seed([])']]); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); expect(result).toHaveProperty('document'); expect(result).toHaveProperty('handlerCount'); @@ -659,16 +739,120 @@ describe('Document Enhancer', () => { expect(result).toHaveProperty('overrideCount'); }); - it('should return valid OpenAPI document', () => { + it('should return valid OpenAPI document', async () => { const spec = createTestSpec(); - const handlers = new Map(); - const seeds = new Map(); + const handlers = new Map(); + const seeds = new Map(); - const result = enhanceDocument(spec, handlers, seeds, mockLogger); + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); expect(result.document.openapi).toBe('3.1.0'); expect(result.document.info).toBeDefined(); - expect(result.document.info.title).toBe('Test API'); + expect(result.document.paths).toBeDefined(); + }); + }); + + describe('error handling', () => { + it('should log error when handler resolution fails', async () => { + const spec = createTestSpec(); + const failingHandler: HandlerValue = () => { + throw new Error('Handler generation failed'); + }; + + const handlers = new Map([['getPetById', failingHandler]]); + const seeds = new Map(); + + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + + expect(result.handlerCount).toBe(0); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to resolve handler'), + expect.any(Object), + ); + }); + + it('should log error when seed resolution fails', async () => { + const spec = createTestSpec(); + const failingSeed: SeedValue = () => { + throw new Error('Seed generation failed'); + }; + + const handlers = new Map(); + const seeds = new Map([['Pet', failingSeed]]); + + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + + expect(result.seedCount).toBe(0); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to resolve seed'), + expect.any(Object), + ); + }); + + it('should continue processing after resolution error', async () => { + const spec = createTestSpec(); + const failingHandler: HandlerValue = () => { + throw new Error('Failed'); + }; + + const handlers = new Map([ + ['listPets', failingHandler], + ['getPetById', 'return store.get("Pet");'], + ]); + const seeds = new Map(); + + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + + // One failed, one succeeded + expect(result.handlerCount).toBe(1); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); + + describe('mixed static and dynamic values', () => { + it('should handle mixed static and dynamic handlers', async () => { + const spec = createTestSpec(); + const handlers = new Map([ + ['listPets', 'return store.list("Pet");'], // static + ['getPetById', (ctx) => `return store.get("Pet", req.params.petId); // ${ctx.method}`], // dynamic + ]); + const seeds = new Map(); + + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + + expect(result.handlerCount).toBe(2); + + const listPetsOp = findOperationById(result.document, 'listPets'); + expect(listPetsOp).not.toBeNull(); + if (!listPetsOp) throw new Error('Operation listPets not found'); + expect(getExtension(listPetsOp.operation, 'x-handler')).toBe('return store.list("Pet");'); + + const getPetByIdOp = findOperationById(result.document, 'getPetById'); + expect(getPetByIdOp).not.toBeNull(); + if (!getPetByIdOp) throw new Error('Operation getPetById not found'); + const dynamicCode = getExtension(getPetByIdOp.operation, 'x-handler'); + expect(dynamicCode).toContain('store.get("Pet"'); + expect(dynamicCode).toContain('// get'); + }); + + it('should handle mixed static and dynamic seeds', async () => { + const spec = createTestSpec(); + const handlers = new Map(); + const seeds = new Map([ + ['Pet', 'seed.count(15, () => ({ name: faker.animal.dog() }))'], // static + ['Order', (ctx) => `seed.count(20, () => ({ schema: "${ctx.schemaName}" }))`], // dynamic + ]); + + const result = await enhanceDocument(spec, handlers, seeds, mockLogger); + + expect(result.seedCount).toBe(2); + + const petSchema = result.document.components?.schemas?.Pet; + expect(getExtension(petSchema as object, 'x-seed')).toContain('faker.animal.dog'); + + const orderSchema = result.document.components?.schemas?.Order; + const dynamicCode = getExtension(orderSchema as object, 'x-seed'); + expect(dynamicCode).toContain('schema: "Order"'); }); }); }); diff --git a/packages/vite-plugin-open-api-server/src/enhancer/document-enhancer.ts b/packages/vite-plugin-open-api-server/src/enhancer/document-enhancer.ts index 838b031..4e75e3b 100644 --- a/packages/vite-plugin-open-api-server/src/enhancer/document-enhancer.ts +++ b/packages/vite-plugin-open-api-server/src/enhancer/document-enhancer.ts @@ -3,20 +3,26 @@ * * ## What * This module provides functionality to enhance OpenAPI documents with custom - * extensions for handlers and seeds. It clones the parsed spec, injects - * `x-handler` extensions into operations that have custom handlers, and injects - * `x-seed` extensions into schemas that have seed data. + * extensions for handlers and seeds. It clones the parsed spec, resolves handler + * and seed values (calling generator functions if needed), and injects the + * resulting code strings as `x-handler` and `x-seed` extensions. * * ## How * The enhancer deep clones the OpenAPI spec to preserve the original (needed for - * hot reload), then iterates through handler and seed maps to inject extensions - * into matching operations and schemas. Each injection is logged for visibility. + * hot reload), then iterates through handler and seed maps. For each entry: + * - If the value is a string, it's used directly as the code + * - If the value is a function, it's called with the appropriate context to + * generate the code string + * The resolved code strings are then injected into the matching operations/schemas. * * ## Why * Enhancement happens after loading handlers/seeds and before starting the mock - * server. The enhanced document is passed to Scalar mock server, which uses the - * extensions to call custom handlers and pre-populate data. By cloning first, - * we ensure the original spec remains unmodified for subsequent enhancements. + * server. The Scalar Mock Server expects `x-handler` and `x-seed` extensions to + * contain JavaScript code strings (not functions). By resolving functions to + * strings here, we ensure the enhanced document is ready for Scalar consumption. + * + * @see https://scalar.com/products/mock-server/custom-request-handler + * @see https://scalar.com/products/mock-server/data-seeding * * @module */ @@ -24,8 +30,8 @@ import type { OpenAPIV3_1 } from 'openapi-types'; import type { Logger } from 'vite'; -import type { HandlerCodeGenerator } from '../types/handlers.js'; -import type { SeedCodeGenerator } from '../types/seeds.js'; +import type { HandlerCodeContext, HandlerValue } from '../types/handlers.js'; +import type { SeedCodeContext, SeedValue } from '../types/seeds.js'; /** * HTTP methods supported by OpenAPI operations. @@ -88,43 +94,53 @@ interface InjectionResult { /** * Enhance OpenAPI document with x-handler and x-seed extensions. * - * This function clones the original spec, then injects `x-handler` into - * operations matching handler operationIds and `x-seed` into schemas - * matching seed schema names. + * This function clones the original spec, resolves handler and seed values + * (calling generator functions if needed), then injects the resulting code + * strings into operations and schemas. * * @param spec - Parsed OpenAPI specification - * @param handlers - Map of operationId to handler function - * @param seeds - Map of schema name to seed function + * @param handlers - Map of operationId to handler value (string or generator function) + * @param seeds - Map of schema name to seed value (string or generator function) * @param logger - Vite logger - * @returns Enhanced OpenAPI document result + * @returns Promise resolving to enhanced OpenAPI document result * * @example * ```typescript * const handlers = new Map([ - * ['getPetById', async (ctx) => ({ status: 200, body: { id: 1, name: 'Fluffy' } })], + * ['getPetById', 'return store.get("Pet", req.params.petId);'], + * ['addPet', ({ operation }) => { + * const has400 = operation?.responses?.['400']; + * return ` + * if (!req.body.name) return res['${has400 ? '400' : '422'}']; + * return store.create('Pet', req.body); + * `; + * }], * ]); * * const seeds = new Map([ - * ['Pet', async (ctx) => [{ id: 1, name: 'Fluffy' }]], + * ['Pet', `seed.count(15, () => ({ id: faker.number.int(), name: faker.animal.dog() }))`], * ]); * - * const result = enhanceDocument(spec, handlers, seeds, logger); - * // result.document has x-handler in GET /pets/{petId} operation - * // result.document has x-seed in Pet schema + * const result = await enhanceDocument(spec, handlers, seeds, logger); + * // result.document has x-handler code strings in operations + * // result.document has x-seed code strings in schemas * ``` */ -export function enhanceDocument( +export async function enhanceDocument( spec: OpenAPIV3_1.Document, - handlers: Map, - seeds: Map, + handlers: Map, + seeds: Map, logger: Logger, -): EnhanceDocumentResult { +): Promise { // Deep clone spec to preserve original const enhanced = cloneDocument(spec); - // Inject handlers and seeds - const handlerResult = injectHandlers(enhanced, handlers, logger); - const seedResult = injectSeeds(enhanced, seeds, logger); + // Pre-compute schemas map once for all resolution calls + const cachedSchemas = extractSchemas(enhanced); + + // Inject handlers and seeds (with resolution) + const handlerResult = await injectHandlers(enhanced, handlers, cachedSchemas, logger); + const seedResult = await injectSeeds(enhanced, seeds, cachedSchemas, logger); const handlerCount = handlerResult.count; const seedCount = seedResult.count; @@ -141,18 +157,122 @@ export function enhanceDocument( }; } +/** + * Extract all schemas from the OpenAPI document. + * + * This function is called once per enhancement to build a cached schemas map + * that is reused by all handler and seed resolution calls. + * + * @param spec - OpenAPI document + * @returns Record of schema name to schema object (excluding $ref schemas) + */ +function extractSchemas(spec: OpenAPIV3_1.Document): Record { + const schemas: Record = {}; + + if (spec.components?.schemas) { + for (const [name, schemaOrRef] of Object.entries(spec.components.schemas)) { + if (!isReferenceObject(schemaOrRef)) { + schemas[name] = schemaOrRef; + } + } + } + + return schemas; +} + +/** + * Resolve a handler value to a code string. + * + * If the value is already a string, returns it directly. + * If the value is a function, calls it with the handler context. + * + * @param operationId - The operationId for this handler + * @param value - Handler value (string or generator function) + * @param spec - OpenAPI document for context + * @param operationInfo - Operation info for context + * @param schemas - Pre-computed schemas map + * @returns Promise resolving to the code string + */ +async function resolveHandlerValue( + operationId: string, + value: HandlerValue, + spec: OpenAPIV3_1.Document, + operationInfo: OperationInfo, + schemas: Record, +): Promise { + if (typeof value === 'string') { + return value; + } + + // Build context for the generator function + const context: HandlerCodeContext = { + operationId, + path: operationInfo.path, + method: operationInfo.method, + operation: operationInfo.operation, + document: spec, + schemas, + }; + + // Call the generator function (may be sync or async) + const result = value(context); + + // Handle both sync and async returns + return Promise.resolve(result); +} + +/** + * Resolve a seed value to a code string. + * + * If the value is already a string, returns it directly. + * If the value is a function, calls it with the seed context. + * + * @param schemaName - The schema name for this seed + * @param value - Seed value (string or generator function) + * @param spec - OpenAPI document for context + * @param schema - Schema object for context + * @param schemas - Pre-computed schemas map + * @returns Promise resolving to the code string + */ +async function resolveSeedValue( + schemaName: string, + value: SeedValue, + spec: OpenAPIV3_1.Document, + schema: OpenAPIV3_1.SchemaObject, + schemas: Record, +): Promise { + if (typeof value === 'string') { + return value; + } + + // Build context for the generator function + const context: SeedCodeContext = { + schemaName, + schema, + document: spec, + schemas, + }; + + // Call the generator function (may be sync or async) + const result = value(context); + + // Handle both sync and async returns + return Promise.resolve(result); +} + /** * Inject x-handler extensions into operations. */ -function injectHandlers( +async function injectHandlers( spec: OpenAPIV3_1.Document, - handlers: Map, + handlers: Map, + schemas: Record, logger: Logger, -): InjectionResult { +): Promise { let count = 0; let overrides = 0; - for (const [operationId, handlerFn] of handlers) { + for (const [operationId, handlerValue] of handlers) { const operationInfo = findOperationById(spec, operationId); if (!operationInfo) { @@ -171,15 +291,33 @@ function injectHandlers( overrides++; } - setExtension(operation, 'x-handler', handlerFn); - count++; - - logger.info( - `[enhancer] Injected x-handler into ${method.toUpperCase()} ${path} (${operationId})`, - { + try { + // Resolve the handler value to a code string + const code = await resolveHandlerValue( + operationId, + handlerValue, + spec, + operationInfo, + schemas, + ); + + // Inject the resolved code string + setExtension(operation, 'x-handler', code); + count++; + + const codePreview = code.length > 50 ? `${code.slice(0, 50)}...` : code; + logger.info( + `[enhancer] Injected x-handler into ${method.toUpperCase()} ${path} (${operationId}): ${codePreview.replace(/\n/g, ' ').trim()}`, + { + timestamp: true, + }, + ); + } catch (error) { + const err = error as Error; + logger.error(`[enhancer] Failed to resolve handler "${operationId}": ${err.message}`, { timestamp: true, - }, - ); + }); + } } return { count, overrides }; @@ -188,11 +326,12 @@ function injectHandlers( /** * Inject x-seed extensions into schemas. */ -function injectSeeds( +async function injectSeeds( spec: OpenAPIV3_1.Document, - seeds: Map, + seeds: Map, + cachedSchemas: Record, logger: Logger, -): InjectionResult { +): Promise { let count = 0; let overrides = 0; @@ -200,7 +339,7 @@ function injectSeeds( return { count, overrides }; } - for (const [schemaName, seedFn] of seeds) { + for (const [schemaName, seedValue] of seeds) { const schema = spec.components.schemas[schemaName]; if (!schema) { @@ -224,10 +363,25 @@ function injectSeeds( overrides++; } - setExtension(schema, 'x-seed', seedFn); - count++; - - logger.info(`[enhancer] Injected x-seed into schema ${schemaName}`, { timestamp: true }); + try { + // Resolve the seed value to a code string + const code = await resolveSeedValue(schemaName, seedValue, spec, schema, cachedSchemas); + + // Inject the resolved code string + setExtension(schema, 'x-seed', code); + count++; + + const codePreview = code.length > 50 ? `${code.slice(0, 50)}...` : code; + logger.info( + `[enhancer] Injected x-seed into schema ${schemaName}: ${codePreview.replace(/\n/g, ' ').trim()}`, + { timestamp: true }, + ); + } catch (error) { + const err = error as Error; + logger.error(`[enhancer] Failed to resolve seed "${schemaName}": ${err.message}`, { + timestamp: true, + }); + } } return { count, overrides }; @@ -258,7 +412,7 @@ function logEnhancementSummary( * * Uses structuredClone for deep copying. Since the original spec should not * contain functions (only parsed JSON/YAML), this is safe. Functions are - * injected after cloning. + * resolved to strings before injection. * * @param spec - OpenAPI document to clone * @returns Deep clone of the document diff --git a/packages/vite-plugin-open-api-server/src/index.ts b/packages/vite-plugin-open-api-server/src/index.ts index 1f3fe52..9ded576 100644 --- a/packages/vite-plugin-open-api-server/src/index.ts +++ b/packages/vite-plugin-open-api-server/src/index.ts @@ -61,17 +61,28 @@ export { export { openApiServerPlugin, openApiServerPlugin as default } from './plugin.js'; export type { - HandlerCodeGenerator, - HandlerContext, + // Handler types (code-based) + HandlerCodeContext, + HandlerCodeGeneratorFn, + HandlerExports, HandlerFileExports, - HandlerResponse, + HandlerLoadResult, + HandlerValue, + // Security types NormalizedSecurityScheme, + // Registry types OpenApiEndpointRegistry, + // Plugin options OpenApiServerPluginOptions, + ResolvedHandlers, + // Seed types (code-based) + ResolvedSeeds, SecurityContext, SecurityRequirement, - SeedCodeGenerator, - SeedContext, - SeedData, + SeedCodeContext, + SeedCodeGeneratorFn, + SeedExports, SeedFileExports, + SeedLoadResult, + SeedValue, } from './types/index.js'; diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/add-new-pet.handler.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/add-new-pet.handler.mjs index 4f352fa..f7d3016 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/add-new-pet.handler.mjs +++ b/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/add-new-pet.handler.mjs @@ -1,21 +1,47 @@ /** - * Valid handler fixture for testing kebab-case to camelCase conversion. - * Filename: add-new-pet.handler.mjs → operationId: addNewPet + * Valid handler fixture for testing. + * Exports an object with both static and dynamic handlers. */ -export default async function handler(context) { - const { body, logger } = context; +export default { + // Static handler - code as string + addPet: ` + const newPet = { + id: faker.string.uuid(), + ...req.body, + createdAt: new Date().toISOString() + }; + return store.create('Pet', newPet); + `, - logger.info(`Creating new pet: ${body?.name}`); + // Dynamic handler - function that generates code based on context + addNewPet: ({ operation }) => { + const has400 = operation?.responses?.['400']; + const has422 = operation?.responses?.['422']; - return { - status: 201, - body: { - id: Date.now(), - name: body?.name || 'Unknown', - status: body?.status || 'available', - }, - headers: { - 'X-Created-At': new Date().toISOString(), - }, - }; -} + let code = ` + const { name, status, category } = req.body; + `; + + if (has400 || has422) { + code += ` + if (!name) { + return res['${has400 ? '400' : '422'}']; + } + `; + } + + code += ` + const newPet = { + id: faker.number.int({ min: 1, max: 10000 }), + name, + status: status || 'available', + category: category || { id: 1, name: 'Unknown' }, + photoUrls: [], + tags: [] + }; + return store.create('Pet', newPet); + `; + + return code; + }, +}; diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/get-pet.handler.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/get-pet.handler.mjs index fe2b0cc..e97ecb5 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/get-pet.handler.mjs +++ b/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/get-pet.handler.mjs @@ -1,10 +1,17 @@ /** * Valid handler fixture for testing. - * Filename: get-pet.handler.mjs → operationId: getPet + * Exports an object mapping operationId → handler code. */ -export default async function handler(_context) { - return { - status: 200, - body: { id: 1, name: 'Fluffy', status: 'available' }, - }; -} +export default { + // Static handler - code as string + getPet: ` + const pet = store.get('Pet', req.params.petId); + if (!pet) { + return res['404']; + } + return pet; + `, + + // Another static handler in the same file + getPetById: `return store.get('Pet', req.params.petId);`, +}; diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/invalid-array-export.handler.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/invalid-array-export.handler.mjs new file mode 100644 index 0000000..37dad0e --- /dev/null +++ b/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/invalid-array-export.handler.mjs @@ -0,0 +1,9 @@ +/** + * Invalid handler fixture for testing. + * Default export is an array, which is not a valid handler exports format. + * Handler files must export a plain object mapping operationId → handler value. + */ +export default [ + { operationId: 'getPet', code: 'return store.get("Pet", req.params.petId);' }, + { operationId: 'addPet', code: 'return store.create("Pet", req.body);' }, +]; diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/invalid-not-function.handler.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/invalid-not-function.handler.mjs deleted file mode 100644 index 58ee66e..0000000 --- a/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/invalid-not-function.handler.mjs +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Invalid handler fixture for testing. - * Default export is not a function (it's an object). - */ -export default { - handler: async (_context) => { - return { - status: 200, - body: { error: 'This should not work' }, - }; - }, - name: 'invalidHandler', -}; diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/subdirectory/get-pet.handler.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/subdirectory/get-pet.handler.mjs index e866fe6..03c88de 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/subdirectory/get-pet.handler.mjs +++ b/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/subdirectory/get-pet.handler.mjs @@ -1,13 +1,14 @@ /** * Duplicate handler fixture for testing. - * This file has the same operationId as the parent get-pet.handler.mjs - * Filename: get-pet.handler.mjs → operationId: getPet + * This file exports a handler with the same operationId as the parent get-pet.handler.mjs * * Used to test that duplicate operationIds are detected and warned about. */ -export default async function handler(_context) { - return { - status: 200, - body: { id: 2, name: 'Duplicate Fluffy', status: 'pending' }, - }; -} +export default { + // Same operationId as parent directory's get-pet.handler.mjs + getPet: ` + // This is the subdirectory version + const pet = store.get('Pet', req.params.petId); + return pet || { id: 2, name: 'Duplicate Fluffy', status: 'pending' }; + `, +}; diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/handler-loader.test.ts b/packages/vite-plugin-open-api-server/src/loaders/__tests__/handler-loader.test.ts index 6618ea6..3aab44a 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/__tests__/handler-loader.test.ts +++ b/packages/vite-plugin-open-api-server/src/loaders/__tests__/handler-loader.test.ts @@ -3,11 +3,12 @@ * * Tests the loadHandlers function and related utilities for: * - Empty directory handling - * - Valid handler file loading - * - Invalid exports (no default, wrong type) + * - Valid handler file loading (object exports) + * - Static handlers (string code) + * - Dynamic handlers (functions returning string code) + * - Invalid exports (no default, wrong type, array instead of object) + * - Invalid handler values (not string or function) * - Duplicate operationId detection - * - OperationId extraction from filename - * - Kebab-case to camelCase conversion * - Cross-reference with registry * - Error resilience (continue loading on individual failures) */ @@ -16,7 +17,7 @@ import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { OpenApiEndpointRegistry } from '../../types/registry.js'; -import { extractOperationId, kebabToCamelCase, loadHandlers } from '../handler-loader.js'; +import { kebabToCamelCase, loadHandlers } from '../handler-loader.js'; const FIXTURES_DIR = path.join(__dirname, 'fixtures'); const EMPTY_DIR = path.join(__dirname, 'fixtures-empty'); @@ -87,36 +88,6 @@ describe('Handler Loader', () => { }); }); - describe('extractOperationId', () => { - it('should extract operationId from .handler.ts file', () => { - expect(extractOperationId('get-pet.handler.ts')).toBe('getPet'); - }); - - it('should extract operationId from .handler.js file', () => { - expect(extractOperationId('add-pet.handler.js')).toBe('addPet'); - }); - - it('should extract operationId from .handler.mts file', () => { - expect(extractOperationId('list-pets.handler.mts')).toBe('listPets'); - }); - - it('should extract operationId from .handler.mjs file', () => { - expect(extractOperationId('delete-pet.handler.mjs')).toBe('deletePet'); - }); - - it('should convert kebab-case filename to camelCase operationId', () => { - expect(extractOperationId('get-pet-by-id.handler.ts')).toBe('getPetById'); - }); - - it('should handle already camelCase filenames', () => { - expect(extractOperationId('listPets.handler.ts')).toBe('listPets'); - }); - - it('should handle complex kebab-case names', () => { - expect(extractOperationId('add-new-pet-to-store.handler.mjs')).toBe('addNewPetToStore'); - }); - }); - describe('loadHandlers', () => { let mockLogger: ReturnType; @@ -125,11 +96,13 @@ describe('Handler Loader', () => { }); describe('empty directory', () => { - it('should return empty map and log warning for empty directory', async () => { + it('should return empty result and log warning for empty directory', async () => { const registry = createMockRegistry(); - const handlers = await loadHandlers(EMPTY_DIR, registry, mockLogger); + const result = await loadHandlers(EMPTY_DIR, registry, mockLogger); - expect(handlers.size).toBe(0); + expect(result.handlers.size).toBe(0); + expect(result.loadedFiles).toHaveLength(0); + expect(result.warnings.length).toBeGreaterThan(0); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('No handler files found'), ); @@ -137,60 +110,68 @@ describe('Handler Loader', () => { }); describe('missing directory', () => { - it('should return empty map and log warning for non-existent directory', async () => { + it('should return empty result and log warning for non-existent directory', async () => { const registry = createMockRegistry(); const nonExistentDir = path.join(__dirname, 'non-existent-directory'); - const handlers = await loadHandlers(nonExistentDir, registry, mockLogger); + const result = await loadHandlers(nonExistentDir, registry, mockLogger); - expect(handlers.size).toBe(0); + expect(result.handlers.size).toBe(0); + expect(result.warnings.length).toBeGreaterThan(0); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('No handler files found'), ); }); }); - describe('valid handlers', () => { - it('should load valid handler files', async () => { - // Create registry with matching operationIds - const registry = createMockRegistry(['getPet', 'addNewPet']); - const handlers = await loadHandlers(FIXTURES_DIR, registry, mockLogger); - - // Should have loaded at least the valid handlers - // (may also have duplicates from subdirectory) - expect(handlers.size).toBeGreaterThan(0); - expect(handlers.has('getPet')).toBe(true); - expect(handlers.has('addNewPet')).toBe(true); - - // Verify the handler is a function - const getPetHandler = handlers.get('getPet'); - expect(typeof getPetHandler).toBe('function'); + describe('valid handlers (object exports)', () => { + it('should load valid handler files with object exports', async () => { + const registry = createMockRegistry(['getPet', 'getPetById', 'addPet', 'addNewPet']); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); + + // Should have loaded handlers from the valid files + expect(result.handlers.size).toBeGreaterThan(0); + expect(result.loadedFiles.length).toBeGreaterThan(0); + }); + + it('should load static handlers (string code)', async () => { + const registry = createMockRegistry(['getPet', 'getPetById']); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); + + // getPet and getPetById are static string handlers + const getPet = result.handlers.get('getPet'); + const getPetById = result.handlers.get('getPetById'); + + expect(typeof getPet).toBe('string'); + expect(typeof getPetById).toBe('string'); + }); + + it('should load dynamic handlers (functions)', async () => { + const registry = createMockRegistry(['addNewPet']); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); + + // addNewPet is a dynamic function handler + const addNewPet = result.handlers.get('addNewPet'); + expect(typeof addNewPet).toBe('function'); }); it('should log info message for each loaded handler', async () => { - const registry = createMockRegistry(['getPet', 'addNewPet']); + const registry = createMockRegistry(['getPet', 'addPet']); await loadHandlers(FIXTURES_DIR, registry, mockLogger); - // Should log info for loaded handlers - expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Loaded handler:')); + // Should log info for loaded handlers with type info + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringMatching(/Loaded handler:.*\(static|dynamic/), + ); }); it('should load handlers from subdirectories', async () => { const registry = createMockRegistry(['getPet']); - const handlers = await loadHandlers(FIXTURES_DIR, registry, mockLogger); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); - // The subdirectory also has a get-pet.handler.mjs, so getPet should be present - // (duplicate warning should be logged) - expect(handlers.has('getPet')).toBe(true); + // The subdirectory also has getPet, so duplicate warning should be logged + expect(result.handlers.has('getPet')).toBe(true); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Duplicate handler')); }); - - it('should convert kebab-case filenames to camelCase operationIds', async () => { - const registry = createMockRegistry(['addNewPet']); - const handlers = await loadHandlers(FIXTURES_DIR, registry, mockLogger); - - // add-new-pet.handler.mjs → addNewPet - expect(handlers.has('addNewPet')).toBe(true); - }); }); describe('invalid handlers', () => { @@ -202,85 +183,85 @@ describe('Handler Loader', () => { expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to load')); }); - it('should log error for handler with non-function default export', async () => { + it('should log error for handler with array export instead of object', async () => { const registry = createMockRegistry(); await loadHandlers(FIXTURES_DIR, registry, mockLogger); - // Should log error for invalid-not-function.handler.mjs + // Should log error for invalid-array-export.handler.mjs (exports array instead of object) expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to load')); }); it('should continue loading other handlers after error', async () => { - const registry = createMockRegistry(['getPet', 'addNewPet']); - const handlers = await loadHandlers(FIXTURES_DIR, registry, mockLogger); + const registry = createMockRegistry(['getPet', 'addPet', 'addNewPet']); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); // Should still have valid handlers despite errors - expect(handlers.has('getPet')).toBe(true); - expect(handlers.has('addNewPet')).toBe(true); + expect(result.handlers.size).toBeGreaterThan(0); + expect(result.errors.length).toBeGreaterThan(0); }); }); describe('duplicate operationIds', () => { - it('should warn about duplicate operationIds', async () => { + it('should warn about duplicate operationIds across files', async () => { const registry = createMockRegistry(['getPet']); - await loadHandlers(FIXTURES_DIR, registry, mockLogger); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); // Should warn about duplicate getPet (from fixtures/ and fixtures/subdirectory/) expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Duplicate handler')); - expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('getPet')); + expect(result.warnings.some((w) => w.includes('Duplicate'))).toBe(true); }); it('should overwrite earlier handler with later one for duplicates', async () => { const registry = createMockRegistry(['getPet']); - const handlers = await loadHandlers(FIXTURES_DIR, registry, mockLogger); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); // Should still have the handler (last one wins) - expect(handlers.has('getPet')).toBe(true); - expect(handlers.size).toBeGreaterThan(0); + expect(result.handlers.has('getPet')).toBe(true); }); }); describe('registry cross-reference', () => { - it('should warn when handler does not match any endpoint', async () => { + it('should warn when handler operationId does not match any endpoint', async () => { // Registry without matching operationIds const registry = createMockRegistry(['someOtherOperation']); - await loadHandlers(FIXTURES_DIR, registry, mockLogger); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); // Should warn about handlers not matching endpoints expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('does not match any endpoint'), + expect.stringContaining('does not match any operation'), ); + expect(result.warnings.some((w) => w.includes('does not match'))).toBe(true); }); - it('should not warn when handler matches endpoint', async () => { - // Registry with matching operationIds - const registry = createMockRegistry([ - 'getPet', - 'addNewPet', - 'invalidNoDefault', - 'invalidNotFunction', - ]); - const handlers = await loadHandlers(FIXTURES_DIR, registry, mockLogger); - - // Check that we loaded the valid handlers - expect(handlers.has('getPet')).toBe(true); - expect(handlers.has('addNewPet')).toBe(true); - - // Valid handlers that match the registry shouldn't trigger "does not match" warning - // Only the duplicate getPet might cause a different warning (which is expected) - // Verify that getPet and addNewPet are loaded successfully - expect(handlers.size).toBeGreaterThanOrEqual(2); + it('should not warn when handler operationId matches endpoint', async () => { + // Registry with matching operationIds for all handlers in fixtures + const registry = createMockRegistry(['getPet', 'getPetById', 'addPet', 'addNewPet']); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); + + // Check that handlers were loaded + expect(result.handlers.size).toBeGreaterThan(0); + + // Warnings about "does not match" should be fewer or none + // (may still have duplicate warnings which is expected) }); }); describe('error resilience', () => { - it('should log summary with success and error counts', async () => { - const registry = createMockRegistry(['getPet', 'addNewPet']); + it('should return result with errors array populated', async () => { + const registry = createMockRegistry(); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); + + // Should have some errors from invalid fixtures + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should log summary with handler and file counts', async () => { + const registry = createMockRegistry(['getPet', 'addPet', 'addNewPet']); await loadHandlers(FIXTURES_DIR, registry, mockLogger); // Should log summary expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringMatching(/Loaded \d+ handler\(s\), \d+ error\(s\)/), + expect.stringMatching(/Summary.*handler\(s\).*file\(s\)/), ); }); @@ -292,16 +273,45 @@ describe('Handler Loader', () => { }); }); + describe('HandlerLoadResult structure', () => { + it('should return proper HandlerLoadResult structure', async () => { + const registry = createMockRegistry(['getPet']); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); + + expect(result).toHaveProperty('handlers'); + expect(result).toHaveProperty('loadedFiles'); + expect(result).toHaveProperty('warnings'); + expect(result).toHaveProperty('errors'); + + expect(result.handlers).toBeInstanceOf(Map); + expect(Array.isArray(result.loadedFiles)).toBe(true); + expect(Array.isArray(result.warnings)).toBe(true); + expect(Array.isArray(result.errors)).toBe(true); + }); + + it('should track loaded files in loadedFiles array', async () => { + const registry = createMockRegistry(['getPet', 'addPet']); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); + + // Should have loaded some files successfully + expect(result.loadedFiles.length).toBeGreaterThan(0); + + // Each loaded file should be an absolute path + for (const file of result.loadedFiles) { + expect(path.isAbsolute(file)).toBe(true); + expect(file).toMatch(/\.handler\.(ts|js|mts|mjs)$/); + } + }); + }); + describe('file patterns', () => { it('should only load files matching *.handler.{ts,js,mts,mjs} pattern', async () => { const registry = createMockRegistry(); - const handlers = await loadHandlers(FIXTURES_DIR, registry, mockLogger); + const result = await loadHandlers(FIXTURES_DIR, registry, mockLogger); - // All loaded handlers should come from .handler.* files - // The handler map should not include any files without the .handler extension - for (const [operationId] of handlers) { - expect(typeof operationId).toBe('string'); - expect(operationId.length).toBeGreaterThan(0); + // All loaded files should match the pattern + for (const file of result.loadedFiles) { + expect(file).toMatch(/\.handler\.(ts|js|mts|mjs)$/); } }); }); diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/Order.seed.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/Order.seed.mjs index 0b702f5..98da329 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/Order.seed.mjs +++ b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/Order.seed.mjs @@ -2,19 +2,24 @@ * Valid Order Seed Fixture * * Example seed file for testing the seed loader. - * Uses PascalCase filename to match schema name directly. - * Exports a valid async function matching SeedCodeGenerator signature. + * Exports an object mapping schemaName to seed values. + * Uses dynamic seed that generates code based on available schemas. */ -export default async function orderSeed(context) { - const { faker } = context; +export default { + // Dynamic seed - function that generates code based on schema context + Order: ({ schemas }) => { + const hasPet = 'Pet' in schemas; - return Array.from({ length: 3 }, (_, i) => ({ - id: i + 1, - petId: i + 100, - quantity: (i + 1) * 2, - shipDate: faker?.date?.future?.()?.toISOString() ?? '2026-01-15T00:00:00.000Z', - status: 'placed', - complete: false, - })); -} + return ` + seed.count(20, (index) => ({ + id: faker.number.int({ min: 1, max: 10000 }), + petId: ${hasPet ? 'store.list("Pet")[index % 15]?.id' : 'faker.number.int({ min: 1, max: 100 })'}, + quantity: faker.number.int({ min: 1, max: 5 }), + shipDate: faker.date.future().toISOString(), + status: faker.helpers.arrayElement(['placed', 'approved', 'delivered']), + complete: faker.datatype.boolean() + })) + `; + }, +}; diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-array-export.seed.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-array-export.seed.mjs new file mode 100644 index 0000000..2811a8d --- /dev/null +++ b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-array-export.seed.mjs @@ -0,0 +1,13 @@ +/** + * Invalid Seed Fixture - Array Export + * + * This seed file exports an array as default export, which is invalid. + * Seed files must export a plain object mapping schemaName to seed values. + * Used to test that the seed loader properly validates exports. + */ + +// Invalid: default export is an array, not an object +export default [ + { schemaName: 'Pet', code: 'seed([{ id: 1, name: "Test Pet" }])' }, + { schemaName: 'Order', code: 'seed([{ id: 1, petId: 1 }])' }, +]; diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-function-export.seed.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-function-export.seed.mjs new file mode 100644 index 0000000..bee5043 --- /dev/null +++ b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-function-export.seed.mjs @@ -0,0 +1,15 @@ +/** + * Invalid Seed Fixture - Function Export (Not Object) + * + * This seed file exports a function as default export, which is invalid. + * Seed files must export a plain object mapping schemaName to seed values. + * Used to test that the seed loader properly validates exports. + */ + +// Invalid: default export is a function, not an object +export default async function invalidSeed(_context) { + return [ + { id: 1, name: 'Invalid Seed 1' }, + { id: 2, name: 'Invalid Seed 2' }, + ]; +} diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-not-function.seed.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-not-function.seed.mjs deleted file mode 100644 index c8262d0..0000000 --- a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-not-function.seed.mjs +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Invalid Seed Fixture - Not a Function - * - * This seed file exports a non-function default export. - * Used to test that the seed loader properly validates exports. - */ - -// Invalid: default export is not a function -export default { - id: 1, - name: 'Invalid Seed', - data: 'This is not a function', -}; diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/pets.seed.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/pets.seed.mjs index ff24107..c914ac8 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/pets.seed.mjs +++ b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/pets.seed.mjs @@ -2,19 +2,39 @@ * Valid Pet Seed Fixture * * Example seed file for testing the seed loader. - * Exports a valid async function matching SeedCodeGenerator signature. + * Exports an object mapping schemaName to seed values. */ -export default async function petSeed(context) { - const { faker } = context; +export default { + // Static seed - code as string + Pet: ` + seed.count(15, () => ({ + id: faker.number.int({ min: 1, max: 10000 }), + name: faker.animal.dog(), + status: faker.helpers.arrayElement(['available', 'pending', 'sold']), + category: { + id: faker.number.int({ min: 1, max: 5 }), + name: faker.helpers.arrayElement(['Dogs', 'Cats', 'Birds']) + }, + photoUrls: [faker.image.url()], + tags: [{ id: faker.number.int({ min: 1, max: 100 }), name: faker.word.adjective() }] + })) + `, - return Array.from({ length: 5 }, (_, i) => ({ - id: i + 1, - name: faker?.animal?.dog?.() ?? `Pet ${i + 1}`, - status: 'available', - category: { - id: 1, - name: 'Dogs', - }, - })); -} + // Dynamic seed - function that generates code based on schema context + Category: ({ schema }) => { + const hasDescription = schema?.properties?.description; + + const code = ` + seed([ + { id: 1, name: 'Dogs'${hasDescription ? ", description: 'Man\\'s best friend'" : ''} }, + { id: 2, name: 'Cats'${hasDescription ? ", description: 'Independent companions'" : ''} }, + { id: 3, name: 'Birds'${hasDescription ? ", description: 'Feathered friends'" : ''} }, + { id: 4, name: 'Fish'${hasDescription ? ", description: 'Aquatic pets'" : ''} }, + { id: 5, name: 'Reptiles'${hasDescription ? ", description: 'Cold-blooded companions'" : ''} } + ]) + `; + + return code; + }, +}; diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/subdirectory/pets.seed.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/subdirectory/pets.seed.mjs index 4c60737..4cf939e 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/subdirectory/pets.seed.mjs +++ b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/subdirectory/pets.seed.mjs @@ -1,20 +1,24 @@ /** * Duplicate Pet Seed Fixture (subdirectory) * - * This seed file has the same schema name as pets.seed.mjs in the parent directory. + * This seed file has the same schema name (Pet) as pets.seed.mjs in the parent directory. * Used to test duplicate seed detection and overwriting behavior. + * Exports an object mapping schemaName to seed values. */ -export default async function petSeedDuplicate(context) { - const { faker } = context; - - return Array.from({ length: 3 }, (_, i) => ({ - id: i + 100, - name: faker?.animal?.cat?.() ?? `Subdirectory Pet ${i + 1}`, - status: 'pending', - category: { - id: 2, - name: 'Cats', - }, - })); -} +export default { + // Static seed - will override the Pet seed from parent directory + Pet: ` + seed.count(3, () => ({ + id: faker.number.int({ min: 100, max: 200 }), + name: faker.animal.cat(), + status: 'pending', + category: { + id: 2, + name: 'Cats' + }, + photoUrls: [faker.image.url()], + tags: [] + })) + `, +}; diff --git a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-loader.test.ts b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-loader.test.ts index abec141..d507d26 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-loader.test.ts +++ b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-loader.test.ts @@ -3,8 +3,8 @@ * * Tests the loadSeeds function and related utilities for: * - Empty directory handling - * - Valid seed file loading - * - Invalid exports (no default, wrong type) + * - Valid seed file loading (object exports) + * - Invalid exports (no default, function instead of object, array) * - Duplicate schema name detection * - Schema name extraction from filename * - Singular/plural conversion utilities @@ -18,7 +18,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { OpenApiEndpointRegistry } from '../../types/registry.js'; import { capitalize, - extractSchemaName, findMatchingSchema, loadSeeds, pluralize, @@ -163,32 +162,6 @@ describe('Seed Loader', () => { }); }); - describe('extractSchemaName', () => { - it('should extract schema name from .seed.ts file', () => { - expect(extractSchemaName('pets.seed.ts')).toBe('pets'); - }); - - it('should extract schema name from .seed.js file', () => { - expect(extractSchemaName('Pet.seed.js')).toBe('Pet'); - }); - - it('should extract schema name from .seed.mts file', () => { - expect(extractSchemaName('Order.seed.mts')).toBe('Order'); - }); - - it('should extract schema name from .seed.mjs file', () => { - expect(extractSchemaName('users.seed.mjs')).toBe('users'); - }); - - it('should preserve case of filename', () => { - expect(extractSchemaName('OrderItem.seed.ts')).toBe('OrderItem'); - }); - - it('should handle kebab-case names', () => { - expect(extractSchemaName('order-items.seed.ts')).toBe('order-items'); - }); - }); - describe('findMatchingSchema', () => { it('should find exact match', () => { const registry = createMockRegistry(['pets']); @@ -234,11 +207,14 @@ describe('Seed Loader', () => { }); describe('empty directory', () => { - it('should return empty map and log warning for empty directory', async () => { + it('should return empty result and log warning for empty directory', async () => { const registry = createMockRegistry(); - const seeds = await loadSeeds(SEED_EMPTY_DIR, registry, mockLogger); + const result = await loadSeeds(SEED_EMPTY_DIR, registry, mockLogger); - expect(seeds.size).toBe(0); + expect(result.seeds.size).toBe(0); + expect(result.loadedFiles).toHaveLength(0); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toContain('No seed files found'); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('No seed files found'), ); @@ -246,12 +222,13 @@ describe('Seed Loader', () => { }); describe('missing directory', () => { - it('should return empty map and log warning for non-existent directory', async () => { + it('should return empty result and log warning for non-existent directory', async () => { const registry = createMockRegistry(); const nonExistentDir = path.join(__dirname, 'non-existent-seed-directory'); - const seeds = await loadSeeds(nonExistentDir, registry, mockLogger); + const result = await loadSeeds(nonExistentDir, registry, mockLogger); - expect(seeds.size).toBe(0); + expect(result.seeds.size).toBe(0); + expect(result.loadedFiles).toHaveLength(0); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('No seed files found'), ); @@ -259,20 +236,36 @@ describe('Seed Loader', () => { }); describe('valid seeds', () => { - it('should load valid seed files', async () => { - const registry = createMockRegistry(['Pet', 'Order']); - const seeds = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + it('should load valid seed files with object exports', async () => { + const registry = createMockRegistry(['Pet', 'Order', 'Category']); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); // Should have loaded the valid seeds - expect(seeds.size).toBeGreaterThan(0); + expect(result.seeds.size).toBeGreaterThan(0); + expect(result.loadedFiles.length).toBeGreaterThan(0); + }); + + it('should load static seed values as strings', async () => { + const registry = createMockRegistry(['Pet']); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); - // Verify the seed is a function - const petSeed = seeds.get('Pet'); - expect(typeof petSeed).toBe('function'); + // Pet seed should be a string (static) + const petSeed = result.seeds.get('Pet'); + expect(typeof petSeed).toBe('string'); + expect(petSeed).toContain('seed.count'); + }); + + it('should load dynamic seed values as functions', async () => { + const registry = createMockRegistry(['Category']); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + + // Category seed should be a function (dynamic) + const categorySeed = result.seeds.get('Category'); + expect(typeof categorySeed).toBe('function'); }); it('should log info message for each loaded seed', async () => { - const registry = createMockRegistry(['Pet', 'Order']); + const registry = createMockRegistry(['Pet', 'Order', 'Category']); await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Loaded seed:')); @@ -280,74 +273,81 @@ describe('Seed Loader', () => { it('should load seeds from subdirectories', async () => { const registry = createMockRegistry(['Pet']); - const seeds = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); - // The subdirectory also has a pets.seed.mjs, so Pet should be present - // (duplicate warning should be logged) - expect(seeds.has('Pet')).toBe(true); + // The subdirectory also has a pets.seed.mjs with Pet key + // Duplicate warning should be logged + expect(result.seeds.has('Pet')).toBe(true); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Duplicate seed')); }); - it('should match plural filename to singular schema', async () => { - const registry = createMockRegistry(['Pet']); - const seeds = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); - - // pets.seed.mjs → Pet (singular schema) - expect(seeds.has('Pet')).toBe(true); - }); - - it('should match PascalCase filename to schema directly', async () => { - const registry = createMockRegistry(['Order']); - const seeds = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + it('should return loaded files list', async () => { + const registry = createMockRegistry(['Pet', 'Order']); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); - // Order.seed.mjs → Order (exact match) - expect(seeds.has('Order')).toBe(true); + expect(result.loadedFiles.length).toBeGreaterThan(0); + for (const file of result.loadedFiles) { + expect(file).toMatch(/\.seed\.(ts|js|mts|mjs)$/); + } }); }); describe('invalid seeds', () => { it('should log error for seed without default export', async () => { const registry = createMockRegistry(); - await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); // Should log error for invalid-no-default.seed.mjs expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to load')); + expect(result.errors.length).toBeGreaterThan(0); }); - it('should log error for seed with non-function default export', async () => { + it('should log error for seed with function default export', async () => { const registry = createMockRegistry(); - await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + + // Should log error for invalid-function-export.seed.mjs (exports function instead of object) + expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to load')); + expect(result.errors.some((e) => e.includes('function'))).toBe(true); + }); + + it('should log error for seed with array default export', async () => { + const registry = createMockRegistry(); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); - // Should log error for invalid-not-function.seed.mjs + // Should log error for invalid-array-export.seed.mjs expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to load')); + expect(result.errors.some((e) => e.includes('array'))).toBe(true); }); it('should continue loading other seeds after error', async () => { - const registry = createMockRegistry(['Pet', 'Order']); - const seeds = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + const registry = createMockRegistry(['Pet', 'Order', 'Category']); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); // Should still have valid seeds despite errors - expect(seeds.has('Pet')).toBe(true); - expect(seeds.has('Order')).toBe(true); + expect(result.seeds.has('Pet')).toBe(true); + expect(result.seeds.has('Order')).toBe(true); + expect(result.seeds.has('Category')).toBe(true); }); }); describe('duplicate schema names', () => { it('should warn about duplicate schema names', async () => { const registry = createMockRegistry(['Pet']); - await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); // Should warn about duplicate Pet (from fixtures/ and fixtures/subdirectory/) expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Duplicate seed')); + expect(result.warnings.some((w) => w.includes('Duplicate'))).toBe(true); }); it('should overwrite earlier seed with later one for duplicates', async () => { const registry = createMockRegistry(['Pet']); - const seeds = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); // Should still have the seed (last one wins) - expect(seeds.has('Pet')).toBe(true); - expect(seeds.size).toBeGreaterThan(0); + expect(result.seeds.has('Pet')).toBe(true); + expect(result.seeds.size).toBeGreaterThan(0); }); }); @@ -355,34 +355,35 @@ describe('Seed Loader', () => { it('should warn when seed does not match any schema', async () => { // Registry without matching schemas const registry = createMockRegistry(['SomeOtherSchema']); - await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); // Should warn about seeds not matching schemas expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('does not match any schema'), ); + expect(result.warnings.some((w) => w.includes('does not match any schema'))).toBe(true); }); it('should not warn when seed matches schema', async () => { // Registry with matching schema names - const registry = createMockRegistry(['Pet', 'Order']); - await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + const registry = createMockRegistry(['Pet', 'Order', 'Category']); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); - // Verify that Pet and Order are loaded successfully - const seeds = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); - expect(seeds.has('Pet')).toBe(true); - expect(seeds.has('Order')).toBe(true); + // Verify that Pet, Order, Category are loaded successfully + expect(result.seeds.has('Pet')).toBe(true); + expect(result.seeds.has('Order')).toBe(true); + expect(result.seeds.has('Category')).toBe(true); }); }); describe('error resilience', () => { - it('should log summary with success and error counts', async () => { - const registry = createMockRegistry(['Pet', 'Order']); + it('should log summary with file and seed counts', async () => { + const registry = createMockRegistry(['Pet', 'Order', 'Category']); await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); // Should log summary expect(mockLogger.info).toHaveBeenCalledWith( - expect.stringMatching(/Loaded \d+ seed\(s\), \d+ error\(s\)/), + expect.stringMatching(/Summary: \d+ seed\(s\), from \d+ file\(s\)/), ); }); @@ -392,42 +393,69 @@ describe('Seed Loader', () => { // Should not throw even with invalid seeds in fixtures await expect(loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger)).resolves.toBeDefined(); }); + + it('should return errors in result object', async () => { + const registry = createMockRegistry(); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + + // Should have errors for invalid fixtures + expect(result.errors.length).toBeGreaterThan(0); + }); }); describe('file patterns', () => { it('should only load files matching *.seed.{ts,js,mts,mjs} pattern', async () => { const registry = createMockRegistry(); - const seeds = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); - // All loaded seeds should come from .seed.* files - for (const [schemaName] of seeds) { - expect(typeof schemaName).toBe('string'); - expect(schemaName.length).toBeGreaterThan(0); + // All loaded files should match the pattern + for (const file of result.loadedFiles) { + expect(file).toMatch(/\.seed\.(ts|js|mts|mjs)$/); } }); }); - describe('seed function execution', () => { - it('should return callable seed functions', async () => { - const registry = createMockRegistry(['Pet', 'Order']); - const seeds = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + describe('seed value types', () => { + it('should return string seed values for static seeds', async () => { + const registry = createMockRegistry(['Pet']); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + + const petSeed = result.seeds.get('Pet'); + expect(typeof petSeed).toBe('string'); + }); + + it('should return function seed values for dynamic seeds', async () => { + const registry = createMockRegistry(['Category', 'Order']); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); + + // Category is a dynamic seed in pets.seed.mjs + const categorySeed = result.seeds.get('Category'); + expect(typeof categorySeed).toBe('function'); + + // Order is a dynamic seed in Order.seed.mjs + const orderSeed = result.seeds.get('Order'); + expect(typeof orderSeed).toBe('function'); + }); + + it('should allow calling dynamic seed functions', async () => { + const registry = createMockRegistry(['Category']); + const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); - const petSeed = seeds.get('Pet'); - expect(typeof petSeed).toBe('function'); + const categorySeed = result.seeds.get('Category'); + expect(typeof categorySeed).toBe('function'); - // Call the seed function with a mock context + // Call the function with a mock context const mockContext = { - faker: undefined, - logger: mockLogger, - registry, - schemaName: 'Pet', - env: {}, + schemaName: 'Category', + schema: { type: 'object', properties: { description: { type: 'string' } } }, + document: { openapi: '3.1.0', info: { title: 'Test', version: '1.0' }, paths: {} }, + schemas: {}, }; - // biome-ignore lint/style/noNonNullAssertion: test assertion - const result = await petSeed!(mockContext); - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); + // biome-ignore lint/complexity/noBannedTypes: test assertion with dynamic seed function + const code = (categorySeed as Function)(mockContext); + expect(typeof code).toBe('string'); + expect(code).toContain('seed'); }); }); }); diff --git a/packages/vite-plugin-open-api-server/src/loaders/handler-loader.ts b/packages/vite-plugin-open-api-server/src/loaders/handler-loader.ts index 201821e..0ffe4f1 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/handler-loader.ts +++ b/packages/vite-plugin-open-api-server/src/loaders/handler-loader.ts @@ -3,53 +3,52 @@ * * ## What * This module provides functionality to dynamically load custom handler files - * from a directory. Handlers allow developers to override default mock server - * responses with custom logic for specific endpoints. + * from a directory. Handlers define JavaScript code that will be injected as + * `x-handler` extensions into OpenAPI operations for the Scalar Mock Server. * * ## How * The loader scans a directory for files matching the `*.handler.{ts,js,mts,mjs}` * pattern, dynamically imports each file as an ESM module, validates the default - * export matches the `HandlerCodeGenerator` signature, and builds a map of - * operationId → handler function. + * export is an object mapping operationId → handler value, and aggregates all + * handlers into a single Map. * * ## Why * Custom handlers enable realistic mock responses that go beyond static OpenAPI - * examples. By loading handlers dynamically, we support hot reload and allow - * developers to add new handlers without modifying plugin configuration. + * examples. The code-based format (string or function returning string) allows + * handlers to access Scalar's runtime context (store, faker, req, res). + * + * @see https://scalar.com/products/mock-server/custom-request-handler * * @module */ +import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { glob } from 'fast-glob'; +import fg from 'fast-glob'; import type { Logger } from 'vite'; -import type { HandlerCodeGenerator } from '../types/handlers.js'; +import type { HandlerExports, HandlerLoadResult, HandlerValue } from '../types/handlers.js'; import type { OpenApiEndpointRegistry } from '../types/registry.js'; - -/** - * Result of loading handlers from a directory. - * - * Contains the handler map and any errors encountered during loading. - */ -export interface LoadHandlersResult { - /** - * Map of operationId to handler function. - */ - handlers: Map; - - /** - * Errors encountered during loading (file path → error message). - */ - errors: string[]; -} +import { + formatInvalidExportError, + formatInvalidValueError, + getValueType, + isValidExportsObject, + isValidValue, + logLoadSummary, +} from './loader-utils.js'; /** * Load custom handler files from a directory. * * Scans for `*.handler.{ts,js,mts,mjs}` files, validates exports, - * and returns a map of operationId → handler function. + * and returns a map of operationId → handler value. + * + * Handler files must export an object as default export, where each key + * is an operationId and each value is either: + * - A string containing JavaScript code + * - A function that receives HandlerCodeContext and returns a code string * * The loader is resilient: if one handler file fails to load or validate, * it logs the error and continues with the remaining files. @@ -57,16 +56,20 @@ export interface LoadHandlersResult { * @param handlersDir - Directory containing handler files * @param registry - OpenAPI endpoint registry (for validation) * @param logger - Vite logger - * @returns Promise resolving to handler map + * @returns Promise resolving to HandlerLoadResult * * @example * ```typescript - * const handlers = await loadHandlers('./mock/handlers', registry, logger); + * const result = await loadHandlers('./mock/handlers', registry, logger); * - * // Check if a handler exists for an operation - * if (handlers.has('getPetById')) { - * const handler = handlers.get('getPetById'); - * const response = await handler(context); + * // Access loaded handlers + * for (const [operationId, handlerValue] of result.handlers) { + * console.log(`Handler for ${operationId}:`, typeof handlerValue); + * } + * + * // Check for issues + * if (result.errors.length > 0) { + * console.error('Handler loading errors:', result.errors); * } * ``` */ @@ -74,50 +77,68 @@ export async function loadHandlers( handlersDir: string, registry: OpenApiEndpointRegistry, logger: Logger, -): Promise> { - const handlers = new Map(); +): Promise { + const handlers = new Map(); + const loadedFiles: string[] = []; + const warnings: string[] = []; const errors: string[] = []; try { - // Check if directory exists + // Resolve to absolute path const absoluteDir = path.resolve(handlersDir); + // Check if directory exists and is actually a directory before scanning + if (!fs.existsSync(absoluteDir)) { + const msg = `No handler files found in ${handlersDir}`; + logger.warn(`[handler-loader] ${msg}`); + warnings.push(msg); + return { handlers, loadedFiles, warnings, errors }; + } + + // Verify it's a directory, not a file + try { + const stat = fs.statSync(absoluteDir); + if (!stat.isDirectory()) { + const msg = `Path ${handlersDir} exists but is not a directory`; + logger.warn(`[handler-loader] ${msg}`); + warnings.push(msg); + return { handlers, loadedFiles, warnings, errors }; + } + } catch { + const msg = `Cannot access ${handlersDir}`; + logger.warn(`[handler-loader] ${msg}`); + warnings.push(msg); + return { handlers, loadedFiles, warnings, errors }; + } + // Scan for handler files - const files = await glob('**/*.handler.{ts,js,mts,mjs}', { + const files = await fg.glob('**/*.handler.{ts,js,mts,mjs}', { cwd: absoluteDir, absolute: true, }); if (files.length === 0) { - logger.warn(`[handler-loader] No handler files found in ${handlersDir}`); - return handlers; + const msg = `No handler files found in ${handlersDir}`; + logger.warn(`[handler-loader] ${msg}`); + warnings.push(msg); + return { handlers, loadedFiles, warnings, errors }; } logger.info(`[handler-loader] Found ${files.length} handler file(s)`); + // Pre-build a Set of operationIds for O(1) lookups + const operationIdSet = new Set(); + for (const endpoint of registry.endpoints.values()) { + if (endpoint.operationId) { + operationIdSet.add(endpoint.operationId); + } + } + // Load each handler file for (const filePath of files) { try { - // Dynamic import (ESM) - const fileUrl = pathToFileURL(filePath).href; - const module = await import(fileUrl); - - // Validate default export - if (!module.default || typeof module.default !== 'function') { - throw new Error(`Handler file must export a default async function`); - } - - // Extract operationId from filename - const filename = path.basename(filePath); - const operationId = extractOperationId(filename); - - // Add to map (warn on duplicates) - if (handlers.has(operationId)) { - logger.warn(`[handler-loader] Duplicate handler for "${operationId}", overwriting`); - } - - handlers.set(operationId, module.default as HandlerCodeGenerator); - logger.info(`[handler-loader] Loaded handler: ${operationId}`); + await loadHandlerFile(filePath, handlers, operationIdSet, logger, warnings); + loadedFiles.push(filePath); } catch (error) { const err = error as Error; const errorMessage = `${filePath}: ${err.message}`; @@ -126,53 +147,85 @@ export async function loadHandlers( } } - // Cross-reference with registry - for (const operationId of handlers.keys()) { - const hasEndpoint = Array.from(registry.endpoints.values()).some( - (endpoint) => endpoint.operationId === operationId, - ); - - if (!hasEndpoint) { - logger.warn( - `[handler-loader] Handler "${operationId}" does not match any endpoint in OpenAPI spec`, - ); - } - } - // Log summary - const successCount = handlers.size; - const errorCount = errors.length; - logger.info(`[handler-loader] Loaded ${successCount} handler(s), ${errorCount} error(s)`); - - return handlers; + logLoadSummary( + 'handler', + handlers.size, + loadedFiles.length, + warnings.length, + errors.length, + logger, + ); + + return { handlers, loadedFiles, warnings, errors }; } catch (error) { const err = error as Error; - logger.error(`[handler-loader] Fatal error: ${err.message}`); - return handlers; + const fatalError = `Fatal error scanning handlers directory: ${err.message}`; + logger.error(`[handler-loader] ${fatalError}`); + errors.push(fatalError); + return { handlers, loadedFiles, warnings, errors }; } } /** - * Extract operationId from handler filename. - * - * Converts kebab-case filename to camelCase operationId. - * - * @param filename - Handler filename (e.g., 'add-pet.handler.ts') - * @returns OperationId in camelCase (e.g., 'addPet') - * - * @example - * ```typescript - * extractOperationId('add-pet.handler.ts'); // 'addPet' - * extractOperationId('get-pet-by-id.handler.mjs'); // 'getPetById' - * extractOperationId('listPets.handler.js'); // 'listPets' - * ``` + * Load a single handler file and merge its exports into the handlers map. */ -export function extractOperationId(filename: string): string { - // Remove extension(s): .handler.ts, .handler.js, .handler.mts, .handler.mjs - const withoutExtension = filename.replace(/\.handler\.(ts|js|mts|mjs)$/, ''); +async function loadHandlerFile( + filePath: string, + handlers: Map, + operationIdSet: Set, + logger: Logger, + warnings: string[], +): Promise { + // Dynamic import (ESM) + const fileUrl = pathToFileURL(filePath).href; + const module = await import(fileUrl); + + // Validate default export exists (use 'in' operator to detect property presence, not truthy check) + if (!('default' in module)) { + throw new Error('Handler file must have a default export'); + } - // Convert kebab-case to camelCase - return kebabToCamelCase(withoutExtension); + // Validate default export is an object (not function, array, or primitive) + const exports = module.default as unknown; + if (!isValidExportsObject(exports)) { + throw new Error( + `Handler file ${formatInvalidExportError('object mapping operationId to handler values', exports)}`, + ); + } + + const handlerExports = exports as HandlerExports; + const filename = path.basename(filePath); + + // Process each handler in the exports + for (const [operationId, handlerValue] of Object.entries(handlerExports)) { + // Validate handler value type + if (!isValidValue(handlerValue)) { + const msg = formatInvalidValueError(operationId, filename, handlerValue); + warnings.push(msg); + logger.warn(`[handler-loader] ${msg}`); + continue; + } + + // Validate operationId exists in registry (O(1) lookup) + if (!operationIdSet.has(operationId)) { + const msg = `Handler "${operationId}" in ${filename} does not match any operation in OpenAPI spec`; + warnings.push(msg); + logger.warn(`[handler-loader] ${msg}`); + // Continue anyway - user might know what they're doing + } + + // Check for duplicates + if (handlers.has(operationId)) { + const msg = `Duplicate handler for "${operationId}" in ${filename}, overwriting previous`; + warnings.push(msg); + logger.warn(`[handler-loader] ${msg}`); + } + + // Add to handlers map + handlers.set(operationId, handlerValue); + logger.info(`[handler-loader] Loaded handler: ${operationId} (${getValueType(handlerValue)})`); + } } /** diff --git a/packages/vite-plugin-open-api-server/src/loaders/index.ts b/packages/vite-plugin-open-api-server/src/loaders/index.ts index 809f2ca..0fdae48 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/index.ts +++ b/packages/vite-plugin-open-api-server/src/loaders/index.ts @@ -6,18 +6,19 @@ * @module */ +export { kebabToCamelCase, loadHandlers } from './handler-loader.js'; + export { - extractOperationId, - kebabToCamelCase, - type LoadHandlersResult, - loadHandlers, -} from './handler-loader.js'; + formatInvalidExportError, + getValueType, + isValidExportsObject, + isValidValue, + logLoadSummary, +} from './loader-utils.js'; export { capitalize, - extractSchemaName, findMatchingSchema, - type LoadSeedsResult, loadSeeds, pluralize, singularize, diff --git a/packages/vite-plugin-open-api-server/src/loaders/loader-utils.ts b/packages/vite-plugin-open-api-server/src/loaders/loader-utils.ts new file mode 100644 index 0000000..9c26265 --- /dev/null +++ b/packages/vite-plugin-open-api-server/src/loaders/loader-utils.ts @@ -0,0 +1,205 @@ +/** + * Loader Utilities Module + * + * ## What + * This module provides shared utility functions for handler and seed loaders. + * It contains validation helpers, type checking functions, and logging utilities. + * + * ## How + * The utilities are generic and work with both handler and seed values. + * They are imported by handler-loader.ts and seed-loader.ts to reduce duplication. + * + * ## Why + * Extracting shared logic into a single module improves maintainability, + * ensures consistent behavior, and reduces code duplication between loaders. + * + * @module + */ + +import type { Logger } from 'vite'; + +/** + * Check if a value is a valid exports object (plain object, not array/function). + * + * Validates that the value is: + * - An object (typeof === 'object') + * - Not null + * - Not an array + * - A plain object (prototype is Object.prototype) + * + * @param value - Value to check + * @returns True if valid exports object + * + * @example + * ```typescript + * isValidExportsObject({ getPetById: 'code' }); // true + * isValidExportsObject([1, 2, 3]); // false + * isValidExportsObject(() => {}); // false + * isValidExportsObject(null); // false + * ``` + */ +export function isValidExportsObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + // Ensure it's a plain object, not a class instance + Object.getPrototypeOf(value) === Object.prototype + ); +} + +/** + * Check if a value is a valid loader value (string or function). + * + * Both handlers and seeds accept either: + * - A string containing JavaScript code + * - A function that generates JavaScript code + * + * @param value - Value to check + * @returns True if valid (string or function) + * + * @example + * ```typescript + * isValidValue('return store.list("Pet");'); // true + * isValidValue((ctx) => 'return store.get("Pet");'); // true + * isValidValue(123); // false + * isValidValue(null); // false + * ``` + */ +export function isValidValue(value: unknown): value is string | ((...args: unknown[]) => unknown) { + return typeof value === 'string' || typeof value === 'function'; +} + +/** + * Get a human-readable type description for a loader value. + * + * @param value - String or function value + * @returns Description like "static, 42 chars" or "dynamic function" + * + * @example + * ```typescript + * getValueType('return store.list("Pet");'); // "static, 25 chars" + * getValueType((ctx) => 'code'); // "dynamic function" + * ``` + */ +export function getValueType(value: string | ((...args: unknown[]) => unknown)): string { + if (typeof value === 'string') { + return `static, ${value.length} chars`; + } + return 'dynamic function'; +} + +/** + * Log the loading summary for handlers or seeds. + * + * @param itemType - Type of items ("handler" or "seed") + * @param itemCount - Number of items loaded + * @param fileCount - Number of files processed + * @param warningCount - Number of warnings + * @param errorCount - Number of errors + * @param logger - Vite logger instance + * + * @example + * ```typescript + * logLoadSummary('handler', 5, 2, 1, 0, logger); + * // Logs: "[handler-loader] Summary: 5 handler(s), from 2 file(s), 1 warning(s)" + * ``` + */ +export function logLoadSummary( + itemType: 'handler' | 'seed', + itemCount: number, + fileCount: number, + warningCount: number, + errorCount: number, + logger: Logger, +): void { + const itemLabel = itemType === 'handler' ? 'handler(s)' : 'seed(s)'; + const loaderName = `${itemType}-loader`; + const parts = [`${itemCount} ${itemLabel}`, `from ${fileCount} file(s)`]; + + if (warningCount > 0) { + parts.push(`${warningCount} warning(s)`); + } + + if (errorCount > 0) { + parts.push(`${errorCount} error(s)`); + } + + logger.info(`[${loaderName}] Summary: ${parts.join(', ')}`); +} + +/** + * Get a descriptive type string for a value, handling null and arrays properly. + * + * @param value - The value to describe + * @returns Human-readable type description + * + * @example + * ```typescript + * describeValueType(null); // 'null' + * describeValueType([1, 2]); // 'object (array)' + * describeValueType(() => {}); // 'function' + * describeValueType('hello'); // 'string' + * describeValueType(123); // 'number' + * ``` + */ +export function describeValueType(value: unknown): string { + // Handle null explicitly (typeof null === 'object' but we want a clearer message) + if (value === null) { + return 'null'; + } + if (Array.isArray(value)) { + return 'object (array)'; + } + if (typeof value === 'function') { + return 'function'; + } + return typeof value; +} + +/** + * Format an error description for invalid export type. + * + * @param expectedType - What was expected (e.g., "object mapping operationId to handler values") + * @param actualValue - The actual value received + * @returns Formatted error message + * + * @example + * ```typescript + * formatInvalidExportError('object mapping operationId to handler values', [1, 2]); + * // "default export must be an object mapping operationId to handler values. Got: object (array)" + * + * formatInvalidExportError('object mapping schemaName to seed values', null); + * // "default export must be an object mapping schemaName to seed values. Got: null" + * ``` + */ +export function formatInvalidExportError(expectedType: string, actualValue: unknown): string { + return `default export must be an ${expectedType}. Got: ${describeValueType(actualValue)}`; +} + +/** + * Format an error description for invalid value type within an export. + * + * Used when a specific key in the exports object has an invalid value type. + * + * @param keyName - The key name (operationId or schemaName) + * @param filename - The source filename + * @param actualValue - The actual value received + * @returns Formatted error message + * + * @example + * ```typescript + * formatInvalidValueError('getPetById', 'pets.handler.mjs', 123); + * // 'Invalid value for "getPetById" in pets.handler.mjs: expected string or function, got number' + * + * formatInvalidValueError('Pet', 'pets.seed.mjs', null); + * // 'Invalid value for "Pet" in pets.seed.mjs: expected string or function, got null' + * ``` + */ +export function formatInvalidValueError( + keyName: string, + filename: string, + actualValue: unknown, +): string { + return `Invalid value for "${keyName}" in ${filename}: expected string or function, got ${describeValueType(actualValue)}`; +} diff --git a/packages/vite-plugin-open-api-server/src/loaders/seed-loader.ts b/packages/vite-plugin-open-api-server/src/loaders/seed-loader.ts index 5930d61..bb39cb0 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/seed-loader.ts +++ b/packages/vite-plugin-open-api-server/src/loaders/seed-loader.ts @@ -2,71 +2,74 @@ * Seed Loader Module * * ## What - * This module provides functionality to dynamically load seed data generator files - * from a directory. Seeds allow developers to provide consistent, realistic test - * data for mock responses instead of relying on auto-generated mock data. + * This module provides functionality to dynamically load seed data files + * from a directory. Seeds define JavaScript code that will be injected as + * `x-seed` extensions into OpenAPI schemas for the Scalar Mock Server. * * ## How * The loader scans a directory for files matching the `*.seed.{ts,js,mts,mjs}` * pattern, dynamically imports each file as an ESM module, validates the default - * export matches the `SeedCodeGenerator` signature, and builds a map of - * schemaName → seed function. + * export is an object mapping schemaName → seed value, and aggregates all + * seeds into a single Map. * * ## Why * Custom seeds enable realistic mock data that better represents production - * scenarios. By loading seeds dynamically, we support hot reload and allow - * developers to add new seed files without modifying plugin configuration. + * scenarios. The code-based format (string or function returning string) allows + * seeds to access Scalar's runtime context (seed, store, faker). + * + * @see https://scalar.com/products/mock-server/data-seeding * * @module */ +import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { glob } from 'fast-glob'; +import fg from 'fast-glob'; import type { Logger } from 'vite'; import type { OpenApiEndpointRegistry } from '../types/registry.js'; -import type { SeedCodeGenerator } from '../types/seeds.js'; - -/** - * Result of loading seeds from a directory. - * - * Contains the seed map and any errors encountered during loading. - */ -export interface LoadSeedsResult { - /** - * Map of schema name to seed generator function. - */ - seeds: Map; - - /** - * Errors encountered during loading (file path → error message). - */ - errors: string[]; -} +import type { SeedExports, SeedLoadResult, SeedValue } from '../types/seeds.js'; +import { + formatInvalidExportError, + formatInvalidValueError, + getValueType, + isValidExportsObject, + isValidValue, + logLoadSummary, +} from './loader-utils.js'; /** - * Load seed data generator files from a directory. + * Load seed data files from a directory. * * Scans for `*.seed.{ts,js,mts,mjs}` files, validates exports, - * and returns a map of schemaName → seed function. + * and returns a map of schemaName → seed value. + * + * Seed files must export an object as default export, where each key + * is a schemaName and each value is either: + * - A string containing JavaScript code + * - A function that receives SeedCodeContext and returns a code string * * The loader is resilient: if one seed file fails to load or validate, * it logs the error and continues with the remaining files. * * @param seedsDir - Directory containing seed files - * @param registry - OpenAPI endpoint registry (for schema validation) + * @param registry - OpenAPI endpoint registry (for validation) * @param logger - Vite logger - * @returns Promise resolving to seed map + * @returns Promise resolving to SeedLoadResult * * @example * ```typescript - * const seeds = await loadSeeds('./mock/seeds', registry, logger); + * const result = await loadSeeds('./mock/seeds', registry, logger); + * + * // Access loaded seeds + * for (const [schemaName, seedValue] of result.seeds) { + * console.log(`Seed for ${schemaName}:`, typeof seedValue); + * } * - * // Check if a seed exists for a schema - * if (seeds.has('Pet')) { - * const seedFn = seeds.get('Pet'); - * const data = await seedFn(context); + * // Check for issues + * if (result.errors.length > 0) { + * console.error('Seed loading errors:', result.errors); * } * ``` */ @@ -74,23 +77,51 @@ export async function loadSeeds( seedsDir: string, registry: OpenApiEndpointRegistry, logger: Logger, -): Promise> { - const seeds = new Map(); +): Promise { + const seeds = new Map(); + const loadedFiles: string[] = []; + const warnings: string[] = []; const errors: string[] = []; try { // Resolve to absolute path const absoluteDir = path.resolve(seedsDir); + // Check if directory exists and is actually a directory before scanning + if (!fs.existsSync(absoluteDir)) { + const msg = `No seed files found in ${seedsDir}`; + logger.warn(`[seed-loader] ${msg}`); + warnings.push(msg); + return { seeds, loadedFiles, warnings, errors }; + } + + // Verify it's a directory, not a file + try { + const stat = fs.statSync(absoluteDir); + if (!stat.isDirectory()) { + const msg = `Path ${seedsDir} exists but is not a directory`; + logger.warn(`[seed-loader] ${msg}`); + warnings.push(msg); + return { seeds, loadedFiles, warnings, errors }; + } + } catch { + const msg = `Cannot access ${seedsDir}`; + logger.warn(`[seed-loader] ${msg}`); + warnings.push(msg); + return { seeds, loadedFiles, warnings, errors }; + } + // Scan for seed files - const files = await glob('**/*.seed.{ts,js,mts,mjs}', { + const files = await fg.glob('**/*.seed.{ts,js,mts,mjs}', { cwd: absoluteDir, absolute: true, }); if (files.length === 0) { - logger.warn(`[seed-loader] No seed files found in ${seedsDir}`); - return seeds; + const msg = `No seed files found in ${seedsDir}`; + logger.warn(`[seed-loader] ${msg}`); + warnings.push(msg); + return { seeds, loadedFiles, warnings, errors }; } logger.info(`[seed-loader] Found ${files.length} seed file(s)`); @@ -98,37 +129,8 @@ export async function loadSeeds( // Load each seed file for (const filePath of files) { try { - // Dynamic import (ESM) - const fileUrl = pathToFileURL(filePath).href; - const module = await import(fileUrl); - - // Validate default export - if (!module.default || typeof module.default !== 'function') { - throw new Error(`Seed file must export a default async function`); - } - - // Extract schema name from filename - const filename = path.basename(filePath); - const baseSchemaName = extractSchemaName(filename); - - // Try to match with registry schemas (handle singular/plural) - const schemaName = findMatchingSchema(baseSchemaName, registry); - - if (!schemaName) { - logger.warn( - `[seed-loader] Seed "${baseSchemaName}" does not match any schema in OpenAPI spec`, - ); - } - - const finalSchemaName = schemaName || capitalize(baseSchemaName); - - // Add to map (warn on duplicates) - if (seeds.has(finalSchemaName)) { - logger.warn(`[seed-loader] Duplicate seed for "${finalSchemaName}", overwriting`); - } - - seeds.set(finalSchemaName, module.default as SeedCodeGenerator); - logger.info(`[seed-loader] Loaded seed: ${finalSchemaName}`); + await loadSeedFile(filePath, seeds, registry, logger, warnings); + loadedFiles.push(filePath); } catch (error) { const err = error as Error; const errorMessage = `${filePath}: ${err.message}`; @@ -137,39 +139,88 @@ export async function loadSeeds( } } - // Cross-reference with registry is done during loading (warnings logged above) - // Log summary - const successCount = seeds.size; - const errorCount = errors.length; - logger.info(`[seed-loader] Loaded ${successCount} seed(s), ${errorCount} error(s)`); + logLoadSummary('seed', seeds.size, loadedFiles.length, warnings.length, errors.length, logger); - return seeds; + return { seeds, loadedFiles, warnings, errors }; } catch (error) { const err = error as Error; - logger.error(`[seed-loader] Fatal error: ${err.message}`); - return seeds; + const fatalError = `Fatal error scanning seeds directory: ${err.message}`; + logger.error(`[seed-loader] ${fatalError}`); + errors.push(fatalError); + return { seeds, loadedFiles, warnings, errors }; } } /** - * Extract schema name from seed filename. - * - * Removes the `.seed.{ext}` suffix and returns the base name. - * - * @param filename - Seed filename (e.g., 'pets.seed.ts') - * @returns Base schema name (e.g., 'pets') - * - * @example - * ```typescript - * extractSchemaName('pets.seed.ts'); // 'pets' - * extractSchemaName('Pet.seed.mjs'); // 'Pet' - * extractSchemaName('order-items.seed.js'); // 'order-items' - * ``` + * Load a single seed file and merge its exports into the seeds map. */ -export function extractSchemaName(filename: string): string { - // Remove extension(s): .seed.ts, .seed.js, .seed.mts, .seed.mjs - return filename.replace(/\.seed\.(ts|js|mts|mjs)$/, ''); +async function loadSeedFile( + filePath: string, + seeds: Map, + registry: OpenApiEndpointRegistry, + logger: Logger, + warnings: string[], +): Promise { + // Dynamic import (ESM) + const fileUrl = pathToFileURL(filePath).href; + const module = await import(fileUrl); + + // Validate default export exists (use 'in' operator to detect property presence, not truthy check) + if (!('default' in module)) { + throw new Error('Seed file must have a default export'); + } + + // Validate default export is an object (not function, array, or primitive) + const exports = module.default as unknown; + if (!isValidExportsObject(exports)) { + throw new Error( + `Seed file ${formatInvalidExportError('object mapping schemaName to seed values', exports)}`, + ); + } + + const seedExports = exports as SeedExports; + const filename = path.basename(filePath); + + // Process each seed in the exports + for (const [schemaName, seedValue] of Object.entries(seedExports)) { + // Validate seed value type + if (!isValidValue(seedValue)) { + const msg = formatInvalidValueError(schemaName, filename, seedValue); + warnings.push(msg); + logger.warn(`[seed-loader] ${msg}`); + continue; + } + + // Find matching schema name in registry (handles case/plural variations) + const matchedSchemaName = findMatchingSchema(schemaName, registry); + if (!matchedSchemaName) { + const msg = `Seed "${schemaName}" in ${filename} does not match any schema in OpenAPI spec`; + warnings.push(msg); + logger.warn(`[seed-loader] ${msg}`); + // Continue anyway with original name - user might know what they're doing + } + + // Use matched schema name if found, otherwise fall back to original + const keyName = matchedSchemaName ?? schemaName; + + // Check for duplicates + if (seeds.has(keyName)) { + const msg = `Duplicate seed for "${keyName}" in ${filename}, overwriting previous`; + warnings.push(msg); + logger.warn(`[seed-loader] ${msg}`); + } + + // Add to seeds map using the matched registry key + seeds.set(keyName, seedValue); + if (matchedSchemaName && matchedSchemaName !== schemaName) { + logger.info( + `[seed-loader] Loaded seed: ${schemaName} → ${matchedSchemaName} (${getValueType(seedValue)})`, + ); + } else { + logger.info(`[seed-loader] Loaded seed: ${keyName} (${getValueType(seedValue)})`); + } + } } /** @@ -187,7 +238,6 @@ export function extractSchemaName(filename: string): string { * findMatchingSchema('pets', registry); // 'Pet' * findMatchingSchema('pet', registry); // 'Pet' * findMatchingSchema('Pet', registry); // 'Pet' - * findMatchingSchema('Pets', registry); // null (if only 'Pet' exists) * ``` */ export function findMatchingSchema( diff --git a/packages/vite-plugin-open-api-server/src/runner/openapi-server-runner.mts b/packages/vite-plugin-open-api-server/src/runner/openapi-server-runner.mts index 7186e2c..e8694d6 100644 --- a/packages/vite-plugin-open-api-server/src/runner/openapi-server-runner.mts +++ b/packages/vite-plugin-open-api-server/src/runner/openapi-server-runner.mts @@ -42,9 +42,13 @@ import { Hono } from 'hono'; import type { OpenAPIV3_1 } from 'openapi-types'; import { loadOpenApiSpec } from '../core/parser/index.js'; +import { enhanceDocument } from '../enhancer/index.js'; +import { loadHandlers, loadSeeds } from '../loaders/index.js'; import { printRegistryTable } from '../logging/index.js'; import { buildRegistry, serializeRegistry } from '../registry/index.js'; +import type { HandlerValue } from '../types/handlers.js'; import type { OpenApiServerMessage } from '../types/ipc-messages.js'; +import type { SeedValue } from '../types/seeds.js'; import { createRequestLogger } from './request-logger.mjs'; /** @@ -230,6 +234,64 @@ async function main(): Promise { // Print formatted registry table to console printRegistryTable(registry, consoleLogger as Parameters[1]); + // Load custom handlers if directory is configured + let handlers = new Map(); + if (config.handlersDir) { + if (config.verbose) { + console.log(`[mock-server] Loading handlers from: ${config.handlersDir}`); + } + const handlerResult = await loadHandlers( + config.handlersDir, + registry, + consoleLogger as Parameters[2], + ); + handlers = handlerResult.handlers; + + if (handlerResult.errors.length > 0) { + console.warn(`[mock-server] Handler loading had ${handlerResult.errors.length} error(s)`); + } + } + + // Load custom seeds if directory is configured + let seeds = new Map(); + if (config.seedsDir) { + if (config.verbose) { + console.log(`[mock-server] Loading seeds from: ${config.seedsDir}`); + } + const seedResult = await loadSeeds( + config.seedsDir, + registry, + consoleLogger as Parameters[2], + ); + seeds = seedResult.seeds; + + if (seedResult.errors.length > 0) { + console.warn(`[mock-server] Seed loading had ${seedResult.errors.length} error(s)`); + } + } + + // Enhance document with x-handler and x-seed extensions + let documentForMockServer = specAsOpenAPI; + if (handlers.size > 0 || seeds.size > 0) { + if (config.verbose) { + console.log( + `[mock-server] Enhancing document with ${handlers.size} handler(s) and ${seeds.size} seed(s)`, + ); + } + const enhanceResult = await enhanceDocument( + specAsOpenAPI, + handlers, + seeds, + consoleLogger as Parameters[3], + ); + documentForMockServer = enhanceResult.document; + + console.log( + `[mock-server] Document enhanced: ${enhanceResult.handlerCount} handler(s), ${enhanceResult.seedCount} seed(s)` + + (enhanceResult.overrideCount > 0 ? `, ${enhanceResult.overrideCount} override(s)` : ''), + ); + } + // Create Hono app with logging middleware const app = new Hono(); @@ -265,9 +327,10 @@ async function main(): Promise { return c.json(serialized, 200); }); - // Create Scalar mock server (returns a Hono app with all routes configured) + // Create Scalar mock server with enhanced document (returns a Hono app with all routes configured) + // The enhanced document contains x-handler and x-seed extensions that Scalar will execute const mockServer = await createMockServer({ - document: spec, + document: documentForMockServer, }); // Mount the mock server routes on our Hono app diff --git a/packages/vite-plugin-open-api-server/src/types/__tests__/types.test-d.ts b/packages/vite-plugin-open-api-server/src/types/__tests__/types.test-d.ts index e20c2ed..7fa953e 100644 --- a/packages/vite-plugin-open-api-server/src/types/__tests__/types.test-d.ts +++ b/packages/vite-plugin-open-api-server/src/types/__tests__/types.test-d.ts @@ -21,16 +21,19 @@ * @module */ +import type { OpenAPIV3_1 } from 'openapi-types'; import { describe, expectTypeOf, it } from 'vitest'; import type { ApiKeySecurityScheme, EndpointRegistryEntry, - HandlerCodeGenerator, - // Handler types - HandlerContext, + // Handler types (code-based) + HandlerCodeContext, + HandlerCodeGeneratorFn, + HandlerExports, HandlerFileExports, - HandlerResponse, + HandlerLoadResult, + HandlerValue, HttpSecurityScheme, InputPluginOptions, // Security types @@ -45,14 +48,18 @@ import type { OpenApiServerSchemaEntry, OpenIdConnectSecurityScheme, RegistryStats, + ResolvedHandlers, ResolvedPluginOptions, + ResolvedSeeds, SecurityContext, SecurityRequirement, - SeedCodeGenerator, - // Seed types - SeedContext, - SeedData, + // Seed types (code-based) + SeedCodeContext, + SeedCodeGeneratorFn, + SeedExports, SeedFileExports, + SeedLoadResult, + SeedValue, } from '../index.js'; describe('Plugin Options Types', () => { @@ -86,79 +93,107 @@ describe('Plugin Options Types', () => { }); }); -describe('Handler Types', () => { - it('HandlerContext should have all request properties', () => { - type Context = HandlerContext<{ name: string }>; +describe('Handler Types (Code-Based)', () => { + it('HandlerCodeContext should have operation context properties', () => { + type Context = HandlerCodeContext; + expectTypeOf().toBeString(); + expectTypeOf().toMatchTypeOf(); expectTypeOf().toBeString(); expectTypeOf().toBeString(); - expectTypeOf().toEqualTypeOf>(); - expectTypeOf().toEqualTypeOf>(); - expectTypeOf().toEqualTypeOf<{ name: string }>(); - expectTypeOf().toMatchTypeOf< - Record - >(); - expectTypeOf().toBeString(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf>(); }); - it('HandlerContext should have tools and utilities', () => { - type Context = HandlerContext; + it('HandlerCodeGeneratorFn should return string or Promise', () => { + expectTypeOf().toBeCallableWith({} as HandlerCodeContext); + expectTypeOf().returns.toMatchTypeOf>(); + }); + + it('HandlerValue should be string or function', () => { + // Static handler (string code) + const staticHandler: HandlerValue = `return store.list('Pet');`; + expectTypeOf(staticHandler).toMatchTypeOf(); - expectTypeOf().toHaveProperty('info'); - expectTypeOf().toMatchTypeOf>(); - expectTypeOf().toMatchTypeOf(); + // Dynamic handler (function that generates code) + const dynamicHandler: HandlerValue = (_ctx: HandlerCodeContext) => { + return `return store.get('Pet', req.params.petId);`; + }; + expectTypeOf(dynamicHandler).toMatchTypeOf(); }); - it('HandlerContext body should default to unknown', () => { - type Context = HandlerContext; - expectTypeOf().toBeUnknown(); + it('HandlerExports should be a record of operationId to HandlerValue', () => { + const exports: HandlerExports = { + getPetById: `return store.get('Pet', req.params.petId);`, + listPets: (_ctx) => `return store.list('Pet');`, + }; + expectTypeOf(exports).toMatchTypeOf(); }); - it('HandlerResponse should have status, body, and optional headers', () => { - expectTypeOf().toBeNumber(); - expectTypeOf().toBeUnknown(); - expectTypeOf().toEqualTypeOf | undefined>(); + it('HandlerFileExports should have default export of HandlerExports', () => { + expectTypeOf().toMatchTypeOf(); }); - it('HandlerCodeGenerator should be an async function returning response or null', () => { - expectTypeOf().toBeCallableWith({} as HandlerContext); - expectTypeOf().returns.toMatchTypeOf>(); + it('ResolvedHandlers should be a Map of operationId to code string', () => { + expectTypeOf().toMatchTypeOf>(); }); - it('HandlerFileExports should require default export', () => { - expectTypeOf().toMatchTypeOf(); + it('HandlerLoadResult should have handlers map and metadata', () => { + expectTypeOf().toMatchTypeOf>(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); }); }); -describe('Seed Types', () => { - it('SeedContext should have faker and registry', () => { - type Context = SeedContext; +describe('Seed Types (Code-Based)', () => { + it('SeedCodeContext should have schema context properties', () => { + type Context = SeedCodeContext; - expectTypeOf().toHaveProperty('person'); - expectTypeOf().toHaveProperty('info'); - expectTypeOf().toMatchTypeOf>(); expectTypeOf().toBeString(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf>(); + }); + + it('SeedCodeGeneratorFn should return string or Promise', () => { + expectTypeOf().toBeCallableWith({} as SeedCodeContext); + expectTypeOf().returns.toMatchTypeOf>(); }); - it('SeedContext should have optional properties', () => { - type Context = SeedContext; + it('SeedValue should be string or function', () => { + // Static seed (string code) + const staticSeed: SeedValue = `seed.count(10, () => ({ id: faker.string.uuid() }))`; + expectTypeOf(staticSeed).toMatchTypeOf(); + + // Dynamic seed (function that generates code) + const dynamicSeed: SeedValue = (_ctx: SeedCodeContext) => { + return `seed.count(15, () => ({ name: faker.animal.dog() }))`; + }; + expectTypeOf(dynamicSeed).toMatchTypeOf(); + }); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf>(); + it('SeedExports should be a record of schemaName to SeedValue', () => { + const exports: SeedExports = { + Pet: `seed.count(15, () => ({ name: faker.animal.dog() }))`, + Order: (_ctx) => `seed.count(20, () => ({ status: 'placed' }))`, + }; + expectTypeOf(exports).toMatchTypeOf(); }); - it('SeedData should be an array', () => { - expectTypeOf().toMatchTypeOf(); + it('SeedFileExports should have default export of SeedExports', () => { + expectTypeOf().toMatchTypeOf(); }); - it('SeedCodeGenerator should be an async function returning SeedData', () => { - expectTypeOf().toBeCallableWith({} as SeedContext); - expectTypeOf().returns.toMatchTypeOf>(); + it('ResolvedSeeds should be a Map of schemaName to code string', () => { + expectTypeOf().toMatchTypeOf>(); }); - it('SeedFileExports should require default export', () => { - expectTypeOf().toMatchTypeOf(); + it('SeedLoadResult should have seeds map and metadata', () => { + expectTypeOf().toMatchTypeOf>(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); }); }); @@ -281,37 +316,96 @@ describe('Registry Types', () => { }); describe('Type Inference', () => { - it('HandlerContext generic should correctly type body', () => { - interface PetBody { - name: string; - status: 'available' | 'pending' | 'sold'; - } - - type PetHandlerContext = HandlerContext; - - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toBeString(); - expectTypeOf().toEqualTypeOf< - 'available' | 'pending' | 'sold' - >(); - }); - - it('HandlerCodeGenerator generic should correctly type context body', () => { - interface CreatePetBody { - name: string; - category: { id: number; name: string }; - } - - type CreatePetHandler = HandlerCodeGenerator; - - // The handler should accept a context with typed body - const handler: CreatePetHandler = async (context) => { - // context.body should be typed as CreatePetBody - const name: string = context.body.name; - const categoryId: number = context.body.category.id; - return { status: 200, body: { name, categoryId } }; + it('HandlerValue static should be a string', () => { + const staticHandler: HandlerValue = `return store.get('Pet', req.params.petId);`; + expectTypeOf(staticHandler).toMatchTypeOf(); + }); + + it('HandlerValue dynamic should accept context and return string', () => { + const dynamicHandler: HandlerValue = ({ operation, operationId: _operationId }) => { + const has404 = operation.responses && '404' in operation.responses; + return ` + const pet = store.get('Pet', req.params.petId); + ${has404 ? "if (!pet) return res['404'];" : ''} + return pet; + `; }; - - expectTypeOf(handler).toMatchTypeOf(); + expectTypeOf(dynamicHandler).toMatchTypeOf(); + }); + + it('SeedValue static should be a string', () => { + const staticSeed: SeedValue = ` + seed.count(15, () => ({ + id: faker.number.int(), + name: faker.animal.dog() + })) + `; + expectTypeOf(staticSeed).toMatchTypeOf(); + }); + + it('SeedValue dynamic should accept context and return string', () => { + const dynamicSeed: SeedValue = ({ schemas, schemaName: _schemaName }) => { + const hasPet = 'Pet' in schemas; + return ` + seed.count(20, (index) => ({ + id: faker.number.int(), + petId: ${hasPet ? 'store.list("Pet")[index % 15]?.id' : 'faker.number.int()'} + })) + `; + }; + expectTypeOf(dynamicSeed).toMatchTypeOf(); + }); + + it('HandlerExports should accept mixed static and dynamic handlers', () => { + const handlers: HandlerExports = { + getInventory: ` + const pets = store.list('Pet'); + return pets.reduce((acc, pet) => { + acc[pet.status] = (acc[pet.status] || 0) + 1; + return acc; + }, {}); + `, + findPetsByStatus: ({ operation }) => { + const hasStatusParam = operation.parameters?.some( + (p) => 'name' in p && p.name === 'status', + ); + return hasStatusParam + ? `return store.list('Pet').filter(p => p.status === req.query.status);` + : `return store.list('Pet');`; + }, + getPetById: `return store.get('Pet', req.params.petId);`, + addPet: `return store.create('Pet', { id: faker.string.uuid(), ...req.body });`, + }; + expectTypeOf(handlers).toMatchTypeOf(); + }); + + it('SeedExports should accept mixed static and dynamic seeds', () => { + const seeds: SeedExports = { + Pet: ` + seed.count(15, () => ({ + id: faker.number.int({ min: 1, max: 10000 }), + name: faker.animal.dog(), + status: faker.helpers.arrayElement(['available', 'pending', 'sold']) + })) + `, + Category: ` + seed([ + { id: 1, name: 'Dogs' }, + { id: 2, name: 'Cats' }, + { id: 3, name: 'Birds' } + ]) + `, + Order: ({ schemas }) => { + const hasPet = 'Pet' in schemas; + return ` + seed.count(20, (index) => ({ + id: faker.number.int(), + petId: ${hasPet ? 'store.list("Pet")[index % 15]?.id' : 'faker.number.int()'}, + status: faker.helpers.arrayElement(['placed', 'approved', 'delivered']) + })) + `; + }, + }; + expectTypeOf(seeds).toMatchTypeOf(); }); }); diff --git a/packages/vite-plugin-open-api-server/src/types/handlers.ts b/packages/vite-plugin-open-api-server/src/types/handlers.ts index fb0c591..0a331c5 100644 --- a/packages/vite-plugin-open-api-server/src/types/handlers.ts +++ b/packages/vite-plugin-open-api-server/src/types/handlers.ts @@ -2,264 +2,228 @@ * Handler Type Definitions * * ## What - * This module defines the types for custom request handlers. Handlers allow - * users to override default mock server responses with custom logic for - * specific endpoints. + * This module defines the types for custom request handlers that inject + * x-handler code into OpenAPI operations for the Scalar Mock Server. * * ## How - * Handler files export an async function that receives a `HandlerContext` - * with full access to request data, the OpenAPI registry, logger, and - * security context. Handlers return a `HandlerResponse` or null to use - * the default mock behavior. + * Handler files export an object mapping operationId to JavaScript code. + * The code can be a static string or a function that generates code + * dynamically based on the operation context. * * ## Why - * Custom handlers enable realistic mock responses that go beyond static - * OpenAPI examples. With access to the registry and security context, - * handlers can implement complex business logic, validate requests, - * and return dynamic responses based on request parameters. + * The Scalar Mock Server expects x-handler extensions as JavaScript code + * strings in the OpenAPI document. This approach allows handlers to access + * Scalar's runtime context (store, faker, req, res) directly in the code. + * + * @see https://scalar.com/products/mock-server/custom-request-handler * * @module */ -import type { Logger } from 'vite'; -import type { OpenApiEndpointRegistry } from './registry.js'; -import type { SecurityContext } from './security.js'; +import type { OpenAPIV3_1 } from 'openapi-types'; /** - * Context object passed to custom handler functions. - * - * Provides access to request data, the OpenAPI registry, logger, and - * security state. The generic `TBody` parameter allows typed request - * bodies when the handler knows the expected schema. + * Context provided to dynamic handler code generators. * - * @template TBody - Type of request body (defaults to unknown) + * This context allows handler functions to generate operation-specific + * JavaScript code based on the OpenAPI specification. * * @example * ```typescript - * // Handler file: post.createPet.mjs - * export default async function handler(context: HandlerContext<{ name: string; status: string }>) { - * const { body, params, logger, security } = context; - * - * if (!security.credentials) { - * return { status: 401, body: { error: 'Unauthorized' } }; + * // Dynamic handler that generates code based on operation parameters + * const findPetsByStatus: HandlerCodeGeneratorFn = ({ operation }) => { + * const hasStatusParam = operation.parameters?.some(p => p.name === 'status'); + * + * if (hasStatusParam) { + * return ` + * const status = req.query.status || 'available'; + * return store.list('Pet').filter(p => p.status === status); + * `; * } * - * logger.info(`Creating pet: ${body.name}`); - * - * return { - * status: 201, - * body: { id: Date.now(), name: body.name, status: body.status }, - * headers: { 'X-Created-At': new Date().toISOString() }, - * }; - * } + * return `return store.list('Pet');`; + * }; * ``` */ -export interface HandlerContext { - /** - * HTTP method of the request (uppercase). - * - * @example 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' - */ - method: string; - - /** - * Request path without query string. - * - * @example '/pets/123', '/users/456/orders' - */ - path: string; - - /** - * Path parameters extracted from the URL. - * - * Keys correspond to path parameter names defined in the OpenAPI spec. - * - * @example { petId: '123', categoryId: '456' } - */ - params: Record; - - /** - * Query string parameters from the request URL. - * - * Values can be strings or arrays of strings for repeated parameters. - * - * @example { status: 'available', tags: ['dog', 'pet'] } - */ - query: Record; - +export interface HandlerCodeContext { /** - * Parsed request body. - * - * The body is automatically parsed based on the Content-Type header. - * For JSON requests, this will be the parsed JSON object. - * For form data, this will be the parsed form fields. + * The operation ID this handler is for. * - * Use the `TBody` generic parameter for typed access to the body. + * @example 'findPetsByStatus', 'getPetById', 'createPet' */ - body: TBody; - - /** - * Request headers with lowercase keys. - * - * Header values can be strings, arrays of strings (for multiple values), - * or undefined if the header is not present. - * - * @example { 'content-type': 'application/json', 'authorization': 'Bearer token123' } - */ - headers: Record; + operationId: string; /** - * Vite logger for consistent logging. + * Full OpenAPI operation object. * - * Use this logger instead of console.log to integrate with Vite's - * logging system and respect the user's verbose setting. + * Contains parameters, requestBody, responses, security, etc. + * Use this to generate context-aware handler code. */ - logger: Logger; + operation: OpenAPIV3_1.OperationObject; /** - * OpenAPI registry with read-only access to schemas, endpoints, and security. + * HTTP method for this operation (lowercase). * - * Use the registry to access schema definitions for validation, - * endpoint metadata for dynamic responses, or security scheme information. + * @example 'get', 'post', 'put', 'patch', 'delete' */ - registry: Readonly; + method: string; /** - * Security context with current authentication state. + * OpenAPI path for this operation. * - * Contains security requirements from the spec, the matched security - * scheme, extracted credentials, and validated scopes. + * @example '/pet/findByStatus', '/pet/{petId}' */ - security: SecurityContext; + path: string; /** - * Operation ID for this endpoint. - * - * Matches the operationId from the OpenAPI spec. Useful for - * logging or conditional logic based on the operation. + * Complete OpenAPI document for reference. * - * @example 'getPetById', 'createPet', 'listPets' + * Use this to access shared components, security schemes, + * or other operations. */ - operationId: string; + document: OpenAPIV3_1.Document; /** - * Seed data loaded for this endpoint. + * Available schemas from components/schemas. * - * If a seed file exists for this operation, its exported data - * will be available here for use in generating responses. - * Undefined if no seed file exists. + * Pre-extracted for convenience when generating code that + * needs to reference schema structures. */ - seeds?: Record; + schemas: Record; } /** - * Response returned by custom handler functions. + * Function signature for dynamic handler code generation. + * + * Receives operation context and returns JavaScript code as a string. + * The returned code will be injected as x-handler in the OpenAPI spec. * - * Handlers return this response object to override the default mock behavior. - * Return null to fall back to the default mock server response. + * The code has access to Scalar's runtime context: + * - `store` - In-memory data store + * - `faker` - Faker.js instance + * - `req` - Request object (body, params, query, headers) + * - `res` - Example responses by status code * * @example * ```typescript - * // Success response - * const successResponse: HandlerResponse = { - * status: 200, - * body: { id: 1, name: 'Fluffy', status: 'available' }, - * }; - * - * // Error response with custom headers - * const errorResponse: HandlerResponse = { - * status: 400, - * body: { error: 'Invalid pet ID', code: 'INVALID_ID' }, - * headers: { 'X-Error-Code': 'INVALID_ID' }, + * const getPetById: HandlerCodeGeneratorFn = ({ operation }) => { + * const has404 = '404' in (operation.responses || {}); + * + * return ` + * const pet = store.get('Pet', req.params.petId); + * ${has404 ? 'if (!pet) return res[404];' : ''} + * return pet; + * `; * }; * ``` */ -export interface HandlerResponse { - /** - * HTTP status code for the response. - * - * @example 200, 201, 400, 401, 404, 500 - */ - status: number; - - /** - * Response body. - * - * Objects will be JSON-serialized. Strings are sent as-is. - * Use null for empty responses (e.g., 204 No Content). - */ - body: unknown; - - /** - * Optional response headers. - * - * Headers are merged with default headers. Use this to add - * custom headers like cache-control, correlation IDs, etc. - */ - headers?: Record; -} +export type HandlerCodeGeneratorFn = (context: HandlerCodeContext) => string | Promise; /** - * Custom handler function signature. + * Handler value - either static code or a dynamic code generator. * - * Async function that receives a handler context and returns a response - * or null. Return null to use the default mock server response. - * - * @template TBody - Type of request body (defaults to unknown) + * - **String**: Static JavaScript code injected directly as x-handler + * - **Function**: Called with context to generate JavaScript code * * @example * ```typescript - * // Handler that returns custom response - * const getPetHandler: HandlerCodeGenerator = async (context) => { - * const { params, registry } = context; - * const pet = await findPet(params.petId); - * - * if (!pet) { - * return { status: 404, body: { error: 'Pet not found' } }; - * } - * - * return { status: 200, body: pet }; - * }; - * - * // Handler that falls back to default mock - * const listPetsHandler: HandlerCodeGenerator = async (context) => { - * if (context.query.useDefault === 'true') { - * return null; // Use mock server's default response - * } - * return { status: 200, body: [] }; + * // Static handler (simple, no context needed) + * const getInventory: HandlerValue = ` + * const pets = store.list('Pet'); + * return pets.reduce((acc, pet) => { + * acc[pet.status] = (acc[pet.status] || 0) + 1; + * return acc; + * }, {}); + * `; + * + * // Dynamic handler (generates code based on operation) + * const findPetsByStatus: HandlerValue = ({ operation }) => { + * // Generate different code based on operation config + * return `return store.list('Pet').filter(p => p.status === req.query.status);`; * }; * ``` */ -export type HandlerCodeGenerator = ( - context: HandlerContext, -) => Promise; +export type HandlerValue = string | HandlerCodeGeneratorFn; /** - * Expected exports from handler files. + * Handler file exports structure. * - * Handler files must default export an async function matching the - * `HandlerCodeGenerator` signature. Named exports are ignored. + * Handler files export an object mapping operationId to handler values. + * Each value is either a JavaScript code string or a function that + * generates code. * * @example * ```typescript - * // get.getPetById.mjs - * export default async function handler(context) { - * return { status: 200, body: { id: 1, name: 'Fluffy' } }; - * } - * - * // Or with TypeScript types - * import type { HandlerCodeGenerator } from '@websublime/vite-plugin-open-api-server'; - * - * const handler: HandlerCodeGenerator = async (context) => { - * return { status: 200, body: { id: 1, name: 'Fluffy' } }; + * // pets.handler.mjs + * export default { + * // Static: Simple code string + * getInventory: ` + * const pets = store.list('Pet'); + * return pets.reduce((acc, pet) => { + * acc[pet.status] = (acc[pet.status] || 0) + 1; + * return acc; + * }, {}); + * `, + * + * // Dynamic: Function that generates code + * findPetsByStatus: ({ operation }) => { + * const hasStatus = operation.parameters?.some(p => p.name === 'status'); + * return hasStatus + * ? `return store.list('Pet').filter(p => p.status === req.query.status);` + * : `return store.list('Pet');`; + * }, + * + * // Static: CRUD operations + * getPetById: `return store.get('Pet', req.params.petId);`, + * addPet: `return store.create('Pet', { id: faker.string.uuid(), ...req.body });`, + * updatePet: `return store.update('Pet', req.params.petId, req.body);`, + * deletePet: `store.delete('Pet', req.params.petId); return null;`, * }; - * - * export default handler; * ``` */ export interface HandlerFileExports { /** - * Default export must be a handler function. + * Default export must be an object mapping operationId to handler values. + */ + default: HandlerExports; +} + +/** + * Map of operationId to handler values. + * + * This is the expected structure of the default export from handler files. + */ +export type HandlerExports = Record; + +/** + * Result of loading and resolving handler files. + * + * After loading, all handlers are resolved to their final code strings + * for injection into the OpenAPI document. + */ +export type ResolvedHandlers = Map; + +/** + * Handler loading result with metadata. + */ +export interface HandlerLoadResult { + /** + * Map of operationId to handler value (string or function). + */ + handlers: Map; + + /** + * Files that were successfully loaded. + */ + loadedFiles: string[]; + + /** + * Warnings encountered during loading. + */ + warnings: string[]; + + /** + * Errors encountered during loading. */ - default: HandlerCodeGenerator; + errors: string[]; } diff --git a/packages/vite-plugin-open-api-server/src/types/index.ts b/packages/vite-plugin-open-api-server/src/types/index.ts index dbc1c8b..29bda3e 100644 --- a/packages/vite-plugin-open-api-server/src/types/index.ts +++ b/packages/vite-plugin-open-api-server/src/types/index.ts @@ -9,9 +9,9 @@ * ## How * Types are organized into categories: * - **Plugin Configuration**: Options for configuring the plugin - * - **Handler API**: Types for implementing custom request handlers - * - **Seed API**: Types for implementing seed data generators - * - **Security API**: Types for accessing authentication state in handlers + * - **Handler API**: Types for implementing custom request handlers (code-based) + * - **Seed API**: Types for implementing seed data generators (code-based) + * - **Security API**: Types for accessing authentication state * * ## Why * Centralized type exports provide a clean public API surface while keeping @@ -22,9 +22,10 @@ * ```typescript * import type { * OpenApiServerPluginOptions, - * HandlerContext, - * HandlerResponse, - * SeedContext, + * HandlerCodeContext, + * HandlerValue, + * SeedCodeContext, + * SeedValue, * } from '@websublime/vite-plugin-open-api-server'; * ``` * @@ -58,16 +59,26 @@ export type { /** * Types for implementing custom request handlers. * - * @see {@link HandlerContext} - * @see {@link HandlerResponse} - * @see {@link HandlerCodeGenerator} - * @see {@link HandlerFileExports} + * Handler files export an object mapping operationId to JavaScript code. + * The code can be a static string or a function that generates code + * dynamically based on the operation context. + * + * @see {@link HandlerCodeContext} - Context passed to dynamic code generators + * @see {@link HandlerCodeGeneratorFn} - Function signature for dynamic handlers + * @see {@link HandlerValue} - Either static code string or generator function + * @see {@link HandlerExports} - Map of operationId to handler values + * @see {@link HandlerFileExports} - Expected exports from handler files + * @see {@link HandlerLoadResult} - Result of loading handler files + * @see {@link ResolvedHandlers} - Resolved code strings for injection */ export type { - HandlerCodeGenerator, - HandlerContext, + HandlerCodeContext, + HandlerCodeGeneratorFn, + HandlerExports, HandlerFileExports, - HandlerResponse, + HandlerLoadResult, + HandlerValue, + ResolvedHandlers, } from './handlers.js'; // ============================================================================= @@ -77,12 +88,27 @@ export type { /** * Types for implementing seed data generators. * - * @see {@link SeedContext} - * @see {@link SeedData} - * @see {@link SeedCodeGenerator} - * @see {@link SeedFileExports} + * Seed files export an object mapping schemaName to JavaScript code. + * The code can be a static string or a function that generates code + * dynamically based on the schema context. + * + * @see {@link SeedCodeContext} - Context passed to dynamic code generators + * @see {@link SeedCodeGeneratorFn} - Function signature for dynamic seeds + * @see {@link SeedValue} - Either static code string or generator function + * @see {@link SeedExports} - Map of schemaName to seed values + * @see {@link SeedFileExports} - Expected exports from seed files + * @see {@link SeedLoadResult} - Result of loading seed files + * @see {@link ResolvedSeeds} - Resolved code strings for injection */ -export type { SeedCodeGenerator, SeedContext, SeedData, SeedFileExports } from './seeds.js'; +export type { + ResolvedSeeds, + SeedCodeContext, + SeedCodeGeneratorFn, + SeedExports, + SeedFileExports, + SeedLoadResult, + SeedValue, +} from './seeds.js'; // ============================================================================= // Security API Types (Public) @@ -112,7 +138,7 @@ export type { /** * Registry types for accessing parsed OpenAPI endpoint information. - * Exposed as read-only through HandlerContext and SeedContext. + * Exposed as read-only through HandlerCodeContext and SeedCodeContext. * * @see {@link OpenApiEndpointRegistry} * @see {@link OpenApiEndpointEntry} diff --git a/packages/vite-plugin-open-api-server/src/types/seeds.ts b/packages/vite-plugin-open-api-server/src/types/seeds.ts index e99e7e2..13b5667 100644 --- a/packages/vite-plugin-open-api-server/src/types/seeds.ts +++ b/packages/vite-plugin-open-api-server/src/types/seeds.ts @@ -2,231 +2,249 @@ * Seed Type Definitions * * ## What - * This module defines the types for seed data generators. Seeds allow - * users to provide consistent, realistic test data for mock responses - * instead of relying on auto-generated mock data. + * This module defines the types for seed data generators that inject + * x-seed code into OpenAPI schemas for the Scalar Mock Server. * * ## How - * Seed files export an async function that receives a `SeedContext` - * with access to a faker instance, logger, registry, and schema name. - * Seeds return an array of objects matching the target schema. + * Seed files export an object mapping schemaName to JavaScript code. + * The code can be a static string or a function that generates code + * dynamically based on the schema context. * * ## Why - * Custom seeds enable realistic mock data that better represents - * production scenarios. With access to faker and schema information, - * seeds can generate consistent, deterministic data that helps with - * testing and development workflows. + * The Scalar Mock Server expects x-seed extensions as JavaScript code + * strings in the OpenAPI document's schema definitions. This approach + * allows seeds to use Scalar's runtime context (seed, store, faker) + * directly in the code to populate the in-memory store. + * + * @see https://scalar.com/products/mock-server/data-seeding * * @module */ -import type { Faker } from '@faker-js/faker'; -import type { Logger } from 'vite'; -import type { OpenApiEndpointRegistry } from './registry.js'; +import type { OpenAPIV3_1 } from 'openapi-types'; /** - * Context object passed to seed generator functions. + * Context provided to dynamic seed code generators. * - * Provides access to a faker instance for generating realistic data, - * the OpenAPI registry for schema information, and a logger for - * debugging seed generation. + * This context allows seed functions to generate schema-specific + * JavaScript code based on the OpenAPI specification. * * @example * ```typescript - * // Seed file: Pet.seed.mjs - * export default async function seed(context: SeedContext) { - * const { faker, logger, schemaName } = context; - * - * logger.info(`Generating seed data for ${schemaName}`); - * - * return Array.from({ length: 10 }, (_, i) => ({ - * id: i + 1, - * name: faker.animal.petName(), - * status: faker.helpers.arrayElement(['available', 'pending', 'sold']), - * category: { - * id: faker.number.int({ min: 1, max: 5 }), - * name: faker.helpers.arrayElement(['Dogs', 'Cats', 'Birds']), - * }, - * tags: [ - * { id: 1, name: faker.word.adjective() }, - * { id: 2, name: faker.word.adjective() }, - * ], - * })); - * } + * // Dynamic seed that generates code based on schema relationships + * const Order: SeedCodeGeneratorFn = ({ schemas }) => { + * const hasPet = 'Pet' in schemas; + * + * return ` + * seed.count(20, (index) => ({ + * id: faker.number.int({ min: 1, max: 10000 }), + * ${hasPet ? 'petId: store.list("Pet")[index % 15]?.id,' : 'petId: faker.number.int(),'} + * quantity: faker.number.int({ min: 1, max: 5 }), + * status: faker.helpers.arrayElement(['placed', 'approved', 'delivered']), + * complete: faker.datatype.boolean() + * })) + * `; + * }; * ``` */ -export interface SeedContext { +export interface SeedCodeContext { /** - * Faker.js instance for generating realistic fake data. - * - * Provides access to all faker modules (person, animal, commerce, etc.) - * for generating consistent, realistic test data. + * The schema name this seed is for. * - * Note: @faker-js/faker is a peer dependency and may not be installed. - * Check for undefined before using. - * - * @see https://fakerjs.dev/ - * - * @example - * ```typescript - * const name = context.faker.person.fullName(); - * const email = context.faker.internet.email(); - * const price = context.faker.commerce.price(); - * ``` - */ - faker: Faker; - - /** - * Vite logger for logging seed generation progress. - * - * Use this logger instead of console.log to integrate with Vite's - * logging system and respect the user's verbose setting. - */ - logger: Logger; - - /** - * OpenAPI registry with read-only access to schemas. - * - * Use the registry to access schema definitions for generating - * data that matches the expected structure. - */ - registry: Readonly; - - /** - * Schema name this seed is generating data for. - * - * Corresponds to a schema name from `components.schemas` in the - * OpenAPI spec. Use this to generate schema-appropriate data. - * - * @example 'Pet', 'User', 'Order' + * @example 'Pet', 'Order', 'User', 'Category' */ schemaName: string; /** - * Operation ID this seed is associated with. - * - * Useful for generating operation-specific seed data or for - * logging purposes. + * Full OpenAPI schema object for this schema. * - * @example 'listPets', 'getPetById', 'createPet' + * Contains type, properties, required fields, etc. + * Use this to generate context-aware seed code. */ - operationId?: string; + schema: OpenAPIV3_1.SchemaObject; /** - * Number of seed items to generate (suggested). + * Complete OpenAPI document for reference. * - * This is a hint from the plugin about how many items to generate. - * Seed functions may generate more or fewer items as needed. - * - * @default 10 + * Use this to access other parts of the spec like + * paths, security schemes, or other components. */ - count?: number; + document: OpenAPIV3_1.Document; /** - * Environment variables accessible to seed functions. + * Available schemas from components/schemas. * - * Allows seeds to behave differently based on environment settings. + * Pre-extracted for convenience when generating code that + * needs to reference relationships between schemas. */ - env: Record; + schemas: Record; } /** - * Seed data returned by generator functions. + * Function signature for dynamic seed code generation. + * + * Receives schema context and returns JavaScript code as a string. + * The returned code will be injected as x-seed in the OpenAPI spec. * - * An array of objects that match the schema being seeded. - * The exact structure depends on the target schema. + * The code has access to Scalar's runtime context: + * - `seed` - Seed helper: seed(array), seed(factory), seed.count(n, factory) + * - `store` - Direct store access for relationships + * - `faker` - Faker.js instance for data generation + * - `schema` - Schema key name * * @example * ```typescript - * // Pet seed data - * const petSeeds: SeedData = [ - * { id: 1, name: 'Fluffy', status: 'available' }, - * { id: 2, name: 'Buddy', status: 'pending' }, - * { id: 3, name: 'Max', status: 'sold' }, - * ]; - * - * // User seed data - * const userSeeds: SeedData = [ - * { id: 1, username: 'john_doe', email: 'john@example.com' }, - * { id: 2, username: 'jane_doe', email: 'jane@example.com' }, - * ]; + * const Pet: SeedCodeGeneratorFn = ({ schema }) => { + * const hasStatus = schema.properties?.status; + * + * return ` + * seed.count(15, () => ({ + * id: faker.number.int({ min: 1, max: 10000 }), + * name: faker.animal.dog(), + * ${hasStatus ? "status: faker.helpers.arrayElement(['available', 'pending', 'sold'])," : ''} + * photoUrls: [faker.image.url()], + * })) + * `; + * }; * ``` */ -export type SeedData = unknown[]; +export type SeedCodeGeneratorFn = (context: SeedCodeContext) => string | Promise; /** - * Seed generator function signature. + * Seed value - either static code or a dynamic code generator. * - * Async function that receives a seed context and returns an array - * of seed objects matching the target schema. + * - **String**: Static JavaScript code injected directly as x-seed + * - **Function**: Called with context to generate JavaScript code * * @example * ```typescript - * // Basic seed generator - * const petSeedGenerator: SeedCodeGenerator = async (context) => { - * const { faker, count = 10 } = context; - * - * return Array.from({ length: count }, (_, i) => ({ - * id: i + 1, - * name: faker.animal.petName(), + * // Static seed (simple, no context needed) + * const Pet: SeedValue = ` + * seed.count(15, () => ({ + * id: faker.number.int({ min: 1, max: 10000 }), + * name: faker.animal.dog(), * status: faker.helpers.arrayElement(['available', 'pending', 'sold']), - * })); - * }; - * - * // Seed generator using schema information - * const dynamicSeedGenerator: SeedCodeGenerator = async (context) => { - * const { faker, registry, schemaName } = context; - * const schema = registry.schemas.get(schemaName); - * - * if (!schema) { - * return []; - * } - * - * // Generate data based on schema properties - * return generateFromSchema(faker, schema.schema); + * category: { + * id: faker.number.int({ min: 1, max: 5 }), + * name: faker.helpers.arrayElement(['Dogs', 'Cats', 'Birds']) + * }, + * photoUrls: [faker.image.url()], + * tags: [{ id: faker.number.int({ min: 1, max: 100 }), name: faker.word.adjective() }] + * })) + * `; + * + * // Dynamic seed (generates code based on schema) + * const Order: SeedValue = ({ schemas }) => { + * const hasPet = 'Pet' in schemas; + * return ` + * seed.count(20, (index) => ({ + * id: faker.number.int(), + * petId: ${hasPet ? 'store.list("Pet")[index % 15]?.id' : 'faker.number.int()'}, + * status: faker.helpers.arrayElement(['placed', 'approved', 'delivered']) + * })) + * `; * }; * ``` */ -export type SeedCodeGenerator = (context: SeedContext) => Promise; +export type SeedValue = string | SeedCodeGeneratorFn; /** - * Expected exports from seed files. + * Seed file exports structure. * - * Seed files must default export an async function matching the - * `SeedCodeGenerator` signature. Named exports are ignored. + * Seed files export an object mapping schemaName to seed values. + * Each value is either a JavaScript code string or a function that + * generates code. * * @example * ```typescript - * // Pet.seed.mjs - * export default async function seed(context) { - * const { faker } = context; - * - * return Array.from({ length: 10 }, (_, i) => ({ - * id: i + 1, - * name: faker.animal.petName(), - * status: faker.helpers.arrayElement(['available', 'pending', 'sold']), - * })); - * } - * - * // Or with TypeScript types - * import type { SeedCodeGenerator } from '@websublime/vite-plugin-open-api-server'; - * - * const seed: SeedCodeGenerator = async (context) => { - * const { faker } = context; - * - * return Array.from({ length: 10 }, (_, i) => ({ - * id: i + 1, - * name: faker.animal.petName(), - * status: faker.helpers.arrayElement(['available', 'pending', 'sold']), - * })); + * // pets.seed.mjs + * export default { + * // Static: Simple code string for Pet schema + * Pet: ` + * seed.count(15, () => ({ + * id: faker.number.int({ min: 1, max: 10000 }), + * name: faker.animal.dog(), + * status: faker.helpers.arrayElement(['available', 'pending', 'sold']), + * category: { + * id: faker.number.int({ min: 1, max: 5 }), + * name: faker.helpers.arrayElement(['Dogs', 'Cats', 'Birds']) + * }, + * photoUrls: [faker.image.url()], + * tags: [{ id: faker.number.int({ min: 1, max: 100 }), name: faker.word.adjective() }] + * })) + * `, + * + * // Static: Category seed + * Category: ` + * seed([ + * { id: 1, name: 'Dogs' }, + * { id: 2, name: 'Cats' }, + * { id: 3, name: 'Birds' }, + * { id: 4, name: 'Fish' }, + * { id: 5, name: 'Reptiles' } + * ]) + * `, + * + * // Dynamic: Function that generates code based on available schemas + * Order: ({ schemas }) => { + * const hasPet = 'Pet' in schemas; + * return ` + * seed.count(20, (index) => ({ + * id: faker.number.int({ min: 1, max: 10000 }), + * petId: ${hasPet ? 'store.list("Pet")[index % 15]?.id' : 'faker.number.int()'}, + * quantity: faker.number.int({ min: 1, max: 5 }), + * shipDate: faker.date.future().toISOString(), + * status: faker.helpers.arrayElement(['placed', 'approved', 'delivered']), + * complete: faker.datatype.boolean() + * })) + * `; + * }, * }; - * - * export default seed; * ``` */ export interface SeedFileExports { /** - * Default export must be a seed generator function. + * Default export must be an object mapping schemaName to seed values. + */ + default: SeedExports; +} + +/** + * Map of schemaName to seed values. + * + * This is the expected structure of the default export from seed files. + */ +export type SeedExports = Record; + +/** + * Result of loading and resolving seed files. + * + * After loading, all seeds are resolved to their final code strings + * for injection into the OpenAPI document. + */ +export type ResolvedSeeds = Map; + +/** + * Seed loading result with metadata. + */ +export interface SeedLoadResult { + /** + * Map of schemaName to seed value (string or function). + */ + seeds: Map; + + /** + * Files that were successfully loaded. + */ + loadedFiles: string[]; + + /** + * Warnings encountered during loading. + */ + warnings: string[]; + + /** + * Errors encountered during loading. */ - default: SeedCodeGenerator; + errors: string[]; } diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/add-pet.handler.ts b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/add-pet.handler.ts deleted file mode 100644 index 1493df6..0000000 --- a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/add-pet.handler.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Custom Handler for POST /pet (addPet operation) - * - * ## What - * This handler intercepts POST requests to the `/pet` endpoint, allowing custom logic - * to be executed instead of (or before) the default mock server response. - * - * ## How - * When the mock server receives a POST /pet request, it checks for a matching handler. - * If this handler exports a default async function, it will be invoked with a - * `HandlerContext` containing request details, operation metadata, and utility functions. - * - * ## Why - * Custom handlers enable: - * - Database integration for persistent pet storage - * - Request validation beyond OpenAPI schema validation - * - Custom response generation based on business logic - * - Integration with external services (e.g., notification systems) - * - Error simulation for frontend testing - * - * ## Error Simulation - * This handler supports error simulation via query parameters: - * - `simulateError=400` - Returns validation error response - * - `simulateError=500` - Returns server error response - * - `delay=` - Delays response by specified milliseconds - * - * @module handlers/add-pet - * @see {@link https://github.com/websublime/vite-open-api-server} Plugin documentation - * - * @example - * ```typescript - * // Test validation error handling - * fetch('/api/v3/pet?simulateError=400', { method: 'POST', body: JSON.stringify(pet) }) - * - * // Test server error with delay - * fetch('/api/v3/pet?simulateError=500&delay=2000', { method: 'POST', body: JSON.stringify(pet) }) - * ``` - */ - -import type { HandlerContext, HandlerResponse } from '@websublime/vite-plugin-open-api-server'; - -/** - * Maximum allowed delay in milliseconds. - * Prevents hung requests from unreasonably long delays. - */ -const MAX_DELAY_MS = 10000; - -/** - * Parses a query parameter value to a number. - * Handles both string and string[] types safely. - * - * @param value - Query parameter value (string or string[]) - * @returns Parsed number or NaN if invalid - */ -function parseQueryNumber(value: string | string[] | undefined): number { - if (value === undefined) { - return Number.NaN; - } - const stringValue = Array.isArray(value) ? value[0] : value; - return parseInt(stringValue, 10); -} - -/** - * Handler for the addPet operation with error simulation support. - * - * Supports query parameters for simulating error conditions: - * - `simulateError=400` - Validation error (missing required fields) - * - `simulateError=500` - Internal server error - * - `delay=` - Response delay in milliseconds - * - * @param context - The handler context containing request information and utilities - * @returns Error response for simulation, or null to use default mock behavior - * - * @example - * ```typescript - * // Simulate validation error - * POST /api/v3/pet?simulateError=400 - * - * // Simulate server error with 2 second delay - * POST /api/v3/pet?simulateError=500&delay=2000 - * ``` - */ -export default async function handler(context: HandlerContext): Promise { - const { query, logger, operationId } = context; - - // Simulate network delay - const delayMs = parseQueryNumber(query.delay); - if (!Number.isNaN(delayMs) && delayMs > 0) { - const actualDelay = Math.min(delayMs, MAX_DELAY_MS); - logger.info(`[${operationId}] Simulating ${actualDelay}ms delay`); - await new Promise((resolve) => setTimeout(resolve, actualDelay)); - } - - // Simulate error response - const errorCode = parseQueryNumber(query.simulateError); - if (!Number.isNaN(errorCode)) { - switch (errorCode) { - case 400: - logger.info(`[${operationId}] Simulating 400 validation error`); - return { - status: 400, - body: { - error: 'Bad Request', - message: 'Invalid pet data: name is required and must be a non-empty string', - code: 'VALIDATION_ERROR', - details: [ - { field: 'name', message: 'Name is required' }, - { field: 'photoUrls', message: 'At least one photo URL is required' }, - ], - }, - }; - - case 500: - logger.info(`[${operationId}] Simulating 500 server error`); - return { - status: 500, - body: { - error: 'Internal Server Error', - message: 'Failed to save pet: database connection error', - code: 'DATABASE_ERROR', - }, - }; - - default: - logger.warn(`[${operationId}] Unknown error code: ${errorCode}`); - return { - status: 400, - body: { - error: 'Bad Request', - message: `Unknown error code: ${errorCode}. Supported codes for addPet: 400, 500`, - code: 'UNKNOWN_ERROR_CODE', - }, - }; - } - } - - // No simulation requested, use default mock response - return null; -} diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/delete-pet.handler.ts b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/delete-pet.handler.ts deleted file mode 100644 index 7c72f91..0000000 --- a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/delete-pet.handler.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Custom Handler for DELETE /pet/{petId} (deletePet operation) - * - * ## What - * This handler intercepts DELETE requests to the `/pet/{petId}` endpoint, allowing custom - * logic to be executed instead of (or before) the default mock server response. - * - * ## How - * When the mock server receives a DELETE /pet/{petId} request, it checks for a matching handler. - * If this handler exports a default async function, it will be invoked with a - * `HandlerContext` containing request details, path parameters, security context, and utility functions. - * - * ## Why - * Custom handlers enable: - * - Database deletions for specific pets by ID - * - Soft delete implementations (marking as inactive instead of removing) - * - Cascade deletion of related resources (images, orders) - * - Authorization checks before allowing deletion - * - * ## Security - * This handler demonstrates how to access the SecurityContext to check authentication. - * The deletePet operation requires petstore_auth OAuth2 authentication according to the spec. - * - * @module handlers/delete-pet - * @see {@link https://github.com/websublime/vite-open-api-server} Plugin documentation - * - * @example - * ```bash - * # Without authentication (returns 401) - * curl -X DELETE http://localhost:3456/pet/1 - * - * # With Bearer token (returns 200) - * curl -X DELETE -H "Authorization: Bearer my-token" http://localhost:3456/pet/1 - * - * # With API key (also works if spec allows) - * curl -X DELETE -H "api_key: my-key" http://localhost:3456/pet/1 - * ``` - */ - -import type { - HandlerContext, - HandlerResponse, - SecurityContext, -} from '@websublime/vite-plugin-open-api-server'; - -/** - * Logger interface for typing purposes. - */ -interface Logger { - info: (message: string) => void; -} - -/** - * Logs security requirements for the operation. - */ -function logSecurityRequirements( - security: SecurityContext, - operationId: string, - logger: Logger, -): void { - if (security.requirements.length === 0) { - return; - } - - logger.info(`[${operationId}] Security requirements: ${security.requirements.length} scheme(s)`); - - for (const req of security.requirements) { - const scopeInfo = req.scopes.length > 0 ? ` with scopes: ${req.scopes.join(', ')}` : ''; - logger.info(`[${operationId}] - ${req.schemeName}${scopeInfo}`); - } -} - -/** - * Logs the security scheme type and details. - */ -function logSecurityScheme(security: SecurityContext, operationId: string, logger: Logger): void { - if (!security.scheme) { - return; - } - - const { scheme } = security; - - if (scheme.type === 'apiKey') { - logger.info(`[${operationId}] API Key authentication via ${scheme.in}: ${scheme.name}`); - } else if (scheme.type === 'http') { - logger.info(`[${operationId}] HTTP ${scheme.scheme} authentication`); - } else if (scheme.type === 'oauth2') { - logger.info(`[${operationId}] OAuth2 authentication`); - if (security.scopes.length > 0) { - logger.info(`[${operationId}] Scopes: ${security.scopes.join(', ')}`); - } - } else if (scheme.type === 'openIdConnect') { - logger.info(`[${operationId}] OpenID Connect authentication`); - } -} - -/** - * Logs credential information if present. - */ -function logCredentialsInfo(security: SecurityContext, operationId: string, logger: Logger): void { - if (security.credentials) { - logger.info( - `[${operationId}] Credentials provided (length: ${security.credentials.length} chars)`, - ); - logSecurityScheme(security, operationId, logger); - } else { - // Note: Scalar mock server already handles 401 for missing credentials - // This block would only be reached if security is optional - logger.info(`[${operationId}] No credentials provided`); - } -} - -/** - * Handler for the deletePet operation demonstrating SecurityContext access. - * - * This handler shows how to: - * 1. Check if security is required for the endpoint - * 2. Access the matched security scheme (apiKey, http, oauth2, etc.) - * 3. Read the extracted credentials (token, API key, etc.) - * 4. Implement custom authorization logic based on security context - * - * @param context - The handler context containing request information, security, and utilities - * @returns Custom response or null to use default mock behavior - * - * @example - * ```typescript - * // Access security information in a handler - * const { security, logger, params } = context; - * - * // Check if security requirements exist - * if (security.requirements.length > 0) { - * logger.info(`This endpoint requires authentication`); - * } - * - * // Access the matched scheme details - * if (security.scheme?.type === 'oauth2') { - * logger.info('OAuth2 authentication used'); - * } - * ``` - */ -export default async function handler(context: HandlerContext): Promise { - const { security, logger, params, operationId } = context; - const petId = params.petId; - - // Log security context information for debugging - logger.info(`[${operationId}] Processing delete request for pet ${petId}`); - - // Log security requirements - logSecurityRequirements(security, operationId, logger); - - // Log credentials information - logCredentialsInfo(security, operationId, logger); - - // Example: Custom authorization check (commented out as demonstration) - // In a real scenario, you might check specific scopes or roles: - // - // if (security.requirements.length > 0) { - // const hasWriteScope = security.scopes.includes('write:pets'); - // if (!hasWriteScope) { - // return { - // status: 403, - // body: { - // error: 'Forbidden', - // message: 'Insufficient permissions: write:pets scope required', - // code: 'INSUFFICIENT_SCOPE', - // }, - // }; - // } - // } - - // Return null to use the default mock response - // The mock server will return a success response since auth is validated - return null; -} diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/get-pet-by-id.handler.ts b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/get-pet-by-id.handler.ts deleted file mode 100644 index c0583e1..0000000 --- a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/get-pet-by-id.handler.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Custom Handler for GET /pet/{petId} (getPetById operation) - * - * ## What - * This handler intercepts GET requests to the `/pet/{petId}` endpoint, allowing custom - * logic to be executed instead of (or before) the default mock server response. - * - * ## How - * When the mock server receives a GET /pet/{petId} request, it checks for a matching handler. - * If this handler exports a default async function, it will be invoked with a - * `HandlerContext` containing request details, path parameters, and utility functions. - * - * ## Why - * Custom handlers enable: - * - Database lookups for specific pets by ID - * - Custom 404 handling when pets are not found - * - Response transformation or enrichment - * - Access control validation per pet resource - * - Error simulation for frontend testing - * - * ## Error Simulation - * This handler supports error simulation via query parameters: - * - `simulateError=404` - Returns not found error response - * - `simulateError=401` - Returns unauthorized error response - * - `delay=` - Delays response by specified milliseconds - * - * @module handlers/get-pet-by-id - * @see {@link https://github.com/websublime/vite-open-api-server} Plugin documentation - * - * @example - * ```typescript - * // Test 404 not found error - * fetch('/api/v3/pet/999?simulateError=404') - * - * // Test unauthorized access - * fetch('/api/v3/pet/1?simulateError=401') - * - * // Test with delay - * fetch('/api/v3/pet/1?delay=2000') - * ``` - */ - -import type { HandlerContext, HandlerResponse } from '@websublime/vite-plugin-open-api-server'; - -/** - * Maximum allowed delay in milliseconds. - * Prevents hung requests from unreasonably long delays. - */ -const MAX_DELAY_MS = 10000; - -/** - * Parses a query parameter value to a number. - * Handles both string and string[] types safely. - * - * @param value - Query parameter value (string or string[]) - * @returns Parsed number or NaN if invalid - */ -function parseQueryNumber(value: string | string[] | undefined): number { - if (value === undefined) { - return Number.NaN; - } - const stringValue = Array.isArray(value) ? value[0] : value; - return parseInt(stringValue, 10); -} - -/** - * Handler for the getPetById operation with error simulation support. - * - * Supports query parameters for simulating error conditions: - * - `simulateError=404` - Pet not found - * - `simulateError=401` - Unauthorized access - * - `delay=` - Response delay in milliseconds - * - * @param context - The handler context containing request information and utilities - * @returns Error response for simulation, or null to use default mock behavior - * - * @example - * ```typescript - * // Simulate pet not found - * GET /api/v3/pet/999?simulateError=404 - * - * // Simulate unauthorized with 1 second delay - * GET /api/v3/pet/1?simulateError=401&delay=1000 - * ``` - */ -export default async function handler(context: HandlerContext): Promise { - const { query, params, logger, operationId } = context; - - // Simulate network delay - const delayMs = parseQueryNumber(query.delay); - if (!Number.isNaN(delayMs) && delayMs > 0) { - const actualDelay = Math.min(delayMs, MAX_DELAY_MS); - logger.info(`[${operationId}] Simulating ${actualDelay}ms delay`); - await new Promise((resolve) => setTimeout(resolve, actualDelay)); - } - - // Simulate error response - const errorCode = parseQueryNumber(query.simulateError); - if (!Number.isNaN(errorCode)) { - switch (errorCode) { - case 404: - logger.info(`[${operationId}] Simulating 404 not found for petId: ${params.petId}`); - return { - status: 404, - body: { - error: 'Not Found', - message: `Pet with ID ${params.petId} not found`, - code: 'PET_NOT_FOUND', - }, - }; - - case 401: - logger.info(`[${operationId}] Simulating 401 unauthorized`); - return { - status: 401, - body: { - error: 'Unauthorized', - message: 'Authentication required to access pet details', - code: 'AUTH_REQUIRED', - }, - headers: { - 'WWW-Authenticate': 'Bearer realm="petstore"', - }, - }; - - default: - logger.warn(`[${operationId}] Unknown error code: ${errorCode}`); - return { - status: 400, - body: { - error: 'Bad Request', - message: `Unknown error code: ${errorCode}. Supported codes for getPetById: 404, 401`, - code: 'UNKNOWN_ERROR_CODE', - }, - }; - } - } - - // No simulation requested, use default mock response - return null; -} diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.mjs b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.mjs new file mode 100644 index 0000000..e643f4f --- /dev/null +++ b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.mjs @@ -0,0 +1,159 @@ +/** + * Pet Handlers - Code-based handlers for Pet operations + * + * ## What + * This file exports an object mapping operationId to JavaScript code strings + * that will be injected as `x-handler` extensions into the OpenAPI spec. + * + * ## How + * Each key is an operationId from the OpenAPI spec, and each value is a + * JavaScript code string that Scalar Mock Server will execute. The code + * has access to runtime helpers: `store`, `faker`, `req`, `res`, `seed`. + * + * ## Why + * Custom handlers enable realistic mock responses that go beyond static + * OpenAPI examples, allowing CRUD operations with an in-memory store. + * + * @see https://scalar.com/products/mock-server/custom-request-handler + * @module handlers/pets + */ + +/** + * Pet operation handlers. + * + * Available Scalar runtime context: + * - `store` - In-memory data store (list, get, create, update, delete) + * - `faker` - Faker.js instance for generating fake data + * - `req` - Request object (body, params, query, headers) + * - `res` - Response helpers keyed by status code + */ +const handlers = { + /** + * GET /pet/findByStatus - Find pets by status + */ + findPetsByStatus: ` + const status = req.query.status || 'available'; + const pets = store.list('Pet'); + return pets.filter(pet => pet.status === status); + `, + + /** + * GET /pet/findByTags - Find pets by tags + */ + findPetsByTags: ` + const tags = req.query.tags || []; + const tagArray = Array.isArray(tags) ? tags : [tags]; + const pets = store.list('Pet'); + + if (tagArray.length === 0) { + return pets; + } + + return pets.filter(pet => { + if (!pet.tags || !Array.isArray(pet.tags)) return false; + return pet.tags.some(tag => tagArray.includes(tag.name)); + }); + `, + + /** + * GET /pet/{petId} - Find pet by ID + */ + getPetById: ` + const petId = parseInt(req.params.petId, 10); + const pet = store.get('Pet', petId); + + if (!pet) { + return res['404']; + } + + return pet; + `, + + /** + * POST /pet - Add a new pet to the store + */ + addPet: ` + const newPet = { + id: faker.number.int({ min: 100, max: 99999 }), + ...req.body, + status: req.body.status || 'available' + }; + + store.create('Pet', newPet); + return newPet; + `, + + /** + * PUT /pet - Update an existing pet + */ + updatePet: ` + const petData = req.body; + + if (!petData.id) { + return res['400']; + } + + const existingPet = store.get('Pet', petData.id); + + if (!existingPet) { + return res['404']; + } + + const updatedPet = store.update('Pet', petData.id, petData); + return updatedPet; + `, + + /** + * POST /pet/{petId} - Updates a pet with form data + */ + updatePetWithForm: ` + const petId = parseInt(req.params.petId, 10); + const pet = store.get('Pet', petId); + + if (!pet) { + return res['404']; + } + + const updates = {}; + if (req.query.name) updates.name = req.query.name; + if (req.query.status) updates.status = req.query.status; + + const updatedPet = store.update('Pet', petId, { ...pet, ...updates }); + return updatedPet; + `, + + /** + * DELETE /pet/{petId} - Deletes a pet + */ + deletePet: ` + const petId = parseInt(req.params.petId, 10); + const pet = store.get('Pet', petId); + + if (!pet) { + return res['404']; + } + + store.delete('Pet', petId); + return res['200']; + `, + + /** + * POST /pet/{petId}/uploadImage - Uploads an image + */ + uploadFile: ` + const petId = parseInt(req.params.petId, 10); + const pet = store.get('Pet', petId); + + if (!pet) { + return res['404']; + } + + return { + code: 200, + type: 'application/json', + message: 'Image uploaded successfully for pet ' + petId + }; + `, +}; + +export default handlers; diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.mjs b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.mjs new file mode 100644 index 0000000..4a01c93 --- /dev/null +++ b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.mjs @@ -0,0 +1,100 @@ +/** + * Store Handlers - Code-based handlers for Store operations + * + * ## What + * This file exports an object mapping operationId to JavaScript code strings + * that will be injected as `x-handler` extensions into the OpenAPI spec. + * + * ## How + * Each key is an operationId from the OpenAPI spec, and each value is a + * JavaScript code string that Scalar Mock Server will execute. The code + * has access to runtime helpers: `store`, `faker`, `req`, `res`, `seed`. + * + * ## Why + * Custom handlers enable realistic mock responses for store/order operations, + * allowing order management with an in-memory store. + * + * @see https://scalar.com/products/mock-server/custom-request-handler + * @module handlers/store + */ + +/** + * Store operation handlers. + * + * Available Scalar runtime context: + * - `store` - In-memory data store (list, get, create, update, delete) + * - `faker` - Faker.js instance for generating fake data + * - `req` - Request object (body, params, query, headers) + * - `res` - Response helpers keyed by status code + */ +const handlers = { + /** + * GET /store/inventory - Returns pet inventories by status + */ + getInventory: ` + const pets = store.list('Pet'); + const inventory = { + available: 0, + pending: 0, + sold: 0 + }; + + for (const pet of pets) { + if (pet.status && inventory.hasOwnProperty(pet.status)) { + inventory[pet.status]++; + } + } + + return inventory; + `, + + /** + * POST /store/order - Place an order for a pet + */ + placeOrder: ` + const orderData = req.body; + + const newOrder = { + id: faker.number.int({ min: 1, max: 99999 }), + petId: orderData.petId || faker.number.int({ min: 1, max: 100 }), + quantity: orderData.quantity || 1, + shipDate: orderData.shipDate || new Date().toISOString(), + status: orderData.status || 'placed', + complete: orderData.complete || false + }; + + store.create('Order', newOrder); + return newOrder; + `, + + /** + * GET /store/order/{orderId} - Find purchase order by ID + */ + getOrderById: ` + const orderId = parseInt(req.params.orderId, 10); + const order = store.get('Order', orderId); + + if (!order) { + return res['404']; + } + + return order; + `, + + /** + * DELETE /store/order/{orderId} - Delete purchase order by ID + */ + deleteOrder: ` + const orderId = parseInt(req.params.orderId, 10); + const order = store.get('Order', orderId); + + if (!order) { + return res['404']; + } + + store.delete('Order', orderId); + return res['200']; + `, +}; + +export default handlers; diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/update-pet.handler.ts b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/update-pet.handler.ts deleted file mode 100644 index 54b6491..0000000 --- a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/update-pet.handler.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Custom Handler for PUT /pet (updatePet operation) - * - * ## What - * This handler intercepts PUT requests to the `/pet` endpoint, allowing custom logic - * to be executed instead of (or before) the default mock server response. - * - * ## How - * When the mock server receives a PUT /pet request, it checks for a matching handler. - * If this handler exports a default async function, it will be invoked with a - * `HandlerContext` containing request details, operation metadata, and utility functions. - * - * ## Why - * Custom handlers enable: - * - Database updates for existing pet records - * - Optimistic concurrency control with version checks - * - Partial update validation beyond OpenAPI schema validation - * - Audit logging for pet modifications - * - * @module handlers/update-pet - * @see {@link https://github.com/websublime/vite-open-api-server} Plugin documentation - * - * @example - * ```typescript - * // Example implementation (Phase 2) - * export default async function handler(context: HandlerContext) { - * const petData = context.body; - * const existingPet = await database.pets.findById(petData.id); - * - * if (!existingPet) { - * return { - * status: 404, - * body: { message: 'Pet not found' }, - * }; - * } - * - * const updatedPet = await database.pets.update(petData.id, petData); - * return { - * status: 200, - * body: updatedPet, - * }; - * } - * ``` - */ - -import type { HandlerContext } from '@websublime/vite-plugin-open-api-server'; - -/** - * Placeholder handler for the updatePet operation. - * - * Currently returns `null` to indicate that the default mock server behavior - * should be used. This handler will be implemented in Phase 2 (P2-01: Handler Loader). - * - * @param _context - The handler context containing request information and utilities - * @returns `null` to use default mock behavior, or a custom response object - * - * @remarks - * Implementation planned for Phase 2: - * - Validate pet ID exists in request body - * - Check if pet exists in mock database - * - Update pet record with new data - * - Return updated pet with 200 status - */ -export default async function handler(_context: HandlerContext): Promise { - // TODO: Implement custom handler logic in Phase 2 - // Returning null delegates to the default mock server response - return null; -} diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.mjs b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.mjs new file mode 100644 index 0000000..cce8982 --- /dev/null +++ b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.mjs @@ -0,0 +1,151 @@ +/** + * User Handlers - Code-based handlers for User operations + * + * ## What + * This file exports an object mapping operationId to JavaScript code strings + * that will be injected as `x-handler` extensions into the OpenAPI spec. + * + * ## How + * Each key is an operationId from the OpenAPI spec, and each value is a + * JavaScript code string that Scalar Mock Server will execute. The code + * has access to runtime helpers: `store`, `faker`, `req`, `res`, `seed`. + * + * ## Why + * Custom handlers enable realistic mock responses for user operations, + * allowing user management with an in-memory store. + * + * @see https://scalar.com/products/mock-server/custom-request-handler + * @module handlers/users + */ + +/** + * User operation handlers. + * + * Available Scalar runtime context: + * - `store` - In-memory data store (list, get, create, update, delete) + * - `faker` - Faker.js instance for generating fake data + * - `req` - Request object (body, params, query, headers) + * - `res` - Response helpers keyed by status code + */ +const handlers = { + /** + * POST /user - Create user + */ + createUser: ` + const userData = req.body; + + const newUser = { + id: faker.number.int({ min: 1, max: 99999 }), + username: userData.username || faker.internet.username(), + firstName: userData.firstName || faker.person.firstName(), + lastName: userData.lastName || faker.person.lastName(), + email: userData.email || faker.internet.email(), + password: userData.password || faker.internet.password(), + phone: userData.phone || faker.phone.number(), + userStatus: userData.userStatus || 1 + }; + + store.create('User', newUser); + return newUser; + `, + + /** + * POST /user/createWithList - Creates list of users with given input array + */ + createUsersWithListInput: ` + const users = req.body || []; + const createdUsers = []; + + for (const userData of users) { + const newUser = { + id: faker.number.int({ min: 1, max: 99999 }), + ...userData + }; + store.create('User', newUser); + createdUsers.push(newUser); + } + + return createdUsers.length > 0 ? createdUsers[createdUsers.length - 1] : null; + `, + + /** + * GET /user/login - Logs user into the system + */ + loginUser: ` + const username = req.query.username; + const password = req.query.password; + + if (!username || !password) { + return res['400']; + } + + const users = store.list('User'); + const user = users.find(u => u.username === username); + + if (!user) { + return res['400']; + } + + // Generate session token + const token = 'session-' + faker.string.alphanumeric(32); + + return token; + `, + + /** + * GET /user/logout - Logs out current logged in user session + */ + logoutUser: ` + return res['200']; + `, + + /** + * GET /user/{username} - Get user by username + */ + getUserByName: ` + const username = req.params.username; + const users = store.list('User'); + const user = users.find(u => u.username === username); + + if (!user) { + return res['404']; + } + + return user; + `, + + /** + * PUT /user/{username} - Update user + */ + updateUser: ` + const username = req.params.username; + const userData = req.body; + const users = store.list('User'); + const user = users.find(u => u.username === username); + + if (!user) { + return res['404']; + } + + const updatedUser = store.update('User', user.id, { ...user, ...userData }); + return updatedUser; + `, + + /** + * DELETE /user/{username} - Delete user + */ + deleteUser: ` + const username = req.params.username; + const users = store.list('User'); + const user = users.find(u => u.username === username); + + if (!user) { + return res['404']; + } + + store.delete('User', user.id); + return res['200']; + `, +}; + +export default handlers; diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/seeds/orders.seed.mjs b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/orders.seed.mjs new file mode 100644 index 0000000..ebea2e0 --- /dev/null +++ b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/orders.seed.mjs @@ -0,0 +1,45 @@ +/** + * Order Seeds - Code-based seeds for Order schema + * + * ## What + * This file exports an object mapping schemaName to JavaScript code strings + * that will be injected as `x-seed` extensions into the OpenAPI spec. + * + * ## How + * Each key is a schema name from the OpenAPI spec (components.schemas), and + * each value is a JavaScript code string that Scalar Mock Server will execute + * to populate the in-memory store with initial data. + * + * ## Why + * Custom seeds enable realistic mock data for order-related endpoints, + * allowing order workflow testing (placed → approved → delivered). + * + * @see https://scalar.com/products/mock-server/data-seeding + * @module seeds/orders + */ + +/** + * Order schema seeds. + * + * Available Scalar runtime context: + * - `seed` - Seeding utilities (count, etc.) + * - `faker` - Faker.js instance for generating fake data + * - `store` - In-memory data store (for referencing other seeded data) + */ +const seeds = { + /** + * Order - Generates 10 sample orders with realistic data + */ + Order: ` + seed.count(10, (index) => ({ + id: index + 1, + petId: faker.number.int({ min: 1, max: 15 }), + quantity: faker.number.int({ min: 1, max: 5 }), + shipDate: faker.date.future().toISOString(), + status: faker.helpers.arrayElement(['placed', 'approved', 'delivered']), + complete: faker.datatype.boolean() + })) + `, +}; + +export default seeds; diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/seeds/orders.seed.ts b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/orders.seed.ts deleted file mode 100644 index 5705df6..0000000 --- a/playground/petstore-app/src/apis/petstore/open-api-server/seeds/orders.seed.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Seed Data Generator for Order Entities - * - * ## What - * This seed file provides initial data for the Order schema, populating the mock server - * with sample order records when the application starts. - * - * ## How - * When the mock server initializes, it scans the seeds directory and invokes each seed - * file's default export function. The function receives a `SeedContext` with utilities - * for generating fake data and accessing schema definitions. - * - * ## Why - * Seed data enables: - * - Realistic mock responses for store/order-related endpoints - * - Order workflow testing (placed → approved → delivered) - * - Consistent order history across development sessions - * - Demonstration of e-commerce functionality - * - * @module seeds/orders - * @see {@link https://github.com/websublime/vite-open-api-server} Plugin documentation - * - * @example - * ```typescript - * // Example implementation (Phase 2) - * export default async function seed(context: SeedContext) { - * const faker = context.faker; - * - * return Array.from({ length: 10 }, (_, index) => ({ - * id: index + 1, - * petId: faker.number.int({ min: 1, max: 20 }), - * quantity: faker.number.int({ min: 1, max: 5 }), - * shipDate: faker.date.future().toISOString(), - * status: faker.helpers.arrayElement(['placed', 'approved', 'delivered']), - * complete: faker.datatype.boolean(), - * })); - * } - * ``` - */ - -import type { SeedContext } from '@websublime/vite-plugin-open-api-server'; - -/** - * Placeholder seed generator for Order entities. - * - * Currently returns an empty array indicating no seed data should be generated. - * This seed will be implemented in Phase 2 (P2-02: Seed Loader). - * - * @param _context - The seed context containing faker instance and schema utilities - * @returns An empty array (no seed data), or an array of Order objects - * - * @remarks - * Implementation planned for Phase 2: - * - Generate 10-15 sample orders - * - Reference existing pet IDs from pets seed - * - Include all order statuses (placed, approved, delivered) - * - Mix of complete and incomplete orders - * - Ship dates spanning past and future - */ -export default async function seed(_context: SeedContext): Promise { - // TODO: Implement seed data generation in Phase 2 - // Returning empty array means no initial seed data - return []; -} diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/seeds/pets.seed.mjs b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/pets.seed.mjs new file mode 100644 index 0000000..0238b8d --- /dev/null +++ b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/pets.seed.mjs @@ -0,0 +1,83 @@ +/** + * Pet Seeds - Code-based seeds for Pet schema + * + * ## What + * This file exports an object mapping schemaName to JavaScript code strings + * that will be injected as `x-seed` extensions into the OpenAPI spec. + * + * ## How + * Each key is a schema name from the OpenAPI spec (components.schemas), and + * each value is a JavaScript code string that Scalar Mock Server will execute + * to populate the in-memory store with initial data. + * + * ## Why + * Custom seeds enable realistic mock data that better represents production + * scenarios, allowing frontend development with populated data stores. + * + * @see https://scalar.com/products/mock-server/data-seeding + * @module seeds/pets + */ + +/** + * Pet schema seeds. + * + * Available Scalar runtime context: + * - `seed` - Seeding utilities (count, etc.) + * - `faker` - Faker.js instance for generating fake data + * - `store` - In-memory data store (for referencing other seeded data) + */ +const seeds = { + /** + * Pet - Generates 15 sample pets with realistic data + */ + Pet: ` + seed.count(15, (index) => ({ + id: index + 1, + name: faker.animal.petName(), + category: { + id: faker.number.int({ min: 1, max: 5 }), + name: faker.helpers.arrayElement(['Dogs', 'Cats', 'Birds', 'Fish', 'Reptiles']) + }, + photoUrls: [ + faker.image.url({ width: 640, height: 480 }), + faker.image.url({ width: 640, height: 480 }) + ], + tags: faker.helpers.arrayElements( + [ + { id: 1, name: 'friendly' }, + { id: 2, name: 'playful' }, + { id: 3, name: 'trained' }, + { id: 4, name: 'vaccinated' }, + { id: 5, name: 'neutered' }, + { id: 6, name: 'young' }, + { id: 7, name: 'senior' }, + { id: 8, name: 'rescue' } + ], + { min: 1, max: 3 } + ), + status: faker.helpers.arrayElement(['available', 'pending', 'sold']) + })) + `, + + /** + * Category - Generates pet categories + */ + Category: ` + seed.count(5, (index) => ({ + id: index + 1, + name: ['Dogs', 'Cats', 'Birds', 'Fish', 'Reptiles'][index] + })) + `, + + /** + * Tag - Generates pet tags + */ + Tag: ` + seed.count(8, (index) => ({ + id: index + 1, + name: ['friendly', 'playful', 'trained', 'vaccinated', 'neutered', 'young', 'senior', 'rescue'][index] + })) + `, +}; + +export default seeds; diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/seeds/pets.seed.ts b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/pets.seed.ts deleted file mode 100644 index 8b59a38..0000000 --- a/playground/petstore-app/src/apis/petstore/open-api-server/seeds/pets.seed.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Seed Data Generator for Pet Entities - * - * ## What - * This seed file provides initial data for the Pet schema, populating the mock server - * with sample pet records when the application starts. - * - * ## How - * When the mock server initializes, it scans the seeds directory and invokes each seed - * file's default export function. The function receives a `SeedContext` with utilities - * for generating fake data and accessing schema definitions. - * - * ## Why - * Seed data enables: - * - Realistic mock responses without manual data entry - * - Consistent test data across development sessions - * - Demonstration of API capabilities with meaningful examples - * - Frontend development with populated data stores - * - * @module seeds/pets - * @see {@link https://github.com/websublime/vite-open-api-server} Plugin documentation - */ - -import type { SeedContext } from '@websublime/vite-plugin-open-api-server'; - -/** - * Pet seed data generator. - * - * Generates a collection of sample pets with realistic data for testing - * and development purposes. - * - * @param context - The seed context containing faker instance and schema utilities - * @returns An array of Pet objects - */ -export default async function seed(context: SeedContext): Promise { - const { faker } = context; - - // Categories for pets - const categories = [ - { id: 1, name: 'Dogs' }, - { id: 2, name: 'Cats' }, - { id: 3, name: 'Birds' }, - { id: 4, name: 'Fish' }, - { id: 5, name: 'Reptiles' }, - ]; - - // Sample tags - const tagOptions = [ - { id: 1, name: 'friendly' }, - { id: 2, name: 'playful' }, - { id: 3, name: 'trained' }, - { id: 4, name: 'vaccinated' }, - { id: 5, name: 'neutered' }, - { id: 6, name: 'young' }, - { id: 7, name: 'senior' }, - { id: 8, name: 'rescue' }, - ]; - - // Pet statuses - const statuses = ['available', 'pending', 'sold'] as const; - - // Generate 15 sample pets - return Array.from({ length: 15 }, (_, index) => { - const category = faker.helpers.arrayElement(categories); - const numTags = faker.number.int({ min: 1, max: 3 }); - const tags = faker.helpers.arrayElements(tagOptions, numTags); - - return { - id: index + 1, - name: faker.animal.petName(), - category: category, - photoUrls: [ - faker.image.url({ width: 640, height: 480 }), - faker.image.url({ width: 640, height: 480 }), - ], - tags: tags, - status: faker.helpers.arrayElement(statuses), - }; - }); -} diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/seeds/users.seed.mjs b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/users.seed.mjs new file mode 100644 index 0000000..e228fd5 --- /dev/null +++ b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/users.seed.mjs @@ -0,0 +1,66 @@ +/** + * User Seeds - Code-based seeds for User schema + * + * ## What + * This file exports an object mapping schemaName to JavaScript code strings + * that will be injected as `x-seed` extensions into the OpenAPI spec. + * + * ## How + * Each key is a schema name from the OpenAPI spec (components.schemas), and + * each value is a JavaScript code string that Scalar Mock Server will execute + * to populate the in-memory store with initial data. + * + * ## Why + * Custom seeds enable realistic mock data for user-related endpoints, + * allowing authentication flow testing with predefined credentials. + * + * @see https://scalar.com/products/mock-server/data-seeding + * @module seeds/users + */ + +/** + * User schema seeds. + * + * Available Scalar runtime context: + * - `seed` - Seeding utilities (count, etc.) + * - `faker` - Faker.js instance for generating fake data + * - `store` - In-memory data store (for referencing other seeded data) + */ +const seeds = { + /** + * User - Generates 10 sample users with realistic data + * + * Includes a test user with known credentials (user1/password123) + * for easy testing of authentication flows. + */ + User: ` + seed.count(10, (index) => { + // First user is a test user with known credentials + if (index === 0) { + return { + id: 1, + username: 'user1', + firstName: 'John', + lastName: 'Doe', + email: 'user1@example.com', + password: 'password123', + phone: '555-0100', + userStatus: 1 + }; + } + + return { + id: index + 1, + username: faker.internet.username(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + password: faker.internet.password(), + phone: faker.phone.number(), + userStatus: faker.helpers.arrayElement([0, 1, 2]) + }; + }) + `, +}; + +export default seeds; diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/seeds/users.seed.ts b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/users.seed.ts deleted file mode 100644 index a19d4ea..0000000 --- a/playground/petstore-app/src/apis/petstore/open-api-server/seeds/users.seed.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Seed Data Generator for User Entities - * - * ## What - * This seed file provides initial data for the User schema, populating the mock server - * with sample user records when the application starts. - * - * ## How - * When the mock server initializes, it scans the seeds directory and invokes each seed - * file's default export function. The function receives a `SeedContext` with utilities - * for generating fake data and accessing schema definitions. - * - * ## Why - * Seed data enables: - * - Realistic mock responses for user-related endpoints - * - Authentication flow testing with predefined credentials - * - Consistent test users across development sessions - * - Demonstration of user management features - * - * @module seeds/users - * @see {@link https://github.com/websublime/vite-open-api-server} Plugin documentation - * - * @example - * ```typescript - * // Example implementation (Phase 2) - * export default async function seed(context: SeedContext) { - * const faker = context.faker; - * - * return Array.from({ length: 5 }, (_, index) => ({ - * id: index + 1, - * username: faker.internet.username(), - * firstName: faker.person.firstName(), - * lastName: faker.person.lastName(), - * email: faker.internet.email(), - * password: faker.internet.password(), - * phone: faker.phone.number(), - * userStatus: faker.helpers.arrayElement([0, 1, 2]), - * })); - * } - * ``` - */ - -import type { SeedContext } from '@websublime/vite-plugin-open-api-server'; - -/** - * Placeholder seed generator for User entities. - * - * Currently returns an empty array indicating no seed data should be generated. - * This seed will be implemented in Phase 2 (P2-02: Seed Loader). - * - * @param _context - The seed context containing faker instance and schema utilities - * @returns An empty array (no seed data), or an array of User objects - * - * @remarks - * Implementation planned for Phase 2: - * - Generate 5-10 sample users - * - Use faker for realistic names and emails - * - Include test user with known credentials (user1/password) - * - Vary userStatus values across users - */ -export default async function seed(_context: SeedContext): Promise { - // TODO: Implement seed data generation in Phase 2 - // Returning empty array means no initial seed data - return []; -}