@webda/grpc 4.0.0-beta.6 → 4.0.0-beta.7
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 -4
- package/CHANGELOG.md +23 -0
- package/README.md +24 -10
- package/lib/grpc-context.d.ts +55 -0
- package/lib/grpc-context.d.ts.map +1 -0
- package/lib/grpc-context.js +83 -0
- package/lib/grpc-context.js.map +1 -0
- package/lib/grpc-stream.d.ts +13 -4
- package/lib/grpc-stream.d.ts.map +1 -1
- package/lib/grpc-stream.js +67 -23
- package/lib/grpc-stream.js.map +1 -1
- package/lib/grpcservice.service.d.ts +41 -23
- package/lib/grpcservice.service.d.ts.map +1 -1
- package/lib/grpcservice.service.js +155 -169
- package/lib/grpcservice.service.js.map +1 -1
- package/lib/proto-generator.js +1 -1
- package/lib/proto-generator.js.map +1 -1
- package/package.json +8 -5
- package/src/connect.e2e.spec.ts +114 -0
- package/src/grpc-context.ts +93 -0
- package/src/grpc-live.spec.ts +377 -0
- package/src/grpc-stream.spec.ts +122 -1
- package/src/grpc-stream.ts +63 -25
- package/src/grpcservice.service.ts +165 -196
- package/src/grpcservice.spec.ts +29 -442
- package/src/proto-generator.spec.ts +17 -0
- package/src/proto-generator.ts +1 -1
- package/src/query-statements.spec.ts +39 -13
- package/test/fixture.ts +166 -0
- package/webda.module.json +10 -0
package/src/grpcservice.spec.ts
CHANGED
|
@@ -10,10 +10,10 @@ import { EventEmitter } from "node:events";
|
|
|
10
10
|
const mockOperations: Record<string, any> = {};
|
|
11
11
|
const mockSchemas: Record<string, any> = {};
|
|
12
12
|
const coreEventCallbacks: Record<string, Function> = {};
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
const mockCallOperationResult: any = {};
|
|
14
|
+
const mockCallOperationError: any = null;
|
|
15
15
|
/** If set, callOperation will use this as the raw _output string (instead of JSON.stringify(mockCallOperationResult)) */
|
|
16
|
-
|
|
16
|
+
const mockCallOperationRawOutput: string | undefined = undefined;
|
|
17
17
|
/** Services returned by the mocked useCore().getServices() — set per test to inject HttpServer stubs */
|
|
18
18
|
let mockCoreServices: Record<string, any> = {};
|
|
19
19
|
/** Models returned by the mocked useApplication().getModels() — used by build() */
|
|
@@ -83,6 +83,10 @@ vi.mock("@webda/core", () => {
|
|
|
83
83
|
}
|
|
84
84
|
},
|
|
85
85
|
OperationContext: class {},
|
|
86
|
+
AsyncQueue: class {},
|
|
87
|
+
HttpContext: class {},
|
|
88
|
+
WebdaError: { OperationCancelledError: class extends Error {} },
|
|
89
|
+
getOperationStreaming: (op: any) => op?.streaming ?? op?.grpc?.streaming ?? "none",
|
|
86
90
|
SimpleOperationContext: class {
|
|
87
91
|
_input: Buffer;
|
|
88
92
|
_output: string;
|
|
@@ -100,6 +104,8 @@ vi.mock("@webda/core", () => {
|
|
|
100
104
|
}
|
|
101
105
|
},
|
|
102
106
|
WebContext: class {},
|
|
107
|
+
StreamingOperationContext: class {},
|
|
108
|
+
toPublicChunk: (value: unknown) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value))),
|
|
103
109
|
useCoreEvents: (eventName: string, callback: Function) => {
|
|
104
110
|
coreEventCallbacks[eventName] = callback;
|
|
105
111
|
return () => {
|
|
@@ -113,6 +119,9 @@ vi.mock("@webda/core", () => {
|
|
|
113
119
|
useCore: () => ({ getServices: () => mockCoreServices }),
|
|
114
120
|
useInstanceStorage: () => ({}),
|
|
115
121
|
runWithInstanceStorage: (_storage: any, fn: () => any) => fn(),
|
|
122
|
+
runWithContext: (_ctx: any, fn: () => any) => fn(),
|
|
123
|
+
emitCoreEvent: () => {},
|
|
124
|
+
useRouter: () => ({ checkRequest: async () => true }),
|
|
116
125
|
Command: () => () => {},
|
|
117
126
|
BuildCommand: () => () => {}
|
|
118
127
|
};
|
|
@@ -357,7 +366,10 @@ class GrpcServiceGenerateProtoTest {
|
|
|
357
366
|
mockOperations["Test.Ping"] = {};
|
|
358
367
|
|
|
359
368
|
const outPath = join(this.tmpDir, "test.proto");
|
|
360
|
-
const service = new GrpcService(
|
|
369
|
+
const service = new GrpcService(
|
|
370
|
+
"testGrpc",
|
|
371
|
+
new GrpcServiceParameters().load({ protoFile: outPath, packageName: "myapp" })
|
|
372
|
+
);
|
|
361
373
|
|
|
362
374
|
await service.build();
|
|
363
375
|
|
|
@@ -410,128 +422,8 @@ class GrpcServiceGenerateProtoTest {
|
|
|
410
422
|
}
|
|
411
423
|
}
|
|
412
424
|
|
|
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
425
|
@suite
|
|
524
|
-
class
|
|
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
|
-
|
|
426
|
+
class GrpcServiceUnknownMethodTest {
|
|
535
427
|
@test
|
|
536
428
|
async unimplementedMethodReturns12() {
|
|
537
429
|
const service = new GrpcService("testGrpc", new GrpcServiceParameters().load({}));
|
|
@@ -555,320 +447,6 @@ class GrpcServiceHandleGrpcRequestTest {
|
|
|
555
447
|
assert.ok(decodedMessage.includes("Method not found"), `Expected 'Method not found' in: ${decodedMessage}`);
|
|
556
448
|
assert.ok(ended);
|
|
557
449
|
}
|
|
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
450
|
}
|
|
873
451
|
|
|
874
452
|
@suite
|
|
@@ -997,7 +575,10 @@ class GrpcServiceBuildRpcMapTest {
|
|
|
997
575
|
class GrpcServiceInitTest {
|
|
998
576
|
@test
|
|
999
577
|
async initWithoutProtoFileLogsWarning() {
|
|
1000
|
-
const service = new GrpcService(
|
|
578
|
+
const service = new GrpcService(
|
|
579
|
+
"testGrpc",
|
|
580
|
+
new GrpcServiceParameters().load({ protoFile: "/nonexistent/path/app.proto" })
|
|
581
|
+
);
|
|
1001
582
|
|
|
1002
583
|
// Should not throw
|
|
1003
584
|
await service.init();
|
|
@@ -1030,7 +611,10 @@ class GrpcServiceInitTest {
|
|
|
1030
611
|
Plain: {} // no registerRequestInterceptor — must be skipped
|
|
1031
612
|
};
|
|
1032
613
|
|
|
1033
|
-
const service = new GrpcService(
|
|
614
|
+
const service = new GrpcService(
|
|
615
|
+
"testGrpc",
|
|
616
|
+
new GrpcServiceParameters().load({ protoFile: "/nonexistent/file.proto" })
|
|
617
|
+
);
|
|
1034
618
|
await service.init();
|
|
1035
619
|
|
|
1036
620
|
assert.strictEqual(interceptorsA.length, 1, "HttpServer should get the interceptor");
|
|
@@ -1063,7 +647,10 @@ class GrpcServiceInitTest {
|
|
|
1063
647
|
async initLogsWarningWhenNoHttpServer() {
|
|
1064
648
|
// No services registered → init should log a warning but not throw.
|
|
1065
649
|
mockCoreServices = {};
|
|
1066
|
-
const service = new GrpcService(
|
|
650
|
+
const service = new GrpcService(
|
|
651
|
+
"testGrpc",
|
|
652
|
+
new GrpcServiceParameters().load({ protoFile: "/nonexistent/file.proto" })
|
|
653
|
+
);
|
|
1067
654
|
await service.init();
|
|
1068
655
|
mockCoreServices = {};
|
|
1069
656
|
}
|
|
@@ -693,4 +693,21 @@ class ProtoGeneratorTest {
|
|
|
693
693
|
// leading-letter guard.
|
|
694
694
|
assert.ok(result.includes("_1Metadata value = 1;"), "leading-digit sanitized name is prefixed with `_`");
|
|
695
695
|
}
|
|
696
|
+
|
|
697
|
+
@test
|
|
698
|
+
async streamsFromTheOperationStreamingMode() {
|
|
699
|
+
const schemas = {
|
|
700
|
+
"Relay.Frame": { type: "object", properties: { frame: { type: "string" } }, "x-webda-stream": true }
|
|
701
|
+
} as any;
|
|
702
|
+
const proto = generateProto(
|
|
703
|
+
{
|
|
704
|
+
"Relay.Connect": { id: "Relay.Connect", input: "Relay.Frame", output: "Relay.Frame", streaming: "bidi" },
|
|
705
|
+
"Relay.Watch": { id: "Relay.Watch", input: "void", output: "Relay.Frame", streaming: "server" }
|
|
706
|
+
} as any,
|
|
707
|
+
schemas,
|
|
708
|
+
"webda"
|
|
709
|
+
);
|
|
710
|
+
assert.match(proto, /rpc Connect\(stream \w+\) returns \(stream \w+\);/);
|
|
711
|
+
assert.match(proto, /rpc Watch\(google\.protobuf\.Empty\) returns \(stream \w+\);/);
|
|
712
|
+
}
|
|
696
713
|
}
|
package/src/proto-generator.ts
CHANGED
|
@@ -111,7 +111,7 @@ export function generateProto(
|
|
|
111
111
|
for (const { opId, op, rpcName } of ops) {
|
|
112
112
|
const inputType = getInputType(op, schemas, messages);
|
|
113
113
|
const outputType = getOutputType(op, schemas, messages);
|
|
114
|
-
const streaming = op.grpc?.streaming || "none";
|
|
114
|
+
const streaming = op.streaming || (typeof op.grpc === "object" && op.grpc?.streaming) || "none";
|
|
115
115
|
|
|
116
116
|
const inputMod = streaming === "client" || streaming === "bidi" ? "stream " : "";
|
|
117
117
|
const outputMod = streaming === "server" || streaming === "bidi" ? "stream " : "";
|
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
useApplication
|
|
12
12
|
} from "@webda/core";
|
|
13
13
|
import { WebdaApplicationTest } from "@webda/core/lib/test/application.js";
|
|
14
|
-
import {
|
|
14
|
+
import { EventEmitter } from "node:events";
|
|
15
|
+
import { GrpcService, GrpcServiceParameters } from "./grpcservice.service.js";
|
|
15
16
|
import { GrpcStatus } from "./grpc-stream.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -38,23 +39,48 @@ class GrpcQueryStatementsTest extends WebdaApplicationTest {
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
* Send one message through the gRPC
|
|
42
|
+
* Send one message through the gRPC handler
|
|
42
43
|
* @param query - the query field of the request
|
|
43
44
|
* @returns the response or the gRPC error status
|
|
44
45
|
*/
|
|
45
46
|
async unary(query: string): Promise<{ response?: any; status?: number; message?: string }> {
|
|
46
47
|
const service: any = Object.create(GrpcService.prototype);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
48
|
+
service.parameters = new GrpcServiceParameters().load({});
|
|
49
|
+
service.rpcToOperation = new Map([["/webda.GrpcNotes/Query", "GrpcNotes.Query"]]);
|
|
50
|
+
service.rpcMethods = new Map([
|
|
51
|
+
[
|
|
52
|
+
"/webda.GrpcNotes/Query",
|
|
53
|
+
{
|
|
54
|
+
path: "/webda.GrpcNotes/Query",
|
|
55
|
+
requestDeserialize: (buf: Buffer) => JSON.parse(buf.toString()),
|
|
56
|
+
responseSerialize: (msg: any) => Buffer.from(JSON.stringify(msg))
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
]);
|
|
60
|
+
const req: any = new EventEmitter();
|
|
61
|
+
req.url = "/webda.GrpcNotes/Query";
|
|
62
|
+
req.headers = { "content-type": "application/grpc" };
|
|
63
|
+
const written: Buffer[] = [];
|
|
64
|
+
const headers: Record<string, string> = {};
|
|
65
|
+
let trailers: Record<string, string> = {};
|
|
66
|
+
const res: any = new EventEmitter();
|
|
67
|
+
res.headersSent = false;
|
|
68
|
+
res.setHeader = (key: string, value: string) => (headers[key.toLowerCase()] = value);
|
|
69
|
+
res.write = (data: Buffer) => (written.push(Buffer.from(data)), true);
|
|
70
|
+
res.addTrailers = (t: Record<string, string>) => (trailers = t);
|
|
71
|
+
res.end = () => (res.writableEnded = true);
|
|
72
|
+
const done = service.handleGrpcRequest(req, res);
|
|
73
|
+
const payload = Buffer.from(JSON.stringify({ query }));
|
|
74
|
+
const frame = Buffer.alloc(5 + payload.length);
|
|
75
|
+
frame.writeUInt32BE(payload.length, 1);
|
|
76
|
+
payload.copy(frame, 5);
|
|
77
|
+
req.emit("data", frame);
|
|
78
|
+
req.emit("end");
|
|
79
|
+
await done;
|
|
80
|
+
const status = Number({ ...headers, ...trailers }["grpc-status"]);
|
|
81
|
+
return status === GrpcStatus.OK
|
|
82
|
+
? { response: JSON.parse(written[0].subarray(5).toString()) }
|
|
83
|
+
: { status, message: decodeURIComponent({ ...headers, ...trailers }["grpc-message"]) };
|
|
58
84
|
}
|
|
59
85
|
|
|
60
86
|
@test
|