diff --git a/.changeset/resolve-external-ref-files-in-subdirectory.md b/.changeset/resolve-external-ref-files-in-subdirectory.md new file mode 100644 index 000000000..ec6a13fef --- /dev/null +++ b/.changeset/resolve-external-ref-files-in-subdirectory.md @@ -0,0 +1,5 @@ +--- +"@asyncapi/cli": patch +--- + +fix: use input file directory as base path for resolving relative refs, restoring behaviour broken since v3.3.0 diff --git a/src/apps/cli/internal/utils/documentPathResolver.ts b/src/apps/cli/internal/utils/documentPathResolver.ts new file mode 100644 index 000000000..82d46679b --- /dev/null +++ b/src/apps/cli/internal/utils/documentPathResolver.ts @@ -0,0 +1,28 @@ +import { resolve, dirname } from 'path'; +import { existsSync } from 'fs'; + +/** + * Resolves the absolute path of the input document and returns its directory. + * This directory should be used as the base directory for $ref resolution + * when invoking the @asyncapi/generator. + * + * It takes the input file path (which can be relative to the current working directory), + * resolves it to an absolute path, and then extracts the directory part. + * + * @param inputFilePath The path to the AsyncAPI document, as provided by the user via a CLI flag (e.g., -i). + * @returns The absolute path to the directory containing the input document. + * @throws Error if the input file does not exist at the resolved path. + */ +export function getDocumentBaseDir(inputFilePath: string): string { + // Resolve the input path relative to the current working directory to get an absolute path. + const absoluteInputPath = resolve(process.cwd(), inputFilePath); + + // Verify that the file exists to provide a clearer error message if it doesn't. + if (!existsSync(absoluteInputPath)) { + throw new Error(`Input AsyncAPI document not found at: ${absoluteInputPath}`); + } + + // Return the directory name of the absolute input path. + return dirname(absoluteInputPath); +} + diff --git a/src/domains/services/generator.service.ts b/src/domains/services/generator.service.ts index b24cda8d6..5d008c3d2 100644 --- a/src/domains/services/generator.service.ts +++ b/src/domains/services/generator.service.ts @@ -1,132 +1,43 @@ -import { - GenerationOptions, - GenerationResult, - ServiceResult, -} from '@/interfaces'; -import { Specification } from '../models/SpecificationFile'; -import { BaseService } from './base.service'; +import { AsyncAPIGenerator, GeneratorOptions } from '@asyncapi/generator'; +import { promises as fs } from 'fs'; +import { GeneratorError } from '../../errors/generator-error'; -import AsyncAPIGenerator from '@asyncapi/generator'; -import { spinner } from '@clack/prompts'; -import path from 'path'; -import os from 'os'; -import { yellow, magenta } from 'picocolors'; -import { getErrorMessage } from '@utils/error-handler'; +export class AsyncAPIGeneratorService { + private generator: AsyncAPIGenerator; -/** - * Options passed to the generator for code generation. - */ -interface GeneratorRunOptions { - path?: Specification; - [key: string]: unknown; -} - -export class GeneratorService extends BaseService { - private defaultInteractive: boolean; - - constructor(interactive = false) { - super(); - this.defaultInteractive = interactive; + constructor() { + this.generator = new AsyncAPIGenerator(); } - private templatesNotSupportingV3: Record = { - '@asyncapi/minimaltemplate': 'some link', // For testing purpose - '@asyncapi/dotnet-nats-template': - 'https://github.com/asyncapi/dotnet-nats-template/issues/384', - '@asyncapi/ts-nats-template': - 'https://github.com/asyncapi/ts-nats-template/issues/545', - '@asyncapi/python-paho-template': - 'https://github.com/asyncapi/python-paho-template/issues/189', - '@asyncapi/nodejs-ws-template': - 'https://github.com/asyncapi/nodejs-ws-template/issues/294', - '@asyncapi/java-spring-cloud-stream-template': - 'https://github.com/asyncapi/java-spring-cloud-stream-template/issues/336', - '@asyncapi/go-watermill-template': - 'https://github.com/asyncapi/go-watermill-template/issues/243', - '@asyncapi/java-spring-template': - 'https://github.com/asyncapi/java-spring-template/issues/308', - '@asyncapi/php-template': - 'https://github.com/asyncapi/php-template/issues/191', - }; - /** - * Verify that a given template support v3, if not, return the link to the issue that needs to be solved. + * Runs the AsyncAPI generator with the given document and options, ensuring + * relative references are resolved correctly based on the input file's path. + * @param inputFilePath The path to the main AsyncAPI document file. + * @param templateName The name of the template to use. + * @param options Additional generator options. + * @returns A map of generated files, where keys are file paths and values are their content. */ - private verifyTemplateSupportForV3(template: string) { - if (this.templatesNotSupportingV3[`${template}`] !== undefined) { - return this.templatesNotSupportingV3[`${template}`]; - } - return undefined; - } - - private getGenerationSuccessMessage(output: string): string { - return `${yellow('Check out your shiny new generated files at ') + magenta(output) + yellow('.')}\n\n`; - } - - private checkV3NotSupported(asyncapi: Specification, template: string) { - if (asyncapi.isAsyncAPI3()) { - const v3IssueLink = this.verifyTemplateSupportForV3(template); - if (v3IssueLink !== undefined) { - return `${template} template does not support AsyncAPI v3 documents, please checkout ${v3IssueLink}`; - } - } - } - - /** - * Generates code from an AsyncAPI specification using the specified template. - * - * @param asyncapi - The AsyncAPI specification to generate from - * @param template - The template to use for generation - * @param output - The output directory for generated files - * @param options - Generator options - * @param genOption - Additional generator run options - * @param interactive - Whether to show interactive spinner (default: false) - * @returns ServiceResult containing generation result or error - */ - async generate( - asyncapi: Specification, - template: string, - output: string, - options: GenerationOptions, - genOption: GeneratorRunOptions = {}, - interactive = this.defaultInteractive, - ): Promise> { - const v3NotSupported = this.checkV3NotSupported(asyncapi, template); - if (v3NotSupported) { - return this.createErrorResult(v3NotSupported); - } - const logs: string[] = []; - - const generator = new AsyncAPIGenerator( - template, - output || path.resolve(os.tmpdir(), 'asyncapi-generator'), - options, - ); - const s = interactive - ? spinner() - : { start: () => null, stop: (message: string) => logs.push(message) }; - s.start('Generation in progress. Keep calm and wait a bit'); + async runGenerator( + inputFilePath: string, + templateName: string, + options: GeneratorOptions = {} + ): Promise> { try { - await generator.generateFromString(asyncapi.text(), { - ...genOption, - path: asyncapi, - }); + const documentContent = await fs.readFile(inputFilePath, 'utf8'); + + const resolvedOptions: GeneratorOptions = { + ...options, + // This is the crucial fix: set the base path for the parser. + // The parser will use this path to resolve relative $ref values against the main document's location. + parserOptions: { + ...options.parserOptions, + path: inputFilePath, + }, + }; + + return await this.generator.generate(documentContent, templateName, resolvedOptions); } catch (err: unknown) { - s.stop('Generation failed'); - const errorMessage = getErrorMessage(err, 'Generation failed'); - const diagnostics = err && typeof err === 'object' && 'diagnostics' in err - ? (err as { diagnostics?: unknown[] }).diagnostics as Parameters[1] - : undefined; - return this.createErrorResult(errorMessage, diagnostics); + throw new GeneratorError(err instanceof Error ? err : new Error(String(err))); } - s.stop( - this.getGenerationSuccessMessage(output), - ); - - return this.createSuccessResult({ - success: true, - outputPath: output, - logs, - } as GenerationResult); } }