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
1 change: 1 addition & 0 deletions packages/openapi-generator/src/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type UndefinedValue = {
export type Primitive = {
type: 'string' | 'number' | 'integer' | 'boolean' | 'null';
enum?: (string | number | boolean | null | PseudoBigInt)[];
enumDescriptions?: Record<string, string>;
};

export function isPrimitive(schema: Schema): schema is Primitive {
Expand Down
40 changes: 32 additions & 8 deletions packages/openapi-generator/src/knownImports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,38 @@ export const KNOWN_IMPORTS: KnownImports = {
if (arg.type !== 'object') {
return errorLeft(`Unimplemented keyof type ${arg.type}`);
}
const schemas: Schema[] = Object.keys(arg.properties).map((prop) => ({
type: 'string',
enum: [prop],
}));
return E.right({
type: 'union',
schemas,
});

const enumValues = Object.keys(arg.properties);
const enumDescriptions: Record<string, string> = {};
let hasDescriptions = false;

for (const prop of enumValues) {
const propertySchema = arg.properties[prop];
if (propertySchema?.comment?.description) {
enumDescriptions[prop] = propertySchema.comment.description;
hasDescriptions = true;
}
}

if (hasDescriptions) {
return E.right({
type: 'string',
enum: enumValues,
enumDescriptions,
});
} else {
const schemas: Schema[] = enumValues.map((prop) => {
return {
type: 'string',
enum: [prop],
};
});

return E.right({
type: 'union',
schemas,
});
}
},
brand: (_, arg) => E.right(arg),
UnknownRecord: () => E.right({ type: 'record', codomain: { type: 'any' } }),
Expand Down
22 changes: 18 additions & 4 deletions packages/openapi-generator/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,32 @@ export function schemaToOpenAPI(
switch (schema.type) {
case 'boolean':
case 'string':
case 'number':
return {
case 'number': {
const result: any = {
type: schema.type,
...(schema.enum ? { enum: schema.enum } : {}),
...defaultOpenAPIObject,
};
case 'integer':
return {

if (schema.enum && schema.enumDescriptions) {
result['x-enumDescriptions'] = schema.enumDescriptions;
}

return result;
}
case 'integer': {
const result: any = {
type: 'number',
...(schema.enum ? { enum: schema.enum } : {}),
...defaultOpenAPIObject,
};

if (schema.enum && schema.enumDescriptions) {
result['x-enumDescriptions'] = schema.enumDescriptions;
}

return result;
}
case '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?
Expand Down
10 changes: 7 additions & 3 deletions packages/openapi-generator/src/optimize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,13 @@ export function simplifyUnion(schema: Schema, optimize: OptimizeFn): Schema {
const remainder: Schema[] = [];
innerSchemas.forEach((innerSchema) => {
if (isPrimitive(innerSchema) && innerSchema.enum !== undefined) {
innerSchema.enum.forEach((value) => {
literals[innerSchema.type].add(value);
});
if (innerSchema.comment || innerSchema.enumDescriptions) {
remainder.push(innerSchema);
} else {
innerSchema.enum.forEach((value) => {
literals[innerSchema.type].add(value);
});
}
} else {
remainder.push(innerSchema);
}
Expand Down
242 changes: 242 additions & 0 deletions packages/openapi-generator/test/openapi/comments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1440,3 +1440,245 @@ testCase(
},
},
);

const ROUTE_WITH_INDIVIDUAL_ENUM_DESCRIPTIONS = `
import * as t from 'io-ts';
import * as h from '@api-ts/io-ts-http';

/**
* Transaction Request State Enum with individual descriptions
*/
export const TransactionRequestState = t.keyof(
{
/** Transaction is waiting for approval from authorized users */
pendingApproval: 1,
/** Transaction was canceled by the user */
canceled: 1,
/** Transaction was rejected by approvers */
rejected: 1,
/** Transaction has been initialized but not yet processed */
initialized: 1,
/** Transaction is ready to be delivered */
pendingDelivery: 1,
/** Transaction has been successfully delivered */
delivered: 1,
},
'TransactionRequestState',
);

/**
* Route to test individual enum variant descriptions
*
* @operationId api.v1.enumVariantDescriptions
* @tag Test Routes
*/
export const route = h.httpRoute({
path: '/transactions',
method: 'GET',
request: h.httpRequest({
query: {
states: t.array(TransactionRequestState),
},
}),
response: {
200: {
result: t.string
}
},
});
`;

testCase(
'individual enum variant descriptions use x-enumDescriptions extension',
ROUTE_WITH_INDIVIDUAL_ENUM_DESCRIPTIONS,
{
openapi: '3.0.3',
info: {
title: 'Test',
version: '1.0.0',
},
paths: {
'/transactions': {
get: {
summary: 'Route to test individual enum variant descriptions',
operationId: 'api.v1.enumVariantDescriptions',
tags: ['Test Routes'],
parameters: [
{
name: 'states',
in: 'query',
required: true,
schema: {
type: 'array',
items: {
type: 'string',
enum: [
'pendingApproval',
'canceled',
'rejected',
'initialized',
'pendingDelivery',
'delivered',
],
'x-enumDescriptions': {
pendingApproval:
'Transaction is waiting for approval from authorized users',
canceled: 'Transaction was canceled by the user',
rejected: 'Transaction was rejected by approvers',
initialized:
'Transaction has been initialized but not yet processed',
pendingDelivery: 'Transaction is ready to be delivered',
delivered: 'Transaction has been successfully delivered',
},
description:
'Transaction Request State Enum with individual descriptions',
},
},
},
],
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
required: ['result'],
},
},
},
},
},
},
},
},
components: {
schemas: {
TransactionRequestState: {
title: 'TransactionRequestState',
description: 'Transaction Request State Enum with individual descriptions',
type: 'string',
enum: [
'pendingApproval',
'canceled',
'rejected',
'initialized',
'pendingDelivery',
'delivered',
],
'x-enumDescriptions': {
pendingApproval:
'Transaction is waiting for approval from authorized users',
canceled: 'Transaction was canceled by the user',
rejected: 'Transaction was rejected by approvers',
initialized: 'Transaction has been initialized but not yet processed',
pendingDelivery: 'Transaction is ready to be delivered',
delivered: 'Transaction has been successfully delivered',
},
},
},
},
},
);

const ROUTE_WITH_ENUM_WITHOUT_DESCRIPTIONS = `
import * as t from 'io-ts';
import * as h from '@api-ts/io-ts-http';

/**
* Simple enum without individual descriptions
*/
export const SimpleEnum = t.keyof(
{
value1: 1,
value2: 1,
value3: 1,
},
'SimpleEnum',
);

/**
* Route to test enum without individual descriptions
*
* @operationId api.v1.simpleEnum
* @tag Test Routes
*/
export const route = h.httpRoute({
path: '/simple',
method: 'GET',
request: h.httpRequest({
query: {
value: SimpleEnum,
},
}),
response: {
200: {
result: t.string
}
},
});
`;

testCase(
'enum without individual descriptions uses standard enum format',
ROUTE_WITH_ENUM_WITHOUT_DESCRIPTIONS,
{
openapi: '3.0.3',
info: {
title: 'Test',
version: '1.0.0',
},
paths: {
'/simple': {
get: {
summary: 'Route to test enum without individual descriptions',
operationId: 'api.v1.simpleEnum',
tags: ['Test Routes'],
parameters: [
{
name: 'value',
in: 'query',
required: true,
schema: {
$ref: '#/components/schemas/SimpleEnum',
},
},
],
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
required: ['result'],
},
},
},
},
},
},
},
},
components: {
schemas: {
SimpleEnum: {
title: 'SimpleEnum',
type: 'string',
enum: ['value1', 'value2', 'value3'],
description: 'Simple enum without individual descriptions',
},
},
},
},
);