From 2918f98f8a5554ebd2ffa10ae4004cc84389183b Mon Sep 17 00:00:00 2001 From: arnavk23 Date: Mon, 25 May 2026 04:29:43 +0530 Subject: [PATCH] feat: add publish/sync/share commands and RegistryService for sharing and registry operations --- docs/usage.md | 5 +- src/apps/cli/commands/publish.ts | 40 ++++++++++ src/apps/cli/commands/share.ts | 39 ++++++++++ src/apps/cli/commands/sync.ts | 51 ++++++++++++ src/apps/cli/commands/validate.ts | 26 +++++++ src/apps/cli/internal/flags/validate.flags.ts | 5 ++ src/domains/services/registry.service.ts | 77 +++++++++++++++++++ src/domains/services/validation.service.ts | 77 ++++++++++++------- test/fixtures/custom-ruleset.yml | 2 + test/integration/validate.test.ts | 16 ++++ 10 files changed, 308 insertions(+), 30 deletions(-) create mode 100644 src/apps/cli/commands/publish.ts create mode 100644 src/apps/cli/commands/share.ts create mode 100644 src/apps/cli/commands/sync.ts create mode 100644 src/domains/services/registry.service.ts create mode 100644 test/fixtures/custom-ruleset.yml diff --git a/docs/usage.md b/docs/usage.md index ecfa68d17..8844d05df 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -905,8 +905,8 @@ validate asyncapi file USAGE $ asyncapi validate [SPEC-FILE] [-h] [-w] [--log-diagnostics] [--diagnostics-format json|stylish|junit|html|text|teamcity|pretty|github-actions|sarif|code-climate|gitlab|markdown] [--fail-severity - error|warn|info|hint] [-s ] [--score] [--suppressWarnings ...] [--suppressAllWarnings] [--proxyHost - ] [--proxyPort ] + error|warn|info|hint] [-s ] [--score] [--suppressWarnings ...] [--suppressAllWarnings] [--ruleset + ] [--proxyHost ] [--proxyPort ] ARGUMENTS [SPEC-FILE] spec path, url, or context-name @@ -928,6 +928,7 @@ FLAGS document has description, license, server and/or channels. --suppressAllWarnings Suppress all warnings from the validation output. --suppressWarnings=... List of warning codes to suppress from the validation output. + --ruleset= Path to a custom Spectral ruleset file used during validation. DESCRIPTION validate asyncapi file diff --git a/src/apps/cli/commands/publish.ts b/src/apps/cli/commands/publish.ts new file mode 100644 index 000000000..cd5c6d5ba --- /dev/null +++ b/src/apps/cli/commands/publish.ts @@ -0,0 +1,40 @@ +import { Args, Flags } from '@oclif/core'; +import Command from '@cli/internal/base'; +import { load } from '@models/SpecificationFile'; +import { RegistryService } from '@services/registry.service'; +import { proxyFlags } from '@cli/internal/flags/proxy.flags'; +import { applyProxyToPath } from '@utils/proxy'; + +export default class Publish extends Command { + static description = 'publish AsyncAPI file to a schema registry or endpoint'; + + static flags = { + endpoint: Flags.string({ description: 'Full registry endpoint URL to publish to' }), + 'content-type': Flags.string({ description: 'Content-Type for the published document', default: 'application/yaml' }), + ...proxyFlags(), + }; + + static args = { + 'spec-file': Args.string({ description: 'Spec path or url', required: true }), + }; + + async run() { + const { args, flags } = await this.parse(Publish); + const filePath = applyProxyToPath(args['spec-file'], flags['proxyHost'], flags['proxyPort']); + this.specFile = await load(filePath); + + const registry = new RegistryService(); + const endpoint = flags.endpoint || ''; + + const { success, location, error } = await registry.publish(this.specFile, endpoint || filePath || '', { + endpoint: endpoint || undefined, + contentType: flags['content-type'], + }); + + if (!success) { + this.error(error || 'Publish failed', { exit: 1 }); + } + + this.log(`Published successfully${location ? ` at ${location}` : ''}`); + } +} diff --git a/src/apps/cli/commands/share.ts b/src/apps/cli/commands/share.ts new file mode 100644 index 000000000..803072538 --- /dev/null +++ b/src/apps/cli/commands/share.ts @@ -0,0 +1,39 @@ +import { Args, Flags } from '@oclif/core'; +import Command from '@cli/internal/base'; +import { load } from '@models/SpecificationFile'; +import { RegistryService } from '@services/registry.service'; +import { proxyFlags } from '@cli/internal/flags/proxy.flags'; +import { applyProxyToPath } from '@utils/proxy'; + +export default class Share extends Command { + static description = 'share AsyncAPI file (convenience wrapper around publish)'; + + static flags = { + endpoint: Flags.string({ description: 'Share endpoint URL' }), + ...proxyFlags(), + }; + + static args = { + 'spec-file': Args.string({ description: 'Spec path or url', required: true }), + }; + + async run() { + const { args, flags } = await this.parse(Share); + const filePath = applyProxyToPath(args['spec-file'], flags['proxyHost'], flags['proxyPort']); + this.specFile = await load(filePath); + + const registry = new RegistryService(); + const endpoint = flags.endpoint || ''; + + const { success, location, error } = await registry.publish(this.specFile, endpoint || filePath || '', { + endpoint: endpoint || undefined, + contentType: 'application/yaml', + }); + + if (!success) { + this.error(error || 'Share failed', { exit: 1 }); + } + + this.log(`Shared successfully${location ? ` at ${location}` : ''}`); + } +} diff --git a/src/apps/cli/commands/sync.ts b/src/apps/cli/commands/sync.ts new file mode 100644 index 000000000..a6380e074 --- /dev/null +++ b/src/apps/cli/commands/sync.ts @@ -0,0 +1,51 @@ +import { Args, Flags } from '@oclif/core'; +import Command from '@cli/internal/base'; +import { load } from '@models/SpecificationFile'; +import { RegistryService } from '@services/registry.service'; +import { applyProxyToPath } from '@utils/proxy'; +import { proxyFlags } from '@cli/internal/flags/proxy.flags'; +import { promises as fs } from 'fs'; +import path from 'path'; + +export default class Sync extends Command { + static description = 'sync local AsyncAPI file with remote registry (push or pull)'; + + static flags = { + endpoint: Flags.string({ description: 'Remote registry endpoint or document URL' }), + direction: Flags.string({ description: 'sync direction: push or pull', options: ['push', 'pull'], default: 'push' }), + out: Flags.string({ description: 'Output file when pulling' }), + ...proxyFlags(), + }; + + static args = { + 'spec-file': Args.string({ description: 'Local spec path (for push) or target file (for pull)', required: true }), + }; + + async run() { + const { args, flags } = await this.parse(Sync); + const registry = new RegistryService(); + const endpoint = flags.endpoint; + const direction = flags.direction as 'push' | 'pull'; + + if (direction === 'push') { + const filePath = applyProxyToPath(args['spec-file'], flags['proxyHost'], flags['proxyPort']); + this.specFile = await load(filePath); + const { success, error } = await registry.publish(this.specFile, endpoint || filePath || '', { endpoint: endpoint || undefined }); + if (!success) this.error(error || 'Push failed', { exit: 1 }); + this.log('Push succeeded'); + return; + } + + // pull + if (!endpoint) { + this.error('Endpoint required for pull', { exit: 1 }); + } + + const pullRes = await registry.pull(endpoint as string); + if (!pullRes.success) this.error(pullRes.error || 'Pull failed', { exit: 1 }); + + const outPath = flags.out || args['spec-file']; + await fs.writeFile(path.resolve(outPath), pullRes.content || '', 'utf8'); + this.log(`Pulled content saved to ${outPath}`); + } +} diff --git a/src/apps/cli/commands/validate.ts b/src/apps/cli/commands/validate.ts index ce761c24c..c15f72317 100644 --- a/src/apps/cli/commands/validate.ts +++ b/src/apps/cli/commands/validate.ts @@ -1,4 +1,7 @@ import { Args } from '@oclif/core'; +import { promises as fs } from 'fs'; +import yaml from 'js-yaml'; +import path from 'path'; import Command from '@cli/internal/base'; import { load } from '@models/SpecificationFile'; import { specWatcher } from '@cli/internal/globals'; @@ -41,6 +44,11 @@ export default class Validate extends Command { this.specFile = await load(filePath); const watchMode = flags.watch; + const customRuleset = flags.ruleset + ? await this.loadCustomRuleset(flags.ruleset) + : undefined; + + this.validationService = new ValidationService({}, customRuleset); if (watchMode) { specWatcher({ @@ -81,6 +89,24 @@ export default class Validate extends Command { } } + private async loadCustomRuleset(rulesetPath: string): Promise> { + const absolutePath = path.resolve(rulesetPath); + const rulesetContent = await fs.readFile(absolutePath, 'utf8'); + + try { + const parsedRuleset = yaml.load(rulesetContent) as Record; + + if (!parsedRuleset || typeof parsedRuleset !== 'object' || Array.isArray(parsedRuleset)) { + throw new Error('The ruleset file must resolve to an object.'); + } + + return parsedRuleset; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`Failed to load custom ruleset from ${absolutePath}: ${message}`); + } + } + private async handleDiagnostics( result: ServiceResult, flags: any, diff --git a/src/apps/cli/internal/flags/validate.flags.ts b/src/apps/cli/internal/flags/validate.flags.ts index 95a2268aa..14abfa7d1 100644 --- a/src/apps/cli/internal/flags/validate.flags.ts +++ b/src/apps/cli/internal/flags/validate.flags.ts @@ -24,5 +24,10 @@ export const validateFlags = () => { required: false, default: false, }), + ruleset: Flags.string({ + description: + 'Path to a custom Spectral ruleset file used during validation.', + required: false, + }), }; }; diff --git a/src/domains/services/registry.service.ts b/src/domains/services/registry.service.ts new file mode 100644 index 000000000..e912b9191 --- /dev/null +++ b/src/domains/services/registry.service.ts @@ -0,0 +1,77 @@ +import { BaseService } from './base.service'; +import { Specification } from '@models/SpecificationFile'; +import { ConfigService } from './config.service'; + +export interface PublishOptions { + endpoint?: string; // full URL to POST the document to + path?: string; // path appended to endpoint + contentType?: string; +} + +export class RegistryService extends BaseService { + /** + * Publish an AsyncAPI specification to a registry endpoint. + * This performs a simple POST with the document text. Authentication + * is taken from `ConfigService` when available for the given URL. + */ + async publish( + spec: Specification, + registryUrl: string, + options: PublishOptions = {}, + ): Promise<{ success: boolean; location?: string; error?: string }> { + try { + const url = options.endpoint || registryUrl; + const headers: Record = { + 'Content-Type': options.contentType || 'application/yaml', + 'User-Agent': 'AsyncAPI-CLI', + }; + + const auth = await ConfigService.getAuthForUrl(url); + if (auth) { + headers['Authorization'] = `${auth.authType} ${auth.token}`; + Object.assign(headers, auth.headers || {}); + } + + const res = await fetch(url + (options.path ?? ''), { + method: 'POST', + headers, + body: spec.text(), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + return { success: false, error: `Publish failed: ${res.status} ${res.statusText} ${body}` }; + } + + const location = res.headers.get('location') || undefined; + return { success: true, location }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { success: false, error: msg }; + } + } + + /** + * Pull a document from a remote registry URL. + */ + async pull(url: string): Promise<{ success: boolean; content?: string; error?: string }> { + try { + const headers: Record = { 'User-Agent': 'AsyncAPI-CLI' }; + const auth = await ConfigService.getAuthForUrl(url); + if (auth) { + headers['Authorization'] = `${auth.authType} ${auth.token}`; + Object.assign(headers, auth.headers || {}); + } + + const res = await fetch(url, { headers }); + if (!res.ok) { + return { success: false, error: `Failed to fetch: ${res.status} ${res.statusText}` }; + } + const text = await res.text(); + return { success: true, content: text }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { success: false, error: msg }; + } + } +} diff --git a/src/domains/services/validation.service.ts b/src/domains/services/validation.service.ts index 11ac45fc4..3806334f4 100644 --- a/src/domains/services/validation.service.ts +++ b/src/domains/services/validation.service.ts @@ -201,31 +201,43 @@ const validFormats = [ export class ValidationService extends BaseService { private parser: Parser; + private readonly parserOptions: ParserOptions; + private readonly customRuleset?: Record; - constructor(parserOptions: ParserOptions = {}) { + constructor( + parserOptions: ParserOptions = {}, + ruleset?: Record, + ) { super(); + this.parserOptions = parserOptions; + this.customRuleset = ruleset; + + this.parser = this.createParser(this.customRuleset); + this.parser.registerSchemaParser(OpenAPISchemaParser()); + this.parser.registerSchemaParser(RamlDTSchemaParser()); + this.parser.registerSchemaParser(AvroSchemaParser()); + this.parser.registerSchemaParser(ProtoBuffSchemaParser()); + } + + private createParser(ruleset?: Record): Parser { // Create parser with custom GitHub resolver const customParserOptions = { - ...parserOptions, + ...this.parserOptions, + ...(ruleset ? { ruleset } : {}), __unstable: { - ...parserOptions.__unstable, + ...this.parserOptions.__unstable, resolver: { - ...parserOptions.__unstable?.resolver, + ...this.parserOptions.__unstable?.resolver, cache: false, resolvers: [ createHttpWithAuthResolver(), - ...(parserOptions.__unstable?.resolver?.resolvers || []) + ...(this.parserOptions.__unstable?.resolver?.resolvers || []) ], }, }, }; - this.parser = new Parser(customParserOptions); - - this.parser.registerSchemaParser(OpenAPISchemaParser()); - this.parser.registerSchemaParser(RamlDTSchemaParser()); - this.parser.registerSchemaParser(AvroSchemaParser()); - this.parser.registerSchemaParser(ProtoBuffSchemaParser()); + return new Parser(customParserOptions); } /** @@ -326,20 +338,10 @@ export class ValidationService extends BaseService { * Creates a custom parser with specific rules turned off. */ private buildCustomParser(rulesToSuppress: string[]): Parser { - return new Parser({ - ruleset: { - extends: [], - rules: Object.fromEntries( - rulesToSuppress.map((rule) => [rule, 'off']), - ), - }, - __unstable: { - resolver: { - cache: false, - resolvers: [createHttpWithAuthResolver()], - }, - }, - }); + const ruleset = this.mergeRulesetWithOverrides( + Object.fromEntries(rulesToSuppress.map((rule) => [rule, 'off'])), + ); + return this.createParser(ruleset); } /** @@ -362,15 +364,34 @@ export class ValidationService extends BaseService { source: specFile.getSource(), }); const allRuleNames = Array.from( - new Set( + new Set( diagnostics - .map((d) => d.code) - .filter((c): c is string => typeof c === 'string'), + .map((diagnostic: Diagnostic) => diagnostic.code) + .filter((code: unknown): code is string => typeof code === 'string'), ), ); return this.buildCustomParser(allRuleNames); } + private mergeRulesetWithOverrides( + rulesToOverride: Record, + ): Record { + const baseRuleset = (this.customRuleset ?? {}) as Record; + const baseRules = (baseRuleset.rules ?? {}) as Record; + const baseExtends = Array.isArray(baseRuleset.extends) + ? baseRuleset.extends + : []; + + return { + ...baseRuleset, + extends: baseExtends, + rules: { + ...baseRules, + ...rulesToOverride, + }, + }; + } + /** * Builds a parser that suppresses specific warnings, handling invalid rules gracefully. */ diff --git a/test/fixtures/custom-ruleset.yml b/test/fixtures/custom-ruleset.yml new file mode 100644 index 000000000..3b7c5a385 --- /dev/null +++ b/test/fixtures/custom-ruleset.yml @@ -0,0 +1,2 @@ +rules: + asyncapi-id: off \ No newline at end of file diff --git a/test/integration/validate.test.ts b/test/integration/validate.test.ts index ea888cafc..bc3c49cc5 100644 --- a/test/integration/validate.test.ts +++ b/test/integration/validate.test.ts @@ -360,6 +360,22 @@ describe('validate', () => { }); }); + describe('validate command with a custom ruleset', () => { + test + .stdout() + .command([ + 'validate', + path.join('test', 'fixtures', 'specification.yml'), + '--ruleset', + path.join('test', 'fixtures', 'custom-ruleset.yml') + ]) + .it('should apply custom Spectral rules from the provided ruleset file', (ctx, done) => { + expect(ctx.stdout).to.not.include('asyncapi-id'); + expect(ctx.stdout).to.include('File ./test/fixtures/specification.yml is valid but has (itself and/or referenced documents) governance issues.'); + done(); + }); + }); + describe('with --save-output flag', () => { beforeEach(() => { testHelper.createDummyContextFile();