From ee08f25a293fef68dcead178a77be6340c00682e Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Sat, 21 Jan 2023 12:39:36 -0500 Subject: [PATCH 1/3] feat: add with required utility to enforce properties of allOf Resolves #657 --- src/index.ts | 15 ++++++-- src/transform/schema-object.ts | 7 +++- src/types.ts | 4 +-- src/utils.ts | 5 +++ test/index.test.ts | 66 ++++++++++++++++++++++++++++++++-- test/schema-object.test.ts | 20 +++++++++++ 6 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index f83868c62..a90e85ee9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -190,12 +190,12 @@ async function openapiTS( output.push(`export type operations = Record;`, ""); } - // 4. OneOf type helper (@see https://github.com/Microsoft/TypeScript/issues/14094#issuecomment-723571692) + // 4a. OneOf type helper (@see https://github.com/Microsoft/TypeScript/issues/14094#issuecomment-723571692) if (output.join("\n").includes("OneOf")) { output.splice( 1, 0, - "/** Type helpers */", + "/** OneOf type helpers */", "type Without = { [P in Exclude]?: never };", "type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U;", "type OneOf = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR, ...Rest]> : never;", @@ -203,6 +203,17 @@ async function openapiTS( ); } + // 4b. WithRequired type helper (@see https://github.com/drwpow/openapi-typescript/issues/657#issuecomment-1399274607) + if (output.join("\n").includes("WithRequired")) { + output.splice( + 1, + 0, + "/** WithRequired type helpers */", + "type WithRequired = T & { [P in K]-?: T[P] };", + "" + ); + } + return output.join("\n"); } diff --git a/src/transform/schema-object.ts b/src/transform/schema-object.ts index b814fac75..b601a54c7 100644 --- a/src/transform/schema-object.ts +++ b/src/transform/schema-object.ts @@ -14,6 +14,7 @@ import { tsReadonly, tsTupleOf, tsUnionOf, + tsWithRequired, } from "../utils.js"; export interface TransformSchemaObjectOptions { @@ -229,8 +230,12 @@ export function defaultSchemaObjectTransform( finalType = finalType ? tsIntersectionOf(finalType, oneOfType) : oneOfType; } else { // allOf - if ("allOf" in schemaObject && Array.isArray(schemaObject.allOf)) + if ("allOf" in schemaObject && Array.isArray(schemaObject.allOf)) { finalType = tsIntersectionOf(...(finalType ? [finalType] : []), ...collectCompositions(schemaObject.allOf)); + if ("required" in schemaObject && Array.isArray(schemaObject.required)) { + finalType = tsWithRequired(finalType, schemaObject.required); + } + } // anyOf if ("anyOf" in schemaObject && Array.isArray(schemaObject.anyOf)) { const anyOfTypes = tsUnionOf(...collectCompositions(schemaObject.anyOf)); diff --git a/src/types.ts b/src/types.ts index 259743ef6..7f75b8fe7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -449,8 +449,8 @@ export type SchemaObject = { allOf?: (SchemaObject | ReferenceObject)[]; anyOf?: (SchemaObject | ReferenceObject)[]; } - | { allOf: (SchemaObject | ReferenceObject)[]; anyOf?: (SchemaObject | ReferenceObject)[] } - | { allOf?: (SchemaObject | ReferenceObject)[]; anyOf: (SchemaObject | ReferenceObject)[] } + | { allOf: (SchemaObject | ReferenceObject)[]; anyOf?: (SchemaObject | ReferenceObject)[]; required?: string[]; } + | { allOf?: (SchemaObject | ReferenceObject)[]; anyOf: (SchemaObject | ReferenceObject)[]; required?: string[]; } ); /** diff --git a/src/utils.ts b/src/utils.ts index ca95a91f6..0de50ae22 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -182,6 +182,11 @@ export function tsOmit(root: string, keys: string[]): string { return `Omit<${root}, ${tsUnionOf(...keys.map(escStr))}>`; } +/** WithRequired */ +export function tsWithRequired(root: string, keys: string[]): string { + return `WithRequired<${root}, ${tsUnionOf(...keys.map(escStr))}>`; +} + /** make a given property key optional */ export function tsOptionalProperty(key: string): string { return `${key}?`; diff --git a/test/index.test.ts b/test/index.test.ts index 14b8ab4c9..16c1261e0 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -9,13 +9,18 @@ const BOILERPLATE = `/** `; -const TYPE_HELPERS = ` -/** Type helpers */ +const ONE_OF_TYPE_HELPERS = ` +/** OneOf type helpers */ type Without = { [P in Exclude]?: never }; type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U; type OneOf = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR, ...Rest]> : never; `; +const WITH_REQUIRED_TYPE_HELPERS = ` +/** WithRequired type helpers */ +type WithRequired = T & { [P in K]-?: T[P] }; +`; + beforeAll(() => { vi.spyOn(process, "exit").mockImplementation(((code: number) => { throw new Error(`Process exited with error code ${code}`); @@ -494,7 +499,7 @@ export type operations = Record; }, { exportType: false } ); - expect(generated).toBe(`${BOILERPLATE}${TYPE_HELPERS} + expect(generated).toBe(`${BOILERPLATE}${ONE_OF_TYPE_HELPERS} export type paths = Record; export type webhooks = Record; @@ -516,11 +521,66 @@ export interface components { export type external = Record; +export type operations = Record; +`); + }); + }); + + describe("WithRequired type helpers", () => { + test("should be added only when used", async () => { + const generated = await openapiTS( + { + openapi: "3.1", + info: { title: "Test", version: "1.0" }, + components: { + schemas: { + User: { + allOf: [ + { + type: "object", + properties: { firstName: { type: "string" }, lastName: { type: "string" } }, + }, + { + type: "object", + properties: { middleName: { type: "string" } }, + }, + ], + required: ["firstName", "lastName"], + }, + }, + }, + }, + { exportType: false } + ); + expect(generated).toBe(`${BOILERPLATE}${WITH_REQUIRED_TYPE_HELPERS} +export type paths = Record; + +export type webhooks = Record; + +export interface components { + schemas: { + User: WithRequired<{ + firstName?: string; + lastName?: string; + } & { + middleName?: string; + }, "firstName" | "lastName">; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} + +export type external = Record; + export type operations = Record; `); }); }); }); + // note: this tests the Node API; the snapshots in cli.test.ts test the CLI describe("snapshots", () => { diff --git a/test/schema-object.test.ts b/test/schema-object.test.ts index 3dda108fa..fef0d9f04 100644 --- a/test/schema-object.test.ts +++ b/test/schema-object.test.ts @@ -255,6 +255,26 @@ describe("Schema Object", () => { green: number; }`); }); + + test("sibling required", () => { + const schema: SchemaObject = { + required: ["red", "blue", "green"], + allOf: [ + { + type: "object", + properties: { red: { type: "number" }, blue: { type: "number" } }, + }, + { type: "object", properties: { green: { type: "number" } } }, + ], + }; + const generated = transformSchemaObject(schema, options); + expect(generated).toBe(`WithRequired<{ + red?: number; + blue?: number; +} & { + green?: number; +}, "red" | "blue" | "green">`); + }); }); describe("anyOf", () => { From ecb9a01918962614808fcda5757ee592ecc738f6 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Sat, 21 Jan 2023 18:12:23 -0500 Subject: [PATCH 2/3] style: corrected lint issues --- src/types.ts | 4 ++-- test/index.test.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/types.ts b/src/types.ts index 7f75b8fe7..858e9424e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -449,8 +449,8 @@ export type SchemaObject = { allOf?: (SchemaObject | ReferenceObject)[]; anyOf?: (SchemaObject | ReferenceObject)[]; } - | { allOf: (SchemaObject | ReferenceObject)[]; anyOf?: (SchemaObject | ReferenceObject)[]; required?: string[]; } - | { allOf?: (SchemaObject | ReferenceObject)[]; anyOf: (SchemaObject | ReferenceObject)[]; required?: string[]; } + | { allOf: (SchemaObject | ReferenceObject)[]; anyOf?: (SchemaObject | ReferenceObject)[]; required?: string[] } + | { allOf?: (SchemaObject | ReferenceObject)[]; anyOf: (SchemaObject | ReferenceObject)[]; required?: string[] } ); /** diff --git a/test/index.test.ts b/test/index.test.ts index 16c1261e0..d8b781a78 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -580,7 +580,6 @@ export type operations = Record; }); }); }); - // note: this tests the Node API; the snapshots in cli.test.ts test the CLI describe("snapshots", () => { From e0a80fb7790384577985e6ff7bb0e715171ff55a Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Sat, 21 Jan 2023 18:46:50 -0500 Subject: [PATCH 3/3] test: updated snapshots --- examples/digital-ocean-api.ts | 39 +++++++++++++++++++---------------- examples/github-api-next.ts | 2 +- examples/github-api.ts | 2 +- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/examples/digital-ocean-api.ts b/examples/digital-ocean-api.ts index c53331939..79c3b81c3 100644 --- a/examples/digital-ocean-api.ts +++ b/examples/digital-ocean-api.ts @@ -4,7 +4,10 @@ */ -/** Type helpers */ +/** WithRequired type helpers */ +type WithRequired = T & { [P in K]-?: T[P] }; + +/** OneOf type helpers */ type Without = { [P in Exclude]?: never }; type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U; type OneOf = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR, ...Rest]> : never; @@ -1828,7 +1831,7 @@ export interface external { */ databases?: (external["resources/apps/models/app_database_spec.yml"])[]; } - "resources/apps/models/app_static_site_spec.yml": external["resources/apps/models/app_component_base.yml"] & { + "resources/apps/models/app_static_site_spec.yml": WithRequired "resources/apps/models/app_variable_definition.yml": { /** * @description The variable name @@ -1884,7 +1887,7 @@ export interface external { */ value?: string; } - "resources/apps/models/app_worker_spec.yml": external["resources/apps/models/app_component_base.yml"] & external["resources/apps/models/app_component_instance_base.yml"] + "resources/apps/models/app_worker_spec.yml": WithRequired "resources/apps/models/app.yml": { active_deployment?: external["resources/apps/models/apps_deployment.yml"]; /** @@ -3691,7 +3694,7 @@ export interface external { * "size": "db-s-2vcpu-4gb" * } */ - "application/json": external["resources/databases/models/database_replica.yml"]; + "application/json": WithRequired; }; }; responses: { @@ -7391,7 +7394,7 @@ export interface external { * ] * } */ - "application/json": external["resources/firewalls/models/firewall.yml"] & (Record | Record); + "application/json": WithRequired | Record), "name">; }; }; responses: { @@ -8117,7 +8120,7 @@ export interface external { }; } "resources/images/models/image_action.yml": Record - "resources/images/models/image_new_custom.yml": external["resources/images/models/image_update.yml"] & { + "resources/images/models/image_new_custom.yml": WithRequired "resources/images/models/image_update.yml": { name?: external["resources/images/attributes.yml"]["image_name"]; distribution?: external["shared/attributes/distribution.yml"]; @@ -9748,15 +9751,15 @@ export interface external { */ disable_lets_encrypt_dns_records?: boolean; } - "resources/load_balancers/models/load_balancer_create.yml": OneOf<[{ + "resources/load_balancers/models/load_balancer_create.yml": OneOf<[WithRequired<{ $ref?: external["resources/load_balancers/models/attributes.yml"]["load_balancer_droplet_ids"]; } & { region?: external["shared/attributes/region_slug.yml"]; - } & external["resources/load_balancers/models/load_balancer_base.yml"], { + } & external["resources/load_balancers/models/load_balancer_base.yml"], "droplet_ids" | "region">, WithRequired<{ $ref?: external["resources/load_balancers/models/attributes.yml"]["load_balancer_droplet_tag"]; } & { region?: external["shared/attributes/region_slug.yml"]; - } & external["resources/load_balancers/models/load_balancer_base.yml"]]> + } & external["resources/load_balancers/models/load_balancer_base.yml"], "tag" | "region">]> "resources/load_balancers/models/load_balancer.yml": external["resources/load_balancers/models/load_balancer_base.yml"] & ({ region?: Record & external["resources/regions/models/region.yml"]; }) & { @@ -10397,7 +10400,7 @@ export interface external { */ requestBody: { content: { - "application/json": external["resources/projects/models/project.yml"]["project_base"]; + "application/json": WithRequired; }; }; responses: { @@ -10552,7 +10555,7 @@ export interface external { */ requestBody: { content: { - "application/json": external["resources/projects/models/project.yml"]["project"]; + "application/json": WithRequired; }; }; responses: { @@ -10571,7 +10574,7 @@ export interface external { */ requestBody: { content: { - "application/json": external["resources/projects/models/project.yml"]["project"]; + "application/json": WithRequired; }; }; responses: { @@ -12373,7 +12376,7 @@ export interface external { */ requestBody: { content: { - "application/json": external["resources/uptime/models/alert.yml"]["alert"]; + "application/json": WithRequired; }; }; responses: { @@ -12393,7 +12396,7 @@ export interface external { */ requestBody: { content: { - "application/json": external["resources/uptime/models/check.yml"]["check_updatable"]; + "application/json": WithRequired; }; }; responses: { @@ -13175,7 +13178,7 @@ export interface external { */ requestBody: { content: { - "application/json": external["resources/vpcs/models/vpc.yml"]["vpc_updatable"] & external["resources/vpcs/models/vpc.yml"]["vpc_create"]; + "application/json": WithRequired; }; }; responses: { @@ -13280,7 +13283,7 @@ export interface external { */ requestBody: { content: { - "application/json": external["resources/vpcs/models/vpc.yml"]["vpc_updatable"] & external["resources/vpcs/models/vpc.yml"]["vpc_default"]; + "application/json": WithRequired; }; }; responses: { diff --git a/examples/github-api-next.ts b/examples/github-api-next.ts index fbec9b8d6..8795d73cd 100644 --- a/examples/github-api-next.ts +++ b/examples/github-api-next.ts @@ -4,7 +4,7 @@ */ -/** Type helpers */ +/** OneOf type helpers */ type Without = { [P in Exclude]?: never }; type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U; type OneOf = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR, ...Rest]> : never; diff --git a/examples/github-api.ts b/examples/github-api.ts index 845e95778..d8be03b40 100644 --- a/examples/github-api.ts +++ b/examples/github-api.ts @@ -4,7 +4,7 @@ */ -/** Type helpers */ +/** OneOf type helpers */ type Without = { [P in Exclude]?: never }; type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U; type OneOf = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR, ...Rest]> : never;