cerulea/lexicon
define atproto schemas in TypeScript
git clone https://git.t4t.associates/cerulea/lexicon
1945441
main
1import * as l from "../mod.ts" ; 2import { assert , equal , throws } from "./assert.ts" ; 3 4Deno . test ( "queries emit parameters, JSON output, errors, and referenced definitions" , () => { 5const Actor = l . named ( "blue.cerulea.app.views#actor" , l . object ({ did :l . did })); 6const endpoint = l . query ( "blue.cerulea.app.getActors" , { 7description :"Get actors." , 8parameters :l . params ({ 9actors :l . array ( l . atIdentifier , { minLength :1 , maxLength :10 }), 10limit :l . optional ( l . integerWith ({ minimum :1 , maximum :10 })), 11}), 12output :l . object ({ actors :l . array ( Actor ) }), 13errors :[{ name :"ActorNotFound" , description :"No such actor." }], 14}); 15equal ( l . toLexicons ( endpoint ), [ 16{ 17lexicon :1 , 18id :"blue.cerulea.app.getActors" , 19defs :{ 20main :{ 21type :"query" , 22description :"Get actors." , 23parameters :{ 24type :"params" , 25properties :{ 26actors :{ 27type :"array" , 28items :{ type :"string" , format :"at-identifier" }, 29minLength :1 , 30maxLength :10 , 31}, 32limit :{ type :"integer" , minimum :1 , maximum :10 }, 33}, 34required :[ "actors" ], 35}, 36output :{ 37encoding :"application/json" , 38schema :{ 39type :"object" , 40properties :{ 41actors :{ 42type :"array" , 43items :{ type :"ref" , ref :"blue.cerulea.app.views#actor" }, 44}, 45}, 46required :[ "actors" ], 47}, 48}, 49errors :[{ name :"ActorNotFound" , description :"No such actor." }], 50}, 51}, 52}, 53{ 54lexicon :1 , 55id :"blue.cerulea.app.views" , 56defs :{ 57actor :{ 58type :"object" , 59properties :{ did :{ type :"string" , format :"did" } }, 60required :[ "did" ], 61}, 62}, 63}, 64]); 65}); 66 67Deno . test ( "procedures support JSON input and intentionally absent output" , () => { 68const endpoint = l . procedure ( "blue.cerulea.app.setMute" , { 69input :l . object ({ actor :l . did , muted :l . boolean }), 70}); 71equal ( l . toLexicons ( endpoint ), [{ 72lexicon :1 , 73id :"blue.cerulea.app.setMute" , 74defs :{ 75main :{ 76type :"procedure" , 77input :{ 78encoding :"application/json" , 79schema :{ 80type :"object" , 81properties :{ actor :{ type :"string" , format :"did" }, muted :{ type :"boolean" } }, 82required :[ "actor" , "muted" ], 83}, 84}, 85}, 86}, 87}]); 88const check = l . compile ( endpoint . input ); 89const value = { actor :"did:plc:alice" , muted :false }; 90const result = check ( value ); 91assert ( result . success && result . value === value ); 92assert ( ! check ({ actor :"alice.example" , muted :false }). success ); 93assert ( ! check ({ actor :"did:plc:alice" }). success ); 94}); 95 96Deno . test ( "RPC bodies can reference records or use tagged unions" , () => { 97const Record = l . record ( "blue.cerulea.app.example" , { key :"tid" }, { text :l . string }); 98const Result = l . union ( "blue.cerulea.app.result" , { 99"#success" :l . object ({ record :Record }), 100"#failure" :l . object ({ message :l . string }), 101}); 102const endpoint = l . procedure ( "blue.cerulea.app.echo" , { input :Record , output :Result }); 103const docs = l . toLexicons ( endpoint , Record ); 104const def = docs . find (( doc ) => doc . id === endpoint . id ) ! . defs . main ! ; 105equal ( def . input , { 106encoding :"application/json" , 107schema :{ type :"ref" , ref :"blue.cerulea.app.example" }, 108}); 109equal ( def . output , { 110encoding :"application/json" , 111schema :{ 112type :"union" , 113refs :[ "blue.cerulea.app.result#failure" , "blue.cerulea.app.result#success" ], 114closed :true , 115}, 116}); 117equal ( docs . find (( doc ) => doc . id === "blue.cerulea.app.example" ) ! . defs . main ! . type , "record" ); 118assert ( 119l . compile ( endpoint . output )({ $type :"blue.cerulea.app.result#failure" , message :"no" }). success , 120); 121}); 122 123Deno . test ( "query parameter validation checks decoded scalar values and arrays" , () => { 124const check = l . compile ( l . params ({ 125actor :l . atIdentifier , 126enabled :l . optional ( l . boolean ), 127limit :l . optional ( l . integerWith ({ minimum :1 , maximum :100 })), 128ids :l . optional ( l . array ( l . string , { maxLength :2 })), 129})); 130assert ( check ({ actor :"alice.example" , enabled :false , limit :10 , ids :[ "a" ] }). success ); 131assert ( check ({ actor :"did:plc:alice" }). success ); 132for ( 133const bad of [ 134{}, 135{ actor :"alice" }, 136{ actor :"alice.example" , enabled :"false" }, 137{ actor :"alice.example" , limit :"10" }, 138{ actor :"alice.example" , limit :0 }, 139{ actor :"alice.example" , ids :[ "a" , "b" , "c" ] }, 140{ actor :"alice.example" , enabled :null }, 141] 142) assert ( ! check ( bad ). success ); 143}); 144 145Deno . test ( "unsupported RPC shapes and conflicting IDs fail explicitly" , () => { 146for ( 147const field of [ 148l . object ({}), 149l . blob (), 150l . bytes , 151l . cidLink , 152l . nullable ( l . string ), 153l . optional ( l . nullable ( l . string )), 154l . array ( l . array ( l . string )), 155l . named ( "blue.cerulea.app.defs#text" , l . string ), 156] 157) { 158throws (() => l . params ({ field}), "Parameter field" ); 159} 160throws (() => l . query ( "invalid" , {}), "RPC NSID" ); 161throws (() => l . procedure ( "blue.cerulea.app.write" , { input :l . array ( l . string ) }), "RPC bodies" ); 162throws ( 163() => 164l . query ( "blue.cerulea.app.read" , { output :l . named ( "blue.cerulea.app.defs#text" , l . string ) }), 165"RPC bodies" , 166); 167throws ( 168() => l . query ( "blue.cerulea.app.read" , { errors :[{ name :"Bad Error" }] }), 169"Invalid RPC error" , 170); 171throws ( 172() => l . query ( "blue.cerulea.app.read" , { errors :[{ name :"Bad" }, { name :"Bad" }] }), 173"Duplicate RPC error" , 174); 175throws (() => 176l . toLexicons ( 177l . query ( "blue.cerulea.app.read" , {}), 178l . procedure ( "blue.cerulea.app.read" , {}), 179), "Conflicting definition" ); 180// @ts-expect-error Queries cannot have request bodies. 181throws (() => l . query ( "blue.cerulea.app.read" , { input :l . object ({}) }), "Queries cannot" ); 182// @ts-expect-error Params are only used as RPC parameters, never as named definitions. 183throws (() => l . toLexicons ( l . params ({})), "roots" ); 184}); 185 186export function rpcInference () :void { 187const endpoint = l . procedure ( "blue.cerulea.app.example" , { 188parameters :l . params ({ dryRun :l . optional ( l . boolean ) }), 189input :l . object ({ name :l . string , labels :l . optional ( l . array ( l . string )) }), 190output :l . object ({ accepted :l . boolean }), 191errors :[{ name :"Denied" }], 192}); 193const input :l . Infer < typeof endpoint . input > = { name :"Alice" }; 194const output :l . Infer < typeof endpoint . output > = { accepted :true }; 195const parameters :l . Infer < typeof endpoint . parameters > = { dryRun :false }; 196const id :"blue.cerulea.app.example" = endpoint . id ; 197const errorName :"Denied" = endpoint . errors [ 0 ]. name ; 198void [ output , parameters , id , errorName ]; 199// @ts-expect-error Input properties remain readonly. 200input . name = "Bob" ; 201// @ts-expect-error Required body fields are retained. 202const missing :l . Infer < typeof endpoint . input > = {}; 203// @ts-expect-error Parameter types are not strings just because HTTP transports them as strings. 204const wrong :l . Infer < typeof endpoint . parameters > = { dryRun :"false" }; 205const undefinedValue :l . Infer < typeof endpoint . parameters > = { dryRun :undefined }; 206// @ts-expect-error RPCs are not data validators; compile a parameters/input/output schema. 207l . compile ( endpoint ); 208// @ts-expect-error Params cannot appear inside stored data. 209l . array ( endpoint . parameters ); 210void [ missing , wrong , undefinedValue ]; 211}