Skip to content

Commit ee08f25

Browse files
committed
feat: add with required utility to enforce properties of allOf
Resolves openapi-ts#657
1 parent 49a9082 commit ee08f25

File tree

6 files changed

+109
-8
lines changed

6 files changed

+109
-8
lines changed

src/index.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -190,19 +190,30 @@ async function openapiTS(
190190
output.push(`export type operations = Record<string, never>;`, "");
191191
}
192192

193-
// 4. OneOf type helper (@see https://github.com/Microsoft/TypeScript/issues/14094#issuecomment-723571692)
193+
// 4a. OneOf type helper (@see https://github.com/Microsoft/TypeScript/issues/14094#issuecomment-723571692)
194194
if (output.join("\n").includes("OneOf")) {
195195
output.splice(
196196
1,
197197
0,
198-
"/** Type helpers */",
198+
"/** OneOf type helpers */",
199199
"type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };",
200200
"type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;",
201201
"type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;",
202202
""
203203
);
204204
}
205205

206+
// 4b. WithRequired type helper (@see https://github.com/drwpow/openapi-typescript/issues/657#issuecomment-1399274607)
207+
if (output.join("\n").includes("WithRequired")) {
208+
output.splice(
209+
1,
210+
0,
211+
"/** WithRequired type helpers */",
212+
"type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };",
213+
""
214+
);
215+
}
216+
206217
return output.join("\n");
207218
}
208219

src/transform/schema-object.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
tsReadonly,
1515
tsTupleOf,
1616
tsUnionOf,
17+
tsWithRequired,
1718
} from "../utils.js";
1819

1920
export interface TransformSchemaObjectOptions {
@@ -229,8 +230,12 @@ export function defaultSchemaObjectTransform(
229230
finalType = finalType ? tsIntersectionOf(finalType, oneOfType) : oneOfType;
230231
} else {
231232
// allOf
232-
if ("allOf" in schemaObject && Array.isArray(schemaObject.allOf))
233+
if ("allOf" in schemaObject && Array.isArray(schemaObject.allOf)) {
233234
finalType = tsIntersectionOf(...(finalType ? [finalType] : []), ...collectCompositions(schemaObject.allOf));
235+
if ("required" in schemaObject && Array.isArray(schemaObject.required)) {
236+
finalType = tsWithRequired(finalType, schemaObject.required);
237+
}
238+
}
234239
// anyOf
235240
if ("anyOf" in schemaObject && Array.isArray(schemaObject.anyOf)) {
236241
const anyOfTypes = tsUnionOf(...collectCompositions(schemaObject.anyOf));

src/types.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -449,8 +449,8 @@ export type SchemaObject = {
449449
allOf?: (SchemaObject | ReferenceObject)[];
450450
anyOf?: (SchemaObject | ReferenceObject)[];
451451
}
452-
| { allOf: (SchemaObject | ReferenceObject)[]; anyOf?: (SchemaObject | ReferenceObject)[] }
453-
| { allOf?: (SchemaObject | ReferenceObject)[]; anyOf: (SchemaObject | ReferenceObject)[] }
452+
| { allOf: (SchemaObject | ReferenceObject)[]; anyOf?: (SchemaObject | ReferenceObject)[]; required?: string[]; }
453+
| { allOf?: (SchemaObject | ReferenceObject)[]; anyOf: (SchemaObject | ReferenceObject)[]; required?: string[]; }
454454
);
455455

456456
/**

src/utils.ts

+5
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ export function tsOmit(root: string, keys: string[]): string {
182182
return `Omit<${root}, ${tsUnionOf(...keys.map(escStr))}>`;
183183
}
184184

185+
/** WithRequired<T> */
186+
export function tsWithRequired(root: string, keys: string[]): string {
187+
return `WithRequired<${root}, ${tsUnionOf(...keys.map(escStr))}>`;
188+
}
189+
185190
/** make a given property key optional */
186191
export function tsOptionalProperty(key: string): string {
187192
return `${key}?`;

test/index.test.ts

+63-3
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ const BOILERPLATE = `/**
99
1010
`;
1111

12-
const TYPE_HELPERS = `
13-
/** Type helpers */
12+
const ONE_OF_TYPE_HELPERS = `
13+
/** OneOf type helpers */
1414
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
1515
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
1616
type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;
1717
`;
1818

19+
const WITH_REQUIRED_TYPE_HELPERS = `
20+
/** WithRequired type helpers */
21+
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
22+
`;
23+
1924
beforeAll(() => {
2025
vi.spyOn(process, "exit").mockImplementation(((code: number) => {
2126
throw new Error(`Process exited with error code ${code}`);
@@ -494,7 +499,7 @@ export type operations = Record<string, never>;
494499
},
495500
{ exportType: false }
496501
);
497-
expect(generated).toBe(`${BOILERPLATE}${TYPE_HELPERS}
502+
expect(generated).toBe(`${BOILERPLATE}${ONE_OF_TYPE_HELPERS}
498503
export type paths = Record<string, never>;
499504
500505
export type webhooks = Record<string, never>;
@@ -516,11 +521,66 @@ export interface components {
516521
517522
export type external = Record<string, never>;
518523
524+
export type operations = Record<string, never>;
525+
`);
526+
});
527+
});
528+
529+
describe("WithRequired type helpers", () => {
530+
test("should be added only when used", async () => {
531+
const generated = await openapiTS(
532+
{
533+
openapi: "3.1",
534+
info: { title: "Test", version: "1.0" },
535+
components: {
536+
schemas: {
537+
User: {
538+
allOf: [
539+
{
540+
type: "object",
541+
properties: { firstName: { type: "string" }, lastName: { type: "string" } },
542+
},
543+
{
544+
type: "object",
545+
properties: { middleName: { type: "string" } },
546+
},
547+
],
548+
required: ["firstName", "lastName"],
549+
},
550+
},
551+
},
552+
},
553+
{ exportType: false }
554+
);
555+
expect(generated).toBe(`${BOILERPLATE}${WITH_REQUIRED_TYPE_HELPERS}
556+
export type paths = Record<string, never>;
557+
558+
export type webhooks = Record<string, never>;
559+
560+
export interface components {
561+
schemas: {
562+
User: WithRequired<{
563+
firstName?: string;
564+
lastName?: string;
565+
} & {
566+
middleName?: string;
567+
}, "firstName" | "lastName">;
568+
};
569+
responses: never;
570+
parameters: never;
571+
requestBodies: never;
572+
headers: never;
573+
pathItems: never;
574+
}
575+
576+
export type external = Record<string, never>;
577+
519578
export type operations = Record<string, never>;
520579
`);
521580
});
522581
});
523582
});
583+
524584

525585
// note: this tests the Node API; the snapshots in cli.test.ts test the CLI
526586
describe("snapshots", () => {

test/schema-object.test.ts

+20
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,26 @@ describe("Schema Object", () => {
255255
green: number;
256256
}`);
257257
});
258+
259+
test("sibling required", () => {
260+
const schema: SchemaObject = {
261+
required: ["red", "blue", "green"],
262+
allOf: [
263+
{
264+
type: "object",
265+
properties: { red: { type: "number" }, blue: { type: "number" } },
266+
},
267+
{ type: "object", properties: { green: { type: "number" } } },
268+
],
269+
};
270+
const generated = transformSchemaObject(schema, options);
271+
expect(generated).toBe(`WithRequired<{
272+
red?: number;
273+
blue?: number;
274+
} & {
275+
green?: number;
276+
}, "red" | "blue" | "green">`);
277+
});
258278
});
259279

260280
describe("anyOf", () => {

0 commit comments

Comments
 (0)