Skip to content

Commit 60972b1

Browse files
authored
feat(openapi-v3): Preserve JSON Schema const values as TypeScript literal types (#332)
* feat(openapi-v3): Enhance schema handling with const literals support - Introduced `SchemaObjectWithConst` type to support const values in schemas. - Added tests to verify generation of literal types for string, number, boolean, and null const values. - Updated `getType` and `getConstType` functions to handle const values correctly. - Ensured const literals are preserved in adjacent schemas during type generation. * chore: run prettier and eslint fix
1 parent d440e08 commit 60972b1

3 files changed

Lines changed: 259 additions & 0 deletions

File tree

plugins/typescript/src/core/schemaToTypeAliasDeclaration.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
} from "./schemaToTypeAliasDeclaration";
1212

1313
describe("schemaToTypeAliasDeclaration", () => {
14+
type SchemaObjectWithConst = SchemaObject & {
15+
const?: string | number | boolean | null;
16+
};
17+
1418
it("should generate null", () => {
1519
const schema: SchemaObject = {
1620
type: "null",
@@ -52,6 +56,52 @@ describe("schemaToTypeAliasDeclaration", () => {
5256
expect(printSchema(schema)).toBe("export type Test = number | null;");
5357
});
5458

59+
it("should generate string const as a literal", () => {
60+
const schema: SchemaObjectWithConst = {
61+
type: "string",
62+
const: "foo",
63+
};
64+
65+
expect(printSchema(schema)).toBe(`export type Test = "foo";`);
66+
});
67+
68+
it("should generate number const as a literal", () => {
69+
const schema: SchemaObjectWithConst = {
70+
type: "integer",
71+
const: 42,
72+
};
73+
74+
expect(printSchema(schema)).toBe(`export type Test = 42;`);
75+
});
76+
77+
it("should generate boolean const as a literal", () => {
78+
const schema: SchemaObjectWithConst = {
79+
type: "boolean",
80+
const: true,
81+
};
82+
83+
expect(printSchema(schema)).toBe(`export type Test = true;`);
84+
});
85+
86+
it("should generate null const as a literal", () => {
87+
const schema: SchemaObjectWithConst = {
88+
type: "null",
89+
const: null,
90+
};
91+
92+
expect(printSchema(schema)).toBe("export type Test = null;");
93+
});
94+
95+
it("should preserve nullable semantics for non-null const", () => {
96+
const schema: SchemaObjectWithConst = {
97+
type: "string",
98+
const: "foo",
99+
nullable: true,
100+
};
101+
102+
expect(printSchema(schema)).toBe(`export type Test = "foo" | null;`);
103+
});
104+
55105
it("should generate an array of numbers", () => {
56106
const schema: SchemaObject = {
57107
type: "array",
@@ -682,6 +732,106 @@ describe("schemaToTypeAliasDeclaration", () => {
682732
);
683733
});
684734

735+
it("should generate a oneOf with const branches", () => {
736+
const schema: SchemaObjectWithConst = {
737+
oneOf: [
738+
{ type: "string", const: "foo" } as SchemaObjectWithConst,
739+
{ type: "number", const: 42 } as SchemaObjectWithConst,
740+
],
741+
};
742+
743+
expect(printSchema(schema)).toMatchInlineSnapshot(
744+
`"export type Test = "foo" | 42;"`
745+
);
746+
});
747+
748+
it("should preserve const discriminators inside object oneOf branches", () => {
749+
const schema: SchemaObject = {
750+
oneOf: [
751+
{
752+
type: "object",
753+
properties: {
754+
status: {
755+
type: "string",
756+
const: "READY",
757+
} as SchemaObjectWithConst,
758+
value: {
759+
type: "string",
760+
},
761+
},
762+
required: ["status", "value"],
763+
},
764+
{
765+
type: "object",
766+
properties: {
767+
status: {
768+
type: "string",
769+
const: "COUNT",
770+
} as SchemaObjectWithConst,
771+
value: {
772+
$ref: "#/components/schemas/CountToken",
773+
},
774+
},
775+
required: ["status", "value"],
776+
},
777+
{
778+
type: "object",
779+
properties: {
780+
status: {
781+
type: "string",
782+
const: "RATIO",
783+
} as SchemaObjectWithConst,
784+
value: {
785+
type: "number",
786+
format: "double",
787+
},
788+
},
789+
required: ["status", "value"],
790+
},
791+
{
792+
type: "object",
793+
properties: {
794+
status: {
795+
type: "string",
796+
const: "ENABLED",
797+
} as SchemaObjectWithConst,
798+
value: {
799+
type: "boolean",
800+
},
801+
},
802+
required: ["status", "value"],
803+
},
804+
],
805+
};
806+
807+
expect(
808+
printSchema(schema, "Test", "schemas", {
809+
schemas: {
810+
CountToken: {
811+
type: "string",
812+
},
813+
},
814+
})
815+
).toMatchInlineSnapshot(`
816+
"export type Test = {
817+
status: "READY";
818+
value: string;
819+
} | {
820+
status: "COUNT";
821+
value: CountToken;
822+
} | {
823+
status: "RATIO";
824+
/**
825+
* @format double
826+
*/
827+
value: number;
828+
} | {
829+
status: "ENABLED";
830+
value: boolean;
831+
};"
832+
`);
833+
});
834+
685835
describe("discrimination", () => {
686836
const schema: SchemaObject = {
687837
oneOf: [
@@ -904,6 +1054,16 @@ describe("schemaToTypeAliasDeclaration", () => {
9041054
`);
9051055
});
9061056

1057+
it("should preserve const when merged with compatible keywords", () => {
1058+
const schema: SchemaObjectWithConst = {
1059+
allOf: [{ type: "string" }, { const: "foo" } as SchemaObjectWithConst],
1060+
};
1061+
1062+
expect(printSchema(schema)).toMatchInlineSnapshot(
1063+
`"export type Test = "foo";"`
1064+
);
1065+
});
1066+
9071067
it("should combine ref and inline type", () => {
9081068
const schema: SchemaObject = {
9091069
allOf: [
@@ -1063,6 +1223,19 @@ describe("schemaToTypeAliasDeclaration", () => {
10631223
);
10641224
});
10651225

1226+
it("should generate a union with const branches", () => {
1227+
const schema: SchemaObjectWithConst = {
1228+
anyOf: [
1229+
{ type: "boolean", const: false } as SchemaObjectWithConst,
1230+
{ type: "string", const: "foo" } as SchemaObjectWithConst,
1231+
],
1232+
};
1233+
1234+
expect(printSchema(schema)).toMatchInlineSnapshot(
1235+
`"export type Test = false | "foo";"`
1236+
);
1237+
});
1238+
10661239
it("should combine required & properties", () => {
10671240
// from github api - operationId: gists/update
10681241
const schema: SchemaObject = {

plugins/typescript/src/core/schemaToTypeAliasDeclaration.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export type Context = {
4242
currentComponent: OpenAPIComponentType | null;
4343
};
4444

45+
type SupportedConstValue = string | number | boolean | null;
46+
type SchemaObjectWithConst = SchemaObject & {
47+
const?: SupportedConstValue;
48+
};
49+
4550
let useEnumsConfigBase: boolean | undefined;
4651

4752
/**
@@ -148,6 +153,15 @@ export const getType = (
148153
return f.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword);
149154
}
150155

156+
const literalConstType = getConstType(schema);
157+
const constValue = (schema as SchemaObjectWithConst).const;
158+
if (literalConstType) {
159+
return withNullable(
160+
literalConstType,
161+
constValue !== null ? schema.nullable : false
162+
);
163+
}
164+
151165
if (schema.oneOf) {
152166
return f.createUnionTypeNode([
153167
...schema.oneOf.map((i) =>
@@ -349,6 +363,28 @@ export const getType = (
349363
}
350364
};
351365

366+
const getConstType = (schema: SchemaObject): ts.LiteralTypeNode | undefined => {
367+
const constValue = (schema as SchemaObjectWithConst).const;
368+
369+
if (typeof constValue === "string") {
370+
return f.createLiteralTypeNode(f.createStringLiteral(constValue));
371+
}
372+
373+
if (typeof constValue === "number") {
374+
return f.createLiteralTypeNode(f.createNumericLiteral(constValue));
375+
}
376+
377+
if (typeof constValue === "boolean") {
378+
return f.createLiteralTypeNode(
379+
constValue ? f.createTrue() : f.createFalse()
380+
);
381+
}
382+
383+
if (constValue === null) {
384+
return f.createLiteralTypeNode(f.createNull());
385+
}
386+
};
387+
352388
/**
353389
* Add nullable option if needed.
354390
*

plugins/typescript/src/generators/generateSchemaTypes.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it, vi } from "vitest";
2+
import { OpenAPIObject } from "openapi3-ts/oas30";
23
import { petstore } from "../fixtures/petstore";
34
import { generateSchemaTypes } from "./generateSchemaTypes";
45
import { createWriteFileMock } from "../testUtils";
@@ -324,6 +325,55 @@ describe("generateSchemaTypes", () => {
324325
`);
325326
});
326327

328+
it("should preserve const literals without changing adjacent schemas", async () => {
329+
const writeFile = createWriteFileMock();
330+
const readFile = vi.fn(() => Promise.resolve(""));
331+
const openAPIDocument = {
332+
openapi: "3.0.3",
333+
info: {
334+
title: "Const API",
335+
version: "1.0.0",
336+
},
337+
paths: {},
338+
components: {
339+
schemas: {
340+
Status: {
341+
type: "string",
342+
const: "ready",
343+
},
344+
Counter: {
345+
type: "integer",
346+
},
347+
},
348+
},
349+
} as OpenAPIObject;
350+
351+
await generateSchemaTypes(
352+
{
353+
openAPIDocument,
354+
writeFile,
355+
readFile,
356+
existsFile: () => true,
357+
},
358+
{
359+
filenameCase: "camel",
360+
}
361+
);
362+
363+
expect(writeFile.mock.calls[0][0]).toBe("constApiSchemas.ts");
364+
expect(writeFile.mock.calls[0][1]).toMatchInlineSnapshot(`
365+
"/**
366+
* Generated by @openapi-codegen
367+
*
368+
* @version 1.0.0
369+
*/
370+
export type Status = "ready";
371+
372+
export type Counter = number;
373+
"
374+
`);
375+
});
376+
327377
it("should generate the responses file", async () => {
328378
const writeFile = createWriteFileMock();
329379
const readFile = vi.fn(() => Promise.resolve(""));

0 commit comments

Comments
 (0)