From e9adc3d23f0ee796cc6db25a7bd74a1eda09c40e Mon Sep 17 00:00:00 2001
From: Sergei Zharinov <zharinov@users.noreply.github.com>
Date: Mon, 17 Apr 2023 11:01:23 +0300
Subject: [PATCH] feat(schema): Better utility for JSON parsing (#21536)

---
 lib/util/schema-utils.spec.ts | 166 +++++++++++++++++++++++++---------
 lib/util/schema-utils.ts      |  37 ++++----
 2 files changed, 139 insertions(+), 64 deletions(-)

diff --git a/lib/util/schema-utils.spec.ts b/lib/util/schema-utils.spec.ts
index f8ae991b26..9d1dfe47d3 100644
--- a/lib/util/schema-utils.spec.ts
+++ b/lib/util/schema-utils.spec.ts
@@ -1,10 +1,10 @@
 import { z } from 'zod';
 import {
+  Json,
+  Json5,
   looseArray,
   looseRecord,
   looseValue,
-  parseJson,
-  safeParseJson,
 } from './schema-utils';
 
 describe('util/schema-utils', () => {
@@ -99,57 +99,135 @@ describe('util/schema-utils', () => {
     });
   });
 
-  describe('parseJson', () => {
+  describe('Json', () => {
     it('parses json', () => {
-      const res = parseJson('{"foo": "bar"}', z.object({ foo: z.string() }));
-      expect(res).toEqual({ foo: 'bar' });
-    });
+      const Schema = Json.pipe(z.object({ foo: z.literal('bar') }));
+
+      expect(Schema.parse('{"foo": "bar"}')).toEqual({ foo: 'bar' });
+
+      expect(Schema.safeParse(42)).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Expected string, received number',
+              code: 'invalid_type',
+              expected: 'string',
+              received: 'number',
+              path: [],
+            },
+          ],
+        },
+        success: false,
+      });
 
-    it('throws on invalid json', () => {
-      expect(() =>
-        parseJson('{"foo": "bar"', z.object({ foo: z.string() }))
-      ).toThrow(SyntaxError);
-    });
+      expect(Schema.safeParse('{"foo": "foo"}')).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Invalid literal value, expected "bar"',
+              code: 'invalid_literal',
+              expected: 'bar',
+              received: 'foo',
+              path: ['foo'],
+            },
+          ],
+        },
+        success: false,
+      });
 
-    it('throws on invalid schema', () => {
-      expect(() =>
-        parseJson('{"foo": "bar"}', z.object({ foo: z.number() }))
-      ).toThrow(z.ZodError);
-    });
-  });
+      expect(Schema.safeParse('["foo", "bar"]')).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Expected object, received array',
+              code: 'invalid_type',
+              expected: 'object',
+              received: 'array',
+              path: [],
+            },
+          ],
+        },
+        success: false,
+      });
 
-  describe('safeParseJson', () => {
-    it('parses json', () => {
-      const res = safeParseJson(
-        '{"foo": "bar"}',
-        z.object({ foo: z.string() })
-      );
-      expect(res).toEqual({ foo: 'bar' });
+      expect(Schema.safeParse('{{{}}}')).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Invalid JSON',
+              code: 'custom',
+              path: [],
+            },
+          ],
+        },
+        success: false,
+      });
     });
+  });
 
-    it('returns null on invalid json', () => {
-      const res = safeParseJson('{"foo": "bar"', z.object({ foo: z.string() }));
-      expect(res).toBeNull();
-    });
+  describe('Json5', () => {
+    it('parses JSON5', () => {
+      const Schema = Json5.pipe(z.object({ foo: z.literal('bar') }));
+
+      expect(Schema.parse('{"foo": "bar"}')).toEqual({ foo: 'bar' });
+
+      expect(Schema.safeParse(42)).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Expected string, received number',
+              code: 'invalid_type',
+              expected: 'string',
+              received: 'number',
+              path: [],
+            },
+          ],
+        },
+        success: false,
+      });
 
-    it('returns null on invalid schema', () => {
-      const res = safeParseJson(
-        '{"foo": "bar"}',
-        z.object({ foo: z.number() })
-      );
-      expect(res).toBeNull();
-    });
+      expect(Schema.safeParse('{"foo": "foo"}')).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Invalid literal value, expected "bar"',
+              code: 'invalid_literal',
+              expected: 'bar',
+              received: 'foo',
+              path: ['foo'],
+            },
+          ],
+        },
+        success: false,
+      });
 
-    it('runs callback on invalid json', () => {
-      const callback = jest.fn();
-      safeParseJson('{"foo": "bar"', z.object({ foo: z.string() }), callback);
-      expect(callback).toHaveBeenCalledWith(expect.any(SyntaxError));
-    });
+      expect(Schema.safeParse('["foo", "bar"]')).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Expected object, received array',
+              code: 'invalid_type',
+              expected: 'object',
+              received: 'array',
+              path: [],
+            },
+          ],
+        },
+        success: false,
+      });
 
-    it('runs callback on invalid schema', () => {
-      const callback = jest.fn();
-      safeParseJson('{"foo": "bar"}', z.object({ foo: z.number() }), callback);
-      expect(callback).toHaveBeenCalledWith(expect.any(z.ZodError));
+      expect(Schema.safeParse('{{{}}}')).toMatchObject({
+        error: {
+          issues: [
+            {
+              message: 'Invalid JSON5',
+              code: 'custom',
+              path: [],
+            },
+          ],
+        },
+        success: false,
+      });
     });
   });
 });
diff --git a/lib/util/schema-utils.ts b/lib/util/schema-utils.ts
index d8996cb2a9..6473f0d6db 100644
--- a/lib/util/schema-utils.ts
+++ b/lib/util/schema-utils.ts
@@ -1,3 +1,5 @@
+import JSON5 from 'json5';
+import type { JsonValue } from 'type-fest';
 import { z } from 'zod';
 
 export function looseArray<T extends z.ZodTypeAny>(
@@ -91,26 +93,21 @@ export function looseValue<T, U extends z.ZodTypeDef, V>(
   return schemaWithFallback;
 }
 
-export function parseJson<
-  T = unknown,
-  Schema extends z.ZodType<T> = z.ZodType<T>
->(input: string, schema: Schema): z.infer<Schema> {
-  const parsed = JSON.parse(input);
-  return schema.parse(parsed);
-}
+export const Json = z.string().transform((str, ctx): JsonValue => {
+  try {
+    return JSON.parse(str);
+  } catch (e) {
+    ctx.addIssue({ code: 'custom', message: 'Invalid JSON' });
+    return z.NEVER;
+  }
+});
+type Json = z.infer<typeof Json>;
 
-export function safeParseJson<
-  T = unknown,
-  Schema extends z.ZodType<T> = z.ZodType<T>
->(
-  input: string,
-  schema: Schema,
-  catchCallback?: (e: SyntaxError | z.ZodError) => void
-): z.infer<Schema> | null {
+export const Json5 = z.string().transform((str, ctx): JsonValue => {
   try {
-    return parseJson(input, schema);
-  } catch (err) {
-    catchCallback?.(err);
-    return null;
+    return JSON5.parse(str);
+  } catch (e) {
+    ctx.addIssue({ code: 'custom', message: 'Invalid JSON5' });
+    return z.NEVER;
   }
-}
+});
-- 
GitLab