From 674c6fca49da233684a6ae05b726202523d47f9b Mon Sep 17 00:00:00 2001 From: Sergei Zharinov <zharinov@users.noreply.github.com> Date: Thu, 27 Jul 2023 18:17:18 +0300 Subject: [PATCH] feat: Support Zod values in `Result` transforms (#23583) --- lib/modules/datasource/rubygems/index.ts | 12 +- lib/util/http/index.ts | 2 +- lib/util/result.spec.ts | 35 ++++++ lib/util/result.ts | 134 +++++++++++++++++++---- 4 files changed, 150 insertions(+), 33 deletions(-) diff --git a/lib/modules/datasource/rubygems/index.ts b/lib/modules/datasource/rubygems/index.ts index 4543160843..246658368a 100644 --- a/lib/modules/datasource/rubygems/index.ts +++ b/lib/modules/datasource/rubygems/index.ts @@ -101,10 +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 }) => { - const res = GemInfo.safeParse(body); - return res.success ? Result.ok(res.data) : Result.err(res.error); - }); + return Result.wrap(this.http.get(url)).transform(({ body }) => + GemInfo.safeParse(body) + ); } private getReleasesViaDeprecatedAPI( @@ -117,10 +116,7 @@ export class RubyGemsDatasource extends Datasource { const bufPromise = this.http.getBuffer(url); return Result.wrap(bufPromise).transform(({ body }) => { const data = Marshal.parse(body); - const releases = MarshalledVersionInfo.safeParse(data); - return releases.success - ? Result.ok(releases.data) - : Result.err(releases.error); + return MarshalledVersionInfo.safeParse(data); }); } } diff --git a/lib/util/http/index.ts b/lib/util/http/index.ts index bcc0037e82..1c0bdcc43b 100644 --- a/lib/util/http/index.ts +++ b/lib/util/http/index.ts @@ -372,7 +372,7 @@ export class Http<Opts extends HttpOptions = HttpOptions> { ): AsyncResult<ResT, SafeJsonError> { const args = this.resolveArgs<ResT>(arg1, arg2, arg3); return Result.wrap(this.requestJson<ResT>('get', args)).transform( - (response) => response.body + (response) => Result.ok(response.body) ); } diff --git a/lib/util/result.spec.ts b/lib/util/result.spec.ts index 1d213025d3..319ff82176 100644 --- a/lib/util/result.spec.ts +++ b/lib/util/result.spec.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import { logger } from '../../test/util'; import { AsyncResult, Result } from './result'; @@ -68,6 +69,18 @@ describe('util/result', () => { }, 'nullable'); expect(res).toEqual(Result.err('oops')); }); + + it('wraps zod parse result', () => { + const schema = z.string().transform((x) => x.toUpperCase()); + expect(Result.wrap(schema.safeParse('foo'))).toEqual(Result.ok('FOO')); + expect(Result.wrap(schema.safeParse(42))).toMatchObject( + Result.err({ + issues: [ + { code: 'invalid_type', expected: 'string', received: 'number' }, + ], + }) + ); + }); }); describe('Unwrapping', () => { @@ -149,6 +162,12 @@ describe('util/result', () => { 'Result: unhandled transform error' ); }); + + it('automatically converts zod values', () => { + const schema = z.string().transform((x) => x.toUpperCase()); + const res = Result.ok('foo').transform((x) => schema.safeParse(x)); + expect(res).toEqual(Result.ok('FOO')); + }); }); describe('Catch', () => { @@ -416,6 +435,22 @@ describe('util/result', () => { expect(res).toEqual(Result.ok('F-O-O')); }); + + it('asynchronously transforms Result to zod values', async () => { + const schema = z.string().transform((x) => x.toUpperCase()); + const res = await Result.ok('foo').transform((x) => + Promise.resolve(schema.safeParse(x)) + ); + expect(res).toEqual(Result.ok('FOO')); + }); + + it('transforms AsyncResult to zod values', async () => { + const schema = z.string().transform((x) => x.toUpperCase()); + const res = await AsyncResult.ok('foo').transform((x) => + schema.safeParse(x) + ); + expect(res).toEqual(Result.ok('FOO')); + }); }); describe('Catch', () => { diff --git a/lib/util/result.ts b/lib/util/result.ts index 68fae331db..526cf6b889 100644 --- a/lib/util/result.ts +++ b/lib/util/result.ts @@ -1,3 +1,4 @@ +import { SafeParseReturnType, ZodError } from 'zod'; import { logger } from '../logger'; interface Ok<T> { @@ -20,6 +21,45 @@ interface Err<E> { type Res<T, E> = Ok<T> | Err<E>; +function isZodResult<Input, Output>( + input: unknown +): input is SafeParseReturnType<Input, NonNullable<Output>> { + if ( + typeof input !== 'object' || + input === null || + Object.keys(input).length !== 2 || + !('success' in input) || + typeof input.success !== 'boolean' + ) { + return false; + } + + if (input.success) { + return ( + 'data' in input && + typeof input.data !== 'undefined' && + input.data !== null + ); + } else { + return 'error' in input && input.error instanceof ZodError; + } +} + +function fromZodResult<Input, Output>( + input: SafeParseReturnType<Input, NonNullable<Output>> +): Result<Output, ZodError<Input>> { + return input.success ? Result.ok(input.data) : Result.err(input.error); +} + +/** + * 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`. + */ +type RawValue<T> = Exclude< + NonNullable<T>, + SafeParseReturnType<unknown, NonNullable<T>> | Promise<unknown> +>; + /** * Class for representing a result that can fail. * @@ -72,19 +112,25 @@ export class Result<T, E = Error> { * * ``` */ - static wrap<T, E = Error>(callback: () => NonNullable<T>): Result<T, E>; + static wrap<T, Input = any>( + zodResult: SafeParseReturnType<Input, NonNullable<T>> + ): Result<T, ZodError<Input>>; + static wrap<T, E = Error>(callback: () => RawValue<T>): Result<T, E>; static wrap<T, E = Error, EE = never>( promise: Promise<Result<T, EE>> ): AsyncResult<T, E | EE>; - static wrap<T, E = Error>( - promise: Promise<NonNullable<T>> - ): AsyncResult<T, E>; - static wrap<T, E = Error, EE = never>( + static wrap<T, E = Error>(promise: Promise<RawValue<T>>): AsyncResult<T, E>; + static wrap<T, E = Error, EE = never, Input = any>( input: - | (() => NonNullable<T>) + | SafeParseReturnType<Input, NonNullable<T>> + | (() => RawValue<T>) | Promise<Result<T, EE>> - | Promise<NonNullable<T>> - ): Result<T, E | EE> | AsyncResult<T, E | EE> { + | Promise<RawValue<T>> + ): Result<T, ZodError<Input>> | Result<T, E | EE> | AsyncResult<T, E | EE> { + if (isZodResult<Input, T>(input)) { + return fromZodResult(input); + } + if (input instanceof Promise) { return AsyncResult.wrap(input as never); } @@ -244,6 +290,8 @@ export class Result<T, E = Error> { * Uncaught errors are logged and wrapped to `Result._uncaught()`, * which leads to re-throwing them in `unwrap()`. * + * Zod `.safeParse()` results are converted automatically. + * * ```ts * * // SYNC @@ -267,23 +315,35 @@ export class Result<T, E = Error> { transform<U, EE>( fn: (value: NonNullable<T>) => AsyncResult<U, E | EE> ): AsyncResult<U, E | EE>; + transform<U, Input = any>( + fn: (value: NonNullable<T>) => SafeParseReturnType<Input, NonNullable<U>> + ): Result<U, E | ZodError<Input>>; + transform<U, Input = any>( + fn: ( + value: NonNullable<T> + ) => Promise<SafeParseReturnType<Input, NonNullable<U>>> + ): AsyncResult<U, E | ZodError<Input>>; transform<U, EE>( fn: (value: NonNullable<T>) => Promise<Result<U, E | EE>> ): AsyncResult<U, E | EE>; transform<U>( - fn: (value: NonNullable<T>) => Promise<NonNullable<U>> + fn: (value: NonNullable<T>) => Promise<RawValue<U>> ): AsyncResult<U, E>; - transform<U>(fn: (value: NonNullable<T>) => NonNullable<U>): Result<U, E>; - transform<U, EE>( + transform<U>(fn: (value: NonNullable<T>) => RawValue<U>): Result<U, E>; + transform<U, EE, Input = any>( fn: ( value: NonNullable<T> ) => | Result<U, E | EE> | AsyncResult<U, E | EE> + | SafeParseReturnType<Input, NonNullable<U>> + | Promise<SafeParseReturnType<Input, NonNullable<U>>> | Promise<Result<U, E | EE>> - | Promise<NonNullable<U>> - | NonNullable<U> - ): Result<U, E | EE> | AsyncResult<U, E | EE> { + | Promise<RawValue<U>> + | RawValue<U> + ): + | Result<U, E | EE | ZodError<Input>> + | AsyncResult<U, E | EE | ZodError<Input>> { if (!this.res.ok) { return Result.err(this.res.err); } @@ -299,6 +359,10 @@ export class Result<T, E = Error> { return result; } + if (isZodResult<Input, U>(result)) { + return fromZodResult(result); + } + if (result instanceof Promise) { return AsyncResult.wrap(result, (err) => { logger.warn({ err }, 'Result: unhandled async transform error'); @@ -383,8 +447,11 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> { return new AsyncResult(Promise.resolve(Result.err(err))); } - static wrap<T, E = Error, EE = never>( - promise: Promise<Result<T, EE>> | Promise<NonNullable<T>>, + static wrap<T, E = Error, EE = never, Input = any>( + promise: + | Promise<SafeParseReturnType<Input, NonNullable<T>>> + | Promise<Result<T, EE>> + | Promise<RawValue<T>>, onErr?: (err: NonNullable<E>) => Result<T, E> ): AsyncResult<T, E | EE> { return new AsyncResult( @@ -393,6 +460,11 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> { if (value instanceof Result) { return value; } + + if (isZodResult<Input, T>(value)) { + return fromZodResult(value); + } + return Result.ok(value); }) .catch((err) => { @@ -469,6 +541,8 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> { * Uncaught errors are logged and wrapped to `Result._uncaught()`, * which leads to re-throwing them in `unwrap()`. * + * Zod `.safeParse()` results are converted automatically. + * * ```ts * * const { val, err } = await Result.wrap( @@ -485,25 +559,33 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> { transform<U, EE>( fn: (value: NonNullable<T>) => AsyncResult<U, E | EE> ): AsyncResult<U, E | EE>; + transform<U, Input = any>( + fn: (value: NonNullable<T>) => SafeParseReturnType<Input, NonNullable<U>> + ): AsyncResult<U, E | ZodError<Input>>; + transform<U, Input = any>( + fn: ( + value: NonNullable<T> + ) => Promise<SafeParseReturnType<Input, NonNullable<U>>> + ): AsyncResult<U, E | ZodError<Input>>; transform<U, EE>( fn: (value: NonNullable<T>) => Promise<Result<U, E | EE>> ): AsyncResult<U, E | EE>; transform<U>( - fn: (value: NonNullable<T>) => Promise<NonNullable<U>> - ): AsyncResult<U, E>; - transform<U>( - fn: (value: NonNullable<T>) => NonNullable<U> + fn: (value: NonNullable<T>) => Promise<RawValue<U>> ): AsyncResult<U, E>; - transform<U, EE>( + transform<U>(fn: (value: NonNullable<T>) => RawValue<U>): AsyncResult<U, E>; + transform<U, EE, Input = any>( fn: ( value: NonNullable<T> ) => | Result<U, E | EE> | AsyncResult<U, E | EE> + | SafeParseReturnType<Input, NonNullable<U>> + | Promise<SafeParseReturnType<Input, NonNullable<U>>> | Promise<Result<U, E | EE>> - | Promise<NonNullable<U>> - | NonNullable<U> - ): AsyncResult<U, E | EE> { + | Promise<RawValue<U>> + | RawValue<U> + ): AsyncResult<U, E | EE | ZodError<Input>> { return new AsyncResult( this.asyncResult .then((oldResult) => { @@ -523,6 +605,10 @@ export class AsyncResult<T, E> implements PromiseLike<Result<T, E>> { return result; } + if (isZodResult<Input, U>(result)) { + return fromZodResult(result); + } + if (result instanceof Promise) { return AsyncResult.wrap(result, (err) => { logger.warn( -- GitLab