diff --git a/plugins/typescript/src/core/schemaToTypeAliasDeclaration.test.ts b/plugins/typescript/src/core/schemaToTypeAliasDeclaration.test.ts index 5429320..83da449 100644 --- a/plugins/typescript/src/core/schemaToTypeAliasDeclaration.test.ts +++ b/plugins/typescript/src/core/schemaToTypeAliasDeclaration.test.ts @@ -11,6 +11,10 @@ import { } from "./schemaToTypeAliasDeclaration"; describe("schemaToTypeAliasDeclaration", () => { + type SchemaObjectWithConst = SchemaObject & { + const?: string | number | boolean | null; + }; + it("should generate null", () => { const schema: SchemaObject = { type: "null", @@ -52,6 +56,52 @@ describe("schemaToTypeAliasDeclaration", () => { expect(printSchema(schema)).toBe("export type Test = number | null;"); }); + it("should generate string const as a literal", () => { + const schema: SchemaObjectWithConst = { + type: "string", + const: "foo", + }; + + expect(printSchema(schema)).toBe(`export type Test = "foo";`); + }); + + it("should generate number const as a literal", () => { + const schema: SchemaObjectWithConst = { + type: "integer", + const: 42, + }; + + expect(printSchema(schema)).toBe(`export type Test = 42;`); + }); + + it("should generate boolean const as a literal", () => { + const schema: SchemaObjectWithConst = { + type: "boolean", + const: true, + }; + + expect(printSchema(schema)).toBe(`export type Test = true;`); + }); + + it("should generate null const as a literal", () => { + const schema: SchemaObjectWithConst = { + type: "null", + const: null, + }; + + expect(printSchema(schema)).toBe("export type Test = null;"); + }); + + it("should preserve nullable semantics for non-null const", () => { + const schema: SchemaObjectWithConst = { + type: "string", + const: "foo", + nullable: true, + }; + + expect(printSchema(schema)).toBe(`export type Test = "foo" | null;`); + }); + it("should generate an array of numbers", () => { const schema: SchemaObject = { type: "array", @@ -682,6 +732,106 @@ describe("schemaToTypeAliasDeclaration", () => { ); }); + it("should generate a oneOf with const branches", () => { + const schema: SchemaObjectWithConst = { + oneOf: [ + { type: "string", const: "foo" } as SchemaObjectWithConst, + { type: "number", const: 42 } as SchemaObjectWithConst, + ], + }; + + expect(printSchema(schema)).toMatchInlineSnapshot( + `"export type Test = "foo" | 42;"` + ); + }); + + it("should preserve const discriminators inside object oneOf branches", () => { + const schema: SchemaObject = { + oneOf: [ + { + type: "object", + properties: { + status: { + type: "string", + const: "READY", + } as SchemaObjectWithConst, + value: { + type: "string", + }, + }, + required: ["status", "value"], + }, + { + type: "object", + properties: { + status: { + type: "string", + const: "COUNT", + } as SchemaObjectWithConst, + value: { + $ref: "#/components/schemas/CountToken", + }, + }, + required: ["status", "value"], + }, + { + type: "object", + properties: { + status: { + type: "string", + const: "RATIO", + } as SchemaObjectWithConst, + value: { + type: "number", + format: "double", + }, + }, + required: ["status", "value"], + }, + { + type: "object", + properties: { + status: { + type: "string", + const: "ENABLED", + } as SchemaObjectWithConst, + value: { + type: "boolean", + }, + }, + required: ["status", "value"], + }, + ], + }; + + expect( + printSchema(schema, "Test", "schemas", { + schemas: { + CountToken: { + type: "string", + }, + }, + }) + ).toMatchInlineSnapshot(` + "export type Test = { + status: "READY"; + value: string; + } | { + status: "COUNT"; + value: CountToken; + } | { + status: "RATIO"; + /** + * @format double + */ + value: number; + } | { + status: "ENABLED"; + value: boolean; + };" + `); + }); + describe("discrimination", () => { const schema: SchemaObject = { oneOf: [ @@ -904,6 +1054,16 @@ describe("schemaToTypeAliasDeclaration", () => { `); }); + it("should preserve const when merged with compatible keywords", () => { + const schema: SchemaObjectWithConst = { + allOf: [{ type: "string" }, { const: "foo" } as SchemaObjectWithConst], + }; + + expect(printSchema(schema)).toMatchInlineSnapshot( + `"export type Test = "foo";"` + ); + }); + it("should combine ref and inline type", () => { const schema: SchemaObject = { allOf: [ @@ -1063,6 +1223,19 @@ describe("schemaToTypeAliasDeclaration", () => { ); }); + it("should generate a union with const branches", () => { + const schema: SchemaObjectWithConst = { + anyOf: [ + { type: "boolean", const: false } as SchemaObjectWithConst, + { type: "string", const: "foo" } as SchemaObjectWithConst, + ], + }; + + expect(printSchema(schema)).toMatchInlineSnapshot( + `"export type Test = false | "foo";"` + ); + }); + it("should combine required & properties", () => { // from github api - operationId: gists/update const schema: SchemaObject = { diff --git a/plugins/typescript/src/core/schemaToTypeAliasDeclaration.ts b/plugins/typescript/src/core/schemaToTypeAliasDeclaration.ts index 36e3327..908b448 100644 --- a/plugins/typescript/src/core/schemaToTypeAliasDeclaration.ts +++ b/plugins/typescript/src/core/schemaToTypeAliasDeclaration.ts @@ -42,6 +42,11 @@ export type Context = { currentComponent: OpenAPIComponentType | null; }; +type SupportedConstValue = string | number | boolean | null; +type SchemaObjectWithConst = SchemaObject & { + const?: SupportedConstValue; +}; + let useEnumsConfigBase: boolean | undefined; /** @@ -148,6 +153,15 @@ export const getType = ( return f.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword); } + const literalConstType = getConstType(schema); + const constValue = (schema as SchemaObjectWithConst).const; + if (literalConstType) { + return withNullable( + literalConstType, + constValue !== null ? schema.nullable : false + ); + } + if (schema.oneOf) { return f.createUnionTypeNode([ ...schema.oneOf.map((i) => @@ -349,6 +363,28 @@ export const getType = ( } }; +const getConstType = (schema: SchemaObject): ts.LiteralTypeNode | undefined => { + const constValue = (schema as SchemaObjectWithConst).const; + + if (typeof constValue === "string") { + return f.createLiteralTypeNode(f.createStringLiteral(constValue)); + } + + if (typeof constValue === "number") { + return f.createLiteralTypeNode(f.createNumericLiteral(constValue)); + } + + if (typeof constValue === "boolean") { + return f.createLiteralTypeNode( + constValue ? f.createTrue() : f.createFalse() + ); + } + + if (constValue === null) { + return f.createLiteralTypeNode(f.createNull()); + } +}; + /** * Add nullable option if needed. * diff --git a/plugins/typescript/src/generators/generateSchemaTypes.test.ts b/plugins/typescript/src/generators/generateSchemaTypes.test.ts index 23c1012..36d89c9 100644 --- a/plugins/typescript/src/generators/generateSchemaTypes.test.ts +++ b/plugins/typescript/src/generators/generateSchemaTypes.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { OpenAPIObject } from "openapi3-ts/oas30"; import { petstore } from "../fixtures/petstore"; import { generateSchemaTypes } from "./generateSchemaTypes"; import { createWriteFileMock } from "../testUtils"; @@ -324,6 +325,55 @@ describe("generateSchemaTypes", () => { `); }); + it("should preserve const literals without changing adjacent schemas", async () => { + const writeFile = createWriteFileMock(); + const readFile = vi.fn(() => Promise.resolve("")); + const openAPIDocument = { + openapi: "3.0.3", + info: { + title: "Const API", + version: "1.0.0", + }, + paths: {}, + components: { + schemas: { + Status: { + type: "string", + const: "ready", + }, + Counter: { + type: "integer", + }, + }, + }, + } as OpenAPIObject; + + await generateSchemaTypes( + { + openAPIDocument, + writeFile, + readFile, + existsFile: () => true, + }, + { + filenameCase: "camel", + } + ); + + expect(writeFile.mock.calls[0][0]).toBe("constApiSchemas.ts"); + expect(writeFile.mock.calls[0][1]).toMatchInlineSnapshot(` + "/** + * Generated by @openapi-codegen + * + * @version 1.0.0 + */ + export type Status = "ready"; + + export type Counter = number; + " + `); + }); + it("should generate the responses file", async () => { const writeFile = createWriteFileMock(); const readFile = vi.fn(() => Promise.resolve(""));