forked from
slices.network/slices
Highly ambitious ATProtocol AppView service and sdks
1import type { SourceFile } from "ts-morph";
2import { Scope } from "ts-morph";
3import type { Lexicon } from "./mod.ts";
4import { nsidToPascalCase, capitalizeFirst, sanitizeIdentifier } from "./mod.ts";
5
6interface NestedStructure {
7 [key: string]: NestedStructure | string | QueryProcedureInfo[] | undefined;
8 _recordType?: string;
9 _collectionPath?: string;
10 _queryProcedures?: QueryProcedureInfo[];
11}
12
13interface QueryProcedureInfo {
14 nsid: string;
15 type: "query" | "procedure";
16 methodName: string;
17 parametersType?: string;
18 inputType?: string;
19 outputType?: string;
20}
21
22interface PropertyInfo {
23 name: string;
24 type: string;
25}
26
27interface MethodInfo {
28 name: string;
29 parameters: Array<{ name: string; type: string; hasQuestionToken?: boolean }>;
30 returnType: string;
31}
32
33export function generateClient(
34 sourceFile: SourceFile,
35 lexicons: Lexicon[]
36): void {
37 // Create nested structure from lexicons
38 const nestedStructure: NestedStructure = {};
39
40 for (const lexicon of lexicons) {
41 if (lexicon.definitions && typeof lexicon.definitions === "object") {
42 // Check if this lexicon has any records, queries, or procedures
43 const hasRecordsOrEndpoints = Object.values(lexicon.definitions).some(
44 (defValue) =>
45 (defValue.type === "record" && defValue.record) ||
46 defValue.type === "query" ||
47 defValue.type === "procedure"
48 );
49
50 // Only build nested structure for lexicons that have records
51 if (hasRecordsOrEndpoints) {
52 for (const [_, defValue] of Object.entries(lexicon.definitions)) {
53 if (defValue.type === "record" && defValue.record) {
54 const parts = lexicon.id.split(".");
55 let current = nestedStructure;
56
57 // Build nested structure
58 for (const part of parts) {
59 if (!current[part]) {
60 current[part] = {};
61 }
62 current = current[part] as NestedStructure;
63 }
64
65 // Add the record interface name and store collection path
66 current._recordType = nsidToPascalCase(lexicon.id);
67 current._collectionPath = lexicon.id;
68 }
69 }
70 }
71 }
72 }
73
74 // Add query/procedure methods to the appropriate parent clients
75 function addQueryProcedureMethods(
76 obj: NestedStructure,
77 lexicons: Lexicon[]
78 ): void {
79 // Group query/procedure endpoints by their parent collection path
80 const endpointsByParent = new Map<string, QueryProcedureInfo[]>();
81
82 for (const lexicon of lexicons) {
83 if (lexicon.definitions && typeof lexicon.definitions === "object") {
84 for (const [_, defValue] of Object.entries(lexicon.definitions)) {
85 if (defValue.type === "query" || defValue.type === "procedure") {
86 const parts = lexicon.id.split(".");
87 const methodName = parts[parts.length - 1];
88
89 // Find the parent collection by removing the method name
90 // e.g., "network.slices.slice.getJobStatus" -> "network.slices.slice"
91 const parentPath = parts.slice(0, -1).join(".");
92
93 const queryProcedureInfo: QueryProcedureInfo = {
94 nsid: lexicon.id,
95 type: defValue.type as "query" | "procedure",
96 methodName,
97 parametersType:
98 defValue.parameters?.properties &&
99 Object.keys(defValue.parameters.properties).length > 0
100 ? `${nsidToPascalCase(lexicon.id)}Params`
101 : undefined,
102 inputType: defValue.input
103 ? `${nsidToPascalCase(lexicon.id)}Input`
104 : undefined,
105 outputType: defValue.output
106 ? `${nsidToPascalCase(lexicon.id)}Output`
107 : undefined,
108 };
109
110 if (!endpointsByParent.has(parentPath)) {
111 endpointsByParent.set(parentPath, []);
112 }
113 endpointsByParent.get(parentPath)!.push(queryProcedureInfo);
114 }
115 }
116 }
117 }
118
119 // Add endpoints to their respective parent clients
120 function addEndpointsToNode(
121 current: NestedStructure,
122 path: string[],
123 fullPath: string
124 ): void {
125 if (path.length === 0) {
126 // We've reached the target node, add the endpoints if this has a record type
127 const endpoints = endpointsByParent.get(fullPath);
128 if (endpoints && current._recordType) {
129 if (!current._queryProcedures) {
130 current._queryProcedures = [];
131 }
132 current._queryProcedures.push(...endpoints);
133 }
134 return;
135 }
136
137 const [head, ...tail] = path;
138 if (current[head]) {
139 addEndpointsToNode(current[head] as NestedStructure, tail, fullPath);
140 }
141 }
142
143 // Add endpoints to their parent nodes
144 for (const parentPath of endpointsByParent.keys()) {
145 const pathParts = parentPath.split(".");
146 addEndpointsToNode(obj, pathParts, parentPath);
147 }
148 }
149
150 // Add query/procedure methods before generating classes
151 addQueryProcedureMethods(nestedStructure, lexicons);
152
153 // Generate nested class structure
154 function generateNestedClass(
155 obj: NestedStructure,
156 className = "CollectionNode",
157 currentPath: string[] = []
158 ): void {
159 const properties: PropertyInfo[] = [];
160 const methods: MethodInfo[] = [];
161
162 let collectionPath = "";
163
164 for (const [key, value] of Object.entries(obj)) {
165 if (key === "_recordType") {
166 // Add collection operations for this record type
167 const recordName = value as string;
168 // Check if we have sortable fields for this record
169 const hasSortFields = sourceFile.getTypeAlias(
170 `${recordName}SortFields`
171 );
172 const sortFieldsType = hasSortFields
173 ? `${recordName}SortFields`
174 : "IndexedRecordFields";
175 const whereFieldsType = hasSortFields
176 ? `${recordName}SortFields | IndexedRecordFields`
177 : "IndexedRecordFields";
178
179 methods.push({
180 name: "getRecords",
181 parameters: [
182 {
183 name: "params",
184 type: `{ limit?: number; cursor?: string; where?: { [K in ${whereFieldsType}]?: WhereCondition }; orWhere?: { [K in ${whereFieldsType}]?: WhereCondition }; sortBy?: SortField<${sortFieldsType}>[]; }`,
185 hasQuestionToken: true,
186 },
187 ],
188 returnType: `Promise<GetRecordsResponse<${value}>>`,
189 });
190 methods.push({
191 name: "getRecord",
192 parameters: [{ name: "params", type: "GetRecordParams" }],
193 returnType: `Promise<RecordResponse<${value}>>`,
194 });
195 methods.push({
196 name: "countRecords",
197 parameters: [
198 {
199 name: "params",
200 type: `{ limit?: number; cursor?: string; where?: { [K in ${whereFieldsType}]?: WhereCondition }; orWhere?: { [K in ${whereFieldsType}]?: WhereCondition }; sortBy?: SortField<${sortFieldsType}>[]; }`,
201 hasQuestionToken: true,
202 },
203 ],
204 returnType: "Promise<CountRecordsResponse>",
205 });
206 // Add create, update, delete methods
207 methods.push({
208 name: "createRecord",
209 parameters: [
210 { name: "record", type: value as string },
211 { name: "useSelfRkey", type: "boolean", hasQuestionToken: true },
212 ],
213 returnType: `Promise<{ uri: string; cid: string }>`,
214 });
215 methods.push({
216 name: "updateRecord",
217 parameters: [
218 { name: "rkey", type: "string" },
219 { name: "record", type: value as string },
220 ],
221 returnType: `Promise<{ uri: string; cid: string }>`,
222 });
223 methods.push({
224 name: "deleteRecord",
225 parameters: [{ name: "rkey", type: "string" }],
226 returnType: `Promise<void>`,
227 });
228 } else if (key === "_collectionPath") {
229 collectionPath = value as string;
230 } else if (key === "_queryProcedures") {
231 // Add query and procedure methods
232 const queryProcedures = value as QueryProcedureInfo[];
233 for (const qp of queryProcedures) {
234 if (qp.type === "query") {
235 // Generate query method (GET)
236 const parameters = [];
237 if (qp.parametersType) {
238 parameters.push({
239 name: "params",
240 type: qp.parametersType,
241 hasQuestionToken: true,
242 });
243 }
244 methods.push({
245 name: qp.methodName,
246 parameters,
247 returnType: `Promise<${qp.outputType || "void"}>`,
248 });
249 } else if (qp.type === "procedure") {
250 // Generate procedure method (POST)
251 const parameters = [];
252 if (qp.inputType) {
253 parameters.push({
254 name: "input",
255 type: qp.inputType,
256 });
257 } else if (qp.parametersType) {
258 parameters.push({
259 name: "params",
260 type: qp.parametersType,
261 });
262 }
263 methods.push({
264 name: qp.methodName,
265 parameters,
266 returnType: `Promise<${qp.outputType || "void"}>`,
267 });
268 }
269 }
270 } else if (typeof value === "object" && Object.keys(value).length > 0) {
271 // Add nested property with PascalCase class name
272 // Sanitize the key for both class name and property name
273 const sanitizedKey = sanitizeIdentifier(key);
274 const nestedClassName = `${capitalizeFirst(sanitizedKey)}${className}`;
275 generateNestedClass(value as NestedStructure, nestedClassName, [
276 ...currentPath,
277 key,
278 ]);
279 properties.push({
280 name: sanitizedKey,
281 type: nestedClassName,
282 });
283 }
284 }
285
286 if (properties.length > 0 || methods.length > 0) {
287 // Use proper naming for the main client
288 const finalClassName =
289 className === "Client" ? "AtProtoClient" : className;
290
291 const classDeclaration = sourceFile.addClass({
292 name: finalClassName,
293 isExported: className === "Client",
294 extends: className === "Client" ? "SlicesClient" : undefined,
295 properties: [
296 ...properties.map((p) => ({
297 name: p.name,
298 type: p.type,
299 isReadonly: true,
300 })),
301 // Add OAuth client to the main AtProtoClient
302 ...(className === "Client"
303 ? [
304 {
305 name: "oauth",
306 type: "OAuthClient | AuthProvider",
307 isReadonly: true,
308 hasQuestionToken: true,
309 },
310 ]
311 : [
312 // Nested classes need a reference to the client
313 {
314 name: "client",
315 type: "SlicesClient",
316 scope: Scope.Private,
317 isReadonly: true,
318 },
319 ]),
320 ],
321 });
322
323 // Add constructor
324 const ctor = classDeclaration.addConstructor({
325 parameters:
326 className === "Client"
327 ? [
328 { name: "baseUrl", type: "string" },
329 { name: "sliceUri", type: "string" },
330 {
331 name: "oauthClient",
332 type: "OAuthClient | AuthProvider",
333 hasQuestionToken: true,
334 },
335 ]
336 : [{ name: "client", type: "SlicesClient" }],
337 });
338
339 if (className === "Client") {
340 ctor.addStatements([
341 "super(baseUrl, sliceUri, oauthClient);",
342 ...properties.map((p) => `this.${p.name} = new ${p.type}(this);`),
343 "this.oauth = oauthClient;",
344 ]);
345 } else {
346 // Nested classes store reference to parent client
347 ctor.addStatements([
348 "this.client = client;",
349 ...properties.map((p) => `this.${p.name} = new ${p.type}(client);`),
350 ]);
351 }
352
353 // Add methods with implementations
354 for (const method of methods) {
355 const methodDecl = classDeclaration.addMethod({
356 name: method.name,
357 parameters: method.parameters,
358 returnType: method.returnType,
359 isAsync: true,
360 });
361
362 // Add basic implementation using shared client methods
363 // Use this.client for nested classes, this for main client
364 const clientRef = className === "Client" ? "this" : "this.client";
365
366 if (method.name === "getRecords") {
367 methodDecl.addStatements([
368 `return await ${clientRef}.getRecords('${collectionPath}', params);`,
369 ]);
370 } else if (method.name === "getRecord") {
371 methodDecl.addStatements([
372 `return await ${clientRef}.getRecord('${collectionPath}', params);`,
373 ]);
374 } else if (method.name === "createRecord") {
375 methodDecl.addStatements([
376 `return await ${clientRef}.createRecord('${collectionPath}', record, useSelfRkey);`,
377 ]);
378 } else if (method.name === "updateRecord") {
379 methodDecl.addStatements([
380 `return await ${clientRef}.updateRecord('${collectionPath}', rkey, record);`,
381 ]);
382 } else if (method.name === "deleteRecord") {
383 methodDecl.addStatements([
384 `return await ${clientRef}.deleteRecord('${collectionPath}', rkey);`,
385 ]);
386 } else if (method.name === "countRecords") {
387 methodDecl.addStatements([
388 `return await ${clientRef}.countRecords('${collectionPath}', params);`,
389 ]);
390 } else {
391 // Handle query and procedure methods
392 const queryProcedures = obj._queryProcedures || [];
393 const matchingQP = queryProcedures.find(
394 (qp) => qp.methodName === method.name
395 );
396
397 if (matchingQP) {
398 if (matchingQP.type === "query") {
399 // Query methods use GET with query parameters
400 const paramArg = method.parameters.length > 0 ? "params" : "{}";
401 methodDecl.addStatements([
402 `return await ${clientRef}.makeRequest<${
403 matchingQP.outputType || "void"
404 }>('${matchingQP.nsid}', 'GET', ${paramArg});`,
405 ]);
406 } else if (matchingQP.type === "procedure") {
407 // Procedure methods use POST with body
408 const paramArg =
409 method.parameters.length > 0 ? method.parameters[0].name : "{}";
410 methodDecl.addStatements([
411 `return await ${clientRef}.makeRequest<${
412 matchingQP.outputType || "void"
413 }>('${matchingQP.nsid}', 'POST', ${paramArg});`,
414 ]);
415 }
416 }
417 }
418 }
419 }
420 }
421
422 // Generate the main client class
423 if (Object.keys(nestedStructure).length > 0) {
424 generateNestedClass(nestedStructure, "Client");
425 }
426}