Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions packages/openapi-generator/src/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,13 @@ export function parsePlainInitializer(
} else if (init.type === 'ArrayExpression') {
return parseArrayExpression(project, source, init);
} else if (init.type === 'StringLiteral') {
return E.right({ type: 'literal', kind: 'string', value: init.value });
return E.right({ type: 'primitive', value: 'string', enum: [init.value] });
} else if (init.type === 'NumericLiteral') {
return E.right({ type: 'literal', kind: 'number', value: init.value });
return E.right({ type: 'primitive', value: 'number', enum: [init.value] });
} else if (init.type === 'BooleanLiteral') {
return E.right({ type: 'literal', kind: 'boolean', value: init.value });
return E.right({ type: 'primitive', value: 'boolean', enum: [init.value] });
} else if (init.type === 'NullLiteral') {
return E.right({ type: 'literal', kind: 'null', value: null });
return E.right({ type: 'primitive', value: 'null', enum: [null] });
} else if (init.type === 'Identifier' && init.value === 'undefined') {
return E.right({ type: 'undefined' });
} else if (init.type === 'TsConstAssertion' || init.type === 'TsAsExpression') {
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-generator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { parseApiSpec } from './apiSpec';
export { parseCodecInitializer, parsePlainInitializer } from './codec';
export { parseCommentBlock, type JSDoc } from './jsdoc';
export { convertRoutesToOpenAPI } from './openapi';
export { optimize } from './optimize';
export { Project } from './project';
export { getRefs } from './ref';
export { parseRoute, type Route } from './route';
Expand Down
8 changes: 1 addition & 7 deletions packages/openapi-generator/src/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@ export type UndefinedValue = {
export type Primitive = {
type: 'primitive';
value: 'string' | 'number' | 'integer' | 'boolean' | 'null';
};

export type Literal = {
type: 'literal';
kind: 'string' | 'number' | 'integer' | 'boolean' | 'null';
value: string | number | boolean | null | PseudoBigInt;
enum?: (string | number | boolean | null | PseudoBigInt)[];
};

export type Array = {
Expand Down Expand Up @@ -49,7 +44,6 @@ export type Reference = {

export type BaseSchema =
| Primitive
| Literal
| Array
| Object
| RecordObject
Expand Down
8 changes: 4 additions & 4 deletions packages/openapi-generator/src/knownImports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const KNOWN_IMPORTS: KnownImports = {
return E.right({ type: 'intersection', schemas: schema.schemas });
},
literal: (_, arg) => {
if (arg.type !== 'literal') {
if (arg.type !== 'primitive' || arg.enum === undefined) {
return E.left(`Unimplemented literal type ${arg.type}`);
} else {
return E.right(arg);
Expand All @@ -100,9 +100,9 @@ export const KNOWN_IMPORTS: KnownImports = {
return E.left(`Unimplemented keyof type ${arg.type}`);
}
const schemas: Schema[] = Object.keys(arg.properties).map((prop) => ({
type: 'literal',
kind: 'string',
value: prop,
type: 'primitive',
value: 'string',
enum: [prop],
}));
return E.right({
type: 'union',
Expand Down
13 changes: 5 additions & 8 deletions packages/openapi-generator/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,25 @@ import { OpenAPIV3 } from 'openapi-types';

import { STATUS_CODES } from 'http';
import { parseCommentBlock } from './jsdoc';
import { optimize } from './optimize';
import type { Route } from './route';
import type { Schema } from './ir';

function schemaToOpenAPI(
schema: Schema,
): OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined {
schema = optimize(schema);

switch (schema.type) {
case 'primitive':
if (schema.value === 'integer') {
return { type: 'number' };
return { type: 'number', ...(schema.enum ? { enum: schema.enum } : {}) };
} else if (schema.value === 'null') {
// TODO: OpenAPI v3 does not have an explicit null type, is there a better way to represent this?
// Or should we just conflate explicit null and undefined properties?
return { nullable: true, enum: [] };
} else {
return { type: schema.value };
}
case 'literal':
if (schema.kind === 'null') {
return { nullable: true, enum: [] };
} else {
return { type: schema.kind, enum: [schema.value] };
return { type: schema.value, ...(schema.enum ? { enum: schema.enum } : {}) };
}
case 'ref':
return { $ref: `#/components/schemas/${schema.name}` };
Expand Down
132 changes: 132 additions & 0 deletions packages/openapi-generator/src/optimize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { Schema } from './ir';

export type OptimizeFn = (schema: Schema) => Schema;

export function foldIntersection(schema: Schema, optimize: OptimizeFn): Schema {
if (schema.type !== 'intersection') {
return schema;
}

const innerSchemas = schema.schemas.map(optimize);
let combinedObject: Schema & { type: 'object' } = {
type: 'object',
properties: {},
required: [],
};
let result: Schema = combinedObject;
innerSchemas.forEach((innerSchema) => {
if (innerSchema.type === 'object') {
Object.assign(combinedObject.properties, innerSchema.properties);
combinedObject.required.push(...innerSchema.required);
} else if (result.type === 'intersection') {
result.schemas.push(innerSchema);
} else {
result = {
type: 'intersection',
schemas: [combinedObject, innerSchema],
};
}
});

return result;
}

export function simplifyUnion(schema: Schema, optimize: OptimizeFn): Schema {
if (schema.type !== 'union') {
return schema;
} else if (schema.schemas.length === 1) {
return schema.schemas[0]!;
} else if (schema.schemas.length === 0) {
return { type: 'undefined' };
}

const innerSchemas = schema.schemas.map(optimize);

const literals: Record<(Schema & { type: 'primitive' })['value'], any[]> = {
string: [],
number: [],
integer: [],
boolean: [],
null: [],
};
const remainder: Schema[] = [];
innerSchemas.forEach((innerSchema) => {
if (innerSchema.type === 'primitive' && innerSchema.enum !== undefined) {
literals[innerSchema.value].push(...innerSchema.enum);
} else {
remainder.push(innerSchema);
}
});
const result: Schema = {
type: 'union',
schemas: remainder,
};
for (const [key, value] of Object.entries(literals)) {
if (value.length > 0) {
result.schemas.push({
type: 'primitive',
value: key as any,
enum: value,
});
}
}

if (result.schemas.length === 1) {
return result.schemas[0]!;
} else {
return result;
}
}

export function filterUndefinedUnion(schema: Schema): [boolean, Schema] {
if (schema.type !== 'union') {
return [false, schema];
}

const undefinedIndex = schema.schemas.findIndex((s) => s.type === 'undefined');
if (undefinedIndex < 0) {
return [false, schema];
}

const schemas = schema.schemas.filter((s) => s.type !== 'undefined');
if (schemas.length === 0) {
return [true, { type: 'undefined' }];
} else if (schemas.length === 1) {
return [true, schemas[0]!];
} else {
return [true, { type: 'union', schemas }];
}
}

export function optimize(schema: Schema): Schema {
if (schema.type === 'object') {
const properties: Record<string, Schema> = {};
const required: string[] = [];
for (const [key, prop] of Object.entries(schema.properties)) {
const optimized = optimize(prop);
if (optimized.type === 'undefined') {
continue;
}
const [isOptional, filteredSchema] = filterUndefinedUnion(optimized);
properties[key] = filteredSchema;
if (schema.required.indexOf(key) >= 0 && !isOptional) {
required.push(key);
}
}
return { type: 'object', properties, required };
} else if (schema.type === 'intersection') {
return foldIntersection(schema, optimize);
} else if (schema.type === 'union') {
return simplifyUnion(schema, optimize);
} else if (schema.type === 'array') {
const optimized = optimize(schema.items);
return { type: 'array', items: optimized };
} else if (schema.type === 'record') {
return { type: 'record', codomain: optimize(schema.codomain) };
} else if (schema.type === 'tuple') {
const schemas = schema.schemas.map(optimize);
return { type: 'tuple', schemas };
} else {
return schema;
}
}
14 changes: 8 additions & 6 deletions packages/openapi-generator/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,17 +210,19 @@ export function parseRoute(project: Project, schema: Schema): E.Either<string, R
if (schema.properties['path'] === undefined) {
return E.left('Route must have a path');
} else if (
schema.properties['path'].type !== 'literal' ||
schema.properties['path'].kind !== 'string'
schema.properties['path'].type !== 'primitive' ||
schema.properties['path'].value !== 'string' ||
schema.properties['path'].enum?.length !== 1
) {
return E.left('Route path must be a string literal');
}

if (schema.properties['method'] === undefined) {
return E.left('Route must have a method');
} else if (
schema.properties['method'].type !== 'literal' ||
schema.properties['method'].kind !== 'string'
schema.properties['method'].type !== 'primitive' ||
schema.properties['method'].value !== 'string' ||
schema.properties['method'].enum?.length !== 1
) {
return E.left('Route method must be a string literal');
}
Expand All @@ -242,8 +244,8 @@ export function parseRoute(project: Project, schema: Schema): E.Either<string, R
}

return E.right({
path: schema.properties['path'].value as string,
method: schema.properties['method'].value as string,
path: schema.properties['path'].enum![0] as string,
method: schema.properties['method'].enum![0] as string,
parameters,
response: schema.properties['response'].properties,
...(body !== undefined ? { body } : {}),
Expand Down
20 changes: 10 additions & 10 deletions packages/openapi-generator/test/codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,16 +310,16 @@ testCase('enum type is parsed', ENUM, {
Foo: {
type: 'object',
properties: {
Foo: { type: 'literal', kind: 'string', value: 'foo' },
Bar: { type: 'literal', kind: 'string', value: 'bar' },
Foo: { type: 'primitive', value: 'string', enum: ['foo'] },
Bar: { type: 'primitive', value: 'string', enum: ['bar'] },
},
required: ['Foo', 'Bar'],
},
TEST: {
type: 'union',
schemas: [
{ type: 'literal', kind: 'string', value: 'Foo' },
{ type: 'literal', kind: 'string', value: 'Bar' },
{ type: 'primitive', value: 'string', enum: ['Foo'] },
{ type: 'primitive', value: 'string', enum: ['Bar'] },
],
},
});
Expand All @@ -330,7 +330,7 @@ export const FOO = t.literal('foo');
`;

testCase('string literal type is parsed', STRING_LITERAL, {
FOO: { type: 'literal', kind: 'string', value: 'foo' },
FOO: { type: 'primitive', value: 'string', enum: ['foo'] },
});

const NUMBER_LITERAL = `
Expand All @@ -339,7 +339,7 @@ export const FOO = t.literal(42);
`;

testCase('number literal type is parsed', NUMBER_LITERAL, {
FOO: { type: 'literal', kind: 'number', value: 42 },
FOO: { type: 'primitive', value: 'number', enum: [42] },
});

const BOOLEAN_LITERAL = `
Expand All @@ -348,7 +348,7 @@ export const FOO = t.literal(true);
`;

testCase('boolean literal type is parsed', BOOLEAN_LITERAL, {
FOO: { type: 'literal', kind: 'boolean', value: true },
FOO: { type: 'primitive', value: 'boolean', enum: [true] },
});

const NULL_LITERAL = `
Expand All @@ -357,7 +357,7 @@ export const FOO = t.literal(null);
`;

testCase('null literal type is parsed', NULL_LITERAL, {
FOO: { type: 'literal', kind: 'null', value: null },
FOO: { type: 'primitive', value: 'null', enum: [null] },
});

const UNDEFINED_LITERAL = `
Expand All @@ -378,8 +378,8 @@ testCase('keyof type is parsed', KEYOF, {
FOO: {
type: 'union',
schemas: [
{ type: 'literal', kind: 'string', value: 'foo' },
{ type: 'literal', kind: 'string', value: 'bar' },
{ type: 'primitive', value: 'string', enum: ['foo'] },
{ type: 'primitive', value: 'string', enum: ['bar'] },
],
},
});
Expand Down
Loading