File size: 2,731 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
/**
 * zod-backed wire validation. Every procedure in the contract validates its
 * input tuple before it goes on the wire (caller bugs) and its output when it
 * comes back (server drift). Event payloads are validated on delivery;
 * failures there are reported, not thrown, so one bad event cannot kill a
 * subscription stream.
 */

import { z } from 'zod';

import type { ProcedureContract, StreamingProcedureContract } from '#/contract/types';

export type ValidationPhase = 'input' | 'output' | 'event' | 'chunk';

export class KlientValidationError extends Error {
  constructor(
    readonly phase: ValidationPhase,
    /** `service.method` for calls, the klient event name for events. */
    readonly procedure: string,
    readonly issues: z.ZodError['issues'],
    /** The offending raw payload (input args, output data, or event data). */
    readonly payload: unknown,
  ) {
    super(
      `${phase} validation failed for ${procedure}: ${issues
        .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
        .join('; ')}`,
    );
    this.name = 'KlientValidationError';
  }
}

/** Parse the positional-args tuple; returns the normalized args to send. */
export function parseInput(
  procedure: string,
  contract: ProcedureContract | StreamingProcedureContract,
  args: unknown[],
): unknown[] {
  const result = contract.input.safeParse(args);
  if (!result.success) {
    throw new KlientValidationError('input', procedure, result.error.issues, args);
  }
  return result.data as unknown[];
}

/** Parse a wire result; returns the normalized data to hand to the caller. */
export function parseOutput(
  procedure: string,
  contract: ProcedureContract,
  data: unknown,
): unknown {
  const result = contract.output.safeParse(data);
  if (!result.success) {
    throw new KlientValidationError('output', procedure, result.error.issues, data);
  }
  return result.data;
}

/** Validate one streamed chunk; throws on mismatch. */
export function parseChunk(
  procedure: string,
  contract: StreamingProcedureContract,
  data: unknown,
): unknown {
  const result = contract.chunk.safeParse(data);
  if (!result.success) {
    throw new KlientValidationError('chunk', procedure, result.error.issues, data);
  }
  return result.data;
}

/** Parse an event payload without throwing; `undefined` on failure. */
export function parseEvent(
  event: string,
  schema: z.ZodType,
  data: unknown,
): { ok: true; data: unknown } | { ok: false; error: KlientValidationError } {
  const result = schema.safeParse(data);
  if (!result.success) {
    return { ok: false, error: new KlientValidationError('event', event, result.error.issues, data) };
  }
  return { ok: true, data: result.data };
}