Skip to content

Commit 142798a

Browse files
authored
Merge pull request #11 from atreya2011/support-url-query-params
Handle rendering of URL Query Parameters in renderURL
2 parents 4dae0ad + 2c992f8 commit 142798a

File tree

11 files changed

+1149
-138
lines changed

11 files changed

+1149
-138
lines changed

README.md

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,56 @@
11
# protoc-gen-grpc-gateway-ts
22

3-
`protoc-gen-grpc-gateway-ts` is a Typescript client generator for the [grpc-gateway](https://github.com/grpc-ecosystem/grpc-gateway/) project. It generates idiomatic Typescript clients that connect the web frontend and golang backend fronted by grpc-gateway.
4-
3+
`protoc-gen-grpc-gateway-ts` is a TypeScript client generator for the [grpc-gateway](https://github.com/grpc-ecosystem/grpc-gateway/) project. It generates idiomatic TypeScript clients that connect the web frontend and golang backend fronted by grpc-gateway.
54

65
## Features:
7-
1. idiomatic Typescript clients and messages
8-
2. Supports both One way and server side streaming gRPC calls
9-
3. POJO request construction guarded by message type definitions, which is way easier compare to `grpc-web`
6+
1. Idiomatic Typescript clients and messages.
7+
2. Supports both one way and server side streaming gRPC calls.
8+
3. POJO request construction guarded by message type definitions, which is way easier compare to `grpc-web`.
109
4. No need to use swagger/open api to generate client code for the web.
1110

12-
## Get Started
11+
## Getting Started:
1312

1413
### Install `protoc-gen-grpc-gateway-ts`
15-
You will need to install `protoc-gen-grpc-gateway-ts` before it could be picked up by the `protoc` command. Just run `cd protoc-gen-grpc-gateway-ts; go install .`
14+
You will need to install `protoc-gen-grpc-gateway-ts` before it could be picked up by the `protoc` command. Just run `go install github.com/grpc-ecosystem/protoc-gen-grpc-gateway-ts`
1615

17-
### Sample Usage
18-
`protoc-gen-grpc-gateway-ts` will be used along with the `protoc` command. A sample invocation looks like the following:
16+
### Sample Usage:
17+
`protoc-gen-grpc-gateway-ts` should be used along with the `protoc` command. A sample invocation looks like the following:
1918

2019
`protoc --grpc-gateway-ts_out=ts_import_roots=$(pwd),ts_import_root_aliases=base:. input.proto`
2120

22-
As a result the generated file will be `input.pb.ts` in the same directory.
21+
As a result the generated file will be `input.pb.ts` in the same directory.
2322

2423
## Parameters:
2524
### `ts_import_roots`
26-
Since a protoc plugin do not get the import path information as what's specified in `protoc -I`, this parameter gives the plugin the same information to figure out where a specific type is coming from so that it can generate `import` statement at the top of the generated typescript file. Defaults to `$(pwd)`
25+
Since protoc plugins do not get the import path information as what's specified in `protoc -I`, this parameter gives the plugin the same information to figure out where a specific type is coming from so that it can generate `import` statement at the top of the generated typescript file. Defaults to `$(pwd)`
2726

2827
### `ts_import_root_aliases`
29-
If a project has setup alias for their import. This parameter can be used to keep up with the project setup. It will print out alias instead of relative path in the import statement. Default is "".
28+
If a project has setup an alias for their import. This parameter can be used to keep up with the project setup. It will print out alias instead of relative path in the import statement. Default to "".
3029

3130
`ts_import_roots` & `ts_import_root_aliases` are useful when you have setup import alias in your project with the project asset bundler, e.g. Webpack.
3231

3332
### `fetch_module_directory` and `fetch_module_filename`
34-
`protoc-gen-grpc-gateway-ts` will a shared typescript file with communication functions. These two parameters together will determine where the fetch module file is located. Default to `$(pwd)/fetch.pb.ts`
33+
`protoc-gen-grpc-gateway-ts` generates a shared typescript file with communication functions. These two parameters together will determine where the fetch module file is located. Default to `$(pwd)/fetch.pb.ts`
3534

3635
### `use_proto_names`
3736
To keep the same convention with `grpc-gateway` v2 & `protojson`. The field name in message generated by this library is in lowerCamelCase by default. If you prefer to make it stick the same with what is defined in the proto file, this option needs to be set to true.
3837

3938
### `logtostderr`
40-
Turn Ton logging to stderr. Default to false.
39+
Turn on logging to stderr. Default to false.
4140

4241
### `loglevel`
4342
Defines the logging levels. Default to info. Valid values are: debug, info, warn, error
4443

44+
### Notes:
45+
Zero-value fields are omitted from the URL query parameter list for GET requests. Therefore for a request payload such as `{ a: "A", b: "" c: 1, d: 0, e: false }` will become `/path/query?a=A&c=1`. A sample implementation is present within this [proto file](https://github.com/grpc-ecosystem/protoc-gen-grpc-gateway-ts/blob/master/integration_tests/service.proto) in the`integration_tests` folder. For further explanation please read the following:
46+
- <https://developers.google.com/protocol-buffers/docs/proto3#default>
47+
- <https://github.com/googleapis/googleapis/blob/master/google/api/http.proto>
48+
4549
## Examples:
46-
The following shows how to use the generated typescript code.
50+
The following shows how to use the generated TypeScript code.
4751

4852
Proto file: `counter.proto`
53+
4954
```proto
5055
// file: counter.proto
5156
message Request {
@@ -62,7 +67,7 @@ service CounterService {
6267
}
6368
```
6469

65-
Run the following command to generate the Typescript client:
70+
Run the following command to generate the TypeScript client:
6671

6772
`protoc --grpc-gateway-ts_out=. counter.proto`
6873

@@ -90,7 +95,8 @@ async function increaseRepeatedly(base: number): Promise<number[]> {
9095

9196
```
9297

93-
##License
98+
## License
99+
94100
```text
95101
Copyright 2020 Square, Inc.
96102
@@ -106,4 +112,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
106112
See the License for the specific language governing permissions and
107113
limitations under the License.
108114
```
109-

generator/template.go

Lines changed: 141 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package generator
33
import (
44
"bytes"
55
"fmt"
6+
"net/url"
67
"regexp"
78
"strings"
89
"text/template"
@@ -195,6 +196,123 @@ function getNotifyEntityArrivalSink<T>(notifyCallback: NotifyStreamEntityArrival
195196
}
196197
})
197198
}
199+
200+
type Primitive = string | boolean | number;
201+
type RequestPayload = Record<string, unknown>;
202+
type FlattenedRequestPayload = Record<string, Primitive | Array<Primitive>>;
203+
204+
/**
205+
* Checks if given value is a plain object
206+
* Logic copied and adapted from below source:
207+
* https://github.com/char0n/ramda-adjunct/blob/master/src/isPlainObj.js
208+
* @param {unknown} value
209+
* @return {boolean}
210+
*/
211+
function isPlainObject(value: unknown): boolean {
212+
const isObject =
213+
Object.prototype.toString.call(value).slice(8, -1) === "Object";
214+
const isObjLike = value !== null && isObject;
215+
216+
if (!isObjLike || !isObject) {
217+
return false;
218+
}
219+
220+
const proto = Object.getPrototypeOf(value);
221+
222+
const hasObjectConstructor =
223+
typeof proto === "object" &&
224+
proto.constructor === Object.prototype.constructor;
225+
226+
return hasObjectConstructor;
227+
}
228+
229+
/**
230+
* Checks if given value is of a primitive type
231+
* @param {unknown} value
232+
* @return {boolean}
233+
*/
234+
function isPrimitive(value: unknown): boolean {
235+
return ["string", "number", "boolean"].some(t => typeof value === t);
236+
}
237+
238+
/**
239+
* Checks if given primitive is zero-value
240+
* @param {Primitive} value
241+
* @return {boolean}
242+
*/
243+
function isZeroValuePrimitive(value: Primitive): boolean {
244+
return value === false || value === 0 || value === "";
245+
}
246+
247+
/**
248+
* Flattens a deeply nested request payload and returns an object
249+
* with only primitive values and non-empty array of primitive values
250+
* as per https://github.com/googleapis/googleapis/blob/master/google/api/http.proto
251+
* @param {RequestPayload} requestPayload
252+
* @param {String} path
253+
* @return {FlattenedRequestPayload>}
254+
*/
255+
function flattenRequestPayload<T extends RequestPayload>(
256+
requestPayload: T,
257+
path: string = ""
258+
): FlattenedRequestPayload {
259+
return Object.keys(requestPayload).reduce(
260+
(acc: T, key: string): T => {
261+
const value = requestPayload[key];
262+
const newPath = path ? [path, key].join(".") : key;
263+
264+
const isNonEmptyPrimitiveArray =
265+
Array.isArray(value) &&
266+
value.every(v => isPrimitive(v)) &&
267+
value.length > 0;
268+
269+
const isNonZeroValuePrimitive =
270+
isPrimitive(value) && !isZeroValuePrimitive(value as Primitive);
271+
272+
let objectToMerge = {};
273+
274+
if (isPlainObject(value)) {
275+
objectToMerge = flattenRequestPayload(value as RequestPayload, newPath);
276+
} else if (isNonZeroValuePrimitive || isNonEmptyPrimitiveArray) {
277+
objectToMerge = { [newPath]: value };
278+
}
279+
280+
return { ...acc, ...objectToMerge };
281+
},
282+
{} as T
283+
) as FlattenedRequestPayload;
284+
}
285+
286+
/**
287+
* Renders a deeply nested request payload into a string of URL search
288+
* parameters by first flattening the request payload and then removing keys
289+
* which are already present in the URL path.
290+
* @param {RequestPayload} requestPayload
291+
* @param {string[]} urlPathParams
292+
* @return {string}
293+
*/
294+
export function renderURLSearchParams<T extends RequestPayload>(
295+
requestPayload: T,
296+
urlPathParams: string[] = []
297+
): string {
298+
const flattenedRequestPayload = flattenRequestPayload(requestPayload);
299+
300+
const urlSearchParams = Object.keys(flattenedRequestPayload).reduce(
301+
(acc: string[][], key: string): string[][] => {
302+
// key should not be present in the url path as a parameter
303+
const value = flattenedRequestPayload[key];
304+
if (urlPathParams.find(f => f === key)) {
305+
return acc;
306+
}
307+
return Array.isArray(value)
308+
? [...acc, ...value.map(m => [key, m.toString()])]
309+
: (acc = [...acc, [key, value.toString()]]);
310+
},
311+
[] as string[][]
312+
);
313+
314+
return new URLSearchParams(urlSearchParams).toString();
315+
}
198316
`
199317

200318
// GetTemplate gets the templates to for the typescript file
@@ -229,20 +347,39 @@ func fieldName(r *registry.Registry) func(name string) string {
229347
func renderURL(r *registry.Registry) func(method data.Method) string {
230348
fieldNameFn := fieldName(r)
231349
return func(method data.Method) string {
232-
url := method.URL
350+
methodURL := method.URL
233351
reg := regexp.MustCompile("{([^}]+)}")
234-
matches := reg.FindAllStringSubmatch(url, -1)
352+
matches := reg.FindAllStringSubmatch(methodURL, -1)
353+
fieldsInPath := make([]string, 0, len(matches))
235354
if len(matches) > 0 {
236355
log.Debugf("url matches %v", matches)
237356
for _, m := range matches {
238357
expToReplace := m[0]
239358
fieldName := fieldNameFn(m[1])
240359
part := fmt.Sprintf(`${req["%s"]}`, fieldName)
241-
url = strings.ReplaceAll(url, expToReplace, part)
360+
methodURL = strings.ReplaceAll(methodURL, expToReplace, part)
361+
fieldsInPath = append(fieldsInPath, fmt.Sprintf(`"%s"`, fieldName))
362+
}
363+
}
364+
urlPathParams := fmt.Sprintf("[%s]", strings.Join(fieldsInPath, ", "))
365+
366+
if !method.ClientStreaming && method.HTTPMethod == "GET" {
367+
// parse the url to check for query string
368+
parsedURL, err := url.Parse(methodURL)
369+
if err != nil {
370+
return methodURL
371+
}
372+
renderURLSearchParamsFn := fmt.Sprintf("${fm.renderURLSearchParams(req, %s)}", urlPathParams)
373+
// prepend "&" if query string is present otherwise prepend "?"
374+
// trim leading "&" if present before prepending it
375+
if parsedURL.RawQuery != "" {
376+
methodURL = strings.TrimRight(methodURL, "&") + "&" + renderURLSearchParamsFn
377+
} else {
378+
methodURL += "?" + renderURLSearchParamsFn
242379
}
243380
}
244381

245-
return url
382+
return methodURL
246383
}
247384
}
248385

integration_tests/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Integration test
22

33
The integration test first runs `./scripts/gen-protos.sh` again to generate Typescript file for the proto `service.proto`.
4+
45
Then it starts `main.go` server that loads up the protos and run tests via `Karma` to verify if the generated client works properly.
6+
57
The JS integration test file is `integration_test.ts`.
68

79
Changes on the server side needs to run `./scripts/gen-server-proto.sh` to update the protos and the implementation is in `service.go`.

integration_tests/integration_test.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,47 +14,55 @@ function getField(obj: {[key: string]: any}, name: string) {
1414

1515
describe("test grpc-gateway-ts communication", () => {
1616
it("unary request", async () => {
17-
const result = await CounterService.Increment({counter: 199}, {pathPrefix: "http://localhost:8081"})
17+
const result = await CounterService.Increment({ counter: 199 }, { pathPrefix: "http://localhost:8081" })
1818

1919
expect(result.result).to.equal(200)
2020
})
2121

2222
it('streaming request', async () => {
2323
const response = [] as number[]
24-
await CounterService.StreamingIncrements({counter: 1}, (resp) => response.push(resp.result), {pathPrefix: "http://localhost:8081"})
24+
await CounterService.StreamingIncrements({ counter: 1 }, (resp) => response.push(resp.result), { pathPrefix: "http://localhost:8081" })
2525

26-
expect(response).to.deep.equal([2,3,4,5,6])
26+
expect(response).to.deep.equal([2, 3, 4, 5, 6])
2727
})
2828

2929
it('http get check request', async () => {
30-
const result = await CounterService.HTTPGet({[getFieldName('num_to_increase')]: 10}, {pathPrefix: "http://localhost:8081"})
30+
const result = await CounterService.HTTPGet({ [getFieldName('num_to_increase')]: 10 }, { pathPrefix: "http://localhost:8081" })
3131
expect(result.result).to.equal(11)
3232
})
3333

3434
it('http post body check request with nested body path', async () => {
35-
const result = await CounterService.HTTPPostWithNestedBodyPath({a: 10, req: { b: 15 }}, {pathPrefix: "http://localhost:8081"})
35+
const result = await CounterService.HTTPPostWithNestedBodyPath({ a: 10, req: { b: 15 } }, { pathPrefix: "http://localhost:8081" })
3636
expect(getField(result, 'post_result')).to.equal(25)
3737
})
3838

39-
4039
it('http post body check request with star in path', async () => {
41-
const result = await CounterService.HTTPPostWithStarBodyPath({a: 10, req: { b: 15 }, c: 23}, {pathPrefix: "http://localhost:8081"})
40+
const result = await CounterService.HTTPPostWithStarBodyPath({ a: 10, req: { b: 15 }, c: 23 }, { pathPrefix: "http://localhost:8081" })
4241
expect(getField(result, 'post_result')).to.equal(48)
4342
})
4443

4544
it('able to communicate with external message reference without package defined', async () => {
46-
const result = await CounterService.ExternalMessage({ content: "hello" }, {pathPrefix: "http://localhost:8081"})
45+
const result = await CounterService.ExternalMessage({ content: "hello" }, { pathPrefix: "http://localhost:8081" })
4746
expect(getField(result, 'result')).to.equal("hello!!")
4847
})
4948

5049
it('http patch request with star in path', async () => {
51-
const result = await CounterService.HTTPPatch({a: 10, c: 23}, {pathPrefix: "http://localhost:8081"})
50+
const result = await CounterService.HTTPPatch({ a: 10, c: 23 }, { pathPrefix: "http://localhost:8081" })
5251
expect(getField(result, 'patch_result')).to.equal(33)
5352
})
5453

5554
it('http delete check request', async () => {
56-
const result = await CounterService.HTTPDelete({a: 10}, {pathPrefix: "http://localhost:8081"})
55+
const result = await CounterService.HTTPDelete({ a: 10 }, { pathPrefix: "http://localhost:8081" })
5756
expect(result).to.be.empty
5857
})
58+
59+
it('http get request with url search parameters', async () => {
60+
const result = await CounterService.HTTPGetWithURLSearchParams({ a: 10, [getFieldName('post_req')]: { b: 0 }, c: [23, 25], [getFieldName('ext_msg')]: { d: 12 } }, { pathPrefix: "http://localhost:8081" })
61+
expect(getField(result, 'url_search_params_result')).to.equal(70)
62+
})
5963

64+
it('http get request with zero value url search parameters', async () => {
65+
const result = await CounterService.HTTPGetWithZeroValueURLSearchParams({ a: "A", b: "", [getFieldName('zero_value_msg')]: { c: 1, d: [1, 0, 2], e: false } }, { pathPrefix: "http://localhost:8081" })
66+
expect(result).to.deep.equal({ a: "A", b: "hello", [getFieldName('zero_value_msg')]: { c: 2, d: [2, 1, 3], e: true } })
67+
})
6068
})

0 commit comments

Comments
 (0)