From 31b3f28d5daf82ea903aee8cae2fce9f13a4e7dd Mon Sep 17 00:00:00 2001
From: Sergei Zharinov <zharinov@users.noreply.github.com>
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 95f4952b61..30f22018a8 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<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}/`, '');
diff --git a/lib/modules/datasource/rubygems/index.ts b/lib/modules/datasource/rubygems/index.ts
index a2a69ec0c6..2f44fc6ab5 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<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(
@@ -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 87e3e8c27d..5d108ff119 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<number> => {
+          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 14c9b4ef99..59132bb357 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<unknown>;
@@ -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`.
@@ -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.
@@ -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);
+  }
 }
 
 /**
@@ -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)
+        )
+    );
+  }
 }
-- 
GitLab