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