Skip to content

Commit 459c526

Browse files
committed
fix(zod): parser generation when content-type contains charset precision
1 parent f2b15fe commit 459c526

60 files changed

Lines changed: 1016 additions & 130 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/zod/src/index.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,13 +1335,27 @@ const parseBodyAndResponse = ({
13351335
| OpenApiRequestBodyObject;
13361336

13371337
// Only handle JSON and form-data; other content types (e.g., application/octet-stream)
1338-
// are skipped - unclear if this is correct behavior for root-level binary/text bodies
1339-
const jsonMedia = resolvedRef.content?.['application/json'];
1340-
const formDataMedia = resolvedRef.content?.['multipart/form-data'];
1341-
const [contentType, mediaType] = jsonMedia
1342-
? (['application/json', jsonMedia] as const)
1343-
: formDataMedia
1344-
? (['multipart/form-data', formDataMedia] as const)
1338+
// Only handle JSON and form-data; other content types (e.g., application/octet-stream)
1339+
// are skipped - unclear if this is correct behavior for root-level binary/text bodies.
1340+
const contentEntries = Object.entries(resolvedRef.content ?? {});
1341+
1342+
const jsonContent = contentEntries.find(
1343+
isMediaType(
1344+
// application/json
1345+
// application/geo+json
1346+
// application/ld+json
1347+
// application/manifest+json
1348+
// application/vnd.api+json (and other valid vendor subtypes)
1349+
String.raw`^application\/([^/;]+\+)?json$`,
1350+
),
1351+
);
1352+
const formDataContent = contentEntries.find(
1353+
isMediaType('multipart/form-data'),
1354+
);
1355+
const [contentType, mediaType] = jsonContent
1356+
? (['application/json', jsonContent[1]] as const)
1357+
: formDataContent
1358+
? (['multipart/form-data', formDataContent[1]] as const)
13451359
: [undefined, undefined];
13461360

13471361
const schema = mediaType?.schema;
@@ -1352,7 +1366,6 @@ const parseBodyAndResponse = ({
13521366
isArray: false,
13531367
};
13541368
}
1355-
13561369
const encoding = mediaType.encoding;
13571370

13581371
const resolvedJsonSchema = dereference(schema, context);
@@ -1420,6 +1433,11 @@ const parseBodyAndResponse = ({
14201433
};
14211434
};
14221435

1436+
const isMediaType =
1437+
(pattern: string) =>
1438+
([contentType]: [string, object]): boolean =>
1439+
new RegExp(pattern).test(contentType.split(';')[0].trim().toLowerCase());
1440+
14231441
const getSingleResponse = (
14241442
responses:
14251443
| Record<string, OpenApiResponseObject | OpenApiReferenceObject | undefined>

packages/zod/src/zod.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6273,6 +6273,136 @@ describe('generateZod (content type handling - parity with res-req-types.test.ts
62736273
})
62746274
})
62756275
6276+
`);
6277+
});
6278+
6279+
it('content type with charset precision: comprehensive content type handling', async () => {
6280+
// Matches type gen test structure in res-req-types.test.ts
6281+
const schema = {
6282+
pathRoute: '/upload-form',
6283+
context: {
6284+
spec: {
6285+
paths: {
6286+
'/upload-form': {
6287+
post: {
6288+
operationId: 'uploadForm',
6289+
requestBody: {
6290+
required: true,
6291+
content: {
6292+
'multipart/form-data; charset=utf-8': {
6293+
schema: {
6294+
type: 'object',
6295+
properties: {
6296+
encBinary: { type: 'string' },
6297+
encText: { type: 'string' },
6298+
cmtBinary: {
6299+
type: 'string',
6300+
contentMediaType: 'image/png',
6301+
},
6302+
cmtText: {
6303+
type: 'string',
6304+
contentMediaType: 'application/xml',
6305+
},
6306+
encOverride: {
6307+
type: 'string',
6308+
contentMediaType: 'image/png',
6309+
},
6310+
formatBinary: { type: 'string', format: 'binary' },
6311+
base64Field: {
6312+
type: 'string',
6313+
contentMediaType: 'image/png',
6314+
contentEncoding: 'base64',
6315+
},
6316+
metadata: {
6317+
type: 'object',
6318+
properties: { name: { type: 'string' } },
6319+
},
6320+
},
6321+
required: [
6322+
'encBinary',
6323+
'encText',
6324+
'cmtBinary',
6325+
'cmtText',
6326+
'encOverride',
6327+
'formatBinary',
6328+
'base64Field',
6329+
'metadata',
6330+
],
6331+
},
6332+
encoding: {
6333+
encBinary: { contentType: 'image/png' },
6334+
encText: { contentType: 'text/plain' },
6335+
encOverride: { contentType: 'text/csv' },
6336+
metadata: { contentType: 'application/json' },
6337+
},
6338+
},
6339+
},
6340+
},
6341+
responses: {
6342+
'200': {
6343+
content: {
6344+
'application/json; charset=utf-8': {
6345+
schema: {
6346+
type: 'object',
6347+
properties: {
6348+
success: { type: 'boolean' },
6349+
uploaded: { type: 'number' },
6350+
},
6351+
},
6352+
},
6353+
},
6354+
},
6355+
},
6356+
},
6357+
},
6358+
},
6359+
},
6360+
output: { override: { zod: { generateEachHttpStatus: false } } },
6361+
},
6362+
} as unknown as GeneratorOptions;
6363+
const result = await generateZod(
6364+
{
6365+
pathRoute: '/upload-form',
6366+
verb: 'post',
6367+
operationName: 'uploadForm',
6368+
override: {
6369+
...zodOverride,
6370+
zod: {
6371+
...zodOverride.zod,
6372+
generate: { ...zodOverride.zod.generate, response: true },
6373+
},
6374+
},
6375+
} as unknown as Parameters<typeof generateZod>[0],
6376+
schema,
6377+
testOutput,
6378+
);
6379+
// encBinary: encoding image/png → File
6380+
// encText: encoding text/plain → File | string
6381+
// cmtBinary: contentMediaType image/png → File
6382+
// cmtText: contentMediaType application/xml → File | string
6383+
// encOverride: encoding text/csv overrides contentMediaType image/png → File | string
6384+
// formatBinary: format: binary → File (same as instanceof check)
6385+
// base64Field: contentEncoding base64 → stays string
6386+
// metadata: object → object schema
6387+
expect(result.implementation)
6388+
.toBe(`export const UploadFormBody = zod.object({
6389+
"encBinary": zod.instanceof(File),
6390+
"encText": zod.instanceof(File).or(zod.string()),
6391+
"cmtBinary": zod.instanceof(File),
6392+
"cmtText": zod.instanceof(File).or(zod.string()),
6393+
"encOverride": zod.instanceof(File).or(zod.string()),
6394+
"formatBinary": zod.instanceof(File),
6395+
"base64Field": zod.string(),
6396+
"metadata": zod.object({
6397+
"name": zod.string().optional()
6398+
})
6399+
})
6400+
6401+
export const UploadFormResponse = zod.object({
6402+
"success": zod.boolean().optional(),
6403+
"uploaded": zod.number().optional()
6404+
})
6405+
62766406
`);
62776407
});
62786408
});

samples/angular-app/__snapshots__/api/endpoints-zod/pets/pets.msw.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const getSearchPetsResponseMock = (): Pets =>
3636
phone: (() =>
3737
faker.helpers.arrayElement([
3838
`+1${faker.string.numeric({ length: 10 })}`,
39-
undefined,
39+
void 0,
4040
]))(),
4141
requiredNullableString: faker.helpers.arrayElement([
4242
faker.helpers.arrayElement([
@@ -80,7 +80,7 @@ export const getListPetsResponseMock = (): Pets =>
8080
phone: (() =>
8181
faker.helpers.arrayElement([
8282
`+1${faker.string.numeric({ length: 10 })}`,
83-
undefined,
83+
void 0,
8484
]))(),
8585
requiredNullableString: faker.helpers.arrayElement([
8686
faker.helpers.arrayElement([
@@ -121,7 +121,7 @@ export const getListPetsResponseMock = (): Pets =>
121121
phone: (() =>
122122
faker.helpers.arrayElement([
123123
`+1${faker.string.numeric({ length: 10 })}`,
124-
undefined,
124+
void 0,
125125
]))(),
126126
requiredNullableString: faker.helpers.arrayElement([
127127
faker.helpers.arrayElement([
@@ -144,17 +144,17 @@ export const getShowPetByIdResponseMock = () =>
144144
(() => ({
145145
id: faker.number.int({ min: 1, max: 99 }),
146146
name: faker.person.firstName(),
147-
tag: faker.helpers.arrayElement([faker.word.sample(), undefined]),
147+
tag: faker.helpers.arrayElement([faker.word.sample(), void 0]),
148148
status: faker.helpers.arrayElement(['available', 'pending', 'sold']),
149149
requiredNullableString: null,
150150
optionalNullableString: faker.helpers.arrayElement([
151151
faker.word.sample(),
152152
null,
153-
undefined,
153+
void 0,
154154
]),
155155
phone: faker.helpers.arrayElement([
156156
`+1${faker.string.numeric({ length: 10 })}`,
157-
undefined,
157+
void 0,
158158
]),
159159
}))();
160160

@@ -183,7 +183,7 @@ export const getUpdatePetByIdResponseMock = (
183183
phone: (() =>
184184
faker.helpers.arrayElement([
185185
`+1${faker.string.numeric({ length: 10 })}`,
186-
undefined,
186+
void 0,
187187
]))(),
188188
requiredNullableString: faker.helpers.arrayElement([
189189
faker.helpers.arrayElement([
@@ -229,7 +229,7 @@ export const getPatchPetByIdResponseMock = (
229229
phone: (() =>
230230
faker.helpers.arrayElement([
231231
`+1${faker.string.numeric({ length: 10 })}`,
232-
undefined,
232+
void 0,
233233
]))(),
234234
requiredNullableString: faker.helpers.arrayElement([
235235
faker.helpers.arrayElement([

samples/angular-app/__snapshots__/api/http-both/pets/pets.msw.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const getSearchPetsResponseMock = (): Pets =>
3636
phone: (() =>
3737
faker.helpers.arrayElement([
3838
`+1${faker.string.numeric({ length: 10 })}`,
39-
undefined,
39+
void 0,
4040
]))(),
4141
requiredNullableString: faker.helpers.arrayElement([
4242
faker.helpers.arrayElement([
@@ -80,7 +80,7 @@ export const getListPetsResponseMock = (): Pets =>
8080
phone: (() =>
8181
faker.helpers.arrayElement([
8282
`+1${faker.string.numeric({ length: 10 })}`,
83-
undefined,
83+
void 0,
8484
]))(),
8585
requiredNullableString: faker.helpers.arrayElement([
8686
faker.helpers.arrayElement([
@@ -121,7 +121,7 @@ export const getListPetsResponseMock = (): Pets =>
121121
phone: (() =>
122122
faker.helpers.arrayElement([
123123
`+1${faker.string.numeric({ length: 10 })}`,
124-
undefined,
124+
void 0,
125125
]))(),
126126
requiredNullableString: faker.helpers.arrayElement([
127127
faker.helpers.arrayElement([
@@ -144,17 +144,17 @@ export const getShowPetByIdResponseMock = () =>
144144
(() => ({
145145
id: faker.number.int({ min: 1, max: 99 }),
146146
name: faker.person.firstName(),
147-
tag: faker.helpers.arrayElement([faker.word.sample(), undefined]),
147+
tag: faker.helpers.arrayElement([faker.word.sample(), void 0]),
148148
status: faker.helpers.arrayElement(['available', 'pending', 'sold']),
149149
requiredNullableString: null,
150150
optionalNullableString: faker.helpers.arrayElement([
151151
faker.word.sample(),
152152
null,
153-
undefined,
153+
void 0,
154154
]),
155155
phone: faker.helpers.arrayElement([
156156
`+1${faker.string.numeric({ length: 10 })}`,
157-
undefined,
157+
void 0,
158158
]),
159159
}))();
160160

@@ -183,7 +183,7 @@ export const getUpdatePetByIdResponseMock = (
183183
phone: (() =>
184184
faker.helpers.arrayElement([
185185
`+1${faker.string.numeric({ length: 10 })}`,
186-
undefined,
186+
void 0,
187187
]))(),
188188
requiredNullableString: faker.helpers.arrayElement([
189189
faker.helpers.arrayElement([
@@ -229,7 +229,7 @@ export const getPatchPetByIdResponseMock = (
229229
phone: (() =>
230230
faker.helpers.arrayElement([
231231
`+1${faker.string.numeric({ length: 10 })}`,
232-
undefined,
232+
void 0,
233233
]))(),
234234
requiredNullableString: faker.helpers.arrayElement([
235235
faker.helpers.arrayElement([

samples/angular-app/__snapshots__/api/http-client-custom-params/pets/pets.msw.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const getSearchPetsResponseMock = (): Pets =>
3636
phone: (() =>
3737
faker.helpers.arrayElement([
3838
`+1${faker.string.numeric({ length: 10 })}`,
39-
undefined,
39+
void 0,
4040
]))(),
4141
requiredNullableString: faker.helpers.arrayElement([
4242
faker.helpers.arrayElement([
@@ -80,7 +80,7 @@ export const getListPetsResponseMock = (): Pets =>
8080
phone: (() =>
8181
faker.helpers.arrayElement([
8282
`+1${faker.string.numeric({ length: 10 })}`,
83-
undefined,
83+
void 0,
8484
]))(),
8585
requiredNullableString: faker.helpers.arrayElement([
8686
faker.helpers.arrayElement([
@@ -121,7 +121,7 @@ export const getListPetsResponseMock = (): Pets =>
121121
phone: (() =>
122122
faker.helpers.arrayElement([
123123
`+1${faker.string.numeric({ length: 10 })}`,
124-
undefined,
124+
void 0,
125125
]))(),
126126
requiredNullableString: faker.helpers.arrayElement([
127127
faker.helpers.arrayElement([
@@ -144,17 +144,17 @@ export const getShowPetByIdResponseMock = () =>
144144
(() => ({
145145
id: faker.number.int({ min: 1, max: 99 }),
146146
name: faker.person.firstName(),
147-
tag: faker.helpers.arrayElement([faker.word.sample(), undefined]),
147+
tag: faker.helpers.arrayElement([faker.word.sample(), void 0]),
148148
status: faker.helpers.arrayElement(['available', 'pending', 'sold']),
149149
requiredNullableString: null,
150150
optionalNullableString: faker.helpers.arrayElement([
151151
faker.word.sample(),
152152
null,
153-
undefined,
153+
void 0,
154154
]),
155155
phone: faker.helpers.arrayElement([
156156
`+1${faker.string.numeric({ length: 10 })}`,
157-
undefined,
157+
void 0,
158158
]),
159159
}))();
160160

@@ -183,7 +183,7 @@ export const getUpdatePetByIdResponseMock = (
183183
phone: (() =>
184184
faker.helpers.arrayElement([
185185
`+1${faker.string.numeric({ length: 10 })}`,
186-
undefined,
186+
void 0,
187187
]))(),
188188
requiredNullableString: faker.helpers.arrayElement([
189189
faker.helpers.arrayElement([
@@ -229,7 +229,7 @@ export const getPatchPetByIdResponseMock = (
229229
phone: (() =>
230230
faker.helpers.arrayElement([
231231
`+1${faker.string.numeric({ length: 10 })}`,
232-
undefined,
232+
void 0,
233233
]))(),
234234
requiredNullableString: faker.helpers.arrayElement([
235235
faker.helpers.arrayElement([

0 commit comments

Comments
 (0)