Skip to content

Commit

Permalink
feat(result): Add helper for Zod schema parsing (#23992)
Browse files Browse the repository at this point in the history
  • Loading branch information
zharinov authored Aug 21, 2023
1 parent be8a9f7 commit 31b3f28
Show file tree
Hide file tree
Showing 4 changed files with 160 additions and 64 deletions.
2 changes: 1 addition & 1 deletion lib/modules/datasource/cdnjs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export class CdnJsDatasource extends Datasource {
override readonly caching = true;

async getReleases(config: GetReleasesConfig): Promise<ReleaseResult | null> {
const result = Result.wrap(ReleasesConfig.safeParse(config))
const result = Result.parse(ReleasesConfig, config)
.transform(({ packageName, registryUrl }) => {
const [library] = packageName.split('/');
const assetName = packageName.replace(`${library}/`, '');
Expand Down
13 changes: 6 additions & 7 deletions lib/modules/datasource/rubygems/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ export class RubyGemsDatasource extends Datasource {
packageName: string
): AsyncResult<ReleaseResult, Error | ZodError> {
const url = joinUrlParts(registryUrl, '/info', packageName);
return Result.wrap(this.http.get(url)).transform(({ body }) =>
GemInfo.safeParse(body)
);
return Result.wrap(this.http.get(url))
.transform(({ body }) => body)
.parse(GemInfo);
}

private getReleasesViaDeprecatedAPI(
Expand All @@ -114,9 +114,8 @@ export class RubyGemsDatasource extends Datasource {
const query = getQueryString({ gems: packageName });
const url = `${path}?${query}`;
const bufPromise = this.http.getBuffer(url);
return Result.wrap(bufPromise).transform(({ body }) => {
const data = Marshal.parse(body);
return MarshalledVersionInfo.safeParse(data);
});
return Result.wrap(bufPromise).transform(({ body }) =>
MarshalledVersionInfo.safeParse(Marshal.parse(body))
);
}
}
97 changes: 78 additions & 19 deletions lib/util/result.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ZodError, z } from 'zod';
import { z } from 'zod';
import { logger } from '../../test/util';
import { AsyncResult, Result } from './result';

Expand Down Expand Up @@ -99,13 +99,6 @@ describe('util/result', () => {
})
);
});

it('wraps Zod schema', () => {
const schema = z.string().transform((x) => x.toUpperCase());
const parse = Result.wrapSchema(schema);
expect(parse('foo')).toEqual(Result.ok('FOO'));
expect(parse(42)).toMatchObject(Result.err(expect.any(ZodError)));
});
});

describe('Unwrapping', () => {
Expand Down Expand Up @@ -224,6 +217,56 @@ describe('util/result', () => {
expect(result).toEqual(Result._uncaught('oops'));
});
});

describe('Parsing', () => {
it('parses Zod schema', () => {
const schema = z
.string()
.transform((x) => x.toUpperCase())
.nullish();

expect(Result.parse(schema, 'foo')).toEqual(Result.ok('FOO'));

expect(Result.parse(schema, 42).unwrap()).toMatchObject({
err: { issues: [{ message: 'Expected string, received number' }] },
});

expect(Result.parse(schema, undefined).unwrap()).toMatchObject({
err: {
issues: [
{
message: `Result can't accept nullish values, but input was parsed by Zod schema to undefined`,
},
],
},
});

expect(Result.parse(schema, null).unwrap()).toMatchObject({
err: {
issues: [
{
message: `Result can't accept nullish values, but input was parsed by Zod schema to null`,
},
],
},
});
});

it('parses Zod schema by piping from Result', () => {
const schema = z
.string()
.transform((x) => x.toUpperCase())
.nullish();

expect(Result.ok('foo').parse(schema)).toEqual(Result.ok('FOO'));

expect(Result.ok(42).parse(schema).unwrap()).toMatchObject({
err: { issues: [{ message: 'Expected string, received number' }] },
});

expect(Result.err('oops').parse(schema)).toEqual(Result.err('oops'));
});
});
});

describe('AsyncResult', () => {
Expand Down Expand Up @@ -281,17 +324,6 @@ describe('util/result', () => {
const res = Result.wrapNullable(Promise.reject('oops'), 'nullable');
await expect(res).resolves.toEqual(Result.err('oops'));
});

it('wraps Zod async schema', async () => {
const schema = z
.string()
.transform((x) => Promise.resolve(x.toUpperCase()));
const parse = Result.wrapSchemaAsync(schema);
await expect(parse('foo')).resolves.toEqual(Result.ok('FOO'));
await expect(parse(42)).resolves.toMatchObject(
Result.err(expect.any(ZodError))
);
});
});

describe('Unwrapping', () => {
Expand Down Expand Up @@ -520,4 +552,31 @@ describe('util/result', () => {
});
});
});

describe('Parsing', () => {
it('parses Zod schema by piping from AsyncResult', async () => {
const schema = z
.string()
.transform((x) => x.toUpperCase())
.nullish();

expect(await AsyncResult.ok('foo').parse(schema)).toEqual(
Result.ok('FOO')
);

expect(await AsyncResult.ok(42).parse(schema).unwrap()).toMatchObject({
err: { issues: [{ message: 'Expected string, received number' }] },
});
});

it('handles uncaught error thrown in the steps before parsing', async () => {
const res = await AsyncResult.ok(42)
.transform(async (): Promise<number> => {
await Promise.resolve();
throw 'oops';
})
.parse(z.number().transform((x) => x + 1));
expect(res).toEqual(Result._uncaught('oops'));
});
});
});
112 changes: 75 additions & 37 deletions lib/util/result.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SafeParseReturnType, ZodError, ZodType, ZodTypeDef } from 'zod';
import { SafeParseReturnType, ZodError, ZodType, ZodTypeDef, z } from 'zod';
import { logger } from '../logger';

type Val = NonNullable<unknown>;
Expand Down Expand Up @@ -54,14 +54,6 @@ function fromZodResult<ZodInput, ZodOutput extends Val>(
return input.success ? Result.ok(input.data) : Result.err(input.error);
}

type SchemaParseFn<T extends Val, Input = unknown> = (
input: unknown
) => Result<T, ZodError<Input>>;

type SchemaAsyncParseFn<T extends Val, Input = unknown> = (
input: unknown
) => AsyncResult<T, ZodError<Input>>;

/**
* All non-nullable values that also are not Promises nor Zod results.
* It's useful for restricting Zod results to not return `null` or `undefined`.
Expand Down Expand Up @@ -312,34 +304,6 @@ export class Result<T extends Val, E extends Val = Error> {
return fromNullable(input, errForNull, errForUndefined);
}

/**
* Wraps a Zod schema and returns a parse function that returns a `Result`.
*/
static wrapSchema<
T extends Val,
Schema extends ZodType<T, ZodTypeDef, Input>,
Input = unknown
>(schema: Schema): SchemaParseFn<T, Input> {
return (input) => {
const result = schema.safeParse(input);
return fromZodResult(result);
};
}

/**
* Wraps a Zod schema and returns a parse function that returns an `AsyncResult`.
*/
static wrapSchemaAsync<
T extends Val,
Schema extends ZodType<T, ZodTypeDef, Input>,
Input = unknown
>(schema: Schema): SchemaAsyncParseFn<T, Input> {
return (input) => {
const result = schema.safeParseAsync(input);
return AsyncResult.wrap(result);
};
}

/**
* Returns a discriminated union for type-safe consumption of the result.
* When `fallback` is provided, the error is discarded and value is returned directly.
Expand Down Expand Up @@ -520,6 +484,63 @@ export class Result<T extends Val, E extends Val = Error> {
return Result._uncaught(err);
}
}

/**
* Given a `schema` and `input`, returns a `Result` with `val` being the parsed value.
* Additionally, `null` and `undefined` values are converted into Zod error.
*/
static parse<
T,
Schema extends ZodType<T, ZodTypeDef, Input>,
Input = unknown
>(
schema: Schema,
input: unknown
): Result<NonNullable<z.infer<Schema>>, ZodError<Input>> {
const parseResult = schema
.transform((result, ctx): NonNullable<T> => {
if (result === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Result can't accept nullish values, but input was parsed by Zod schema to undefined`,
});
return z.NEVER;
}

if (result === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Result can't accept nullish values, but input was parsed by Zod schema to null`,
});
return z.NEVER;
}

return result;
})
.safeParse(input);

return fromZodResult(parseResult);
}

/**
* Given a `schema`, returns a `Result` with `val` being the parsed value.
* Additionally, `null` and `undefined` values are converted into Zod error.
*/
parse<T, Schema extends ZodType<T, ZodTypeDef, Input>, Input = unknown>(
schema: Schema
): Result<NonNullable<z.infer<Schema>>, E | ZodError<Input>> {
if (this.res.ok) {
return Result.parse(schema, this.res.val);
}

const err = this.res.err;

if (this.res._uncaught) {
return Result._uncaught(err);
}

return Result.err(err);
}
}

/**
Expand Down Expand Up @@ -752,4 +773,21 @@ export class AsyncResult<T extends Val, E extends Val>
);
return AsyncResult.wrap(caughtAsyncResult);
}

/**
* Given a `schema`, returns a `Result` with `val` being the parsed value.
* Additionally, `null` and `undefined` values are converted into Zod error.
*/
parse<T, Schema extends ZodType<T, ZodTypeDef, Input>, Input = unknown>(
schema: Schema
): AsyncResult<NonNullable<z.infer<Schema>>, E | ZodError<Input>> {
return new AsyncResult(
this.asyncResult
.then((oldResult) => oldResult.parse(schema))
.catch(
/* istanbul ignore next: should never happen */
(err) => Result._uncaught(err)
)
);
}
}

0 comments on commit 31b3f28

Please sign in to comment.