Skip to content

Commit aca12fa

Browse files
authored
feat: apply generated patches at client level creation (#199)
1 parent f52f4da commit aca12fa

30 files changed

+2166
-622
lines changed

ts/package-lock.json

+18
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ts/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@bufbuild/protoc-gen-es": "^2.2.3",
4949
"@bufbuild/protoplugin": "^2.2.3",
5050
"@eslint/js": "^9.21.0",
51+
"@faker-js/faker": "^9.7.0",
5152
"@jest/globals": "^29.7.0",
5253
"@stylistic/eslint-plugin": "^4.0.1",
5354
"eslint": "^9.24.0",

ts/script/protoc-gen-customtype-patches.ts

+80-21
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
#!/usr/bin/env -S node --experimental-strip-types
22

3-
import { type DescField, ScalarType } from "@bufbuild/protobuf";
3+
import { type DescField, type DescMessage, ScalarType } from "@bufbuild/protobuf";
44
import {
55
createEcmaScriptPlugin,
6+
type GeneratedFile,
67
runNodeJs,
78
type Schema,
89
} from "@bufbuild/protoplugin";
9-
import { normalize as normalizePath } from "path";
10+
import { basename, normalize as normalizePath } from "path";
1011

1112
import { findPathsToCustomField, getCustomType } from "../src/encoding/customTypes/utils.ts";
1213

@@ -18,6 +19,7 @@ runNodeJs(
1819
}),
1920
);
2021

22+
const PROTO_PATH = "../protos";
2123
function generateTs(schema: Schema): void {
2224
const allPaths: DescField[][] = [];
2325

@@ -31,19 +33,22 @@ function generateTs(schema: Schema): void {
3133
return;
3234
}
3335

34-
const messageToCustomFields: Record<string, Set<DescField>> = {};
36+
const messageToCustomFields: Map<DescMessage, Set<DescField>> = new Map();
3537
allPaths.forEach((path) => {
3638
path.forEach((field) => {
37-
messageToCustomFields[field.parent.typeName] ??= new Set();
38-
messageToCustomFields[field.parent.typeName].add(field);
39+
if (!messageToCustomFields.has(field.parent)) {
40+
messageToCustomFields.set(field.parent, new Set());
41+
}
42+
messageToCustomFields.get(field.parent)!.add(field);
3943
});
4044
});
4145

4246
const patches: string[] = [];
4347
const imports: Record<string, Set<string>> = {};
44-
const f = schema.generateFile(getOutputFileName(schema));
48+
const fileName = getOutputFileName(schema);
49+
const patchesFile = schema.generateFile(fileName);
4550

46-
Object.entries(messageToCustomFields).forEach(([typeName, fields]) => {
51+
Array.from(messageToCustomFields.entries()).forEach(([descMessage, fields]) => {
4752
const encoded: string[] = [];
4853

4954
fields.forEach((field) => {
@@ -58,41 +63,44 @@ function generateTs(schema: Schema): void {
5863
imports[`../../encoding/binaryEncoding`] ??= new Set(["encodeBinary", "decodeBinary"]);
5964
}
6065

61-
encoded.push(generateNestedFieldSetter(field, {
66+
encoded.push(generateFieldTransformation(field, {
6267
fn: `${customType.shortName}[transformType]`,
6368
value: "value",
6469
newValue: "newValue",
6570
}));
6671
} else {
6772
encoded.push(generateNestedFieldUpdate(field, {
68-
fn: `t["${field.message!.typeName}"]`,
73+
fn: `p["${field.message!.typeName}"]`,
6974
value: "value",
7075
newValue: "newValue",
7176
}));
7277
}
7378
});
7479

75-
const shapeImport = f.importShape(fields.values().next().value.parent);
76-
const path = normalizePath(`../protos/${shapeImport.from.replace(/\.js$/, "")}`);
80+
const shapeImport = patchesFile.importShape(fields.values().next().value.parent);
81+
const path = normalizePath(`${PROTO_PATH}/${shapeImport.from.replace(/\.js$/, "")}`);
7782
imports[path] ??= new Set(["type *"]);
7883

7984
patches.push([
80-
`"${typeName}"(value: ${dirnameToVar(path)}.${shapeImport.name}, transformType: 'encode' | 'decode') {\n${
81-
indent(`if (value == null) return value;`) + "\n"
85+
`"${descMessage.typeName}"(value: ${dirnameToVar(path)}.${shapeImport.name} | undefined | null, transformType: 'encode' | 'decode') {\n${
86+
indent(`if (value == null) return;`) + "\n"
8287
+ indent(`const newValue = { ...value };`) + "\n"
83-
+ indent(encoded.join(";\n")) + "\n"
88+
+ indent(encoded.join("\n")) + "\n"
8489
+ indent("return newValue;")
8590
}\n}`,
8691
].join("\n"));
8792
});
8893

8994
const importExtension = schema.options.importExtension ? `.${schema.options.importExtension}` : "";
9095
Object.entries(imports).forEach(([path, symbols]) => {
91-
f.print(`import ${generateImportSymbols(path, symbols)} from "${path}${importExtension}";`);
96+
patchesFile.print(`import ${generateImportSymbols(path, symbols)} from "${path}${importExtension}";`);
9297
});
93-
f.print("");
94-
f.print(`const t = {\n${indent(patches.join(",\n"))}\n};\n`);
95-
f.print(`export const transformations = t;`);
98+
patchesFile.print("");
99+
patchesFile.print(`const p = {\n${indent(patches.join(",\n"))}\n};\n`);
100+
patchesFile.print(`export const patches = p;`);
101+
102+
const testsFile = schema.generateFile(fileName.replace(/\.ts$/, ".spec.ts"));
103+
generateTests(basename(fileName), testsFile, messageToCustomFields);
96104
}
97105
98106
function getOutputFileName(schema: Schema): string {
@@ -121,13 +129,13 @@ function generateNestedFieldUpdate(field: DescField, vars: VarNames) {
121129
const fieldRef = `${vars.value}.${field.localName}`;
122130
const newValueRef = `${vars.newValue}.${field.localName}`;
123131
if (field.fieldKind === "list") {
124-
return `if (${fieldRef}) ${newValueRef} = ${fieldRef}.map((item) => ${vars.fn}(item, transformType));`;
132+
return `if (${fieldRef}) ${newValueRef} = ${fieldRef}.map((item) => ${vars.fn}(item, transformType)!);`;
125133
}
126134
127135
if (field.fieldKind === "map") {
128136
return `if (${fieldRef}) {\n`
129137
+ ` ${newValueRef} = {};\n`
130-
+ ` Object.keys(${fieldRef}).forEach(k => ${newValueRef}[k] = ${vars.fn}(${fieldRef}[k], transformType));\n`
138+
+ ` Object.keys(${fieldRef}).forEach(k => ${newValueRef}[k] = ${vars.fn}(${fieldRef}[k], transformType)!);\n`
131139
+ `}`;
132140
}
133141
@@ -144,7 +152,7 @@ function generateNestedFieldUpdate(field: DescField, vars: VarNames) {
144152
return `if (${fieldRef} != null) ${newValueRef} = ${vars.fn}(${fieldRef}, transformType);`;
145153
}
146154
147-
function generateNestedFieldSetter(field: DescField, vars: VarNames) {
155+
function generateFieldTransformation(field: DescField, vars: VarNames) {
148156
const valueRef = `${vars.value}.${field.localName}`;
149157
const newValueRef = `${vars.newValue}.${field.localName}`;
150158
@@ -167,3 +175,54 @@ function generateImportSymbols(path: string, symbols: Set<string>): string {
167175
if (symbols.has("*")) return `* as ${dirnameToVar(path)}`;
168176
return `{ ${Array.from(symbols).join(", ")} }`;
169177
}
178+
179+
function generateTests(fileName: string, testsFile: GeneratedFile, messageToCustomFields: Map<DescMessage, Set<DescField>>) {
180+
testsFile.print(`import { expect, describe, it } from "@jest/globals";`);
181+
testsFile.print(`import type { DescMessage } from "@bufbuild/protobuf";`);
182+
testsFile.print(`import { patches } from "./${basename(fileName)}";`);
183+
testsFile.print(`import { generateMessage } from "@test/helpers/generateMessage";`);
184+
testsFile.print(`import type { TypePatches } from "../../utils/applyPatches";`);
185+
testsFile.print("");
186+
testsFile.print(`type MessageTypes = Record<string, { fields: string[], schema: DescMessage }>;`);
187+
testsFile.print(`const messageTypes: MessageTypes = {`);
188+
for (const [message, fields] of messageToCustomFields.entries()) {
189+
const importedSchema = testsFile.importSchema(message);
190+
testsFile.print(` "${message.typeName}": {`);
191+
testsFile.print(` fields: ${JSON.stringify(Array.from(fields, (f) => f.localName))},`);
192+
testsFile.print(` schema: `, {
193+
...importedSchema,
194+
from: normalizePath(`${PROTO_PATH}/${importedSchema.from}`),
195+
});
196+
testsFile.print(` },`);
197+
}
198+
testsFile.print(`};`);
199+
testsFile.print(`describe("${fileName}", () => {`);
200+
testsFile.print(` describe.each(Object.entries(patches))('patch %s', (typeName, patch: TypePatches[keyof TypePatches]) => {`);
201+
testsFile.print(` it('returns undefined if receives null or undefined', () => {`);
202+
testsFile.print(` expect(patch(null, 'encode')).toBe(undefined);`);
203+
testsFile.print(` expect(patch(null, 'decode')).toBe(undefined);`);
204+
testsFile.print(` expect(patch(undefined, 'encode')).toBe(undefined);`);
205+
testsFile.print(` expect(patch(undefined, 'decode')).toBe(undefined);`);
206+
testsFile.print(` });`);
207+
testsFile.print("");
208+
testsFile.print(` it.each(generateTestCases(typeName, messageTypes))('patches and returns cloned value: %s', (name, value) => {`);
209+
testsFile.print(` const transformedValue = patch(patch(value, 'encode'), 'decode');`);
210+
testsFile.print(` expect(value).toEqual(transformedValue);`);
211+
testsFile.print(` expect(value).not.toBe(transformedValue);`);
212+
testsFile.print(` });`);
213+
testsFile.print(` });`);
214+
testsFile.print("");
215+
testsFile.print(` function generateTestCases(typeName: string, messageTypes: MessageTypes) {`);
216+
testsFile.print(` const type = messageTypes[typeName];`);
217+
testsFile.print(` const cases = type.fields.map((name) => ["single " + name + " field", generateMessage(type.schema, {`);
218+
testsFile.print(` ...messageTypes,`);
219+
testsFile.print(` [typeName]: {`);
220+
testsFile.print(` ...type,`);
221+
testsFile.print(` fields: [name],`);
222+
testsFile.print(` }`);
223+
testsFile.print(` })]);`);
224+
testsFile.print(` cases.push(["all fields", generateMessage(type.schema, messageTypes)]);`);
225+
testsFile.print(` return cases;`);
226+
testsFile.print(` }`);
227+
testsFile.print("});");
228+
}

ts/script/protoc-gen-sdk-object.ts

+24-22
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env -S node --experimental-strip-types
22

3-
import { type DescMethod, type DescService, hasOption } from "@bufbuild/protobuf";
3+
import { type DescMethod, hasOption } from "@bufbuild/protobuf";
44
import {
55
createEcmaScriptPlugin,
66
getComments,
@@ -17,6 +17,7 @@ runNodeJs(
1717
}),
1818
);
1919

20+
const PROTO_PATH = "./protos";
2021
function generateTs(schema: Schema): void {
2122
const servicesLoaderDefs: string[] = [];
2223
const sdkDefs: Record<string, string> = {};
@@ -25,19 +26,20 @@ function generateTs(schema: Schema): void {
2526
if (!services.length) return;
2627

2728
const msgServiceExtension = findMsgServiceExtension(schema);
29+
const hasMsgService = !!msgServiceExtension && services.some((service) => hasOption(service, msgServiceExtension));
2830

2931
const f = schema.generateFile(getOutputFileName(schema));
30-
let hasMsgService = false;
3132
const importExtension = schema.options.importExtension ? `.${schema.options.importExtension}` : "";
3233

3334
services.forEach((service) => {
34-
const isMsgService = !!msgServiceExtension && hasOption(service, msgServiceExtension);
35-
if (isMsgService) {
36-
hasMsgService = true;
37-
}
35+
const isMsgService = !!msgServiceExtension && (
36+
hasOption(service, msgServiceExtension)
37+
// some cosmos-sdk tx services do not have "cosmos.msg.v1.service" option
38+
|| (service.name === "Msg" && service.typeName.startsWith("cosmos."))
39+
);
3840
const serviceImport = f.importSchema(service);
3941
const serviceImportPath = normalizePath(serviceImport.from.replace(/\.js$/, importExtension));
40-
servicesLoaderDefs.push(`() => import("./protos/${serviceImportPath}").then(m => m.${serviceImport.name})`);
42+
servicesLoaderDefs.push(`() => import("${PROTO_PATH}/${serviceImportPath}").then(m => m.${serviceImport.name})`);
4143
const serviceIndex = servicesLoaderDefs.length - 1;
4244
const serviceMethods = service.methods.map((method, methodIndex) => {
4345
const inputType = f.importJson(method.input);
@@ -46,10 +48,10 @@ function generateTs(schema: Schema): void {
4648
imports.add(importPath);
4749
const methodArgs = [
4850
`input: ${fileNameToScope(importPath)}.${inputType.name}${isInputEmpty ? " = {}" : ""}`,
49-
`options?: ${isTxService(service) ? "TxCallOptions" : "CallOptions"}`,
51+
`options?: ${isMsgService ? "TxCallOptions" : "CallOptions"}`,
5052
];
51-
const methodName = getSdkMethodName(method);
52-
let comment = jsDoc(method);
53+
const methodName = getSdkMethodName(method, hasMsgService && !isMsgService ? "get" : "");
54+
let comment = jsDoc(method, methodName);
5355
if (comment) comment += "\n";
5456

5557
return comment
@@ -76,11 +78,12 @@ function generateTs(schema: Schema): void {
7678
});
7779

7880
Array.from(imports).forEach((importPath) => {
79-
f.print(`import type * as ${fileNameToScope(importPath)} from "${importPath.startsWith("./") ? "./" + normalizePath(`protos/${importPath}${importExtension}`) : importPath}";`);
81+
f.print(`import type * as ${fileNameToScope(importPath)} from "${importPath.startsWith("./") ? "./" + normalizePath(`${PROTO_PATH}/${importPath}${importExtension}`) : importPath}";`);
8082
});
81-
f.print(`import { createClientFactory } from "../sdk/createClientFactory${importExtension}";`);
83+
f.print(`import { createClientFactory } from "../client/createClientFactory${importExtension}";`);
8284

83-
f.print(`import type { Transport,CallOptions${hasMsgService ? ", TxCallOptions" : ""} } from "../transport/index${importExtension}";`);
85+
f.print(`import type { Transport, CallOptions${hasMsgService ? ", TxCallOptions" : ""} } from "../transport/types${importExtension}";`);
86+
f.print(`import type { SDKOptions } from "../sdk/types${importExtension}";`);
8487
f.print(`import { createServiceLoader } from "../utils/createServiceLoader${importExtension}";`);
8588
f.print(`import { withMetadata } from "../utils/sdkMetadata${importExtension}";`);
8689
f.print("\n");
@@ -89,9 +92,9 @@ function generateTs(schema: Schema): void {
8992
const factoryArgs = hasMsgService
9093
? `queryTransport: Transport, txTransport: Transport`
9194
: `transport: Transport`;
92-
f.print(f.export("function", `createSDK(${factoryArgs}) {\n`
93-
+ ` const getClient = createClientFactory(${hasMsgService ? "queryTransport" : "transport"});\n`
94-
+ (hasMsgService ? ` const getMsgClient = createClientFactory(txTransport);\n` : "")
95+
f.print(f.export("function", `createSDK(${factoryArgs}, options?: SDKOptions) {\n`
96+
+ ` const getClient = createClientFactory(${hasMsgService ? "queryTransport" : "transport"}, options?.clientOptions);\n`
97+
+ (hasMsgService ? ` const getMsgClient = createClientFactory(txTransport, options?.clientOptions);\n` : "")
9598
+ ` return ${indent(stringifyObject(sdkDefs)).trim()};\n`
9699
+ `}`,
97100
));
@@ -143,14 +146,13 @@ function setByPath(obj: Record<string, any>, path: string, value: unknown) {
143146
};
144147

145148
const indent = (value: string, tab = " ".repeat(2)) => tab + value.replace(/\n/g, "\n" + tab);
146-
const isTxService = (service: DescService) => service.name === "Msg";
147149

148-
function getSdkMethodName(method: DescMethod) {
149-
if (isTxService(method.parent) || method.name.startsWith("get") || method.name.startsWith("Get")) {
150+
function getSdkMethodName(method: DescMethod, prefix: string): string {
151+
if (!prefix || method.name.startsWith(prefix) || method.name.startsWith(capitalize(prefix))) {
150152
return decapitalize(method.name);
151153
}
152154

153-
return `get${capitalize(method.name)}`;
155+
return prefix + capitalize(method.name);
154156
}
155157

156158
function capitalize(str: string): string {
@@ -180,15 +182,15 @@ function fileNameToScope(fileName: string) {
180182
return normalizePath(fileName).replace(/\W+/g, "_").replace(/^_+/, "");
181183
}
182184
183-
function jsDoc(method: DescMethod) {
185+
function jsDoc(method: DescMethod, methodName: string) {
184186
const comments: string[] = [];
185187
const methodComments = getComments(method);
186188
187189
if (methodComments.leading) {
188190
comments.push(methodComments.leading
189191
.replace(/^ *buf:lint:.+[\n\r]*/mg, "")
190192
.trim()
191-
.replace(new RegExp(`\\b${method.name}\\b`, "g"), getSdkMethodName(method))
193+
.replace(new RegExp(`\\b${method.name}\\b`, "g"), methodName)
192194
.replace(/\n/g, "\n *"),
193195
);
194196
}

ts/src/client/createClientFactory.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { DescService } from "@bufbuild/protobuf";
2+
3+
import type { Transport } from "../transport/types.ts";
4+
import type { Client, ServiceClientOptions } from "./createServiceClient.ts";
5+
import { createServiceClient } from "./createServiceClient.ts";
6+
7+
export function createClientFactory(transport: Transport, options?: ServiceClientOptions) {
8+
const services = Object.create(null);
9+
10+
return function getClient<T extends DescService>(service: T): Client<T> {
11+
if (!services[service.typeName]) {
12+
services[service.typeName] = createServiceClient(service, transport, options);
13+
}
14+
return services[service.typeName] as Client<T>;
15+
};
16+
}

0 commit comments

Comments
 (0)