Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/throw-on-validation-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@asyncapi/parser": minor
---

Add `throwOnError` option to `parse()` to surface validation errors as thrown exceptions instead of returning them in the result. Defaults to `false` to preserve backward compatibility. When set to `true`, validation errors are thrown synchronously from `parse()`, and `parseAndValidate()` rethrows the same error rather than just attaching it to the result.
43 changes: 41 additions & 2 deletions packages/parser/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { applyUniqueIds, customOperations } from './custom-operations';
import { validate } from './validate';
import { copy } from './stringify';
import { createAsyncAPIDocument } from './document';
import { createDetailedAsyncAPI, mergePatch, setExtension, createUncaghtDiagnostic } from './utils';
import { createDetailedAsyncAPI, mergePatch, setExtension, createUncaghtDiagnostic, hasErrorDiagnostic } from './utils';

import { xParserSpecParsed, xParserApiVersion } from './constants';

import type { Spectral, Document, RulesetFunctionContext } from '@stoplight/spectral-core';
import type { Spectral, RulesetFunctionContext } from '@stoplight/spectral-core';
import { Document } from '@stoplight/spectral-core';
import type { Parser } from './parser';
import type { ResolverOptions } from './resolver';
import type { ValidateOptions } from './validate';
Expand All @@ -27,6 +28,16 @@ export interface ParseOptions {
applyTraits?: boolean;
parseSchemas?: boolean;
validateOptions?: Omit<ValidateOptions, 'source'>;
/**
* If `true`, the parser will throw an `AsyncAPIParseError` when the input
* document fails validation (i.e. one or more diagnostics with
* `severity === DiagnosticSeverity.Error` are produced). The thrown error
* carries the diagnostics on its `diagnostics` property.
*
* Defaults to `false` to preserve the historical "return undefined document
* alongside diagnostics" behavior. See https://github.com/asyncapi/parser-js/issues/878
*/
throwOnError?: boolean;
__unstable?: {
resolver?: Omit<ResolverOptions, 'cache'>;
};
Expand All @@ -35,6 +46,7 @@ export interface ParseOptions {
const defaultOptions: ParseOptions = {
applyTraits: true,
parseSchemas: true,
throwOnError: false,
validateOptions: {},
__unstable: {},
};
Expand All @@ -54,6 +66,18 @@ export async function parse(parser: Parser, spectral: Spectral, asyncapi: Input,

const { validated, diagnostics, extras } = await validate(parser, spectral, asyncapi, { ...options.validateOptions, source: options.source, __unstable: options.__unstable });
if (validated === undefined) {
// Issue #878: when validation fails the parser used to silently return
// a result with `document: undefined`, which made it easy for callers to
// miss the failure (e.g. `result.document()` would throw a confusing
// "is not a function" error). With `throwOnError` enabled we surface the
// failure with a descriptive error instead.
if (options.throwOnError && hasErrorDiagnostic(diagnostics)) {
throw new AsyncAPIParseError(
'AsyncAPI document failed validation. See the `diagnostics` property for details.',
diagnostics,
extras?.document
);
}
return {
document: undefined,
diagnostics,
Expand Down Expand Up @@ -81,6 +105,21 @@ export async function parse(parser: Parser, spectral: Spectral, asyncapi: Input,
extras,
};
} catch (err: unknown) {
if (err instanceof AsyncAPIParseError) {
throw err;
}
return { document: undefined, diagnostics: createUncaghtDiagnostic(err, 'Error thrown during AsyncAPI document parsing', spectralDocument), extras: undefined };
}
}

export class AsyncAPIParseError extends Error {
public readonly diagnostics: Diagnostic[];
public readonly document?: Document;

constructor(message: string, diagnostics: Diagnostic[], document?: Document) {
super(message);
this.name = 'AsyncAPIParseError';
this.diagnostics = diagnostics;
this.document = document;
}
}
79 changes: 79 additions & 0 deletions packages/parser/test/parse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1276,3 +1276,82 @@ components:
expect(filterLastVersionDiagnostics(diagnostics)[0].range.start.line === 236).toEqual(true);
});
});

describe('parse() - throwOnError (issue #878)', function () {
const parser = new Parser();

const invalidV3Document = {
asyncapi: '3.0.0',
// `test` is not part of the AsyncAPI 3.0 root schema, so this triggers
// a validation error (severity === 0). See https://github.com/asyncapi/parser-js/issues/878
test: 'hello',
info: {
title: 'Invalid AsyncApi document',
version: '1.0',
},
channels: {},
};

it('should return document: undefined with throwOnError: false (default behavior)', async function () {
const result = await parser.parse(invalidV3Document);
expect(result.document).toBeUndefined();
expect(result.diagnostics.some(d => d.severity === 0)).toBe(true);
});

it('should return document: undefined when throwOnError: false is passed explicitly', async function () {
const result = await parser.parse(invalidV3Document, { throwOnError: false });
expect(result.document).toBeUndefined();
expect(result.diagnostics.some(d => d.severity === 0)).toBe(true);
});

it('should throw AsyncAPIParseError when throwOnError: true and there are validation errors', async function () {
await expect(parser.parse(invalidV3Document, { throwOnError: true }))
.rejects.toMatchObject({
name: 'AsyncAPIParseError',
message: expect.stringContaining('failed validation'),
diagnostics: expect.arrayContaining([
expect.objectContaining({ severity: 0 })
])
});
});

it('should expose the diagnostics on the thrown error', async function () {
let caught: any;
try {
await parser.parse(invalidV3Document, { throwOnError: true });
} catch (e) {
caught = e;
}
expect(caught).toBeDefined();
expect(caught.name).toBe('AsyncAPIParseError');
expect(Array.isArray(caught.diagnostics)).toBe(true);
expect(caught.diagnostics.some((d: any) => d.severity === 0)).toBe(true);
});

it('should not throw on a valid document even with throwOnError: true', async function () {
const validDoc = {
asyncapi: '3.0.0',
info: {
title: 'Valid AsyncApi document',
version: '1.0',
},
channels: {},
};
const result = await parser.parse(validDoc, { throwOnError: true });
expect(result.document).toBeInstanceOf(AsyncAPIDocumentV3);
});

it('should not throw when diagnostics only contain warnings/infos and throwOnError: true', async function () {
// latest-version info + no errors
const minorDoc = {
asyncapi: '3.0.0',
info: {
title: 'Valid but old version',
version: '1.0',
},
channels: {},
};
const result = await parser.parse(minorDoc, { throwOnError: true });
expect(result.document).toBeDefined();
});
});
Loading