Skip to content

Commit a76d65a

Browse files
committed
feat(utils): provide validateAsync alternative to synchronous validate
1 parent f5a8b2f commit a76d65a

File tree

3 files changed

+98
-3
lines changed

3 files changed

+98
-3
lines changed

packages/models/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export { exists } from './lib/implementation/utils.js';
7474
export {
7575
SchemaValidationError,
7676
validate,
77+
validateAsync,
7778
} from './lib/implementation/validate.js';
7879
export {
7980
issueSchema,

packages/models/src/lib/implementation/validate.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ type SchemaValidationContext = {
66
filePath?: string;
77
};
88

9+
/**
10+
* Autocompletes valid Zod Schema input for convience, but will accept any other data as well
11+
*/
12+
type ZodInputLooseAutocomplete<T extends ZodType> =
13+
| z.input<T>
14+
| {}
15+
| null
16+
| undefined;
17+
918
export class SchemaValidationError extends Error {
1019
constructor(
1120
error: ZodError,
@@ -29,7 +38,7 @@ export class SchemaValidationError extends Error {
2938

3039
export function validate<T extends ZodType>(
3140
schema: T,
32-
data: z.input<T> | {} | null | undefined, // loose autocomplete
41+
data: ZodInputLooseAutocomplete<T>,
3342
context: SchemaValidationContext = {},
3443
): z.output<T> {
3544
const result = schema.safeParse(data);
@@ -38,3 +47,15 @@ export function validate<T extends ZodType>(
3847
}
3948
throw new SchemaValidationError(result.error, schema, context);
4049
}
50+
51+
export async function validateAsync<T extends ZodType>(
52+
schema: T,
53+
data: ZodInputLooseAutocomplete<T>,
54+
context: SchemaValidationContext = {},
55+
): Promise<z.output<T>> {
56+
const result = await schema.safeParseAsync(data);
57+
if (result.success) {
58+
return result.data;
59+
}
60+
throw new SchemaValidationError(result.error, schema, context);
61+
}

packages/models/src/lib/implementation/validate.unit.test.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import ansis from 'ansis';
2+
import { vol } from 'memfs';
3+
import { readFile, stat } from 'node:fs/promises';
24
import path from 'node:path';
3-
import z, { ZodError } from 'zod';
4-
import { SchemaValidationError, validate } from './validate.js';
5+
import { ZodError, z } from 'zod';
6+
import { MEMFS_VOLUME } from '@code-pushup/test-utils';
7+
import { SchemaValidationError, validate, validateAsync } from './validate.js';
58

69
describe('validate', () => {
710
it('should return parsed data if valid', () => {
@@ -37,6 +40,76 @@ describe('validate', () => {
3740
✖ Invalid ISO date
3841
→ at dateOfBirth`);
3942
});
43+
44+
it('should throw if async schema provided (handled by validateAsync)', () => {
45+
const projectNameSchema = z
46+
.string()
47+
.optional()
48+
.transform(
49+
async name =>
50+
name || JSON.parse(await readFile('package.json', 'utf8')).name,
51+
)
52+
.meta({ title: 'ProjectName' });
53+
54+
expect(() => validate(projectNameSchema, undefined)).toThrow(
55+
'Encountered Promise during synchronous parse. Use .parseAsync() instead.',
56+
);
57+
});
58+
});
59+
60+
describe('validateAsync', () => {
61+
it('should parse schema with async transform', async () => {
62+
vol.fromJSON({ 'package.json': '{ "name": "core" }' }, MEMFS_VOLUME);
63+
const projectNameSchema = z
64+
.string()
65+
.optional()
66+
.transform(
67+
async name =>
68+
name || JSON.parse(await readFile('package.json', 'utf8')).name,
69+
)
70+
.meta({ title: 'ProjectName' });
71+
72+
await expect(validateAsync(projectNameSchema, undefined)).resolves.toBe(
73+
'core',
74+
);
75+
});
76+
77+
it('should parse schema with async refinement', async () => {
78+
vol.fromJSON({ 'package.json': '{}' }, MEMFS_VOLUME);
79+
const filePathSchema = z
80+
.string()
81+
.refine(
82+
file =>
83+
stat(file)
84+
.then(stats => stats.isFile())
85+
.catch(() => false),
86+
{ error: 'File does not exist' },
87+
)
88+
.transform(file => path.resolve(process.cwd(), file))
89+
.meta({ title: 'FilePath' });
90+
91+
await expect(validateAsync(filePathSchema, 'package.json')).resolves.toBe(
92+
path.join(process.cwd(), 'package.json'),
93+
);
94+
});
95+
96+
it('should reject with formatted error if async schema is invalid', async () => {
97+
vol.fromJSON({}, MEMFS_VOLUME);
98+
const filePathSchema = z
99+
.string()
100+
.refine(
101+
file =>
102+
stat(file)
103+
.then(stats => stats.isFile())
104+
.catch(() => false),
105+
{ error: 'File does not exist' },
106+
)
107+
.meta({ title: 'FilePath' });
108+
109+
await expect(validateAsync(filePathSchema, 'package.json')).rejects.toThrow(
110+
`Invalid ${ansis.bold('FilePath')}\n✖ File does not exist`,
111+
);
112+
});
40113
});
41114

42115
describe('SchemaValidationError', () => {

0 commit comments

Comments
 (0)