diff --git a/lib/modules/datasource/rubygems/index.ts b/lib/modules/datasource/rubygems/index.ts index 45431608438171..246658368a6199 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 { 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 bcc0037e82db05..1c0bdcc43bee7b 100644 --- a/lib/util/http/index.ts +++ b/lib/util/http/index.ts @@ -372,7 +372,7 @@ export class Http { ): AsyncResult { const args = this.resolveArgs(arg1, arg2, arg3); return Result.wrap(this.requestJson('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 1d213025d3c771..319ff8217699a1 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 68fae331dbb1a3..526cf6b8898984 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 { @@ -20,6 +21,45 @@ interface Err { type Res = Ok | Err; +function isZodResult( + input: unknown +): input is SafeParseReturnType> { + 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: SafeParseReturnType> +): Result> { + 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 = Exclude< + NonNullable, + SafeParseReturnType> | Promise +>; + /** * Class for representing a result that can fail. * @@ -72,19 +112,25 @@ export class Result { * * ``` */ - static wrap(callback: () => NonNullable): Result; + static wrap( + zodResult: SafeParseReturnType> + ): Result>; + static wrap(callback: () => RawValue): Result; static wrap( promise: Promise> ): AsyncResult; - static wrap( - promise: Promise> - ): AsyncResult; - static wrap( + static wrap(promise: Promise>): AsyncResult; + static wrap( input: - | (() => NonNullable) + | SafeParseReturnType> + | (() => RawValue) | Promise> - | Promise> - ): Result | AsyncResult { + | Promise> + ): Result> | Result | AsyncResult { + if (isZodResult(input)) { + return fromZodResult(input); + } + if (input instanceof Promise) { return AsyncResult.wrap(input as never); } @@ -244,6 +290,8 @@ export class Result { * 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 { transform( fn: (value: NonNullable) => AsyncResult ): AsyncResult; + transform( + fn: (value: NonNullable) => SafeParseReturnType> + ): Result>; + transform( + fn: ( + value: NonNullable + ) => Promise>> + ): AsyncResult>; transform( fn: (value: NonNullable) => Promise> ): AsyncResult; transform( - fn: (value: NonNullable) => Promise> + fn: (value: NonNullable) => Promise> ): AsyncResult; - transform(fn: (value: NonNullable) => NonNullable): Result; - transform( + transform(fn: (value: NonNullable) => RawValue): Result; + transform( fn: ( value: NonNullable ) => | Result | AsyncResult + | SafeParseReturnType> + | Promise>> | Promise> - | Promise> - | NonNullable - ): Result | AsyncResult { + | Promise> + | RawValue + ): + | Result> + | AsyncResult> { if (!this.res.ok) { return Result.err(this.res.err); } @@ -299,6 +359,10 @@ export class Result { return result; } + if (isZodResult(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 implements PromiseLike> { return new AsyncResult(Promise.resolve(Result.err(err))); } - static wrap( - promise: Promise> | Promise>, + static wrap( + promise: + | Promise>> + | Promise> + | Promise>, onErr?: (err: NonNullable) => Result ): AsyncResult { return new AsyncResult( @@ -393,6 +460,11 @@ export class AsyncResult implements PromiseLike> { if (value instanceof Result) { return value; } + + if (isZodResult(value)) { + return fromZodResult(value); + } + return Result.ok(value); }) .catch((err) => { @@ -469,6 +541,8 @@ export class AsyncResult implements PromiseLike> { * 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 implements PromiseLike> { transform( fn: (value: NonNullable) => AsyncResult ): AsyncResult; + transform( + fn: (value: NonNullable) => SafeParseReturnType> + ): AsyncResult>; + transform( + fn: ( + value: NonNullable + ) => Promise>> + ): AsyncResult>; transform( fn: (value: NonNullable) => Promise> ): AsyncResult; transform( - fn: (value: NonNullable) => Promise> - ): AsyncResult; - transform( - fn: (value: NonNullable) => NonNullable + fn: (value: NonNullable) => Promise> ): AsyncResult; - transform( + transform(fn: (value: NonNullable) => RawValue): AsyncResult; + transform( fn: ( value: NonNullable ) => | Result | AsyncResult + | SafeParseReturnType> + | Promise>> | Promise> - | Promise> - | NonNullable - ): AsyncResult { + | Promise> + | RawValue + ): AsyncResult> { return new AsyncResult( this.asyncResult .then((oldResult) => { @@ -523,6 +605,10 @@ export class AsyncResult implements PromiseLike> { return result; } + if (isZodResult(result)) { + return fromZodResult(result); + } + if (result instanceof Promise) { return AsyncResult.wrap(result, (err) => { logger.warn(