Skip to content

Commit

Permalink
Merge pull request #11 from atreya2011/support-url-query-params
Browse files Browse the repository at this point in the history
Handle rendering of URL Query Parameters in renderURL
  • Loading branch information
lyonlai authored Apr 28, 2021
2 parents 4dae0ad + 2c992f8 commit 142798a
Show file tree
Hide file tree
Showing 11 changed files with 1,149 additions and 138 deletions.
41 changes: 23 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,56 @@
# protoc-gen-grpc-gateway-ts

`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.

`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.

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

## Get Started
## Getting Started:

### Install `protoc-gen-grpc-gateway-ts`
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 .`
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`

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

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

As a result the generated file will be `input.pb.ts` in the same directory.
As a result the generated file will be `input.pb.ts` in the same directory.

## Parameters:
### `ts_import_roots`
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)`
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)`

### `ts_import_root_aliases`
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 "".
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 "".

`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.

### `fetch_module_directory` and `fetch_module_filename`
`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`
`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`

### `use_proto_names`
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.

### `logtostderr`
Turn Ton logging to stderr. Default to false.
Turn on logging to stderr. Default to false.

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

### Notes:
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:
- <https://developers.google.com/protocol-buffers/docs/proto3#default>
- <https://github.com/googleapis/googleapis/blob/master/google/api/http.proto>

## Examples:
The following shows how to use the generated typescript code.
The following shows how to use the generated TypeScript code.

Proto file: `counter.proto`

```proto
// file: counter.proto
message Request {
Expand All @@ -62,7 +67,7 @@ service CounterService {
}
```

Run the following command to generate the Typescript client:
Run the following command to generate the TypeScript client:

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

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

```

##License
## License

```text
Copyright 2020 Square, Inc.
Expand All @@ -106,4 +112,3 @@ 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.
```

145 changes: 141 additions & 4 deletions generator/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package generator
import (
"bytes"
"fmt"
"net/url"
"regexp"
"strings"
"text/template"
Expand Down Expand Up @@ -195,6 +196,123 @@ function getNotifyEntityArrivalSink<T>(notifyCallback: NotifyStreamEntityArrival
}
})
}
type Primitive = string | boolean | number;
type RequestPayload = Record<string, unknown>;
type FlattenedRequestPayload = Record<string, Primitive | Array<Primitive>>;
/**
* Checks if given value is a plain object
* Logic copied and adapted from below source:
* https://github.com/char0n/ramda-adjunct/blob/master/src/isPlainObj.js
* @param {unknown} value
* @return {boolean}
*/
function isPlainObject(value: unknown): boolean {
const isObject =
Object.prototype.toString.call(value).slice(8, -1) === "Object";
const isObjLike = value !== null && isObject;
if (!isObjLike || !isObject) {
return false;
}
const proto = Object.getPrototypeOf(value);
const hasObjectConstructor =
typeof proto === "object" &&
proto.constructor === Object.prototype.constructor;
return hasObjectConstructor;
}
/**
* Checks if given value is of a primitive type
* @param {unknown} value
* @return {boolean}
*/
function isPrimitive(value: unknown): boolean {
return ["string", "number", "boolean"].some(t => typeof value === t);
}
/**
* Checks if given primitive is zero-value
* @param {Primitive} value
* @return {boolean}
*/
function isZeroValuePrimitive(value: Primitive): boolean {
return value === false || value === 0 || value === "";
}
/**
* Flattens a deeply nested request payload and returns an object
* with only primitive values and non-empty array of primitive values
* as per https://github.com/googleapis/googleapis/blob/master/google/api/http.proto
* @param {RequestPayload} requestPayload
* @param {String} path
* @return {FlattenedRequestPayload>}
*/
function flattenRequestPayload<T extends RequestPayload>(
requestPayload: T,
path: string = ""
): FlattenedRequestPayload {
return Object.keys(requestPayload).reduce(
(acc: T, key: string): T => {
const value = requestPayload[key];
const newPath = path ? [path, key].join(".") : key;
const isNonEmptyPrimitiveArray =
Array.isArray(value) &&
value.every(v => isPrimitive(v)) &&
value.length > 0;
const isNonZeroValuePrimitive =
isPrimitive(value) && !isZeroValuePrimitive(value as Primitive);
let objectToMerge = {};
if (isPlainObject(value)) {
objectToMerge = flattenRequestPayload(value as RequestPayload, newPath);
} else if (isNonZeroValuePrimitive || isNonEmptyPrimitiveArray) {
objectToMerge = { [newPath]: value };
}
return { ...acc, ...objectToMerge };
},
{} as T
) as FlattenedRequestPayload;
}
/**
* Renders a deeply nested request payload into a string of URL search
* parameters by first flattening the request payload and then removing keys
* which are already present in the URL path.
* @param {RequestPayload} requestPayload
* @param {string[]} urlPathParams
* @return {string}
*/
export function renderURLSearchParams<T extends RequestPayload>(
requestPayload: T,
urlPathParams: string[] = []
): string {
const flattenedRequestPayload = flattenRequestPayload(requestPayload);
const urlSearchParams = Object.keys(flattenedRequestPayload).reduce(
(acc: string[][], key: string): string[][] => {
// key should not be present in the url path as a parameter
const value = flattenedRequestPayload[key];
if (urlPathParams.find(f => f === key)) {
return acc;
}
return Array.isArray(value)
? [...acc, ...value.map(m => [key, m.toString()])]
: (acc = [...acc, [key, value.toString()]]);
},
[] as string[][]
);
return new URLSearchParams(urlSearchParams).toString();
}
`

// GetTemplate gets the templates to for the typescript file
Expand Down Expand Up @@ -229,20 +347,39 @@ func fieldName(r *registry.Registry) func(name string) string {
func renderURL(r *registry.Registry) func(method data.Method) string {
fieldNameFn := fieldName(r)
return func(method data.Method) string {
url := method.URL
methodURL := method.URL
reg := regexp.MustCompile("{([^}]+)}")
matches := reg.FindAllStringSubmatch(url, -1)
matches := reg.FindAllStringSubmatch(methodURL, -1)
fieldsInPath := make([]string, 0, len(matches))
if len(matches) > 0 {
log.Debugf("url matches %v", matches)
for _, m := range matches {
expToReplace := m[0]
fieldName := fieldNameFn(m[1])
part := fmt.Sprintf(`${req["%s"]}`, fieldName)
url = strings.ReplaceAll(url, expToReplace, part)
methodURL = strings.ReplaceAll(methodURL, expToReplace, part)
fieldsInPath = append(fieldsInPath, fmt.Sprintf(`"%s"`, fieldName))
}
}
urlPathParams := fmt.Sprintf("[%s]", strings.Join(fieldsInPath, ", "))

if !method.ClientStreaming && method.HTTPMethod == "GET" {
// parse the url to check for query string
parsedURL, err := url.Parse(methodURL)
if err != nil {
return methodURL
}
renderURLSearchParamsFn := fmt.Sprintf("${fm.renderURLSearchParams(req, %s)}", urlPathParams)
// prepend "&" if query string is present otherwise prepend "?"
// trim leading "&" if present before prepending it
if parsedURL.RawQuery != "" {
methodURL = strings.TrimRight(methodURL, "&") + "&" + renderURLSearchParamsFn
} else {
methodURL += "?" + renderURLSearchParamsFn
}
}

return url
return methodURL
}
}

Expand Down
2 changes: 2 additions & 0 deletions integration_tests/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Integration test

The integration test first runs `./scripts/gen-protos.sh` again to generate Typescript file for the proto `service.proto`.

Then it starts `main.go` server that loads up the protos and run tests via `Karma` to verify if the generated client works properly.

The JS integration test file is `integration_test.ts`.

Changes on the server side needs to run `./scripts/gen-server-proto.sh` to update the protos and the implementation is in `service.go`.
Expand Down
28 changes: 18 additions & 10 deletions integration_tests/integration_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,47 +14,55 @@ function getField(obj: {[key: string]: any}, name: string) {

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

expect(result.result).to.equal(200)
})

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

expect(response).to.deep.equal([2,3,4,5,6])
expect(response).to.deep.equal([2, 3, 4, 5, 6])
})

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

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


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

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

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

it('http delete check request', async () => {
const result = await CounterService.HTTPDelete({a: 10}, {pathPrefix: "http://localhost:8081"})
const result = await CounterService.HTTPDelete({ a: 10 }, { pathPrefix: "http://localhost:8081" })
expect(result).to.be.empty
})

it('http get request with url search parameters', async () => {
const result = await CounterService.HTTPGetWithURLSearchParams({ a: 10, [getFieldName('post_req')]: { b: 0 }, c: [23, 25], [getFieldName('ext_msg')]: { d: 12 } }, { pathPrefix: "http://localhost:8081" })
expect(getField(result, 'url_search_params_result')).to.equal(70)
})

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

0 comments on commit 142798a

Please sign in to comment.