Skip to content

Commit

Permalink
feat(tonic-web): implement grpc <-> grpc-web protocol translation (#455)
Browse files Browse the repository at this point in the history
tonic-web enables tonic servers to handle requests from grpc-web 
clients directly, without the need of an external proxy. 

Co-authored-by: John Hernandez <john.hern@outlook.com>
Co-authored-by: zancas <zancas@zingolabs.com>
  • Loading branch information
3 people committed May 13, 2021
1 parent 352b0f5 commit c309063
Show file tree
Hide file tree
Showing 24 changed files with 2,445 additions and 2 deletions.
6 changes: 6 additions & 0 deletions tonic-web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
/target
binary
text
run.sh
package-lock.json
6 changes: 6 additions & 0 deletions tonic-web/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[workspace]
members = [
"tonic-web",
"interop",
"tests/integration"
]
2 changes: 2 additions & 0 deletions tonic-web/interop/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!client/*
13 changes: 13 additions & 0 deletions tonic-web/interop/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "web-interop"
version = "0.1.0"
authors = ["Juan Alvarez <j@yabit.io>"]
publish = false
edition = "2018"

[dependencies]
interop = { path = "../../interop" }
tonic = { path = "../../tonic" }
tonic-web = { path = "../tonic-web" }
tokio = { version = "1.0.1", features = ["rt-multi-thread", "macros"] }

28 changes: 28 additions & 0 deletions tonic-web/interop/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
FROM node:12-stretch

RUN apt-get install -y unzip

WORKDIR /tmp

RUN curl -sSL https://github.com/protocolbuffers/protobuf/releases/download/v3.14.0/\
protoc-3.14.0-linux-x86_64.zip -o protoc.zip && \
unzip -qq protoc.zip && \
cp ./bin/protoc /usr/local/bin/protoc

RUN curl -sSL https://github.com/grpc/grpc-web/releases/download/1.2.1/\
protoc-gen-grpc-web-1.2.1-linux-x86_64 -o /usr/local/bin/protoc-gen-grpc-web && \
chmod +x /usr/local/bin/protoc-gen-grpc-web

WORKDIR /

COPY ./client ./

RUN echo "\nloglevel=error\n" >> $HOME/.npmrc && npm install && mkdir -p binary text

RUN protoc -I=. ./test.proto\
--js_out=import_style=commonjs:./text\
--grpc-web_out=import_style=commonjs,mode=grpcwebtext:./text

RUN protoc -I=. ./test.proto\
--js_out=import_style=commonjs:./binary\
--grpc-web_out=import_style=commonjs,mode=grpcweb:./binary
27 changes: 27 additions & 0 deletions tonic-web/interop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Running interop tests

Start the server:

```bash
cd tonic-web/interop
cargo run
```
Build the client docker image:

```bash
cd tonic-web/interop
docker build -t grpcweb-client .
```
Run tests on linux:

```bash
docker run --network=host --rm grpcweb-client /test.sh
```
Run tests on docker desktop:

```bash
docker run --rm grpcweb-client /test.sh host.docker.internal
```
204 changes: 204 additions & 0 deletions tonic-web/interop/client/interop_client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/**
*
* Copyright 2018 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

// Adapted from https://github.com/grpc/grpc-web/tree/master/test/interop

global.XMLHttpRequest = require("xhr2");

const parseArgs = require('minimist');
const argv = parseArgs(process.argv, {
string: ['mode', 'host']
});

const SERVER_HOST = `http://${argv.host || "localhost"}:9999`;

if (argv.mode === 'binary') {
console.log('Testing tonic-web mode (binary)...');
} else {
console.log('Testing tonic-web mode (text)...');
}
console.log('Tonic server:', SERVER_HOST);

const PROTO_PATH = argv.mode === 'binary' ? './binary' : './text';

const {
Empty,
SimpleRequest,
StreamingOutputCallRequest,
EchoStatus,
Payload,
ResponseParameters
} = require(`${PROTO_PATH}/test_pb.js`);

const {TestServiceClient} = require(`${PROTO_PATH}/test_grpc_web_pb.js`);

const assert = require('assert');
const grpc = {};
grpc.web = require('grpc-web');

function multiDone(done, count) {
return function () {
count -= 1;
if (count <= 0) {
done();
}
};
}

function doEmptyUnary(done) {
const testService = new TestServiceClient(SERVER_HOST, null, null);
testService.emptyCall(new Empty(), null, (err, response) => {
assert.ifError(err);
assert(response instanceof Empty);
done();
});
}

function doLargeUnary(done) {
const testService = new TestServiceClient(SERVER_HOST, null, null);
const req = new SimpleRequest();
const size = 314159;

const payload = new Payload();
payload.setBody('0'.repeat(271828));

req.setPayload(payload);
req.setResponseSize(size);

testService.unaryCall(req, null, (err, response) => {
assert.ifError(err);
assert.equal(response.getPayload().getBody().length, size);
done();
});
}

function doServerStreaming(done) {
const testService = new TestServiceClient(SERVER_HOST, null, null);
const sizes = [31415, 9, 2653, 58979];

const responseParams = sizes.map((size, idx) => {
const param = new ResponseParameters();
param.setSize(size);
param.setIntervalUs(idx * 10);
return param;
});

const req = new StreamingOutputCallRequest();
req.setResponseParametersList(responseParams);

const stream = testService.streamingOutputCall(req);

done = multiDone(done, sizes.length);
let numCallbacks = 0;
stream.on('data', (response) => {
assert.equal(response.getPayload().getBody().length, sizes[numCallbacks]);
numCallbacks++;
done();
});
}

function doCustomMetadata(done) {
const testService = new TestServiceClient(SERVER_HOST, null, null);
done = multiDone(done, 3);

const req = new SimpleRequest();
const size = 314159;
const ECHO_INITIAL_KEY = 'x-grpc-test-echo-initial';
const ECHO_INITIAL_VALUE = 'test_initial_metadata_value';
const ECHO_TRAILING_KEY = 'x-grpc-test-echo-trailing-bin';
const ECHO_TRAILING_VALUE = 0xababab;

const payload = new Payload();
payload.setBody('0'.repeat(271828));

req.setPayload(payload);
req.setResponseSize(size);

const call = testService.unaryCall(req, {
[ECHO_INITIAL_KEY]: ECHO_INITIAL_VALUE,
[ECHO_TRAILING_KEY]: ECHO_TRAILING_VALUE
}, (err, response) => {
assert.ifError(err);
assert.equal(response.getPayload().getBody().length, size);
done();
});

call.on('metadata', (metadata) => {
assert(ECHO_INITIAL_KEY in metadata);
assert.equal(metadata[ECHO_INITIAL_KEY], ECHO_INITIAL_VALUE);
done();
});

call.on('status', (status) => {
assert('metadata' in status);
assert(ECHO_TRAILING_KEY in status.metadata);
assert.equal(status.metadata[ECHO_TRAILING_KEY], ECHO_TRAILING_VALUE);
done();
});
}

function doStatusCodeAndMessage(done) {
const testService = new TestServiceClient(SERVER_HOST, null, null);
const req = new SimpleRequest();

const TEST_STATUS_MESSAGE = 'test status message';
const echoStatus = new EchoStatus();
echoStatus.setCode(2);
echoStatus.setMessage(TEST_STATUS_MESSAGE);

req.setResponseStatus(echoStatus);

testService.unaryCall(req, {}, (err, response) => {
assert(err);
assert('code' in err);
assert('message' in err);
assert.equal(err.code, 2);
assert.equal(err.message, TEST_STATUS_MESSAGE);
done();
});
}

function doUnimplementedMethod(done) {
const testService = new TestServiceClient(SERVER_HOST, null, null);
testService.unimplementedCall(new Empty(), {}, (err, response) => {
assert(err);
assert('code' in err);
assert.equal(err.code, 12);
done();
});
}

const testCases = {
'empty_unary': {testFunc: doEmptyUnary},
'large_unary': {testFunc: doLargeUnary},
'server_streaming': {
testFunc: doServerStreaming,
skipBinaryMode: true
},
'custom_metadata': {testFunc: doCustomMetadata},
'status_code_and_message': {testFunc: doStatusCodeAndMessage},
'unimplemented_method': {testFunc: doUnimplementedMethod}
};


describe('tonic-web interop tests', function () {
Object.keys(testCases).forEach((testCase) => {
if (argv.mode === 'binary' && testCases[testCase].skipBinaryMode) return;
it('should pass ' + testCase, testCases[testCase].testFunc);
});
});
19 changes: 19 additions & 0 deletions tonic-web/interop/client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "grpc-web-interop-test",
"version": "0.1.0",
"description": "gRPC-Web Interop Test Client",
"license": "Apache-2.0",
"private": true,
"scripts": {
"test": "mocha -b --timeout 500 ./interop_client.js"
},
"dependencies": {
"google-protobuf": "~3.14.0",
"grpc-web": "~1.2.1"
},
"devDependencies": {
"minimist": "~1.2.5",
"mocha": "~7.1.1",
"xhr2": "~0.2.0"
}
}
68 changes: 68 additions & 0 deletions tonic-web/interop/client/test.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@

// Copyright 2015-2016 gRPC authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Adapted from https://github.com/grpc/grpc-web/tree/master/src/proto/grpc/testing

syntax = "proto3";

package grpc.testing;

service TestService {
rpc EmptyCall(grpc.testing.Empty) returns (grpc.testing.Empty);
rpc UnaryCall(SimpleRequest) returns (SimpleResponse);
rpc StreamingOutputCall(StreamingOutputCallRequest)
returns (stream StreamingOutputCallResponse);
rpc UnimplementedCall(grpc.testing.Empty) returns (grpc.testing.Empty);
}

message Empty {}

message BoolValue {
bool value = 1;
}

message Payload {
bytes body = 2;
}

message EchoStatus {
int32 code = 1;
string message = 2;
}

message SimpleRequest {
int32 response_size = 2;
Payload payload = 3;
EchoStatus response_status = 7;
}

message SimpleResponse {
Payload payload = 1;
}

message ResponseParameters {
int32 size = 1;
int32 interval_us = 2;
}

message StreamingOutputCallRequest {
repeated ResponseParameters response_parameters = 2;
Payload payload = 3;
EchoStatus response_status = 7;
}

message StreamingOutputCallResponse {
Payload payload = 1;
}
8 changes: 8 additions & 0 deletions tonic-web/interop/client/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/bash

set -e

HOST=${1:-"localhost"}

npm test -- --host="$HOST"
npm test -- --mode=binary --host="$HOST"
Loading

0 comments on commit c309063

Please sign in to comment.