├── test ├── fixture │ ├── greeter-client.js │ └── greeter.proto ├── errors.test.js └── index.test.js ├── package.json ├── LICENSE ├── .gitignore ├── README.md └── index.js /test/fixture/greeter-client.js: -------------------------------------------------------------------------------- 1 | const {promisify} = require("util"); 2 | const path = require("path"); 3 | const {createClient} = require("grpc-kit"); 4 | 5 | const client = createClient({ 6 | protoPath: path.resolve(__dirname, "./greeter.proto"), 7 | packageName: "greeter", 8 | serviceName: "Greeter" 9 | }, "0.0.0.0:50051"); 10 | 11 | exports.client = client; 12 | exports.hello = promisify(client.hello.bind(client)); 13 | exports.goodbye = promisify(client.goodbye.bind(client)); -------------------------------------------------------------------------------- /test/fixture/greeter.proto: -------------------------------------------------------------------------------- 1 | syntax="proto3"; 2 | 3 | package greeter; 4 | 5 | service Greeter { 6 | rpc Hello (RequestGreet) returns (ResponseGreet) {} 7 | rpc Goodbye (RequestGreet) returns (ResponseGreet) {} 8 | rpc HowAreYou (stream RequestGreet) returns (ResponseGreet) {} 9 | rpc NiceToMeetYou (RequestGreet) returns (stream ResponseGreet) {} 10 | rpc Chat (stream RequestGreet) returns (stream ResponseGreet) {} 11 | } 12 | 13 | message RequestGreet { 14 | string message = 1; 15 | } 16 | 17 | message ResponseGreet { 18 | string message = 1; 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grpc-mock", 3 | "version": "0.7.0", 4 | "description": "a simple grpc mock server", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "$(npm bin)/mocha --require intelli-espower-loader test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/YoshiyukiKato/grpc-mock.git" 12 | }, 13 | "keywords": [ 14 | "grpc", 15 | "mock" 16 | ], 17 | "author": "YoshiyukiKato", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/YoshiyukiKato/grpc-mock/issues" 21 | }, 22 | "homepage": "https://github.com/YoshiyukiKato/grpc-mock#readme", 23 | "dependencies": { 24 | "@grpc/proto-loader": "^0.3.0", 25 | "grpc": "^1.17.0", 26 | "grpc-kit": "^0.2.0", 27 | "partial-compare": "^1.0.1" 28 | }, 29 | "devDependencies": { 30 | "intelli-espower-loader": "^1.0.1", 31 | "mocha": "^5.2.0", 32 | "power-assert": "^1.6.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Yoshiyuki Kato 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/1ee8dd15d6aa5adbdd7f575143c6be4dc5dea56b/Global/OSX.gitignore 2 | 3 | .DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | .idea 7 | 8 | # Icon must ends with two \r. 9 | Icon 10 | 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear on external disk 16 | .Spotlight-V100 17 | .Trashes 18 | 19 | # Directories potentially created on remote AFP share 20 | .AppleDB 21 | .AppleDesktop 22 | Network Trash Folder 23 | Temporary Items 24 | 25 | 26 | 27 | ### https://raw.github.com/github/gitignore/1ee8dd15d6aa5adbdd7f575143c6be4dc5dea56b/Node.gitignore 28 | 29 | # Logs 30 | logs 31 | *.log 32 | 33 | # Runtime data 34 | pids 35 | *.pid 36 | *.seed 37 | 38 | # Directory for instrumented libs generated by jscoverage/JSCover 39 | lib-cov 40 | 41 | # Coverage directory used by tools like istanbul 42 | coverage 43 | 44 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 45 | .grunt 46 | 47 | # Compiled binary addons (http://nodejs.org/api/addons.html) 48 | build/Release 49 | 50 | # Dependency directory 51 | # Deployed apps should consider commenting this line out: 52 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 53 | node_modules 54 | 55 | 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # grpc-mock 2 | [![npm version](https://badge.fury.io/js/grpc-mock.svg)](https://badge.fury.io/js/grpc-mock) 3 | 4 | A simple mock gRPC server on Node.js. 5 | 6 | ```js 7 | const {createMockServer} = require("grpc-mock"); 8 | const mockServer = createMockServer({ 9 | protoPath: "/path/to/greeter.proto", 10 | packageName: "greeter", 11 | serviceName: "Greeter", 12 | rules: [ 13 | { method: "hello", input: { message: "test" }, output: { message: "Hello" } }, 14 | { method: "goodbye", input: ".*", output: { message: "Goodbye" } }, 15 | 16 | { 17 | method: "howAreYou", 18 | streamType: "client", 19 | stream: [ 20 | { input: { message: "Hi" } }, 21 | { input: { message: "How are you?" } }, 22 | ], 23 | output: { message: "I'm fine, thank you" } 24 | }, 25 | 26 | { 27 | method: "niceToMeetYou", 28 | streamType: "server", 29 | stream: [ 30 | { output: { message: "Hi, I'm Sana" } }, 31 | { output: { message: "Nice to meet you too" } }, 32 | ], 33 | input: { message: "Hi. I'm John. Nice to meet you" } 34 | }, 35 | 36 | { 37 | method: "chat", 38 | streamType: "mutual", 39 | stream: [ 40 | { input: { message: "Hi" }, output: { message: "Hi there" } }, 41 | { input: { message: "How are you?" }, output: { message: "I'm fine, thank you." } }, 42 | ] 43 | }, 44 | 45 | { method: "returnsError", input: { }, error: { code: 3, message: "Message text is required"} }, 46 | 47 | { 48 | method: "returnsErrorWithMetadata", 49 | streamType: "server", 50 | input: { }, 51 | error: { code: 3, message: "Message text is required", metadata: { key: "value"}} 52 | } 53 | ] 54 | }); 55 | mockServer.listen("0.0.0.0:50051"); 56 | ``` 57 | 58 | ```proto 59 | syntax="proto3"; 60 | 61 | package greeter; 62 | 63 | service Greeter { 64 | rpc Hello (RequestGreet) returns (ResponseGreet) {} 65 | rpc Goodbye (RequestGreet) returns (ResponseGreet) {} 66 | rpc HowAreYou (stream RequestGreet) returns (ResponseGreet) {} 67 | rpc NiceToMeetYou (RequestGreet) returns (stream ResponseGreet) {} 68 | rpc Chat (stream RequestGreet) returns (stream ResponseGreet) {} 69 | } 70 | 71 | message RequestGreet { 72 | string message = 1; 73 | } 74 | 75 | message ResponseGreet { 76 | string message = 1; 77 | } 78 | ``` 79 | 80 | ## api 81 | ### createMockServer({`protoPath`,`packageName`,`serviceName`,`options`,`rules`}): [grpc-kit](https://github.com/YoshiyukiKato/grpc-kit).GrpcServer 82 | 83 | |arg name|type|required/optional|description| 84 | |:-------|:---|:----------------|:----------| 85 | |**`protoPath`**|String|Required|path to `.proto` file| 86 | |**`packageName`**|String|Required|name of package| 87 | |**`serviceName`**|String|Required|name of service| 88 | |**`options`**|@grpc/proto-loader.Options|Optional|options for `@grpc/proto-loader` to load `.proto` file. In detail, please check [here](https://github.com/grpc/grpc-node/blob/master/packages/proto-loader/README.md) out. Default is `null`| 89 | |**`rules`**|Array\|Required|Array of Rules| 90 | 91 | ### Rule 92 | |prop name|type|required/optional|description| 93 | |:-------|:---|:----------------|:----------| 94 | |**`method`**|String|Required|path to `.proto` file| 95 | |**`streamType`**|Enum<"client"\|"server"\|"mutual">|Optional|Type of stream. Set `client` if only using client side stream, set `server` if only using server side stream, and set `mutual` if using both of client and server side stream. Set null/undefined if not using stream. Default is null| 96 | |**`input`**|Object\|String|Required when `streamType` is null or `server`|Specifying an expected input. Raw object or pattern string(RegExp) is available| 97 | |**`output`**|String|Required when `streamType` is null or `client`|Specifying an output to an expected input| 98 | |**`stream`**|Array\|Required when `streamType` is `client`, `server` and `mutual`|Array of Chunks| 99 | |**`error`**|Object|Optional|If provided, server will respond with this error object| 100 | 101 | #### Chunk 102 | |prop name|type|required/optional|description| 103 | |:-------|:---|:----------------|:----------| 104 | |**`input`**|Object\|String|Required when `streamType` is `client`. Optional when `streamType` is `mutual`|Specifying an expected input. Raw object or pattern string(RegExp) is available.| 105 | |**`output`**|Object|Required when `streamType` is `server`. Optional when `streamType` is `mutual`|Specifying an output to an expected input| 106 | -------------------------------------------------------------------------------- /test/errors.test.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const assert = require("power-assert"); 3 | const { client, hello, goodbye } = require("./fixture/greeter-client"); 4 | const { createMockServer } = require("../index"); 5 | const protoPath = path.resolve(__dirname, "./fixture/greeter.proto"); 6 | const packageName = "greeter"; 7 | const serviceName = "Greeter"; 8 | const mockServer = createMockServer({ 9 | protoPath, 10 | packageName, 11 | serviceName, 12 | rules: [ 13 | { 14 | method: "hello", input: { message: "test" }, 15 | error: { 16 | code: 3, message: "Wrong request", metadata: { code: 400 } 17 | } 18 | }, 19 | { 20 | method: "hello", input: { message: "what" }, 21 | error: { 22 | code: 3, message: "How rude", metadata: { code: 400 } 23 | } 24 | }, 25 | { 26 | method: "goodbye", input: ".*", 27 | error: { 28 | code: 5, message: "Not found", metadata: { code: "404" } 29 | } 30 | }, 31 | { 32 | method: "howAreYou", 33 | streamType: "client", 34 | stream: [ 35 | { input: { message: "Hi" } }, 36 | { input: { message: "How are you?" } }, 37 | ], 38 | error: { code: 3, message: "Wrong request", metadata: { code: 400 } } 39 | }, 40 | { 41 | method: "howAreYou", 42 | streamType: "client", 43 | stream: [ 44 | { input: { message: "Hello" } }, 45 | { input: { message: "What's up?" } }, 46 | ], 47 | error: { code: 3, message: "Short request", metadata: { code: 400 } } 48 | }, 49 | { 50 | method: "niceToMeetYou", 51 | streamType: "server", 52 | error: { code: 3, message: "Wrong request", metadata: { code: 400 } }, 53 | input: { message: "Hi. I\'m John. Nice to meet you" } 54 | }, 55 | { 56 | method: "niceToMeetYou", 57 | streamType: "server", 58 | error: { code: 3, message: "So you are", metadata: { code: 400 } }, 59 | input: { message: "Hi. I\'m Frank." } 60 | }, 61 | { 62 | method: "chat", 63 | streamType: "mutual", 64 | stream: [ 65 | { 66 | input: { message: "Hi" }, 67 | }, 68 | { 69 | input: { message: "How are you?" }, 70 | }, 71 | ], 72 | error: { code: 3, message: "Wrong request", metadata: { code: 400 } }, 73 | }, 74 | { 75 | method: "chat", 76 | streamType: "mutual", 77 | stream: [ 78 | { 79 | input: { message: "Hello" }, 80 | }, 81 | { 82 | input: { message: "Rain?" }, 83 | }, 84 | ], 85 | error: { code: 3, message: "You are all wet", metadata: { code: 400 } }, 86 | } 87 | ] 88 | }); 89 | 90 | describe("grpc-mock errors", () => { 91 | before((done) => { 92 | mockServer.listen("0.0.0.0:50051"); 93 | done(); 94 | }); 95 | 96 | afterEach(() => mockServer.clearInteractions()); 97 | 98 | it("responds with 'Wrong request' on Hello", () => { 99 | return hello({ message: "test" }) 100 | .then(() => assert(false, "Shouldn't respond with payload")) 101 | .catch(({code, message, metadata})=> 102 | assert.deepEqual( 103 | { code, message, metadata: { code: metadata.get("code").pop() } }, 104 | { code: 3, message: "3 INVALID_ARGUMENT: Wrong request", metadata: { code: "400" } } 105 | ) 106 | ); 107 | }); 108 | 109 | it("responds with 'How rude' on Hello", () => { 110 | return hello({ message: "what" }) 111 | .then(() => assert(false, "Shouldn't respond with payload")) 112 | .catch(({code, message, metadata})=> 113 | assert.deepEqual( 114 | { code, message, metadata: { code: metadata.get("code").pop() } }, 115 | { code: 3, message: "3 INVALID_ARGUMENT: How rude", metadata: { code: "400" } } 116 | ) 117 | ); 118 | }); 119 | 120 | it("responds with 'Not found' in Goodbye", () => { 121 | return goodbye({}) 122 | .then(() => assert(false, "Shouldn't respond with payload")) 123 | .catch(({code, message, metadata})=> 124 | assert.deepEqual( 125 | { code, message, metadata: { code: metadata.get("code").pop() } }, 126 | { code: 5, message: "5 NOT_FOUND: Not found", metadata: { code: "404" } } 127 | ) 128 | ); 129 | }); 130 | 131 | describe("client stream", () => { 132 | it("client stream responds with 'Wrong request' on 'how are you'", (done) => { 133 | let counter = 0; 134 | const call = client.howAreYou((err, data) => { 135 | assert(!data, "Shouldn\"t respond with payload"); 136 | const { code, message, metadata } = err; 137 | assert(counter===0, 'Should be called once'); 138 | assert.deepEqual( 139 | { code, message, metadata: { code: metadata.get("code").pop() } }, 140 | { code: 3, message: "3 INVALID_ARGUMENT: Wrong request", metadata: { code: "400" } } 141 | ); 142 | counter++; 143 | done(); 144 | }); 145 | call.write({ message: "Hi" }); 146 | call.write({ message: "How are you?" }); 147 | call.end(); 148 | }); 149 | 150 | it("client stream responds with 'Short request' on 'how are you'", (done) => { 151 | let counter = 0; 152 | const call = client.howAreYou((err, data) => { 153 | assert(!data, "Shouldn\"t respond with payload"); 154 | const { code, message, metadata } = err; 155 | assert(counter===0, 'Should be called once'); 156 | assert.deepEqual( 157 | { code, message, metadata: { code: metadata.get("code").pop() } }, 158 | { code: 3, message: "3 INVALID_ARGUMENT: Short request", metadata: { code: "400" } } 159 | ); 160 | counter++; 161 | done(); 162 | }); 163 | call.write({ message: "Hello" }); 164 | call.write({ message: "What's up?" }); 165 | call.end(); 166 | }); 167 | }); 168 | 169 | describe("server stream", () => { 170 | it("responds with 'Wrong request' to 'nice to meet you'", (done) => { 171 | const call = client.niceToMeetYou({ message: "Hi. I'm John. Nice to meet you" }); 172 | call.on("error", ({code, message, metadata}) => { 173 | assert.deepEqual( 174 | { code, message, metadata: { code: metadata.get("code").pop() } }, 175 | { code: 3, message: "3 INVALID_ARGUMENT: Wrong request", metadata: { code: "400" } } 176 | ); 177 | done(); 178 | }); 179 | call.on("data", () => assert("Should't respond with payload")); 180 | call.on("end", () => assert("Should't end with payload")); 181 | }); 182 | 183 | it("responds with 'So you are' to 'nice to meet you'", (done) => { 184 | const call = client.niceToMeetYou({ message: "Hi. I'm Frank." }); 185 | call.on("error", ({code, message, metadata}) => { 186 | assert.deepEqual( 187 | { code, message, metadata: { code: metadata.get("code").pop() } }, 188 | { code: 3, message: "3 INVALID_ARGUMENT: So you are", metadata: { code: "400" } } 189 | ); 190 | done(); 191 | }); 192 | call.on("data", () => assert("Should't respond with payload")); 193 | call.on("end", () => assert("Should't end with payload")); 194 | }); 195 | }); 196 | 197 | describe("mutual stream", () => { 198 | it("responds with 'Wrong request' to 'chat'", (done) => { 199 | const call = client.chat(); 200 | call.on("error", ({code, message, metadata}) => { 201 | assert.deepEqual( 202 | { code, message, metadata: { code: metadata.get("code").pop() } }, 203 | { code: 3, message: "3 INVALID_ARGUMENT: Wrong request", metadata: { code: "400" } } 204 | ); 205 | done(); 206 | }); 207 | call.on("data", () => assert("Should't respond with payload")); 208 | call.on("end", () => assert("Should't end with payload")); 209 | call.write({ message: "Hi" }); 210 | call.write({ message: "How are you?" }); 211 | }); 212 | 213 | it("responds with 'You are all wet' to 'chat'", (done) => { 214 | const call = client.chat(); 215 | call.on("error", ({code, message, metadata}) => { 216 | assert.deepEqual( 217 | { code, message, metadata: { code: metadata.get("code").pop() } }, 218 | { code: 3, message: "3 INVALID_ARGUMENT: You are all wet", metadata: { code: "400" } } 219 | ); 220 | done(); 221 | }); 222 | call.on("data", () => assert("Should't respond with payload")); 223 | call.on("end", () => assert("Should't end with payload")); 224 | call.write({ message: "Hello" }); 225 | call.write({ message: "Rain?" }); 226 | }); 227 | }); 228 | 229 | after(() => { 230 | mockServer.close(true); 231 | }); 232 | }); 233 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const assert =require("power-assert"); 3 | const {client, hello, goodbye} = require("./fixture/greeter-client"); 4 | const {createMockServer} = require("../index"); 5 | const protoPath = path.resolve(__dirname, "./fixture/greeter.proto"); 6 | const packageName = "greeter"; 7 | const serviceName = "Greeter"; 8 | const mockServer = createMockServer({ 9 | protoPath, 10 | packageName, 11 | serviceName, 12 | rules: [ 13 | { method: "hello", input: { message: "test" }, output: { message: "Hello" } }, 14 | { method: "hello", input: { message: "Hi" }, output: { message: "Back at you" } }, 15 | { method: "goodbye", input: ".*", output: { message: "Goodbye" } }, 16 | { 17 | method: "howAreYou", 18 | streamType: "client", 19 | stream: [ 20 | { input: { message: "Hi" } }, 21 | { input: { message: "How are you?" } }, 22 | ], 23 | output: { message: "I'm fine, thank you" } 24 | }, 25 | { 26 | method: "howAreYou", 27 | streamType: "client", 28 | stream: [ 29 | { input: { message: "Hello" } }, 30 | { input: { message: "How is the weather?" } }, 31 | ], 32 | output: { message: "Looks like it might rain" } 33 | }, 34 | { 35 | method: "niceToMeetYou", 36 | streamType: "server", 37 | stream: [ 38 | { output: { message: "Hi, I'm Sana" } }, 39 | { output: { message: "Nice to meet you too" } }, 40 | ], 41 | input: { message: "Hi. I'm John. Nice to meet you" } 42 | }, 43 | { 44 | method: "niceToMeetYou", 45 | streamType: "server", 46 | stream: [ 47 | { output: { message: "Hi, I'm Sana" } }, 48 | { output: { message: "Have you met John?" } }, 49 | ], 50 | input: { message: "Hi. I'm Frank. Nice to meet you" } 51 | }, 52 | { 53 | method: "chat", 54 | streamType: "mutual", 55 | stream: [ 56 | { input: { message: "Hi" }, output: { message: "Hi there" } }, 57 | { input: { message: "How are you?" }, output: { message: "I'm fine, thank you." } }, 58 | ] 59 | }, 60 | { 61 | method: "chat", 62 | streamType: "mutual", 63 | stream: [ 64 | { input: { message: "Hello" }, output: { message: "G'day" } }, 65 | { input: { message: "Do you think it will rain?" }, output: { message: "No, the sky looks clear" } }, 66 | ] 67 | } 68 | ] 69 | }); 70 | 71 | describe("grpc-mock", () => { 72 | before((done) => { 73 | mockServer.listen("0.0.0.0:50051"); 74 | done(); 75 | }); 76 | 77 | afterEach(() => mockServer.clearInteractions()); 78 | 79 | it("responds Hello", () => { 80 | return hello({ message : "test" }) 81 | .then((res) => { 82 | assert(res.message === "Hello"); 83 | }) 84 | .catch(assert); 85 | }); 86 | 87 | it("responds Back at you", () => { 88 | return hello({ message : "Hi" }) 89 | .then((res) => { 90 | assert(res.message === "Back at you"); 91 | }) 92 | .catch(assert); 93 | }); 94 | 95 | it("responds Goodbye", () => { 96 | return goodbye({}) 97 | .then((res) => { 98 | assert(res.message === "Goodbye"); 99 | }) 100 | .catch(assert); 101 | }); 102 | 103 | it("throws unexpected input pattern error", () => { 104 | return hello({ message : "unexpected" }) 105 | .then((res) => { 106 | assert.fail("unexpected success with response:", res); 107 | }) 108 | .catch((err) => { 109 | assert(err.code, 3); 110 | assert(err.details, "unexpected input pattern"); 111 | }); 112 | }); 113 | 114 | it("records the interactions", () => { 115 | return hello({ message : "test" }) 116 | .then((res) => { 117 | assert.deepEqual(mockServer.getInteractionsOn("hello"), [ { message: "test" } ]); 118 | }); 119 | }); 120 | 121 | it("records the interactions when there are no valid responses", (done) => { 122 | hello({ message : "test1" }).catch(e => {}); 123 | 124 | setTimeout(() => { 125 | assert.deepEqual(mockServer.getInteractionsOn("hello"), [ { message: "test1" } ]); 126 | done(); 127 | }, 20); 128 | }); 129 | 130 | describe("client stream", () => { 131 | it("responds how are you", (done) => { 132 | const call = client.howAreYou((err, data) => { 133 | if(err){ 134 | assert(err); 135 | }else{ 136 | assert.deepEqual(data, { message: "I'm fine, thank you" }); 137 | } 138 | done(); 139 | }); 140 | call.write({ message: "Hi" }); 141 | call.write({ message: "How are you?" }); 142 | call.end(); 143 | }); 144 | 145 | it("responds it is raining", (done) => { 146 | const call = client.howAreYou((err, data) => { 147 | if(err){ 148 | assert(err); 149 | }else{ 150 | assert.deepEqual(data, { message: "Looks like it might rain" }); 151 | } 152 | done(); 153 | }); 154 | call.write({ message: "Hello" }); 155 | call.write({ message: "How is the weather?" }); 156 | call.end(); 157 | }); 158 | 159 | it("throws unexpected input pattern error", (done) => { 160 | const call = client.howAreYou((err, data) => { 161 | if(err){ 162 | assert(err.code, 3); 163 | assert(err.details, "unexpected input pattern"); 164 | }else{ 165 | assert.fail("unexpected success with response:", data); 166 | } 167 | done(); 168 | }); 169 | call.write({ message: "Hi" }); 170 | call.write({ message: "unexpected" }); 171 | call.end(); 172 | }); 173 | 174 | it("records the interactions when there are valid responses", (done) => { 175 | const call = client.howAreYou((err, data) => { 176 | if(err){ 177 | assert(err); 178 | }else{ 179 | assert.deepEqual(mockServer.getInteractionsOn("howAreYou"), [ 180 | { message: "Hi" }, 181 | { message: "How are you?" }, 182 | ]); 183 | } 184 | done(); 185 | }); 186 | call.write({ message: "Hi" }); 187 | call.write({ message: "How are you?" }); 188 | call.end(); 189 | }); 190 | 191 | it("records the interactions when there are no valid responses", (done) => { 192 | const call = client.howAreYou((err, data) => {}); 193 | call.write({ message: "Hi" }); 194 | call.write({ message: "Unexpected message" }); 195 | call.write({ message: "How are you?" }); 196 | call.end(); 197 | 198 | setTimeout(() => { 199 | assert.deepEqual(mockServer.getInteractionsOn("howAreYou"), [ 200 | { message: "Hi" }, 201 | { message: "Unexpected message" } 202 | ]); 203 | done(); 204 | }, 20); 205 | 206 | }); 207 | }); 208 | 209 | describe("server stream", () => { 210 | it("responds nice to meet you", (done) => { 211 | const call = client.niceToMeetYou({ message: "Hi. I'm John. Nice to meet you" }); 212 | const memo = []; 213 | call.on("data", (data) => { 214 | memo.push(data); 215 | }); 216 | call.on("end", () => { 217 | assert.deepEqual(memo, [ 218 | { message: "Hi, I'm Sana" }, 219 | { message: "Nice to meet you too" } 220 | ]); 221 | done(); 222 | }); 223 | }); 224 | 225 | it("responds have you met john", (done) => { 226 | const call = client.niceToMeetYou({ message: "Hi. I'm Frank. Nice to meet you" }); 227 | const memo = []; 228 | call.on("data", (data) => { 229 | memo.push(data); 230 | }); 231 | call.on("end", () => { 232 | assert.deepEqual(memo, [ 233 | { message: "Hi, I'm Sana" }, 234 | { message: "Have you met John?" } 235 | ]); 236 | done(); 237 | }); 238 | }); 239 | 240 | it("throws unexpected input pattern error", (done) => { 241 | const call = client.niceToMeetYou({ message: "unexpected" }); 242 | call.on("data", (data) => { 243 | assert.fail("unexpected success with response:", data); 244 | done(); 245 | }); 246 | call.on("error", (err) => { 247 | assert(err.code, 3); 248 | assert(err.details, "unexpected input pattern"); 249 | done(); 250 | }); 251 | }); 252 | }); 253 | 254 | describe("mutual stream", () => { 255 | it("responds chat", (done) => { 256 | const call = client.chat(); 257 | const memo = []; 258 | call.on("data", (data) => { 259 | memo.push(data); 260 | }); 261 | call.on("end", () => { 262 | assert.deepEqual(memo, [ 263 | { message: "Hi there" }, 264 | { message: "I'm fine, thank you." } 265 | ]); 266 | done(); 267 | }); 268 | call.write({ message: "Hi" }); 269 | call.write({ message: "How are you?" }); 270 | }); 271 | 272 | it("responds weather", (done) => { 273 | const call = client.chat(); 274 | const memo = []; 275 | call.on("data", (data) => { 276 | memo.push(data); 277 | }); 278 | call.on("end", () => { 279 | assert.deepEqual(memo, [ 280 | { message: "G'day" }, 281 | { message: "No, the sky looks clear" } 282 | ]); 283 | done(); 284 | }); 285 | call.write({ message: "Hello" }); 286 | call.write({ message: "Do you think it will rain?" }); 287 | }); 288 | 289 | it("throws unexpected input pattern error", (done) => { 290 | const call = client.chat(); 291 | call.on("data", (data) => { 292 | assert.fail("unexpected success with response:", data); 293 | done(); 294 | }); 295 | call.on("error", (err) => { 296 | assert(err.code, 3); 297 | assert(err.details, "unexpected input pattern"); 298 | done(); 299 | }); 300 | call.write({ message: "Unexpected" }); 301 | }); 302 | }); 303 | 304 | after(() => { 305 | mockServer.close(true); 306 | }); 307 | }); 308 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { createServer } = require('grpc-kit'); 2 | const { Metadata } = require('grpc'); 3 | const partial_compare = require('partial-compare'); 4 | const UNEXPECTED_INPUT_PATTERN_ERROR = { 5 | code: 3, 6 | message: "unexpected input pattern" 7 | }; 8 | 9 | function createMockServer({ rules, ...config }) { 10 | const routesFactory = rules.reduce((_routesFactory, { method, streamType, stream, input, output, error }) => { 11 | const handlerFactory = _routesFactory.getHandlerFactory(method) 12 | || _routesFactory.initHandlerFactory(method); 13 | handlerFactory.addRule({ method, streamType, stream, input, output, error }); 14 | return _routesFactory; 15 | }, new RoutesFactory()); 16 | const routes = routesFactory.generateRoutes(); 17 | const grpcServer = createServer(); 18 | 19 | grpcServer.getInteractionsOn = (method) => routes[method].interactions; 20 | grpcServer.clearInteractions = () => Object.keys(routes).forEach(method => routes[method].interactions.length = 0); 21 | 22 | return grpcServer.use({ ...config, routes }); 23 | } 24 | 25 | class RoutesFactory { 26 | constructor() { 27 | this.routebook = {}; 28 | } 29 | 30 | getHandlerFactory(method) { 31 | return this.routebook[method]; 32 | } 33 | 34 | initHandlerFactory(method) { 35 | this.routebook[method] = new HandlerFactory(); 36 | return this.routebook[method]; 37 | } 38 | 39 | generateRoutes() { 40 | return Object.entries(this.routebook).reduce((_routes, [method, handlerFactory]) => { 41 | _routes[method] = handlerFactory.generateHandler(); 42 | return _routes; 43 | }, {}); 44 | } 45 | } 46 | 47 | const prepareMetadata = error => { 48 | let errorFields = Object.entries(error); 49 | if (error.metadata) { 50 | const grpcMetadata = Object.entries(error.metadata) 51 | .reduce((m, [k, v]) => (m.add(k, String(v)), m), new Metadata()); 52 | errorFields = [ 53 | ...errorFields, 54 | ['metadata', grpcMetadata], 55 | ]; 56 | } 57 | return errorFields.reduce((e, [k, v]) => (e[k] = v, e), new Error()); 58 | }; 59 | 60 | class HandlerFactory { 61 | constructor() { 62 | this.rules = []; 63 | } 64 | 65 | addRule(rule) { 66 | this.rules.push(rule); 67 | } 68 | 69 | generateHandler() { 70 | let interactions = []; 71 | const handler = function (call, callback) { 72 | 73 | /* 74 | * On each request handlers are generated for that request based on the 75 | * defined rules. It is possible, if there are multiple rules for a 76 | * method, that mutiple handlers will get generated and each will 77 | * attempt to process the incoming messages. This can lead to multiple 78 | * handlers attempting to respond and all sort of nastiness happens. 79 | * 80 | * To "work-a-round" this some state variables are used to capture the 81 | * responses from each of the handlers for a rule and insure only a 82 | * single response is sent. 83 | * 84 | * The basic flows is capture the output of each handler and when they 85 | * all have run to completion look at the results and pick the best one, 86 | * where best is defined (in order) as: 87 | * 1. a successful match and response 88 | * 2. a successful match and error 89 | * 3. an unexpected pattern error 90 | * 91 | * Before kicking off the processing the number of know rules is known 92 | * to be `this.rules.length`, so it is known haw many response should 93 | * be expected. 94 | */ 95 | var response = { 96 | // number of yet to complete rule handlers 97 | active: this.rules.length, 98 | 99 | // used to capture a successful output 100 | output: undefined, 101 | 102 | // used to capture an error output 103 | error: undefined, 104 | 105 | // used to capture interaction data (needed for testing) 106 | data: [], 107 | 108 | // used by the `mutual` client handling to ensure only a single 109 | // rule is matched 110 | locked: false, 111 | }; 112 | for (const { streamType, stream, input, output, error } of this.rules) { 113 | if (streamType === 'client') { 114 | // give each rule handler its own "done" and data stack variables 115 | (function () { 116 | var done = false 117 | var dataStack = [] 118 | call.on('data', function (memo, data) { 119 | if (!done) { 120 | memo.push(data); 121 | dataStack.push(data) 122 | const included = memo.reduce((_matched, memoData, index) => { 123 | if(stream[index]){ 124 | return _matched && isMatched(memoData, stream[index].input); 125 | }else{ 126 | return false; 127 | } 128 | }, true); 129 | const matched = included && memo.length === stream.length; 130 | 131 | if (matched) { 132 | if (error) { 133 | response.error = error; 134 | response.active = response.active - 1; 135 | response.data = dataStack; 136 | done = true 137 | } else { 138 | response.output = output; 139 | response.active = response.active - 1; 140 | response.data = dataStack; 141 | done = true 142 | } 143 | } else if(included) { 144 | //nothing todo 145 | } else { 146 | response.active = response.active - 1; 147 | response.data = dataStack; 148 | done = true; 149 | } 150 | } 151 | if (response.active == 0) { 152 | // set to -1 so no one else attempts to set the output 153 | response.active = -1 154 | for (var i in response.data) { 155 | interactions.push(dataStack[i]) 156 | } 157 | if (response.output) { 158 | callback(null, response.output); 159 | } else if (response.error) { 160 | callback(prepareMetadata(response.error)); 161 | } else { 162 | callback(prepareMetadata(UNEXPECTED_INPUT_PATTERN_ERROR)); 163 | } 164 | } 165 | }.bind(null, [])); 166 | })(); 167 | } else if (streamType === 'server') { 168 | var dataStack = []; 169 | dataStack.push(call.request); 170 | if (isMatched(call.request, input)) { 171 | if (error) { 172 | response.data = dataStack; 173 | response.error = error; 174 | } else { 175 | response.data = dataStack; 176 | response.output = stream; 177 | } 178 | } else { 179 | response.data = dataStack; 180 | } 181 | response.active = response.active - 1; 182 | if (response.active == 0) { 183 | // set to -1 so no one else attempts to set the output 184 | response.active = -1; 185 | for (var i in response.data) { 186 | interactions.push(dataStack[i]) 187 | } 188 | if (response.output) { 189 | for (const { output } of response.output) { 190 | call.write(output); 191 | } 192 | } else if (response.error) { 193 | call.emit('error', prepareMetadata(response.error)); 194 | } else { 195 | call.emit('error', prepareMetadata(UNEXPECTED_INPUT_PATTERN_ERROR)); 196 | } 197 | call.end(); 198 | } 199 | } else if (streamType === 'mutual') { 200 | /* 201 | * `mutual` handling is a little bit different because we can't 202 | * attempt a "best" match and then give output. Instead we we "lock" 203 | * on to the first rule that matches and run that to completion. 204 | * Once one rule is locked, the other will simply be no-ops. 205 | */ 206 | (function () { 207 | var done = false; 208 | var haveLock = false; 209 | call.on('data', function (stream, memo, data) { 210 | if (response.locked && !haveLock) { 211 | if (!done) { 212 | response.active = response.active = 1; 213 | done = true; 214 | } 215 | } 216 | if (!done) { 217 | memo.push(data); 218 | 219 | if (haveLock && error) { 220 | interactions.push(data); 221 | response.active = response.active - 1; 222 | response.error = error; 223 | done = true; 224 | call.emit('error', prepareMetadata(error)); 225 | } else if (haveLock && stream && stream[0] && !stream[0].input) { 226 | interactions.push(data); 227 | const { output } = stream.shift(); 228 | call.write(output); 229 | } else if ((haveLock || !response.locked) && stream && stream[0] && isMatched(memo[0], stream[0].input)) { 230 | interactions.push(data); 231 | response.locked = true; 232 | if (!haveLock) { 233 | response.active = response.active - 1; 234 | haveLock = true; 235 | } 236 | memo.shift(); 237 | const { output } = stream.shift(); 238 | if (output) { 239 | call.write(output); 240 | } 241 | } else if (haveLock) { 242 | //TODO: raise error 243 | interactions.push(data); 244 | call.emit('error', prepareMetadata(UNEXPECTED_INPUT_PATTERN_ERROR)); 245 | call.end(); 246 | } else { 247 | response.active = response.active - 1; 248 | done = true; 249 | } 250 | 251 | if (haveLock && stream.length === 0) { 252 | call.end(); 253 | } 254 | } 255 | if (!response.locked && response.active == 0) { 256 | call.emit('error', prepareMetadata(UNEXPECTED_INPUT_PATTERN_ERROR)); 257 | call.end(); 258 | } 259 | }.bind(null, [...stream], [])); 260 | })() 261 | } else { 262 | if (isMatched(call.request, input)) { 263 | if (error) { 264 | response.error = error; 265 | } else { 266 | response.output = output; 267 | } 268 | } 269 | response.active = response.active - 1; 270 | if (response.active == 0) { 271 | // set to -1 so no one else attempts to set the output 272 | response.active = -1; 273 | interactions.push(call.request); 274 | if (response.output) { 275 | callback(null, response.output); 276 | } else if (response.error) { 277 | callback(prepareMetadata(response.error)); 278 | } else { 279 | callback(prepareMetadata(UNEXPECTED_INPUT_PATTERN_ERROR)); 280 | } 281 | } 282 | } 283 | } 284 | }.bind(this); 285 | handler.interactions = interactions; 286 | return handler; 287 | } 288 | 289 | } 290 | 291 | function isMatched(actual, expected) { 292 | if (typeof expected === 'string') { 293 | return JSON.stringify(actual).match(new RegExp(expected)); 294 | } else { 295 | if (process.env.GRPC_MOCK_COMPARE && process.env.GRPC_MOCK_COMPARE == "sparse") { 296 | return partial_compare(actual, expected); 297 | } 298 | return JSON.stringify(actual) === JSON.stringify(expected); 299 | } 300 | } 301 | 302 | exports.createMockServer = createMockServer; 303 | --------------------------------------------------------------------------------