diff --git a/lib/util/schema.spec.ts b/lib/util/schema.spec.ts index 1b15920c73dcca28257856b3663c25c3bf07d53a..a8c4221144cd5a29165d08f9079e8b9bc24271c2 100644 --- a/lib/util/schema.spec.ts +++ b/lib/util/schema.spec.ts @@ -81,4 +81,74 @@ describe('util/schema', () => { ); }); }); + + describe('looseArray', () => { + it('parses array', () => { + const s = schema.looseArray(z.string()); + expect(s.parse(['foo', 'bar'])).toEqual(['foo', 'bar']); + }); + + it('handles non-array', () => { + const s = schema.looseArray(z.string()); + expect(s.parse({ foo: 'bar' })).toEqual([]); + }); + + it('drops wrong items', () => { + const s = schema.looseArray(z.string()); + expect(s.parse(['foo', 123, null, undefined, []])).toEqual(['foo']); + }); + + it('runs callback for wrong elements', () => { + let called = false; + const s = schema.looseArray(z.string(), () => { + called = true; + }); + expect(s.parse(['foo', 123, 'bar'])).toEqual(['foo', 'bar']); + expect(called).toBeTrue(); + }); + + it('runs callback for non-array', () => { + let called = false; + const s = schema.looseArray(z.string(), () => { + called = true; + }); + expect(s.parse('foobar')).toEqual([]); + expect(called).toBeTrue(); + }); + }); + + describe('looseRecord', () => { + it('parses record', () => { + const s = schema.looseRecord(z.string()); + expect(s.parse({ foo: 'bar' })).toEqual({ foo: 'bar' }); + }); + + it('handles non-record', () => { + const s = schema.looseRecord(z.string()); + expect(s.parse(['foo', 'bar'])).toEqual({}); + }); + + it('drops wrong items', () => { + const s = schema.looseRecord(z.string()); + expect(s.parse({ foo: 'foo', bar: 123 })).toEqual({ foo: 'foo' }); + }); + + it('runs callback for wrong elements', () => { + let called = false; + const s = schema.looseRecord(z.string(), () => { + called = true; + }); + expect(s.parse({ foo: 'foo', bar: 123 })).toEqual({ foo: 'foo' }); + expect(called).toBeTrue(); + }); + + it('runs callback for non-record', () => { + let called = false; + const s = schema.looseRecord(z.string(), () => { + called = true; + }); + expect(s.parse('foobar')).toEqual({}); + expect(called).toBeTrue(); + }); + }); }); diff --git a/lib/util/schema.ts b/lib/util/schema.ts index 8e874cb957ed98bca50fc53df68293625378b837..7b0756def41bea71eed2e0a4b03411ecf95f5a15 100644 --- a/lib/util/schema.ts +++ b/lib/util/schema.ts @@ -1,5 +1,6 @@ +import is from '@sindresorhus/is'; import hasha from 'hasha'; -import type { z } from 'zod'; +import { z } from 'zod'; import { logger } from '../logger'; import * as memCache from './cache/memory'; import { safeStringify } from './stringify'; @@ -65,3 +66,80 @@ export function match<T extends z.ZodSchema>( return true; } + +export function looseArray<T extends z.ZodTypeAny>( + schema: T, + catchCallback?: () => void +): z.ZodEffects< + z.ZodCatch<z.ZodArray<z.ZodCatch<z.ZodNullable<T>>, 'many'>>, + z.TypeOf<T>[], + unknown +> { + type Elem = z.infer<T>; + + const nullableSchema = schema.nullable().catch( + catchCallback + ? () => { + catchCallback(); + return null; + } + : null + ); + + const arrayOfNullables = z.array(nullableSchema); + + const arrayWithFallback = catchCallback + ? arrayOfNullables.catch(() => { + catchCallback(); + return []; + }) + : arrayOfNullables.catch([]); + + const filteredArray = arrayWithFallback.transform((xs) => + xs.filter((x): x is Elem => !is.null_(x)) + ); + + return filteredArray; +} + +export function looseRecord<T extends z.ZodTypeAny>( + schema: T, + catchCallback?: () => void +): z.ZodEffects< + z.ZodCatch<z.ZodRecord<z.ZodString, z.ZodCatch<z.ZodNullable<T>>>>, + Record<string, z.TypeOf<T>>, + unknown +> { + type Elem = z.infer<T>; + + const nullableSchema = schema.nullable().catch( + catchCallback + ? () => { + catchCallback(); + return null; + } + : null + ); + + const recordOfNullables = z.record(nullableSchema); + + const recordWithFallback = catchCallback + ? recordOfNullables.catch(() => { + catchCallback(); + return {}; + }) + : recordOfNullables.catch({}); + + const filteredRecord = recordWithFallback.transform( + (rec): Record<string, Elem> => { + for (const key of Object.keys(rec)) { + if (is.null_(rec[key])) { + delete rec[key]; + } + } + return rec; + } + ); + + return filteredRecord; +}