Skip to content

Commit 9225d87

Browse files
committed
feat(validation): enhance JSON string handling in UploadSongDto schema
- Introduce a new jsonStringField function to validate and parse JSON strings, ensuring invalid JSON surfaces as a Zod issue. - Update thumbnailData and customInstruments fields to utilize jsonStringField for improved validation.
1 parent d58c628 commit 9225d87

4 files changed

Lines changed: 91 additions & 14 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from 'bun:test';
2+
3+
import { z } from 'zod';
4+
5+
import { jsonStringField } from './jsonStringField.js';
6+
7+
describe('jsonStringField', () => {
8+
it('parses a valid JSON string and validates against the inner schema', () => {
9+
const schema = jsonStringField(z.array(z.string()));
10+
const result = schema.safeParse('["a","b"]');
11+
12+
expect(result.success).toBe(true);
13+
if (result.success) {
14+
expect(result.data).toEqual(['a', 'b']);
15+
}
16+
});
17+
18+
it('parses a JSON object string when the inner schema is an object', () => {
19+
const schema = jsonStringField(z.object({ n: z.number(), s: z.string() }));
20+
const result = schema.safeParse('{"n":1,"s":"x"}');
21+
22+
expect(result.success).toBe(true);
23+
if (result.success) {
24+
expect(result.data).toEqual({ n: 1, s: 'x' });
25+
}
26+
});
27+
28+
it('turns invalid JSON into a Zod custom issue instead of throwing', () => {
29+
const schema = jsonStringField(z.array(z.string()));
30+
const inputs = ['{', 'not json', '', '{"unclosed": true'];
31+
32+
for (const input of inputs) {
33+
const result = schema.safeParse(input);
34+
expect(result.success).toBe(false);
35+
if (!result.success) {
36+
const custom = result.error.issues.filter((i) => i.code === 'custom');
37+
expect(custom.length).toBeGreaterThanOrEqual(1);
38+
expect(custom.some((i) => i.message === 'Invalid JSON string')).toBe(
39+
true,
40+
);
41+
}
42+
}
43+
});
44+
45+
it('does not classify valid JSON that fails the inner schema as invalid JSON', () => {
46+
const schema = jsonStringField(z.array(z.string()));
47+
const result = schema.safeParse('123');
48+
49+
expect(result.success).toBe(false);
50+
if (!result.success) {
51+
expect(
52+
result.error.issues.some((i) => i.message === 'Invalid JSON string'),
53+
).toBe(false);
54+
}
55+
});
56+
57+
it('rejects non-string input at the outer string schema', () => {
58+
const schema = jsonStringField(z.array(z.string()));
59+
const result = schema.safeParse(['already', 'an', 'array'] as unknown);
60+
61+
expect(result.success).toBe(false);
62+
});
63+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { z } from 'zod';
2+
3+
/**
4+
* Multipart / form fields often arrive as JSON strings. Parses the string and
5+
* validates with `schema`. Invalid JSON becomes a Zod issue (e.g. HTTP 400 via
6+
* `ZodValidationPipe`) instead of a raw `SyntaxError` from `JSON.parse`.
7+
*/
8+
export function jsonStringField<T>(schema: z.ZodType<T>) {
9+
return z
10+
.string()
11+
.transform((val, ctx) => {
12+
try {
13+
return JSON.parse(val) as unknown;
14+
} catch {
15+
ctx.addIssue({
16+
code: 'custom',
17+
message: 'Invalid JSON string',
18+
input: val,
19+
});
20+
return z.NEVER;
21+
}
22+
})
23+
.pipe(schema);
24+
}

packages/validation/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './auth/DiscordStrategyConfig.dto';
22
export * from './config/EnvironmentVariables.dto';
33

4+
export * from './common/jsonStringField';
45
export * from './common/Page.dto';
56
export * from './common/PageQuery.dto';
67
export * from './common/types';

packages/validation/src/song/UploadSongDto.dto.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod';
22

3+
import { jsonStringField } from '../common/jsonStringField.js';
34
import { UPLOAD_CONSTANTS } from '../config-shim.js';
45

56
import { thumbnailDataSchema } from './ThumbnailData.dto';
@@ -31,23 +32,11 @@ export const uploadSongDtoSchema = z.object({
3132
categories as [string, ...string[]],
3233
) as z.ZodType<CategoryType>,
3334
thumbnailData: z
34-
.union([
35-
thumbnailDataSchema,
36-
z
37-
.string()
38-
.transform((val) => JSON.parse(val))
39-
.pipe(thumbnailDataSchema),
40-
])
35+
.union([thumbnailDataSchema, jsonStringField(thumbnailDataSchema)])
4136
.pipe(thumbnailDataSchema),
4237
license: z.enum(licenses as [string, ...string[]]) as z.ZodType<LicenseType>,
4338
customInstruments: z
44-
.union([
45-
z.array(z.string()),
46-
z
47-
.string()
48-
.transform((val) => JSON.parse(val))
49-
.pipe(z.array(z.string())),
50-
])
39+
.union([z.array(z.string()), jsonStringField(z.array(z.string()))])
5140
.pipe(z.array(z.string()).max(UPLOAD_CONSTANTS.customInstruments.maxCount)),
5241
});
5342

0 commit comments

Comments
 (0)