Skip to content

Commit

Permalink
Merge pull request #1196 from BradenM/fix/oas-op-ids
Browse files Browse the repository at this point in the history
Implement `operationIdTemplate` config parameter.
  • Loading branch information
WoH committed Mar 26, 2022
2 parents d0f2092 + a756a94 commit 13472bc
Show file tree
Hide file tree
Showing 9 changed files with 79 additions and 6 deletions.
3 changes: 3 additions & 0 deletions packages/cli/src/cli.ts
Expand Up @@ -121,6 +121,9 @@ export const validateSpecConfig = async (config: Config): Promise<ExtendedSpecCo
config.spec.description = config.spec.description || (await descriptionDefault());
config.spec.license = config.spec.license || (await licenseDefault());
config.spec.basePath = config.spec.basePath || '/';
// defaults to template that may generate non-unique operation ids.
// @see https://github.com/lukeautry/tsoa/issues/1005
config.spec.operationIdTemplate = config.spec.operationIdTemplate || '{{titleCase method.name}}';

if (!config.spec.contact) {
config.spec.contact = {};
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/swagger/specGenerator.ts
@@ -1,5 +1,6 @@
import { ExtendedSpecConfig } from '../cli';
import { Tsoa, assertNever, Swagger } from '@tsoa/runtime';
import * as handlebars from 'handlebars';

export abstract class SpecGenerator {
constructor(protected readonly metadata: Tsoa.Metadata, protected readonly config: ExtendedSpecConfig) {}
Expand All @@ -8,8 +9,17 @@ export abstract class SpecGenerator {
return this.getSwaggerType(type);
}

protected getOperationId(methodName: string) {
return methodName.charAt(0).toUpperCase() + methodName.substr(1);
protected buildOperationIdTemplate(inlineTemplate: string) {
handlebars.registerHelper('titleCase', (value: string) => (value ? value.charAt(0).toUpperCase() + value.slice(1) : value));
handlebars.registerHelper('replace', (subject: string, searchValue: string, withValue = '') => (subject ? subject.replace(searchValue, withValue) : subject));
return handlebars.compile(inlineTemplate, { noEscape: true });
}

protected getOperationId(controllerName: string, method: Tsoa.Method) {
return this.buildOperationIdTemplate(this.config.operationIdTemplate ?? '{{titleCase method.name}}')({
method,
controllerName,
});
}

public throwIfNotDataFormat(strToTest: string): Swagger.DataFormat {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/swagger/specGenerator2.ts
Expand Up @@ -230,7 +230,7 @@ export class SpecGenerator2 extends SpecGenerator {
}

const operation: Swagger.Operation = {
operationId: this.getOperationId(method.name),
operationId: this.getOperationId(controllerName, method),
produces: produces as string[],
responses: swaggerResponses,
};
Expand Down Expand Up @@ -276,7 +276,7 @@ export class SpecGenerator2 extends SpecGenerator {
name: 'body',
schema: {
properties,
title: `${this.getOperationId(method.name)}Body`,
title: `${this.getOperationId(controllerName, method)}Body`,
type: 'object',
},
} as Swagger.Parameter;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/swagger/specGenerator3.ts
Expand Up @@ -367,7 +367,7 @@ export class SpecGenerator3 extends SpecGenerator {
});

const operation: Swagger.Operation3 = {
operationId: this.getOperationId(method.name),
operationId: this.getOperationId(controllerName, method),
responses: swaggerResponses,
};

Expand Down
11 changes: 11 additions & 0 deletions packages/runtime/src/config.ts
Expand Up @@ -149,6 +149,17 @@ export interface SpecConfig {
*/
specMerging?: 'immediate' | 'recursive' | 'deepmerge';

/**
* Template string for generating operation ids.
* This should be a valid handlebars template and is provided
* with the following context:
* - 'controllerName' - String name of controller class.
* - 'method' - Tsoa.Method object.
*
* @default '{{titleCase method.name}}'
*/
operationIdTemplate?: string;

/**
* Security Definitions Object
* A declaration of the security schemes available to be used in the
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/defaultOptions.ts
Expand Up @@ -18,6 +18,7 @@ export function getDefaultOptions(outputDirectory = '', entryFile = ''): Config
name: 'Jane Doe',
url: 'www.jane-doe.com',
},
operationIdTemplate: '{{titleCase method.name}}',
outputDirectory,
securityDefinitions: {
basic: {
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/swagger/config.spec.ts
Expand Up @@ -93,5 +93,14 @@ describe('Configuration', () => {
done();
});
});

it('should set the default spec operationIdTemplate when not specified', done => {
const config: Config = getDefaultOptions('some/output/directory', 'tsoa.json');
delete config.spec.operationIdTemplate;
validateSpecConfig(config).then((configResult: ExtendedSpecConfig) => {
expect(configResult.operationIdTemplate).to.equal('{{titleCase method.name}}');
done();
});
});
});
});
20 changes: 20 additions & 0 deletions tests/unit/swagger/schemaDetails.spec.ts
Expand Up @@ -303,6 +303,26 @@ describe('Schema details generation', () => {
});

describe('methods', () => {
describe('operationId', () => {
const optionsWithOperationIdTemplate = Object.assign<{}, ExtendedSpecConfig, Partial<ExtendedSpecConfig>>({}, getDefaultExtendedOptions(), {
operationIdTemplate: "{{replace controllerName 'Controller' ''}}_{{titleCase method.name}}",
});

// for backwards compatibility.
it('should default to title-cased method name.', () => {
const metadata = new MetadataGenerator('./fixtures/controllers/exampleController.ts').Generate();
const exampleSpec = new SpecGenerator2(metadata, getDefaultExtendedOptions()).GetSpec();
const operationId = exampleSpec.paths['/ExampleTest/post_body']?.post?.operationId;
expect(operationId).to.eq('Post');
});
it('should utilize operationIdTemplate if set.', () => {
const metadata = new MetadataGenerator('./fixtures/controllers/exampleController.ts').Generate();
const exampleSpec = new SpecGenerator2(metadata, optionsWithOperationIdTemplate).GetSpec();
const operationId = exampleSpec.paths['/ExampleTest/post_body']?.post?.operationId;
expect(operationId).to.eq('ExampleTest_Post');
});
});

describe('responses', () => {
describe('should generate headers from method reponse decorator.', () => {
const metadata = new MetadataGenerator('./fixtures/controllers/responseHeaderController.ts').Generate();
Expand Down
21 changes: 20 additions & 1 deletion tests/unit/swagger/schemaDetails3.spec.ts
Expand Up @@ -19,13 +19,16 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
const optionsWithXEnumVarnames = Object.assign<{}, ExtendedSpecConfig, Partial<ExtendedSpecConfig>>({}, defaultOptions, {
xEnumVarnames: true,
});
const optionsWithOperationIdTemplate = Object.assign<{}, ExtendedSpecConfig, Partial<ExtendedSpecConfig>>({}, defaultOptions, {
operationIdTemplate: "{{replace controllerName 'Controller' ''}}_{{titleCase method.name}}",
});

interface SpecAndName {
spec: Swagger.Spec3;
/**
* If you want to add another spec here go for it. The reason why we use a string literal is so that tests below won't have "magic string" errors when expected test results differ based on the name of the spec you're testing.
*/
specName: 'specDefault' | 'specWithNoImplicitExtras' | 'specWithXEnumVarnames';
specName: 'specDefault' | 'specWithNoImplicitExtras' | 'specWithXEnumVarnames' | 'specWithOperationIdTemplate';
}

const specDefault: SpecAndName = {
Expand Down Expand Up @@ -576,6 +579,22 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
});

describe('methods', () => {
describe('operationId', () => {
// for backwards compatibility.
it('should default to title-cased method name.', () => {
const metadata = new MetadataGenerator('./fixtures/controllers/exampleController.ts').Generate();
const exampleSpec = new SpecGenerator3(metadata, getDefaultExtendedOptions()).GetSpec();
const operationId = exampleSpec.paths['/ExampleTest/post_body']?.post?.operationId;
expect(operationId).to.eq('Post');
});
it('should utilize operationIdTemplate if set.', () => {
const metadata = new MetadataGenerator('./fixtures/controllers/exampleController.ts').Generate();
const exampleSpec = new SpecGenerator3(metadata, optionsWithOperationIdTemplate).GetSpec();
const operationId = exampleSpec.paths['/ExampleTest/post_body']?.post?.operationId;
expect(operationId).to.eq('ExampleTest_Post');
});
});

describe('responses', () => {
describe('should generate headers from method reponse decorator.', () => {
const metadata = new MetadataGenerator('./fixtures/controllers/responseHeaderController.ts').Generate();
Expand Down

0 comments on commit 13472bc

Please sign in to comment.