Skip to content

Commit

Permalink
feat(controller function): map to controller function now can fallback
Browse files Browse the repository at this point in the history
It can fallback to path + method (path in lower and method in PascalCase) as function name if no
operationId is specified. This changes the default behavior! The current behavior can be achieved by
an opt-out.

BREAKING CHANGE: If no operationId is specified in an operation the mapped controller function name
falls back to path + method (path in lower and method in PascalCase). You can opt this out with
fallbackControllerFunctionToPath on AddFromSpecificationOpts.

Closes #18
  • Loading branch information
matzehecht committed Aug 20, 2020
1 parent 6a5377f commit 3761d5b
Show file tree
Hide file tree
Showing 3 changed files with 87 additions and 29 deletions.
3 changes: 2 additions & 1 deletion src/const.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"VALIDATE_SPECIFICATION": true,
"PROVIDE_STUBS": true,
"MAP_CONTROLLER_BY": "TAG",
"FALLBACK_CONTROLLER_TO_INDEX": true
"FALLBACK_CONTROLLER_TO_INDEX": true,
"FALLBACK_CONTROLLER_FUNCTION_TO_PATH": true
}
}
52 changes: 35 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,16 @@ export class KoaOasRouter<StateT = any, CustomT = {}> extends Router {
const validateSpecification = opts?.validateSpecification || CONST.addRoutesFromSpecification.VALIDATE_SPECIFICATION;
const provideStubs = opts?.provideStubs || CONST.addRoutesFromSpecification.PROVIDE_STUBS;
const mapControllerBy: mapControllerBy = opts?.mapControllerBy || (CONST.addRoutesFromSpecification.MAP_CONTROLLER_BY as mapControllerBy);

const fallbackControllerToIndex =
opts && typeof opts.fallbackControllerToIndex === 'boolean' ? opts.fallbackControllerToIndex : CONST.addRoutesFromSpecification.FALLBACK_CONTROLLER_TO_INDEX;
opts && typeof opts.fallbackControllerToIndex === 'boolean'
? opts.fallbackControllerToIndex
: CONST.addRoutesFromSpecification.FALLBACK_CONTROLLER_TO_INDEX;

const fallbackControllerFunctionToPath =
opts && typeof opts.fallbackControllerFunctionToPath === 'boolean'
? opts.fallbackControllerFunctionToPath
: CONST.addRoutesFromSpecification.FALLBACK_CONTROLLER_FUNCTION_TO_PATH;

if (validateSpecification) {
if (!(await validator.validate(specification, {}))?.valid) {
Expand All @@ -70,10 +78,10 @@ export class KoaOasRouter<StateT = any, CustomT = {}> extends Router {
let operationControllerMapping: OperationControllerMapping = {};
switch (mapControllerBy) {
case 'TAG':
operationControllerMapping = this.mapOperationsByTag(specification.paths, fallbackControllerToIndex);
operationControllerMapping = this.mapOperationsByTag(specification.paths, { fallbackControllerToIndex, fallbackControllerFunctionToPath });
break;
case 'PATH':
operationControllerMapping = this.mapOperationsByPath(specification.paths);
operationControllerMapping = this.mapOperationsByPath(specification.paths, { fallbackControllerFunctionToPath });
break;
}

Expand All @@ -87,7 +95,7 @@ export class KoaOasRouter<StateT = any, CustomT = {}> extends Router {
import(path.resolve(path.join(controllerBasePath, controllerName === 'index' ? controllerName.toPascalCase() : 'index')))
.then((controller) => {
// For each operation (with operationId)
Object.entries(operationMapping).forEach(([operationId, operation]: [string, Operation]) => {
Object.entries(operationMapping).forEach(([operationId, operation]) => {
// get the function in controller and check if it is defined.
const operationInController = controller[operationId];
if (operationInController) {
Expand Down Expand Up @@ -144,34 +152,36 @@ export class KoaOasRouter<StateT = any, CustomT = {}> extends Router {
return;
}

private mapOperationsByTag(paths: Paths, fallbackControllerToIndex: boolean): OperationControllerMapping {
private mapOperationsByTag(paths: Paths, opts?: { fallbackControllerToIndex?: boolean, fallbackControllerFunctionToPath?: boolean }): OperationControllerMapping {
const mapping: OperationControllerMapping = {};
Object.entries(paths).forEach(([path, pathObj]: [string, Path]) => {
Object.entries(pathObj).forEach(([method, operation]: [string, Operation]) => {
if ((!operation.tags || !operation.tags[0]) && !fallbackControllerToIndex) {
if ((!operation.tags || !operation.tags[0]) && !opts?.fallbackControllerToIndex) {
throw new Error('Method ' + method + ' in path ' + path + ' has no tags!');
}
if (!operation.operationId) {

if (!operation.operationId && !opts?.fallbackControllerFunctionToPath) {
throw new Error('Method ' + method + ' in path ' + path + ' has no operationId!');
}

const tag = (operation.tags && operation.tags[0]) || 'index';
const operationId = operation.operationId || `${method.toLowerCase()}${path.toPascalCase()}`;
if (mapping[tag]) {
// If tag is already mapped:
if (mapping[tag][operation.operationId]) {
if (mapping[tag][operationId]) {
// Throw error if operationId is already used (shouldn't be possible in valid oas document!)
throw new Error("OperationId in specification isn't used uniquely! " + operation.operationId);
throw new Error("Controller function name isn't used uniquely! " + operationId);
} else {
// Add the operation by it's Id to the tag in the mapping. As values add the path and the specification.
mapping[tag][operation.operationId] = {
mapping[tag][operationId] = {
path,
method,
operationSpec: operation,
};
}
} else {
mapping[tag] = {};
mapping[tag][operation.operationId] = {
mapping[tag][operationId] = {
path,
method,
operationSpec: operation,
Expand All @@ -182,31 +192,32 @@ export class KoaOasRouter<StateT = any, CustomT = {}> extends Router {
return mapping;
}

private mapOperationsByPath(paths: Paths): OperationControllerMapping {
private mapOperationsByPath(paths: Paths, opts?: { fallbackControllerFunctionToPath?: boolean }): OperationControllerMapping {
const mapping: OperationControllerMapping = {};
Object.entries(paths).forEach(([path, operations]) => {
const mappedPath = path.replace(/[{}]/gi, '').replace('/', '_');
Object.entries(operations).forEach(([method, operation]) => {
if (!operation.operationId) {
if (!operation.operationId && !opts?.fallbackControllerFunctionToPath) {
throw new Error('Method ' + method + ' in path ' + path + ' has no operationId!');
}

const operationId = operation.operationId || `${method.toLowerCase()}${path.toPascalCase()}`;
if (mapping[mappedPath]) {
// If path is already mapped:
if (mapping[mappedPath][operation.operationId]) {
if (mapping[mappedPath][operationId]) {
// Throw error if operationId is already used (shouldn't be possible in valid oas document!)
throw new Error("OperationId in specification isn't used uniquely! " + operation.operationId);
throw new Error("Controller function name isn't used uniquely! " + operationId);
} else {
// Add the operation by it's Id to the tag in the mapping. As values add the path and the specification.
mapping[mappedPath][operation.operationId] = {
mapping[mappedPath][operationId] = {
path,
method,
operationSpec: operation,
};
}
} else {
mapping[mappedPath] = {};
mapping[mappedPath][operation.operationId] = {
mapping[mappedPath][operationId] = {
path,
method,
operationSpec: operation,
Expand Down Expand Up @@ -268,6 +279,13 @@ export interface AddFromSpecificationOpts {
* @default true
*/
fallbackControllerToIndex?: boolean;
/**
* Should the controller function name fallback to method + path (method in lower and path in PascalCase)?
*
* @type {boolean}
* @default true
*/
fallbackControllerFunctionToPath?: boolean;
}

/**
Expand Down
61 changes: 50 additions & 11 deletions src/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,20 @@ test('Initialization with options', t => {
t.true(router.match('/b/a', 'get').path.length > 0, 'KoaRouter should have a prefix');
});

test('Add routes by TAG (with fallback) and operationId', async t => {
test('Add routes by TAG (with fallback) and operationId (without fallback)', async t => {
const router = new KoaOasRouter();
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_AND_OPERATION_ID, {fallbackControllerFunctionToPath: false}), 'Should not throw');
t.truthy(router.match('/a', 'get').path.find(p => p.methods.includes('GET')), 'GET /a should be included');
t.truthy(router.match('/a', 'delete').path.find(p => p.methods.includes('DELETE')), 'DELETE /a should be included');
t.truthy(router.match('/b', 'put').path.find(p => p.methods.includes('PUT')), 'PUT /b should be included');
t.truthy(router.match('/post', 'post').path.find(p => p.methods.includes('POST')), 'POST /post should be included');

await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_OPERATION_ID_WITHOUT_TAG, {fallbackControllerFunctionToPath: false}), 'Should not throw');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_WITHOUT_OPERATION_ID, {fallbackControllerFunctionToPath: false}), { message: /^Method.*in path.*has no operationId!$/}, 'Should throw that a method has no operationId');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITHOUT_TAG_AND_OPERATION_ID, {fallbackControllerFunctionToPath: false}), { message: /^Method.*in path.*has no operationId!$/}, 'Should throw that a method has no operationId');
});

test('Add routes by TAG (with fallback) and operationId (with fallback)', async t => {
const router = new KoaOasRouter();
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_AND_OPERATION_ID), 'Should not throw');
t.truthy(router.match('/a', 'get').path.find(p => p.methods.includes('GET')), 'GET /a should be included');
Expand All @@ -36,32 +49,58 @@ test('Add routes by TAG (with fallback) and operationId', async t => {
t.truthy(router.match('/post', 'post').path.find(p => p.methods.includes('POST')), 'POST /post should be included');

await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_OPERATION_ID_WITHOUT_TAG), 'Should not throw');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_WITHOUT_OPERATION_ID), { message: /^Method.*in path.*has no operationId!$/}, 'Should throw that a method has no operationId');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITHOUT_TAG_AND_OPERATION_ID), { message: /^Method.*in path.*has no operationId!$/}, 'Should throw that a method has no operationId');
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_WITHOUT_OPERATION_ID), 'Should not throw');
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITHOUT_TAG_AND_OPERATION_ID), 'Should not throw');
});

test('Add routes by TAG (without fallback) and operationId (without fallback)', async t => {
const router = new KoaOasRouter();
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_AND_OPERATION_ID, {fallbackControllerToIndex: false, fallbackControllerFunctionToPath: false}), 'Should not throw');
t.truthy(router.match('/a', 'get').path.find(p => p.methods.includes('GET')), 'GET /a should be included');
t.truthy(router.match('/a', 'delete').path.find(p => p.methods.includes('DELETE')), 'DELETE /a should be included');
t.truthy(router.match('/b', 'put').path.find(p => p.methods.includes('PUT')), 'PUT /b should be included');
t.truthy(router.match('/post', 'post').path.find(p => p.methods.includes('POST')), 'POST /post should be included');

await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITH_OPERATION_ID_WITHOUT_TAG, {fallbackControllerToIndex: false, fallbackControllerFunctionToPath: false}), { message: /^Method.*in path.*has no tags!$/}, 'Should throw that a method has no tags');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_WITHOUT_OPERATION_ID, {fallbackControllerToIndex: false, fallbackControllerFunctionToPath: false}), { message: /^Method.*in path.*has no operationId!$/}, 'Should throw that a method has no operationId');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITHOUT_TAG_AND_OPERATION_ID, {fallbackControllerToIndex: false, fallbackControllerFunctionToPath: false}), { message: /^Method.*in path.*has no tags!$/}, 'Should throw that a method has no tags');
});

test('Add routes by TAG (without fallback) and operationId', async t => {
test('Add routes by TAG (without fallback) and operationId (with fallback)', async t => {
const router = new KoaOasRouter();
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_AND_OPERATION_ID, {fallbackControllerToIndex: false}), 'Should not throw');
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_AND_OPERATION_ID), 'Should not throw');
t.truthy(router.match('/a', 'get').path.find(p => p.methods.includes('GET')), 'GET /a should be included');
t.truthy(router.match('/a', 'delete').path.find(p => p.methods.includes('DELETE')), 'DELETE /a should be included');
t.truthy(router.match('/b', 'put').path.find(p => p.methods.includes('PUT')), 'PUT /b should be included');
t.truthy(router.match('/post', 'post').path.find(p => p.methods.includes('POST')), 'POST /post should be included');

await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITH_OPERATION_ID_WITHOUT_TAG, {fallbackControllerToIndex: false}), { message: /^Method.*in path.*has no tags!$/}, 'Should throw that a method has no tags');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_WITHOUT_OPERATION_ID, {fallbackControllerToIndex: false}), { message: /^Method.*in path.*has no operationId!$/}, 'Should throw that a method has no operationId');
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_WITHOUT_OPERATION_ID, {fallbackControllerToIndex: false}), 'Should not throw');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITHOUT_TAG_AND_OPERATION_ID, {fallbackControllerToIndex: false}), { message: /^Method.*in path.*has no tags!$/}, 'Should throw that a method has no tags');
});

test('Add routes by PATH and operationId', async t => {
test('Add routes by PATH and operationId (without fallback)', async t => {
const router = new KoaOasRouter();
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_OPERATION_ID_WITHOUT_TAG, {mapControllerBy: 'PATH', fallbackControllerFunctionToPath: false}), 'Should not throw');
t.truthy(router.match('/a', 'get').path.find(p => p.methods.includes('GET')), 'GET /a should be included');
t.truthy(router.match('/a', 'delete').path.find(p => p.methods.includes('DELETE')), 'DELETE /a should be included');
t.truthy(router.match('/b', 'put').path.find(p => p.methods.includes('PUT')), 'PUT /b should be included');
t.truthy(router.match('/post', 'post').path.find(p => p.methods.includes('POST')), 'POST /post should be included');

await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_AND_OPERATION_ID, {mapControllerBy: 'PATH', fallbackControllerFunctionToPath: false}), 'Should throw that a method has no tags');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_WITHOUT_OPERATION_ID, {mapControllerBy: 'PATH', fallbackControllerFunctionToPath: false}), { message: /^Method.*in path.*has no operationId!$/}, 'Should throw that a method has no operationId');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITHOUT_TAG_AND_OPERATION_ID, {mapControllerBy: 'PATH', fallbackControllerFunctionToPath: false}), { message: /^Method.*in path.*has no operationId!$/}, 'Should throw that a method has no operationId');
});

test('Add routes by PATH and operationId (with fallback)', async t => {
const router = new KoaOasRouter();
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_OPERATION_ID_WITHOUT_TAG, {mapControllerBy: 'PATH'}), 'Should not throw');
t.truthy(router.match('/a', 'get').path.find(p => p.methods.includes('GET')), 'GET /a should be included');
t.truthy(router.match('/a', 'delete').path.find(p => p.methods.includes('DELETE')), 'DELETE /a should be included');
t.truthy(router.match('/b', 'put').path.find(p => p.methods.includes('PUT')), 'PUT /b should be included');
t.truthy(router.match('/post', 'post').path.find(p => p.methods.includes('POST')), 'POST /post should be included');

await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_AND_OPERATION_ID, {mapControllerBy: 'PATH'}), 'Should throw that a method has no tags');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_WITHOUT_OPERATION_ID, {mapControllerBy: 'PATH'}), { message: /^Method.*in path.*has no operationId!$/}, 'Should throw that a method has no operationId');
await t.throwsAsync(router.addRoutesFromSpecification(SPEC_WITHOUT_TAG_AND_OPERATION_ID, {mapControllerBy: 'PATH'}), { message: /^Method.*in path.*has no operationId!$/}, 'Should throw that a method has no operationId');
});
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_AND_OPERATION_ID, {mapControllerBy: 'PATH'}), 'Should not throw');
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITH_TAG_WITHOUT_OPERATION_ID, {mapControllerBy: 'PATH'}), 'Should not throw');
await t.notThrowsAsync(router.addRoutesFromSpecification(SPEC_WITHOUT_TAG_AND_OPERATION_ID, {mapControllerBy: 'PATH'}), 'Should not throw');
});

0 comments on commit 3761d5b

Please sign in to comment.