From 05352f9daea56151ceaa259545d65775145cd3bb Mon Sep 17 00:00:00 2001 From: Patrick McLaughlin Date: Wed, 23 Aug 2023 11:50:10 -0400 Subject: [PATCH] feat: handle apiSpec spread elements in openapi-generator --- packages/openapi-generator/src/apiSpec.ts | 26 +++ .../openapi-generator/test/apiSpec.test.ts | 186 +++++++++++++----- .../openapi-generator/test/resolve.test.ts | 64 +----- .../openapi-generator/test/testProject.ts | 65 ++++++ 4 files changed, 228 insertions(+), 113 deletions(-) create mode 100644 packages/openapi-generator/test/testProject.ts diff --git a/packages/openapi-generator/src/apiSpec.ts b/packages/openapi-generator/src/apiSpec.ts index 3d6b7b15..3caf5eee 100644 --- a/packages/openapi-generator/src/apiSpec.ts +++ b/packages/openapi-generator/src/apiSpec.ts @@ -18,6 +18,32 @@ export function parseApiSpec( const result: Route[] = []; for (const apiAction of Object.values(expr.properties)) { + if (apiAction.type === 'SpreadElement') { + const spreadExprE = resolveLiteralOrIdentifier( + project, + sourceFile, + apiAction.arguments, + ); + if (E.isLeft(spreadExprE)) { + return spreadExprE; + } + let [spreadSourceFile, spreadExpr] = spreadExprE.right; + // TODO: This is just assuming that a `CallExpression` here is to `h.apiSpec` + if (spreadExpr.type === 'CallExpression') { + const arg = spreadExpr.arguments[0]; + if (arg === undefined) { + return E.left(`unimplemented spread argument type ${arg}`); + } + spreadExpr = arg.expression; + } + const spreadSpecE = parseApiSpec(project, spreadSourceFile, spreadExpr); + if (E.isLeft(spreadSpecE)) { + return spreadSpecE; + } + result.push(...spreadSpecE.right); + continue; + } + if (apiAction.type !== 'KeyValueProperty') { return E.left(`unimplemented route property type ${apiAction.type}`); } diff --git a/packages/openapi-generator/test/apiSpec.test.ts b/packages/openapi-generator/test/apiSpec.test.ts index c1793f21..4e85663e 100644 --- a/packages/openapi-generator/test/apiSpec.test.ts +++ b/packages/openapi-generator/test/apiSpec.test.ts @@ -1,17 +1,26 @@ import * as E from 'fp-ts/lib/Either'; import assert from 'node:assert'; import test from 'node:test'; +import type { NestedDirectoryJSON } from 'memfs'; -import { parseSource, parseApiSpec, Project, type Route } from '../src'; +import { TestProject } from './testProject'; +import { parseApiSpec, type Route } from '../src'; async function testCase( description: string, - src: string, + files: NestedDirectoryJSON, + entryPoint: string, expected: Record, expectedErrors: string[] = [], ) { test(description, async () => { - const sourceFile = await parseSource('./index.ts', src); + const project = new TestProject(files); + + await project.parseEntryPoint(entryPoint); + const sourceFile = project.get(entryPoint); + if (sourceFile === undefined) { + throw new Error(`could not find source file ${entryPoint}`); + } const actual: Record = {}; const errors: string[] = []; @@ -32,7 +41,7 @@ async function testCase( if (arg.expression.type !== 'ObjectExpression') { continue; } - const result = parseApiSpec(new Project(), sourceFile, arg.expression); + const result = parseApiSpec(project, sourceFile, arg.expression); if (E.isLeft(result)) { errors.push(result.left); } else { @@ -46,24 +55,57 @@ async function testCase( }); } -const SIMPLE = ` -import * as t from 'io-ts'; -import * as h from '@api-ts/io-ts-http'; -export const test = h.apiSpec({ - 'api.test': { - get: h.httpRoute({ +const SIMPLE = { + '/index.ts': ` + import * as t from 'io-ts'; + import * as h from '@api-ts/io-ts-http'; + export const test = h.apiSpec({ + 'api.test': { + get: h.httpRoute({ + path: '/test', + method: 'GET', + request: h.httpRequest({}), + response: { + 200: t.string, + }, + }) + } + });`, +}; + +testCase('simple api spec', SIMPLE, '/index.ts', { + test: [ + { + path: '/test', + method: 'GET', + parameters: [], + response: { 200: { type: 'primitive', value: 'string' } }, + }, + ], +}); + +const ROUTE_REF = { + '/index.ts': ` + import * as t from 'io-ts'; + import * as h from '@api-ts/io-ts-http'; + + const testRoute = h.httpRoute({ path: '/test', method: 'GET', request: h.httpRequest({}), response: { 200: t.string, }, - }) - } -}); -`; + }); -testCase('simple api spec', SIMPLE, { + export const test = h.apiSpec({ + 'api.test': { + get: testRoute, + } + });`, +}; + +testCase('const route reference', ROUTE_REF, '/index.ts', { test: [ { path: '/test', @@ -74,27 +116,28 @@ testCase('simple api spec', SIMPLE, { ], }); -const ROUTE_REF = ` -import * as t from 'io-ts'; -import * as h from '@api-ts/io-ts-http'; - -const testRoute = h.httpRoute({ - path: '/test', - method: 'GET', - request: h.httpRequest({}), - response: { - 200: t.string, - }, -}); +const ACTION_REF = { + '/index.ts': ` + import * as t from 'io-ts'; + import * as h from '@api-ts/io-ts-http'; -export const test = h.apiSpec({ - 'api.test': { - get: testRoute, - } -}); -`; + const testAction = { + get: h.httpRoute({ + path: '/test', + method: 'GET', + request: h.httpRequest({}), + response: { + 200: t.string, + }, + }), + }; + + export const test = h.apiSpec({ + 'api.test': testAction, + });`, +}; -testCase('const route reference', ROUTE_REF, { +testCase('const action reference', ACTION_REF, '/index.ts', { test: [ { path: '/test', @@ -105,27 +148,68 @@ testCase('const route reference', ROUTE_REF, { ], }); -const ACTION_REF = ` -import * as t from 'io-ts'; -import * as h from '@api-ts/io-ts-http'; - -const testAction = { - get: h.httpRoute({ - path: '/test', - method: 'GET', - request: h.httpRequest({}), - response: { - 200: t.string, - }, - }), +const SPREAD = { + '/index.ts': ` + import * as h from '@api-ts/io-ts-http'; + + import { Ref } from './ref'; + + export const test = h.apiSpec({ + ...Ref, + });`, + '/ref.ts': ` + import * as t from 'io-ts'; + import * as h from '@api-ts/io-ts-http'; + export const Ref = { + 'api.test': { + get: h.httpRoute({ + path: '/test', + method: 'GET', + request: h.httpRequest({}), + response: { + 200: t.string, + }, + }) + } + }; + `, }; -export const test = h.apiSpec({ - 'api.test': testAction, +testCase('spread api spec', SPREAD, '/index.ts', { + test: [ + { + path: '/test', + method: 'GET', + parameters: [], + response: { 200: { type: 'primitive', value: 'string' } }, + }, + ], }); -`; -testCase('const action reference', ACTION_REF, { +const COMPUTED_PROPERTY = { + '/index.ts': ` + import * as t from 'io-ts'; + import * as h from '@api-ts/io-ts-http'; + + function test(): 'api.test' { + return 'api.test'; + } + + export const test = h.apiSpec({ + [test()]: { + get: h.httpRoute({ + path: '/test', + method: 'GET', + request: h.httpRequest({}), + response: { + 200: t.string, + }, + }) + } + });`, +}; + +testCase('computed property api spec', COMPUTED_PROPERTY, '/index.ts', { test: [ { path: '/test', diff --git a/packages/openapi-generator/test/resolve.test.ts b/packages/openapi-generator/test/resolve.test.ts index 480142b2..ff032822 100644 --- a/packages/openapi-generator/test/resolve.test.ts +++ b/packages/openapi-generator/test/resolve.test.ts @@ -1,71 +1,11 @@ import * as E from 'fp-ts/lib/Either'; -import { Volume, type NestedDirectoryJSON } from 'memfs'; -import resolve from 'resolve'; +import { type NestedDirectoryJSON } from 'memfs'; import assert from 'node:assert'; import test from 'node:test'; -import { promisify } from 'util'; +import { TestProject } from './testProject'; import { parseCodecInitializer, Project, type Schema } from '../src'; -class TestProject extends Project { - private volume: ReturnType<(typeof Volume)['fromJSON']>; - - constructor(files: NestedDirectoryJSON) { - super(); - this.volume = Volume.fromNestedJSON(files, '/'); - } - - override async readFile(filename: string): Promise { - const file: any = await promisify(this.volume.readFile.bind(this.volume))(filename); - return file.toString('utf-8'); - } - - override resolve(basedir: string, path: string): E.Either { - try { - const result = resolve.sync(path, { - basedir, - extensions: ['.ts', '.js'], - readFileSync: this.volume.readFileSync.bind(this.volume), - isFile: (file) => { - try { - var stat = this.volume.statSync(file); - } catch (e: any) { - if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return false; - throw e; - } - return stat.isFile() || stat.isFIFO(); - }, - isDirectory: (dir) => { - try { - var stat = this.volume.statSync(dir); - } catch (e: any) { - if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return false; - throw e; - } - return stat.isDirectory(); - }, - realpathSync: (file) => { - try { - return this.volume.realpathSync(file) as string; - } catch (realPathErr: any) { - if (realPathErr.code !== 'ENOENT') { - throw realPathErr; - } - } - return file; - }, - }); - return E.right(result); - } catch (e: any) { - if (typeof e === 'object' && e.hasOwnProperty('message')) { - return E.left(e.message); - } else { - return E.left(JSON.stringify(e)); - } - } - } -} - async function testCase( description: string, files: NestedDirectoryJSON, diff --git a/packages/openapi-generator/test/testProject.ts b/packages/openapi-generator/test/testProject.ts new file mode 100644 index 00000000..cd48a2c0 --- /dev/null +++ b/packages/openapi-generator/test/testProject.ts @@ -0,0 +1,65 @@ +import * as E from 'fp-ts/lib/Either'; +import { Volume, type NestedDirectoryJSON } from 'memfs'; +import resolve from 'resolve'; +import { promisify } from 'util'; + +import { Project } from '../src'; + +export class TestProject extends Project { + private volume: ReturnType<(typeof Volume)['fromJSON']>; + + constructor(files: NestedDirectoryJSON) { + super(); + this.volume = Volume.fromNestedJSON(files, '/'); + } + + override async readFile(filename: string): Promise { + const file: any = await promisify(this.volume.readFile.bind(this.volume))(filename); + return file.toString('utf-8'); + } + + override resolve(basedir: string, path: string): E.Either { + try { + const result = resolve.sync(path, { + basedir, + extensions: ['.ts', '.js'], + readFileSync: this.volume.readFileSync.bind(this.volume), + isFile: (file) => { + try { + var stat = this.volume.statSync(file); + } catch (e: any) { + if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return false; + throw e; + } + return stat.isFile() || stat.isFIFO(); + }, + isDirectory: (dir) => { + try { + var stat = this.volume.statSync(dir); + } catch (e: any) { + if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return false; + throw e; + } + return stat.isDirectory(); + }, + realpathSync: (file) => { + try { + return this.volume.realpathSync(file) as string; + } catch (realPathErr: any) { + if (realPathErr.code !== 'ENOENT') { + throw realPathErr; + } + } + return file; + }, + }); + return E.right(result); + } catch (e: any) { + if (typeof e === 'object' && e.hasOwnProperty('message')) { + return E.left(e.message); + } else { + return E.left(JSON.stringify(e)); + } + } + } +}