diff --git a/.changeset/honest-feet-worry.md b/.changeset/honest-feet-worry.md new file mode 100644 index 00000000..a926d45e --- /dev/null +++ b/.changeset/honest-feet-worry.md @@ -0,0 +1,5 @@ +--- +"swagger-typescript-api": minor +--- + +Implement const object-style enum generation diff --git a/index.ts b/index.ts index f3356e94..fbfbe814 100644 --- a/index.ts +++ b/index.ts @@ -181,6 +181,12 @@ const generateCommand = defineCommand({ description: 'generate all "enum" types as union types (T1 | T2 | TN)', default: codeGenBaseConfig.generateUnionEnums, }, + "generate-const-object-enums": { + type: "boolean", + description: + 'generate all "enum" types as pairs of const objects and types derived from those objects\' keys. Mutually exclusive with, and pre-empted by, generateUnionEnums', + default: codeGenBaseConfig.generateConstObjectEnums, // TODO: collapse enum booleans into a single field taking an enum? + }, "http-client": { type: "string", description: `http client type (possible values: ${Object.values( @@ -311,6 +317,7 @@ const generateCommand = defineCommand({ generateResponses: args.responses, generateRouteTypes: args["route-types"], generateUnionEnums: args["generate-union-enums"], + generateConstObjectEnums: args["generate-const-object-enums"], httpClientType: args["http-client"] || args.axios ? HTTP_CLIENT.AXIOS diff --git a/src/configuration.ts b/src/configuration.ts index 8824268f..c7f91c0a 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -33,6 +33,7 @@ const TsKeyword = { Record: "Record", Intersection: "&", Union: "|", + Const: "const", }; const TsCodeGenKeyword = { @@ -54,6 +55,8 @@ export class CodeGenConfig { /** CLI flag */ generateUnionEnums = false; /** CLI flag */ + generateConstObjectEnums = false; + /** CLI flag */ addReadonly = false; enumNamesAsValues = false; /** parsed swagger schema from getSwaggerObject() */ diff --git a/src/schema-parser/schema-formatters.ts b/src/schema-parser/schema-formatters.ts index ccd57d7d..ece4e334 100644 --- a/src/schema-parser/schema-formatters.ts +++ b/src/schema-parser/schema-formatters.ts @@ -29,6 +29,21 @@ export class SchemaFormatters { }; } + if (this.config.generateConstObjectEnums) { + const entries = parsedSchema.content + .map(({ key, value }) => { + return `${key}: ${value}`; + }) + .join(",\n "); + return { + ...parsedSchema, + $content: parsedSchema.content, + typeIdentifier: this.config.Ts.Keyword.Const, + content: `{\n ${entries}\n} as const;\nexport type ${parsedSchema.name} = (typeof ${parsedSchema.name})[keyof typeof ${parsedSchema.name}];`, + }; + } + + // Fallback: classic TypeScript enum return { ...parsedSchema, $content: parsedSchema.content, diff --git a/templates/base/data-contracts.ejs b/templates/base/data-contracts.ejs index 166908da..90ac3d70 100644 --- a/templates/base/data-contracts.ejs +++ b/templates/base/data-contracts.ejs @@ -25,6 +25,9 @@ const dataContractTemplates = { type: (contract) => { return `type ${contract.name}${buildGenerics(contract)} = ${contract.content}`; }, + 'const': (contract) => { + return `const ${contract.name}${buildGenerics(contract)} = ${contract.content}`; + }, } %> diff --git a/tests/spec/constObjectEnums/__snapshots__/basic.test.ts.snap b/tests/spec/constObjectEnums/__snapshots__/basic.test.ts.snap new file mode 100644 index 00000000..a34a4718 --- /dev/null +++ b/tests/spec/constObjectEnums/__snapshots__/basic.test.ts.snap @@ -0,0 +1,314 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`basic > --generate-const-object-enums 1`] = ` +"/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +/** + * FooBar + * @format int32 + */ +export const IntEnumWithNames = { + Unknown: 0, + String: 1, + Int32: 2, + Int64: 3, + Double: 4, + DateTime: 5, + Test2: 6, + Test23: 7, + Tess44: 8, + BooFar: 9, +} as const; +export type IntEnumWithNames = + (typeof IntEnumWithNames)[keyof typeof IntEnumWithNames]; + +export const BooleanEnum = { + True: true, + False: false, +} as const; +export type BooleanEnum = (typeof BooleanEnum)[keyof typeof BooleanEnum]; + +export const NumberEnum = { + Value1: 1, + Value2: 2, + Value3: 3, + Value4: 4, +} as const; +export type NumberEnum = (typeof NumberEnum)[keyof typeof NumberEnum]; + +export const StringEnum = { + String1: "String1", + String2: "String2", + String3: "String3", + String4: "String4", +} as const; +export type StringEnum = (typeof StringEnum)[keyof typeof StringEnum]; + +export type QueryParamsType = Record; +export type ResponseFormat = keyof Omit; + +export interface FullRequestParams extends Omit { + /** set parameter to \`true\` for call \`securityWorker\` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseFormat; + /** request body */ + body?: unknown; + /** base url */ + baseUrl?: string; + /** request cancellation token */ + cancelToken?: CancelToken; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig { + baseUrl?: string; + baseApiParams?: Omit; + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | RequestParams | void; + customFetch?: typeof fetch; +} + +export interface HttpResponse + extends Response { + data: D; + error: E; +} + +type CancelToken = Symbol | string | number; + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public baseUrl: string = "http://localhost:8080/api/v1"; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private abortControllers = new Map(); + private customFetch = (...fetchParams: Parameters) => + fetch(...fetchParams); + + private baseApiParams: RequestParams = { + credentials: "same-origin", + headers: {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return \`\${encodedKey}=\${encodeURIComponent(typeof value === "number" ? value : \`\${value}\`)}\`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + const keys = Object.keys(query).filter( + (key) => "undefined" !== typeof query[key], + ); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join("&"); + } + + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? \`?\${queryString}\` : ""; + } + + private contentFormatters: Record any> = { + [ContentType.Json]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.JsonApi]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.Text]: (input: any) => + input !== null && typeof input !== "string" + ? JSON.stringify(input) + : input, + [ContentType.FormData]: (input: any) => + Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob + ? property + : typeof property === "object" && property !== null + ? JSON.stringify(property) + : \`\${property}\`, + ); + return formData; + }, new FormData()), + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + }; + + protected mergeRequestParams( + params1: RequestParams, + params2?: RequestParams, + ): RequestParams { + return { + ...this.baseApiParams, + ...params1, + ...(params2 || {}), + headers: { + ...(this.baseApiParams.headers || {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected createAbortSignal = ( + cancelToken: CancelToken, + ): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + return abortController.signal; + } + return void 0; + } + + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + }; + + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken); + + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + }; + + public request = async ({ + body, + secure, + path, + type, + query, + format, + baseUrl, + cancelToken, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.baseApiParams.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const queryString = query && this.toQueryString(query); + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const responseFormat = format || requestParams.format; + + return this.customFetch( + \`\${baseUrl || this.baseUrl || ""}\${path}\${queryString ? \`?\${queryString}\` : ""}\`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== ContentType.FormData + ? { "Content-Type": type } + : {}), + }, + signal: + (cancelToken + ? this.createAbortSignal(cancelToken) + : requestParams.signal) || null, + body: + typeof body === "undefined" || body === null + ? null + : payloadFormatter(body), + }, + ).then(async (response) => { + const r = response.clone() as HttpResponse; + r.data = null as unknown as T; + r.error = null as unknown as E; + + const data = !responseFormat + ? r + : await response[responseFormat]() + .then((data) => { + if (r.ok) { + r.data = data; + } else { + r.error = data; + } + return r; + }) + .catch((e) => { + r.error = e; + return r; + }); + + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + + if (!response.ok) throw data; + return data; + }); + }; +} + +/** + * @title No title + * @baseUrl http://localhost:8080/api/v1 + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient {} +" +`; diff --git a/tests/spec/constObjectEnums/basic.test.ts b/tests/spec/constObjectEnums/basic.test.ts new file mode 100644 index 00000000..31166ef9 --- /dev/null +++ b/tests/spec/constObjectEnums/basic.test.ts @@ -0,0 +1,35 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { generateApi } from "../../../src/index.js"; + +describe("basic", async () => { + let tmpdir = ""; + + beforeAll(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), "swagger-typescript-api")); + }); + + afterAll(async () => { + await fs.rm(tmpdir, { recursive: true }); + }); + + test("--generate-const-object-enums", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateConstObjectEnums: true, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/constObjectEnums/schema.json b/tests/spec/constObjectEnums/schema.json new file mode 100644 index 00000000..85744f09 --- /dev/null +++ b/tests/spec/constObjectEnums/schema.json @@ -0,0 +1,52 @@ +{ + "components": { + "examples": {}, + "headers": {}, + "parameters": {}, + "requestBodies": {}, + "responses": {}, + "schemas": { + "StringEnum": { + "enum": ["String1", "String2", "String3", "String4"], + "type": "string" + }, + "NumberEnum": { + "enum": [1, 2, 3, 4], + "type": "number" + }, + "BooleanEnum": { + "enum": ["true", "false"], + "type": "boolean" + }, + "IntEnumWithNames": { + "enum": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], + "type": "integer", + "description": "FooBar", + "format": "int32", + "x-enumNames": [ + "Unknown", + "String", + "Int32", + "Int64", + "Double", + "DateTime", + "Test2", + "Test23", + "Tess44", + "BooFar" + ] + } + }, + "securitySchemes": {} + }, + "info": { + "title": "" + }, + "openapi": "3.0.0", + "paths": {}, + "servers": [ + { + "url": "http://localhost:8080/api/v1" + } + ] +} diff --git a/types/index.ts b/types/index.ts index 42736d93..644519ee 100644 --- a/types/index.ts +++ b/types/index.ts @@ -34,6 +34,11 @@ interface GenerateApiParamsBase { */ generateUnionEnums?: boolean; + /** + * generate all "enum" types as pairs of const objects and types derived from those objects' keys. (default: false, mutually exclusive with, and pre-empted by, generateUnionEnums). + */ + generateConstObjectEnums?: boolean; + /** * generate type definitions for API routes (default: false) */ @@ -682,6 +687,7 @@ export interface GenerateApiConfiguration { generateRouteTypes: boolean; generateClient: boolean; generateUnionEnums: boolean; + generateConstObjectEnums: boolean; swaggerSchema: object; originalSchema: object; componentsMap: Record;