Skip to content

Commit 6fa6bea

Browse files
authored
feat: add with required utility to enforce properties of allOf (#1027)
* feat: add with required utility to enforce properties of allOf Resolves #657 * style: corrected lint issues * test: updated snapshots
1 parent 6ae859c commit 6fa6bea

File tree

9 files changed

+131
-28
lines changed

9 files changed

+131
-28
lines changed

examples/digital-ocean-api.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
*/
55

66

7-
/** Type helpers */
7+
/** WithRequired type helpers */
8+
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
9+
10+
/** OneOf type helpers */
811
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
912
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
1013
type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;
@@ -1828,7 +1831,7 @@ export interface external {
18281831
*/
18291832
databases?: (external["resources/apps/models/app_database_spec.yml"])[];
18301833
}
1831-
"resources/apps/models/app_static_site_spec.yml": external["resources/apps/models/app_component_base.yml"] & {
1834+
"resources/apps/models/app_static_site_spec.yml": WithRequired<external["resources/apps/models/app_component_base.yml"] & {
18321835
/**
18331836
* @description The name of the index document to use when serving this static site. Default: index.html
18341837
* @default index.html
@@ -1854,7 +1857,7 @@ export interface external {
18541857
cors?: external["resources/apps/models/apps_cors_policy.yml"];
18551858
/** @description A list of HTTP routes that should be routed to this component. */
18561859
routes?: (external["resources/apps/models/app_route_spec.yml"])[];
1857-
}
1860+
}, "name">
18581861
"resources/apps/models/app_variable_definition.yml": {
18591862
/**
18601863
* @description The variable name
@@ -1884,7 +1887,7 @@ export interface external {
18841887
*/
18851888
value?: string;
18861889
}
1887-
"resources/apps/models/app_worker_spec.yml": external["resources/apps/models/app_component_base.yml"] & external["resources/apps/models/app_component_instance_base.yml"]
1890+
"resources/apps/models/app_worker_spec.yml": WithRequired<external["resources/apps/models/app_component_base.yml"] & external["resources/apps/models/app_component_instance_base.yml"], "name">
18881891
"resources/apps/models/app.yml": {
18891892
active_deployment?: external["resources/apps/models/apps_deployment.yml"];
18901893
/**
@@ -3691,7 +3694,7 @@ export interface external {
36913694
* "size": "db-s-2vcpu-4gb"
36923695
* }
36933696
*/
3694-
"application/json": external["resources/databases/models/database_replica.yml"];
3697+
"application/json": WithRequired<external["resources/databases/models/database_replica.yml"], "name" | "size">;
36953698
};
36963699
};
36973700
responses: {
@@ -7391,7 +7394,7 @@ export interface external {
73917394
* ]
73927395
* }
73937396
*/
7394-
"application/json": external["resources/firewalls/models/firewall.yml"] & (Record<string, never> | Record<string, never>);
7397+
"application/json": WithRequired<external["resources/firewalls/models/firewall.yml"] & (Record<string, never> | Record<string, never>), "name">;
73957398
};
73967399
};
73977400
responses: {
@@ -8117,15 +8120,15 @@ export interface external {
81178120
};
81188121
}
81198122
"resources/images/models/image_action.yml": Record<string, never>
8120-
"resources/images/models/image_new_custom.yml": external["resources/images/models/image_update.yml"] & {
8123+
"resources/images/models/image_new_custom.yml": WithRequired<external["resources/images/models/image_update.yml"] & {
81218124
/**
81228125
* @description A URL from which the custom Linux virtual machine image may be retrieved. The image it points to must be in the raw, qcow2, vhdx, vdi, or vmdk format. It may be compressed using gzip or bzip2 and must be smaller than 100 GB after being decompressed.
81238126
* @example http://cloud-images.ubuntu.com/minimal/releases/bionic/release/ubuntu-18.04-minimal-cloudimg-amd64.img
81248127
*/
81258128
url?: string;
81268129
region?: external["shared/attributes/region_slug.yml"];
81278130
tags?: external["shared/attributes/tags_array.yml"];
8128-
}
8131+
}, "name" | "url" | "region">
81298132
"resources/images/models/image_update.yml": {
81308133
name?: external["resources/images/attributes.yml"]["image_name"];
81318134
distribution?: external["shared/attributes/distribution.yml"];
@@ -9748,15 +9751,15 @@ export interface external {
97489751
*/
97499752
disable_lets_encrypt_dns_records?: boolean;
97509753
}
9751-
"resources/load_balancers/models/load_balancer_create.yml": OneOf<[{
9754+
"resources/load_balancers/models/load_balancer_create.yml": OneOf<[WithRequired<{
97529755
$ref?: external["resources/load_balancers/models/attributes.yml"]["load_balancer_droplet_ids"];
97539756
} & {
97549757
region?: external["shared/attributes/region_slug.yml"];
9755-
} & external["resources/load_balancers/models/load_balancer_base.yml"], {
9758+
} & external["resources/load_balancers/models/load_balancer_base.yml"], "droplet_ids" | "region">, WithRequired<{
97569759
$ref?: external["resources/load_balancers/models/attributes.yml"]["load_balancer_droplet_tag"];
97579760
} & {
97589761
region?: external["shared/attributes/region_slug.yml"];
9759-
} & external["resources/load_balancers/models/load_balancer_base.yml"]]>
9762+
} & external["resources/load_balancers/models/load_balancer_base.yml"], "tag" | "region">]>
97609763
"resources/load_balancers/models/load_balancer.yml": external["resources/load_balancers/models/load_balancer_base.yml"] & ({
97619764
region?: Record<string, never> & external["resources/regions/models/region.yml"];
97629765
}) & {
@@ -10397,7 +10400,7 @@ export interface external {
1039710400
*/
1039810401
requestBody: {
1039910402
content: {
10400-
"application/json": external["resources/projects/models/project.yml"]["project_base"];
10403+
"application/json": WithRequired<external["resources/projects/models/project.yml"]["project_base"], "name" | "purpose">;
1040110404
};
1040210405
};
1040310406
responses: {
@@ -10552,7 +10555,7 @@ export interface external {
1055210555
*/
1055310556
requestBody: {
1055410557
content: {
10555-
"application/json": external["resources/projects/models/project.yml"]["project"];
10558+
"application/json": WithRequired<external["resources/projects/models/project.yml"]["project"], "name" | "description" | "purpose" | "environment" | "is_default">;
1055610559
};
1055710560
};
1055810561
responses: {
@@ -10571,7 +10574,7 @@ export interface external {
1057110574
*/
1057210575
requestBody: {
1057310576
content: {
10574-
"application/json": external["resources/projects/models/project.yml"]["project"];
10577+
"application/json": WithRequired<external["resources/projects/models/project.yml"]["project"], "name" | "description" | "purpose" | "environment" | "is_default">;
1057510578
};
1057610579
};
1057710580
responses: {
@@ -12373,7 +12376,7 @@ export interface external {
1237312376
*/
1237412377
requestBody: {
1237512378
content: {
12376-
"application/json": external["resources/uptime/models/alert.yml"]["alert"];
12379+
"application/json": WithRequired<external["resources/uptime/models/alert.yml"]["alert"], "name" | "type" | "notifications">;
1237712380
};
1237812381
};
1237912382
responses: {
@@ -12393,7 +12396,7 @@ export interface external {
1239312396
*/
1239412397
requestBody: {
1239512398
content: {
12396-
"application/json": external["resources/uptime/models/check.yml"]["check_updatable"];
12399+
"application/json": WithRequired<external["resources/uptime/models/check.yml"]["check_updatable"], "name" | "method" | "target">;
1239712400
};
1239812401
};
1239912402
responses: {
@@ -13175,7 +13178,7 @@ export interface external {
1317513178
*/
1317613179
requestBody: {
1317713180
content: {
13178-
"application/json": external["resources/vpcs/models/vpc.yml"]["vpc_updatable"] & external["resources/vpcs/models/vpc.yml"]["vpc_create"];
13181+
"application/json": WithRequired<external["resources/vpcs/models/vpc.yml"]["vpc_updatable"] & external["resources/vpcs/models/vpc.yml"]["vpc_create"], "name" | "region">;
1317913182
};
1318013183
};
1318113184
responses: {
@@ -13280,7 +13283,7 @@ export interface external {
1328013283
*/
1328113284
requestBody: {
1328213285
content: {
13283-
"application/json": external["resources/vpcs/models/vpc.yml"]["vpc_updatable"] & external["resources/vpcs/models/vpc.yml"]["vpc_default"];
13286+
"application/json": WithRequired<external["resources/vpcs/models/vpc.yml"]["vpc_updatable"] & external["resources/vpcs/models/vpc.yml"]["vpc_default"], "name">;
1328413287
};
1328513288
};
1328613289
responses: {

examples/github-api-next.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66

7-
/** Type helpers */
7+
/** OneOf type helpers */
88
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
99
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
1010
type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;

examples/github-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66

7-
/** Type helpers */
7+
/** OneOf type helpers */
88
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
99
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
1010
type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;

src/index.ts

Lines changed: 13 additions & 2 deletions
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

Lines changed: 6 additions & 1 deletion
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 {
@@ -238,8 +239,12 @@ export function defaultSchemaObjectTransform(
238239
finalType = finalType ? tsIntersectionOf(finalType, oneOfType) : oneOfType;
239240
} else {
240241
// allOf
241-
if ("allOf" in schemaObject && Array.isArray(schemaObject.allOf))
242+
if ("allOf" in schemaObject && Array.isArray(schemaObject.allOf)) {
242243
finalType = tsIntersectionOf(...(finalType ? [finalType] : []), ...collectCompositions(schemaObject.allOf));
244+
if ("required" in schemaObject && Array.isArray(schemaObject.required)) {
245+
finalType = tsWithRequired(finalType, schemaObject.required);
246+
}
247+
}
243248
// anyOf
244249
if ("anyOf" in schemaObject && Array.isArray(schemaObject.anyOf)) {
245250
const anyOfTypes = tsUnionOf(...collectCompositions(schemaObject.anyOf));

src/types.ts

Lines changed: 2 additions & 2 deletions
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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 62 additions & 3 deletions
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,6 +521,60 @@ 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
});

test/schema-object.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,26 @@ describe("Schema Object", () => {
297297
green: number;
298298
}`);
299299
});
300+
301+
test("sibling required", () => {
302+
const schema: SchemaObject = {
303+
required: ["red", "blue", "green"],
304+
allOf: [
305+
{
306+
type: "object",
307+
properties: { red: { type: "number" }, blue: { type: "number" } },
308+
},
309+
{ type: "object", properties: { green: { type: "number" } } },
310+
],
311+
};
312+
const generated = transformSchemaObject(schema, options);
313+
expect(generated).toBe(`WithRequired<{
314+
red?: number;
315+
blue?: number;
316+
} & {
317+
green?: number;
318+
}, "red" | "blue" | "green">`);
319+
});
300320
});
301321

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

0 commit comments

Comments
 (0)