From 31b3f28d5daf82ea903aee8cae2fce9f13a4e7dd Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Mon, 21 Aug 2023 18:55:34 +0300 Subject: [PATCH] feat(result): Add helper for Zod schema parsing (#23992) --- lib/modules/datasource/cdnjs/index.ts | 2 +- lib/modules/datasource/rubygems/index.ts | 13 ++- lib/util/result.spec.ts | 97 ++++++++++++++++---- lib/util/result.ts | 112 +++++++++++++++-------- 4 files changed, 160 insertions(+), 64 deletions(-) diff --git a/lib/modules/datasource/cdnjs/index.ts b/lib/modules/datasource/cdnjs/index.ts index 95f4952b615760..30f22018a8bb0a 100644 --- a/lib/modules/datasource/cdnjs/index.ts +++ b/lib/modules/datasource/cdnjs/index.ts @@ -40,7 +40,7 @@ export class CdnJsDatasource extends Datasource { override readonly caching = true; async getReleases(config: GetReleasesConfig): Promise { - 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}/`, ''); diff --git a/lib/modules/datasource/rubygems/index.ts b/lib/modules/datasource/rubygems/index.ts index a2a69ec0c62e3d..2f44fc6ab5965f 100644 --- a/lib/modules/datasource/rubygems/index.ts +++ b/lib/modules/datasource/rubygems/index.ts @@ -101,9 +101,9 @@ export class RubyGemsDatasource extends Datasource { packageName: string ): AsyncResult { 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( @@ -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)) + ); } } diff --git a/lib/util/result.spec.ts b/lib/util/result.spec.ts index 87e3e8c27d4c8f..5d108ff1190a6e 100644 --- a/lib/util/result.spec.ts +++ b/lib/util/result.spec.ts @@ -1,4 +1,4 @@ -import { ZodError, z } from 'zod'; +import { z } from 'zod'; import { logger } from '../../test/util'; import { AsyncResult, Result } from './result'; @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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 => { + await Promise.resolve(); + throw 'oops'; + }) + .parse(z.number().transform((x) => x + 1)); + expect(res).toEqual(Result._uncaught('oops')); + }); + }); }); diff --git a/lib/util/result.ts b/lib/util/result.ts index 14c9b4ef99e39e..59132bb35779ad 100644 --- a/lib/util/result.ts +++ b/lib/util/result.ts @@ -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; @@ -54,14 +54,6 @@ function fromZodResult( return input.success ? Result.ok(input.data) : Result.err(input.error); } -type SchemaParseFn = ( - input: unknown -) => Result>; - -type SchemaAsyncParseFn = ( - input: unknown -) => AsyncResult>; - /** * 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`. @@ -312,34 +304,6 @@ export class Result { 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, - Input = unknown - >(schema: Schema): SchemaParseFn { - 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, - Input = unknown - >(schema: Schema): SchemaAsyncParseFn { - 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. @@ -520,6 +484,63 @@ export class Result { 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, + Input = unknown + >( + schema: Schema, + input: unknown + ): Result>, ZodError> { + const parseResult = schema + .transform((result, ctx): NonNullable => { + 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, Input = unknown>( + schema: Schema + ): Result>, E | ZodError> { + 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); + } } /** @@ -752,4 +773,21 @@ export class AsyncResult ); 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, Input = unknown>( + schema: Schema + ): AsyncResult>, E | ZodError> { + return new AsyncResult( + this.asyncResult + .then((oldResult) => oldResult.parse(schema)) + .catch( + /* istanbul ignore next: should never happen */ + (err) => Result._uncaught(err) + ) + ); + } }