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