Skip to content

Commit 5eb2996

Browse files
authored
Merge pull request #3504 from hey-api/copilot/fix-transformer-anyof-issue
Fix missing transformer when response schema uses anyOf with $ref and null
2 parents 60fc6a9 + 4b6b398 commit 5eb2996

10 files changed

Lines changed: 248 additions & 6 deletions

File tree

.changeset/sweet-turkeys-reply.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hey-api/openapi-ts": patch
3+
---
4+
5+
**plugin(@hey-api/transformers)**: fix: support `anyOf` schema with null
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
// This file is auto-generated by @hey-api/openapi-ts
22

3-
export type { ClientOptions, Foo, GetFooData, GetFooResponse, GetFooResponses } from './types.gen';
3+
export type { ClientOptions, Foo, GetFooData, GetFooResponse, GetFooResponses, GetNullablePollData, GetNullablePollResponse, GetNullablePollResponses, GetPollData, GetPollResponse, GetPollResponses, Poll } from './types.gen';

packages/openapi-ts-tests/main/test/__snapshots__/3.0.x/transformers-any-of-null/transformers.gen.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// This file is auto-generated by @hey-api/openapi-ts
22

3-
import type { GetFooResponse } from './types.gen';
3+
import type { GetFooResponse, GetNullablePollResponse, GetPollResponse } from './types.gen';
44

55
const fooSchemaResponseTransformer = (data: any) => {
66
if (data.foo) {
@@ -19,3 +19,20 @@ export const getFooResponseTransformer = async (data: any): Promise<GetFooRespon
1919
data = data.map((item: any) => fooSchemaResponseTransformer(item));
2020
return data;
2121
};
22+
23+
const pollSchemaResponseTransformer = (data: any) => {
24+
data.createdAt = new Date(data.createdAt);
25+
return data;
26+
};
27+
28+
export const getPollResponseTransformer = async (data: any): Promise<GetPollResponse> => {
29+
data = pollSchemaResponseTransformer(data);
30+
return data;
31+
};
32+
33+
export const getNullablePollResponseTransformer = async (data: any): Promise<GetNullablePollResponse> => {
34+
if (data) {
35+
data = pollSchemaResponseTransformer(data);
36+
}
37+
return data;
38+
};

packages/openapi-ts-tests/main/test/__snapshots__/3.0.x/transformers-any-of-null/types.gen.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ export type ClientOptions = {
44
baseUrl: `${string}://${string}` | (string & {});
55
};
66

7+
export type Poll = {
8+
id: number;
9+
createdAt: Date;
10+
};
11+
712
export type Foo = {
813
foo?: Date;
914
bar?: Date | null;
@@ -25,3 +30,35 @@ export type GetFooResponses = {
2530
};
2631

2732
export type GetFooResponse = GetFooResponses[keyof GetFooResponses];
33+
34+
export type GetPollData = {
35+
body?: never;
36+
path?: never;
37+
query?: never;
38+
url: '/polls';
39+
};
40+
41+
export type GetPollResponses = {
42+
/**
43+
* OK
44+
*/
45+
200: Poll;
46+
};
47+
48+
export type GetPollResponse = GetPollResponses[keyof GetPollResponses];
49+
50+
export type GetNullablePollData = {
51+
body?: never;
52+
path?: never;
53+
query?: never;
54+
url: '/polls/nullable';
55+
};
56+
57+
export type GetNullablePollResponses = {
58+
/**
59+
* OK
60+
*/
61+
200: Poll | null;
62+
};
63+
64+
export type GetNullablePollResponse = GetNullablePollResponses[keyof GetNullablePollResponses];
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
// This file is auto-generated by @hey-api/openapi-ts
22

3-
export type { ClientOptions, Foo, GetFooData, GetFooResponse, GetFooResponses, NestedDateObject, NestedDateObjectData, NestedDateObjectResponse, NestedDateObjectResponses } from './types.gen';
3+
export type { ClientOptions, Foo, GetFooData, GetFooResponse, GetFooResponses, GetNullablePollData, GetNullablePollResponse, GetNullablePollResponses, GetPollData, GetPollResponse, GetPollResponses, NestedDateObject, NestedDateObjectData, NestedDateObjectResponse, NestedDateObjectResponses, Poll } from './types.gen';

packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/transformers-any-of-null/transformers.gen.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// This file is auto-generated by @hey-api/openapi-ts
22

3-
import type { GetFooResponse, NestedDateObjectResponse } from './types.gen';
3+
import type { GetFooResponse, GetNullablePollResponse, GetPollResponse, NestedDateObjectResponse } from './types.gen';
44

55
const fooSchemaResponseTransformer = (data: any) => {
66
if (data.foo) {
@@ -23,6 +23,23 @@ export const getFooResponseTransformer = async (data: any): Promise<GetFooRespon
2323
return data;
2424
};
2525

26+
const pollSchemaResponseTransformer = (data: any) => {
27+
data.createdAt = new Date(data.createdAt);
28+
return data;
29+
};
30+
31+
export const getPollResponseTransformer = async (data: any): Promise<GetPollResponse> => {
32+
data = pollSchemaResponseTransformer(data);
33+
return data;
34+
};
35+
36+
export const getNullablePollResponseTransformer = async (data: any): Promise<GetNullablePollResponse> => {
37+
if (data) {
38+
data = pollSchemaResponseTransformer(data);
39+
}
40+
return data;
41+
};
42+
2643
const nestedDateObjectSchemaResponseTransformer = (data: any) => {
2744
if (data.foo) {
2845
if (data.foo.bar) {

packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/transformers-any-of-null/types.gen.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ export type ClientOptions = {
44
baseUrl: `${string}://${string}` | (string & {});
55
};
66

7+
export type Poll = {
8+
id: number;
9+
createdAt: Date;
10+
};
11+
712
/**
813
* Object with a nested date structure
914
*/
@@ -36,6 +41,38 @@ export type GetFooResponses = {
3641

3742
export type GetFooResponse = GetFooResponses[keyof GetFooResponses];
3843

44+
export type GetPollData = {
45+
body?: never;
46+
path?: never;
47+
query?: never;
48+
url: '/polls';
49+
};
50+
51+
export type GetPollResponses = {
52+
/**
53+
* OK
54+
*/
55+
200: Poll;
56+
};
57+
58+
export type GetPollResponse = GetPollResponses[keyof GetPollResponses];
59+
60+
export type GetNullablePollData = {
61+
body?: never;
62+
path?: never;
63+
query?: never;
64+
url: '/polls/nullable';
65+
};
66+
67+
export type GetNullablePollResponses = {
68+
/**
69+
* OK
70+
*/
71+
200: Poll | null;
72+
};
73+
74+
export type GetNullablePollResponse = GetNullablePollResponses[keyof GetNullablePollResponses];
75+
3976
export type NestedDateObjectData = {
4077
body?: never;
4178
path?: never;

packages/openapi-ts/src/plugins/@hey-api/transformers/plugin.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,16 @@ export const handler: HeyApiTransformersPlugin['Handler'] = ({ plugin }) => {
283283
const { response } = operationResponsesMap(operation);
284284
if (!response) return;
285285

286-
if (response.items && response.items.length > 1 && response.logicalOperator !== 'and') {
286+
const isNullableUnion =
287+
response.items?.length === 2 &&
288+
response.items.some((item) => item.type === 'null' || item.type === 'void');
289+
290+
if (
291+
response.items &&
292+
response.items.length > 1 &&
293+
response.logicalOperator !== 'and' &&
294+
!isNullableUnion
295+
) {
287296
if (plugin.context.config.logs.level === 'debug') {
288297
console.warn(
289298
`❗️ Transformers warning: route ${createOperationKey(operation)} has ${response.items.length} non-void success responses. This is currently not handled and we will not generate a response transformer. Please open an issue if you'd like this feature https://github.com/hey-api/openapi-ts/issues`,
@@ -306,6 +315,20 @@ export const handler: HeyApiTransformersPlugin['Handler'] = ({ plugin }) => {
306315
schema: response,
307316
});
308317
if (!nodes.length) return;
318+
319+
// For nullable union responses (e.g. anyOf: [SomeSchema, null]), wrap the
320+
// transformation in a null guard so that null data is returned as-is.
321+
// We require nodes.length >= 2 because we need at least one transformation
322+
// statement AND a return statement (empty .do() would fail validation).
323+
let finalNodes = nodes;
324+
if (isNullableUnion && nodes.length >= 2) {
325+
const lastNode = nodes[nodes.length - 1]!;
326+
if (isNodeReturnStatement(lastNode as Expr)) {
327+
const transformNodes = nodes.slice(0, -1) as Array<Expr>;
328+
finalNodes = [$.if($(dataVariableName)).do(...transformNodes), lastNode];
329+
}
330+
}
331+
309332
const symbol = plugin.symbol(
310333
applyNaming(operation.id, {
311334
case: 'camelCase',
@@ -328,7 +351,7 @@ export const handler: HeyApiTransformersPlugin['Handler'] = ({ plugin }) => {
328351
.async()
329352
.param(dataVariableName, (p) => p.type('any'))
330353
.returns($.type('Promise').generic(symbolResponse))
331-
.do(...nodes),
354+
.do(...finalNodes),
332355
);
333356
plugin.node(value);
334357
},

specs/3.0.x/transformers-any-of-null.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,62 @@
2323
}
2424
}
2525
}
26+
},
27+
"/polls": {
28+
"get": {
29+
"operationId": "getPoll",
30+
"responses": {
31+
"200": {
32+
"description": "OK",
33+
"content": {
34+
"application/json": {
35+
"schema": {
36+
"$ref": "#/components/schemas/Poll"
37+
}
38+
}
39+
}
40+
}
41+
}
42+
}
43+
},
44+
"/polls/nullable": {
45+
"get": {
46+
"operationId": "getNullablePoll",
47+
"responses": {
48+
"200": {
49+
"description": "OK",
50+
"content": {
51+
"application/json": {
52+
"schema": {
53+
"anyOf": [
54+
{
55+
"$ref": "#/components/schemas/Poll"
56+
}
57+
],
58+
"nullable": true
59+
}
60+
}
61+
}
62+
}
63+
}
64+
}
2665
}
2766
},
2867
"components": {
2968
"schemas": {
69+
"Poll": {
70+
"type": "object",
71+
"properties": {
72+
"id": {
73+
"type": "integer"
74+
},
75+
"createdAt": {
76+
"type": "string",
77+
"format": "date-time"
78+
}
79+
},
80+
"required": ["id", "createdAt"]
81+
},
3082
"Foo": {
3183
"type": "object",
3284
"properties": {

specs/3.1.x/transformers-any-of-null.json

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,47 @@
2424
}
2525
}
2626
},
27+
"/polls": {
28+
"get": {
29+
"operationId": "getPoll",
30+
"responses": {
31+
"200": {
32+
"description": "OK",
33+
"content": {
34+
"application/json": {
35+
"schema": {
36+
"$ref": "#/components/schemas/Poll"
37+
}
38+
}
39+
}
40+
}
41+
}
42+
}
43+
},
44+
"/polls/nullable": {
45+
"get": {
46+
"operationId": "getNullablePoll",
47+
"responses": {
48+
"200": {
49+
"description": "OK",
50+
"content": {
51+
"application/json": {
52+
"schema": {
53+
"anyOf": [
54+
{
55+
"$ref": "#/components/schemas/Poll"
56+
},
57+
{
58+
"type": "null"
59+
}
60+
]
61+
}
62+
}
63+
}
64+
}
65+
}
66+
}
67+
},
2768
"/api/nested-date-object": {
2869
"get": {
2970
"operationId": "nestedDateObject",
@@ -44,6 +85,19 @@
4485
},
4586
"components": {
4687
"schemas": {
88+
"Poll": {
89+
"type": "object",
90+
"properties": {
91+
"id": {
92+
"type": "integer"
93+
},
94+
"createdAt": {
95+
"type": "string",
96+
"format": "date-time"
97+
}
98+
},
99+
"required": ["id", "createdAt"]
100+
},
47101
"NestedDateObject": {
48102
"description": "Object with a nested date structure",
49103
"type": "object",

0 commit comments

Comments
 (0)