Skip to content
10 changes: 10 additions & 0 deletions .changeset/1901.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@asyncapi/cli': minor
---

feat: add --ruleset flag for custom Spectral rules

- 5872e92: feat: add --ruleset flag for custom Spectral rules
- 97ec751: fixed the sonar issue


1 change: 1 addition & 0 deletions src/apps/cli/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export default class Validate extends Command {
...flags,
suppressWarnings: flags['suppressWarnings'],
suppressAllWarnings: flags['suppressAllWarnings'],
ruleset: flags['ruleset'],
};

const result = await this.validationService.validateDocument(
Expand Down
5 changes: 5 additions & 0 deletions src/apps/cli/internal/flags/validate.flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,10 @@ export const validateFlags = () => {
required: false,
default: false,
}),
ruleset: Flags.string({
description:
'Path to custom Spectral ruleset file (.js, .mjs, or .cjs)',
required: false,
}),
};
};
52 changes: 47 additions & 5 deletions src/domains/services/validation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
import { DiagnosticSeverity, Parser } from '@asyncapi/parser/cjs';
import { RamlDTSchemaParser } from '@asyncapi/raml-dt-schema-parser';
import { ProtoBuffSchemaParser } from '@asyncapi/protobuf-schema-parser';
import { getDiagnosticSeverity } from '@stoplight/spectral-core';
import { getDiagnosticSeverity, RulesetDefinition } from '@stoplight/spectral-core';
import * as fs from 'node:fs';
import {
html,
json,
Expand All @@ -23,7 +24,7 @@
text,
} from '@stoplight/spectral-formatters';
import { red, yellow, green, cyan } from 'chalk';
import { promises } from 'fs';
import { promises } from 'node:fs';
import path from 'path';

import type { Diagnostic } from '@asyncapi/parser/cjs';
Expand Down Expand Up @@ -88,7 +89,7 @@
schema: 'https',
order: 1,

read: async (uri: any) => {

Check warning on line 92 in src/domains/services/validation.service.ts

View workflow job for this annotation

GitHub Actions / Test NodeJS PR - ubuntu-latest

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed
let url = uri.toString();

// Default headers
Expand Down Expand Up @@ -254,6 +255,24 @@
}
}

/**
* Load a custom Spectral ruleset from file
*/
async loadCustomRuleset(rulesetPath: string): Promise<RulesetDefinition> {
const absolutePath = path.resolve(process.cwd(), rulesetPath);

if (!fs.existsSync(absolutePath)) {
throw new Error(`Ruleset file not found: ${absolutePath}`);
}

if (!rulesetPath.endsWith('.js') && !rulesetPath.endsWith('.mjs') && !rulesetPath.endsWith('.cjs')) {
throw new Error(`Only JavaScript ruleset files (.js, .mjs, .cjs) are supported. Provided: ${rulesetPath}`);
}

// eslint-disable-next-line @typescript-eslint/no-var-requires
return require(absolutePath);

Check warning on line 273 in src/domains/services/validation.service.ts

View workflow job for this annotation

GitHub Actions / Test NodeJS PR - ubuntu-latest

Found non-literal argument in require
}

/**
* Validates an AsyncAPI document
*/
Expand All @@ -264,9 +283,25 @@
try {
const suppressAllWarnings = options.suppressAllWarnings ?? false;
const suppressedWarnings = options.suppressWarnings ?? [];
const customRulesetPath = options.ruleset;
let activeParser: Parser;

if (suppressAllWarnings || suppressedWarnings.length) {
if (customRulesetPath) {
const customRuleset = await this.loadCustomRuleset(customRulesetPath);
activeParser = new Parser({
ruleset: customRuleset,
__unstable: {
resolver: {
cache: false,
resolvers: [createHttpWithAuthResolver()],
},
},
});
activeParser.registerSchemaParser(AvroSchemaParser());
activeParser.registerSchemaParser(OpenAPISchemaParser());
activeParser.registerSchemaParser(RamlDTSchemaParser());
activeParser.registerSchemaParser(ProtoBuffSchemaParser());
} else if (suppressAllWarnings || suppressedWarnings.length) {
activeParser = await this.buildAndRegisterCustomParser(
specFile,
suppressedWarnings,
Expand All @@ -293,8 +328,15 @@
};

return this.createSuccessResult<ValidationResult>(result);
} catch (error) {
return this.handleServiceError(error);
} catch (error: any) {
let errorMessage = error?.message || error?.toString() || 'Unknown error';

if (error?.errors && Array.isArray(error.errors)) {
const errors = error.errors.map((e: any) => e?.message || e?.toString()).join('; ');
errorMessage = `${errorMessage}: ${errors}`;
}

return this.createErrorResult(errorMessage);
}
}

Expand Down
1 change: 1 addition & 0 deletions src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface ValidationOptions {
output?: string;
suppressWarnings?: string[];
suppressAllWarnings?: boolean;
ruleset?: string;
}

export interface ValidationResult {
Expand Down
112 changes: 112 additions & 0 deletions test/unit/services/validation.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';

const spectralFunctionsPath = require.resolve('@stoplight/spectral-functions');
const customJsRuleset = `
const { pattern } = require('${spectralFunctionsPath.replace(/\\/g, '/')}');
module.exports = {
extends: [],
rules: {
'asyncapi-latest-version': {
description: 'Checks AsyncAPI version',
recommended: true,
severity: 3,
given: '$.asyncapi',
then: {
function: pattern,
functionOptions: {
match: '^2',
},
},
},
},
};
`;

const asyncAPIWithDescription = `{
"asyncapi": "2.6.0",
"info": {
"title": "Test Service",
"version": "1.0.0",
"description": "A test service description"
},
"channels": {}
}`;

const asyncAPIWithoutDescription = `{
"asyncapi": "2.6.0",
"info": {
"title": "Test Service",
"version": "1.0.0"
},
"channels": {}
}`;

const validAsyncAPI = `{
"asyncapi": "2.6.0",
"info": {
Expand Down Expand Up @@ -251,4 +292,75 @@ describe('ValidationService', () => {
}
});
});

describe('validateDocument() with custom rulesets', () => {
let tempDir: string;
let jsRulesetPath: string;

beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'asyncapi-test-'));
jsRulesetPath = path.join(tempDir, '.spectral.js');

await fs.writeFile(jsRulesetPath, customJsRuleset, 'utf8');
});

afterEach(async () => {
try {
await fs.rm(tempDir, { recursive: true });
} catch (err) {
// Ignore cleanup errors
}
});

it('should validate with custom JS ruleset', async () => {
const specFile = new Specification(validAsyncAPI);
const options = {
'diagnostics-format': 'stylish' as const,
ruleset: jsRulesetPath
};

const result = await validationService.validateDocument(specFile, options);

if (!result.success) {
console.error('Test error:', JSON.stringify(result, null, 2));
}
expect(result.success).to.equal(true);
if (result.success) {
expect(result.data).to.have.property('status');
expect(result.data).to.have.property('diagnostics');
}
});

it('should handle non-existent ruleset file', async () => {
const specFile = new Specification(validAsyncAPI);
const options = {
'diagnostics-format': 'stylish' as const,
ruleset: '/non/existent/path/.spectral.yaml'
};

const result = await validationService.validateDocument(specFile, options);

expect(result.success).to.equal(false);
expect(result.error).to.include('Ruleset file not found');
});

it('should load custom ruleset using loadCustomRuleset method', async () => {
const ruleset = await validationService.loadCustomRuleset(jsRulesetPath);
// eslint-disable-next-line no-unused-expressions
expect(ruleset).to.exist;
});

it('should reject unsupported ruleset file types', async () => {
const yamlPath = path.join(tempDir, '.spectral.yaml');
await fs.writeFile(yamlPath, 'rules: {}', 'utf8');

try {
await validationService.loadCustomRuleset(yamlPath);
expect.fail('Should have thrown an error');
} catch (error: any) {
expect(error.message).to.include('Only JavaScript ruleset files');
expect(error.message).to.include('.js, .mjs, .cjs');
}
});
});
});
Loading