More user-friendly (and developer-friendly) zod error reporting (#12)

Show each error message from zod and include an excerpt of the data
around the failing path if possible
This commit is contained in:
Lars Strojny 2022-11-09 21:36:23 +01:00 committed by GitHub
parent 0735327b7c
commit 71e45a3d8c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 103 additions and 5 deletions

View file

@ -1,6 +1,6 @@
import type { HapDiscover } from './api' import type { HapDiscover } from './api'
import { HAPNodeJSClient } from 'hap-node-client' import { HAPNodeJSClient } from 'hap-node-client'
import { Device, DeviceBoundary } from '../../boundaries/hap' import { Device, DeviceBoundary, checkBoundary } from '../../boundaries'
import { Logger } from 'homebridge' import { Logger } from 'homebridge'
import z from 'zod' import z from 'zod'
@ -29,9 +29,9 @@ function startDiscovery(logger: Logger, config: HapConfig, resolve: ResolveFunc,
try { try {
const devices: Device[] = [] const devices: Device[] = []
for (const device of MaybeDevices.parse(deviceData)) { for (const device of checkBoundary(MaybeDevices, deviceData)) {
try { try {
devices.push(DeviceBoundary.parse(device)) devices.push(checkBoundary(DeviceBoundary, device))
} catch (e) { } catch (e) {
logger.error('Boundary check for device data failed %o %s', e, JSON.stringify(device, null, 4)) logger.error('Boundary check for device data failed %o %s', e, JSON.stringify(device, null, 4))
} }

52
src/boundaries/checker.ts Normal file
View file

@ -0,0 +1,52 @@
import z from 'zod'
type Path = (string | number)[]
function resolvePath(data: unknown, path: Path): { resolvedValue: string; resolvedPath: Path } {
const resolvedPath: Path = []
for (const element of path) {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (data[element] != null) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
data = data[element]
resolvedPath.push(element)
} else {
break
}
} catch (e) {
break
}
}
return { resolvedValue: JSON.stringify(data), resolvedPath }
}
function formatPath(path: Path): string {
return path.map((element) => (typeof element === 'number' ? `[${element}]` : element)).join('.')
}
export function checkBoundary<Output, T extends z.ZodType<Output>>(type: T, data: unknown): z.infer<T> {
const result = type.safeParse(data)
if (result.success) {
return result.data
}
const message =
'Error checking type. Details: ' +
result.error.issues
.map((issue) => ({ ...issue, ...resolvePath(data, issue.path) }))
.map(
(issue) =>
`[${issue.code}] ${issue.message}${
issue.path.length > 0 ? ` at path "${formatPath(issue.path)}"` : ''
} (data${
issue.resolvedPath.length > 0 ? ` at resolved path "${formatPath(issue.resolvedPath)}"` : ''
} is "${issue.resolvedValue}")`,
)
.join(' | ')
throw new Error(message)
}

View file

@ -1,6 +1,7 @@
import z from 'zod' import z from 'zod'
import { ConfigBoundary as BaseConfigBoundary } from './config' import { ConfigBoundary as BaseConfigBoundary } from './config'
export * from './checker'
export * from './hap' export * from './hap'
export const ConfigBoundary = z.intersection(BaseConfigBoundary, z.object({ platform: z.string() })) export const ConfigBoundary = z.intersection(BaseConfigBoundary, z.object({ platform: z.string() }))
export type Config = z.infer<typeof ConfigBoundary> export type Config = z.infer<typeof ConfigBoundary>

View file

@ -5,7 +5,7 @@ import { discover } from './adapters/discovery/hap_node_js_client'
import { serve } from './adapters/http/fastify' import { serve } from './adapters/http/fastify'
import { HttpServerController } from './adapters/http/api' import { HttpServerController } from './adapters/http/api'
import { PrometheusServer } from './prometheus' import { PrometheusServer } from './prometheus'
import { Config, ConfigBoundary } from './boundaries' import { Config, ConfigBoundary, checkBoundary } from './boundaries'
export class PrometheusExporterPlatform implements IndependentPlatformPlugin { export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
private readonly httpServer: PrometheusServer private readonly httpServer: PrometheusServer
@ -15,7 +15,7 @@ export class PrometheusExporterPlatform implements IndependentPlatformPlugin {
constructor(public readonly log: Logger, config: PlatformConfig, public readonly api: API) { constructor(public readonly log: Logger, config: PlatformConfig, public readonly api: API) {
this.log.debug('Initializing platform %s', config.platform) this.log.debug('Initializing platform %s', config.platform)
this.config = ConfigBoundary.parse(config) this.config = checkBoundary(ConfigBoundary, config)
this.log.debug('Configuration parsed', this.config) this.log.debug('Configuration parsed', this.config)

View file

@ -0,0 +1,45 @@
import { describe, expect, test } from '@jest/globals'
import z from 'zod'
import { checkBoundary } from '../../src/boundaries'
const TestBoundary = z.object({
member: z.literal('something'),
anotherMember: z.optional(z.literal('something else')),
yetAnotherMember: z.optional(
z.array(
z.object({
member: z.literal('member'),
}),
),
),
})
describe('Test boundary checker', () => {
test('Returns checked data after successful check', () => {
const result = checkBoundary(TestBoundary, { member: 'something' })
expect(result).toEqual({ member: 'something' })
})
test('Returns error and insightful error message on failing check for simple string', () => {
expect(() => checkBoundary(z.string(), 123)).toThrow(
'[invalid_type] Expected string, received number (data is "123")',
)
})
test('Returns error and insightful error message on failing check for nested object', () => {
expect(() =>
checkBoundary(TestBoundary, {
member: 'something else',
anotherMember: 'unexpected',
yetAnotherMember: [{ foo: 123 }],
}),
).toThrow(
[
'[invalid_literal] Invalid literal value, expected "something" at path "member" (data at resolved path "member" is ""something else"") | ',
'[invalid_literal] Invalid literal value, expected "something else" at path "anotherMember" (data at resolved path "anotherMember" is ""unexpected"") | ',
'[invalid_literal] Invalid literal value, expected "member" at path "yetAnotherMember.[0].member" (data at resolved path "yetAnotherMember.[0]" is "{"foo":123}")',
].join(''),
)
})
})