diff --git a/src/commands/start/preview.ts b/src/commands/start/preview.ts new file mode 100644 index 000000000..7f056f28e --- /dev/null +++ b/src/commands/start/preview.ts @@ -0,0 +1,147 @@ +import Command from '../../core/base'; +import { start as startStudio } from '../../core/models/Studio'; +import { load } from '../../core/models/SpecificationFile'; +import bundle from '@asyncapi/bundler'; +import path from 'path'; +import fs from 'fs'; +import { Args } from '@oclif/core'; +import chokidar from 'chokidar'; +import { previewFlags } from '../../core/flags/start/preview.flags'; +import * as yaml from 'js-yaml'; +import { NO_CONTEXTS_SAVED } from '../../core/errors/context-error'; + +interface LocalRef { + filePath: string; + pointer: string | null; +} + +class StartPreview extends Command { + static description = + 'Starts Studio in preview mode with local reference files bundled and hot reloading enabled.'; + + static examples = [ + 'asyncapi start preview', + 'asyncapi start preview ./asyncapi.yaml', + 'asyncapi start preview CONTEXT_NAME' + ]; + + static flags = previewFlags(); + + static args = { + 'spec-file': Args.string({ + description: 'spec path, url, or context-name', + required: false, + }), + }; + + parseRef(ref: string): LocalRef { + const [filePath, pointer] = ref.split('#'); + return { + filePath: filePath || '', + pointer: pointer || null, + }; + } + + findLocalRefFiles(obj: any, basePath: string, files: Set): void { + if (typeof obj === 'object' && obj !== null) { + for (const [key, value] of Object.entries(obj)) { + if ( + key === '$ref' && + typeof value === 'string' && + (value.startsWith('.') || value.startsWith('./') || value.startsWith('../')) + ) { + const { filePath } = this.parseRef(value); + const resolvedPath = path.resolve(basePath, filePath); + + if (fs.existsSync(resolvedPath)) { + files.add(resolvedPath); + const referencedFile = yaml.load( + fs.readFileSync(resolvedPath, 'utf8') + ); + this.findLocalRefFiles(referencedFile, path.dirname(resolvedPath), files); + } else { + this.error(`Missing local reference: ${value}`); + } + } else { + this.findLocalRefFiles(value, basePath, files); + } + } + } else if (Array.isArray(obj)) { + for (const item of obj) { + this.findLocalRefFiles(item, basePath, files); + } + } + } + + async updateBundledFile( + AsyncAPIFile: string, + outputFormat: string, + bundledFilePath: string + ) { + try { + const document = await bundle(AsyncAPIFile); + const fileContent = + outputFormat === '.yaml' || outputFormat === '.yml' + ? document.yml() + : JSON.stringify(document.json(), null, 2); + fs.writeFileSync(bundledFilePath, fileContent, { encoding: 'utf-8' }); + this.log('Bundled file updated successfully.'); + } catch (error: any) { + this.error(`Error bundling files: ${error.message}`); + } + } + + async run() { + const { args, flags } = await this.parse(StartPreview); + const port = flags.port; + const filePath = args['spec-file']; + + this.log('Starting Studio in preview mode...'); + + this.specFile = await load(filePath); + this.metricsMetadata.port = port; + + const AsyncAPIFile = this.specFile.getFilePath(); + + if (!AsyncAPIFile) { + this.error(NO_CONTEXTS_SAVED); + } + + const outputFormat = path.extname(AsyncAPIFile); + if (!outputFormat) { + this.error('Unable to determine file format from the provided AsyncAPI file.'); + } + + const bundledFilePath = `./asyncapi-bundled${outputFormat}`; + const basePath = path.dirname(path.resolve(AsyncAPIFile)); + const filesToWatch = new Set(); + + filesToWatch.add(this.specFile.getFilePath()!); + const asyncapiDocument = yaml.load( + fs.readFileSync(this.specFile.getFilePath()!, 'utf8') + ); + this.findLocalRefFiles(asyncapiDocument, basePath, filesToWatch); + + const watcher = chokidar.watch(Array.from(filesToWatch), { persistent: true }); + + await this.updateBundledFile(AsyncAPIFile, outputFormat, bundledFilePath); + + watcher.on('change', async (changedPath) => { + this.log(`File changed: ${changedPath}`); + try { + await this.updateBundledFile(AsyncAPIFile, outputFormat, bundledFilePath); + } catch (error: any) { + this.error(`Error updating bundled file: ${error.message}`); + } + }); + + watcher.on('error', (error) => { + this.error(`Watcher error: ${error.message}`); + }); + + this.log(`Starting Studio on port ${port}`); + startStudio(bundledFilePath, port); + } +} + +export default StartPreview; diff --git a/src/core/flags/start/preview.flags.ts b/src/core/flags/start/preview.flags.ts new file mode 100644 index 000000000..06118c9d9 --- /dev/null +++ b/src/core/flags/start/preview.flags.ts @@ -0,0 +1,8 @@ +import { Flags } from '@oclif/core'; + +export const previewFlags = () => { + return { + help: Flags.help({ char: 'h' ,description: 'Show CLI help'}), + port: Flags.integer({ char: 'p', description: 'port in which to start Studio' }), + }; +}; diff --git a/test/integration/preview.test.ts b/test/integration/preview.test.ts new file mode 100644 index 000000000..f4883a1df --- /dev/null +++ b/test/integration/preview.test.ts @@ -0,0 +1,37 @@ +import { test } from '@oclif/test'; +import fs from 'fs'; +import TestHelper from '../helpers'; +import { expect } from 'chai'; + +const testHelper = new TestHelper(); +const bundledFilePath = './asyncapi-bundled.yml'; + +describe('start preview', () => { + beforeEach(() => { + testHelper.createDummyContextFile(); + }); + + afterEach(() => { + testHelper.deleteDummyContextFile(); + }); + + // test + // .stderr() + // .stdout() + // .command(['start:preview', './test/fixtures/specification.yml']) + // .it('bundles the AsyncAPI file and starts the studio', ({ stdout, stderr }) => { + // const output = stdout + stderr; + // expect(output).to.include('Starting Studio in preview mode...'); + // expect(output).to.include('Bundled file updated successfully.'); + // expect(fs.existsSync(bundledFilePath)).to.be.true; + // }); + + test + .stderr() + .stdout() + .command(['start:preview', 'non-existing-context']) + .it('throws error if context does not exist', ({ stdout, stderr }) => { + const output = stdout + stderr; + expect(output).to.include('ContextError: Context "non-existing-context" does not exist'); + }); +});