From ac1dbda3d91789e59b4e04e066365b5a98762d40 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Mon, 19 Jan 2026 16:20:02 +0000 Subject: [PATCH 1/6] feat(core): implement OpenAPI processor pipeline - Implement bundle step with @scalar/json-magic for external ref resolution - Implement upgrade step with @scalar/openapi-upgrader for OAS 2.0/3.0 to 3.1 - Implement dereference step with @scalar/openapi-parser for inline refs - Add ProcessorError with step tracking (bundle/upgrade/dereference/validation) - Handle empty input by returning minimal valid OpenAPI 3.1 document - Add comprehensive unit tests with YAML/JSON fixtures - Update vitest.config.ts to exclude temp/ from test discovery Closes: vite-open-api-server-z5y.2 --- .../feature-task-1-2-openapi-processor.json | 13 ++ packages/core/package.json | 2 +- .../parser/__tests__/fixtures/minimal.yaml | 6 + .../parser/__tests__/fixtures/swagger2.json | 20 ++ .../parser/__tests__/fixtures/with-refs.yaml | 64 ++++++ .../src/parser/__tests__/processor.test.ts | 215 ++++++++++++++++++ packages/core/src/parser/processor.ts | 192 +++++++++++++--- vitest.config.ts | 3 +- 8 files changed, 484 insertions(+), 31 deletions(-) create mode 100644 .changesets/feature-task-1-2-openapi-processor.json create mode 100644 packages/core/src/parser/__tests__/fixtures/minimal.yaml create mode 100644 packages/core/src/parser/__tests__/fixtures/swagger2.json create mode 100644 packages/core/src/parser/__tests__/fixtures/with-refs.yaml create mode 100644 packages/core/src/parser/__tests__/processor.test.ts diff --git a/.changesets/feature-task-1-2-openapi-processor.json b/.changesets/feature-task-1-2-openapi-processor.json new file mode 100644 index 0000000..f6d6750 --- /dev/null +++ b/.changesets/feature-task-1-2-openapi-processor.json @@ -0,0 +1,13 @@ +{ + "branch": "feature/task-1-2-openapi-processor", + "bump": "minor", + "environments": [ + "production" + ], + "packages": [ + "@websublime/vite-open-api-core" + ], + "changes": [], + "created_at": "2026-01-19T16:19:44.597177Z", + "updated_at": "2026-01-19T16:19:44.597906Z" +} \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index 80eaee9..4fd9dd1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -50,4 +50,4 @@ "types": "./dist/index.d.ts" } } -} \ No newline at end of file +} diff --git a/packages/core/src/parser/__tests__/fixtures/minimal.yaml b/packages/core/src/parser/__tests__/fixtures/minimal.yaml new file mode 100644 index 0000000..90bd1bf --- /dev/null +++ b/packages/core/src/parser/__tests__/fixtures/minimal.yaml @@ -0,0 +1,6 @@ +# Minimal valid OpenAPI 3.0 document +openapi: '3.0.0' +info: + title: Minimal API + version: '1.0.0' +paths: {} diff --git a/packages/core/src/parser/__tests__/fixtures/swagger2.json b/packages/core/src/parser/__tests__/fixtures/swagger2.json new file mode 100644 index 0000000..0ff276c --- /dev/null +++ b/packages/core/src/parser/__tests__/fixtures/swagger2.json @@ -0,0 +1,20 @@ +{ + "swagger": "2.0", + "info": { + "title": "Swagger 2.0 API", + "version": "1.0.0" + }, + "paths": { + "/pets": { + "get": { + "summary": "List pets", + "operationId": "listPets", + "responses": { + "200": { + "description": "Success" + } + } + } + } + } +} diff --git a/packages/core/src/parser/__tests__/fixtures/with-refs.yaml b/packages/core/src/parser/__tests__/fixtures/with-refs.yaml new file mode 100644 index 0000000..fd0fddc --- /dev/null +++ b/packages/core/src/parser/__tests__/fixtures/with-refs.yaml @@ -0,0 +1,64 @@ +# OpenAPI document with internal $ref references +openapi: '3.0.0' +info: + title: API with References + version: '1.0.0' +paths: + /pets: + get: + summary: List pets + operationId: listPets + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + /pets/{petId}: + get: + summary: Get pet by ID + operationId: getPetById + parameters: + - name: petId + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + category: + $ref: '#/components/schemas/Category' + status: + type: string + enum: + - available + - pending + - sold + Category: + type: object + properties: + id: + type: integer + name: + type: string diff --git a/packages/core/src/parser/__tests__/processor.test.ts b/packages/core/src/parser/__tests__/processor.test.ts new file mode 100644 index 0000000..ec545ac --- /dev/null +++ b/packages/core/src/parser/__tests__/processor.test.ts @@ -0,0 +1,215 @@ +/** + * OpenAPI Processor Tests + * + * Tests for the processOpenApiDocument function which processes OpenAPI documents + * through the bundle -> upgrade -> dereference pipeline. + */ + +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { ProcessorError, processOpenApiDocument } from '../processor.js'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const fixturesDir = resolve(__dirname, 'fixtures'); + +describe('processOpenApiDocument', () => { + describe('empty input handling', () => { + it('should return minimal document for undefined input', async () => { + const result = await processOpenApiDocument(undefined as unknown as string); + + expect(result).toEqual({ + openapi: '3.1.0', + info: { title: 'OpenAPI Server', version: '1.0.0' }, + paths: {}, + }); + }); + + it('should return minimal document for empty string', async () => { + const result = await processOpenApiDocument(''); + + expect(result).toEqual({ + openapi: '3.1.0', + info: { title: 'OpenAPI Server', version: '1.0.0' }, + paths: {}, + }); + }); + + it('should return minimal document for empty object', async () => { + const result = await processOpenApiDocument({}); + + expect(result).toEqual({ + openapi: '3.1.0', + info: { title: 'OpenAPI Server', version: '1.0.0' }, + paths: {}, + }); + }); + }); + + describe('object input', () => { + it('should process a valid OpenAPI 3.0 object', async () => { + const input = { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: {}, + }; + + const result = await processOpenApiDocument(input); + + // Should be upgraded to 3.1 + expect(result.openapi).toMatch(/^3\.1\./); + expect(result.info?.title).toBe('Test API'); + expect(result.info?.version).toBe('1.0.0'); + expect(result.paths).toEqual({}); + }); + + it('should upgrade Swagger 2.0 to OpenAPI 3.1', async () => { + const input = { + swagger: '2.0', + info: { title: 'Swagger API', version: '2.0.0' }, + paths: {}, + }; + + const result = await processOpenApiDocument(input); + + // Should be upgraded to 3.1 + expect(result.openapi).toMatch(/^3\.1\./); + expect(result.info?.title).toBe('Swagger API'); + }); + + it('should dereference internal $ref pointers', async () => { + const input = { + openapi: '3.0.0', + info: { title: 'Ref API', version: '1.0.0' }, + paths: { + '/pets': { + get: { + responses: { + '200': { + description: 'Success', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Pet', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Pet: { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + }, + }, + }, + }, + }; + + const result = await processOpenApiDocument(input); + + // The $ref should be dereferenced - the schema should be inlined + const responseSchema = + result.paths?.['/pets']?.get?.responses?.['200']?.content?.['application/json']?.schema; + + expect(responseSchema).toBeDefined(); + // After dereferencing, the schema should have the properties directly + expect(responseSchema?.type).toBe('object'); + expect(responseSchema?.properties?.id?.type).toBe('integer'); + expect(responseSchema?.properties?.name?.type).toBe('string'); + }); + }); + + describe('file input', () => { + it('should process a minimal YAML file', async () => { + const filePath = resolve(fixturesDir, 'minimal.yaml'); + + const result = await processOpenApiDocument(filePath); + + expect(result.openapi).toMatch(/^3\.1\./); + expect(result.info?.title).toBe('Minimal API'); + expect(result.info?.version).toBe('1.0.0'); + }); + + it('should process Swagger 2.0 JSON file and upgrade', async () => { + const filePath = resolve(fixturesDir, 'swagger2.json'); + + const result = await processOpenApiDocument(filePath); + + // Should be upgraded from 2.0 to 3.1 + expect(result.openapi).toMatch(/^3\.1\./); + expect(result.info?.title).toBe('Swagger 2.0 API'); + expect(result.paths?.['/pets']?.get?.operationId).toBe('listPets'); + }); + + it('should process YAML file with internal $refs', async () => { + const filePath = resolve(fixturesDir, 'with-refs.yaml'); + + const result = await processOpenApiDocument(filePath); + + expect(result.openapi).toMatch(/^3\.1\./); + expect(result.info?.title).toBe('API with References'); + + // Check that Pet schema was dereferenced + const petSchema = result.components?.schemas?.Pet; + expect(petSchema).toBeDefined(); + expect(petSchema?.type).toBe('object'); + expect(petSchema?.properties?.name?.type).toBe('string'); + + // Check that the Category reference within Pet was dereferenced + const categoryInPet = petSchema?.properties?.category; + expect(categoryInPet).toBeDefined(); + expect(categoryInPet?.type).toBe('object'); + expect(categoryInPet?.properties?.name?.type).toBe('string'); + }); + }); + + describe('error handling', () => { + it('should throw ProcessorError for non-existent file', async () => { + const filePath = resolve(fixturesDir, 'non-existent.yaml'); + + await expect(processOpenApiDocument(filePath)).rejects.toThrow(ProcessorError); + }); + + it('should throw ProcessorError with step information', async () => { + const filePath = resolve(fixturesDir, 'non-existent.yaml'); + + try { + await processOpenApiDocument(filePath); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessorError); + expect((error as ProcessorError).step).toBe('bundle'); + } + }); + }); + + describe('ProcessorError', () => { + it('should have correct name', () => { + const error = new ProcessorError('Test error'); + expect(error.name).toBe('ProcessorError'); + }); + + it('should have correct step', () => { + const bundleError = new ProcessorError('Bundle failed', 'bundle'); + expect(bundleError.step).toBe('bundle'); + + const upgradeError = new ProcessorError('Upgrade failed', 'upgrade'); + expect(upgradeError.step).toBe('upgrade'); + + const derefError = new ProcessorError('Dereference failed', 'dereference'); + expect(derefError.step).toBe('dereference'); + }); + + it('should default to validation step', () => { + const error = new ProcessorError('Validation failed'); + expect(error.step).toBe('validation'); + }); + }); +}); diff --git a/packages/core/src/parser/processor.ts b/packages/core/src/parser/processor.ts index 4044b35..989a557 100644 --- a/packages/core/src/parser/processor.ts +++ b/packages/core/src/parser/processor.ts @@ -1,12 +1,23 @@ /** * OpenAPI Document Processor * - * What: Processes OpenAPI documents through bundle → upgrade → dereference pipeline + * What: Processes OpenAPI documents through bundle -> upgrade -> dereference pipeline * How: Uses @scalar packages for each transformation step * Why: Ensures all documents are normalized to OpenAPI 3.1 with no external references + * + * Pipeline: + * 1. Bundle - Resolve external $ref references (files, URLs) + * 2. Upgrade - Convert OAS 2.0/3.0 to 3.1 for consistency + * 3. Dereference - Inline all $ref pointers for easy traversal + * + * @module parser/processor */ -// TODO: Will be implemented in Task 1.2: OpenAPI Processor +import { bundle } from '@scalar/json-magic/bundle'; +import { fetchUrls, parseJson, parseYaml, readFiles } from '@scalar/json-magic/bundle/plugins/node'; +import { dereference } from '@scalar/openapi-parser'; +import type { OpenAPIV3_1 } from '@scalar/openapi-types'; +import { upgrade } from '@scalar/openapi-upgrader'; /** * Options for processing OpenAPI documents @@ -18,11 +29,24 @@ export interface ProcessorOptions { /** * Error thrown during OpenAPI document processing + * + * @remarks + * This error is thrown when any step in the processing pipeline fails: + * - Bundle: Failed to resolve external references + * - Upgrade: Failed to convert to OpenAPI 3.1 + * - Dereference: Failed to inline $ref pointers */ export class ProcessorError extends Error { - constructor(message: string) { + /** The processing step that failed */ + readonly step: 'bundle' | 'upgrade' | 'dereference' | 'validation'; + + constructor( + message: string, + step: 'bundle' | 'upgrade' | 'dereference' | 'validation' = 'validation', + ) { super(message); this.name = 'ProcessorError'; + this.step = step; // Capture V8 stack trace excluding constructor frame if (typeof Error.captureStackTrace === 'function') { @@ -31,8 +55,41 @@ export class ProcessorError extends Error { } } -// Flag to ensure we only warn once about stub implementation -let _processWarned = false; +/** + * Creates a minimal valid OpenAPI 3.1 document + * + * @returns Empty OpenAPI 3.1 document with required fields + */ +function createEmptyDocument(): OpenAPIV3_1.Document { + return { + openapi: '3.1.0', + info: { title: 'OpenAPI Server', version: '1.0.0' }, + paths: {}, + }; +} + +/** + * Checks if the input is empty or undefined + * + * @param input - The input to check + * @returns True if input is empty/undefined + */ +function isEmptyInput(input: string | Record | undefined | null): boolean { + if (!input) { + return true; + } + + if (typeof input === 'string') { + const trimmed = input.trim(); + return trimmed === '' || trimmed === '{}' || trimmed === '[]'; + } + + if (typeof input === 'object') { + return Object.keys(input).length === 0; + } + + return false; +} /** * Process an OpenAPI document through the full pipeline: @@ -40,39 +97,116 @@ let _processWarned = false; * 2. Upgrade - convert to OpenAPI 3.1 * 3. Dereference - inline all $ref pointers * - * @param input - OpenAPI document as file path, URL, or object - * @param options - Processing options + * @param input - OpenAPI document as file path, URL, YAML string, JSON string, or object + * @param _options - Processing options (reserved for future use) * @returns Fully dereferenced OpenAPI 3.1 document - * @throws ProcessorError if processing fails + * @throws ProcessorError if processing fails at any step * - * @remarks - * This is a stub implementation that returns the input as-is or an empty object. - * Full implementation coming in Task 1.2. + * @example + * ```typescript + * // From file path + * const doc = await processOpenApiDocument('./openapi/petstore.yaml'); + * + * // From URL + * const doc = await processOpenApiDocument('https://api.example.com/openapi.json'); + * + * // From object + * const doc = await processOpenApiDocument({ + * openapi: '3.0.0', + * info: { title: 'My API', version: '1.0.0' }, + * paths: {} + * }); + * ``` */ export async function processOpenApiDocument( input: string | Record, _options?: ProcessorOptions, -): Promise> { - // Log warning once about stub implementation - if (!_processWarned) { - // biome-ignore lint/suspicious/noConsole: Intentional warning for stub implementation - console.warn( - '[vite-open-api-core] processOpenApiDocument is not yet implemented (Task 1.2). Returning input as-is.', +): Promise { + // Handle empty/undefined input by returning minimal valid document + if (isEmptyInput(input)) { + return createEmptyDocument(); + } + + // Step 1: Bundle - Resolve external $ref references + let bundled: Record; + try { + const bundleResult = await bundle(input, { + plugins: [parseJson(), parseYaml(), readFiles(), fetchUrls()], + treeShake: false, + }); + bundled = bundleResult as Record; + } catch (error) { + throw new ProcessorError( + `Failed to bundle OpenAPI document: ${error instanceof Error ? error.message : String(error)}`, + 'bundle', ); - _processWarned = true; } - // Return input as-is if it's an object, otherwise return empty placeholder - if (typeof input === 'object' && input !== null) { - return input; + // Validate bundle result + if (!bundled || typeof bundled !== 'object') { + throw new ProcessorError('Bundled document is invalid: expected an object', 'bundle'); } - // For string inputs (file path/URL), return minimal OpenAPI stub - return { - openapi: '3.1.0', - info: { title: 'Stub', version: '0.0.0' }, - paths: {}, - _stub: true, - _source: input, - }; + // Step 2: Upgrade - Convert to OpenAPI 3.1 + let upgraded: OpenAPIV3_1.Document; + try { + // The upgrade function accepts the document and target version + upgraded = upgrade(bundled, '3.1') as OpenAPIV3_1.Document; + } catch (error) { + throw new ProcessorError( + `Failed to upgrade to OpenAPI 3.1: ${error instanceof Error ? error.message : String(error)}`, + 'upgrade', + ); + } + + // Validate upgrade result + if (!upgraded || typeof upgraded !== 'object') { + throw new ProcessorError( + 'Upgraded document is invalid: upgrade returned null or undefined', + 'upgrade', + ); + } + + // Step 3: Dereference - Inline all $ref pointers + let dereferenced: OpenAPIV3_1.Document; + try { + const result = await dereference(upgraded); + + // Check for dereference errors + if (result.errors && result.errors.length > 0) { + const errorMessages = result.errors.map((e) => e.message).join(', '); + throw new ProcessorError( + `Failed to dereference OpenAPI document: ${errorMessages}`, + 'dereference', + ); + } + + // Get the dereferenced schema + dereferenced = result.schema as OpenAPIV3_1.Document; + } catch (error) { + // Re-throw ProcessorError as-is + if (error instanceof ProcessorError) { + throw error; + } + + throw new ProcessorError( + `Failed to dereference OpenAPI document: ${error instanceof Error ? error.message : String(error)}`, + 'dereference', + ); + } + + // Validate dereference result + if (!dereferenced || typeof dereferenced !== 'object') { + throw new ProcessorError('Dereferenced schema is invalid: expected an object', 'dereference'); + } + + // Validate that we have a valid OpenAPI document + if (!dereferenced.openapi || !dereferenced.info) { + throw new ProcessorError( + 'Processed document is missing required OpenAPI fields (openapi, info)', + 'validation', + ); + } + + return dereferenced; } diff --git a/vitest.config.ts b/vitest.config.ts index b9a4532..32a27ee 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,12 +31,13 @@ export default defineConfig({ typecheck: { enabled: true, include: ['**/*.test-d.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '**/.git/**', '**/temp/**'], }, /** * Directories and patterns to exclude from test discovery. */ - exclude: ['**/node_modules/**', '**/dist/**', '**/.git/**'], + exclude: ['**/node_modules/**', '**/dist/**', '**/.git/**', '**/temp/**'], /** * Enable global test APIs (describe, it, expect) without explicit imports. From ad6be7c18d63cd49e537ae2ba59163dd71079df8 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Mon, 19 Jan 2026 16:20:28 +0000 Subject: [PATCH 2/6] chore: sync changeset for feature/task-1-2-openapi-processor --- .changesets/feature-task-1-2-openapi-processor.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.changesets/feature-task-1-2-openapi-processor.json b/.changesets/feature-task-1-2-openapi-processor.json index f6d6750..5835bab 100644 --- a/.changesets/feature-task-1-2-openapi-processor.json +++ b/.changesets/feature-task-1-2-openapi-processor.json @@ -7,7 +7,9 @@ "packages": [ "@websublime/vite-open-api-core" ], - "changes": [], + "changes": [ + "ac1dbda3d91789e59b4e04e066365b5a98762d40" + ], "created_at": "2026-01-19T16:19:44.597177Z", - "updated_at": "2026-01-19T16:19:44.597906Z" + "updated_at": "2026-01-19T16:20:28.435289Z" } \ No newline at end of file From ca4e2e06471cbce0f1f882e6a76b5e6d10f54a07 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Mon, 19 Jan 2026 16:35:39 +0000 Subject: [PATCH 3/6] refactor(core): reduce processor complexity and improve test coverage - Extract pipeline steps into helper functions (bundleDocument, upgradeDocument, dereferenceDocument) - Add DRY helpers: getErrorMessage, isValidObject - Document security considerations for SSRF and file read risks - Document basePath option as reserved for future use - Export ProcessorStep type for better type safety - Update TECHNICAL-SPECIFICATION.md with ProcessorError.step signature - Add 8 new tests for edge cases and error paths Cognitive complexity reduced from 22 to within allowed limits. Closes: vite-open-api-server-z5y.7 --- history/TECHNICAL-SPECIFICATION.md | 9 +- .../src/parser/__tests__/processor.test.ts | 83 ++++++- packages/core/src/parser/processor.ts | 219 +++++++++++++----- 3 files changed, 248 insertions(+), 63 deletions(-) diff --git a/history/TECHNICAL-SPECIFICATION.md b/history/TECHNICAL-SPECIFICATION.md index 71be904..24bc1f5 100644 --- a/history/TECHNICAL-SPECIFICATION.md +++ b/history/TECHNICAL-SPECIFICATION.md @@ -329,10 +329,17 @@ function createEmptyDocument(): OpenAPIV3_1.Document { }; } +/** Processing step identifier for error tracking */ +export type ProcessorStep = 'bundle' | 'upgrade' | 'dereference' | 'validation'; + export class ProcessorError extends Error { - constructor(message: string) { + /** The processing step that failed */ + readonly step: ProcessorStep; + + constructor(message: string, step: ProcessorStep = 'validation') { super(message); this.name = 'ProcessorError'; + this.step = step; } } ``` diff --git a/packages/core/src/parser/__tests__/processor.test.ts b/packages/core/src/parser/__tests__/processor.test.ts index ec545ac..a59ee10 100644 --- a/packages/core/src/parser/__tests__/processor.test.ts +++ b/packages/core/src/parser/__tests__/processor.test.ts @@ -8,7 +8,7 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { ProcessorError, processOpenApiDocument } from '../processor.js'; +import { ProcessorError, type ProcessorStep, processOpenApiDocument } from '../processor.js'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const fixturesDir = resolve(__dirname, 'fixtures'); @@ -25,6 +25,16 @@ describe('processOpenApiDocument', () => { }); }); + it('should return minimal document for null input', async () => { + const result = await processOpenApiDocument(null as unknown as string); + + expect(result).toEqual({ + openapi: '3.1.0', + info: { title: 'OpenAPI Server', version: '1.0.0' }, + paths: {}, + }); + }); + it('should return minimal document for empty string', async () => { const result = await processOpenApiDocument(''); @@ -35,6 +45,26 @@ describe('processOpenApiDocument', () => { }); }); + it('should return minimal document for empty object string "{}"', async () => { + const result = await processOpenApiDocument('{}'); + + expect(result).toEqual({ + openapi: '3.1.0', + info: { title: 'OpenAPI Server', version: '1.0.0' }, + paths: {}, + }); + }); + + it('should return minimal document for empty array string "[]"', async () => { + const result = await processOpenApiDocument('[]'); + + expect(result).toEqual({ + openapi: '3.1.0', + info: { title: 'OpenAPI Server', version: '1.0.0' }, + paths: {}, + }); + }); + it('should return minimal document for empty object', async () => { const result = await processOpenApiDocument({}); @@ -44,6 +74,16 @@ describe('processOpenApiDocument', () => { paths: {}, }); }); + + it('should return minimal document for whitespace-only string', async () => { + const result = await processOpenApiDocument(' \n\t '); + + expect(result).toEqual({ + openapi: '3.1.0', + info: { title: 'OpenAPI Server', version: '1.0.0' }, + paths: {}, + }); + }); }); describe('object input', () => { @@ -188,6 +228,32 @@ describe('processOpenApiDocument', () => { expect((error as ProcessorError).step).toBe('bundle'); } }); + + it('should throw ProcessorError with bundle step for invalid file path', async () => { + const invalidPath = '/this/path/definitely/does/not/exist/openapi.yaml'; + + try { + await processOpenApiDocument(invalidPath); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessorError); + expect((error as ProcessorError).step).toBe('bundle'); + expect((error as ProcessorError).message).toContain('Failed to bundle'); + } + }); + + it('should include original error message in ProcessorError', async () => { + const filePath = resolve(fixturesDir, 'non-existent.yaml'); + + try { + await processOpenApiDocument(filePath); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessorError); + // The error message should be descriptive + expect((error as ProcessorError).message.length).toBeGreaterThan(20); + } + }); }); describe('ProcessorError', () => { @@ -211,5 +277,20 @@ describe('processOpenApiDocument', () => { const error = new ProcessorError('Validation failed'); expect(error.step).toBe('validation'); }); + + it('should accept all valid ProcessorStep values', () => { + const steps: ProcessorStep[] = ['bundle', 'upgrade', 'dereference', 'validation']; + + for (const step of steps) { + const error = new ProcessorError(`Error at ${step}`, step); + expect(error.step).toBe(step); + } + }); + + it('should be instanceof Error', () => { + const error = new ProcessorError('Test'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(ProcessorError); + }); }); }); diff --git a/packages/core/src/parser/processor.ts b/packages/core/src/parser/processor.ts index 989a557..1738519 100644 --- a/packages/core/src/parser/processor.ts +++ b/packages/core/src/parser/processor.ts @@ -10,6 +10,15 @@ * 2. Upgrade - Convert OAS 2.0/3.0 to 3.1 for consistency * 3. Dereference - Inline all $ref pointers for easy traversal * + * @remarks + * **Security Considerations**: This processor is designed for development use only. + * It allows loading files and URLs from user input without sandboxing: + * - File paths can access any file readable by the process + * - URLs can fetch from any accessible endpoint + * + * Do NOT use this processor with untrusted input in production environments. + * For production use cases, implement URL allowlisting and path sandboxing. + * * @module parser/processor */ @@ -19,11 +28,21 @@ import { dereference } from '@scalar/openapi-parser'; import type { OpenAPIV3_1 } from '@scalar/openapi-types'; import { upgrade } from '@scalar/openapi-upgrader'; +/** Processing step identifier for error tracking */ +export type ProcessorStep = 'bundle' | 'upgrade' | 'dereference' | 'validation'; + /** * Options for processing OpenAPI documents + * + * @remarks + * The `basePath` option is reserved for future use. Currently, file paths + * are resolved relative to the current working directory. */ export interface ProcessorOptions { - /** Base directory for relative file resolution */ + /** + * Base directory for relative file resolution. + * @reserved This option is not yet implemented and is reserved for future use. + */ basePath?: string; } @@ -35,15 +54,13 @@ export interface ProcessorOptions { * - Bundle: Failed to resolve external references * - Upgrade: Failed to convert to OpenAPI 3.1 * - Dereference: Failed to inline $ref pointers + * - Validation: Processed document is missing required fields */ export class ProcessorError extends Error { /** The processing step that failed */ - readonly step: 'bundle' | 'upgrade' | 'dereference' | 'validation'; + readonly step: ProcessorStep; - constructor( - message: string, - step: 'bundle' | 'upgrade' | 'dereference' | 'validation' = 'validation', - ) { + constructor(message: string, step: ProcessorStep = 'validation') { super(message); this.name = 'ProcessorError'; this.step = step; @@ -55,6 +72,30 @@ export class ProcessorError extends Error { } } +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Extracts error message from unknown error type + * + * @param error - The caught error value + * @returns Human-readable error message + */ +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Type guard to check if a value is a valid object (non-null, non-array) + * + * @param value - The value to check + * @returns True if value is a valid object + */ +function isValidObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + /** * Creates a minimal valid OpenAPI 3.1 document * @@ -91,98 +132,93 @@ function isEmptyInput(input: string | Record | undefined | null return false; } +// ============================================================================ +// Pipeline Step Functions +// ============================================================================ + /** - * Process an OpenAPI document through the full pipeline: - * 1. Bundle - resolve external $ref references - * 2. Upgrade - convert to OpenAPI 3.1 - * 3. Dereference - inline all $ref pointers - * - * @param input - OpenAPI document as file path, URL, YAML string, JSON string, or object - * @param _options - Processing options (reserved for future use) - * @returns Fully dereferenced OpenAPI 3.1 document - * @throws ProcessorError if processing fails at any step + * Step 1: Bundle external $ref references * - * @example - * ```typescript - * // From file path - * const doc = await processOpenApiDocument('./openapi/petstore.yaml'); - * - * // From URL - * const doc = await processOpenApiDocument('https://api.example.com/openapi.json'); - * - * // From object - * const doc = await processOpenApiDocument({ - * openapi: '3.0.0', - * info: { title: 'My API', version: '1.0.0' }, - * paths: {} - * }); - * ``` + * @param input - OpenAPI document as file path, URL, or object + * @returns Bundled document with external refs resolved + * @throws ProcessorError if bundling fails */ -export async function processOpenApiDocument( +async function bundleDocument( input: string | Record, - _options?: ProcessorOptions, -): Promise { - // Handle empty/undefined input by returning minimal valid document - if (isEmptyInput(input)) { - return createEmptyDocument(); - } +): Promise> { + let bundled: unknown; - // Step 1: Bundle - Resolve external $ref references - let bundled: Record; try { - const bundleResult = await bundle(input, { + bundled = await bundle(input, { plugins: [parseJson(), parseYaml(), readFiles(), fetchUrls()], treeShake: false, }); - bundled = bundleResult as Record; } catch (error) { throw new ProcessorError( - `Failed to bundle OpenAPI document: ${error instanceof Error ? error.message : String(error)}`, + `Failed to bundle OpenAPI document: ${getErrorMessage(error)}`, 'bundle', ); } - // Validate bundle result - if (!bundled || typeof bundled !== 'object') { + if (!isValidObject(bundled)) { throw new ProcessorError('Bundled document is invalid: expected an object', 'bundle'); } - // Step 2: Upgrade - Convert to OpenAPI 3.1 - let upgraded: OpenAPIV3_1.Document; + return bundled; +} + +/** + * Step 2: Upgrade document to OpenAPI 3.1 + * + * @param bundled - Bundled OpenAPI document + * @returns Upgraded OpenAPI 3.1 document + * @throws ProcessorError if upgrade fails + */ +function upgradeDocument(bundled: Record): OpenAPIV3_1.Document { + let upgraded: unknown; + try { - // The upgrade function accepts the document and target version - upgraded = upgrade(bundled, '3.1') as OpenAPIV3_1.Document; + upgraded = upgrade(bundled, '3.1'); } catch (error) { throw new ProcessorError( - `Failed to upgrade to OpenAPI 3.1: ${error instanceof Error ? error.message : String(error)}`, + `Failed to upgrade to OpenAPI 3.1: ${getErrorMessage(error)}`, 'upgrade', ); } - // Validate upgrade result - if (!upgraded || typeof upgraded !== 'object') { + if (!isValidObject(upgraded)) { throw new ProcessorError( 'Upgraded document is invalid: upgrade returned null or undefined', 'upgrade', ); } - // Step 3: Dereference - Inline all $ref pointers - let dereferenced: OpenAPIV3_1.Document; + return upgraded as OpenAPIV3_1.Document; +} + +/** + * Step 3: Dereference all $ref pointers + * + * @param upgraded - Upgraded OpenAPI 3.1 document + * @returns Dereferenced document with all refs inlined + * @throws ProcessorError if dereferencing fails + */ +async function dereferenceDocument(upgraded: OpenAPIV3_1.Document): Promise { + let dereferenced: unknown; + try { const result = await dereference(upgraded); // Check for dereference errors if (result.errors && result.errors.length > 0) { - const errorMessages = result.errors.map((e) => e.message).join(', '); + const errorMessages = result.errors.map((e) => e?.message ?? 'Unknown error').join(', '); throw new ProcessorError( `Failed to dereference OpenAPI document: ${errorMessages}`, 'dereference', ); } - // Get the dereferenced schema - dereferenced = result.schema as OpenAPIV3_1.Document; + dereferenced = result.schema; } catch (error) { // Re-throw ProcessorError as-is if (error instanceof ProcessorError) { @@ -190,23 +226,84 @@ export async function processOpenApiDocument( } throw new ProcessorError( - `Failed to dereference OpenAPI document: ${error instanceof Error ? error.message : String(error)}`, + `Failed to dereference OpenAPI document: ${getErrorMessage(error)}`, 'dereference', ); } - // Validate dereference result - if (!dereferenced || typeof dereferenced !== 'object') { + if (!isValidObject(dereferenced)) { throw new ProcessorError('Dereferenced schema is invalid: expected an object', 'dereference'); } - // Validate that we have a valid OpenAPI document - if (!dereferenced.openapi || !dereferenced.info) { + return dereferenced as OpenAPIV3_1.Document; +} + +/** + * Validates the final processed document + * + * @param document - The processed OpenAPI document + * @throws ProcessorError if validation fails + */ +function validateDocument(document: OpenAPIV3_1.Document): void { + if (!document.openapi || !document.info) { throw new ProcessorError( 'Processed document is missing required OpenAPI fields (openapi, info)', 'validation', ); } +} + +// ============================================================================ +// Main Processor Function +// ============================================================================ + +/** + * Process an OpenAPI document through the full pipeline: + * 1. Bundle - resolve external $ref references + * 2. Upgrade - convert to OpenAPI 3.1 + * 3. Dereference - inline all $ref pointers + * + * @param input - OpenAPI document as file path, URL, YAML string, JSON string, or object + * @param _options - Processing options (reserved for future use) + * @returns Fully dereferenced OpenAPI 3.1 document + * @throws ProcessorError if processing fails at any step + * + * @remarks + * When input is empty, undefined, or an empty object, a minimal valid OpenAPI 3.1 + * document is returned instead of throwing an error. This allows graceful handling + * of missing or placeholder specifications. + * + * @example + * ```typescript + * // From file path + * const doc = await processOpenApiDocument('./openapi/petstore.yaml'); + * + * // From URL + * const doc = await processOpenApiDocument('https://api.example.com/openapi.json'); + * + * // From object + * const doc = await processOpenApiDocument({ + * openapi: '3.0.0', + * info: { title: 'My API', version: '1.0.0' }, + * paths: {} + * }); + * ``` + */ +export async function processOpenApiDocument( + input: string | Record, + _options?: ProcessorOptions, +): Promise { + // Handle empty/undefined input by returning minimal valid document + if (isEmptyInput(input)) { + return createEmptyDocument(); + } + + // Execute pipeline: bundle -> upgrade -> dereference -> validate + const bundled = await bundleDocument(input); + const upgraded = upgradeDocument(bundled); + const dereferenced = await dereferenceDocument(upgraded); + + validateDocument(dereferenced); return dereferenced; } From b22c8f6c8e8de2a0c858b48ec4137da332c7a219 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Mon, 19 Jan 2026 16:35:52 +0000 Subject: [PATCH 4/6] chore: update changeset for refactoring changes --- .changesets/feature-task-1-2-openapi-processor.json | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.changesets/feature-task-1-2-openapi-processor.json b/.changesets/feature-task-1-2-openapi-processor.json index 5835bab..38e9591 100644 --- a/.changesets/feature-task-1-2-openapi-processor.json +++ b/.changesets/feature-task-1-2-openapi-processor.json @@ -1,15 +1,13 @@ { "branch": "feature/task-1-2-openapi-processor", - "bump": "minor", + "bump": "patch", "environments": [ "production" ], "packages": [ "@websublime/vite-open-api-core" ], - "changes": [ - "ac1dbda3d91789e59b4e04e066365b5a98762d40" - ], - "created_at": "2026-01-19T16:19:44.597177Z", - "updated_at": "2026-01-19T16:20:28.435289Z" + "changes": [], + "created_at": "2026-01-19T16:35:47.532886Z", + "updated_at": "2026-01-19T16:35:47.534722Z" } \ No newline at end of file From e73993f80f4ef51da2745e7bfa8177bead248379 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Mon, 19 Jan 2026 16:36:12 +0000 Subject: [PATCH 5/6] chore: sync changeset for feature/task-1-2-openapi-processor --- .changesets/feature-task-1-2-openapi-processor.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.changesets/feature-task-1-2-openapi-processor.json b/.changesets/feature-task-1-2-openapi-processor.json index 38e9591..03cf319 100644 --- a/.changesets/feature-task-1-2-openapi-processor.json +++ b/.changesets/feature-task-1-2-openapi-processor.json @@ -7,7 +7,11 @@ "packages": [ "@websublime/vite-open-api-core" ], - "changes": [], + "changes": [ + "b22c8f6c8e8de2a0c858b48ec4137da332c7a219", + "ca4e2e06471cbce0f1f882e6a76b5e6d10f54a07", + "ac1dbda3d91789e59b4e04e066365b5a98762d40" + ], "created_at": "2026-01-19T16:35:47.532886Z", - "updated_at": "2026-01-19T16:35:47.534722Z" + "updated_at": "2026-01-19T16:36:12.781836Z" } \ No newline at end of file From 0848d614d9d3783649ada5c137a74f6b4e6864e7 Mon Sep 17 00:00:00 2001 From: Miguel Ramos Date: Mon, 19 Jan 2026 16:39:16 +0000 Subject: [PATCH 6/6] fix(core): address PR review comments for processor - Throw ProcessorError for array literal '[]' instead of treating as empty (arrays are not valid OpenAPI documents) - Update function signature to include null | undefined in input type to match runtime behavior - Update tests to use new signature without type assertions - Add test for '[]' validation error with step='validation' --- .../src/parser/__tests__/processor.test.ts | 21 +++++++----- packages/core/src/parser/processor.ts | 33 +++++++++++++++---- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/core/src/parser/__tests__/processor.test.ts b/packages/core/src/parser/__tests__/processor.test.ts index a59ee10..ab9218c 100644 --- a/packages/core/src/parser/__tests__/processor.test.ts +++ b/packages/core/src/parser/__tests__/processor.test.ts @@ -16,7 +16,7 @@ const fixturesDir = resolve(__dirname, 'fixtures'); describe('processOpenApiDocument', () => { describe('empty input handling', () => { it('should return minimal document for undefined input', async () => { - const result = await processOpenApiDocument(undefined as unknown as string); + const result = await processOpenApiDocument(undefined); expect(result).toEqual({ openapi: '3.1.0', @@ -26,7 +26,7 @@ describe('processOpenApiDocument', () => { }); it('should return minimal document for null input', async () => { - const result = await processOpenApiDocument(null as unknown as string); + const result = await processOpenApiDocument(null); expect(result).toEqual({ openapi: '3.1.0', @@ -55,14 +55,17 @@ describe('processOpenApiDocument', () => { }); }); - it('should return minimal document for empty array string "[]"', async () => { - const result = await processOpenApiDocument('[]'); + it('should throw ProcessorError for array literal string "[]"', async () => { + await expect(processOpenApiDocument('[]')).rejects.toThrow(ProcessorError); - expect(result).toEqual({ - openapi: '3.1.0', - info: { title: 'OpenAPI Server', version: '1.0.0' }, - paths: {}, - }); + try { + await processOpenApiDocument('[]'); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(ProcessorError); + expect((error as ProcessorError).step).toBe('validation'); + expect((error as ProcessorError).message).toContain('array literal'); + } }); it('should return minimal document for empty object', async () => { diff --git a/packages/core/src/parser/processor.ts b/packages/core/src/parser/processor.ts index 1738519..b4c394e 100644 --- a/packages/core/src/parser/processor.ts +++ b/packages/core/src/parser/processor.ts @@ -114,6 +114,7 @@ function createEmptyDocument(): OpenAPIV3_1.Document { * * @param input - The input to check * @returns True if input is empty/undefined + * @throws ProcessorError if input is an array literal '[]' (invalid OpenAPI document) */ function isEmptyInput(input: string | Record | undefined | null): boolean { if (!input) { @@ -122,7 +123,17 @@ function isEmptyInput(input: string | Record | undefined | null if (typeof input === 'string') { const trimmed = input.trim(); - return trimmed === '' || trimmed === '{}' || trimmed === '[]'; + + // Array literals are not valid OpenAPI documents - throw validation error + if (trimmed === '[]') { + throw new ProcessorError( + 'Invalid OpenAPI document: array literal "[]" is not a valid document', + 'validation', + ); + } + + // Empty string or empty object literal treated as empty input + return trimmed === '' || trimmed === '{}'; } if (typeof input === 'object') { @@ -263,16 +274,19 @@ function validateDocument(document: OpenAPIV3_1.Document): void { * 2. Upgrade - convert to OpenAPI 3.1 * 3. Dereference - inline all $ref pointers * - * @param input - OpenAPI document as file path, URL, YAML string, JSON string, or object + * @param input - OpenAPI document as file path, URL, YAML string, JSON string, object, null, or undefined * @param _options - Processing options (reserved for future use) * @returns Fully dereferenced OpenAPI 3.1 document * @throws ProcessorError if processing fails at any step * * @remarks - * When input is empty, undefined, or an empty object, a minimal valid OpenAPI 3.1 + * When input is empty, undefined, null, or an empty object, a minimal valid OpenAPI 3.1 * document is returned instead of throwing an error. This allows graceful handling * of missing or placeholder specifications. * + * Array literals (e.g., '[]') are not valid OpenAPI documents and will throw a + * ProcessorError with step 'validation'. + * * @example * ```typescript * // From file path @@ -287,19 +301,26 @@ function validateDocument(document: OpenAPIV3_1.Document): void { * info: { title: 'My API', version: '1.0.0' }, * paths: {} * }); + * + * // Null/undefined returns minimal document + * const doc = await processOpenApiDocument(null); * ``` */ export async function processOpenApiDocument( - input: string | Record, + input: string | Record | null | undefined, _options?: ProcessorOptions, ): Promise { - // Handle empty/undefined input by returning minimal valid document + // Handle empty/undefined/null input by returning minimal valid document if (isEmptyInput(input)) { return createEmptyDocument(); } + // At this point, input is guaranteed to be a non-empty string or object + // (isEmptyInput returns true for null/undefined and throws for '[]') + const validInput = input as string | Record; + // Execute pipeline: bundle -> upgrade -> dereference -> validate - const bundled = await bundleDocument(input); + const bundled = await bundleDocument(validInput); const upgraded = upgradeDocument(bundled); const dereferenced = await dereferenceDocument(upgraded);