├── .gitignore ├── run ├── tools ├── gen_collection_example.sh ├── README.md ├── helloworld.proto ├── helloworld_collection.json └── postman.tmpl ├── Dockerfile ├── .github ├── dependabot.yml └── workflows │ ├── go.yml │ └── build.yml ├── go.mod ├── codec ├── codec.go └── LICENSE ├── gogoprotobuf └── codec │ └── codec.go ├── LICENSE ├── grpcresponse.go ├── grpcrequest.go ├── main.go ├── README.md ├── proxy.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /pm-grpc 2 | /grpc-json-proxy 3 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | go build -v -o grpc-json-proxy 6 | 7 | ./grpc-json-proxy 8 | -------------------------------------------------------------------------------- /tools/gen_collection_example.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | POSTMAN_COLLECTION_NAME="Hello World gRPC JSON API" protoc \ 4 | --doc_out=./ \ 5 | --doc_opt=./postman.tmpl,helloworld_collection.json \ 6 | helloworld.proto 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Builder 2 | 3 | FROM golang:1.17 AS builder 4 | 5 | WORKDIR /data 6 | COPY . . 7 | 8 | RUN CGO_ENABLED=0 go build -v 9 | 10 | # Runtime 11 | 12 | FROM scratch 13 | 14 | COPY --from=builder /data/grpc-json-proxy / 15 | 16 | EXPOSE 7001 17 | 18 | CMD ["/grpc-json-proxy"] 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jnewmano/grpc-json-proxy 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/gogo/protobuf v1.3.2 7 | github.com/golang/protobuf v1.5.3 8 | golang.org/x/net v0.24.0 9 | google.golang.org/grpc v1.59.0 10 | ) 11 | 12 | require ( 13 | golang.org/x/text v0.14.0 // indirect 14 | google.golang.org/protobuf v1.33.0 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ main, master] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | goversion: 16 | - '1.20' 17 | - '1.21' 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: '${{ matrix.goversion }}' 26 | 27 | - name: Build 28 | run: go build -v ./... 29 | 30 | - name: Test 31 | run: go test -v ./... 32 | 33 | - name: Vet 34 | run: go vet ./... 35 | -------------------------------------------------------------------------------- /tools/README.md: -------------------------------------------------------------------------------- 1 | ## postman.tmpl 2 | 3 | This method of Postman Collection generation relies on the documentation generator for `protoc`: https://github.com/pseudomuto/protoc-gen-doc 4 | 5 | Install with: 6 | ``` 7 | go get -u github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc 8 | ``` 9 | 10 | Download the `postman.tmpl` file locally. 11 | 12 | Building the collection for `helloworld.proto`: 13 | 14 | ```bash 15 | POSTMAN_COLLECTION_NAME="Hello World gRPC JSON API" protoc \ 16 | --doc_out=./ \ 17 | --doc_opt=./postman.tmpl,helloworld_collection.json \ 18 | helloworld.proto 19 | ``` 20 | 21 | Try importing one of these example collections into Postman. View the documentation and requests to see all populated fields. 22 | 23 | Hello World Collection: https://gist.githubusercontent.com/kevinswiber/867699e4efe57ccde83fb510c9de6075/raw/b082f5505b22112d5f989dae2765a065de2e53fd/zz_helloworld_collection.json 24 | 25 | Route Guide Collection: https://gist.githubusercontent.com/kevinswiber/867699e4efe57ccde83fb510c9de6075/raw/b082f5505b22112d5f989dae2765a065de2e53fd/zz_route_guide.proto 26 | -------------------------------------------------------------------------------- /codec/codec.go: -------------------------------------------------------------------------------- 1 | package codec 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | 7 | "github.com/golang/protobuf/jsonpb" 8 | "github.com/golang/protobuf/proto" 9 | "google.golang.org/grpc/encoding" 10 | ) 11 | 12 | func init() { 13 | encoding.RegisterCodec(JSON{ 14 | Marshaler: jsonpb.Marshaler{ 15 | EmitDefaults: true, 16 | OrigName: true, 17 | }, 18 | }) 19 | } 20 | 21 | type JSON struct { 22 | jsonpb.Marshaler 23 | jsonpb.Unmarshaler 24 | } 25 | 26 | func (_ JSON) Name() string { 27 | return "json" 28 | } 29 | 30 | func (j JSON) Marshal(v interface{}) (out []byte, err error) { 31 | if pm, ok := v.(proto.Message); ok { 32 | b := new(bytes.Buffer) 33 | err := j.Marshaler.Marshal(b, pm) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return b.Bytes(), nil 38 | } 39 | return json.Marshal(v) 40 | } 41 | 42 | func (j JSON) Unmarshal(data []byte, v interface{}) (err error) { 43 | if pm, ok := v.(proto.Message); ok { 44 | b := bytes.NewBuffer(data) 45 | return j.Unmarshaler.Unmarshal(b, pm) 46 | } 47 | return json.Unmarshal(data, v) 48 | } 49 | -------------------------------------------------------------------------------- /gogoprotobuf/codec/codec.go: -------------------------------------------------------------------------------- 1 | package codec 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | 7 | "github.com/gogo/protobuf/jsonpb" 8 | "github.com/gogo/protobuf/proto" 9 | "google.golang.org/grpc/encoding" 10 | ) 11 | 12 | func init() { 13 | encoding.RegisterCodec(JSON{ 14 | Marshaler: jsonpb.Marshaler{ 15 | EmitDefaults: true, 16 | OrigName: true, 17 | }, 18 | }) 19 | } 20 | 21 | type JSON struct { 22 | jsonpb.Marshaler 23 | jsonpb.Unmarshaler 24 | } 25 | 26 | func (_ JSON) Name() string { 27 | return "json" 28 | } 29 | 30 | func (j JSON) Marshal(v interface{}) (out []byte, err error) { 31 | if pm, ok := v.(proto.Message); ok { 32 | b := new(bytes.Buffer) 33 | err := j.Marshaler.Marshal(b, pm) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return b.Bytes(), nil 38 | } 39 | return json.Marshal(v) 40 | } 41 | 42 | func (j JSON) Unmarshal(data []byte, v interface{}) (err error) { 43 | if pm, ok := v.(proto.Message); ok { 44 | b := bytes.NewBuffer(data) 45 | return j.Unmarshaler.Unmarshal(b, pm) 46 | } 47 | return json.Unmarshal(data, v) 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jason Newman 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 | -------------------------------------------------------------------------------- /codec/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Johan Brandhorst 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 | -------------------------------------------------------------------------------- /tools/helloworld.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2015 gRPC authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | option java_multiple_files = true; 18 | option java_package = "io.grpc.examples.helloworld"; 19 | option java_outer_classname = "HelloWorldProto"; 20 | 21 | package helloworld; 22 | 23 | // The greeting service definition. 24 | service Greeter { 25 | // Sends a "greeting" 26 | rpc SayHello (HelloRequest) returns (HelloReply) {} 27 | } 28 | 29 | // The request message containing the user's name. 30 | message HelloRequest { 31 | string name = 1; 32 | } 33 | 34 | // The response message containing the greetings 35 | message HelloReply { 36 | string message = 1; 37 | } 38 | -------------------------------------------------------------------------------- /grpcresponse.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | func handleGRPCResponse(resp *http.Response) (*http.Response, error) { 11 | 12 | // After Body.Read has returned io.EOF, Trailer will contain 13 | // any trailer values sent by the server. 14 | body := bytes.NewBuffer(nil) 15 | _, err := io.Copy(body, resp.Body) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | code := metadata(resp, headerGRPCStatusCode) 21 | if code != "0" && code != "" { 22 | r := struct { 23 | Error string `json:"error"` 24 | Code string `json:"code"` 25 | }{ 26 | Error: metadata(resp, headerGRPCMessage), 27 | Code: code, 28 | } 29 | 30 | buff := bytes.NewBuffer(nil) 31 | _ = json.NewEncoder(buff).Encode(r) 32 | 33 | resp.StatusCode = 500 34 | resp.Body = io.NopCloser(buff) 35 | 36 | return resp, nil 37 | } 38 | 39 | // drop the first 5 bytes of the response body 40 | prefix := make([]byte, 5) 41 | _, _ = body.Read(prefix) 42 | resp.Body = io.NopCloser(body) 43 | 44 | resp.Header.Del(headerContentLength) 45 | 46 | return resp, nil 47 | 48 | } 49 | 50 | func metadata(resp *http.Response, field string) string { 51 | v := resp.Header.Get(field) 52 | if v != "" { 53 | return v 54 | } 55 | return resp.Trailer.Get(field) 56 | } 57 | -------------------------------------------------------------------------------- /grpcrequest.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "io/ioutil" 7 | "net/http" 8 | ) 9 | 10 | const ( 11 | // header to detect if it is a grpc+json request 12 | contentTypeGRPCJSON = "application/grpc+json" 13 | 14 | grpcNoCompression byte = 0x00 15 | ) 16 | 17 | func modifyRequestToJSONgRPC(r *http.Request) *http.Request { 18 | // https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md 19 | 20 | var body []byte 21 | // read body so we can add the grpc prefix 22 | if r.Body != nil { 23 | body, _ = ioutil.ReadAll(r.Body) 24 | } 25 | 26 | b := make([]byte, 0, len(body)+5) 27 | buff := bytes.NewBuffer(b) 28 | 29 | // grpc prefix is 30 | // 1 byte: compression indicator 31 | // 4 bytes: content length (excluding prefix) 32 | _ = buff.WriteByte(grpcNoCompression) // 0 or 1, indicates compressed payload 33 | 34 | lenBytes := make([]byte, 4) 35 | binary.BigEndian.PutUint32(lenBytes, uint32(len(body))) 36 | 37 | _, _ = buff.Write(lenBytes) 38 | _, _ = buff.Write(body) 39 | 40 | // create new request 41 | req, _ := http.NewRequest(r.Method, r.URL.String(), buff) 42 | req.Header = r.Header 43 | 44 | // remove content length header 45 | req.Header.Del(headerContentLength) 46 | 47 | return req 48 | 49 | } 50 | 51 | func isJSONGRPC(r *http.Request) bool { 52 | 53 | h := r.Header.Get("Content-Type") 54 | 55 | if h == contentTypeGRPCJSON { 56 | return true 57 | } 58 | 59 | return false 60 | } 61 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Image 2 | on: 3 | push: 4 | branches: [ main ] 5 | tags: [ '*' ] 6 | 7 | jobs: 8 | register: 9 | name: Package, Publish, and Register 10 | runs-on: 11 | - ubuntu-latest 12 | steps: 13 | - id: checkout 14 | uses: actions/checkout@v4 15 | - name: Set tag var 16 | id: tag 17 | shell: bash 18 | run: | 19 | #!/usr/bin/env bash 20 | set -euo pipefail 21 | 22 | TYPE=$(echo ${GITHUB_REF} | cut -d'/' -f2) 23 | REF=$(echo ${GITHUB_REF} | cut -d'/' -f3) 24 | SHA7=$(echo ${GITHUB_SHA} | cut -c1-7) 25 | 26 | TAG=$REF-${SHA7} 27 | if [ "${TYPE}" == "main" ] && [ "${TYPE}" == "tags" ]; then 28 | TAG=$REF 29 | fi 30 | 31 | echo ::set-output name=tag::$TAG 32 | 33 | - uses: docker/login-action@v3 34 | with: 35 | registry: docker.io 36 | username: ${{ secrets.DOCKER_USERNAME }} 37 | password: ${{ secrets.DOCKER_TOKEN }} 38 | - id: setup-pack 39 | uses: buildpacks/github-actions/setup-pack@v5.6.0 40 | - id: package 41 | run: | 42 | #!/usr/bin/env bash 43 | set -euo pipefail 44 | 45 | NAME=$(echo ${GITHUB_REPOSITORY} | cut -d'/' -f2) 46 | PACKAGE="${REPO}/${NAME}" 47 | TAG=${{ steps.tag.outputs.tag }} 48 | 49 | pack build --builder paketobuildpacks/builder:tiny --publish ${PACKAGE}:${TAG} 50 | 51 | shell: bash 52 | env: 53 | REPO: docker.io/${{ secrets.DOCKER_USERNAME }} 54 | -------------------------------------------------------------------------------- /tools/helloworld_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "Hello World gRPC JSON API", 4 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 5 | "description": "## Messages\n\n#### helloworld.HelloReply\n\nThe response message containing the greetings\n\n| Field | Type | Description |\n| ----- | ---- | ----------- |\n| message | string | |\n\n#### helloworld.HelloRequest\n\nThe request message containing the user's name.\n\n| Field | Type | Description |\n| ----- | ---- | ----------- |\n| name | string | |\n\n" 6 | }, 7 | "item": [ 8 | { 9 | "name": "helloworld.proto", 10 | "description": "", 11 | "item": [ 12 | { 13 | "name": "helloworld.Greeter", 14 | "description": "The greeting service definition.", 15 | "items": [ 16 | { 17 | "name": "SayHello", 18 | "request": { 19 | "description": "Sends a \"greeting\"\n\n\n\nRequest Message: `helloworld.HelloRequest`\n\nResponse Message: `helloworld.HelloReply`\n", 20 | "method": "POST", 21 | "header": [ 22 | { 23 | "key": "Content-Type", 24 | "value": "application/grpc+json" 25 | }, 26 | { 27 | "key": "Grpc-Insecure", 28 | "value": "{{grpcInsecure}}" 29 | } 30 | ], 31 | "url": { 32 | "raw": "{{baseURL}}/helloworld.Greeter/SayHello", 33 | "host": [ 34 | "{{baseURL}}" 35 | ], 36 | "path": [ 37 | "helloworld.Greeter", 38 | "SayHello" 39 | ] 40 | }, 41 | "body": { 42 | "mode": "raw", 43 | "raw": "{\n \"name\": \"\"\n}" 44 | } 45 | } 46 | } 47 | ] 48 | } 49 | ] 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | "net/http" 8 | "net/http/httputil" 9 | "os" 10 | "os/signal" 11 | "sync" 12 | "syscall" 13 | "time" 14 | ) 15 | 16 | var ( 17 | shutdownTimeout = time.Second * 3 18 | defaultIdleTimeout = time.Second * 60 19 | ) 20 | 21 | func main() { 22 | addr := flag.String("http-addr", "127.0.0.1:7001", "listen addr for HTTP server") 23 | 24 | flag.Parse() 25 | 26 | ctx := context.Background() 27 | 28 | proxyListenAddr := *addr 29 | 30 | p := NewProxy() 31 | 32 | wg := sync.WaitGroup{} 33 | wg.Add(1) 34 | 35 | ctx, s, err := StartProxy(ctx, proxyListenAddr, p) 36 | if err != nil { 37 | log.Println("unable to start proxy", err) 38 | os.Exit(1) 39 | } 40 | 41 | wait(ctx, s) 42 | } 43 | 44 | // StartProxy starts a httputil.ReverseProxy listening on addr 45 | func StartProxy(ctx context.Context, addr string, p *httputil.ReverseProxy) (context.Context, stopFunction, error) { 46 | 47 | idleTimeout := defaultIdleTimeout 48 | 49 | s := &http.Server{ 50 | Addr: addr, 51 | Handler: p, 52 | IdleTimeout: idleTimeout, 53 | } 54 | 55 | go func() { 56 | var done func() 57 | ctx, done = context.WithCancel(ctx) 58 | log.Println("starting HTTP server", addr) 59 | err := s.ListenAndServe() 60 | if err != nil { 61 | log.Println("listen and serve ended with error", err) 62 | } 63 | done() 64 | }() 65 | 66 | sf := func(ctx context.Context) error { 67 | log.Println("shutting down server") 68 | return s.Close() 69 | } 70 | 71 | return ctx, sf, nil 72 | } 73 | 74 | type stopFunction func(ctx context.Context) error 75 | 76 | func wait(ctx context.Context, stoppers ...stopFunction) { 77 | 78 | c := make(chan os.Signal, 1) 79 | signal.Notify(c, os.Interrupt, syscall.SIGTERM) 80 | 81 | select { 82 | case <-ctx.Done(): 83 | case <-c: 84 | } 85 | 86 | // call stop functions in reverse order 87 | for i := len(stoppers) - 1; i >= 0; i-- { 88 | s := stoppers[i] 89 | if s == nil { 90 | continue 91 | } 92 | 93 | ctx, done := context.WithTimeout(ctx, shutdownTimeout) 94 | 95 | err := s(ctx) 96 | if err != nil { 97 | log.Println("did not exit", err) 98 | } 99 | 100 | done() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # grpc-json-proxy 2 | 3 | GRPC JSON is a proxy which allows HTTP API tools like Postman to interact with gRPC servers. 4 | 5 | ## Requirements 6 | - grpc+json codec must be enabled on the grpc server 7 | - Postman must be configured to use the proxy 8 | 9 | Configuration of the proxy and its dependencies is a three step process. 10 | 11 | 1. Register a JSON codec with the gRPC server. In Go, it can be automatically registered simple by adding the following import: 12 | 13 | `import _"github.com/jnewmano/grpc-json-proxy/codec"` 14 | 15 | If you're using `gogo/protobuf` as your protobuf backend, import the following: 16 | 17 | `import _"github.com/jnewmano/grpc-json-proxy/gogoprotobuf/codec"` 18 | 19 | 2. Run the grpc-json-proxy. Download pre-built binaries from https://github.com/jnewmano/grpc-json-proxy/releases/ or build from source: 20 | 21 | `go get -u github.com/jnewmano/grpc-json-proxy` 22 | 23 | `grpc-json-proxy` 24 | 25 | Other way, you can simply use `grpc-json-proxy` docker image out of the box: 26 | 27 | ```bash 28 | docker run -p 7001:7001 jnewmano/grpc-json-proxy 29 | ``` 30 | 31 | 3. Configure Postman to send requests through the proxy. 32 | Postman -> Preferences -> Proxy -> Global Proxy 33 | 34 | `Proxy Server: localhost 7001` 35 | 36 | 37 | ![Postman Proxy Configuration](https://cdn-images-1.medium.com/max/1600/1*oc09cwpCC9XrjpU9Gl5YTw.png) 38 | 39 | Setup your Postman gRPC request with the following: 40 | 41 | 1. Set request method to Post . 42 | 1. Set the URL to http://{{ grpc server address}}/{{proto package}}.{{proto service}}/{{method}} Always use http, proxy will establish a secure connection to the gRPC server. 43 | 1. Set the Content-Type header to application/grpc+json . 44 | 1. Optionally add a Grpc-Insecure header set to true for an insecure connection. 45 | 1. Set the request body to appropriate JSON for the message. For reference, generated Go code includes JSON tags on the generated message structs. 46 | 47 | 48 | For example: 49 | 50 | ![Postman Example Request](https://cdn-images-1.medium.com/max/1600/1*npRlBiKxuJ5KMnnk0F5n6g.png) 51 | 52 | 53 | 54 | Inspired by Johan Brandhorst's [grpc-json](https://jbrandhorst.com/post/grpc-json/) 55 | 56 | ### Host accessibility 57 | 58 | If you use docker image to run grpc-json-proxy server, and want to access grpc via loopback address `127.0.0.1`, you should pay attention to docker network accessibility. 59 | 60 | 1. use `host.docker.internal` instead of `127.0.0.1` in Linux. 61 | 2. use `docker.for.mac.host.internal` instead of `127.0.0.1` in MacOS and with Docker for Mac 17.12 or above. 62 | 63 | See: https://docs.docker.com/docker-for-mac/networking/#use-cases-and-workarounds 64 | -------------------------------------------------------------------------------- /proxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "io/ioutil" 7 | "log" 8 | "net" 9 | "net/http" 10 | "net/http/httputil" 11 | "net/url" 12 | "time" 13 | 14 | "golang.org/x/net/http2" 15 | ) 16 | 17 | const ( 18 | headerContentLength = "Content-Length" 19 | headerGRPCMessage = "Grpc-Message" 20 | headerGRPCStatusCode = "Grpc-Status" 21 | headerUseInsecure = "Grpc-Insecure" 22 | 23 | defaultClientTimeout = time.Second * 60 24 | ) 25 | 26 | // Transport struct for intercepting grpc+json requests 27 | type Transport struct { 28 | HTTPClient *http.Client 29 | H2Client *http.Client 30 | H2NoTLSClient *http.Client 31 | } 32 | 33 | /* 34 | NewProxy returns a configured reverse proxy 35 | to handle grpc+json requests 36 | */ 37 | func NewProxy() *httputil.ReverseProxy { 38 | 39 | h2NoTLSClient := &http.Client{ 40 | // Skip TLS dial 41 | Transport: &http2.Transport{ 42 | AllowHTTP: true, 43 | DialTLS: func(netw, addr string, cfg *tls.Config) (net.Conn, error) { 44 | return net.Dial(netw, addr) 45 | }, 46 | }, 47 | Timeout: defaultClientTimeout, 48 | } 49 | 50 | h2Client := &http.Client{ 51 | Transport: &http2.Transport{}, 52 | Timeout: defaultClientTimeout, 53 | } 54 | 55 | client := &http.Client{ 56 | Timeout: defaultClientTimeout, 57 | } 58 | 59 | t := &Transport{ 60 | HTTPClient: client, 61 | H2Client: h2Client, 62 | H2NoTLSClient: h2NoTLSClient, 63 | } 64 | 65 | u := url.URL{} 66 | p := httputil.NewSingleHostReverseProxy(&u) 67 | p.Director = t.director 68 | p.Transport = t 69 | 70 | return p 71 | } 72 | 73 | func (t Transport) director(r *http.Request) { 74 | } 75 | 76 | /* 77 | RoundTrip handles processing the incoming request 78 | and outgoing response for grpc+json detection 79 | */ 80 | func (t Transport) RoundTrip(r *http.Request) (*http.Response, error) { 81 | 82 | isGRPC := false 83 | if isJSONGRPC(r) { 84 | if r.Method != http.MethodPost { 85 | buff := bytes.NewBufferString("HTTP method must be POST") 86 | resp := &http.Response{ 87 | StatusCode: 502, 88 | Body: ioutil.NopCloser(buff), 89 | } 90 | return resp, nil 91 | } 92 | isGRPC = true 93 | r = modifyRequestToJSONgRPC(r) 94 | } 95 | 96 | client := t.HTTPClient 97 | if isGRPC { 98 | if r.Header.Get(headerUseInsecure) != "" { 99 | client = t.H2NoTLSClient 100 | } else { 101 | client = t.H2Client 102 | } 103 | } 104 | 105 | // clear requestURI, set in call to director 106 | r.RequestURI = "" 107 | 108 | log.Printf("proxying request url=[%s] isJSONGRPC=[%t]\n", r.URL.String(), isGRPC) 109 | 110 | resp, err := client.Do(r) 111 | if err != nil { 112 | log.Printf("unable to do request err=[%s]", err) 113 | 114 | buff := bytes.NewBuffer(nil) 115 | buff.WriteString(err.Error()) 116 | resp = &http.Response{ 117 | StatusCode: 502, 118 | Body: ioutil.NopCloser(buff), 119 | } 120 | 121 | return resp, nil 122 | } 123 | 124 | if isGRPC { 125 | return handleGRPCResponse(resp) 126 | } 127 | 128 | return resp, err 129 | } 130 | -------------------------------------------------------------------------------- /tools/postman.tmpl: -------------------------------------------------------------------------------- 1 | {{- $messages := (index .Files 0).Messages -}} 2 | {{- if gt (len .Files) 1 -}} 3 | {{- range $file := (slice .Files 1) -}} 4 | {{- $messages = concat $messages $file.Messages -}} 5 | {{- end -}} 6 | {{- end -}} 7 | { 8 | "info": { 9 | "name": "{{coalesce (env "POSTMAN_COLLECTION_NAME") "Imported gRPC JSON API"}}", 10 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 11 | "description": "## Messages\n\n 12 | {{- range $msg := $messages }} 13 | {{- $msgDesc := (toJson $msg.Description) }}#### {{ $msg.FullName }}\n\n 14 | {{- substr 1 (int (sub (len $msgDesc) 1)) $msgDesc }}\n\n 15 | {{- if $msg.HasFields }}| Field | Type | Description |\n| ----- | ---- | ----------- |\n 16 | {{- range $f := $msg.Fields }} 17 | {{- $desc := (toJson $f.Description) }}{{ $descLen := len $desc}}| {{$f.Name}} | {{$f.FullType}} | {{substr 1 (int (sub (len $desc) 1)) $desc}} |\n 18 | {{- end }} 19 | {{- end }}\n 20 | {{- end }}" 21 | }, 22 | "item": [ 23 | {{- $fileLen := len .Files }} 24 | {{- range $fileIndex, $file := .Files -}} 25 | {{- if $file.HasServices }} 26 | { 27 | "name": "{{$file.Name}}", 28 | "description": {{$file.Description | toJson }}, 29 | "item": [ 30 | {{- $serviceLen := len $file.Services}} 31 | {{- range $serviceIndex, $service := .Services}} 32 | { 33 | "name": "{{$service.FullName}}", 34 | "description": {{$service.Description | toJson }}, 35 | "items": [ 36 | {{- $methodLen := len $service.Methods}} 37 | {{- range $methodIndex, $method := $service.Methods}} 38 | { 39 | "name": "{{$method.Name}}", 40 | "request": { 41 | "description": {{(printf "%s\n\n\n\nRequest Message: `%s`\n\nResponse Message: `%s`\n" $method.Description $method.RequestFullType $method.ResponseFullType) | toJson }}, 42 | "method": "POST", 43 | "header": [ 44 | { 45 | "key": "Content-Type", 46 | "value": "application/grpc+json" 47 | }, 48 | { 49 | "key": "Grpc-Insecure", 50 | "value": "{{"{{"}}grpcInsecure{{"}}"}}" 51 | } 52 | ], 53 | "url": { 54 | "raw": "{{"{{"}}baseURL{{"}}"}}/{{$service.FullName}}/{{$method.Name}}", 55 | "host": [ 56 | "{{"{{"}}baseURL{{"}}"}}" 57 | ], 58 | "path": [ 59 | "{{$service.FullName}}", 60 | "{{$method.Name}}" 61 | ] 62 | }, 63 | "body": { 64 | "mode": "raw", 65 | {{- range $msg := $messages -}} 66 | {{if eq $msg.FullName $method.RequestFullType }} 67 | "raw": "{\n 68 | {{- $fieldLen := len $msg.Fields -}} 69 | {{- range $fieldIndex, $field := $msg.Fields -}} 70 | {{" \\\""}}{{$field.Name}}\": {{ if eq $field.FullType "string" -}} 71 | {{- "\\\"\\\"" -}} 72 | {{- else if eq $field.FullType "bool" -}} 73 | {{- "true" -}} 74 | {{- else if (has $field.FullType (list "int" "int32" "int64" "double" "float" "uint32" "uint64" "sint32" "sint64" "fixed32" "fixed64" "sfixed32" "sfixed64")) -}} 75 | {{- "0" -}} 76 | {{- else -}} 77 | {{- "{}" -}} 78 | {{- end -}}{{if lt $fieldIndex (sub $fieldLen 1)}},\n{{end}} 79 | {{- end }}\n}" 80 | } 81 | {{- end }} 82 | {{- end }} 83 | } 84 | }{{if lt $methodIndex (sub $methodLen 1)}},{{end}} 85 | {{- end}} 86 | ] 87 | }{{if lt $serviceIndex (sub $serviceLen 1)}},{{end}} 88 | ] 89 | {{- end}} 90 | }{{if (lt $fileIndex (sub $fileLen 1))}}{{if (first (slice $.Files (add $fileIndex 1) 1)).HasServices}},{{end}}{{end}} 91 | {{- end}} 92 | {{- end}} 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 2 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 3 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 4 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 5 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 6 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 7 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 8 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 9 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 10 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 11 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 12 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 13 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 14 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 15 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 16 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 17 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 18 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 19 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 20 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 21 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 22 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 23 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 24 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 25 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 26 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 27 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 28 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 29 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 30 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 31 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 32 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 33 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 34 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 35 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 36 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 37 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 38 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 39 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 40 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 41 | google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= 42 | google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= 43 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 44 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 45 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 46 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 47 | --------------------------------------------------------------------------------