Skip to content

Commit

Permalink
feat: add error handler to rpc interface (#965)
Browse files Browse the repository at this point in the history
* adding handle error functionality

* improvements and additional testcases

* tests wrapped up

* refactors

* add documentation to readme

* remove jest config for debugging in vscode

* fix: update with PR feedback

* fix: test case had object properties out of order
  • Loading branch information
lukealvoeiro committed Nov 28, 2023
1 parent bfed59d commit 47cd16e
Show file tree
Hide file tree
Showing 19 changed files with 688 additions and 49 deletions.
18 changes: 10 additions & 8 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
- [Highlights](#highlights)
- [Auto-Batching / N+1 Prevention](#auto-batching--n1-prevention)
- [Usage](#usage)
- [Supported options](#supported-options)
- [NestJS Support](#nestjs-support)
- [Watch Mode](#watch-mode)
- [Basic gRPC implementation](#basic-grpc-implementation)
- [Supported options](#supported-options)
- [NestJS Support](#nestjs-support)
- [Watch Mode](#watch-mode)
- [Basic gRPC implementation](#basic-grpc-implementation)
- [Sponsors](#sponsors)
- [Development](#development)
- [Assumptions](#assumptions)
Expand Down Expand Up @@ -121,8 +121,8 @@ plugins:

If you're using a modern TS setup with either `esModuleInterop` or running in an ESM environment, you'll need to pass `ts_proto_opt`s of:

* `esModuleInterop=true` if using `esModuleInterop` in your `tsconfig.json`, and
* `importSuffix=.js` if executing the generated ts-proto code in an ESM environment
- `esModuleInterop=true` if using `esModuleInterop` in your `tsconfig.json`, and
- `importSuffix=.js` if executing the generated ts-proto code in an ESM environment

# Goals

Expand Down Expand Up @@ -448,9 +448,11 @@ Generated code will be placed in the Gradle build directory.

- With `--ts_proto_opt=outputServices=false`, or `=none`, ts-proto will output NO service definitions.

- With `--ts_proto_opt=outputBeforeRequest=true`, ts-proto will add a function definition to the Rpc interface definition with the signature: `beforeRequest(request: <RequestType>)`. It will will also automatically set `outputTypeRegistry=true` and `outputServices=true`. Each of the Service's methods will call `beforeRequest` before performing it's request.
- With `--ts_proto_opt=rpcBeforeRequest=true`, ts-proto will add a function definition to the Rpc interface definition with the signature: `beforeRequest(service: string, message: string, request: <RequestType>)`. It will will also automatically set `outputServices=default`. Each of the Service's methods will call `beforeRequest` before performing it's request.

- With `--ts_proto_opt=outputAfterResponse=true`, ts-proto will add a function definition to the Rpc interface definition with the signature: `afterResponse(response: <ResponseType>)`. It will will also automatically set `outputTypeRegistry=true` and `outputServices=true`. Each of the Service's methods will call `afterResponse` before returning the response.
- With `--ts_proto_opt=rpcAfterResponse=true`, ts-proto will add a function definition to the Rpc interface definition with the signature: `afterResponse(service: string, message: string, response: <ResponseType>)`. It will will also automatically set `outputServices=default`. Each of the Service's methods will call `afterResponse` before returning the response.

- With `--ts_proto_opt=rpcErrorHandler=true`, ts-proto will add a function definition to the Rpc interface definition with the signature: `handleError(service: string, message: string, error: Error)`. It will will also automatically set `outputServices=default`.

- With `--ts_proto_opt=useAbortSignal=true`, the generated services will accept an `AbortSignal` to cancel RPC calls.

Expand Down
12 changes: 8 additions & 4 deletions integration/before-after-request/before-after-request-test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { FooServiceClientImpl, FooServiceCreateRequest, FooServiceCreateResponse } from "./simple";
import { MessageType } from "./typeRegistry";
import {
FooServiceClientImpl,
FooServiceCreateRequest,
FooServiceCreateResponse,
FooServiceServiceName,
} from "./simple";

interface Rpc {
request(service: string, method: string, data: Uint8Array): Promise<Uint8Array>;
Expand Down Expand Up @@ -27,14 +31,14 @@ describe("before-after-request", () => {
const req = FooServiceCreateRequest.create(exampleData);
client = new FooServiceClientImpl({ ...rpc, beforeRequest: beforeRequest });
await client.Create(req);
expect(beforeRequest).toHaveBeenCalledWith(req);
expect(beforeRequest).toHaveBeenCalledWith(FooServiceServiceName, "Create", req);
});

it("performs function after request if specified", async () => {
const req = FooServiceCreateRequest.create(exampleData);
client = new FooServiceClientImpl({ ...rpc, afterResponse: afterResponse });
await client.Create(req);
expect(afterResponse).toHaveBeenCalledWith(exampleData);
expect(afterResponse).toHaveBeenCalledWith(FooServiceServiceName, "Create", exampleData);
});

it("doesn't perform function before or after request if they are not specified", async () => {
Expand Down
2 changes: 1 addition & 1 deletion integration/before-after-request/parameters.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
outputBeforeRequest=true,outputAfterResponse=true
rpcBeforeRequest=true,rpcAfterResponse=true,outputServices=default,outputServices=generic-definitions,
24 changes: 20 additions & 4 deletions integration/before-after-request/simple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,23 +432,39 @@ export class FooServiceClientImpl implements FooService {
Create(request: FooServiceCreateRequest): Promise<FooServiceCreateResponse> {
const data = FooServiceCreateRequest.encode(request).finish();
if (this.rpc.beforeRequest) {
this.rpc.beforeRequest(request);
this.rpc.beforeRequest(this.service, "Create", request);
}
const promise = this.rpc.request(this.service, "Create", data);
return promise.then((data) => {
const response = FooServiceCreateResponse.decode(_m0.Reader.create(data));
if (this.rpc.afterResponse) {
this.rpc.afterResponse(response);
this.rpc.afterResponse(this.service, "Create", response);
}
return response;
});
}
}

export type FooServiceDefinition = typeof FooServiceDefinition;
export const FooServiceDefinition = {
name: "FooService",
fullName: "simple.FooService",
methods: {
create: {
name: "Create",
requestType: FooServiceCreateRequest,
requestStream: false,
responseType: FooServiceCreateResponse,
responseStream: false,
options: {},
},
},
} as const;

interface Rpc {
request(service: string, method: string, data: Uint8Array): Promise<Uint8Array>;
beforeRequest?<T extends { [k in keyof T]: unknown }>(request: T): void;
afterResponse?<T extends { [k in keyof T]: unknown }>(response: T): void;
beforeRequest?<T extends { [k in keyof T]: unknown }>(service: string, method: string, request: T): void;
afterResponse?<T extends { [k in keyof T]: unknown }>(service: string, method: string, response: T): void;
}

type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
Expand Down
65 changes: 65 additions & 0 deletions integration/handle-error-in-default-service/handle-error-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { GetBasicResponse, GetBasicRequest, BasicServiceClientImpl, BasicServiceServiceName } from "./simple";

interface Rpc {
request(service: string, method: string, data: Uint8Array): Promise<Uint8Array>;
handleError?(service: string, method: string, error: Error): Error;
}

describe("before-after-request", () => {
const exampleData = {
name: "test-name",
};
let rpc = {
request: jest.fn(() => Promise.resolve(new Uint8Array())),
};
let client = new BasicServiceClientImpl(rpc);
let err = new Error("error");

let modifiedError = new Error("modified error");
const handleError = jest.fn(() => modifiedError);

beforeEach(() => {
jest.clearAllMocks();
});

it("doesn't perform handleError if error occurs during encode step", async () => {
const encodeSpy = jest.spyOn(GetBasicRequest, "encode").mockImplementation(() => {
throw err;
});
const req = GetBasicRequest.create(exampleData);
client = new BasicServiceClientImpl({ ...rpc, handleError: handleError });
try {
await client.GetBasic(req);
} catch (error) {
expect(error).toBe(err);
expect(handleError).not.toHaveBeenCalled();
}
encodeSpy.mockRestore();
});

it("performs handleError if error occurs when decoding", async () => {
const decodeSpy = jest.spyOn(GetBasicResponse, "decode").mockImplementation(() => {
throw err;
});
const req = GetBasicRequest.create(exampleData);
client = new BasicServiceClientImpl({ ...rpc, handleError: handleError });
try {
await client.GetBasic(req);
} catch (error) {
expect(error).toBe(modifiedError);
expect(handleError).toHaveBeenCalledWith(BasicServiceServiceName, "GetBasic", err);
}
decodeSpy.mockRestore();
});

it("doesn't perform handleError if it is not specified", async () => {
const req = GetBasicRequest.create(exampleData);
client = new BasicServiceClientImpl(rpc);
try {
await client.GetBasic(req);
} catch (error) {
expect(error).toBe(err);
expect(handleError).not.toHaveBeenCalled();
}
});
});
1 change: 1 addition & 0 deletions integration/handle-error-in-default-service/parameters.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
outputServices=default,rpcErrorHandler=true
Binary file not shown.
14 changes: 14 additions & 0 deletions integration/handle-error-in-default-service/simple.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
syntax = "proto3";
package basic;

message GetBasicRequest {
string name = 1;
}

message GetBasicResponse {
string name = 1;
}

service BasicService {
rpc GetBasic (GetBasicRequest) returns (GetBasicResponse) {}
}
178 changes: 178 additions & 0 deletions integration/handle-error-in-default-service/simple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/* eslint-disable */
import * as _m0 from "protobufjs/minimal";

export const protobufPackage = "basic";

export interface GetBasicRequest {
name: string;
}

export interface GetBasicResponse {
name: string;
}

function createBaseGetBasicRequest(): GetBasicRequest {
return { name: "" };
}

export const GetBasicRequest = {
encode(message: GetBasicRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
if (message.name !== "") {
writer.uint32(10).string(message.name);
}
return writer;
},

decode(input: _m0.Reader | Uint8Array, length?: number): GetBasicRequest {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetBasicRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag !== 10) {
break;
}

message.name = reader.string();
continue;
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
}
return message;
},

fromJSON(object: any): GetBasicRequest {
return { name: isSet(object.name) ? globalThis.String(object.name) : "" };
},

toJSON(message: GetBasicRequest): unknown {
const obj: any = {};
if (message.name !== "") {
obj.name = message.name;
}
return obj;
},

create<I extends Exact<DeepPartial<GetBasicRequest>, I>>(base?: I): GetBasicRequest {
return GetBasicRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetBasicRequest>, I>>(object: I): GetBasicRequest {
const message = createBaseGetBasicRequest();
message.name = object.name ?? "";
return message;
},
};

function createBaseGetBasicResponse(): GetBasicResponse {
return { name: "" };
}

export const GetBasicResponse = {
encode(message: GetBasicResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
if (message.name !== "") {
writer.uint32(10).string(message.name);
}
return writer;
},

decode(input: _m0.Reader | Uint8Array, length?: number): GetBasicResponse {
const reader = input instanceof _m0.Reader ? input : _m0.Reader.create(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetBasicResponse();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
if (tag !== 10) {
break;
}

message.name = reader.string();
continue;
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skipType(tag & 7);
}
return message;
},

fromJSON(object: any): GetBasicResponse {
return { name: isSet(object.name) ? globalThis.String(object.name) : "" };
},

toJSON(message: GetBasicResponse): unknown {
const obj: any = {};
if (message.name !== "") {
obj.name = message.name;
}
return obj;
},

create<I extends Exact<DeepPartial<GetBasicResponse>, I>>(base?: I): GetBasicResponse {
return GetBasicResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetBasicResponse>, I>>(object: I): GetBasicResponse {
const message = createBaseGetBasicResponse();
message.name = object.name ?? "";
return message;
},
};

export interface BasicService {
GetBasic(request: GetBasicRequest): Promise<GetBasicResponse>;
}

export const BasicServiceServiceName = "basic.BasicService";
export class BasicServiceClientImpl implements BasicService {
private readonly rpc: Rpc;
private readonly service: string;
constructor(rpc: Rpc, opts?: { service?: string }) {
this.service = opts?.service || BasicServiceServiceName;
this.rpc = rpc;
this.GetBasic = this.GetBasic.bind(this);
}
GetBasic(request: GetBasicRequest): Promise<GetBasicResponse> {
const data = GetBasicRequest.encode(request).finish();
const promise = this.rpc.request(this.service, "GetBasic", data);
return promise.then((data) => {
try {
return GetBasicResponse.decode(_m0.Reader.create(data));
} catch (error) {
return Promise.reject(error);
}
}).catch((error) => {
if (error instanceof Error && this.rpc.handleError) {
return Promise.reject(this.rpc.handleError(this.service, "GetBasic", error));
}
return Promise.reject(error);
});
}
}

interface Rpc {
request(service: string, method: string, data: Uint8Array): Promise<Uint8Array>;
handleError?(service: string, method: string, error: Error): Error;
}

type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;

export type DeepPartial<T> = T extends Builtin ? T
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;

type KeysOfUnion<T> = T extends T ? keyof T : never;
export type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };

function isSet(value: any): boolean {
return value !== null && value !== undefined;
}

0 comments on commit 47cd16e

Please sign in to comment.