cerulea/lexicon

define atproto schemas in TypeScript

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

Charlotte Sominitial commit1945441

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