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/resolve-external-ref-files-in-subdirectory.md
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions src/apps/cli/internal/utils/documentPathResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { resolve, dirname } from 'path';

Check warning on line 1 in src/apps/cli/internal/utils/documentPathResolver.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=asyncapi_cli&issues=AZ1qXMmyQ2GN9G34OnD-&open=AZ1qXMmyQ2GN9G34OnD-&pullRequest=2106
import { existsSync } from 'fs';

Check warning on line 2 in src/apps/cli/internal/utils/documentPathResolver.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=asyncapi_cli&issues=AZ1qXMmyQ2GN9G34OnD_&open=AZ1qXMmyQ2GN9G34OnD_&pullRequest=2106

/**
* 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);
}

153 changes: 32 additions & 121 deletions src/domains/services/generator.service.ts
Original file line number Diff line number Diff line change
@@ -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';

Check warning on line 2 in src/domains/services/generator.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=asyncapi_cli&issues=AZ1qiOQxuHSDi7zfAxPU&open=AZ1qiOQxuHSDi7zfAxPU&pullRequest=2106
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;

Check warning on line 6 in src/domains/services/generator.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'generator' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=asyncapi_cli&issues=AZ1qiOQxuHSDi7zfAxPV&open=AZ1qiOQxuHSDi7zfAxPV&pullRequest=2106

/**
* 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<string, string> = {
'@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<ServiceResult<GenerationResult>> {
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<Map<string, string | Buffer>> {
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<typeof this.createErrorResult>[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);
}
}
Loading