|
| 1 | +/** |
| 2 | + * @fileoverview Universal schema validator — non-throwing. |
| 3 | + * |
| 4 | + * Accepts any Zod-shaped schema (`.safeParse`-exposing) and returns a tagged |
| 5 | + * result `{ ok: true, value } | { ok: false, errors }` with normalized |
| 6 | + * `{ path, message }` issues. No runtime dependency on `zod` — detection |
| 7 | + * is purely structural. |
| 8 | + * |
| 9 | + * @internal |
| 10 | + * socket-lib additionally recognizes TypeBox schemas for its own internal |
| 11 | + * use (e.g. `src/ipc.ts`'s stub-file validation). That path is not a |
| 12 | + * supported consumer API. |
| 13 | + * |
| 14 | + * @example |
| 15 | + * ```ts |
| 16 | + * import { z } from 'zod' |
| 17 | + * import { validateSchema } from '@socketsecurity/lib/schema/validate' |
| 18 | + * |
| 19 | + * const User = z.object({ name: z.string() }) |
| 20 | + * const r = validateSchema(User, data) |
| 21 | + * if (r.ok) r.value.name // string |
| 22 | + * else r.errors // ValidationIssue[] |
| 23 | + * ``` |
| 24 | + */ |
| 25 | + |
| 26 | +import type { |
| 27 | + Infer, |
| 28 | + ParseResult, |
| 29 | + ValidateResult, |
| 30 | + ValidationIssue, |
| 31 | +} from './types' |
| 32 | + |
| 33 | +/** |
| 34 | + * Detect a TypeBox schema structurally: object with a symbol key whose |
| 35 | + * description is `'TypeBox.Kind'`, holding a string value. |
| 36 | + * |
| 37 | + * @internal |
| 38 | + */ |
| 39 | +function isTypeBoxSchema(schema: unknown): boolean { |
| 40 | + if (schema === null || typeof schema !== 'object') { |
| 41 | + return false |
| 42 | + } |
| 43 | + for (const sym of Object.getOwnPropertySymbols(schema)) { |
| 44 | + if (sym.description === 'TypeBox.Kind') { |
| 45 | + return typeof (schema as Record<symbol, unknown>)[sym] === 'string' |
| 46 | + } |
| 47 | + } |
| 48 | + return false |
| 49 | +} |
| 50 | + |
| 51 | +/** |
| 52 | + * Normalize a TypeBox `ValueError` iterator into plain issues. |
| 53 | + * TypeBox paths are JSON Pointers (`/user/0/name`); convert to arrays. |
| 54 | + * |
| 55 | + * @internal |
| 56 | + */ |
| 57 | +function normalizeTypeBoxErrors( |
| 58 | + errors: Iterable<{ path: string; message: string }>, |
| 59 | +): ValidationIssue[] { |
| 60 | + const out: ValidationIssue[] = [] |
| 61 | + for (const err of errors) { |
| 62 | + const segs = err.path.split('/').filter(Boolean) |
| 63 | + out.push({ |
| 64 | + path: segs.map(s => { |
| 65 | + const n = Number(s) |
| 66 | + return Number.isInteger(n) && String(n) === s ? n : s |
| 67 | + }), |
| 68 | + message: err.message, |
| 69 | + }) |
| 70 | + } |
| 71 | + return out |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Normalize a Zod error object (v3 or v4) into plain issues. |
| 76 | + * Both versions expose `.issues: Array<{ path, message }>`. |
| 77 | + * |
| 78 | + * @internal |
| 79 | + */ |
| 80 | +function normalizeZodError(err: unknown): ValidationIssue[] { |
| 81 | + if (err === null || typeof err !== 'object') { |
| 82 | + return [{ path: [], message: String(err) }] |
| 83 | + } |
| 84 | + const issues = (err as { issues?: unknown }).issues |
| 85 | + if (!Array.isArray(issues)) { |
| 86 | + return [{ path: [], message: 'Unknown validation error' }] |
| 87 | + } |
| 88 | + return issues.map(issue => { |
| 89 | + const i = issue as { |
| 90 | + path?: Array<string | number> |
| 91 | + message?: string |
| 92 | + } |
| 93 | + return { |
| 94 | + path: Array.isArray(i.path) ? i.path : [], |
| 95 | + message: typeof i.message === 'string' ? i.message : 'Invalid value', |
| 96 | + } |
| 97 | + }) |
| 98 | +} |
| 99 | + |
| 100 | +/** |
| 101 | + * Validate `data` against a Zod-style `schema`. Non-throwing. |
| 102 | + * |
| 103 | + * The return type narrows `value` to `Infer<S>`, so callers get |
| 104 | + * `z.infer<typeof S>` with no casts. Errors are normalized to |
| 105 | + * `{ path, message }` regardless of the underlying validator. |
| 106 | + * |
| 107 | + * @throws {TypeError} When `schema` is not a recognized validator kind. |
| 108 | + */ |
| 109 | +export function validateSchema<S>( |
| 110 | + schema: S, |
| 111 | + data: unknown, |
| 112 | +): ValidateResult<Infer<S>> { |
| 113 | + // Internal TypeBox path: socket-lib uses TypeBox schemas in a few |
| 114 | + // places (e.g. src/ipc.ts), detected here via the structural |
| 115 | + // `[Kind]: string` marker. Not a supported consumer API. The runtime |
| 116 | + // is loaded lazily from the bundled external under `src/external/`. |
| 117 | + if (isTypeBoxSchema(schema)) { |
| 118 | + // eslint-disable-next-line @typescript-eslint/no-require-imports |
| 119 | + const { Value } = require('../external/@sinclair/typebox/value') as { |
| 120 | + Value: { |
| 121 | + Check(schema: unknown, value: unknown): boolean |
| 122 | + Errors( |
| 123 | + schema: unknown, |
| 124 | + value: unknown, |
| 125 | + ): Iterable<{ path: string; message: string }> |
| 126 | + } |
| 127 | + } |
| 128 | + if (Value.Check(schema, data)) { |
| 129 | + return { ok: true, value: data as Infer<S> } |
| 130 | + } |
| 131 | + return { |
| 132 | + ok: false, |
| 133 | + errors: normalizeTypeBoxErrors(Value.Errors(schema, data)), |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + // Zod / Schema<T> duck-type path: any object exposing `.safeParse`. |
| 138 | + if ( |
| 139 | + schema !== null && |
| 140 | + typeof schema === 'object' && |
| 141 | + typeof (schema as { safeParse?: unknown }).safeParse === 'function' |
| 142 | + ) { |
| 143 | + const result = ( |
| 144 | + schema as unknown as { |
| 145 | + safeParse( |
| 146 | + data: unknown, |
| 147 | + ): |
| 148 | + | ParseResult<unknown> |
| 149 | + | { success: true; data: unknown } |
| 150 | + | { success: false; error: unknown } |
| 151 | + } |
| 152 | + ).safeParse(data) |
| 153 | + |
| 154 | + if ((result as { success: boolean }).success === true) { |
| 155 | + return { |
| 156 | + ok: true, |
| 157 | + value: (result as { data: unknown }).data as Infer<S>, |
| 158 | + } |
| 159 | + } |
| 160 | + return { |
| 161 | + ok: false, |
| 162 | + errors: normalizeZodError((result as { error: unknown }).error), |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + throw new TypeError( |
| 167 | + 'validateSchema: unsupported schema kind. Expected a Zod schema or ' + |
| 168 | + 'an object with a safeParse method.', |
| 169 | + ) |
| 170 | +} |
0 commit comments