Skip to content

Commit 73a41a0

Browse files
Merge pull request #549 from bitgopatmcl/ir-optimize-pass
Ir optimize pass
2 parents 3df34eb + f7bff97 commit 73a41a0

File tree

10 files changed

+277
-42
lines changed

10 files changed

+277
-42
lines changed

packages/openapi-generator/src/codec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,13 +227,13 @@ export function parsePlainInitializer(
227227
} else if (init.type === 'ArrayExpression') {
228228
return parseArrayExpression(project, source, init);
229229
} else if (init.type === 'StringLiteral') {
230-
return E.right({ type: 'literal', kind: 'string', value: init.value });
230+
return E.right({ type: 'primitive', value: 'string', enum: [init.value] });
231231
} else if (init.type === 'NumericLiteral') {
232-
return E.right({ type: 'literal', kind: 'number', value: init.value });
232+
return E.right({ type: 'primitive', value: 'number', enum: [init.value] });
233233
} else if (init.type === 'BooleanLiteral') {
234-
return E.right({ type: 'literal', kind: 'boolean', value: init.value });
234+
return E.right({ type: 'primitive', value: 'boolean', enum: [init.value] });
235235
} else if (init.type === 'NullLiteral') {
236-
return E.right({ type: 'literal', kind: 'null', value: null });
236+
return E.right({ type: 'primitive', value: 'null', enum: [null] });
237237
} else if (init.type === 'Identifier' && init.value === 'undefined') {
238238
return E.right({ type: 'undefined' });
239239
} else if (init.type === 'TsConstAssertion' || init.type === 'TsAsExpression') {

packages/openapi-generator/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { parseApiSpec } from './apiSpec';
22
export { parseCodecInitializer, parsePlainInitializer } from './codec';
33
export { parseCommentBlock, type JSDoc } from './jsdoc';
44
export { convertRoutesToOpenAPI } from './openapi';
5+
export { optimize } from './optimize';
56
export { Project } from './project';
67
export { getRefs } from './ref';
78
export { parseRoute, type Route } from './route';

packages/openapi-generator/src/ir.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,7 @@ export type UndefinedValue = {
1212
export type Primitive = {
1313
type: 'primitive';
1414
value: 'string' | 'number' | 'integer' | 'boolean' | 'null';
15-
};
16-
17-
export type Literal = {
18-
type: 'literal';
19-
kind: 'string' | 'number' | 'integer' | 'boolean' | 'null';
20-
value: string | number | boolean | null | PseudoBigInt;
15+
enum?: (string | number | boolean | null | PseudoBigInt)[];
2116
};
2217

2318
export type Array = {
@@ -49,7 +44,6 @@ export type Reference = {
4944

5045
export type BaseSchema =
5146
| Primitive
52-
| Literal
5347
| Array
5448
| Object
5549
| RecordObject

packages/openapi-generator/src/knownImports.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export const KNOWN_IMPORTS: KnownImports = {
8989
return E.right({ type: 'intersection', schemas: schema.schemas });
9090
},
9191
literal: (_, arg) => {
92-
if (arg.type !== 'literal') {
92+
if (arg.type !== 'primitive' || arg.enum === undefined) {
9393
return E.left(`Unimplemented literal type ${arg.type}`);
9494
} else {
9595
return E.right(arg);
@@ -100,9 +100,9 @@ export const KNOWN_IMPORTS: KnownImports = {
100100
return E.left(`Unimplemented keyof type ${arg.type}`);
101101
}
102102
const schemas: Schema[] = Object.keys(arg.properties).map((prop) => ({
103-
type: 'literal',
104-
kind: 'string',
105-
value: prop,
103+
type: 'primitive',
104+
value: 'string',
105+
enum: [prop],
106106
}));
107107
return E.right({
108108
type: 'union',

packages/openapi-generator/src/openapi.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,25 @@ import { OpenAPIV3 } from 'openapi-types';
22

33
import { STATUS_CODES } from 'http';
44
import { parseCommentBlock } from './jsdoc';
5+
import { optimize } from './optimize';
56
import type { Route } from './route';
67
import type { Schema } from './ir';
78

89
function schemaToOpenAPI(
910
schema: Schema,
1011
): OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined {
12+
schema = optimize(schema);
13+
1114
switch (schema.type) {
1215
case 'primitive':
1316
if (schema.value === 'integer') {
14-
return { type: 'number' };
17+
return { type: 'number', ...(schema.enum ? { enum: schema.enum } : {}) };
1518
} else if (schema.value === 'null') {
1619
// TODO: OpenAPI v3 does not have an explicit null type, is there a better way to represent this?
1720
// Or should we just conflate explicit null and undefined properties?
1821
return { nullable: true, enum: [] };
1922
} else {
20-
return { type: schema.value };
21-
}
22-
case 'literal':
23-
if (schema.kind === 'null') {
24-
return { nullable: true, enum: [] };
25-
} else {
26-
return { type: schema.kind, enum: [schema.value] };
23+
return { type: schema.value, ...(schema.enum ? { enum: schema.enum } : {}) };
2724
}
2825
case 'ref':
2926
return { $ref: `#/components/schemas/${schema.name}` };
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type { Schema } from './ir';
2+
3+
export type OptimizeFn = (schema: Schema) => Schema;
4+
5+
export function foldIntersection(schema: Schema, optimize: OptimizeFn): Schema {
6+
if (schema.type !== 'intersection') {
7+
return schema;
8+
}
9+
10+
const innerSchemas = schema.schemas.map(optimize);
11+
let combinedObject: Schema & { type: 'object' } = {
12+
type: 'object',
13+
properties: {},
14+
required: [],
15+
};
16+
let result: Schema = combinedObject;
17+
innerSchemas.forEach((innerSchema) => {
18+
if (innerSchema.type === 'object') {
19+
Object.assign(combinedObject.properties, innerSchema.properties);
20+
combinedObject.required.push(...innerSchema.required);
21+
} else if (result.type === 'intersection') {
22+
result.schemas.push(innerSchema);
23+
} else {
24+
result = {
25+
type: 'intersection',
26+
schemas: [combinedObject, innerSchema],
27+
};
28+
}
29+
});
30+
31+
return result;
32+
}
33+
34+
export function simplifyUnion(schema: Schema, optimize: OptimizeFn): Schema {
35+
if (schema.type !== 'union') {
36+
return schema;
37+
} else if (schema.schemas.length === 1) {
38+
return schema.schemas[0]!;
39+
} else if (schema.schemas.length === 0) {
40+
return { type: 'undefined' };
41+
}
42+
43+
const innerSchemas = schema.schemas.map(optimize);
44+
45+
const literals: Record<(Schema & { type: 'primitive' })['value'], any[]> = {
46+
string: [],
47+
number: [],
48+
integer: [],
49+
boolean: [],
50+
null: [],
51+
};
52+
const remainder: Schema[] = [];
53+
innerSchemas.forEach((innerSchema) => {
54+
if (innerSchema.type === 'primitive' && innerSchema.enum !== undefined) {
55+
literals[innerSchema.value].push(...innerSchema.enum);
56+
} else {
57+
remainder.push(innerSchema);
58+
}
59+
});
60+
const result: Schema = {
61+
type: 'union',
62+
schemas: remainder,
63+
};
64+
for (const [key, value] of Object.entries(literals)) {
65+
if (value.length > 0) {
66+
result.schemas.push({
67+
type: 'primitive',
68+
value: key as any,
69+
enum: value,
70+
});
71+
}
72+
}
73+
74+
if (result.schemas.length === 1) {
75+
return result.schemas[0]!;
76+
} else {
77+
return result;
78+
}
79+
}
80+
81+
export function filterUndefinedUnion(schema: Schema): [boolean, Schema] {
82+
if (schema.type !== 'union') {
83+
return [false, schema];
84+
}
85+
86+
const undefinedIndex = schema.schemas.findIndex((s) => s.type === 'undefined');
87+
if (undefinedIndex < 0) {
88+
return [false, schema];
89+
}
90+
91+
const schemas = schema.schemas.filter((s) => s.type !== 'undefined');
92+
if (schemas.length === 0) {
93+
return [true, { type: 'undefined' }];
94+
} else if (schemas.length === 1) {
95+
return [true, schemas[0]!];
96+
} else {
97+
return [true, { type: 'union', schemas }];
98+
}
99+
}
100+
101+
export function optimize(schema: Schema): Schema {
102+
if (schema.type === 'object') {
103+
const properties: Record<string, Schema> = {};
104+
const required: string[] = [];
105+
for (const [key, prop] of Object.entries(schema.properties)) {
106+
const optimized = optimize(prop);
107+
if (optimized.type === 'undefined') {
108+
continue;
109+
}
110+
const [isOptional, filteredSchema] = filterUndefinedUnion(optimized);
111+
properties[key] = filteredSchema;
112+
if (schema.required.indexOf(key) >= 0 && !isOptional) {
113+
required.push(key);
114+
}
115+
}
116+
return { type: 'object', properties, required };
117+
} else if (schema.type === 'intersection') {
118+
return foldIntersection(schema, optimize);
119+
} else if (schema.type === 'union') {
120+
return simplifyUnion(schema, optimize);
121+
} else if (schema.type === 'array') {
122+
const optimized = optimize(schema.items);
123+
return { type: 'array', items: optimized };
124+
} else if (schema.type === 'record') {
125+
return { type: 'record', codomain: optimize(schema.codomain) };
126+
} else if (schema.type === 'tuple') {
127+
const schemas = schema.schemas.map(optimize);
128+
return { type: 'tuple', schemas };
129+
} else {
130+
return schema;
131+
}
132+
}

packages/openapi-generator/src/route.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -210,17 +210,19 @@ export function parseRoute(project: Project, schema: Schema): E.Either<string, R
210210
if (schema.properties['path'] === undefined) {
211211
return E.left('Route must have a path');
212212
} else if (
213-
schema.properties['path'].type !== 'literal' ||
214-
schema.properties['path'].kind !== 'string'
213+
schema.properties['path'].type !== 'primitive' ||
214+
schema.properties['path'].value !== 'string' ||
215+
schema.properties['path'].enum?.length !== 1
215216
) {
216217
return E.left('Route path must be a string literal');
217218
}
218219

219220
if (schema.properties['method'] === undefined) {
220221
return E.left('Route must have a method');
221222
} else if (
222-
schema.properties['method'].type !== 'literal' ||
223-
schema.properties['method'].kind !== 'string'
223+
schema.properties['method'].type !== 'primitive' ||
224+
schema.properties['method'].value !== 'string' ||
225+
schema.properties['method'].enum?.length !== 1
224226
) {
225227
return E.left('Route method must be a string literal');
226228
}
@@ -242,8 +244,8 @@ export function parseRoute(project: Project, schema: Schema): E.Either<string, R
242244
}
243245

244246
return E.right({
245-
path: schema.properties['path'].value as string,
246-
method: schema.properties['method'].value as string,
247+
path: schema.properties['path'].enum![0] as string,
248+
method: schema.properties['method'].enum![0] as string,
247249
parameters,
248250
response: schema.properties['response'].properties,
249251
...(body !== undefined ? { body } : {}),

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -310,16 +310,16 @@ testCase('enum type is parsed', ENUM, {
310310
Foo: {
311311
type: 'object',
312312
properties: {
313-
Foo: { type: 'literal', kind: 'string', value: 'foo' },
314-
Bar: { type: 'literal', kind: 'string', value: 'bar' },
313+
Foo: { type: 'primitive', value: 'string', enum: ['foo'] },
314+
Bar: { type: 'primitive', value: 'string', enum: ['bar'] },
315315
},
316316
required: ['Foo', 'Bar'],
317317
},
318318
TEST: {
319319
type: 'union',
320320
schemas: [
321-
{ type: 'literal', kind: 'string', value: 'Foo' },
322-
{ type: 'literal', kind: 'string', value: 'Bar' },
321+
{ type: 'primitive', value: 'string', enum: ['Foo'] },
322+
{ type: 'primitive', value: 'string', enum: ['Bar'] },
323323
],
324324
},
325325
});
@@ -330,7 +330,7 @@ export const FOO = t.literal('foo');
330330
`;
331331

332332
testCase('string literal type is parsed', STRING_LITERAL, {
333-
FOO: { type: 'literal', kind: 'string', value: 'foo' },
333+
FOO: { type: 'primitive', value: 'string', enum: ['foo'] },
334334
});
335335

336336
const NUMBER_LITERAL = `
@@ -339,7 +339,7 @@ export const FOO = t.literal(42);
339339
`;
340340

341341
testCase('number literal type is parsed', NUMBER_LITERAL, {
342-
FOO: { type: 'literal', kind: 'number', value: 42 },
342+
FOO: { type: 'primitive', value: 'number', enum: [42] },
343343
});
344344

345345
const BOOLEAN_LITERAL = `
@@ -348,7 +348,7 @@ export const FOO = t.literal(true);
348348
`;
349349

350350
testCase('boolean literal type is parsed', BOOLEAN_LITERAL, {
351-
FOO: { type: 'literal', kind: 'boolean', value: true },
351+
FOO: { type: 'primitive', value: 'boolean', enum: [true] },
352352
});
353353

354354
const NULL_LITERAL = `
@@ -357,7 +357,7 @@ export const FOO = t.literal(null);
357357
`;
358358

359359
testCase('null literal type is parsed', NULL_LITERAL, {
360-
FOO: { type: 'literal', kind: 'null', value: null },
360+
FOO: { type: 'primitive', value: 'null', enum: [null] },
361361
});
362362

363363
const UNDEFINED_LITERAL = `
@@ -378,8 +378,8 @@ testCase('keyof type is parsed', KEYOF, {
378378
FOO: {
379379
type: 'union',
380380
schemas: [
381-
{ type: 'literal', kind: 'string', value: 'foo' },
382-
{ type: 'literal', kind: 'string', value: 'bar' },
381+
{ type: 'primitive', value: 'string', enum: ['foo'] },
382+
{ type: 'primitive', value: 'string', enum: ['bar'] },
383383
],
384384
},
385385
});

0 commit comments

Comments
 (0)