Skip to content

feat: add with required utility to enforce properties of allOf #1027

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions examples/digital-ocean-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
*/


/** Type helpers */
/** WithRequired type helpers */
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };

/** OneOf type helpers */
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;
Expand Down Expand Up @@ -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<external["resources/apps/models/app_component_base.yml"] & {
/**
* @description The name of the index document to use when serving this static site. Default: index.html
* @default index.html
Expand All @@ -1854,7 +1857,7 @@ export interface external {
cors?: external["resources/apps/models/apps_cors_policy.yml"];
/** @description A list of HTTP routes that should be routed to this component. */
routes?: (external["resources/apps/models/app_route_spec.yml"])[];
}
}, "name">
"resources/apps/models/app_variable_definition.yml": {
/**
* @description The variable name
Expand Down Expand Up @@ -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<external["resources/apps/models/app_component_base.yml"] & external["resources/apps/models/app_component_instance_base.yml"], "name">
"resources/apps/models/app.yml": {
active_deployment?: external["resources/apps/models/apps_deployment.yml"];
/**
Expand Down Expand Up @@ -3691,7 +3694,7 @@ export interface external {
* "size": "db-s-2vcpu-4gb"
* }
*/
"application/json": external["resources/databases/models/database_replica.yml"];
"application/json": WithRequired<external["resources/databases/models/database_replica.yml"], "name" | "size">;
};
};
responses: {
Expand Down Expand Up @@ -7391,7 +7394,7 @@ export interface external {
* ]
* }
*/
"application/json": external["resources/firewalls/models/firewall.yml"] & (Record<string, never> | Record<string, never>);
"application/json": WithRequired<external["resources/firewalls/models/firewall.yml"] & (Record<string, never> | Record<string, never>), "name">;
};
};
responses: {
Expand Down Expand Up @@ -8117,15 +8120,15 @@ export interface external {
};
}
"resources/images/models/image_action.yml": Record<string, never>
"resources/images/models/image_new_custom.yml": external["resources/images/models/image_update.yml"] & {
"resources/images/models/image_new_custom.yml": WithRequired<external["resources/images/models/image_update.yml"] & {
/**
* @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.
* @example http://cloud-images.ubuntu.com/minimal/releases/bionic/release/ubuntu-18.04-minimal-cloudimg-amd64.img
*/
url?: string;
region?: external["shared/attributes/region_slug.yml"];
tags?: external["shared/attributes/tags_array.yml"];
}
}, "name" | "url" | "region">
"resources/images/models/image_update.yml": {
name?: external["resources/images/attributes.yml"]["image_name"];
distribution?: external["shared/attributes/distribution.yml"];
Expand Down Expand Up @@ -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<string, never> & external["resources/regions/models/region.yml"];
}) & {
Expand Down Expand Up @@ -10397,7 +10400,7 @@ export interface external {
*/
requestBody: {
content: {
"application/json": external["resources/projects/models/project.yml"]["project_base"];
"application/json": WithRequired<external["resources/projects/models/project.yml"]["project_base"], "name" | "purpose">;
};
};
responses: {
Expand Down Expand Up @@ -10552,7 +10555,7 @@ export interface external {
*/
requestBody: {
content: {
"application/json": external["resources/projects/models/project.yml"]["project"];
"application/json": WithRequired<external["resources/projects/models/project.yml"]["project"], "name" | "description" | "purpose" | "environment" | "is_default">;
};
};
responses: {
Expand All @@ -10571,7 +10574,7 @@ export interface external {
*/
requestBody: {
content: {
"application/json": external["resources/projects/models/project.yml"]["project"];
"application/json": WithRequired<external["resources/projects/models/project.yml"]["project"], "name" | "description" | "purpose" | "environment" | "is_default">;
};
};
responses: {
Expand Down Expand Up @@ -12373,7 +12376,7 @@ export interface external {
*/
requestBody: {
content: {
"application/json": external["resources/uptime/models/alert.yml"]["alert"];
"application/json": WithRequired<external["resources/uptime/models/alert.yml"]["alert"], "name" | "type" | "notifications">;
};
};
responses: {
Expand All @@ -12393,7 +12396,7 @@ export interface external {
*/
requestBody: {
content: {
"application/json": external["resources/uptime/models/check.yml"]["check_updatable"];
"application/json": WithRequired<external["resources/uptime/models/check.yml"]["check_updatable"], "name" | "method" | "target">;
};
};
responses: {
Expand Down Expand Up @@ -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<external["resources/vpcs/models/vpc.yml"]["vpc_updatable"] & external["resources/vpcs/models/vpc.yml"]["vpc_create"], "name" | "region">;
};
};
responses: {
Expand Down Expand Up @@ -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<external["resources/vpcs/models/vpc.yml"]["vpc_updatable"] & external["resources/vpcs/models/vpc.yml"]["vpc_default"], "name">;
};
};
responses: {
Expand Down
2 changes: 1 addition & 1 deletion examples/github-api-next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/


/** Type helpers */
/** OneOf type helpers */
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;
Expand Down
2 changes: 1 addition & 1 deletion examples/github-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/


/** Type helpers */
/** OneOf type helpers */
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;
Expand Down
15 changes: 13 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,19 +190,30 @@ async function openapiTS(
output.push(`export type operations = Record<string, never>;`, "");
}

// 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<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };",
"type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;",
"type OneOf<T extends any[]> = T extends [infer Only] ? Only : T extends [infer A, infer B, ...infer Rest] ? OneOf<[XOR<A, B>, ...Rest]> : never;",
""
);
}

// 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, K extends keyof T> = T & { [P in K]-?: T[P] };",
""
);
}

return output.join("\n");
}

Expand Down
7 changes: 6 additions & 1 deletion src/transform/schema-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
tsReadonly,
tsTupleOf,
tsUnionOf,
tsWithRequired,
} from "../utils.js";

export interface TransformSchemaObjectOptions {
Expand Down Expand Up @@ -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));
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }
);

/**
Expand Down
5 changes: 5 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ export function tsOmit(root: string, keys: string[]): string {
return `Omit<${root}, ${tsUnionOf(...keys.map(escStr))}>`;
}

/** WithRequired<T> */
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}?`;
Expand Down
65 changes: 62 additions & 3 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ const BOILERPLATE = `/**

`;

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

const WITH_REQUIRED_TYPE_HELPERS = `
/** WithRequired type helpers */
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
`;

beforeAll(() => {
vi.spyOn(process, "exit").mockImplementation(((code: number) => {
throw new Error(`Process exited with error code ${code}`);
Expand Down Expand Up @@ -494,7 +499,7 @@ export type operations = Record<string, never>;
},
{ exportType: false }
);
expect(generated).toBe(`${BOILERPLATE}${TYPE_HELPERS}
expect(generated).toBe(`${BOILERPLATE}${ONE_OF_TYPE_HELPERS}
export type paths = Record<string, never>;

export type webhooks = Record<string, never>;
Expand All @@ -516,6 +521,60 @@ export interface components {

export type external = Record<string, never>;

export type operations = Record<string, never>;
`);
});
});

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<string, never>;

export type webhooks = Record<string, never>;

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<string, never>;

export type operations = Record<string, never>;
`);
});
Expand Down
20 changes: 20 additions & 0 deletions test/schema-object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down