From 65ad2332f3bd3d07cdeef174ddba746ee3b0c318 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 12:17:35 +0000 Subject: [PATCH 01/19] feat(types): rewrite handler and seed types to code-based format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Changed - Replaced function-based handler/seed types with code-based types - Handlers now export objects mapping operationId → JS code string - Seeds now export objects mapping schemaName → JS code string - Both support static strings or dynamic functions that generate code ## New Types Handler Types: - HandlerCodeContext: Context for dynamic code generators - HandlerCodeGeneratorFn: Function that returns code string - HandlerValue: string | HandlerCodeGeneratorFn - HandlerExports: Record - HandlerLoadResult, ResolvedHandlers Seed Types: - SeedCodeContext: Context for dynamic code generators - SeedCodeGeneratorFn: Function that returns code string - SeedValue: string | SeedCodeGeneratorFn - SeedExports: Record - SeedLoadResult, ResolvedSeeds ## Why The Scalar Mock Server expects x-handler/x-seed as JavaScript code strings, not runtime functions. This aligns with PRD FR-004/FR-005. ## Temporary Changes (TODO) - Loaders updated with minimal changes, full rewrite pending - Document enhancer updated with minimal changes, full rewrite pending Closes: vite-open-api-server-thy.1 --- ...open-api-server-thy-integrate-loaders.json | 13 + .../vite-plugin-open-api-server/package.json | 2 +- .../src/enhancer/document-enhancer.ts | 15 +- .../vite-plugin-open-api-server/src/index.ts | 29 +- .../src/loaders/handler-loader.ts | 24 +- .../src/loaders/seed-loader.ts | 24 +- .../src/types/__tests__/types.test-d.ts | 262 ++++++++----- .../src/types/handlers.ts | 340 ++++++++--------- .../src/types/index.ts | 64 +++- .../src/types/seeds.ts | 346 +++++++++--------- 10 files changed, 630 insertions(+), 489 deletions(-) create mode 100644 .changesets/fix-vite-open-api-server-thy-integrate-loaders.json 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..eb0c205 --- /dev/null +++ b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json @@ -0,0 +1,13 @@ +{ + "branch": "fix/vite-open-api-server-thy-integrate-loaders", + "bump": "minor", + "environments": [ + "production" + ], + "packages": [ + "@websublime/vite-plugin-open-api-server" + ], + "changes": [], + "created_at": "2026-01-16T12:17:16.928160Z", + "updated_at": "2026-01-16T12:17:16.928825Z" +} \ 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/document-enhancer.ts b/packages/vite-plugin-open-api-server/src/enhancer/document-enhancer.ts index 838b031..5f2133d 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 @@ -24,8 +24,11 @@ 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'; +// TODO: Full rewrite in subtask vite-open-api-server-thy.4 +// Currently using HandlerValue/SeedValue but the enhancer logic needs to be rewritten +// to resolve code strings from values before injection +import type { HandlerValue } from '../types/handlers.js'; +import type { SeedValue } from '../types/seeds.js'; /** * HTTP methods supported by OpenAPI operations. @@ -115,8 +118,8 @@ interface InjectionResult { */ export function enhanceDocument( spec: OpenAPIV3_1.Document, - handlers: Map, - seeds: Map, + handlers: Map, + seeds: Map, logger: Logger, ): EnhanceDocumentResult { // Deep clone spec to preserve original @@ -146,7 +149,7 @@ export function enhanceDocument( */ function injectHandlers( spec: OpenAPIV3_1.Document, - handlers: Map, + handlers: Map, logger: Logger, ): InjectionResult { let count = 0; @@ -190,7 +193,7 @@ function injectHandlers( */ function injectSeeds( spec: OpenAPIV3_1.Document, - seeds: Map, + seeds: Map, logger: Logger, ): InjectionResult { let count = 0; diff --git a/packages/vite-plugin-open-api-server/src/index.ts b/packages/vite-plugin-open-api-server/src/index.ts index 1f3fe52..a7944f4 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, + ResolvedHandlers, + // Seed types (code-based) + ResolvedSeeds, + SeedCodeContext, + SeedCodeGeneratorFn, + SeedExports, + SeedFileExports, + SeedLoadResult, + SeedValue, + // Security types NormalizedSecurityScheme, - OpenApiEndpointRegistry, - OpenApiServerPluginOptions, SecurityContext, SecurityRequirement, - SeedCodeGenerator, - SeedContext, - SeedData, - SeedFileExports, + // Registry types + OpenApiEndpointRegistry, + // Plugin options + OpenApiServerPluginOptions, } from './types/index.js'; 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..82c1e0e 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 @@ -25,7 +25,10 @@ import { pathToFileURL } from 'node:url'; import { glob } from 'fast-glob'; import type { Logger } from 'vite'; -import type { HandlerCodeGenerator } from '../types/handlers.js'; +// TODO: Full rewrite in subtask vite-open-api-server-thy.2 +// Currently using HandlerValue but the loader logic needs to be rewritten +// to support object exports instead of function exports +import type { HandlerValue } from '../types/handlers.js'; import type { OpenApiEndpointRegistry } from '../types/registry.js'; /** @@ -35,9 +38,9 @@ import type { OpenApiEndpointRegistry } from '../types/registry.js'; */ export interface LoadHandlersResult { /** - * Map of operationId to handler function. + * Map of operationId to handler value (string or function). */ - handlers: Map; + handlers: Map; /** * Errors encountered during loading (file path → error message). @@ -74,8 +77,9 @@ export async function loadHandlers( handlersDir: string, registry: OpenApiEndpointRegistry, logger: Logger, -): Promise> { - const handlers = new Map(); +): Promise> { + // TODO: Rewrite to load object exports { operationId: string | fn } instead of default function + const handlers = new Map(); const errors: string[] = []; try { @@ -102,9 +106,10 @@ export async function loadHandlers( 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`); + // TODO: Rewrite validation - should check for object export, not function + // Validate default export (temporary - accepts both old and new format) + if (!module.default) { + throw new Error(`Handler file must have a default export`); } // Extract operationId from filename @@ -116,7 +121,8 @@ export async function loadHandlers( logger.warn(`[handler-loader] Duplicate handler for "${operationId}", overwriting`); } - handlers.set(operationId, module.default as HandlerCodeGenerator); + // TODO: Handle object exports properly - for now cast to HandlerValue + handlers.set(operationId, module.default as HandlerValue); logger.info(`[handler-loader] Loaded handler: ${operationId}`); } catch (error) { const err = error as Error; 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..bff4725 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 @@ -26,7 +26,10 @@ import { glob } from 'fast-glob'; import type { Logger } from 'vite'; import type { OpenApiEndpointRegistry } from '../types/registry.js'; -import type { SeedCodeGenerator } from '../types/seeds.js'; +// TODO: Full rewrite in subtask vite-open-api-server-thy.3 +// Currently using SeedValue but the loader logic needs to be rewritten +// to support object exports instead of function exports +import type { SeedValue } from '../types/seeds.js'; /** * Result of loading seeds from a directory. @@ -35,9 +38,9 @@ import type { SeedCodeGenerator } from '../types/seeds.js'; */ export interface LoadSeedsResult { /** - * Map of schema name to seed generator function. + * Map of schema name to seed value (string or function). */ - seeds: Map; + seeds: Map; /** * Errors encountered during loading (file path → error message). @@ -74,8 +77,9 @@ export async function loadSeeds( seedsDir: string, registry: OpenApiEndpointRegistry, logger: Logger, -): Promise> { - const seeds = new Map(); +): Promise> { + // TODO: Rewrite to load object exports { schemaName: string | fn } instead of default function + const seeds = new Map(); const errors: string[] = []; try { @@ -102,9 +106,10 @@ export async function loadSeeds( 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`); + // TODO: Rewrite validation - should check for object export, not function + // Validate default export (temporary - accepts both old and new format) + if (!module.default) { + throw new Error(`Seed file must have a default export`); } // Extract schema name from filename @@ -127,7 +132,8 @@ export async function loadSeeds( logger.warn(`[seed-loader] Duplicate seed for "${finalSchemaName}", overwriting`); } - seeds.set(finalSchemaName, module.default as SeedCodeGenerator); + // TODO: Handle object exports properly - for now cast to SeedValue + seeds.set(finalSchemaName, module.default as SeedValue); logger.info(`[seed-loader] Loaded seed: ${finalSchemaName}`); } catch (error) { const err = error as Error; 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[]; } From af38aa4726904360d4fe8a3c57ed457143a08007 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 12:22:08 +0000 Subject: [PATCH 02/19] =?UTF-8?q?feat(handler-loader):=20rewrite=20to=20lo?= =?UTF-8?q?ad=20object=20exports=20with=20operationId=20=E2=86=92=20code?= =?UTF-8?q?=20mappings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Changed - Handler files now export objects: { operationId: string | function } - Static handlers are code strings injected directly as x-handler - Dynamic handlers are functions that generate code based on context - Loader validates object exports (not functions or arrays) - Returns HandlerLoadResult with handlers, loadedFiles, warnings, errors ## Implementation - loadHandlerFile(): Process each key-value pair from exports object - isValidExportsObject(): Validate default export is plain object - isValidHandlerValue(): Validate each value is string or function - checkOperationExists(): Cross-reference with registry - getHandlerType(): Return human-readable type for logging ## Updated Tests - All fixtures converted to new object export format - Tests verify static (string) and dynamic (function) handlers - Tests verify HandlerLoadResult structure Closes: vite-open-api-server-thy.2 --- .../vite-plugin-open-api-server/src/index.ts | 16 +- .../fixtures/add-new-pet.handler.mjs | 60 ++-- .../__tests__/fixtures/get-pet.handler.mjs | 21 +- .../fixtures/invalid-not-function.handler.mjs | 16 +- .../fixtures/subdirectory/get-pet.handler.mjs | 17 +- .../loaders/__tests__/handler-loader.test.ts | 236 ++++++++------- .../src/loaders/handler-loader.ts | 272 ++++++++++++------ .../src/loaders/index.ts | 3 +- 8 files changed, 395 insertions(+), 246 deletions(-) diff --git a/packages/vite-plugin-open-api-server/src/index.ts b/packages/vite-plugin-open-api-server/src/index.ts index a7944f4..9ded576 100644 --- a/packages/vite-plugin-open-api-server/src/index.ts +++ b/packages/vite-plugin-open-api-server/src/index.ts @@ -68,21 +68,21 @@ export type { HandlerFileExports, HandlerLoadResult, HandlerValue, + // Security types + NormalizedSecurityScheme, + // Registry types + OpenApiEndpointRegistry, + // Plugin options + OpenApiServerPluginOptions, ResolvedHandlers, // Seed types (code-based) ResolvedSeeds, + SecurityContext, + SecurityRequirement, SeedCodeContext, SeedCodeGeneratorFn, SeedExports, SeedFileExports, SeedLoadResult, SeedValue, - // Security types - NormalizedSecurityScheme, - SecurityContext, - SecurityRequirement, - // Registry types - OpenApiEndpointRegistry, - // Plugin options - OpenApiServerPluginOptions, } 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-not-function.handler.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/invalid-not-function.handler.mjs index 58ee66e..37dad0e 100644 --- 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 @@ -1,13 +1,9 @@ /** * Invalid handler fixture for testing. - * Default export is not a function (it's an object). + * 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 { - handler: async (_context) => { - return { - status: 200, - body: { error: 'This should not work' }, - }; - }, - name: 'invalidHandler', -}; +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/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..15d6aa9 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 { extractBaseName, kebabToCamelCase, loadHandlers } from '../handler-loader.js'; const FIXTURES_DIR = path.join(__dirname, 'fixtures'); const EMPTY_DIR = path.join(__dirname, 'fixtures-empty'); @@ -87,33 +88,25 @@ describe('Handler Loader', () => { }); }); - describe('extractOperationId', () => { - it('should extract operationId from .handler.ts file', () => { - expect(extractOperationId('get-pet.handler.ts')).toBe('getPet'); + describe('extractBaseName', () => { + it('should extract base name from .handler.ts file', () => { + expect(extractBaseName('pets.handler.ts')).toBe('pets'); }); - it('should extract operationId from .handler.js file', () => { - expect(extractOperationId('add-pet.handler.js')).toBe('addPet'); + it('should extract base name from .handler.js file', () => { + expect(extractBaseName('store.handler.js')).toBe('store'); }); - it('should extract operationId from .handler.mts file', () => { - expect(extractOperationId('list-pets.handler.mts')).toBe('listPets'); + it('should extract base name from .handler.mts file', () => { + expect(extractBaseName('users.handler.mts')).toBe('users'); }); - it('should extract operationId from .handler.mjs file', () => { - expect(extractOperationId('delete-pet.handler.mjs')).toBe('deletePet'); + it('should extract base name from .handler.mjs file', () => { + expect(extractBaseName('orders.handler.mjs')).toBe('orders'); }); - 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'); + it('should preserve kebab-case in filename', () => { + expect(extractBaseName('pet-store.handler.ts')).toBe('pet-store'); }); }); @@ -125,11 +118,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 +132,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 +205,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-not-function.handler.mjs (now exports array) 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 +295,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/handler-loader.ts b/packages/vite-plugin-open-api-server/src/loaders/handler-loader.ts index 82c1e0e..a9bb4e3 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,19 +3,21 @@ * * ## 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 */ @@ -25,34 +27,19 @@ import { pathToFileURL } from 'node:url'; import { glob } from 'fast-glob'; import type { Logger } from 'vite'; -// TODO: Full rewrite in subtask vite-open-api-server-thy.2 -// Currently using HandlerValue but the loader logic needs to be rewritten -// to support object exports instead of function exports -import type { HandlerValue } 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 value (string or function). - */ - handlers: Map; - - /** - * Errors encountered during loading (file path → error message). - */ - errors: string[]; -} - /** * 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. @@ -60,16 +47,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); + * + * // Access loaded handlers + * for (const [operationId, handlerValue] of result.handlers) { + * console.log(`Handler for ${operationId}:`, typeof handlerValue); + * } * - * // Check if a handler exists for an operation - * if (handlers.has('getPetById')) { - * const handler = handlers.get('getPetById'); - * const response = await handler(context); + * // Check for issues + * if (result.errors.length > 0) { + * console.error('Handler loading errors:', result.errors); * } * ``` */ @@ -77,13 +68,14 @@ export async function loadHandlers( handlersDir: string, registry: OpenApiEndpointRegistry, logger: Logger, -): Promise> { - // TODO: Rewrite to load object exports { operationId: string | fn } instead of default function +): 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); // Scan for handler files @@ -93,8 +85,10 @@ export async function loadHandlers( }); 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)`); @@ -102,28 +96,8 @@ export async function loadHandlers( // Load each handler file for (const filePath of files) { try { - // Dynamic import (ESM) - const fileUrl = pathToFileURL(filePath).href; - const module = await import(fileUrl); - - // TODO: Rewrite validation - should check for object export, not function - // Validate default export (temporary - accepts both old and new format) - if (!module.default) { - throw new Error(`Handler file must have a default export`); - } - - // 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`); - } - - // TODO: Handle object exports properly - for now cast to HandlerValue - handlers.set(operationId, module.default as HandlerValue); - logger.info(`[handler-loader] Loaded handler: ${operationId}`); + await loadHandlerFile(filePath, handlers, registry, logger, warnings); + loadedFiles.push(filePath); } catch (error) { const err = error as Error; const errorMessage = `${filePath}: ${err.message}`; @@ -132,53 +106,167 @@ 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)`); + logLoadSummary(handlers.size, loadedFiles.length, warnings.length, errors.length, logger); - return handlers; + 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 }; + } +} + +/** + * Load a single handler file and merge its exports into the handlers map. + */ +async function loadHandlerFile( + filePath: string, + handlers: 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 + if (!module.default) { + throw new Error('Handler file must have a default export'); + } + + // Validate default export is an object (not function, array, or primitive) + const exports = module.default; + if (!isValidExportsObject(exports)) { + throw new Error( + 'Handler file default export must be an object mapping operationId to handler values. ' + + `Got: ${typeof exports}${Array.isArray(exports) ? ' (array)' : ''}`, + ); + } + + 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 (!isValidHandlerValue(handlerValue)) { + const msg = `Invalid handler value for "${operationId}" in ${filename}: expected string or function, got ${typeof handlerValue}`; + warnings.push(msg); + logger.warn(`[handler-loader] ${msg}`); + continue; + } + + // Validate operationId exists in registry + const operationExists = checkOperationExists(operationId, registry); + if (!operationExists) { + 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} (${getHandlerType(handlerValue)})`, + ); + } +} + +/** + * Check if a value is a valid exports object (plain object, not array/function). + */ +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 handler value (string or function). + */ +function isValidHandlerValue(value: unknown): value is HandlerValue { + return typeof value === 'string' || typeof value === 'function'; +} + +/** + * Check if an operationId exists in the registry. + */ +function checkOperationExists(operationId: string, registry: OpenApiEndpointRegistry): boolean { + for (const endpoint of registry.endpoints.values()) { + if (endpoint.operationId === operationId) { + return true; + } } + return false; +} + +/** + * Get a human-readable type description for a handler value. + */ +function getHandlerType(value: HandlerValue): string { + if (typeof value === 'string') { + return `static, ${value.length} chars`; + } + return 'dynamic function'; +} + +/** + * Log the loading summary. + */ +function logLoadSummary( + handlerCount: number, + fileCount: number, + warningCount: number, + errorCount: number, + logger: Logger, +): void { + const parts = [`${handlerCount} handler(s)`, `from ${fileCount} file(s)`]; + + if (warningCount > 0) { + parts.push(`${warningCount} warning(s)`); + } + + if (errorCount > 0) { + parts.push(`${errorCount} error(s)`); + } + + logger.info(`[handler-loader] Summary: ${parts.join(', ')}`); } /** * Extract operationId from handler filename. * - * Converts kebab-case filename to camelCase operationId. + * Note: This function is no longer used for extraction since handlers + * now export objects with explicit operationId keys. Kept for potential + * future use or backward compatibility. * - * @param filename - Handler filename (e.g., 'add-pet.handler.ts') - * @returns OperationId in camelCase (e.g., 'addPet') + * @param filename - Handler filename (e.g., 'pets.handler.ts') + * @returns Base name without extension (e.g., 'pets') * * @example * ```typescript - * extractOperationId('add-pet.handler.ts'); // 'addPet' - * extractOperationId('get-pet-by-id.handler.mjs'); // 'getPetById' - * extractOperationId('listPets.handler.js'); // 'listPets' + * extractBaseName('pets.handler.ts'); // 'pets' + * extractBaseName('store-orders.handler.mjs'); // 'store-orders' * ``` */ -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)$/, ''); - - // Convert kebab-case to camelCase - return kebabToCamelCase(withoutExtension); +export function extractBaseName(filename: string): string { + return filename.replace(/\.handler\.(ts|js|mts|mjs)$/, ''); } /** 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..c809e24 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/index.ts +++ b/packages/vite-plugin-open-api-server/src/loaders/index.ts @@ -7,9 +7,8 @@ */ export { - extractOperationId, + extractBaseName, kebabToCamelCase, - type LoadHandlersResult, loadHandlers, } from './handler-loader.js'; From d8646e6007b8045a9acd794d9582f2b9403515ed Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 12:29:13 +0000 Subject: [PATCH 03/19] feat(seed-loader): rewrite to load object exports BREAKING CHANGE: Seed files must now export an object mapping schemaName to seed values instead of a single function. ## Changes - Rewrite seed-loader.ts to load object exports { schemaName: string | fn } - Return SeedLoadResult with seeds Map, loadedFiles, warnings, errors - Add validation for object exports (reject functions, arrays) - Cross-reference schemaNames with registry.schemas for validation - Keep singular/plural matching utilities for schema name lookups ## Fixtures Updated - pets.seed.mjs: Export object with Pet (static) and Category (dynamic) - Order.seed.mjs: Export object with Order (dynamic) - invalid-not-function.seed.mjs: Now exports function (invalid) - invalid-array-export.seed.mjs: New fixture for array export (invalid) - subdirectory/pets.seed.mjs: Export object for duplicate testing ## Tests - 59 tests covering: object exports, static/dynamic seeds, duplicates, registry cross-reference, error handling, utility functions Closes: vite-open-api-server-thy.3 --- .../__tests__/seed-fixtures/Order.seed.mjs | 31 +- .../invalid-array-export.seed.mjs | 13 + .../invalid-not-function.seed.mjs | 18 +- .../__tests__/seed-fixtures/pets.seed.mjs | 46 ++- .../seed-fixtures/subdirectory/pets.seed.mjs | 32 +- .../src/loaders/__tests__/seed-loader.test.ts | 207 ++++++++----- .../src/loaders/index.ts | 1 - .../src/loaders/seed-loader.ts | 282 ++++++++++++------ 8 files changed, 417 insertions(+), 213 deletions(-) create mode 100644 packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-array-export.seed.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-not-function.seed.mjs b/packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-not-function.seed.mjs index c8262d0..bee5043 100644 --- 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 @@ -1,13 +1,15 @@ /** - * Invalid Seed Fixture - Not a Function + * Invalid Seed Fixture - Function Export (Not Object) * - * This seed file exports a non-function default export. + * 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 not a function -export default { - id: 1, - name: 'Invalid Seed', - data: 'This is not a function', -}; +// 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/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..8ef928e 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 @@ -234,11 +234,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 +249,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 +263,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); + + // 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); - // Verify the seed is a function - const petSeed = seeds.get('Pet'); - expect(typeof petSeed).toBe('function'); + // 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 +300,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-not-function.seed.mjs (now exports function) + 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 +382,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 +420,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/index.ts b/packages/vite-plugin-open-api-server/src/loaders/index.ts index c809e24..41585b6 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/index.ts +++ b/packages/vite-plugin-open-api-server/src/loaders/index.ts @@ -16,7 +16,6 @@ export { capitalize, extractSchemaName, findMatchingSchema, - type LoadSeedsResult, loadSeeds, pluralize, singularize, 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 bff4725..b25c221 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,20 +2,22 @@ * 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 */ @@ -26,50 +28,39 @@ import { glob } from 'fast-glob'; import type { Logger } from 'vite'; import type { OpenApiEndpointRegistry } from '../types/registry.js'; -// TODO: Full rewrite in subtask vite-open-api-server-thy.3 -// Currently using SeedValue but the loader logic needs to be rewritten -// to support object exports instead of function exports -import type { SeedValue } from '../types/seeds.js'; +import type { SeedExports, SeedLoadResult, SeedValue } 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 value (string or function). - */ - seeds: Map; - - /** - * Errors encountered during loading (file path → error message). - */ - errors: string[]; -} - -/** - * 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); * } * ``` */ @@ -77,9 +68,10 @@ export async function loadSeeds( seedsDir: string, registry: OpenApiEndpointRegistry, logger: Logger, -): Promise> { - // TODO: Rewrite to load object exports { schemaName: string | fn } instead of default function +): Promise { const seeds = new Map(); + const loadedFiles: string[] = []; + const warnings: string[] = []; const errors: string[] = []; try { @@ -93,8 +85,10 @@ export async function loadSeeds( }); 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)`); @@ -102,39 +96,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); - - // TODO: Rewrite validation - should check for object export, not function - // Validate default export (temporary - accepts both old and new format) - if (!module.default) { - throw new Error(`Seed file must have a default export`); - } - - // 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`); - } - - // TODO: Handle object exports properly - for now cast to SeedValue - seeds.set(finalSchemaName, module.default as SeedValue); - 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}`; @@ -143,38 +106,182 @@ 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(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 }; + } +} + +/** + * Load a single seed file and merge its exports into the seeds map. + */ +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 + if (!module.default) { + 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; + if (!isValidExportsObject(exports)) { + throw new Error( + 'Seed file default export must be an object mapping schemaName to seed values. ' + + `Got: ${typeof exports}${Array.isArray(exports) ? ' (array)' : typeof exports === 'function' ? ' (function)' : ''}`, + ); + } + + 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 (!isValidSeedValue(seedValue)) { + const msg = `Invalid seed value for "${schemaName}" in ${filename}: expected string or function, got ${typeof seedValue}`; + warnings.push(msg); + logger.warn(`[seed-loader] ${msg}`); + continue; + } + + // Validate schemaName exists in registry + const schemaExists = checkSchemaExists(schemaName, registry); + if (!schemaExists) { + const msg = `Seed "${schemaName}" in ${filename} does not match any schema in OpenAPI spec`; + warnings.push(msg); + logger.warn(`[seed-loader] ${msg}`); + // Continue anyway - user might know what they're doing + } + + // Check for duplicates + if (seeds.has(schemaName)) { + const msg = `Duplicate seed for "${schemaName}" in ${filename}, overwriting previous`; + warnings.push(msg); + logger.warn(`[seed-loader] ${msg}`); + } + + // Add to seeds map + seeds.set(schemaName, seedValue); + logger.info(`[seed-loader] Loaded seed: ${schemaName} (${getSeedType(seedValue)})`); + } +} + +/** + * Check if a value is a valid exports object (plain object, not array/function). + */ +function isValidExportsObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof value !== 'function' && + // Ensure it's a plain object, not a class instance + Object.getPrototypeOf(value) === Object.prototype + ); +} + +/** + * Check if a value is a valid seed value (string or function). + */ +function isValidSeedValue(value: unknown): value is SeedValue { + return typeof value === 'string' || typeof value === 'function'; +} + +/** + * Check if a schemaName exists in the registry. + * + * Tries multiple candidates: exact match, capitalized, singular, plural forms. + */ +function checkSchemaExists(schemaName: string, registry: OpenApiEndpointRegistry): boolean { + // Direct match first + if (registry.schemas.has(schemaName)) { + return true; + } + + // Try variations + const candidates = [ + capitalize(schemaName), + singularize(schemaName), + capitalize(singularize(schemaName)), + pluralize(schemaName), + capitalize(pluralize(schemaName)), + ]; + + for (const candidate of candidates) { + if (registry.schemas.has(candidate)) { + return true; + } + } + + return false; +} + +/** + * Get a human-readable type description for a seed value. + */ +function getSeedType(value: SeedValue): string { + if (typeof value === 'string') { + return `static, ${value.length} chars`; + } + return 'dynamic function'; +} + +/** + * Log the loading summary. + */ +function logLoadSummary( + seedCount: number, + fileCount: number, + warningCount: number, + errorCount: number, + logger: Logger, +): void { + const parts = [`${seedCount} seed(s)`, `from ${fileCount} file(s)`]; + + if (warningCount > 0) { + parts.push(`${warningCount} warning(s)`); + } + + if (errorCount > 0) { + parts.push(`${errorCount} error(s)`); + } + + logger.info(`[seed-loader] Summary: ${parts.join(', ')}`); } /** * Extract schema name from seed filename. * - * Removes the `.seed.{ext}` suffix and returns the base name. + * Note: This function is no longer used for extraction since seeds + * now export objects with explicit schemaName keys. Kept for potential + * future use or backward compatibility. * * @param filename - Seed filename (e.g., 'pets.seed.ts') - * @returns Base schema name (e.g., 'pets') + * @returns Base name without extension (e.g., 'pets') * * @example * ```typescript * extractSchemaName('pets.seed.ts'); // 'pets' - * extractSchemaName('Pet.seed.mjs'); // 'Pet' - * extractSchemaName('order-items.seed.js'); // 'order-items' + * extractSchemaName('Order.seed.mjs'); // 'Order' * ``` */ 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)$/, ''); } @@ -193,7 +300,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( From 2faaa947da39ca6d471ae082fd2778e538db8252 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 12:34:53 +0000 Subject: [PATCH 04/19] feat(document-enhancer): resolve handler/seed values before injection BREAKING CHANGE: enhanceDocument() is now async and returns Promise. ## Changes - enhanceDocument() now resolves handler/seed values to code strings before injection - Static values (strings) are used directly - Dynamic values (functions) are called with context to generate code strings - Handler context includes: operationId, path, method, operation, document, schemas - Seed context includes: schemaName, schema, document, schemas - Both sync and async generator functions are supported - Error handling for failed resolution with continued processing ## Tests - 49 tests covering: static/dynamic handlers, static/dynamic seeds, context passing, async functions, error handling, mixed values Closes: vite-open-api-server-thy.4 --- .../__tests__/document-enhancer.test.ts | 558 ++++++++++++------ .../src/enhancer/document-enhancer.ts | 229 +++++-- 2 files changed, 545 insertions(+), 242 deletions(-) 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..60d3a9a 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,298 @@ 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); + 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(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(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'); + 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); + 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(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(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 +595,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 +641,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 +729,116 @@ 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(getExtension(listPetsOp!.operation, 'x-handler')).toBe('return store.list("Pet");'); + + const getPetByIdOp = findOperationById(result.document, 'getPetById'); + 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 5f2133d..3103d2c 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,11 +30,8 @@ import type { OpenAPIV3_1 } from 'openapi-types'; import type { Logger } from 'vite'; -// TODO: Full rewrite in subtask vite-open-api-server-thy.4 -// Currently using HandlerValue/SeedValue but the enhancer logic needs to be rewritten -// to resolve code strings from values before injection -import type { HandlerValue } from '../types/handlers.js'; -import type { SeedValue } 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. @@ -91,43 +94,50 @@ 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, 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); + // Inject handlers and seeds (with resolution) + const handlerResult = await injectHandlers(enhanced, handlers, logger); + const seedResult = await injectSeeds(enhanced, seeds, logger); const handlerCount = handlerResult.count; const seedCount = seedResult.count; @@ -144,18 +154,114 @@ export function enhanceDocument( }; } +/** + * 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 + * @returns Promise resolving to the code string + */ +async function resolveHandlerValue( + operationId: string, + value: HandlerValue, + spec: OpenAPIV3_1.Document, + operationInfo: OperationInfo, +): Promise { + if (typeof value === 'string') { + return value; + } + + // Extract all schemas for context + const schemas: Record = {}; + if (spec.components?.schemas) { + for (const [name, schemaOrRef] of Object.entries(spec.components.schemas)) { + if (!isReferenceObject(schemaOrRef)) { + schemas[name] = schemaOrRef; + } + } + } + + // 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 + * @returns Promise resolving to the code string + */ +async function resolveSeedValue( + schemaName: string, + value: SeedValue, + spec: OpenAPIV3_1.Document, + schema: OpenAPIV3_1.SchemaObject, +): Promise { + if (typeof value === 'string') { + return value; + } + + // Extract all schemas for context + const schemas: Record = {}; + if (spec.components?.schemas) { + for (const [name, schemaOrRef] of Object.entries(spec.components.schemas)) { + if (!isReferenceObject(schemaOrRef)) { + schemas[name] = schemaOrRef; + } + } + } + + // 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, 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) { @@ -174,15 +280,27 @@ 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); + + // 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 }; @@ -191,11 +309,11 @@ function injectHandlers( /** * Inject x-seed extensions into schemas. */ -function injectSeeds( +async function injectSeeds( spec: OpenAPIV3_1.Document, seeds: Map, logger: Logger, -): InjectionResult { +): Promise { let count = 0; let overrides = 0; @@ -203,7 +321,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) { @@ -227,10 +345,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); + + // 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 }; @@ -261,7 +394,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 From ff531c3d38181424e432c5ba160a9ee70ef7cab4 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 12:38:48 +0000 Subject: [PATCH 05/19] feat(runner): integrate loaders and document enhancer - Import loadHandlers and loadSeeds from loaders module - Import enhanceDocument from enhancer module - Load custom handlers if HANDLERS_DIR is configured - Load custom seeds if SEEDS_DIR is configured - Call enhanceDocument to inject x-handler and x-seed extensions - Pass enhanced document (with extensions) to Scalar createMockServer - Log summary of loaded handlers/seeds and enhancement stats Closes: vite-open-api-server-thy.5 --- .../src/runner/openapi-server-runner.mts | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) 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 From 30771708ee15438b1e4836c530c6ee6cc90c3572 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 12:43:14 +0000 Subject: [PATCH 06/19] feat(playground): rewrite handler/seed examples to code-based format Replace function-based handlers and seeds with code-based format that follows the PRD specification and Scalar Mock Server x-handler/x-seed conventions. ## Handlers (new format) - pets.handler.ts: All pet operations (findPetsByStatus, getPetById, addPet, etc.) - store.handler.ts: Store operations (getInventory, placeOrder, getOrderById, etc.) - users.handler.ts: User operations (createUser, loginUser, getUserByName, etc.) Each handler exports an object mapping operationId to JavaScript code strings that Scalar will execute with access to store, faker, req, and res. ## Seeds (updated format) - pets.seed.ts: Pet, Category, Tag schemas with realistic data - orders.seed.ts: Order schema with workflow states - users.seed.ts: User schema with test user (user1/password123) Each seed exports an object mapping schemaName to JavaScript code strings that use seed.count() to populate the in-memory store. Deleted (old function-based format): - add-pet.handler.ts - delete-pet.handler.ts - get-pet-by-id.handler.ts - update-pet.handler.ts Closes: vite-open-api-server-thy.6 --- .../handlers/add-pet.handler.ts | 139 -------------- .../handlers/delete-pet.handler.ts | 174 ------------------ .../handlers/get-pet-by-id.handler.ts | 141 -------------- .../open-api-server/handlers/pets.handler.ts | 161 ++++++++++++++++ .../open-api-server/handlers/store.handler.ts | 102 ++++++++++ .../handlers/update-pet.handler.ts | 68 ------- .../open-api-server/handlers/users.handler.ts | 153 +++++++++++++++ .../open-api-server/seeds/orders.seed.ts | 81 ++++---- .../open-api-server/seeds/pets.seed.ts | 125 +++++++------ .../open-api-server/seeds/users.seed.ts | 103 ++++++----- 10 files changed, 566 insertions(+), 681 deletions(-) delete mode 100644 playground/petstore-app/src/apis/petstore/open-api-server/handlers/add-pet.handler.ts delete mode 100644 playground/petstore-app/src/apis/petstore/open-api-server/handlers/delete-pet.handler.ts delete mode 100644 playground/petstore-app/src/apis/petstore/open-api-server/handlers/get-pet-by-id.handler.ts create mode 100644 playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.ts create mode 100644 playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.ts delete mode 100644 playground/petstore-app/src/apis/petstore/open-api-server/handlers/update-pet.handler.ts create mode 100644 playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.ts 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.ts b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.ts new file mode 100644 index 0000000..db7b203 --- /dev/null +++ b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.ts @@ -0,0 +1,161 @@ +/** + * 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 + */ + +import type { HandlerExports } from '@websublime/vite-plugin-open-api-server'; + +/** + * 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: HandlerExports = { + /** + * 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.ts b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.ts new file mode 100644 index 0000000..67603b5 --- /dev/null +++ b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.ts @@ -0,0 +1,102 @@ +/** + * 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 + */ + +import type { HandlerExports } from '@websublime/vite-plugin-open-api-server'; + +/** + * 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: HandlerExports = { + /** + * 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.ts b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.ts new file mode 100644 index 0000000..1f376b3 --- /dev/null +++ b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.ts @@ -0,0 +1,153 @@ +/** + * 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 + */ + +import type { HandlerExports } from '@websublime/vite-plugin-open-api-server'; + +/** + * 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: HandlerExports = { + /** + * 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.ts b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/orders.seed.ts index 5705df6..665cd08 100644 --- 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 @@ -1,64 +1,47 @@ /** - * Seed Data Generator for Order Entities + * Order Seeds - Code-based seeds for Order schema * * ## What - * This seed file provides initial data for the Order schema, populating the mock server - * with sample order records when the application starts. + * This file exports an object mapping schemaName to JavaScript code strings + * that will be injected as `x-seed` extensions into the OpenAPI spec. * * ## 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. + * 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 - * 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 + * 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 - * @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'; +import type { SeedExports } 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). + * Order schema seeds. * - * @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 + * 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) */ -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 []; -} +const seeds: SeedExports = { + /** + * 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/pets.seed.ts b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/pets.seed.ts index 8b59a38..37c8be8 100644 --- 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 @@ -1,80 +1,85 @@ /** - * Seed Data Generator for Pet Entities + * Pet Seeds - Code-based seeds for Pet schema * * ## What - * This seed file provides initial data for the Pet schema, populating the mock server - * with sample pet records when the application starts. + * This file exports an object mapping schemaName to JavaScript code strings + * that will be injected as `x-seed` extensions into the OpenAPI spec. * * ## 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. + * 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 - * 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 + * 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 - * @see {@link https://github.com/websublime/vite-open-api-server} Plugin documentation */ -import type { SeedContext } from '@websublime/vite-plugin-open-api-server'; +import type { SeedExports } from '@websublime/vite-plugin-open-api-server'; /** - * Pet seed data generator. + * Pet schema seeds. * - * 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 + * 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) */ -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 { +const seeds: SeedExports = { + /** + * Pet - Generates 15 sample pets with realistic data + */ + Pet: ` + seed.count(15, (index) => ({ id: index + 1, name: faker.animal.petName(), - category: category, + 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 }), + faker.image.url({ width: 640, height: 480 }) ], - tags: tags, - status: faker.helpers.arrayElement(statuses), - }; - }); -} + 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/users.seed.ts b/playground/petstore-app/src/apis/petstore/open-api-server/seeds/users.seed.ts index a19d4ea..21c4737 100644 --- 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 @@ -1,65 +1,68 @@ /** - * Seed Data Generator for User Entities + * User Seeds - Code-based seeds for User schema * * ## What - * This seed file provides initial data for the User schema, populating the mock server - * with sample user records when the application starts. + * This file exports an object mapping schemaName to JavaScript code strings + * that will be injected as `x-seed` extensions into the OpenAPI spec. * * ## 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. + * 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 - * 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 + * 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 - * @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'; +import type { SeedExports } 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). + * User schema seeds. * - * @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 + * 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) */ -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 []; -} +const seeds: SeedExports = { + /** + * 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; From 91148f2bde45577e312148cf101b45bcc311b9bb Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 12:48:17 +0000 Subject: [PATCH 07/19] fix(loaders): use default import for fast-glob CJS compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change from named import { glob } to default import fg from 'fast-glob' - Use fg.glob() instead of glob() to call the function - Fixes 'Named export glob not found' error when running in Node ESM mode Also convert playground handler/seed files from .ts to .mjs: - Node.js cannot import TypeScript files directly without a loader - Changed to pure JavaScript .mjs files for runtime compatibility - Removed TypeScript imports and type annotations Tested end-to-end: - Handler loading: 19 handlers from 3 files ✓ - Seed loading: 5 seeds from 3 files ✓ - x-handler/x-seed injection into OpenAPI spec ✓ - API responses with seeded data (faker-generated pets) ✓ Closes: vite-open-api-server-thy.7 --- .../vite-plugin-open-api-server/src/loaders/handler-loader.ts | 4 ++-- .../vite-plugin-open-api-server/src/loaders/seed-loader.ts | 4 ++-- .../handlers/{pets.handler.ts => pets.handler.mjs} | 4 +--- .../handlers/{store.handler.ts => store.handler.mjs} | 4 +--- .../handlers/{users.handler.ts => users.handler.mjs} | 4 +--- .../open-api-server/seeds/{orders.seed.ts => orders.seed.mjs} | 4 +--- .../open-api-server/seeds/{pets.seed.ts => pets.seed.mjs} | 4 +--- .../open-api-server/seeds/{users.seed.ts => users.seed.mjs} | 4 +--- 8 files changed, 10 insertions(+), 22 deletions(-) rename playground/petstore-app/src/apis/petstore/open-api-server/handlers/{pets.handler.ts => pets.handler.mjs} (96%) rename playground/petstore-app/src/apis/petstore/open-api-server/handlers/{store.handler.ts => store.handler.mjs} (95%) rename playground/petstore-app/src/apis/petstore/open-api-server/handlers/{users.handler.ts => users.handler.mjs} (97%) rename playground/petstore-app/src/apis/petstore/open-api-server/seeds/{orders.seed.ts => orders.seed.mjs} (92%) rename playground/petstore-app/src/apis/petstore/open-api-server/seeds/{pets.seed.ts => pets.seed.mjs} (95%) rename playground/petstore-app/src/apis/petstore/open-api-server/seeds/{users.seed.ts => users.seed.mjs} (94%) 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 a9bb4e3..d8b8f6f 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 @@ -22,9 +22,9 @@ * @module */ +import fg from 'fast-glob'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { glob } from 'fast-glob'; import type { Logger } from 'vite'; import type { HandlerExports, HandlerLoadResult, HandlerValue } from '../types/handlers.js'; @@ -79,7 +79,7 @@ export async function loadHandlers( const absoluteDir = path.resolve(handlersDir); // 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, }); 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 b25c221..5cd0f53 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 @@ -22,9 +22,9 @@ * @module */ +import fg from 'fast-glob'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { glob } from 'fast-glob'; import type { Logger } from 'vite'; import type { OpenApiEndpointRegistry } from '../types/registry.js'; @@ -79,7 +79,7 @@ export async function loadSeeds( const absoluteDir = path.resolve(seedsDir); // 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, }); diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.ts b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.mjs similarity index 96% rename from playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.ts rename to playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.mjs index db7b203..e643f4f 100644 --- a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.ts +++ b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/pets.handler.mjs @@ -18,8 +18,6 @@ * @module handlers/pets */ -import type { HandlerExports } from '@websublime/vite-plugin-open-api-server'; - /** * Pet operation handlers. * @@ -29,7 +27,7 @@ import type { HandlerExports } from '@websublime/vite-plugin-open-api-server'; * - `req` - Request object (body, params, query, headers) * - `res` - Response helpers keyed by status code */ -const handlers: HandlerExports = { +const handlers = { /** * GET /pet/findByStatus - Find pets by status */ diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.ts b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.mjs similarity index 95% rename from playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.ts rename to playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.mjs index 67603b5..4a01c93 100644 --- a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.ts +++ b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/store.handler.mjs @@ -18,8 +18,6 @@ * @module handlers/store */ -import type { HandlerExports } from '@websublime/vite-plugin-open-api-server'; - /** * Store operation handlers. * @@ -29,7 +27,7 @@ import type { HandlerExports } from '@websublime/vite-plugin-open-api-server'; * - `req` - Request object (body, params, query, headers) * - `res` - Response helpers keyed by status code */ -const handlers: HandlerExports = { +const handlers = { /** * GET /store/inventory - Returns pet inventories by status */ diff --git a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.ts b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.mjs similarity index 97% rename from playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.ts rename to playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.mjs index 1f376b3..cce8982 100644 --- a/playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.ts +++ b/playground/petstore-app/src/apis/petstore/open-api-server/handlers/users.handler.mjs @@ -18,8 +18,6 @@ * @module handlers/users */ -import type { HandlerExports } from '@websublime/vite-plugin-open-api-server'; - /** * User operation handlers. * @@ -29,7 +27,7 @@ import type { HandlerExports } from '@websublime/vite-plugin-open-api-server'; * - `req` - Request object (body, params, query, headers) * - `res` - Response helpers keyed by status code */ -const handlers: HandlerExports = { +const handlers = { /** * POST /user - Create user */ 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.mjs similarity index 92% rename from playground/petstore-app/src/apis/petstore/open-api-server/seeds/orders.seed.ts rename to playground/petstore-app/src/apis/petstore/open-api-server/seeds/orders.seed.mjs index 665cd08..ebea2e0 100644 --- 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.mjs @@ -18,8 +18,6 @@ * @module seeds/orders */ -import type { SeedExports } from '@websublime/vite-plugin-open-api-server'; - /** * Order schema seeds. * @@ -28,7 +26,7 @@ import type { SeedExports } from '@websublime/vite-plugin-open-api-server'; * - `faker` - Faker.js instance for generating fake data * - `store` - In-memory data store (for referencing other seeded data) */ -const seeds: SeedExports = { +const seeds = { /** * Order - Generates 10 sample orders with realistic data */ 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.mjs similarity index 95% rename from playground/petstore-app/src/apis/petstore/open-api-server/seeds/pets.seed.ts rename to playground/petstore-app/src/apis/petstore/open-api-server/seeds/pets.seed.mjs index 37c8be8..0238b8d 100644 --- 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.mjs @@ -18,8 +18,6 @@ * @module seeds/pets */ -import type { SeedExports } from '@websublime/vite-plugin-open-api-server'; - /** * Pet schema seeds. * @@ -28,7 +26,7 @@ import type { SeedExports } from '@websublime/vite-plugin-open-api-server'; * - `faker` - Faker.js instance for generating fake data * - `store` - In-memory data store (for referencing other seeded data) */ -const seeds: SeedExports = { +const seeds = { /** * Pet - Generates 15 sample pets with realistic data */ 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.mjs similarity index 94% rename from playground/petstore-app/src/apis/petstore/open-api-server/seeds/users.seed.ts rename to playground/petstore-app/src/apis/petstore/open-api-server/seeds/users.seed.mjs index 21c4737..e228fd5 100644 --- 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.mjs @@ -18,8 +18,6 @@ * @module seeds/users */ -import type { SeedExports } from '@websublime/vite-plugin-open-api-server'; - /** * User schema seeds. * @@ -28,7 +26,7 @@ import type { SeedExports } from '@websublime/vite-plugin-open-api-server'; * - `faker` - Faker.js instance for generating fake data * - `store` - In-memory data store (for referencing other seeded data) */ -const seeds: SeedExports = { +const seeds = { /** * User - Generates 10 sample users with realistic data * From e9efc77e13b81e5b657e5dae309c04a1114f6b2c Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 12:49:44 +0000 Subject: [PATCH 08/19] chore: sync changeset for fix/vite-open-api-server-thy-integrate-loaders --- ...x-vite-open-api-server-thy-integrate-loaders.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json index eb0c205..176180f 100644 --- a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json +++ b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json @@ -7,7 +7,15 @@ "packages": [ "@websublime/vite-plugin-open-api-server" ], - "changes": [], + "changes": [ + "91148f2bde45577e312148cf101b45bcc311b9bb", + "30771708ee15438b1e4836c530c6ee6cc90c3572", + "ff531c3d38181424e432c5ba160a9ee70ef7cab4", + "2faaa947da39ca6d471ae082fd2778e538db8252", + "d8646e6007b8045a9acd794d9582f2b9403515ed", + "af38aa4726904360d4fe8a3c57ed457143a08007", + "65ad2332f3bd3d07cdeef174ddba746ee3b0c318" + ], "created_at": "2026-01-16T12:17:16.928160Z", - "updated_at": "2026-01-16T12:17:16.928825Z" + "updated_at": "2026-01-16T12:49:44.139366Z" } \ No newline at end of file From 8cfaad41dcabc9307b25384e8b88e1aff0dbdc50 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 12:57:32 +0000 Subject: [PATCH 09/19] refactor(vite-open-api-server-thy): fix lint errors in loaders and tests - Fix import organization in handler-loader.ts (node imports before external) - Fix import organization in seed-loader.ts (node imports before external) - Replace non-null assertions with optional chaining in document-enhancer.test.ts Refs: vite-open-api-server-thy --- .../enhancer/__tests__/document-enhancer.test.ts | 16 ++++++++-------- .../src/loaders/handler-loader.ts | 2 +- .../src/loaders/seed-loader.ts | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) 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 60d3a9a..3b33751 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 @@ -305,7 +305,7 @@ describe('Document Enhancer', () => { const operationInfo = findOperationById(result.document, 'getPetById'); expect(operationInfo).not.toBeNull(); - expect(getExtension(operationInfo!.operation, 'x-handler')).toBe(handlerCode); + expect(getExtension(operationInfo?.operation, 'x-handler')).toBe(handlerCode); }); it('should inject multiple static handlers', async () => { @@ -321,10 +321,10 @@ describe('Document Enhancer', () => { expect(result.handlerCount).toBe(2); const listPetsOp = findOperationById(result.document, 'listPets'); - expect(getExtension(listPetsOp!.operation, 'x-handler')).toBe('return store.list("Pet");'); + expect(getExtension(listPetsOp?.operation, 'x-handler')).toBe('return store.list("Pet");'); const createPetOp = findOperationById(result.document, 'createPet'); - expect(getExtension(createPetOp!.operation, 'x-handler')).toBe( + expect(getExtension(createPetOp?.operation, 'x-handler')).toBe( 'return store.create("Pet", req.body);', ); }); @@ -347,7 +347,7 @@ describe('Document Enhancer', () => { const result = await enhanceDocument(spec, handlers, seeds, mockLogger); const operationInfo = findOperationById(result.document, 'getPetById'); - const injectedCode = getExtension(operationInfo!.operation, 'x-handler'); + const injectedCode = getExtension(operationInfo?.operation, 'x-handler'); expect(injectedCode).toContain('store.get("Pet"'); expect(injectedCode).toContain('res["404"]'); // Should include 404 handling @@ -387,7 +387,7 @@ describe('Document Enhancer', () => { const result = await enhanceDocument(spec, handlers, seeds, mockLogger); const operationInfo = findOperationById(result.document, 'listPets'); - expect(getExtension(operationInfo!.operation, 'x-handler')).toBe( + expect(getExtension(operationInfo?.operation, 'x-handler')).toBe( 'return store.list("Pet");', ); }); @@ -557,7 +557,7 @@ describe('Document Enhancer', () => { ); const operationInfo = findOperationById(result.document, 'getPetById'); - expect(getExtension(operationInfo!.operation, 'x-handler')).toBe( + expect(getExtension(operationInfo?.operation, 'x-handler')).toBe( 'return store.get("Pet");', ); }); @@ -813,10 +813,10 @@ describe('Document Enhancer', () => { expect(result.handlerCount).toBe(2); const listPetsOp = findOperationById(result.document, 'listPets'); - expect(getExtension(listPetsOp!.operation, 'x-handler')).toBe('return store.list("Pet");'); + expect(getExtension(listPetsOp?.operation, 'x-handler')).toBe('return store.list("Pet");'); const getPetByIdOp = findOperationById(result.document, 'getPetById'); - const dynamicCode = getExtension(getPetByIdOp!.operation, 'x-handler'); + const dynamicCode = getExtension(getPetByIdOp?.operation, 'x-handler'); expect(dynamicCode).toContain('store.get("Pet"'); expect(dynamicCode).toContain('// get'); }); 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 d8b8f6f..498aa90 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 @@ -22,9 +22,9 @@ * @module */ -import fg from 'fast-glob'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import fg from 'fast-glob'; import type { Logger } from 'vite'; import type { HandlerExports, HandlerLoadResult, HandlerValue } from '../types/handlers.js'; 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 5cd0f53..0b29740 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 @@ -22,9 +22,9 @@ * @module */ -import fg from 'fast-glob'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import fg from 'fast-glob'; import type { Logger } from 'vite'; import type { OpenApiEndpointRegistry } from '../types/registry.js'; From c926db0fb634641d6a6ed07829180f4b5baf6a32 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 13:04:42 +0000 Subject: [PATCH 10/19] chore: sync changeset for fix/vite-open-api-server-thy-integrate-loaders --- .../fix-vite-open-api-server-thy-integrate-loaders.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json index 176180f..3a357b2 100644 --- a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json +++ b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json @@ -14,8 +14,9 @@ "2faaa947da39ca6d471ae082fd2778e538db8252", "d8646e6007b8045a9acd794d9582f2b9403515ed", "af38aa4726904360d4fe8a3c57ed457143a08007", - "65ad2332f3bd3d07cdeef174ddba746ee3b0c318" + "65ad2332f3bd3d07cdeef174ddba746ee3b0c318", + "8cfaad41dcabc9307b25384e8b88e1aff0dbdc50" ], "created_at": "2026-01-16T12:17:16.928160Z", - "updated_at": "2026-01-16T12:49:44.139366Z" + "updated_at": "2026-01-16T13:04:41.860411Z" } \ No newline at end of file From 84832906c83354f68c36d98e344e788977136b2f Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 14:01:30 +0000 Subject: [PATCH 11/19] refactor: address code review feedback - Extract shared loader utilities to loader-utils.ts (isValidExportsObject, isValidValue, getValueType, logLoadSummary) - Cache schemas extraction in document-enhancer to avoid redundant O(n*m) work - Add null guards in tests instead of non-null assertions (noNonNullAssertion lint rule) - Check directory existence before scanning in loaders (prevents ENOENT errors) - Rename invalid-not-function.handler.mjs to invalid-array-export.handler.mjs - Rename invalid-not-function.seed.mjs to invalid-function-export.seed.mjs - Fix import ordering per Biome rules --- .../__tests__/document-enhancer.test.ts | 34 ++-- .../src/enhancer/document-enhancer.ts | 66 +++++--- ...r.mjs => invalid-array-export.handler.mjs} | 0 .../loaders/__tests__/handler-loader.test.ts | 2 +- ...d.mjs => invalid-function-export.seed.mjs} | 0 .../src/loaders/__tests__/seed-loader.test.ts | 2 +- .../src/loaders/handler-loader.ts | 83 +++------- .../src/loaders/index.ts | 8 + .../src/loaders/loader-utils.ts | 154 ++++++++++++++++++ .../src/loaders/seed-loader.ts | 75 ++------- 10 files changed, 273 insertions(+), 151 deletions(-) rename packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/{invalid-not-function.handler.mjs => invalid-array-export.handler.mjs} (100%) rename packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/{invalid-not-function.seed.mjs => invalid-function-export.seed.mjs} (100%) create mode 100644 packages/vite-plugin-open-api-server/src/loaders/loader-utils.ts 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 3b33751..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 @@ -305,7 +305,8 @@ describe('Document Enhancer', () => { const operationInfo = findOperationById(result.document, 'getPetById'); expect(operationInfo).not.toBeNull(); - expect(getExtension(operationInfo?.operation, 'x-handler')).toBe(handlerCode); + if (!operationInfo) throw new Error('Operation getPetById not found'); + expect(getExtension(operationInfo.operation, 'x-handler')).toBe(handlerCode); }); it('should inject multiple static handlers', async () => { @@ -321,10 +322,14 @@ describe('Document Enhancer', () => { expect(result.handlerCount).toBe(2); const listPetsOp = findOperationById(result.document, 'listPets'); - expect(getExtension(listPetsOp?.operation, 'x-handler')).toBe('return store.list("Pet");'); + 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, 'x-handler')).toBe( + 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);', ); }); @@ -347,7 +352,9 @@ describe('Document Enhancer', () => { const result = await enhanceDocument(spec, handlers, seeds, mockLogger); const operationInfo = findOperationById(result.document, 'getPetById'); - const injectedCode = getExtension(operationInfo?.operation, 'x-handler'); + 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 @@ -363,6 +370,7 @@ describe('Document Enhancer', () => { 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'); @@ -387,7 +395,9 @@ describe('Document Enhancer', () => { const result = await enhanceDocument(spec, handlers, seeds, mockLogger); const operationInfo = findOperationById(result.document, 'listPets'); - expect(getExtension(operationInfo?.operation, 'x-handler')).toBe( + expect(operationInfo).not.toBeNull(); + if (!operationInfo) throw new Error('Operation listPets not found'); + expect(getExtension(operationInfo.operation, 'x-handler')).toBe( 'return store.list("Pet");', ); }); @@ -557,9 +567,9 @@ describe('Document Enhancer', () => { ); const operationInfo = findOperationById(result.document, 'getPetById'); - expect(getExtension(operationInfo?.operation, 'x-handler')).toBe( - 'return store.get("Pet");', - ); + 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', async () => { @@ -813,10 +823,14 @@ describe('Document Enhancer', () => { expect(result.handlerCount).toBe(2); const listPetsOp = findOperationById(result.document, 'listPets'); - expect(getExtension(listPetsOp?.operation, 'x-handler')).toBe('return store.list("Pet");'); + 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'); - const dynamicCode = getExtension(getPetByIdOp?.operation, 'x-handler'); + 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'); }); 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 3103d2c..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 @@ -135,9 +135,12 @@ export async function enhanceDocument( // Deep clone spec to preserve original const enhanced = cloneDocument(spec); + // 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, logger); - const seedResult = await injectSeeds(enhanced, seeds, logger); + const handlerResult = await injectHandlers(enhanced, handlers, cachedSchemas, logger); + const seedResult = await injectSeeds(enhanced, seeds, cachedSchemas, logger); const handlerCount = handlerResult.count; const seedCount = seedResult.count; @@ -154,6 +157,29 @@ export async 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. * @@ -164,6 +190,7 @@ export async function enhanceDocument( * @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( @@ -171,21 +198,12 @@ async function resolveHandlerValue( value: HandlerValue, spec: OpenAPIV3_1.Document, operationInfo: OperationInfo, + schemas: Record, ): Promise { if (typeof value === 'string') { return value; } - // Extract all schemas for context - const schemas: Record = {}; - if (spec.components?.schemas) { - for (const [name, schemaOrRef] of Object.entries(spec.components.schemas)) { - if (!isReferenceObject(schemaOrRef)) { - schemas[name] = schemaOrRef; - } - } - } - // Build context for the generator function const context: HandlerCodeContext = { operationId, @@ -213,6 +231,7 @@ async function resolveHandlerValue( * @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( @@ -220,21 +239,12 @@ async function resolveSeedValue( value: SeedValue, spec: OpenAPIV3_1.Document, schema: OpenAPIV3_1.SchemaObject, + schemas: Record, ): Promise { if (typeof value === 'string') { return value; } - // Extract all schemas for context - const schemas: Record = {}; - if (spec.components?.schemas) { - for (const [name, schemaOrRef] of Object.entries(spec.components.schemas)) { - if (!isReferenceObject(schemaOrRef)) { - schemas[name] = schemaOrRef; - } - } - } - // Build context for the generator function const context: SeedCodeContext = { schemaName, @@ -256,6 +266,7 @@ async function resolveSeedValue( async function injectHandlers( spec: OpenAPIV3_1.Document, handlers: Map, + schemas: Record, logger: Logger, ): Promise { let count = 0; @@ -282,7 +293,13 @@ async function injectHandlers( try { // Resolve the handler value to a code string - const code = await resolveHandlerValue(operationId, handlerValue, spec, operationInfo); + const code = await resolveHandlerValue( + operationId, + handlerValue, + spec, + operationInfo, + schemas, + ); // Inject the resolved code string setExtension(operation, 'x-handler', code); @@ -312,6 +329,7 @@ async function injectHandlers( async function injectSeeds( spec: OpenAPIV3_1.Document, seeds: Map, + cachedSchemas: Record, logger: Logger, ): Promise { let count = 0; @@ -347,7 +365,7 @@ async function injectSeeds( try { // Resolve the seed value to a code string - const code = await resolveSeedValue(schemaName, seedValue, spec, schema); + const code = await resolveSeedValue(schemaName, seedValue, spec, schema, cachedSchemas); // Inject the resolved code string setExtension(schema, 'x-seed', code); 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-array-export.handler.mjs similarity index 100% rename from packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/invalid-not-function.handler.mjs rename to packages/vite-plugin-open-api-server/src/loaders/__tests__/fixtures/invalid-array-export.handler.mjs 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 15d6aa9..400e110 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 @@ -209,7 +209,7 @@ describe('Handler Loader', () => { const registry = createMockRegistry(); await loadHandlers(FIXTURES_DIR, registry, mockLogger); - // Should log error for invalid-not-function.handler.mjs (now exports array) + // Should log error for invalid-array-export.handler.mjs (exports array instead of object) expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to load')); }); 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-function-export.seed.mjs similarity index 100% rename from packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-not-function.seed.mjs rename to packages/vite-plugin-open-api-server/src/loaders/__tests__/seed-fixtures/invalid-function-export.seed.mjs 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 8ef928e..8df2ea2 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 @@ -333,7 +333,7 @@ describe('Seed Loader', () => { const registry = createMockRegistry(); const result = await loadSeeds(SEED_FIXTURES_DIR, registry, mockLogger); - // Should log error for invalid-not-function.seed.mjs (now exports function) + // 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); }); 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 498aa90..09fd4f3 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 @@ -22,6 +22,7 @@ * @module */ +import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import fg from 'fast-glob'; @@ -29,6 +30,12 @@ import type { Logger } from 'vite'; import type { HandlerExports, HandlerLoadResult, HandlerValue } from '../types/handlers.js'; import type { OpenApiEndpointRegistry } from '../types/registry.js'; +import { + getValueType, + isValidExportsObject, + isValidValue, + logLoadSummary, +} from './loader-utils.js'; /** * Load custom handler files from a directory. @@ -78,6 +85,14 @@ export async function loadHandlers( // Resolve to absolute path const absoluteDir = path.resolve(handlersDir); + // Check if directory exists 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 }; + } + // Scan for handler files const files = await fg.glob('**/*.handler.{ts,js,mts,mjs}', { cwd: absoluteDir, @@ -107,7 +122,14 @@ export async function loadHandlers( } // Log summary - logLoadSummary(handlers.size, loadedFiles.length, warnings.length, errors.length, logger); + logLoadSummary( + 'handler', + handlers.size, + loadedFiles.length, + warnings.length, + errors.length, + logger, + ); return { handlers, loadedFiles, warnings, errors }; } catch (error) { @@ -153,7 +175,7 @@ async function loadHandlerFile( // Process each handler in the exports for (const [operationId, handlerValue] of Object.entries(handlerExports)) { // Validate handler value type - if (!isValidHandlerValue(handlerValue)) { + if (!isValidValue(handlerValue)) { const msg = `Invalid handler value for "${operationId}" in ${filename}: expected string or function, got ${typeof handlerValue}`; warnings.push(msg); logger.warn(`[handler-loader] ${msg}`); @@ -178,32 +200,10 @@ async function loadHandlerFile( // Add to handlers map handlers.set(operationId, handlerValue); - logger.info( - `[handler-loader] Loaded handler: ${operationId} (${getHandlerType(handlerValue)})`, - ); + logger.info(`[handler-loader] Loaded handler: ${operationId} (${getValueType(handlerValue)})`); } } -/** - * Check if a value is a valid exports object (plain object, not array/function). - */ -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 handler value (string or function). - */ -function isValidHandlerValue(value: unknown): value is HandlerValue { - return typeof value === 'string' || typeof value === 'function'; -} - /** * Check if an operationId exists in the registry. */ @@ -216,39 +216,6 @@ function checkOperationExists(operationId: string, registry: OpenApiEndpointRegi return false; } -/** - * Get a human-readable type description for a handler value. - */ -function getHandlerType(value: HandlerValue): string { - if (typeof value === 'string') { - return `static, ${value.length} chars`; - } - return 'dynamic function'; -} - -/** - * Log the loading summary. - */ -function logLoadSummary( - handlerCount: number, - fileCount: number, - warningCount: number, - errorCount: number, - logger: Logger, -): void { - const parts = [`${handlerCount} handler(s)`, `from ${fileCount} file(s)`]; - - if (warningCount > 0) { - parts.push(`${warningCount} warning(s)`); - } - - if (errorCount > 0) { - parts.push(`${errorCount} error(s)`); - } - - logger.info(`[handler-loader] Summary: ${parts.join(', ')}`); -} - /** * Extract operationId from handler filename. * 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 41585b6..3949f8b 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/index.ts +++ b/packages/vite-plugin-open-api-server/src/loaders/index.ts @@ -12,6 +12,14 @@ export { loadHandlers, } from './handler-loader.js'; +export { + formatInvalidExportError, + getValueType, + isValidExportsObject, + isValidValue, + logLoadSummary, +} from './loader-utils.js'; + export { capitalize, extractSchemaName, 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..d15119a --- /dev/null +++ b/packages/vite-plugin-open-api-server/src/loaders/loader-utils.ts @@ -0,0 +1,154 @@ +/** + * 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(', ')}`); +} + +/** + * 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]); + * // "... must be an object mapping operationId to handler values. Got: object (array)" + * ``` + */ +export function formatInvalidExportError(expectedType: string, actualValue: unknown): string { + let typeDesc: string = typeof actualValue; + + if (Array.isArray(actualValue)) { + typeDesc = 'object (array)'; + } else if (typeof actualValue === 'function') { + typeDesc = 'function'; + } + + return `default export must be an ${expectedType}. Got: ${typeDesc}`; +} 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 0b29740..722d835 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 @@ -22,6 +22,7 @@ * @module */ +import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import fg from 'fast-glob'; @@ -29,6 +30,12 @@ import type { Logger } from 'vite'; import type { OpenApiEndpointRegistry } from '../types/registry.js'; import type { SeedExports, SeedLoadResult, SeedValue } from '../types/seeds.js'; +import { + getValueType, + isValidExportsObject, + isValidValue, + logLoadSummary, +} from './loader-utils.js'; /** * Load seed data files from a directory. @@ -78,6 +85,14 @@ export async function loadSeeds( // Resolve to absolute path const absoluteDir = path.resolve(seedsDir); + // Check if directory exists 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 }; + } + // Scan for seed files const files = await fg.glob('**/*.seed.{ts,js,mts,mjs}', { cwd: absoluteDir, @@ -107,7 +122,7 @@ export async function loadSeeds( } // Log summary - logLoadSummary(seeds.size, loadedFiles.length, warnings.length, errors.length, logger); + logLoadSummary('seed', seeds.size, loadedFiles.length, warnings.length, errors.length, logger); return { seeds, loadedFiles, warnings, errors }; } catch (error) { @@ -153,7 +168,7 @@ async function loadSeedFile( // Process each seed in the exports for (const [schemaName, seedValue] of Object.entries(seedExports)) { // Validate seed value type - if (!isValidSeedValue(seedValue)) { + if (!isValidValue(seedValue)) { const msg = `Invalid seed value for "${schemaName}" in ${filename}: expected string or function, got ${typeof seedValue}`; warnings.push(msg); logger.warn(`[seed-loader] ${msg}`); @@ -178,31 +193,10 @@ async function loadSeedFile( // Add to seeds map seeds.set(schemaName, seedValue); - logger.info(`[seed-loader] Loaded seed: ${schemaName} (${getSeedType(seedValue)})`); + logger.info(`[seed-loader] Loaded seed: ${schemaName} (${getValueType(seedValue)})`); } } -/** - * Check if a value is a valid exports object (plain object, not array/function). - */ -function isValidExportsObject(value: unknown): value is Record { - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - typeof value !== 'function' && - // Ensure it's a plain object, not a class instance - Object.getPrototypeOf(value) === Object.prototype - ); -} - -/** - * Check if a value is a valid seed value (string or function). - */ -function isValidSeedValue(value: unknown): value is SeedValue { - return typeof value === 'string' || typeof value === 'function'; -} - /** * Check if a schemaName exists in the registry. * @@ -232,39 +226,6 @@ function checkSchemaExists(schemaName: string, registry: OpenApiEndpointRegistry return false; } -/** - * Get a human-readable type description for a seed value. - */ -function getSeedType(value: SeedValue): string { - if (typeof value === 'string') { - return `static, ${value.length} chars`; - } - return 'dynamic function'; -} - -/** - * Log the loading summary. - */ -function logLoadSummary( - seedCount: number, - fileCount: number, - warningCount: number, - errorCount: number, - logger: Logger, -): void { - const parts = [`${seedCount} seed(s)`, `from ${fileCount} file(s)`]; - - if (warningCount > 0) { - parts.push(`${warningCount} warning(s)`); - } - - if (errorCount > 0) { - parts.push(`${errorCount} error(s)`); - } - - logger.info(`[seed-loader] Summary: ${parts.join(', ')}`); -} - /** * Extract schema name from seed filename. * From 72a85874e4c14dde065af93b08fd9a1dabbeb83c Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 14:01:37 +0000 Subject: [PATCH 12/19] chore: sync changeset for fix/vite-open-api-server-thy-integrate-loaders --- .../fix-vite-open-api-server-thy-integrate-loaders.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json index 3a357b2..4e3be4e 100644 --- a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json +++ b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json @@ -15,8 +15,9 @@ "d8646e6007b8045a9acd794d9582f2b9403515ed", "af38aa4726904360d4fe8a3c57ed457143a08007", "65ad2332f3bd3d07cdeef174ddba746ee3b0c318", - "8cfaad41dcabc9307b25384e8b88e1aff0dbdc50" + "8cfaad41dcabc9307b25384e8b88e1aff0dbdc50", + "84832906c83354f68c36d98e344e788977136b2f" ], "created_at": "2026-01-16T12:17:16.928160Z", - "updated_at": "2026-01-16T13:04:41.860411Z" + "updated_at": "2026-01-16T14:01:37.522657Z" } \ No newline at end of file From 0212c02a5d4af05e057428cceb69b33710e9f7d1 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 14:13:43 +0000 Subject: [PATCH 13/19] refactor(loaders): address review comments for handler and seed loaders - handler-loader: Pre-build operationId Set for O(1) lookups instead of O(n*m) scanning in checkOperationExists; remove the function and use Set.has() directly - handler-loader: Add directory check (not just existence) before globbing - handler-loader: Use 'default' in module check instead of truthy check for exports - seed-loader: Refactor checkSchemaExists to delegate to findMatchingSchema, centralizing schema-matching logic and avoiding duplication - seed-loader: Add directory check (not just existence) before globbing to prevent ENOTDIR errors when seedsDir points to a file - seed-loader: Use 'default' in module check instead of truthy check to distinguish missing-default from falsy-default errors - loader-utils: Handle null explicitly in formatInvalidExportError for clearer error messages (null now shows 'null' instead of 'object') --- .../src/loaders/handler-loader.ts | 53 +++++++++++-------- .../src/loaders/loader-utils.ts | 5 +- .../src/loaders/seed-loader.ts | 50 +++++++++-------- 3 files changed, 60 insertions(+), 48 deletions(-) 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 09fd4f3..86c10cf 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 @@ -85,7 +85,7 @@ export async function loadHandlers( // Resolve to absolute path const absoluteDir = path.resolve(handlersDir); - // Check if directory exists before scanning + // 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}`); @@ -93,6 +93,22 @@ export async function loadHandlers( 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 fg.glob('**/*.handler.{ts,js,mts,mjs}', { cwd: absoluteDir, @@ -108,10 +124,18 @@ export async function loadHandlers( 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 { - await loadHandlerFile(filePath, handlers, registry, logger, warnings); + await loadHandlerFile(filePath, handlers, operationIdSet, logger, warnings); loadedFiles.push(filePath); } catch (error) { const err = error as Error; @@ -147,7 +171,7 @@ export async function loadHandlers( async function loadHandlerFile( filePath: string, handlers: Map, - registry: OpenApiEndpointRegistry, + operationIdSet: Set, logger: Logger, warnings: string[], ): Promise { @@ -155,13 +179,13 @@ async function loadHandlerFile( const fileUrl = pathToFileURL(filePath).href; const module = await import(fileUrl); - // Validate default export exists - if (!module.default) { + // 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'); } // Validate default export is an object (not function, array, or primitive) - const exports = module.default; + const exports = module.default as unknown; if (!isValidExportsObject(exports)) { throw new Error( 'Handler file default export must be an object mapping operationId to handler values. ' + @@ -182,9 +206,8 @@ async function loadHandlerFile( continue; } - // Validate operationId exists in registry - const operationExists = checkOperationExists(operationId, registry); - if (!operationExists) { + // 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}`); @@ -204,18 +227,6 @@ async function loadHandlerFile( } } -/** - * Check if an operationId exists in the registry. - */ -function checkOperationExists(operationId: string, registry: OpenApiEndpointRegistry): boolean { - for (const endpoint of registry.endpoints.values()) { - if (endpoint.operationId === operationId) { - return true; - } - } - return false; -} - /** * Extract operationId from handler filename. * 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 index d15119a..25ccfa8 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/loader-utils.ts +++ b/packages/vite-plugin-open-api-server/src/loaders/loader-utils.ts @@ -144,7 +144,10 @@ export function logLoadSummary( export function formatInvalidExportError(expectedType: string, actualValue: unknown): string { let typeDesc: string = typeof actualValue; - if (Array.isArray(actualValue)) { + // Handle null explicitly (typeof null === 'object' but we want a clearer message) + if (actualValue === null) { + typeDesc = 'null'; + } else if (Array.isArray(actualValue)) { typeDesc = 'object (array)'; } else if (typeof actualValue === 'function') { typeDesc = 'function'; 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 722d835..3ae01e7 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 @@ -85,7 +85,7 @@ export async function loadSeeds( // Resolve to absolute path const absoluteDir = path.resolve(seedsDir); - // Check if directory exists before scanning + // 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}`); @@ -93,6 +93,22 @@ export async function loadSeeds( 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 fg.glob('**/*.seed.{ts,js,mts,mjs}', { cwd: absoluteDir, @@ -148,13 +164,13 @@ async function loadSeedFile( const fileUrl = pathToFileURL(filePath).href; const module = await import(fileUrl); - // Validate default export exists - if (!module.default) { + // 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; + const exports = module.default as unknown; if (!isValidExportsObject(exports)) { throw new Error( 'Seed file default export must be an object mapping schemaName to seed values. ' + @@ -200,30 +216,12 @@ async function loadSeedFile( /** * Check if a schemaName exists in the registry. * - * Tries multiple candidates: exact match, capitalized, singular, plural forms. + * Delegates to findMatchingSchema to centralize all schema-matching logic. + * This avoids duplication of candidate generation between checkSchemaExists + * and findMatchingSchema. */ function checkSchemaExists(schemaName: string, registry: OpenApiEndpointRegistry): boolean { - // Direct match first - if (registry.schemas.has(schemaName)) { - return true; - } - - // Try variations - const candidates = [ - capitalize(schemaName), - singularize(schemaName), - capitalize(singularize(schemaName)), - pluralize(schemaName), - capitalize(pluralize(schemaName)), - ]; - - for (const candidate of candidates) { - if (registry.schemas.has(candidate)) { - return true; - } - } - - return false; + return findMatchingSchema(schemaName, registry) !== null; } /** From 1a810dbfe1a0a3721afd52984281229bc2874f54 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 14:13:50 +0000 Subject: [PATCH 14/19] chore: sync changeset for fix/vite-open-api-server-thy-integrate-loaders --- .../fix-vite-open-api-server-thy-integrate-loaders.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json index 4e3be4e..6531ade 100644 --- a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json +++ b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json @@ -16,8 +16,9 @@ "af38aa4726904360d4fe8a3c57ed457143a08007", "65ad2332f3bd3d07cdeef174ddba746ee3b0c318", "8cfaad41dcabc9307b25384e8b88e1aff0dbdc50", - "84832906c83354f68c36d98e344e788977136b2f" + "84832906c83354f68c36d98e344e788977136b2f", + "0212c02a5d4af05e057428cceb69b33710e9f7d1" ], "created_at": "2026-01-16T12:17:16.928160Z", - "updated_at": "2026-01-16T14:01:37.522657Z" + "updated_at": "2026-01-16T14:13:49.960374Z" } \ No newline at end of file From befa1a8ed8542ce053e266b4046192db8ccb60e1 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 14:20:11 +0000 Subject: [PATCH 15/19] refactor(loaders): use shared error formatters for consistent messaging - loader-utils: Add describeValueType() helper for consistent type descriptions that properly handles null, arrays, and functions - loader-utils: Add formatInvalidValueError() for consistent value-level errors - loader-utils: Refactor formatInvalidExportError() to use describeValueType() - handler-loader: Replace ad-hoc error messages with formatInvalidExportError() and formatInvalidValueError() for consistent null handling and message structure - seed-loader: Replace ad-hoc error messages with formatInvalidExportError() and formatInvalidValueError() for consistent null handling and message structure Both loaders now produce identical formatted messages for invalid exports and values, ensuring consistent error messaging and proper null value representation. --- .../src/loaders/handler-loader.ts | 7 +- .../src/loaders/loader-utils.ts | 72 +++++++++++++++---- .../src/loaders/seed-loader.ts | 7 +- 3 files changed, 68 insertions(+), 18 deletions(-) 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 86c10cf..1dd527e 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 @@ -31,6 +31,8 @@ import type { Logger } from 'vite'; import type { HandlerExports, HandlerLoadResult, HandlerValue } from '../types/handlers.js'; import type { OpenApiEndpointRegistry } from '../types/registry.js'; import { + formatInvalidExportError, + formatInvalidValueError, getValueType, isValidExportsObject, isValidValue, @@ -188,8 +190,7 @@ async function loadHandlerFile( const exports = module.default as unknown; if (!isValidExportsObject(exports)) { throw new Error( - 'Handler file default export must be an object mapping operationId to handler values. ' + - `Got: ${typeof exports}${Array.isArray(exports) ? ' (array)' : ''}`, + `Handler file ${formatInvalidExportError('object mapping operationId to handler values', exports)}`, ); } @@ -200,7 +201,7 @@ async function loadHandlerFile( for (const [operationId, handlerValue] of Object.entries(handlerExports)) { // Validate handler value type if (!isValidValue(handlerValue)) { - const msg = `Invalid handler value for "${operationId}" in ${filename}: expected string or function, got ${typeof handlerValue}`; + const msg = formatInvalidValueError(operationId, filename, handlerValue); warnings.push(msg); logger.warn(`[handler-loader] ${msg}`); continue; 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 index 25ccfa8..9c26265 100644 --- a/packages/vite-plugin-open-api-server/src/loaders/loader-utils.ts +++ b/packages/vite-plugin-open-api-server/src/loaders/loader-utils.ts @@ -128,6 +128,35 @@ export function logLoadSummary( 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. * @@ -138,20 +167,39 @@ export function logLoadSummary( * @example * ```typescript * formatInvalidExportError('object mapping operationId to handler values', [1, 2]); - * // "... must be an object mapping operationId to handler values. Got: object (array)" + * // "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 { - let typeDesc: string = typeof actualValue; - - // Handle null explicitly (typeof null === 'object' but we want a clearer message) - if (actualValue === null) { - typeDesc = 'null'; - } else if (Array.isArray(actualValue)) { - typeDesc = 'object (array)'; - } else if (typeof actualValue === 'function') { - typeDesc = 'function'; - } + return `default export must be an ${expectedType}. Got: ${describeValueType(actualValue)}`; +} - return `default export must be an ${expectedType}. Got: ${typeDesc}`; +/** + * 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 3ae01e7..6127286 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 @@ -31,6 +31,8 @@ import type { Logger } from 'vite'; import type { OpenApiEndpointRegistry } from '../types/registry.js'; import type { SeedExports, SeedLoadResult, SeedValue } from '../types/seeds.js'; import { + formatInvalidExportError, + formatInvalidValueError, getValueType, isValidExportsObject, isValidValue, @@ -173,8 +175,7 @@ async function loadSeedFile( const exports = module.default as unknown; if (!isValidExportsObject(exports)) { throw new Error( - 'Seed file default export must be an object mapping schemaName to seed values. ' + - `Got: ${typeof exports}${Array.isArray(exports) ? ' (array)' : typeof exports === 'function' ? ' (function)' : ''}`, + `Seed file ${formatInvalidExportError('object mapping schemaName to seed values', exports)}`, ); } @@ -185,7 +186,7 @@ async function loadSeedFile( for (const [schemaName, seedValue] of Object.entries(seedExports)) { // Validate seed value type if (!isValidValue(seedValue)) { - const msg = `Invalid seed value for "${schemaName}" in ${filename}: expected string or function, got ${typeof seedValue}`; + const msg = formatInvalidValueError(schemaName, filename, seedValue); warnings.push(msg); logger.warn(`[seed-loader] ${msg}`); continue; From 1b06914eb37f7ed78612d29b1c4c6a5999d27cc3 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 14:20:21 +0000 Subject: [PATCH 16/19] chore: sync changeset for fix/vite-open-api-server-thy-integrate-loaders --- .../fix-vite-open-api-server-thy-integrate-loaders.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json index 6531ade..bd8470f 100644 --- a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json +++ b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json @@ -17,8 +17,9 @@ "65ad2332f3bd3d07cdeef174ddba746ee3b0c318", "8cfaad41dcabc9307b25384e8b88e1aff0dbdc50", "84832906c83354f68c36d98e344e788977136b2f", - "0212c02a5d4af05e057428cceb69b33710e9f7d1" + "0212c02a5d4af05e057428cceb69b33710e9f7d1", + "befa1a8ed8542ce053e266b4046192db8ccb60e1" ], "created_at": "2026-01-16T12:17:16.928160Z", - "updated_at": "2026-01-16T14:13:49.960374Z" + "updated_at": "2026-01-16T14:20:21.105962Z" } \ No newline at end of file From 645ef3a4c6d7c1abf458603aebf1d97e07872cef Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 14:34:44 +0000 Subject: [PATCH 17/19] fix(seed-loader): use matched schema name as key for proper registry lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed loader was storing seeds under the original user-provided key (e.g., 'pets') instead of the matched registry key (e.g., 'Pet'). This caused lookups to fail when the enhancer tried to find seeds by registry schema name. Changes: - Use findMatchingSchema() directly instead of checkSchemaExists() - Store seeds under the matched schema name (if found) for proper lookups - Fall back to original name only when no registry match exists - Improve log messages to show mapping when name differs (e.g., 'pets → Pet') - Remove now-unused checkSchemaExists() function --- .../src/loaders/seed-loader.ts | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) 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 6127286..0de6ef3 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 @@ -192,39 +192,37 @@ async function loadSeedFile( continue; } - // Validate schemaName exists in registry - const schemaExists = checkSchemaExists(schemaName, registry); - if (!schemaExists) { + // 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 - user might know what they're doing + // 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(schemaName)) { - const msg = `Duplicate seed for "${schemaName}" in ${filename}, overwriting previous`; + 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 - seeds.set(schemaName, seedValue); - logger.info(`[seed-loader] Loaded seed: ${schemaName} (${getValueType(seedValue)})`); + // 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)})`); + } } } -/** - * Check if a schemaName exists in the registry. - * - * Delegates to findMatchingSchema to centralize all schema-matching logic. - * This avoids duplication of candidate generation between checkSchemaExists - * and findMatchingSchema. - */ -function checkSchemaExists(schemaName: string, registry: OpenApiEndpointRegistry): boolean { - return findMatchingSchema(schemaName, registry) !== null; -} - /** * Extract schema name from seed filename. * From 7172e94263cad07bc427aa8e378abd6a2be51f9f Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 14:34:51 +0000 Subject: [PATCH 18/19] chore: sync changeset for fix/vite-open-api-server-thy-integrate-loaders --- .../fix-vite-open-api-server-thy-integrate-loaders.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json index bd8470f..50c3744 100644 --- a/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json +++ b/.changesets/fix-vite-open-api-server-thy-integrate-loaders.json @@ -18,8 +18,9 @@ "8cfaad41dcabc9307b25384e8b88e1aff0dbdc50", "84832906c83354f68c36d98e344e788977136b2f", "0212c02a5d4af05e057428cceb69b33710e9f7d1", - "befa1a8ed8542ce053e266b4046192db8ccb60e1" + "befa1a8ed8542ce053e266b4046192db8ccb60e1", + "645ef3a4c6d7c1abf458603aebf1d97e07872cef" ], "created_at": "2026-01-16T12:17:16.928160Z", - "updated_at": "2026-01-16T14:20:21.105962Z" + "updated_at": "2026-01-16T14:34:50.901255Z" } \ No newline at end of file From d20269c89d31c2f3aee9461d29fc4610bc757b94 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Fri, 16 Jan 2026 14:42:44 +0000 Subject: [PATCH 19/19] refactor(loaders): remove unused extractBaseName and extractSchemaName functions These functions were marked as 'no longer used' since handlers/seeds now export objects with explicit operationId/schemaName keys. Since they have no external consumers, remove them entirely instead of deprecating. Removed: - extractBaseName() from handler-loader.ts - extractSchemaName() from seed-loader.ts - Related exports from loaders/index.ts - Related unit tests (11 tests removed) --- .../loaders/__tests__/handler-loader.test.ts | 24 +---------------- .../src/loaders/__tests__/seed-loader.test.ts | 27 ------------------- .../src/loaders/handler-loader.ts | 20 -------------- .../src/loaders/index.ts | 7 +---- .../src/loaders/seed-loader.ts | 20 -------------- 5 files changed, 2 insertions(+), 96 deletions(-) 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 400e110..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 @@ -17,7 +17,7 @@ import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { OpenApiEndpointRegistry } from '../../types/registry.js'; -import { extractBaseName, 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'); @@ -88,28 +88,6 @@ describe('Handler Loader', () => { }); }); - describe('extractBaseName', () => { - it('should extract base name from .handler.ts file', () => { - expect(extractBaseName('pets.handler.ts')).toBe('pets'); - }); - - it('should extract base name from .handler.js file', () => { - expect(extractBaseName('store.handler.js')).toBe('store'); - }); - - it('should extract base name from .handler.mts file', () => { - expect(extractBaseName('users.handler.mts')).toBe('users'); - }); - - it('should extract base name from .handler.mjs file', () => { - expect(extractBaseName('orders.handler.mjs')).toBe('orders'); - }); - - it('should preserve kebab-case in filename', () => { - expect(extractBaseName('pet-store.handler.ts')).toBe('pet-store'); - }); - }); - describe('loadHandlers', () => { let mockLogger: ReturnType; 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 8df2ea2..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 @@ -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']); 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 1dd527e..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 @@ -228,26 +228,6 @@ async function loadHandlerFile( } } -/** - * Extract operationId from handler filename. - * - * Note: This function is no longer used for extraction since handlers - * now export objects with explicit operationId keys. Kept for potential - * future use or backward compatibility. - * - * @param filename - Handler filename (e.g., 'pets.handler.ts') - * @returns Base name without extension (e.g., 'pets') - * - * @example - * ```typescript - * extractBaseName('pets.handler.ts'); // 'pets' - * extractBaseName('store-orders.handler.mjs'); // 'store-orders' - * ``` - */ -export function extractBaseName(filename: string): string { - return filename.replace(/\.handler\.(ts|js|mts|mjs)$/, ''); -} - /** * Convert kebab-case to camelCase. * 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 3949f8b..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,11 +6,7 @@ * @module */ -export { - extractBaseName, - kebabToCamelCase, - loadHandlers, -} from './handler-loader.js'; +export { kebabToCamelCase, loadHandlers } from './handler-loader.js'; export { formatInvalidExportError, @@ -22,7 +18,6 @@ export { export { capitalize, - extractSchemaName, findMatchingSchema, loadSeeds, pluralize, 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 0de6ef3..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 @@ -223,26 +223,6 @@ async function loadSeedFile( } } -/** - * Extract schema name from seed filename. - * - * Note: This function is no longer used for extraction since seeds - * now export objects with explicit schemaName keys. Kept for potential - * future use or backward compatibility. - * - * @param filename - Seed filename (e.g., 'pets.seed.ts') - * @returns Base name without extension (e.g., 'pets') - * - * @example - * ```typescript - * extractSchemaName('pets.seed.ts'); // 'pets' - * extractSchemaName('Order.seed.mjs'); // 'Order' - * ``` - */ -export function extractSchemaName(filename: string): string { - return filename.replace(/\.seed\.(ts|js|mts|mjs)$/, ''); -} - /** * Find matching schema name in registry. *