Protobuf generator and GRPC
Description
Protobuf version 3 is used to work with the backend:
https://developers.google.com/protocol-buffers/docs/proto3
Messages are sent in binary form and do not contain message structures.
This library was created for simple convenient work with the protocol.
It consists of 2 parts:
- generator - converts
proto
files into TS code
- query library - uses the result of the generator to send and receive requests
Comparison
Official client | + | - | - | - |
Simple install | - (binary) | + (full js) | + (full js) | + (full js) |
Data as simple object | - | + | + | + |
Compile simples proto with imports | + | + | - | + |
GRPC client | + | - (poor wrapper) | - (none) | + |
Small client size (kb gzip) | - (50 binary + http) | + (6.8 binary) | + (3.3 binary ) | + (3.3 binary + 3.0 HTTP) |
Tree Shaking (lib + generated code) | - | - | - | + |
Dev tools (JSON) | + | - | - | + |
Dev tools (binary) | - | - | - | + |
Auto wrap StringValue | - | - | - | + |
Auto wrap oneof | - (manual) | - (manual) | - (none) | + |
Auto wrap masks | - | - | - | + |
Install
npm i @whisklabs/grpc
Generator
Before using the library, you need to convert the proto files to the required format.
API
The conversion can be done programmatically.
import { generator } from '@whisklabs/grpc/generator';
const error = await generator({
dir: 'source/folder',
out: 'result/folder',
version: '№123',
exclude: /some|regexp/,
debug: true,
messageRequired: false,
packageName: '@whisklabs/package-one',
packageVersion: '0.1.10',
packageUrl: 'git@github.com:whisklabs/npm.git',
packageRegistry: 'https://npm.pkg.github.com/',
});
console.log(error);
CLI
The conversion can be done via the command line.
PROTO_DIR=source/folder PROTO_OUT=result/folder grpc-generator
export PROTO_DIR=source/folder
export PROTO_OUT=result/folder
npx @whisklabs/grpc
Required ENV params:
PROTO_DIR
- path to root of proto folder
PROTO_OUT
- output path for generated result
PROTO_VERSION
- string of version (default is from npm package lib)
PROTO_EXCLUDE
- optional regexp for exclude files
PROTO_DEBUG
- true | false - generate json debug files
PROTO_MESSAGE_REQUIRED
- true | false - enable strict required mode for messages (default: false)
If we need package.json, set up both options:
PROTO_PACKAGE_NAME
- generate package.json with name
PROTO_PACKAGE_VERSION
- generate package.json with version
PROTO_PACKAGE_URL
- set package url
PROTO_PACKAGE_REGISTRY
- set package registry
Parser
Return JSON structure of .proto
files.
import { readFileSync } from 'fs';
import { parser } from '@whisklabs/grpc';
const parsed = parser(readFileSync('some.proto', 'utf8'));
Issues with protoc
For custom options add in start of file or in 'google/protobuf/descriptor.proto'
import "google/protobuf/descriptor.proto";
extend google.protobuf.FieldOptions {
bool required = 1001; // uniq number more 1000
}
extend google.protobuf.MessageOptions {
bool message_required = 1001; // uniq number more 1000
}
extend google.protobuf.FileOptions {
bool messages_required = 1001; // uniq number more 1000
}
For optional
keyword use ptotoc 3.15+
GRPC
The query library is a factory of endpoints, for simultaneous work with different API servers.
Setup gRPC instance
import { grpcHTTP, StatusCode } from '@whisklabs/grpc';
export const grpc = grpcHTTP({
server: 'https://example.com',
credentials: true,
debug: false,
devtool: false,
logger: console,
timeout: undefined,
transformRequest: ({ xhr, data, meta }) => {
xhr.setRequestHeader('Authorization', 'ANY_TOKEN');
},
transformRequest: async ({ data }) => {
if (isObject(data)) {
(data as any).token = await SomeAction();
return data;
}
return { ...data, x: 1 };
},
transformResponse: ({ xhr, data, meta }) => {
if (!data.success) {
console.log(data.error);
}
return data;
},
transformResponse: async ({ data }) => {
if (!data.success) {
alert(`v1.Grpc.Event.GRPCError ${data.error.message}`);
if (data.error.httpStatus === 403) {
const reason = await forbidden();
return { success: false, error: { message: reason } };
}
if (data.error.grpcCode === StatusCode.UNAUTHENTICATED) {
logout();
}
}
return data;
},
});
Sending requests
After configuring gRPC instance and generating code from proto files, we can make requests to the server.
Simple example
import { whisk_api_user_v2_UserAPI_GetMe } from './proto';
import { grpc } from './grpc';
const user = await grpc(whisk_api_user_v2_UserAPI_GetMe);
console.log(user.success && user.data.user?.email);
console.log(!user.success && user.error.message);
if (user.success) {
console.log(user.data.user?.email);
} else {
console.log(
user.error.message,
user.error.data,
user.error.grpcCode,
user.error.httpStatus
);
}
const { data: me, error } = await grpc(whisk_api_user_v2_UserAPI_GetMe);
console.log(me?.user?.email);
console.log(error?.message);
if (me) {
console.log(me.user?.email);
}
if (error) {
console.log(error.message);
}
Cancel requests
It can be of two types:
Example of manually canceling a request.
import { grpcCancel } from '@whisklabs/grpc';
const cancel = grpcCancel();
document.body.addEventListener('click', () => {
cancel();
console.log('gRPC canceled');
});
const result = await grpc(whisk_api_user_v2_UserAPI_GetMe, { cancel });
if (result.success) {
console.log(result.data.user?.id);
}
Example of automatic request cancellation.
Tokens (strings) are used to identify requests. They operate within a single gRPC instance (independently of each other) and depend only on their name and nothing more.
This is more convenient than the manual version, because occurs in fully automatic mode.
grpc(whisk_api_user_v2_UserAPI_GetMe, { cancel: `some-token` });
grpc(whisk_api_user_v2_UserAPI_GetMe, { cancel: `some-token` });
grpc(whisk_api_user_v2_UserAPI_GetMe, { cancel: `some-token-${123}` });
Mask
Because in grpc there are no null values for collection of fields it is necessary to list all reset variables in the form of an array of lines with ways for these fields.
import { whisk_api_user_v2_UserAPI_UpdateSettings } from './proto';
import { mask, maskWrap } from '@whisklabs/grpc';
const settings = { personalDetails: { age: 1 } };
const mask = mask(settings);
await grpc(whisk_api_user_v2_UserAPI_UpdateSettings, { settings, mask });
await grpc(
whisk_api_user_v2_UserAPI_UpdateSettings,
maskWrap({ settings: { personalDetails: { age: 1 } } }, 'settings')
);
await grpc(
whisk_api_user_v2_UserAPI_UpdateSettings,
{ settings: { personalDetails: { age: 1 } } },
{ mask: { field: 'settings'}
);
await grpc(
whisk_api_user_v2_UserAPI_UpdateSettings,
{ settings: { personalDetails: { age: 1 } } },
{ mask: true }
);
await grpc(
whisk_api_user_v2_UserAPI_UpdateSettings,
{ settings: { personalDetails: { age: 1 } } }
);
All options
interface Meta {
token: string;
}
const grpc = grpcHTTP<Meta>(...);
const result = await grpc(
whisk_api_user_v2_UserAPI_UpdateSettings,
{ ... },
{
mask: { field: 'settings'},
cancel: `some-token-${123}`,
onDownload: e => console.log(e.loaded / e.total),
onUpload: e => console.log(e.loaded / e.total),
timeout: 2000,
meta: {
token: 'CODE',
},
}
);
Web Tools
This library is compatible with gRPC-Web Developer Tools.
Chrome & Firefox Browser extension to aid gRPC-Web development
Chrome extension
Firefox extension
grpcHTTP({
debug: true,
devtool: true,
...
});
import { ServiceRequest, ServiceResponse } from '@whisklabs/grpc';
import { whisk_api_user_v2_UserAPI_UpdateSettings } from './proto';
type request = ServiceRequest<whisk_api_user_v2_UserAPI_UpdateSettings>;
type response = ServiceResponse<whisk_api_user_v2_UserAPI_UpdateSettings>;
Deep readonly
You can switch request and response to DeepReadonly
.
import { GRPCDeep, grpcHTTP } from '@whisklabs/grpc';
const grpc = grpcHTTP({ ... }) as GRPCDeep;
Also there are helper types same as base types.
import { FieldGetDeep, ServiceRequestDeep, ServiceResponseDeep } from '@whisklabs/grpc';
Default values
import { Default } from '@whisklabs/grpc';
import { whisk_api_user_v2_TestItem } from './proto';
const res = Default(whisk_api_user_v2_TestItem);
Unwrap and mapping data
import { unwrap } from '@whisklabs/grpc';
const userEmail = await unwrap(
grpc(whisk_api_user_v2_UserAPI_UpdateSettings, { id: 'abc' }),
data => data.user?.email,
e => {
console.log(e);
return 1000;
}
);
const user = await grpc(whisk_api_user_v2_UserAPI_GetMe);
const item = await unwrap(
user,
async success => {
},
async error => {
}
);
const err = e => {
throw e;
};
try {
const request = grpc(whisk_api_user_v2_UserAPI_GetMe);
const email = await unwrap(request, data => data.user?.email, err);
} catch (e) {
console.error(e);
}
Messages and typings
This lib convert proto to TS, turning a dependency tree into a flat list for tree shaking.
To ensure non-intersection, absolute paths become part of the interface name separated by "_".
Primitives
All primitives are required fields
double | number | parseFloat |
float | number | parseFloat |
int32 | number | parseInt |
int64 | number | parseInt |
uint32 | number | parseInt |
uint64 | number | parseInt |
sint32 | number | parseInt |
sint64 | number | parseInt |
fixed32 | number | parseInt |
fixed64 | number | parseInt |
sfixed32 | number | parseInt |
sfixed64 | number | parseInt |
bool | boolean | === true |
string | string | String |
bytes | Uint8Array | - |
Wrappers
Wrappers are special messages for indicate optional primitive.
This messages auto wrap/unwrap by this lib.
google.protobuf.DoubleValue | double | { value: number } | number? |
google.protobuf.FloatValue | float | { value: number } | number? |
google.protobuf.Int64Value | int64 | { value: number } | number? |
google.protobuf.UInt64Value | uint64 | { value: number } | number? |
google.protobuf.Int32Value | int32 | { value: number } | number? |
google.protobuf.UInt32Value | uint32 | { value: number } | number? |
google.protobuf.BoolValue | bool | { value: boolean } | boolean? |
google.protobuf.StringValue | string | { value: string } | string? |
google.protobuf.BytesValue | bytes | { value: Uint8Array } | Uint8Array? |
Messages
Messages are same as interface in TS
message TestItem {
string id = 1;
google.protobuf.StringValue description = 2;
InnerItem item = 10
repeated bool array = 11;
map<string, OtherItem> map_search = 12;
message InnerItem {
int32 id = 1;
}
}
message OtherItem {
float count = 1;
}
type TestItem = {
id: string;
description?: string;
item?: TestItem_InnerItem;
array: boolean[];
mapSearch: Record<string, OtherItem>;
};
type TestItem_InnerItem = {
id: number;
};
type OtherItem = {
count: number;
};
Enums
As TS enum.
0
fields name with ending _INVALID
or _UNSPECIFIED
removed from typings.
message TestItem {
Type id = 1;
Type force = 2 [ required = true ];
Direction dir = 3;
}
enum Type {
TYPE_UNSPECIFIED = 0; // removed
HEIGHT = 1;
WIDTH = 2;
}
enum Direction {
UP = 0; // Not used in our proto, but can be in third party proto
DOWN = 1;
}
type TestItem = {
id?: Type;
forced: Type;
dir?: Direction;
};
const enum Type {
HEIGHT = 1,
WIDTH = 2,
}
const enum Direction {
UP = 0,
DOWN = 1,
}
Services
This is information about server methods, what types of messages are used for request and response.
message Request {
int32 id = 1;
}
message Response {
string name = 1;
}
service UserAPI {
rpc GetMe(Request) returns (Response) {
// can be some options
}
}
type Request = {
id: number;
};
type Response = {
name: string;
};
export type UserAPI_GetMe = Service<Field<Request>, Field<Response>>;
type request = ServiceRequest<UserAPI_GetMe>;
type response = ServiceResponse<UserAPI_GetMe>;
Oneof
This is a grouping of fields where only one of them can be set or read.
// file path: whisk/api/user/v2/user.proto
message TestOneof {
string id = 1;
oneof device {
EthicalPreference device_type = 11;
string custom_device = 12;
}
}
export type whisk_api_user_v2_TestOneof = {
id: string;
device?:
| { oneof: 'deviceType'; value: whisk_api_user_v2_EthicalPreference }
| { oneof: 'customDevice'; value: string };
};
There are two helpers for work with oneof: oneof
and oneis
.
const data: whisk_api_user_v2_TestOneof = {
id: '123',
device: {
oneof: 'customDevice',
value: 'abc',
},
};
const any = oneof(data.device);
const val = data.device?.value;
if (oneis(data.device, 'customDevice')) {
console.log(data.device?.value);
}
if (data.device?.oneof === 'customDevice') {
console.log(data.device?.value);
}
const a = oneof(data.device, 'customDevice') ?? oneof(data.device, 'deviceType');
console.log(a);
const out = oneof(data.device, 'customDevice', v => `${v}a`) ?? oneof(data.device, 'deviceType', v => v * 3);
console.log(out);
let res: string | number | undefined;
switch (data.device?.oneof) {
case 'customDevice':
res = `${data.device.value}a`;
break;
case 'deviceType':
res = data.device.value * 3;
break;
}
console.log(res);
Required and Optional
By default all primitives
, repeated
and map
are required.
Other types are optional.
You can change this behaveour using option required
or keyword optional
.
message TestOneof {
string id = 1;
string name = 2 [ required = true ];
string item = 3 [ required = false ];
google.protobuf.StringValue description = 10;
google.protobuf.StringValue test = 11 [ required = true ];
google.protobuf.StringValue result = 12 [ required = false ];
optional string story = 13;
whisk.api.shared.v1.Time time = 30;
whisk.api.shared.v1.Date date = 31 [ required = true ];
whisk.api.shared.v1.Date date_new = 32 [ (required) = true ]; // ptotoc compatable
}
export type whisk_api_user_v2_TestOneof = {
id: string;
name: string;
item?: string;
description?: string;
test: string;
result?: string;
story?: string;
time?: whisk_api_shared_v1_Time;
date: whisk_api_shared_v1_Date;
dateNew: whisk_api_shared_v1_Date;
};
You can switch on strict required mode for messages with optional
keyword:
- All fields are required by default, expect
oneof
.
- To mark a field as optional, you can add the keyword
optional
.
- Although this keyword has no effect on binary compatibility and can be added or removed, clients need to be updated so that they understand how to handle values correctly.
- For backward compatibility, wrappers can be used because they are not binary compatible with
optional
.
- If you cannot change the structure in any way, but you need to force the override, then in such a case it is permissible to use
[(required) = true]
or [(required) = false]
.
-
Global
Work on all files in final
PROTO_MESSAGE_REQUIRED=true npx @whisklabs/grpc@1
import { generator } from '@whisklabs/grpc/generator';
const error = await generator({
messageRequired: true,
});
-
Local per file
Add option in start of proto file
syntax = "proto3";
package whisk.api.user.v2;
// Force required mode for messages in file
option (messages_required) = true; // false for disable
message Test {}
-
Local per message
message Day {
int32 num = 1;
}
message Week {
// Force required mode in message
option (message_required) = true; // false for disable
int32 num = 1;
Day day = 2;
}
Example:
message Test {
// Primitive
string id = 1; // required
optional string text = 2; // optional
// Messages
Week current_week = 11; // required
optional Week next_week = 12; // optional
whisk.api.shared.v1.Time time = 13; // required
optional whisk.api.shared.v1.Time time_after = 14; // optional
// Wrappers (legacy)
google.protobuf.StringValue description = 21; // optional
// Force override (backward binary compatibility only)
string item = 31 [ (required) = false ]; // optional
google.protobuf.StringValue test = 32 [ (required) = true ]; // required
// Repeated - can't work with optional!
repeated bool array = 41; // required
repeated bool array_2 = 42 [ (required) = false ]; // optional
// Map - can't work with optional!
map<string, bool> map_search = 51; // required
map<string, bool> map_search_2 = 52 [ (required) = false ]; // optional
// Oneof - can't work with optional!
oneof device_description {
DeviceType device_type = 61; // required
DeviceType custom_device = 62 [ (required) = false ]; // optional
}
}
message Week {
int32 num = 1;
}
Result:
type Test = {
id: string;
text?: string;
currentWeek: Week;
nextWeek?: Week;
time: whisk_api_shared_v1_Time;
timeAfter?: whisk_api_shared_v1_Time;
description: string;
item?: string;
test: string;
array: boolean[];
array_2?: boolean[];
mapSearch: Record<string, boolean>;
mapSearch_2?: Record<string, boolean>;
deviceDescription?:
| { oneof: 'deviceType'; value: Device_DeviceType }
| { oneof: 'customDevice'; value?: Device_DeviceType };
};
Deprecated
For obsolete fields, you can mark them as deprecated
.
message TestOneof {
string id = 1 [ deprecated = true ];
google.protobuf.StringValue description = 10 [ required = true, deprecated = true ];
}
export type whisk_api_user_v2_TestOneof = {
id: string;
description: string;
};
Defaults
If field is required, bit not present in message, it is installed by table:
double | 0 |
float | 0 |
int32 | 0 |
int64 | 0 |
uint32 | 0 |
uint64 | 0 |
sint32 | 0 |
sint64 | 0 |
fixed32 | 0 |
fixed64 | 0 |
sfixed32 | 0 |
sfixed64 | 0 |
enum | 0 |
bool | false |
string | '' |
bytes | Uint8Array |