Skip to content

Commit 7efc91d

Browse files
authored
Merge pull request #8 from cloudnc/feat/add-grpc-observer-functionality
feat(observeGrpcUnary): add capability to observe (not mock) a grpc call.
2 parents 91c51a1 + 5094c66 commit 7efc91d

File tree

6 files changed

+256
-96
lines changed

6 files changed

+256
-96
lines changed

src/base/index.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { status as Status } from "@grpc/grpc-js";
1+
import { Metadata, status as Status } from "@grpc/grpc-js";
22

33
export interface GrpcErrorResponse {
44
status: Status;
@@ -9,7 +9,9 @@ export interface GrpcSuccessResponse {
99
message: Uint8Array;
1010
}
1111

12-
export type GrpcResponse = GrpcSuccessResponse | GrpcErrorResponse;
12+
export type GrpcResponse = (GrpcSuccessResponse | GrpcErrorResponse) & {
13+
trailers?: Metadata;
14+
};
1315

1416
function fourBytesLength(sized: { length: number }): Uint8Array {
1517
const arr = new Uint8Array(4); // an Int32 takes 4 bytes
@@ -18,6 +20,77 @@ function fourBytesLength(sized: { length: number }): Uint8Array {
1820
return arr;
1921
}
2022

23+
export function decodeGrpcWebBody(bodyBuffer: Buffer): GrpcResponse {
24+
if (bodyBuffer.length === 0) {
25+
throw new Error("Body has zero length, cannot decode!");
26+
}
27+
28+
const bodyRaw = new Uint8Array(bodyBuffer);
29+
30+
// layout:
31+
// status code: 1 byte
32+
// message length 4 bytes (int32 big endian)
33+
// the message itself (len defined above)
34+
// trailer start byte: 0x80
35+
// trailers length (same format as above)
36+
// trailers: concatenated `key:value\r\n`
37+
let offset = 0;
38+
39+
const status: number | undefined = bodyRaw.at(offset);
40+
offset += 1;
41+
42+
if (status === undefined || !(status in Status)) {
43+
throw new Error(`Unrecognised status code [${status}]`);
44+
}
45+
46+
const bodyLength = readInt32Length(bodyRaw, offset);
47+
offset += 4;
48+
49+
const message = new Uint8Array(bodyRaw.subarray(offset, offset + bodyLength));
50+
51+
offset += bodyLength;
52+
53+
const trailersHeader = 0x80;
54+
55+
if (bodyRaw.at(offset++) !== trailersHeader) {
56+
throw new Error("Expected trailers header 0x80");
57+
}
58+
59+
const trailersLength = readInt32Length(bodyRaw, offset);
60+
61+
offset += 4;
62+
63+
const trailersView = new DataView(bodyRaw.buffer, offset, trailersLength);
64+
65+
const trailersString = new TextDecoder().decode(trailersView).trim();
66+
67+
const trailers = new Metadata();
68+
69+
trailersString.split("\r\n").forEach((trailer) => {
70+
const [key, value] = trailer.split(":", 2);
71+
trailers.set(key, value);
72+
});
73+
74+
if (status !== Status.OK) {
75+
return {
76+
status,
77+
trailers,
78+
detail: trailers.get("grpc-message")[0] as string | undefined,
79+
};
80+
}
81+
82+
return {
83+
message,
84+
trailers,
85+
};
86+
}
87+
88+
function readInt32Length(data: Uint8Array, offset: number = 0): number {
89+
const view = new DataView(data.buffer);
90+
91+
return view.getInt32(offset, false);
92+
}
93+
2194
export class GrpcUnknownStatus extends Error {
2295
constructor(unknownStatus: unknown) {
2396
super(`An unknown status was provided: ${unknownStatus}`);
@@ -67,3 +140,12 @@ export function grpcResponseToBuffer(response: GrpcResponse): Buffer {
67140
trailerMessage,
68141
]);
69142
}
143+
144+
/**
145+
* Remove the header information from the request body. This is the reverse of the `frameRequest` function that
146+
* gRPC-Web applies to wrap the message body up for transport
147+
* @see https://github.com/improbable-eng/grpc-web/blob/53aaf4cdc0fede7103c1b06f0cfc560c003a5c41/client/grpc-web/src/util.ts#L3
148+
*/
149+
export function unframeRequest(requestBody: Uint8Array): Uint8Array {
150+
return new Uint8Array(requestBody).slice(5);
151+
}

src/playwright/index.ts

Lines changed: 4 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,4 @@
1-
import { expect, Page } from "@playwright/test";
2-
import { grpc } from "@improbable-eng/grpc-web";
3-
import { GrpcResponse, grpcResponseToBuffer } from "../base";
4-
import { Request } from "playwright-core";
5-
6-
export interface UnaryMethodDefinitionish
7-
extends grpc.UnaryMethodDefinition<any, any> {
8-
requestStream: any;
9-
responseStream: any;
10-
}
11-
12-
export interface MockedGrpcCall {
13-
/**
14-
* Wait for the mocked request. This is useful if you want to assert on the request body of an RPC call, or if you
15-
* need to wait for an endpoint to respond before continuing assertions
16-
*
17-
* The request message argument to the optional predicate should be used to match the request payload.
18-
* Note the requestMessage objects need to be decoded using a protobuf decoder for the specific expected message.
19-
*/
20-
waitForMock(
21-
requestPredicate?: (
22-
requestMessage: Uint8Array | null,
23-
request: Request
24-
) => boolean | Promise<boolean>
25-
): Promise<{ requestMessage: Uint8Array | null }>;
26-
}
27-
28-
/**
29-
* Remove the header information from the request body. This is the reverse of the `frameRequest` function that
30-
* gRPC-Web applies to wrap the message body up for transport
31-
* @see https://github.com/improbable-eng/grpc-web/blob/53aaf4cdc0fede7103c1b06f0cfc560c003a5c41/client/grpc-web/src/util.ts#L3
32-
*/
33-
function unframeRequest(requestBody: Uint8Array): Uint8Array {
34-
return new Uint8Array(requestBody).slice(5);
35-
}
36-
37-
export function readGrpcRequest(request: Request): Uint8Array | null {
38-
const requestBody = request.postDataBuffer();
39-
return !requestBody ? null : unframeRequest(requestBody);
40-
}
41-
42-
export async function mockGrpcUnary(
43-
page: Page,
44-
rpc: UnaryMethodDefinitionish,
45-
response: GrpcResponse | ((request: Uint8Array | null) => GrpcResponse)
46-
): Promise<MockedGrpcCall> {
47-
const url = `/${rpc.service.serviceName}/${rpc.methodName}`;
48-
49-
// note this wildcard route url base is done in order to match both localhost and deployed service usages.
50-
await page.route("**" + url, (route) => {
51-
expect(
52-
route.request().method(),
53-
"ALL gRPC requests should be a POST request"
54-
).toBe("POST");
55-
56-
const grpcResponse =
57-
typeof response === "function"
58-
? response(readGrpcRequest(route.request()))
59-
: response;
60-
61-
const grpcResponseBody = grpcResponseToBuffer(grpcResponse);
62-
63-
return route.fulfill({
64-
body: grpcResponseBody,
65-
contentType: "application/grpc-web+proto",
66-
headers: {
67-
"Access-Control-Allow-Origin": "*",
68-
},
69-
});
70-
});
71-
72-
return {
73-
async waitForMock(requestPredicate?) {
74-
const request = await page.waitForRequest((req) => {
75-
if (!req.url().includes(url)) {
76-
return false;
77-
}
78-
79-
if (requestPredicate) {
80-
const unframed = readGrpcRequest(req);
81-
return requestPredicate(unframed, req);
82-
}
83-
84-
return true;
85-
});
86-
87-
await page.waitForResponse((resp) => resp.url().includes(url));
88-
89-
const requestMessage = readGrpcRequest(request);
90-
91-
return { requestMessage };
92-
},
93-
};
94-
}
1+
export { observeGrpcUnary } from "./observe-grpc-unary";
2+
export { mockGrpcUnary } from "./mock-grpc-unary";
3+
export { readGrpcRequest } from "./read-grpc-request";
4+
export * from "./interfaces";

src/playwright/interfaces.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { grpc } from "@improbable-eng/grpc-web";
2+
import { Request } from "playwright-core";
3+
import { status as Status, Metadata } from "@grpc/grpc-js";
4+
5+
export interface UnaryMethodDefinitionish
6+
extends grpc.UnaryMethodDefinition<any, any> {
7+
requestStream: any;
8+
responseStream: any;
9+
}
10+
11+
export type RequestPredicate = (
12+
requestMessage: Uint8Array | null,
13+
request: Request
14+
) => boolean | Promise<boolean>;
15+
16+
export interface MockedGrpcCall {
17+
/**
18+
* Wait for the mocked request. This is useful if you want to assert on the request body of an RPC call, or if you
19+
* need to wait for an endpoint to respond before continuing assertions
20+
*
21+
* The request message argument to the optional predicate should be used to match the request payload.
22+
* Note the requestMessage objects need to be decoded using a protobuf decoder for the specific expected message.
23+
*/
24+
waitForMock(
25+
requestPredicate?: RequestPredicate
26+
): Promise<{ requestMessage: Uint8Array | null }>;
27+
}
28+
29+
export interface ObservedGrpcCallResponse {
30+
requestMessage: Uint8Array | null;
31+
responseMessage: Uint8Array | null;
32+
statusCode: Status;
33+
trailers: Metadata | null;
34+
}
35+
36+
export interface ObservedGrpcCall {
37+
waitForResponse: (
38+
requestPredicate?: RequestPredicate
39+
) => Promise<ObservedGrpcCallResponse>;
40+
}

src/playwright/mock-grpc-unary.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { expect, Page } from "@playwright/test";
2+
import { GrpcResponse, grpcResponseToBuffer } from "../base";
3+
import { MockedGrpcCall, UnaryMethodDefinitionish } from "./interfaces";
4+
import { readGrpcRequest } from "./read-grpc-request";
5+
6+
export async function mockGrpcUnary(
7+
page: Page,
8+
rpc: UnaryMethodDefinitionish,
9+
response: GrpcResponse | ((request: Uint8Array | null) => GrpcResponse)
10+
): Promise<MockedGrpcCall> {
11+
const url = `/${rpc.service.serviceName}/${rpc.methodName}`;
12+
13+
// note this wildcard route url base is done in order to match both localhost and deployed service usages.
14+
await page.route("**" + url, (route) => {
15+
expect(
16+
route.request().method(),
17+
"ALL gRPC requests should be a POST request"
18+
).toBe("POST");
19+
20+
const grpcResponse =
21+
typeof response === "function"
22+
? response(readGrpcRequest(route.request()))
23+
: response;
24+
25+
const grpcResponseBody = grpcResponseToBuffer(grpcResponse);
26+
27+
return route.fulfill({
28+
body: grpcResponseBody,
29+
contentType: "application/grpc-web+proto",
30+
headers: {
31+
"Access-Control-Allow-Origin": "*",
32+
},
33+
});
34+
});
35+
36+
return {
37+
async waitForMock(requestPredicate?) {
38+
const request = await page.waitForRequest((req) => {
39+
if (!req.url().endsWith(url)) {
40+
return false;
41+
}
42+
43+
if (requestPredicate) {
44+
const unframed = readGrpcRequest(req);
45+
return requestPredicate(unframed, req);
46+
}
47+
48+
return true;
49+
});
50+
51+
await page.waitForResponse((resp) => resp.request() === request);
52+
53+
const requestMessage = readGrpcRequest(request);
54+
55+
return { requestMessage };
56+
},
57+
};
58+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Page } from "@playwright/test";
2+
import {
3+
ObservedGrpcCall,
4+
RequestPredicate,
5+
UnaryMethodDefinitionish,
6+
} from "./interfaces";
7+
import { decodeGrpcWebBody } from "../base";
8+
import { status as Status } from "@grpc/grpc-js";
9+
import { readGrpcRequest } from "./read-grpc-request";
10+
11+
export async function observeGrpcUnary(
12+
page: Page,
13+
rpc: UnaryMethodDefinitionish
14+
): Promise<ObservedGrpcCall> {
15+
const url = `/${rpc.service.serviceName}/${rpc.methodName}`;
16+
17+
// note this wildcard route url base is done in order to match both localhost and deployed service usages.
18+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
19+
await page.route("**" + url, async (route) => await route.continue());
20+
21+
return {
22+
async waitForResponse(requestPredicate?: RequestPredicate) {
23+
const request = await page.waitForRequest((req) => {
24+
if (!req.url().endsWith(url)) {
25+
return false;
26+
}
27+
28+
if (requestPredicate) {
29+
const unframed = readGrpcRequest(req);
30+
return requestPredicate(unframed, req);
31+
}
32+
33+
return true;
34+
});
35+
36+
const response = await page.waitForResponse(
37+
(resp) => resp.request() === request
38+
);
39+
40+
const requestMessage = readGrpcRequest(request);
41+
42+
const responseParsed = await decodeGrpcWebBody(await response.body());
43+
44+
const trailers = responseParsed.trailers ?? null;
45+
46+
if ("status" in responseParsed) {
47+
return {
48+
requestMessage,
49+
responseMessage: null,
50+
statusCode: responseParsed.status,
51+
trailers,
52+
};
53+
}
54+
55+
return {
56+
requestMessage,
57+
responseMessage: responseParsed.message,
58+
statusCode: Status.OK,
59+
trailers,
60+
};
61+
},
62+
};
63+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Request } from "playwright-core";
2+
import { unframeRequest } from "../base";
3+
4+
export function readGrpcRequest(request: Request): Uint8Array | null {
5+
const requestBody = request.postDataBuffer();
6+
return !requestBody ? null : unframeRequest(requestBody);
7+
}

0 commit comments

Comments
 (0)