@webda/grpc 4.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.webda/cache +4 -0
- package/CHANGELOG.md +54 -0
- package/LICENSE +166 -0
- package/README.md +100 -0
- package/lib/grpc-stream.d.ts +109 -0
- package/lib/grpc-stream.d.ts.map +1 -0
- package/lib/grpc-stream.js +162 -0
- package/lib/grpc-stream.js.map +1 -0
- package/lib/grpcservice.service.d.ts +112 -0
- package/lib/grpcservice.service.d.ts.map +1 -0
- package/lib/grpcservice.service.js +459 -0
- package/lib/grpcservice.service.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +4 -0
- package/lib/index.js.map +1 -0
- package/lib/proto-generator.d.ts +14 -0
- package/lib/proto-generator.d.ts.map +1 -0
- package/lib/proto-generator.js +312 -0
- package/lib/proto-generator.js.map +1 -0
- package/package.json +42 -0
- package/src/build-integration.spec.ts +187 -0
- package/src/grpc-stream.spec.ts +417 -0
- package/src/grpc-stream.ts +192 -0
- package/src/grpcservice.service.ts +462 -0
- package/src/grpcservice.spec.ts +1092 -0
- package/src/index.ts +4 -0
- package/src/proto-generator.spec.ts +696 -0
- package/src/proto-generator.ts +338 -0
- package/tsconfig.json +33 -0
- package/vitest.config.ts +20 -0
- package/webda.module.json +111 -0
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
import { suite, test } from "@webda/test";
|
|
2
|
+
import * as assert from "assert";
|
|
3
|
+
import { vi } from "vitest";
|
|
4
|
+
import { existsSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { EventEmitter } from "node:events";
|
|
8
|
+
|
|
9
|
+
// Tracked operations map for the mock
|
|
10
|
+
const mockOperations: Record<string, any> = {};
|
|
11
|
+
const mockSchemas: Record<string, any> = {};
|
|
12
|
+
const coreEventCallbacks: Record<string, Function> = {};
|
|
13
|
+
let mockCallOperationResult: any = {};
|
|
14
|
+
let mockCallOperationError: any = null;
|
|
15
|
+
/** If set, callOperation will use this as the raw _output string (instead of JSON.stringify(mockCallOperationResult)) */
|
|
16
|
+
let mockCallOperationRawOutput: string | undefined = undefined;
|
|
17
|
+
/** Services returned by the mocked useCore().getServices() — set per test to inject HttpServer stubs */
|
|
18
|
+
let mockCoreServices: Record<string, any> = {};
|
|
19
|
+
/** Models returned by the mocked useApplication().getModels() — used by build() */
|
|
20
|
+
let mockModels: Record<string, any> = {};
|
|
21
|
+
|
|
22
|
+
vi.mock("@webda/core", () => {
|
|
23
|
+
class MockServiceParameters {
|
|
24
|
+
load(params: any = {}) {
|
|
25
|
+
Object.assign(this, params);
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class MockOperationsTransportParameters extends MockServiceParameters {
|
|
31
|
+
operations?: string[];
|
|
32
|
+
load(params: any = {}) {
|
|
33
|
+
super.load(params);
|
|
34
|
+
this.operations ??= ["*"];
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class MockService {
|
|
40
|
+
parameters: any;
|
|
41
|
+
_name: string;
|
|
42
|
+
|
|
43
|
+
constructor(name?: string, params?: any) {
|
|
44
|
+
this._name = name || "test";
|
|
45
|
+
this.parameters = params || {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
log(_level: string, ..._args: any[]) {}
|
|
49
|
+
|
|
50
|
+
async resolve() {
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async init() {
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async stop() {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class MockOperationsTransport extends MockService {
|
|
62
|
+
getOperations() {
|
|
63
|
+
return mockOperations;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
exposeOperation(_opId: string, _def: any) {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
Service: MockService,
|
|
71
|
+
ServiceParameters: MockServiceParameters,
|
|
72
|
+
OperationsTransport: MockOperationsTransport,
|
|
73
|
+
OperationsTransportParameters: MockOperationsTransportParameters,
|
|
74
|
+
OperationDefinition: {},
|
|
75
|
+
callOperation: async (ctx: any, opId: string) => {
|
|
76
|
+
if (mockCallOperationError) {
|
|
77
|
+
throw mockCallOperationError;
|
|
78
|
+
}
|
|
79
|
+
if (mockCallOperationRawOutput !== undefined) {
|
|
80
|
+
ctx._output = mockCallOperationRawOutput;
|
|
81
|
+
} else {
|
|
82
|
+
ctx._output = JSON.stringify(mockCallOperationResult);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
OperationContext: class {},
|
|
86
|
+
SimpleOperationContext: class {
|
|
87
|
+
_input: Buffer;
|
|
88
|
+
_output: string;
|
|
89
|
+
|
|
90
|
+
async init() {
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setInput(buf: Buffer) {
|
|
95
|
+
this._input = buf;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
getOutput() {
|
|
99
|
+
return this._output;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
WebContext: class {},
|
|
103
|
+
useCoreEvents: (eventName: string, callback: Function) => {
|
|
104
|
+
coreEventCallbacks[eventName] = callback;
|
|
105
|
+
return () => {
|
|
106
|
+
delete coreEventCallbacks[eventName];
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
useApplication: () => ({
|
|
110
|
+
getSchemas: () => mockSchemas,
|
|
111
|
+
getModels: () => mockModels
|
|
112
|
+
}),
|
|
113
|
+
useCore: () => ({ getServices: () => mockCoreServices }),
|
|
114
|
+
useInstanceStorage: () => ({}),
|
|
115
|
+
runWithInstanceStorage: (_storage: any, fn: () => any) => fn(),
|
|
116
|
+
Command: () => () => {},
|
|
117
|
+
BuildCommand: () => () => {}
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
vi.mock("@webda/workout", () => ({
|
|
122
|
+
useLog: () => ({
|
|
123
|
+
info() {},
|
|
124
|
+
warn() {},
|
|
125
|
+
debug() {},
|
|
126
|
+
error() {}
|
|
127
|
+
})
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
vi.mock("@grpc/proto-loader", () => ({
|
|
131
|
+
loadSync: vi.fn(function loadSync() {
|
|
132
|
+
return {};
|
|
133
|
+
})
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
// Dynamic import so mocks are applied first
|
|
137
|
+
const { GrpcService, GrpcServiceParameters } = await import("./grpcservice.service.js");
|
|
138
|
+
const { GrpcStatus } = await import("./grpc-stream.js");
|
|
139
|
+
|
|
140
|
+
@suite
|
|
141
|
+
class GrpcServiceParametersTest {
|
|
142
|
+
@test
|
|
143
|
+
defaultValues() {
|
|
144
|
+
const params = new GrpcServiceParameters();
|
|
145
|
+
params.load({});
|
|
146
|
+
assert.strictEqual(params.protoFile, ".webda/app.proto");
|
|
147
|
+
assert.strictEqual(params.packageName, "webda");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@test
|
|
151
|
+
customValues() {
|
|
152
|
+
const params = new GrpcServiceParameters();
|
|
153
|
+
params.load({
|
|
154
|
+
protoFile: "custom.proto",
|
|
155
|
+
packageName: "myapp"
|
|
156
|
+
});
|
|
157
|
+
assert.strictEqual(params.protoFile, "custom.proto");
|
|
158
|
+
assert.strictEqual(params.packageName, "myapp");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@test
|
|
162
|
+
defaultsNotOverriddenWhenValueProvided() {
|
|
163
|
+
const params = new GrpcServiceParameters();
|
|
164
|
+
params.load({ protoFile: "my.proto" });
|
|
165
|
+
assert.strictEqual(params.protoFile, "my.proto");
|
|
166
|
+
assert.strictEqual(params.packageName, "webda");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@suite
|
|
171
|
+
class GrpcServiceExposeOperationTest {
|
|
172
|
+
@test
|
|
173
|
+
exposeOperationIsNoOp() {
|
|
174
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
175
|
+
|
|
176
|
+
// Should not throw or do anything
|
|
177
|
+
service.exposeOperation("Post.Create", { service: "PostService", method: "create" });
|
|
178
|
+
service.exposeOperation("User.Get", { service: "UserService", method: "get" });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@suite
|
|
183
|
+
class GrpcServiceCountServicesTest {
|
|
184
|
+
@test
|
|
185
|
+
countDistinctPrefixes() {
|
|
186
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
187
|
+
|
|
188
|
+
const operations = {
|
|
189
|
+
"Post.Create": {},
|
|
190
|
+
"Post.Get": {},
|
|
191
|
+
"User.Login": {},
|
|
192
|
+
"User.Logout": {},
|
|
193
|
+
"Admin.Stats": {}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const count = (service as any).countServices(operations);
|
|
197
|
+
assert.strictEqual(count, 3);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@test
|
|
201
|
+
operationsWithoutDotUseDefault() {
|
|
202
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
203
|
+
|
|
204
|
+
const operations = {
|
|
205
|
+
Health: {},
|
|
206
|
+
Ping: {},
|
|
207
|
+
"Post.Create": {}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// "Health" and "Ping" both map to "Default", plus "Post"
|
|
211
|
+
const count = (service as any).countServices(operations);
|
|
212
|
+
assert.strictEqual(count, 2);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@test
|
|
216
|
+
emptyOperations() {
|
|
217
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
218
|
+
const count = (service as any).countServices({});
|
|
219
|
+
assert.strictEqual(count, 0);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@suite
|
|
224
|
+
class GrpcServiceErrorMappingTest {
|
|
225
|
+
@test
|
|
226
|
+
maps400ToInvalidArgument() {
|
|
227
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
228
|
+
const err = { getResponseCode: () => 400, message: "Bad request" };
|
|
229
|
+
assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.INVALID_ARGUMENT);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@test
|
|
233
|
+
maps401ToUnauthenticated() {
|
|
234
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
235
|
+
const err = { getResponseCode: () => 401, message: "Unauthorized" };
|
|
236
|
+
assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.UNAUTHENTICATED);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
@test
|
|
240
|
+
maps403ToPermissionDenied() {
|
|
241
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
242
|
+
const err = { getResponseCode: () => 403, message: "Forbidden" };
|
|
243
|
+
assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.PERMISSION_DENIED);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@test
|
|
247
|
+
maps404ToNotFound() {
|
|
248
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
249
|
+
const err = { getResponseCode: () => 404, message: "Not found" };
|
|
250
|
+
assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.NOT_FOUND);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@test
|
|
254
|
+
maps409ToAlreadyExists() {
|
|
255
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
256
|
+
const err = { getResponseCode: () => 409, message: "Conflict" };
|
|
257
|
+
assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.ALREADY_EXISTS);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@test
|
|
261
|
+
maps429ToResourceExhausted() {
|
|
262
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
263
|
+
const err = { getResponseCode: () => 429, message: "Too many requests" };
|
|
264
|
+
assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.RESOURCE_EXHAUSTED);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
@test
|
|
268
|
+
mapsUnknownCodeToInternal() {
|
|
269
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
270
|
+
const err = { getResponseCode: () => 500, message: "Server error" };
|
|
271
|
+
assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.INTERNAL);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
@test
|
|
275
|
+
mapsErrorWithoutGetResponseCodeToInternal() {
|
|
276
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
277
|
+
const err = new Error("plain error");
|
|
278
|
+
assert.strictEqual((service as any).errorToGrpcStatus(err), GrpcStatus.INTERNAL);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
@test
|
|
282
|
+
mapsNullErrorToInternal() {
|
|
283
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
284
|
+
assert.strictEqual((service as any).errorToGrpcStatus(null), GrpcStatus.INTERNAL);
|
|
285
|
+
assert.strictEqual((service as any).errorToGrpcStatus(undefined), GrpcStatus.INTERNAL);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
@suite
|
|
290
|
+
class GrpcServiceGenerateProtoTest {
|
|
291
|
+
private tmpDir: string;
|
|
292
|
+
|
|
293
|
+
beforeEach() {
|
|
294
|
+
this.tmpDir = join(tmpdir(), `grpc-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
295
|
+
mkdirSync(this.tmpDir, { recursive: true });
|
|
296
|
+
|
|
297
|
+
// Clear mockOperations
|
|
298
|
+
for (const key of Object.keys(mockOperations)) {
|
|
299
|
+
delete mockOperations[key];
|
|
300
|
+
}
|
|
301
|
+
for (const key of Object.keys(mockSchemas)) {
|
|
302
|
+
delete mockSchemas[key];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
afterEach() {
|
|
307
|
+
try {
|
|
308
|
+
rmSync(this.tmpDir, { recursive: true, force: true });
|
|
309
|
+
} catch {
|
|
310
|
+
// Ignore cleanup errors
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
@test
|
|
315
|
+
async writesProtoFile() {
|
|
316
|
+
mockOperations["Post.Create"] = {
|
|
317
|
+
service: "PostService",
|
|
318
|
+
method: "create"
|
|
319
|
+
};
|
|
320
|
+
mockOperations["Post.Get"] = {
|
|
321
|
+
service: "PostService",
|
|
322
|
+
method: "get"
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const outPath = join(this.tmpDir, "output", "app.proto");
|
|
326
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: outPath }));
|
|
327
|
+
|
|
328
|
+
await service.build();
|
|
329
|
+
|
|
330
|
+
assert.ok(existsSync(outPath), "Proto file should be written");
|
|
331
|
+
const content = readFileSync(outPath, "utf-8");
|
|
332
|
+
assert.ok(content.includes('syntax = "proto3"'), "Should contain proto3 syntax");
|
|
333
|
+
assert.ok(content.includes("service PostService {"), "Should contain PostService");
|
|
334
|
+
assert.ok(content.includes("rpc Create"), "Should contain Create rpc");
|
|
335
|
+
assert.ok(content.includes("rpc Get"), "Should contain Get rpc");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
@test
|
|
339
|
+
async writesProtoFileToConfiguredPath() {
|
|
340
|
+
mockOperations["Health"] = {
|
|
341
|
+
service: "HealthService",
|
|
342
|
+
method: "check"
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const customPath = join(this.tmpDir, "custom.proto");
|
|
346
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: customPath }));
|
|
347
|
+
|
|
348
|
+
await service.build();
|
|
349
|
+
|
|
350
|
+
assert.ok(existsSync(customPath), "Proto file should be written to configured path");
|
|
351
|
+
const content = readFileSync(customPath, "utf-8");
|
|
352
|
+
assert.ok(content.includes("service DefaultService {"), "Should contain DefaultService");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
@test
|
|
356
|
+
async usesCustomPackageName() {
|
|
357
|
+
mockOperations["Test.Ping"] = {};
|
|
358
|
+
|
|
359
|
+
const outPath = join(this.tmpDir, "test.proto");
|
|
360
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: outPath, packageName: "myapp" }));
|
|
361
|
+
|
|
362
|
+
await service.build();
|
|
363
|
+
|
|
364
|
+
const content = readFileSync(outPath, "utf-8");
|
|
365
|
+
assert.ok(content.includes("package myapp;"), "Should use custom package name");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
@test
|
|
369
|
+
async createsDirectoryIfNotExists() {
|
|
370
|
+
mockOperations["Test.Op"] = {};
|
|
371
|
+
|
|
372
|
+
const deepPath = join(this.tmpDir, "deep", "nested", "dir", "app.proto");
|
|
373
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: deepPath }));
|
|
374
|
+
|
|
375
|
+
await service.build();
|
|
376
|
+
|
|
377
|
+
assert.ok(existsSync(deepPath), "Should create nested directories and write file");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
@test
|
|
381
|
+
async foldsModelInputSchemasIntoSchemasMap() {
|
|
382
|
+
// app.getModels() returns models whose Metadata.Schemas.Input should be
|
|
383
|
+
// merged into the schema map used for proto generation.
|
|
384
|
+
mockOperations["Post.Create"] = { service: "PostService", method: "create" };
|
|
385
|
+
mockModels = {
|
|
386
|
+
"WebdaSample/Post": {
|
|
387
|
+
Metadata: { Schemas: { Input: { type: "object", properties: { title: { type: "string" } } } } }
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const outPath = join(this.tmpDir, "with-model.proto");
|
|
391
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: outPath }));
|
|
392
|
+
try {
|
|
393
|
+
await service.build();
|
|
394
|
+
assert.ok(existsSync(outPath), "Proto file should be written");
|
|
395
|
+
} finally {
|
|
396
|
+
mockModels = {};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
@test
|
|
401
|
+
async emptyOperationsStillProducesValidProto() {
|
|
402
|
+
const outPath = join(this.tmpDir, "empty.proto");
|
|
403
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: outPath }));
|
|
404
|
+
|
|
405
|
+
await service.build();
|
|
406
|
+
|
|
407
|
+
assert.ok(existsSync(outPath), "Proto file should be written even with no operations");
|
|
408
|
+
const content = readFileSync(outPath, "utf-8");
|
|
409
|
+
assert.ok(content.includes('syntax = "proto3"'));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Create a gRPC frame from a JSON message
|
|
415
|
+
*/
|
|
416
|
+
function createGrpcFrame(data: any): Buffer {
|
|
417
|
+
const payload = Buffer.from(JSON.stringify(data));
|
|
418
|
+
const frame = Buffer.alloc(5 + payload.length);
|
|
419
|
+
frame.writeUInt8(0, 0);
|
|
420
|
+
frame.writeUInt32BE(payload.length, 1);
|
|
421
|
+
payload.copy(frame, 5);
|
|
422
|
+
return frame;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Create a mock request EventEmitter that simulates an HTTP request with gRPC data
|
|
427
|
+
*/
|
|
428
|
+
function createMockReq(url: string, frameData?: any): EventEmitter & { url: string; headers: Record<string, string> } {
|
|
429
|
+
const req = new EventEmitter() as any;
|
|
430
|
+
req.url = url;
|
|
431
|
+
req.headers = { "content-type": "application/grpc" };
|
|
432
|
+
|
|
433
|
+
if (frameData !== undefined) {
|
|
434
|
+
// Schedule data emission on next tick so handlers can be registered
|
|
435
|
+
process.nextTick(() => {
|
|
436
|
+
const frame = createGrpcFrame(frameData);
|
|
437
|
+
req.emit("data", frame);
|
|
438
|
+
// For client/bidi streaming, also emit end
|
|
439
|
+
req.emit("end");
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return req;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Create a mock response that captures what was written
|
|
448
|
+
*/
|
|
449
|
+
function createMockRes(): {
|
|
450
|
+
res: any;
|
|
451
|
+
written: Buffer[];
|
|
452
|
+
trailers: Record<string, string> | null;
|
|
453
|
+
ended: boolean;
|
|
454
|
+
headStatus: number;
|
|
455
|
+
headHeaders: Record<string, string>;
|
|
456
|
+
headers: Record<string, string>;
|
|
457
|
+
} {
|
|
458
|
+
const state = {
|
|
459
|
+
written: [] as Buffer[],
|
|
460
|
+
trailers: null as Record<string, string> | null,
|
|
461
|
+
ended: false,
|
|
462
|
+
headStatus: 0,
|
|
463
|
+
headHeaders: {} as Record<string, string>,
|
|
464
|
+
headers: {} as Record<string, string>,
|
|
465
|
+
res: null as any
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
state.res = {
|
|
469
|
+
setHeader(key: string, value: string) {
|
|
470
|
+
state.headers[key] = value;
|
|
471
|
+
},
|
|
472
|
+
writeHead(status: number, headers: any) {
|
|
473
|
+
state.headStatus = status;
|
|
474
|
+
state.headHeaders = headers || {};
|
|
475
|
+
},
|
|
476
|
+
write(data: Buffer) {
|
|
477
|
+
state.written.push(Buffer.from(data));
|
|
478
|
+
},
|
|
479
|
+
addTrailers(t: Record<string, string>) {
|
|
480
|
+
state.trailers = t;
|
|
481
|
+
},
|
|
482
|
+
end() {
|
|
483
|
+
state.ended = true;
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
return state;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Create a JSON-based method definition mock
|
|
492
|
+
*/
|
|
493
|
+
function createJsonMethodDef() {
|
|
494
|
+
return {
|
|
495
|
+
path: "/webda.TestService/TestMethod",
|
|
496
|
+
requestSerialize: (msg: any) => Buffer.from(JSON.stringify(msg)),
|
|
497
|
+
requestDeserialize: (buf: Buffer) => JSON.parse(buf.toString()),
|
|
498
|
+
responseSerialize: (msg: any) => Buffer.from(JSON.stringify(msg)),
|
|
499
|
+
responseDeserialize: (buf: Buffer) => JSON.parse(buf.toString()),
|
|
500
|
+
requestStream: false,
|
|
501
|
+
responseStream: false
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Set up a service with a mapped rpc method
|
|
507
|
+
*/
|
|
508
|
+
function setupServiceWithRpc(opId: string, opDef: any = {}): InstanceType<typeof GrpcService> {
|
|
509
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
510
|
+
const methodDef = createJsonMethodDef();
|
|
511
|
+
const grpcPath = `/webda.TestService/${opId.split(".").pop()}`;
|
|
512
|
+
|
|
513
|
+
// Manually set up the rpcToOperation and rpcMethods maps
|
|
514
|
+
(service as any).rpcToOperation.set(grpcPath, opId);
|
|
515
|
+
(service as any).rpcMethods.set(grpcPath, methodDef);
|
|
516
|
+
|
|
517
|
+
// Set up the mock operation
|
|
518
|
+
mockOperations[opId] = opDef;
|
|
519
|
+
|
|
520
|
+
return service;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
@suite
|
|
524
|
+
class GrpcServiceHandleGrpcRequestTest {
|
|
525
|
+
beforeEach() {
|
|
526
|
+
// Clear operations
|
|
527
|
+
for (const key of Object.keys(mockOperations)) {
|
|
528
|
+
delete mockOperations[key];
|
|
529
|
+
}
|
|
530
|
+
mockCallOperationResult = {};
|
|
531
|
+
mockCallOperationError = null;
|
|
532
|
+
mockCallOperationRawOutput = undefined;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
@test
|
|
536
|
+
async unimplementedMethodReturns12() {
|
|
537
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
538
|
+
|
|
539
|
+
let writtenHeaders: any = {};
|
|
540
|
+
let ended = false;
|
|
541
|
+
const req = { url: "/webda.UnknownService/UnknownMethod" };
|
|
542
|
+
const res = {
|
|
543
|
+
writeHead(status: number, headers: any) {
|
|
544
|
+
writtenHeaders = headers;
|
|
545
|
+
},
|
|
546
|
+
end() {
|
|
547
|
+
ended = true;
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
await service.handleGrpcRequest(req as any, res as any);
|
|
552
|
+
|
|
553
|
+
assert.strictEqual(writtenHeaders["grpc-status"], String(GrpcStatus.UNIMPLEMENTED));
|
|
554
|
+
const decodedMessage = decodeURIComponent(writtenHeaders["grpc-message"]);
|
|
555
|
+
assert.ok(decodedMessage.includes("Method not found"), `Expected 'Method not found' in: ${decodedMessage}`);
|
|
556
|
+
assert.ok(ended);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
@test
|
|
560
|
+
async unaryRequestReturnsResponse() {
|
|
561
|
+
const service = setupServiceWithRpc("Test.DoSomething", {});
|
|
562
|
+
mockCallOperationResult = { success: true, id: "123" };
|
|
563
|
+
|
|
564
|
+
const grpcPath = "/webda.TestService/DoSomething";
|
|
565
|
+
const req = createMockReq(grpcPath, { input: "hello" });
|
|
566
|
+
const mock = createMockRes();
|
|
567
|
+
|
|
568
|
+
await service.handleGrpcRequest(req as any, mock.res);
|
|
569
|
+
|
|
570
|
+
// Should have written a response frame
|
|
571
|
+
assert.strictEqual(mock.written.length, 1);
|
|
572
|
+
const payload = mock.written[0].subarray(5);
|
|
573
|
+
const response = JSON.parse(payload.toString());
|
|
574
|
+
assert.deepStrictEqual(response, { success: true, id: "123" });
|
|
575
|
+
|
|
576
|
+
// Should have ended with status 0
|
|
577
|
+
assert.ok(mock.trailers !== null);
|
|
578
|
+
assert.strictEqual(mock.trailers!["grpc-status"], "0");
|
|
579
|
+
assert.ok(mock.ended);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
@test
|
|
583
|
+
async unaryWrapsScalarOutputInValueField() {
|
|
584
|
+
// When the operation returns a JSON-parsed scalar (not an object), wrap in {value}
|
|
585
|
+
const service = setupServiceWithRpc("Test.GetVersion", {});
|
|
586
|
+
mockCallOperationRawOutput = '"1.2.3"';
|
|
587
|
+
|
|
588
|
+
const grpcPath = "/webda.TestService/GetVersion";
|
|
589
|
+
const req = createMockReq(grpcPath, {});
|
|
590
|
+
const mock = createMockRes();
|
|
591
|
+
await service.handleGrpcRequest(req as any, mock.res);
|
|
592
|
+
assert.strictEqual(mock.written.length, 1);
|
|
593
|
+
const response = JSON.parse(mock.written[0].subarray(5).toString());
|
|
594
|
+
assert.deepStrictEqual(response, { value: "1.2.3" });
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
@test
|
|
598
|
+
async unaryWrapsUnparseableOutputInValueField() {
|
|
599
|
+
// When the operation output isn't valid JSON, fall through to {value: rawString}
|
|
600
|
+
const service = setupServiceWithRpc("Test.Raw", {});
|
|
601
|
+
mockCallOperationRawOutput = "not-json-at-all";
|
|
602
|
+
|
|
603
|
+
const grpcPath = "/webda.TestService/Raw";
|
|
604
|
+
const req = createMockReq(grpcPath, {});
|
|
605
|
+
const mock = createMockRes();
|
|
606
|
+
await service.handleGrpcRequest(req as any, mock.res);
|
|
607
|
+
assert.strictEqual(mock.written.length, 1);
|
|
608
|
+
const response = JSON.parse(mock.written[0].subarray(5).toString());
|
|
609
|
+
assert.deepStrictEqual(response, { value: "not-json-at-all" });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
@test
|
|
613
|
+
async unaryRequestHandlesOperationError() {
|
|
614
|
+
const service = setupServiceWithRpc("Test.FailOp", {});
|
|
615
|
+
mockCallOperationError = {
|
|
616
|
+
getResponseCode: () => 404,
|
|
617
|
+
message: "Resource not found"
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const grpcPath = "/webda.TestService/FailOp";
|
|
621
|
+
const req = createMockReq(grpcPath, { id: "missing" });
|
|
622
|
+
const mock = createMockRes();
|
|
623
|
+
|
|
624
|
+
await service.handleGrpcRequest(req as any, mock.res);
|
|
625
|
+
|
|
626
|
+
// Should have ended with error status
|
|
627
|
+
assert.ok(mock.trailers !== null);
|
|
628
|
+
assert.strictEqual(mock.trailers!["grpc-status"], String(GrpcStatus.NOT_FOUND));
|
|
629
|
+
assert.ok(mock.ended);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
@test
|
|
633
|
+
async serverStreamingReturnsMultipleMessages() {
|
|
634
|
+
const service = setupServiceWithRpc("Test.StreamItems", {
|
|
635
|
+
grpc: { streaming: "server" }
|
|
636
|
+
});
|
|
637
|
+
mockCallOperationRawOutput = '{"id":1}\n{"id":2}\n{"id":3}';
|
|
638
|
+
|
|
639
|
+
const grpcPath = "/webda.TestService/StreamItems";
|
|
640
|
+
const req = createMockReq(grpcPath, { query: "all" });
|
|
641
|
+
const mock = createMockRes();
|
|
642
|
+
|
|
643
|
+
await service.handleGrpcRequest(req as any, mock.res);
|
|
644
|
+
|
|
645
|
+
// Should have written 3 response frames (one per NDJSON line)
|
|
646
|
+
assert.strictEqual(mock.written.length, 3, `Expected 3 frames, got ${mock.written.length}`);
|
|
647
|
+
|
|
648
|
+
const msg1 = JSON.parse(mock.written[0].subarray(5).toString());
|
|
649
|
+
const msg2 = JSON.parse(mock.written[1].subarray(5).toString());
|
|
650
|
+
const msg3 = JSON.parse(mock.written[2].subarray(5).toString());
|
|
651
|
+
assert.deepStrictEqual(msg1, { id: 1 });
|
|
652
|
+
assert.deepStrictEqual(msg2, { id: 2 });
|
|
653
|
+
assert.deepStrictEqual(msg3, { id: 3 });
|
|
654
|
+
|
|
655
|
+
// Should end with OK
|
|
656
|
+
assert.strictEqual(mock.trailers!["grpc-status"], "0");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
@test
|
|
660
|
+
async serverStreamingHandlesNonJsonLines() {
|
|
661
|
+
const service = setupServiceWithRpc("Test.StreamRaw", {
|
|
662
|
+
grpc: { streaming: "server" }
|
|
663
|
+
});
|
|
664
|
+
mockCallOperationRawOutput = 'not-json-line\n{"valid":true}';
|
|
665
|
+
|
|
666
|
+
const grpcPath = "/webda.TestService/StreamRaw";
|
|
667
|
+
const req = createMockReq(grpcPath, {});
|
|
668
|
+
const mock = createMockRes();
|
|
669
|
+
|
|
670
|
+
await service.handleGrpcRequest(req as any, mock.res);
|
|
671
|
+
|
|
672
|
+
// First line is not valid JSON, should be wrapped in { data: ... }
|
|
673
|
+
assert.strictEqual(mock.written.length, 2);
|
|
674
|
+
const msg1 = JSON.parse(mock.written[0].subarray(5).toString());
|
|
675
|
+
assert.deepStrictEqual(msg1, { data: "not-json-line" });
|
|
676
|
+
const msg2 = JSON.parse(mock.written[1].subarray(5).toString());
|
|
677
|
+
assert.deepStrictEqual(msg2, { valid: true });
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
@test
|
|
681
|
+
async serverStreamingHandlesNoOutput() {
|
|
682
|
+
const service = setupServiceWithRpc("Test.StreamEmpty", {
|
|
683
|
+
grpc: { streaming: "server" }
|
|
684
|
+
});
|
|
685
|
+
// Set rawOutput to empty string to simulate no output
|
|
686
|
+
mockCallOperationRawOutput = "";
|
|
687
|
+
|
|
688
|
+
const grpcPath = "/webda.TestService/StreamEmpty";
|
|
689
|
+
const req = createMockReq(grpcPath, {});
|
|
690
|
+
const mock = createMockRes();
|
|
691
|
+
|
|
692
|
+
await service.handleGrpcRequest(req as any, mock.res);
|
|
693
|
+
|
|
694
|
+
// No data frames should be written (empty string filtered by split+filter)
|
|
695
|
+
assert.strictEqual(mock.written.length, 0);
|
|
696
|
+
assert.strictEqual(mock.trailers!["grpc-status"], "0");
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
@test
|
|
700
|
+
async serverStreamingHandlesOperationError() {
|
|
701
|
+
const service = setupServiceWithRpc("Test.StreamFail", {
|
|
702
|
+
grpc: { streaming: "server" }
|
|
703
|
+
});
|
|
704
|
+
const err: any = new Error("Stream failed");
|
|
705
|
+
err.getResponseCode = () => 400;
|
|
706
|
+
mockCallOperationError = err;
|
|
707
|
+
|
|
708
|
+
const grpcPath = "/webda.TestService/StreamFail";
|
|
709
|
+
const req = createMockReq(grpcPath, {});
|
|
710
|
+
const mock = createMockRes();
|
|
711
|
+
|
|
712
|
+
await service.handleGrpcRequest(req as any, mock.res);
|
|
713
|
+
|
|
714
|
+
assert.strictEqual(mock.trailers!["grpc-status"], String(GrpcStatus.INVALID_ARGUMENT));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
@test
|
|
718
|
+
async clientStreamingCollectsMessagesAndResponds() {
|
|
719
|
+
const service = setupServiceWithRpc("Test.Upload", {
|
|
720
|
+
grpc: { streaming: "client" }
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const grpcPath = "/webda.TestService/Upload";
|
|
724
|
+
|
|
725
|
+
// Create mock req that will emit multiple messages then end
|
|
726
|
+
const req = new EventEmitter() as any;
|
|
727
|
+
req.url = grpcPath;
|
|
728
|
+
req.headers = { "content-type": "application/grpc" };
|
|
729
|
+
|
|
730
|
+
const mock = createMockRes();
|
|
731
|
+
|
|
732
|
+
mockCallOperationResult = { count: 2, status: "received" };
|
|
733
|
+
|
|
734
|
+
const promise = service.handleGrpcRequest(req as any, mock.res);
|
|
735
|
+
|
|
736
|
+
// Emit two messages
|
|
737
|
+
process.nextTick(() => {
|
|
738
|
+
req.emit("data", createGrpcFrame({ item: "a" }));
|
|
739
|
+
req.emit("data", createGrpcFrame({ item: "b" }));
|
|
740
|
+
// End the stream
|
|
741
|
+
req.emit("end");
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
await promise;
|
|
745
|
+
|
|
746
|
+
// Should respond with a unary response (sendUnary)
|
|
747
|
+
assert.strictEqual(mock.written.length, 1);
|
|
748
|
+
const response = JSON.parse(mock.written[0].subarray(5).toString());
|
|
749
|
+
assert.deepStrictEqual(response, { count: 2, status: "received" });
|
|
750
|
+
assert.strictEqual(mock.trailers!["grpc-status"], "0");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
@test
|
|
754
|
+
async bidiStreamingCollectsAndSendsResponse() {
|
|
755
|
+
const service = setupServiceWithRpc("Test.Chat", {
|
|
756
|
+
grpc: { streaming: "bidi" }
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
const grpcPath = "/webda.TestService/Chat";
|
|
760
|
+
const req = new EventEmitter() as any;
|
|
761
|
+
req.url = grpcPath;
|
|
762
|
+
req.headers = { "content-type": "application/grpc" };
|
|
763
|
+
|
|
764
|
+
const mock = createMockRes();
|
|
765
|
+
|
|
766
|
+
mockCallOperationResult = { reply: "ack" };
|
|
767
|
+
|
|
768
|
+
const promise = service.handleGrpcRequest(req as any, mock.res);
|
|
769
|
+
|
|
770
|
+
process.nextTick(() => {
|
|
771
|
+
req.emit("data", createGrpcFrame({ msg: "hello" }));
|
|
772
|
+
req.emit("end");
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
await promise;
|
|
776
|
+
|
|
777
|
+
// Bidi uses stream.send (not sendUnary), so no trailers with end
|
|
778
|
+
// The current implementation sends once then resolves
|
|
779
|
+
assert.strictEqual(mock.written.length, 1);
|
|
780
|
+
const response = JSON.parse(mock.written[0].subarray(5).toString());
|
|
781
|
+
assert.deepStrictEqual(response, { reply: "ack" });
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
@test
|
|
785
|
+
async clientStreamingHandlesOperationError() {
|
|
786
|
+
const service = setupServiceWithRpc("Test.UploadFail", {
|
|
787
|
+
grpc: { streaming: "client" }
|
|
788
|
+
});
|
|
789
|
+
const err: any = new Error("Upload failed");
|
|
790
|
+
err.getResponseCode = () => 401;
|
|
791
|
+
mockCallOperationError = err;
|
|
792
|
+
|
|
793
|
+
const grpcPath = "/webda.TestService/UploadFail";
|
|
794
|
+
const req = new EventEmitter() as any;
|
|
795
|
+
req.url = grpcPath;
|
|
796
|
+
req.headers = { "content-type": "application/grpc" };
|
|
797
|
+
|
|
798
|
+
const mock = createMockRes();
|
|
799
|
+
|
|
800
|
+
const promise = service.handleGrpcRequest(req as any, mock.res);
|
|
801
|
+
|
|
802
|
+
process.nextTick(() => {
|
|
803
|
+
req.emit("data", createGrpcFrame({ item: "x" }));
|
|
804
|
+
req.emit("end");
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
await promise;
|
|
808
|
+
|
|
809
|
+
assert.strictEqual(mock.trailers!["grpc-status"], String(GrpcStatus.UNAUTHENTICATED));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
@test
|
|
813
|
+
async clientStreamingHandlesCancellation() {
|
|
814
|
+
const service = setupServiceWithRpc("Test.CancelUpload", {
|
|
815
|
+
grpc: { streaming: "client" }
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const grpcPath = "/webda.TestService/CancelUpload";
|
|
819
|
+
const req = new EventEmitter() as any;
|
|
820
|
+
req.url = grpcPath;
|
|
821
|
+
req.headers = { "content-type": "application/grpc" };
|
|
822
|
+
|
|
823
|
+
const mock = createMockRes();
|
|
824
|
+
|
|
825
|
+
const promise = service.handleGrpcRequest(req as any, mock.res);
|
|
826
|
+
|
|
827
|
+
// Emit close (cancellation) instead of end
|
|
828
|
+
process.nextTick(() => {
|
|
829
|
+
req.emit("close");
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
await promise;
|
|
833
|
+
|
|
834
|
+
// Should resolve without writing data
|
|
835
|
+
assert.strictEqual(mock.written.length, 0);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
@test
|
|
839
|
+
async unaryWithNoGrpcFieldUsesNoneStreaming() {
|
|
840
|
+
// Operation without grpc field should default to unary
|
|
841
|
+
const service = setupServiceWithRpc("Test.Simple", {
|
|
842
|
+
// no grpc field at all
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
mockCallOperationResult = { ok: true };
|
|
846
|
+
|
|
847
|
+
const grpcPath = "/webda.TestService/Simple";
|
|
848
|
+
const req = createMockReq(grpcPath, { query: "test" });
|
|
849
|
+
const mock = createMockRes();
|
|
850
|
+
|
|
851
|
+
await service.handleGrpcRequest(req as any, mock.res);
|
|
852
|
+
|
|
853
|
+
assert.strictEqual(mock.written.length, 1);
|
|
854
|
+
assert.strictEqual(mock.trailers!["grpc-status"], "0");
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
@test
|
|
858
|
+
async unaryWithNullOutputReturnsEmptyObject() {
|
|
859
|
+
const service = setupServiceWithRpc("Test.NoOutput", {});
|
|
860
|
+
mockCallOperationRawOutput = "";
|
|
861
|
+
|
|
862
|
+
const grpcPath = "/webda.TestService/NoOutput";
|
|
863
|
+
const req = createMockReq(grpcPath, {});
|
|
864
|
+
const mock = createMockRes();
|
|
865
|
+
|
|
866
|
+
await service.handleGrpcRequest(req as any, mock.res);
|
|
867
|
+
|
|
868
|
+
assert.strictEqual(mock.written.length, 1);
|
|
869
|
+
const response = JSON.parse(mock.written[0].subarray(5).toString());
|
|
870
|
+
assert.deepStrictEqual(response, {});
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
@suite
|
|
875
|
+
class GrpcServiceBuildRpcMapTest {
|
|
876
|
+
beforeEach() {
|
|
877
|
+
for (const key of Object.keys(mockOperations)) {
|
|
878
|
+
delete mockOperations[key];
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
@test
|
|
883
|
+
async buildRpcMapWithMatchingOperations() {
|
|
884
|
+
mockOperations["Post.Create"] = { service: "PostService", method: "create" };
|
|
885
|
+
mockOperations["Post.Get"] = { service: "PostService", method: "get" };
|
|
886
|
+
|
|
887
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ packageName: "webda" }));
|
|
888
|
+
|
|
889
|
+
// Set up definitions that match the operations
|
|
890
|
+
(service as any).definitions = {
|
|
891
|
+
"webda.PostService.Create": {
|
|
892
|
+
path: "/webda.PostService/Create",
|
|
893
|
+
requestSerialize: () => Buffer.alloc(0),
|
|
894
|
+
requestDeserialize: () => ({}),
|
|
895
|
+
responseSerialize: () => Buffer.alloc(0),
|
|
896
|
+
responseDeserialize: () => ({})
|
|
897
|
+
},
|
|
898
|
+
"webda.PostService.Get": {
|
|
899
|
+
path: "/webda.PostService/Get",
|
|
900
|
+
requestSerialize: () => Buffer.alloc(0),
|
|
901
|
+
requestDeserialize: () => ({}),
|
|
902
|
+
responseSerialize: () => Buffer.alloc(0),
|
|
903
|
+
responseDeserialize: () => ({})
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
(service as any).buildRpcMap();
|
|
908
|
+
|
|
909
|
+
assert.strictEqual((service as any).rpcToOperation.size, 2);
|
|
910
|
+
assert.strictEqual((service as any).rpcToOperation.get("/webda.PostService/Create"), "Post.Create");
|
|
911
|
+
assert.strictEqual((service as any).rpcToOperation.get("/webda.PostService/Get"), "Post.Get");
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
@test
|
|
915
|
+
async buildRpcMapSkipsNonObjectDefs() {
|
|
916
|
+
mockOperations["Post.Create"] = {};
|
|
917
|
+
|
|
918
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
919
|
+
|
|
920
|
+
(service as any).definitions = {
|
|
921
|
+
someString: "not an object",
|
|
922
|
+
someNull: null,
|
|
923
|
+
someNumber: 42
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
(service as any).buildRpcMap();
|
|
927
|
+
|
|
928
|
+
assert.strictEqual((service as any).rpcToOperation.size, 0);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
@test
|
|
932
|
+
async buildRpcMapSkipsDefsWithoutPath() {
|
|
933
|
+
mockOperations["Post.Create"] = {};
|
|
934
|
+
|
|
935
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
936
|
+
|
|
937
|
+
(service as any).definitions = {
|
|
938
|
+
someObj: { requestSerialize: () => Buffer.alloc(0) } // no path
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
(service as any).buildRpcMap();
|
|
942
|
+
|
|
943
|
+
assert.strictEqual((service as any).rpcToOperation.size, 0);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
@test
|
|
947
|
+
async buildRpcMapSkipsUnmatchedOperations() {
|
|
948
|
+
// Operations don't match the gRPC path names
|
|
949
|
+
mockOperations["User.Login"] = {};
|
|
950
|
+
|
|
951
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
952
|
+
|
|
953
|
+
(service as any).definitions = {
|
|
954
|
+
"webda.PostService.Create": {
|
|
955
|
+
path: "/webda.PostService/Create"
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
(service as any).buildRpcMap();
|
|
960
|
+
|
|
961
|
+
// Post.Create is not in mockOperations, so it should not be mapped
|
|
962
|
+
assert.strictEqual((service as any).rpcToOperation.size, 0);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
@test
|
|
966
|
+
async buildRpcMapHandlesUndefinedDefinitions() {
|
|
967
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
968
|
+
|
|
969
|
+
// definitions is not set
|
|
970
|
+
(service as any).definitions = undefined;
|
|
971
|
+
|
|
972
|
+
// Should not throw
|
|
973
|
+
(service as any).buildRpcMap();
|
|
974
|
+
|
|
975
|
+
assert.strictEqual((service as any).rpcToOperation.size, 0);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
@test
|
|
979
|
+
async buildRpcMapHandlesInvalidPathFormat() {
|
|
980
|
+
mockOperations["Post.Create"] = {};
|
|
981
|
+
|
|
982
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
983
|
+
|
|
984
|
+
(service as any).definitions = {
|
|
985
|
+
invalid: {
|
|
986
|
+
path: "/single-segment" // only one segment after split+filter
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
(service as any).buildRpcMap();
|
|
991
|
+
|
|
992
|
+
assert.strictEqual((service as any).rpcToOperation.size, 0);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
@suite
|
|
997
|
+
class GrpcServiceInitTest {
|
|
998
|
+
@test
|
|
999
|
+
async initWithoutProtoFileLogsWarning() {
|
|
1000
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: "/nonexistent/path/app.proto" }));
|
|
1001
|
+
|
|
1002
|
+
// Should not throw
|
|
1003
|
+
await service.init();
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
@test
|
|
1007
|
+
async initWithExistingProtoFileLoadsDefinitions() {
|
|
1008
|
+
const tmpDir = join(tmpdir(), `grpc-init-test-${Date.now()}`);
|
|
1009
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
1010
|
+
const protoPath = join(tmpDir, "app.proto");
|
|
1011
|
+
writeFileSync(protoPath, 'syntax = "proto3"; package webda;');
|
|
1012
|
+
|
|
1013
|
+
try {
|
|
1014
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: protoPath }));
|
|
1015
|
+
// Should not throw — the mock proto-loader returns an empty object
|
|
1016
|
+
await service.init();
|
|
1017
|
+
} finally {
|
|
1018
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
@test
|
|
1023
|
+
async initRegistersInterceptorOnEveryHttpServer() {
|
|
1024
|
+
// Two HttpServer-like services — each should receive the gRPC interceptor
|
|
1025
|
+
const interceptorsA: Function[] = [];
|
|
1026
|
+
const interceptorsB: Function[] = [];
|
|
1027
|
+
mockCoreServices = {
|
|
1028
|
+
HttpServer: { registerRequestInterceptor: (fn: Function) => interceptorsA.push(fn) },
|
|
1029
|
+
HttpServerH2c: { registerRequestInterceptor: (fn: Function) => interceptorsB.push(fn) },
|
|
1030
|
+
Plain: {} // no registerRequestInterceptor — must be skipped
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: "/nonexistent/file.proto" }));
|
|
1034
|
+
await service.init();
|
|
1035
|
+
|
|
1036
|
+
assert.strictEqual(interceptorsA.length, 1, "HttpServer should get the interceptor");
|
|
1037
|
+
assert.strictEqual(interceptorsB.length, 1, "HttpServerH2c should get the interceptor");
|
|
1038
|
+
|
|
1039
|
+
// Interceptor short-circuits non-grpc content types
|
|
1040
|
+
const req = { headers: { "content-type": "text/html" } };
|
|
1041
|
+
const res = {};
|
|
1042
|
+
assert.strictEqual(interceptorsA[0](req, res), false);
|
|
1043
|
+
|
|
1044
|
+
// Interceptor claims application/grpc requests and runs handleGrpcRequest
|
|
1045
|
+
let handleCalled = false;
|
|
1046
|
+
(service as any).handleGrpcRequest = async () => {
|
|
1047
|
+
handleCalled = true;
|
|
1048
|
+
};
|
|
1049
|
+
const grpcReq = {
|
|
1050
|
+
url: "/webda.Missing/Method",
|
|
1051
|
+
headers: { "content-type": "application/grpc" }
|
|
1052
|
+
};
|
|
1053
|
+
const grpcRes = {};
|
|
1054
|
+
assert.strictEqual(interceptorsA[0](grpcReq, grpcRes), true);
|
|
1055
|
+
// handleGrpcRequest fires synchronously inside runWithInstanceStorage
|
|
1056
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
1057
|
+
assert.ok(handleCalled, "Interceptor should dispatch to handleGrpcRequest");
|
|
1058
|
+
|
|
1059
|
+
mockCoreServices = {};
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
@test
|
|
1063
|
+
async initLogsWarningWhenNoHttpServer() {
|
|
1064
|
+
// No services registered → init should log a warning but not throw.
|
|
1065
|
+
mockCoreServices = {};
|
|
1066
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: "/nonexistent/file.proto" }));
|
|
1067
|
+
await service.init();
|
|
1068
|
+
mockCoreServices = {};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
@test
|
|
1072
|
+
async initHandlesProtoLoadError() {
|
|
1073
|
+
const tmpDir = join(tmpdir(), `grpc-init-err-${Date.now()}`);
|
|
1074
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
1075
|
+
const protoPath = join(tmpDir, "bad.proto");
|
|
1076
|
+
writeFileSync(protoPath, "invalid proto content");
|
|
1077
|
+
|
|
1078
|
+
// Make the mock throw on loadSync
|
|
1079
|
+
const protoLoader = await import("@grpc/proto-loader");
|
|
1080
|
+
vi.mocked(protoLoader.loadSync).mockImplementationOnce(() => {
|
|
1081
|
+
throw new Error("Parse error");
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
try {
|
|
1085
|
+
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({ protoFile: protoPath }));
|
|
1086
|
+
// Should not throw — error is caught and logged
|
|
1087
|
+
await service.init();
|
|
1088
|
+
} finally {
|
|
1089
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|