cerulea/lexicon

define atproto schemas in TypeScript

git clone https://git.t4t.associates/cerulea/lexicon

Charlotte Somvalidation: a Location carries the path and issues25f2629

main
8.8 KiB225 linesraw
1import type { ParamsSchema } from "./rpc.ts";
2import { definitionId, type Infer, type Schema } from "./schema.ts";
3import { isObject } from "./util/data.ts";
4import { unwrap } from "./util/fields.ts";
5import { base64Length, cidCodec, matchesFormat } from "./util/syntax.ts";
6
7export type Issue = { readonly path: readonly (string | number)[]; readonly message: string };
8export type ValidationResult<T> =
9  | { readonly success: true; readonly value: T }
10  | { readonly success: false; readonly issues: readonly Issue[] };
11
12// Where a check is looking, and where it reports what it finds.
13class Location {
14  constructor(readonly issues: Issue[], readonly path: readonly (string | number)[] = []) {}
15  child(key: string | number): Location {
16    return new Location(this.issues, [...this.path, key]);
17  }
18  fail(message: string): void {
19    this.issues.push({ path: this.path, message });
20  }
21  length(length: number, min: number | undefined, max: number | undefined): void {
22    if (min !== undefined && length < min) this.fail(`Length must be at least ${min}`);
23    if (max !== undefined && length > max) this.fail(`Length must be at most ${max}`);
24  }
25}
26
27type Check = (value: unknown, at: Location) => void;
28type Compiled = Schema | ParamsSchema;
29type Scalar = string | number | boolean;
30
31const encoder = new TextEncoder();
32const segmenter = new Intl.Segmenter("und", { granularity: "grapheme" });
33const graphemes = (value: string) => [...segmenter.segment(value)].length;
34const MIME = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
35
36function isCompound(value: unknown, key: "$link" | "$bytes"): value is Record<string, unknown> {
37  if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
38  if (isObject(value)) return Object.hasOwn(value, key) && Object.keys(value).length === 1;
39  // Binary wrappers expose the same JSON fields without making Lexicon depend on a codec.
40  return key in value && "toJSON" in value && typeof value.toJSON === "function";
41}
42
43function constrain<T extends Scalar>(
44  schema: { readonly const?: T; readonly enum?: readonly T[] },
45  value: T,
46  at: Location,
47): void {
48  if (schema.const !== undefined && value !== schema.const) {
49    at.fail(`Expected ${JSON.stringify(schema.const)}`);
50  }
51  if (schema.enum && !schema.enum.includes(value)) at.fail("Value is not in the enum");
52}
53
54function checkLink(value: unknown, at: Location): void {
55  const link = isCompound(value, "$link") && typeof value.$link === "string" ? value.$link : "";
56  if (cidCodec(link) === undefined) at.fail("Expected a CID link ({ $link: valid CID })");
57}
58
59function checkBytes(value: unknown, at: Location): number | undefined {
60  const length = isCompound(value, "$bytes") && typeof value.$bytes === "string"
61    ? base64Length(value.$bytes)
62    : undefined;
63  if (length === undefined) at.fail("Expected bytes ({ $bytes: base64 })");
64  return length;
65}
66
67function checkBlob(value: unknown, at: Location): value is Record<string, unknown> {
68  if (!isObject(value) || value.$type !== "blob") {
69    at.fail("Expected a blob");
70    return false;
71  }
72  const { ref, mimeType, size } = value;
73  checkLink(ref, at.child("ref"));
74  if (isCompound(ref, "$link") && typeof ref.$link === "string" && cidCodec(ref.$link) !== 0x55) {
75    at.child("ref").fail("Blob CID must use the raw codec");
76  }
77  if (typeof mimeType !== "string" || !MIME.test(mimeType)) {
78    at.child("mimeType").fail("Expected a MIME type");
79  }
80  if (typeof size !== "number" || !Number.isSafeInteger(size) || size <= 0) {
81    at.child("size").fail("Expected a positive safe integer");
82  }
83  return true;
84}
85
86function checker(schema: Compiled, build: (schema: Compiled) => Check): Check {
87  switch (schema.type) {
88    case "string":
89      return (value, at) => {
90        if (typeof value !== "string" || !value.isWellFormed()) {
91          return at.fail("Expected a well-formed string");
92        }
93        constrain(schema, value, at);
94        if (schema.format && !matchesFormat(schema.format, value)) {
95          at.fail(`Invalid ${schema.format}`);
96        }
97        if (schema.minLength !== undefined || schema.maxLength !== undefined) {
98          at.length(encoder.encode(value).length, schema.minLength, schema.maxLength);
99        }
100        if (schema.minGraphemes !== undefined || schema.maxGraphemes !== undefined) {
101          at.length(graphemes(value), schema.minGraphemes, schema.maxGraphemes);
102        }
103      };
104    case "integer":
105      return (value, at) => {
106        if (typeof value !== "number" || !Number.isSafeInteger(value)) {
107          return at.fail("Expected a safe integer");
108        }
109        constrain(schema, value, at);
110        if (schema.minimum !== undefined && value < schema.minimum) {
111          at.fail(`Must be at least ${schema.minimum}`);
112        }
113        if (schema.maximum !== undefined && value > schema.maximum) {
114          at.fail(`Must be at most ${schema.maximum}`);
115        }
116      };
117    case "boolean":
118      return (value, at) => {
119        if (typeof value !== "boolean") return at.fail("Expected a boolean");
120        constrain(schema, value, at);
121      };
122    case "bytes":
123      return (value, at) => {
124        const length = checkBytes(value, at);
125        if (length !== undefined) at.length(length, schema.minLength, schema.maxLength);
126      };
127    case "cid-link":
128      return checkLink;
129    case "blob":
130      return (value, at) => {
131        if (!checkBlob(value, at)) return;
132        const { mimeType, size } = value;
133        if (schema.maxSize !== undefined && typeof size === "number" && size > schema.maxSize) {
134          at.child("size").fail(`Must be at most ${schema.maxSize}`);
135        }
136        const accepted = (mime: string) =>
137          mime === "*/*" || mime === mimeType ||
138          (mime.endsWith("/*") && String(mimeType).startsWith(mime.slice(0, -1)));
139        if (schema.accept && typeof mimeType === "string" && !schema.accept.some(accepted)) {
140          at.child("mimeType").fail("MIME type is not accepted");
141        }
142      };
143    case "array": {
144      const item = build(schema.items);
145      return (value, at) => {
146        if (!Array.isArray(value)) return at.fail("Expected an array");
147        at.length(value.length, schema.minLength, schema.maxLength);
148        // Indexing rather than iterating so holes are checked as undefined.
149        for (let i = 0; i < value.length; i++) item(value[i], at.child(i));
150      };
151    }
152    case "params":
153    case "object": {
154      const fields = Object.entries(schema.properties).map(([key, field]) => {
155        const { schema, optional, nullable } = unwrap(field);
156        return { key, optional, nullable, check: build(schema) };
157      });
158      return (value, at) => {
159        if (!isObject(value)) return at.fail("Expected an object");
160        if (
161          Object.hasOwn(value, "$link") || Object.hasOwn(value, "$bytes") || value.$type === "blob"
162        ) {
163          return at.fail("Expected an object, not a compound value");
164        }
165        for (const { key, optional, nullable, check } of fields) {
166          const item = Object.hasOwn(value, key) ? value[key] : undefined;
167          if (item === undefined) {
168            if (!optional) at.child(key).fail("Required field");
169          } else if (item !== null || !nullable) check(item, at.child(key));
170        }
171      };
172    }
173    case "ref":
174      return build(schema.target);
175    case "record": {
176      const body = build(schema.record);
177      return (value, at) => {
178        if (!isObject(value) || value.$type !== schema.id) {
179          at.child("$type").fail(`Expected ${schema.id}`);
180        }
181        body(value, at);
182      };
183    }
184    case "union": {
185      const variants = new Map(
186        Object.entries(schema.variants).map(([id, variant]) => [id, build(variant)]),
187      );
188      return (value, at) => {
189        if (!isObject(value) || typeof value.$type !== "string") {
190          return at.child("$type").fail("Expected a union discriminator");
191        }
192        const variant = variants.get(value.$type);
193        if (variant) return variant(value, at);
194        if (schema.closed) return at.child("$type").fail("Unknown union variant");
195        try {
196          definitionId(value.$type);
197        } catch {
198          at.child("$type").fail("Invalid union discriminator");
199        }
200      };
201    }
202  }
203}
204
205export function compile<S extends Compiled>(
206  schema: S,
207): (value: unknown) => ValidationResult<Infer<S>> {
208  const cache = new Map<Compiled, Check>();
209  // Recursive schemas get a forwarder before their own check exists.
210  function build(schema: Compiled): Check {
211    const cached = cache.get(schema);
212    if (cached) return cached;
213    let check: Check;
214    cache.set(schema, (value, at) => check(value, at));
215    return check = checker(schema, build);
216  }
217  const check = build(schema);
218  return (value) => {
219    const issues: Issue[] = [];
220    check(value, new Location(issues));
221    return issues.length === 0
222      ? { success: true, value: value as Infer<S> }
223      : { success: false, issues };
224  };
225}