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