Skip to content

Commit

Permalink
test: add e2e tests for api spec generation from imported types
Browse files Browse the repository at this point in the history
  • Loading branch information
ad-world committed May 17, 2024
1 parent e3c2336 commit de26d40
Show file tree
Hide file tree
Showing 18 changed files with 429 additions and 12 deletions.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
coverage/
dist/
flake.lock
packages/openapi-generator/test/
1 change: 1 addition & 0 deletions packages/openapi-generator/test/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sample-types/
34 changes: 22 additions & 12 deletions packages/openapi-generator/test/externalModule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,18 @@ async function testCase(
entryPoint: string,
expected: Record<string, Record<string, Schema>>,
expectedErrors: Record<string, string[]> = {},
parseErrorRegex: RegExp | undefined = undefined,
) {
test(description, async () => {
const project = new Project({}, KNOWN_IMPORTS);
const entryPointPath = p.resolve(entryPoint);
await project.parseEntryPoint(entryPointPath);
const parsed = await project.parseEntryPoint(entryPointPath);

if (parseErrorRegex !== undefined) {
assert(E.isLeft(parsed));
assert(parseErrorRegex.test(parsed.left));
return;
}

for (const path of Object.keys(expected)) {
const resolvedPath = p.resolve(path);
Expand Down Expand Up @@ -181,15 +188,18 @@ testCase(
},
);

test('type from external library with import path error', async () => {
const entryPoint = 'test/sample-types/importPathError.ts';
const project = new Project({}, KNOWN_IMPORTS);
const entryPointPath = p.resolve(entryPoint);
const result = await project.parseEntryPoint(entryPointPath);

const errorRegex =
/Could not resolve io-tsg from .*\/test\/sample-types\/node_modules\/@bitgo\/foobar3\/src/;
testCase(
'type from external library with import path error',
'test/sample-types/importPathError.ts',
{},
{},
/Could not resolve io-tsg from .*\/test\/sample-types\/node_modules\/@bitgo\/foobar3\/src/,
);

assert(E.isLeft(result));
assert(errorRegex.test(result.left));
});
testCase(
'type from external library with export path error',
'test/sample-types/exportPathError.ts',
{},
{},
/Could not resolve .\/foobart from .*\/test\/sample-types\/node_modules\/@bitgo\/foobar6\/src/,
);
273 changes: 273 additions & 0 deletions packages/openapi-generator/test/externalModuleApiSpec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import assert from 'assert';
import test from 'node:test';
import { version } from 'typescript';
import {
Project,
Route,
parseApiSpec,
Schema,
getRefs,
parseCodecInitializer,
convertRoutesToOpenAPI,
} from '../src';
import { KNOWN_IMPORTS } from '../src/knownImports';
import { findSymbolInitializer } from '../src/resolveInit';
import * as p from 'path';
import * as E from 'fp-ts/Either';

/** External library parsing and api spec generation test case
*
*
* @param description a description of the test case
* @param entryPoint the entrypoint of the api spec
* @param expected an open api spec object
* @param expectedErrors opetional record of expected parsing errors
*/
async function testCase(
description: string,
entryPoint: string,
expected: Record<string, object | string>,
expectedErrors: string[] = [],
) {
test(description, async () => {
const project = new Project({}, KNOWN_IMPORTS);
const entryPointPath = p.resolve(entryPoint);
await project.parseEntryPoint(entryPointPath);

const sourceFile = project.get(entryPointPath);

if (sourceFile === undefined) {
throw new Error(`could not find source file ${entryPoint}`);
}

const actual: Record<string, Route[]> = {};
const errors: string[] = [];

for (const symbol of sourceFile.symbols.declarations) {
if (symbol.init !== undefined) {
if (symbol.init.type !== 'CallExpression') {
continue;
} else if (
symbol.init.callee.type !== 'MemberExpression' ||
symbol.init.callee.property.type !== 'Identifier' ||
symbol.init.callee.property.value !== 'apiSpec'
) {
continue;
} else if (symbol.init.arguments.length !== 1) {
continue;
}
const arg = symbol.init.arguments[0]!;
if (arg.expression.type !== 'ObjectExpression') {
continue;
}
const result = parseApiSpec(project, sourceFile, arg.expression);
if (E.isLeft(result)) {
errors.push(result.left);
} else {
actual[symbol.name] = result.right;
}
}
}

const apiSpec = Object.values(actual).flatMap((routes) => routes);

const components: Record<string, Schema> = {};
const queue: Schema[] = apiSpec.flatMap((route) => {
return [
...route.parameters.map((p) => p.schema),
...(route.body !== undefined ? [route.body] : []),
...Object.values(route.response),
];
});
let schema: Schema | undefined;
while (((schema = queue.pop()), schema !== undefined)) {
const refs = getRefs(schema, project.getTypes());
for (const ref of refs) {
if (components[ref.name] !== undefined) {
continue;
}
const sourceFile = project.get(ref.location);
if (sourceFile === undefined) {
console.error(`Could not find '${ref.name}' from '${ref.location}'`);
process.exit(1);
}

const initE = findSymbolInitializer(project, sourceFile, ref.name);
if (E.isLeft(initE)) {
console.error(
`Could not find symbol '${ref.name}' in '${ref.location}': ${initE.left}`,
);
process.exit(1);
}
const [newSourceFile, init] = initE.right;

const codecE = parseCodecInitializer(project, newSourceFile, init);
if (E.isLeft(codecE)) {
console.error(
`Could not parse codec '${ref.name}' in '${ref.location}': ${codecE.left}`,
);
process.exit(1);
}
components[ref.name] = codecE.right;
queue.push(codecE.right);
}
}

const name = description;

const openapi = convertRoutesToOpenAPI(
{
title: name,
version,
description,
},
[],
apiSpec,
components,
);

assert.deepEqual(errors, expectedErrors);
assert.deepEqual(openapi, expected);
});
}

testCase(
'simple api spec with imported types',
'test/sample-types/apiSpec.ts',
{
openapi: '3.0.3',
info: {
title: 'simple api spec with imported types',
version: '4.7.4',
description: 'simple api spec with imported types',
},
paths: {
'/test': {
post: {
parameters: [],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
path1: {
type: 'string',
},
path2: {
type: 'number',
},
path3: {
type: 'boolean',
},
path4: {
type: 'string',
enum: ['literal'],
},
},
required: ['path1', 'path2', 'path3', 'path4'],
},
},
},
},
responses: {
'200': {
description: 'OK',
content: {
'application/json': {
schema: {
type: 'string',
},
},
},
},
},
},
get: {
parameters: [],
responses: {
'200': {
description: 'OK',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/SampleGetResponse',
},
},
},
},
},
},
},
},
components: {
schemas: {
SampleGetResponse: {
title: 'SampleGetResponse',
type: 'object',
properties: {
response1: {
type: 'string',
},
response2: {
type: 'object',
properties: {
nested1: {
type: 'number',
},
nested2: {
type: 'boolean',
},
},
required: ['nested1', 'nested2'],
},
},
required: ['response1', 'response2'],
},
},
},
},
[],
);

testCase(
'simple api spec with exported enum',
'test/sample-types/apiSpecWithEnum.ts',
{
openapi: '3.0.3',
info: {
title: 'simple api spec with exported enum',
version: '4.7.4',
description: 'simple api spec with exported enum',
},
paths: {
'/test': {
get: {
parameters: [],
responses: {
'200': {
description: 'OK',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/SampleEnumType',
},
},
},
},
},
},
},
},
components: {
schemas: {
SampleEnumType: {
title: 'SampleEnumType',
type: 'string',
enum: ['Value1', 'Value2'],
},
},
},
},
[],
);
28 changes: 28 additions & 0 deletions packages/openapi-generator/test/sample-types/apiSpec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { SamplePostRequest, SampleGetResponse } from '@bitgo/test-types';
import * as t from 'io-ts';
import * as h from '@api-ts/io-ts-http';

export const test = h.apiSpec({
'api.post.test': {
post: h.httpRoute({
path: '/test',
method: 'POST',
request: h.httpRequest({
body: SamplePostRequest,
}),
response: {
200: t.string,
},
}),
},
'api.get.test': {
get: h.httpRoute({
path: '/test',
method: 'GET',
request: h.httpRequest({}),
response: {
200: SampleGetResponse,
},
}),
},
});
15 changes: 15 additions & 0 deletions packages/openapi-generator/test/sample-types/apiSpecWithEnum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { SampleEnumType } from '@bitgo/test-types';
import * as h from '@api-ts/io-ts-http';

export const enumTest = h.apiSpec({
'api.get.test': {
get: h.httpRoute({
path: '/test',
method: 'GET',
request: h.httpRequest({}),
response: {
200: SampleEnumType,
},
}),
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as f from '@bitgo/foobar6';

export const FOO = {
foobar: f.foobar,
};
Empty file.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit de26d40

Please sign in to comment.