Suite of AT Protocol TypeScript libraries built on web standards
1import type { Lexicons } from "../lexicons.ts";
2import {
3 isDiscriminatedObject,
4 isObj,
5 type LexArray,
6 type LexRefVariant,
7 type LexUserType,
8 ValidationError,
9 type ValidationResult,
10} from "../types.ts";
11import { toLexUri } from "../util.ts";
12import { blob } from "./blob.ts";
13import { validate as validatePrimitive } from "./primitives.ts";
14
15export function validate(
16 lexicons: Lexicons,
17 path: string,
18 def: LexUserType,
19 value: unknown,
20): ValidationResult {
21 switch (def.type) {
22 case "object":
23 return object(lexicons, path, def, value);
24 case "array":
25 return array(lexicons, path, def, value);
26 case "blob":
27 return blob(path, value);
28 default:
29 return validatePrimitive(path, def, value);
30 }
31}
32
33export function array(
34 lexicons: Lexicons,
35 path: string,
36 def: LexArray,
37 value: unknown,
38): ValidationResult {
39 // type
40 if (!Array.isArray(value)) {
41 return {
42 success: false,
43 error: new ValidationError(`${path} must be an array`),
44 };
45 }
46
47 // maxLength
48 if (typeof def.maxLength === "number") {
49 if ((value as Array<unknown>).length > def.maxLength) {
50 return {
51 success: false,
52 error: new ValidationError(
53 `${path} must not have more than ${def.maxLength} elements`,
54 ),
55 };
56 }
57 }
58
59 // minLength
60 if (typeof def.minLength === "number") {
61 if ((value as Array<unknown>).length < def.minLength) {
62 return {
63 success: false,
64 error: new ValidationError(
65 `${path} must not have fewer than ${def.minLength} elements`,
66 ),
67 };
68 }
69 }
70
71 // items
72 const itemsDef = def.items;
73 for (let i = 0; i < (value as Array<unknown>).length; i++) {
74 const itemValue = value[i];
75 const itemPath = `${path}/${i}`;
76 const res = validateOneOf(lexicons, itemPath, itemsDef, itemValue);
77 if (!res.success) {
78 return res;
79 }
80 }
81
82 return { success: true, value };
83}
84
85export function object(
86 lexicons: Lexicons,
87 path: string,
88 def: LexUserType,
89 value: unknown,
90): ValidationResult {
91 // type
92 if (!isObj(value)) {
93 return {
94 success: false,
95 error: new ValidationError(`${path} must be an object`),
96 };
97 }
98
99 // properties
100 let resultValue = value as Record<string, unknown>;
101 if ("properties" in def && def.properties != null) {
102 for (const key in def.properties) {
103 const keyValue = resultValue[key];
104 if (keyValue === null && def.nullable?.includes(key)) {
105 continue;
106 }
107 const propDef = def.properties[key];
108 if (keyValue === undefined && !def.required?.includes(key)) {
109 // Fast path for non-required undefined props.
110 if (
111 propDef.type === "integer" ||
112 propDef.type === "boolean" ||
113 propDef.type === "string"
114 ) {
115 if (propDef.default === undefined) {
116 continue;
117 }
118 } else {
119 // Other types have no defaults.
120 continue;
121 }
122 }
123 const propPath = `${path}/${key}`;
124 const validated = validateOneOf(lexicons, propPath, propDef, keyValue);
125 const propValue = validated.success ? validated.value : keyValue;
126
127 // Return error for bad validation, giving required rule precedence
128 if (propValue === undefined) {
129 if (def.required?.includes(key)) {
130 return {
131 success: false,
132 error: new ValidationError(
133 `${path} must have the property "${key}"`,
134 ),
135 };
136 }
137 } else {
138 if (!validated.success) {
139 return validated;
140 }
141 }
142
143 // Adjust value based on e.g. applied defaults, cloning shallowly if there was a changed value
144 if (propValue !== keyValue) {
145 if (resultValue === value) {
146 // Lazy shallow clone
147 resultValue = { ...value };
148 }
149 resultValue[key] = propValue;
150 }
151 }
152 }
153
154 return { success: true, value: resultValue };
155}
156
157export function validateOneOf(
158 lexicons: Lexicons,
159 path: string,
160 def: LexRefVariant | LexUserType,
161 value: unknown,
162 mustBeObj = false, // this is the only type constraint we need currently (used by xrpc body schema validators)
163): ValidationResult {
164 let concreteDef: LexUserType;
165
166 if (def.type === "union") {
167 if (!isDiscriminatedObject(value)) {
168 return {
169 success: false,
170 error: new ValidationError(
171 `${path} must be an object which includes the "$type" property`,
172 ),
173 };
174 }
175 if (!refsContainType(def.refs, value.$type)) {
176 if (def.closed) {
177 return {
178 success: false,
179 error: new ValidationError(
180 `${path} $type must be one of ${def.refs.join(", ")}`,
181 ),
182 };
183 }
184 return { success: true, value };
185 } else {
186 concreteDef = lexicons.getDefOrThrow(value.$type);
187 }
188 } else if (def.type === "ref") {
189 concreteDef = lexicons.getDefOrThrow(def.ref);
190 } else {
191 concreteDef = def;
192 }
193
194 return mustBeObj
195 ? object(lexicons, path, concreteDef, value)
196 : validate(lexicons, path, concreteDef, value);
197}
198
199// to avoid bugs like #0189 this needs to handle both
200// explicit and implicit #main
201const refsContainType = (refs: string[], type: string) => {
202 const lexUri = toLexUri(type);
203 if (refs.includes(lexUri)) {
204 return true;
205 }
206
207 if (lexUri.endsWith("#main")) {
208 return refs.includes(lexUri.slice(0, -5));
209 } else {
210 return !lexUri.includes("#") && refs.includes(`${lexUri}#main`);
211 }
212};