Skip to content

Commit 05352f9

Browse files
committed
feat: handle apiSpec spread elements in openapi-generator
1 parent f27700e commit 05352f9

File tree

4 files changed

+228
-113
lines changed

4 files changed

+228
-113
lines changed

packages/openapi-generator/src/apiSpec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,32 @@ export function parseApiSpec(
1818

1919
const result: Route[] = [];
2020
for (const apiAction of Object.values(expr.properties)) {
21+
if (apiAction.type === 'SpreadElement') {
22+
const spreadExprE = resolveLiteralOrIdentifier(
23+
project,
24+
sourceFile,
25+
apiAction.arguments,
26+
);
27+
if (E.isLeft(spreadExprE)) {
28+
return spreadExprE;
29+
}
30+
let [spreadSourceFile, spreadExpr] = spreadExprE.right;
31+
// TODO: This is just assuming that a `CallExpression` here is to `h.apiSpec`
32+
if (spreadExpr.type === 'CallExpression') {
33+
const arg = spreadExpr.arguments[0];
34+
if (arg === undefined) {
35+
return E.left(`unimplemented spread argument type ${arg}`);
36+
}
37+
spreadExpr = arg.expression;
38+
}
39+
const spreadSpecE = parseApiSpec(project, spreadSourceFile, spreadExpr);
40+
if (E.isLeft(spreadSpecE)) {
41+
return spreadSpecE;
42+
}
43+
result.push(...spreadSpecE.right);
44+
continue;
45+
}
46+
2147
if (apiAction.type !== 'KeyValueProperty') {
2248
return E.left(`unimplemented route property type ${apiAction.type}`);
2349
}

packages/openapi-generator/test/apiSpec.test.ts

Lines changed: 135 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
import * as E from 'fp-ts/lib/Either';
22
import assert from 'node:assert';
33
import test from 'node:test';
4+
import type { NestedDirectoryJSON } from 'memfs';
45

5-
import { parseSource, parseApiSpec, Project, type Route } from '../src';
6+
import { TestProject } from './testProject';
7+
import { parseApiSpec, type Route } from '../src';
68

79
async function testCase(
810
description: string,
9-
src: string,
11+
files: NestedDirectoryJSON,
12+
entryPoint: string,
1013
expected: Record<string, Route[]>,
1114
expectedErrors: string[] = [],
1215
) {
1316
test(description, async () => {
14-
const sourceFile = await parseSource('./index.ts', src);
17+
const project = new TestProject(files);
18+
19+
await project.parseEntryPoint(entryPoint);
20+
const sourceFile = project.get(entryPoint);
21+
if (sourceFile === undefined) {
22+
throw new Error(`could not find source file ${entryPoint}`);
23+
}
1524

1625
const actual: Record<string, Route[]> = {};
1726
const errors: string[] = [];
@@ -32,7 +41,7 @@ async function testCase(
3241
if (arg.expression.type !== 'ObjectExpression') {
3342
continue;
3443
}
35-
const result = parseApiSpec(new Project(), sourceFile, arg.expression);
44+
const result = parseApiSpec(project, sourceFile, arg.expression);
3645
if (E.isLeft(result)) {
3746
errors.push(result.left);
3847
} else {
@@ -46,24 +55,57 @@ async function testCase(
4655
});
4756
}
4857

49-
const SIMPLE = `
50-
import * as t from 'io-ts';
51-
import * as h from '@api-ts/io-ts-http';
52-
export const test = h.apiSpec({
53-
'api.test': {
54-
get: h.httpRoute({
58+
const SIMPLE = {
59+
'/index.ts': `
60+
import * as t from 'io-ts';
61+
import * as h from '@api-ts/io-ts-http';
62+
export const test = h.apiSpec({
63+
'api.test': {
64+
get: h.httpRoute({
65+
path: '/test',
66+
method: 'GET',
67+
request: h.httpRequest({}),
68+
response: {
69+
200: t.string,
70+
},
71+
})
72+
}
73+
});`,
74+
};
75+
76+
testCase('simple api spec', SIMPLE, '/index.ts', {
77+
test: [
78+
{
79+
path: '/test',
80+
method: 'GET',
81+
parameters: [],
82+
response: { 200: { type: 'primitive', value: 'string' } },
83+
},
84+
],
85+
});
86+
87+
const ROUTE_REF = {
88+
'/index.ts': `
89+
import * as t from 'io-ts';
90+
import * as h from '@api-ts/io-ts-http';
91+
92+
const testRoute = h.httpRoute({
5593
path: '/test',
5694
method: 'GET',
5795
request: h.httpRequest({}),
5896
response: {
5997
200: t.string,
6098
},
61-
})
62-
}
63-
});
64-
`;
99+
});
65100
66-
testCase('simple api spec', SIMPLE, {
101+
export const test = h.apiSpec({
102+
'api.test': {
103+
get: testRoute,
104+
}
105+
});`,
106+
};
107+
108+
testCase('const route reference', ROUTE_REF, '/index.ts', {
67109
test: [
68110
{
69111
path: '/test',
@@ -74,27 +116,28 @@ testCase('simple api spec', SIMPLE, {
74116
],
75117
});
76118

77-
const ROUTE_REF = `
78-
import * as t from 'io-ts';
79-
import * as h from '@api-ts/io-ts-http';
80-
81-
const testRoute = h.httpRoute({
82-
path: '/test',
83-
method: 'GET',
84-
request: h.httpRequest({}),
85-
response: {
86-
200: t.string,
87-
},
88-
});
119+
const ACTION_REF = {
120+
'/index.ts': `
121+
import * as t from 'io-ts';
122+
import * as h from '@api-ts/io-ts-http';
89123
90-
export const test = h.apiSpec({
91-
'api.test': {
92-
get: testRoute,
93-
}
94-
});
95-
`;
124+
const testAction = {
125+
get: h.httpRoute({
126+
path: '/test',
127+
method: 'GET',
128+
request: h.httpRequest({}),
129+
response: {
130+
200: t.string,
131+
},
132+
}),
133+
};
134+
135+
export const test = h.apiSpec({
136+
'api.test': testAction,
137+
});`,
138+
};
96139

97-
testCase('const route reference', ROUTE_REF, {
140+
testCase('const action reference', ACTION_REF, '/index.ts', {
98141
test: [
99142
{
100143
path: '/test',
@@ -105,27 +148,68 @@ testCase('const route reference', ROUTE_REF, {
105148
],
106149
});
107150

108-
const ACTION_REF = `
109-
import * as t from 'io-ts';
110-
import * as h from '@api-ts/io-ts-http';
111-
112-
const testAction = {
113-
get: h.httpRoute({
114-
path: '/test',
115-
method: 'GET',
116-
request: h.httpRequest({}),
117-
response: {
118-
200: t.string,
119-
},
120-
}),
151+
const SPREAD = {
152+
'/index.ts': `
153+
import * as h from '@api-ts/io-ts-http';
154+
155+
import { Ref } from './ref';
156+
157+
export const test = h.apiSpec({
158+
...Ref,
159+
});`,
160+
'/ref.ts': `
161+
import * as t from 'io-ts';
162+
import * as h from '@api-ts/io-ts-http';
163+
export const Ref = {
164+
'api.test': {
165+
get: h.httpRoute({
166+
path: '/test',
167+
method: 'GET',
168+
request: h.httpRequest({}),
169+
response: {
170+
200: t.string,
171+
},
172+
})
173+
}
174+
};
175+
`,
121176
};
122177

123-
export const test = h.apiSpec({
124-
'api.test': testAction,
178+
testCase('spread api spec', SPREAD, '/index.ts', {
179+
test: [
180+
{
181+
path: '/test',
182+
method: 'GET',
183+
parameters: [],
184+
response: { 200: { type: 'primitive', value: 'string' } },
185+
},
186+
],
125187
});
126-
`;
127188

128-
testCase('const action reference', ACTION_REF, {
189+
const COMPUTED_PROPERTY = {
190+
'/index.ts': `
191+
import * as t from 'io-ts';
192+
import * as h from '@api-ts/io-ts-http';
193+
194+
function test(): 'api.test' {
195+
return 'api.test';
196+
}
197+
198+
export const test = h.apiSpec({
199+
[test()]: {
200+
get: h.httpRoute({
201+
path: '/test',
202+
method: 'GET',
203+
request: h.httpRequest({}),
204+
response: {
205+
200: t.string,
206+
},
207+
})
208+
}
209+
});`,
210+
};
211+
212+
testCase('computed property api spec', COMPUTED_PROPERTY, '/index.ts', {
129213
test: [
130214
{
131215
path: '/test',

packages/openapi-generator/test/resolve.test.ts

Lines changed: 2 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,11 @@
11
import * as E from 'fp-ts/lib/Either';
2-
import { Volume, type NestedDirectoryJSON } from 'memfs';
3-
import resolve from 'resolve';
2+
import { type NestedDirectoryJSON } from 'memfs';
43
import assert from 'node:assert';
54
import test from 'node:test';
6-
import { promisify } from 'util';
75

6+
import { TestProject } from './testProject';
87
import { parseCodecInitializer, Project, type Schema } from '../src';
98

10-
class TestProject extends Project {
11-
private volume: ReturnType<(typeof Volume)['fromJSON']>;
12-
13-
constructor(files: NestedDirectoryJSON) {
14-
super();
15-
this.volume = Volume.fromNestedJSON(files, '/');
16-
}
17-
18-
override async readFile(filename: string): Promise<string> {
19-
const file: any = await promisify(this.volume.readFile.bind(this.volume))(filename);
20-
return file.toString('utf-8');
21-
}
22-
23-
override resolve(basedir: string, path: string): E.Either<string, string> {
24-
try {
25-
const result = resolve.sync(path, {
26-
basedir,
27-
extensions: ['.ts', '.js'],
28-
readFileSync: this.volume.readFileSync.bind(this.volume),
29-
isFile: (file) => {
30-
try {
31-
var stat = this.volume.statSync(file);
32-
} catch (e: any) {
33-
if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return false;
34-
throw e;
35-
}
36-
return stat.isFile() || stat.isFIFO();
37-
},
38-
isDirectory: (dir) => {
39-
try {
40-
var stat = this.volume.statSync(dir);
41-
} catch (e: any) {
42-
if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return false;
43-
throw e;
44-
}
45-
return stat.isDirectory();
46-
},
47-
realpathSync: (file) => {
48-
try {
49-
return this.volume.realpathSync(file) as string;
50-
} catch (realPathErr: any) {
51-
if (realPathErr.code !== 'ENOENT') {
52-
throw realPathErr;
53-
}
54-
}
55-
return file;
56-
},
57-
});
58-
return E.right(result);
59-
} catch (e: any) {
60-
if (typeof e === 'object' && e.hasOwnProperty('message')) {
61-
return E.left(e.message);
62-
} else {
63-
return E.left(JSON.stringify(e));
64-
}
65-
}
66-
}
67-
}
68-
699
async function testCase(
7010
description: string,
7111
files: NestedDirectoryJSON,

0 commit comments

Comments
 (0)