├── .gitignore ├── .github ├── dependabot.yml ├── CODE_OF_CONDUCT.md ├── workflows │ ├── add-to-project.yaml │ ├── pr-title.yaml │ └── ci.yaml └── CONTRIBUTING.md ├── SECURITY.md ├── MAINTAINERS.md ├── buf.lock ├── internal ├── examples │ ├── fileserver │ │ ├── README.md │ │ └── main.go │ ├── pets │ │ ├── buf.gen.yaml │ │ ├── README.md │ │ ├── cmd │ │ │ ├── pets-be │ │ │ │ └── main.go │ │ │ └── pets-fe │ │ │ │ └── main.go │ │ └── internal │ │ │ ├── proto │ │ │ └── io │ │ │ │ └── swagger │ │ │ │ └── petstore │ │ │ │ └── v2 │ │ │ │ └── pets.proto │ │ │ └── trace.go │ └── connect-grpc │ │ ├── README.md │ │ └── cmd │ │ ├── client │ │ └── main.go │ │ └── server │ │ └── main.go ├── proto │ └── vanguard │ │ └── test │ │ └── v1 │ │ ├── content.proto │ │ ├── test.proto │ │ └── library.proto └── gen │ └── vanguard │ └── test │ └── v1 │ ├── testv1connect │ └── content.connect.go │ └── content_grpc.pb.go ├── buf.gen.yaml ├── buf.yaml ├── go.mod ├── buffer_pool.go ├── protocol_test.go ├── path_scanner.go ├── .golangci.yml ├── errors.go ├── Makefile ├── compression.go ├── vanguardgrpc ├── vanguardgrpc_examples_test.go └── vanguardgrpc.go ├── type_resolver_test.go ├── type_resolver.go ├── protocol_grpc_test.go ├── regress_test.go ├── vanguard_examples_test.go ├── protocol_http_test.go ├── router_test.go ├── path_parser_test.go ├── path_parser.go ├── codec.go ├── LICENSE └── protocol_http.go /.gitignore: -------------------------------------------------------------------------------- 1 | /.tmp/ 2 | *.pprof 3 | *.svg 4 | cover.out 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Community Code of Conduct 2 | 3 | Connect follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). 4 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Security Policy 2 | =============== 3 | 4 | This project follows the [Connect security policy and reporting 5 | process](https://connectrpc.com/docs/governance/security). 6 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | Maintainers 2 | =========== 3 | 4 | ## Current 5 | * [Josh Humphries](https://github.com/jhump), [Buf](https://buf.build) 6 | * [Edward McFarlane](https://github.com/emcfarlane), [Buf](https://buf.build) 7 | * [John Chadwick](https://github.com/jchadwick-buf), [Buf](https://buf.build) 8 | -------------------------------------------------------------------------------- /buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | version: v2 3 | deps: 4 | - name: buf.build/googleapis/googleapis 5 | commit: 28151c0d0a1641bf938a7672c500e01d 6 | digest: b5:93b70089baa4fc05a92d3e52db91a4b7812db3b57b9664f6cb301733938cb630e377a938e8a56779388171c749c1d42a2e9a6c6230f2ff45f127a8102a6a27d0 7 | -------------------------------------------------------------------------------- /internal/examples/fileserver/README.md: -------------------------------------------------------------------------------- 1 | # File Server 2 | 3 | Example file server app using RPC to connect. This uses the support in Vanguard 4 | for streaming uploads/downloads via the use of 5 | [`google.api.HttpBody`](https://github.com/googleapis/googleapis/blob/ecb1cf0a0021267dd452289fc71c75674ae29fe3/google/api/httpbody.proto#L28). 6 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yaml: -------------------------------------------------------------------------------- 1 | name: Add issues and PRs to project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - reopened 8 | - transferred 9 | pull_request_target: 10 | types: 11 | - opened 12 | - reopened 13 | issue_comment: 14 | types: 15 | - created 16 | 17 | jobs: 18 | call-workflow-add-to-project: 19 | name: Call workflow to add issue to project 20 | uses: connectrpc/base-workflows/.github/workflows/add-to-project.yaml@main 21 | secrets: inherit 22 | -------------------------------------------------------------------------------- /internal/examples/pets/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | managed: 3 | enabled: true 4 | disable: 5 | - file_option: go_package 6 | module: buf.build/googleapis/googleapis 7 | override: 8 | - file_option: go_package_prefix 9 | value: connectrpc.com/vanguard/internal/examples/pets/internal/gen 10 | plugins: 11 | - remote: buf.build/protocolbuffers/go:v1.31.0 12 | out: internal/gen 13 | opt: paths=source_relative 14 | - remote: buf.build/connectrpc/go:v1.11.0 15 | out: internal/gen 16 | opt: paths=source_relative 17 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | managed: 3 | enabled: true 4 | disable: 5 | - file_option: go_package 6 | module: buf.build/googleapis/googleapis 7 | override: 8 | - file_option: go_package_prefix 9 | value: connectrpc.com/vanguard/internal/gen 10 | plugins: 11 | - remote: buf.build/protocolbuffers/go:v1.36.6 12 | out: internal/gen 13 | opt: paths=source_relative 14 | - remote: buf.build/connectrpc/go:v1.18.1 15 | out: internal/gen 16 | opt: paths=source_relative 17 | - remote: buf.build/grpc/go:v1.5.1 18 | out: internal/gen 19 | opt: paths=source_relative 20 | -------------------------------------------------------------------------------- /buf.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | modules: 3 | - path: internal/proto 4 | - path: internal/examples/pets/internal/proto 5 | lint: 6 | use: 7 | - STANDARD 8 | except: 9 | - ENUM_VALUE_PREFIX 10 | - ENUM_VALUE_UPPER_SNAKE_CASE 11 | - ENUM_ZERO_VALUE_SUFFIX 12 | - RPC_REQUEST_RESPONSE_UNIQUE 13 | - RPC_REQUEST_STANDARD_NAME 14 | - RPC_RESPONSE_STANDARD_NAME 15 | deps: 16 | - buf.build/googleapis/googleapis 17 | lint: 18 | use: 19 | - STANDARD 20 | except: 21 | - RPC_REQUEST_RESPONSE_UNIQUE 22 | - RPC_RESPONSE_STANDARD_NAME 23 | breaking: 24 | use: 25 | - WIRE_JSON 26 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yaml: -------------------------------------------------------------------------------- 1 | name: Lint PR Title 2 | # Prevent writing to the repository using the CI token. 3 | # Ref: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#permissions 4 | permissions: 5 | pull-requests: read 6 | on: 7 | pull_request: 8 | # By default, a workflow only runs when a pull_request's activity type is opened, 9 | # synchronize, or reopened. We explicity override here so that PR titles are 10 | # re-linted when the PR text content is edited. 11 | types: 12 | - opened 13 | - edited 14 | - reopened 15 | - synchronize 16 | jobs: 17 | lint: 18 | uses: bufbuild/base-workflows/.github/workflows/pr-title.yaml@main 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module connectrpc.com/vanguard 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | buf.build/gen/go/connectrpc/eliza/connectrpc/go v1.11.1-20230822171018-8b8b971d6fde.1 7 | buf.build/gen/go/connectrpc/eliza/grpc/go v1.3.0-20230822171018-8b8b971d6fde.1 8 | buf.build/gen/go/connectrpc/eliza/protocolbuffers/go v1.31.0-20230822171018-8b8b971d6fde.1 9 | connectrpc.com/connect v1.18.1 10 | connectrpc.com/grpcreflect v1.2.0 11 | github.com/google/go-cmp v0.6.0 12 | github.com/stretchr/testify v1.8.4 13 | golang.org/x/net v0.38.0 14 | golang.org/x/sync v0.12.0 15 | google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e 16 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e 17 | google.golang.org/grpc v1.71.0 18 | google.golang.org/protobuf v1.36.6 19 | ) 20 | 21 | require ( 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/pmezard/go-difflib v1.0.0 // indirect 24 | golang.org/x/sys v0.31.0 // indirect 25 | golang.org/x/text v0.23.0 // indirect 26 | gopkg.in/yaml.v3 v3.0.1 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: [main] 5 | tags: ['v*'] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: '15 22 * * *' 10 | workflow_dispatch: {} # support manual runs 11 | permissions: 12 | contents: read 13 | jobs: 14 | ci: 15 | name: ci (go:${{ matrix.go-version.name }}) 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | go-version: 20 | - name: latest 21 | version: 1.24.x 22 | - name: previous 23 | version: 1.23.x 24 | steps: 25 | - name: Checkout Code 26 | uses: actions/checkout@v6 27 | with: 28 | fetch-depth: 1 29 | - name: Install Go 30 | uses: actions/setup-go@v6 31 | with: 32 | go-version: ${{ matrix.go-version.version }} 33 | - name: Test 34 | run: make test 35 | - name: Lint 36 | # Often, lint & gofmt guidelines depend on the Go version. To prevent 37 | # conflicting guidance, run only on the most recent supported version. 38 | # For the same reason, only check generated code on the most recent 39 | # supported version. 40 | if: matrix.go-version.name == 'latest' 41 | run: make checkgenerate && make lint 42 | -------------------------------------------------------------------------------- /internal/examples/connect-grpc/README.md: -------------------------------------------------------------------------------- 1 | # Supporting Connect Clients from gRPC Server 2 | 3 | This example shows how Vanguard can be used to support a migration from gRPC to Connect. 4 | In such a migration, an organization can start using Connect clients first, typically from 5 | web browser clients and/or mobile app clients. In order to ease the migration, instead of 6 | having to first update servers, to implement both gRPC and Connect handler interfaces, one 7 | can use Vanguard to translate Connect client requests to gRPC, so that existing gRPC handler 8 | implementations can continue to be used. 9 | 10 | The example is an implementation of the demo service in [buf.build/connectrpc/eliza][eliza]. 11 | This is an _extremely_ simple implementation, since the actual service and any logic for it 12 | are not the purpose of this example. 13 | 14 | This example has two components: 15 | 1. `server`: The server command is a gRPC server. It uses the `google.golang.org/grpc` package 16 | as the server runtime and uses generated code from `protoc-gen-grpc-go`. But it wraps the 17 | server handler in Vanguard middleware so that it transparently can support the Connect and 18 | gRPC-Web protocols as well. 19 | 2. `client`: The client command is a simple command-line interface. It uses the Connect protocol 20 | and the `connectrpc.com/connect` runtime package. It prompts the user to interact with the 21 | Eliza service and simply echoes back the service responses. 22 | -------------------------------------------------------------------------------- /buffer_pool.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "bytes" 19 | "sync" 20 | ) 21 | 22 | const ( 23 | initialBufferSize = 512 24 | maxRecycleBufferSize = 8 * 1024 * 1024 // if >8MiB, don't hold onto a buffer 25 | ) 26 | 27 | type bufferPool struct { 28 | sync.Pool 29 | } 30 | 31 | func (b *bufferPool) Get() *bytes.Buffer { 32 | if buffer, ok := b.Pool.Get().(*bytes.Buffer); ok { 33 | buffer.Reset() 34 | return buffer 35 | } 36 | return bytes.NewBuffer(make([]byte, 0, initialBufferSize)) 37 | } 38 | 39 | func (b *bufferPool) Put(buffer *bytes.Buffer) { 40 | if buffer.Cap() > maxRecycleBufferSize { 41 | return 42 | } 43 | b.Pool.Put(buffer) 44 | } 45 | 46 | func (b *bufferPool) Wrap(data []byte, orig *bytes.Buffer) *bytes.Buffer { 47 | if cap(data) > orig.Cap() { 48 | // Original buffer was too small, so we had to grow its slice to 49 | // compute data. Replace the buffer with the larger, 50 | // newly-allocated slice. 51 | return bytes.NewBuffer(data) 52 | } 53 | // The buffer from the pool was large enough so no growing was necessary. 54 | // That means this should be a no-op since the buffer, under the hood, will 55 | // copy the given data to its internal slice, which should be the exact 56 | // same slice. 57 | orig.Reset() 58 | orig.Write(data) 59 | return orig 60 | } 61 | -------------------------------------------------------------------------------- /protocol_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/stretchr/testify/require" 21 | ) 22 | 23 | func TestParseMultiHeader(t *testing.T) { 24 | t.Parallel() 25 | testCases := []struct { 26 | name string 27 | input []string 28 | output []string 29 | actualCap int 30 | }{ 31 | { 32 | name: "empty", 33 | }, 34 | { 35 | name: "one", 36 | input: []string{"gzip"}, 37 | output: []string{"gzip"}, 38 | }, 39 | { 40 | name: "multiple in one value", 41 | input: []string{"gzip,br,deflate,spdy"}, 42 | output: []string{"gzip", "br", "deflate", "spdy"}, 43 | }, 44 | { 45 | name: "multiple values", 46 | input: []string{"gzip", "br", "deflate", "spdy"}, 47 | output: []string{"gzip", "br", "deflate", "spdy"}, 48 | }, 49 | { 50 | name: "multiple in multiple values", 51 | input: []string{"gzip,br", "deflate,spdy", "zstd"}, 52 | output: []string{"gzip", "br", "deflate", "spdy", "zstd"}, 53 | }, 54 | { 55 | name: "ignore empty and errant commas", 56 | input: []string{"", "deflate,,,spdy,", ",zstd,"}, 57 | output: []string{"deflate", "spdy", "zstd"}, 58 | actualCap: 9, // based on errant commas 59 | }, 60 | } 61 | for _, testCase := range testCases { 62 | t.Run(testCase.name, func(t *testing.T) { 63 | t.Parallel() 64 | result := parseMultiHeader(testCase.input) 65 | require.Equal(t, testCase.output, result) 66 | if testCase.actualCap > 0 { 67 | require.Equal(t, testCase.actualCap, cap(result)) 68 | } else { 69 | require.Equal(t, len(result), cap(result)) 70 | } 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/examples/connect-grpc/cmd/client/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package main 16 | 17 | import ( 18 | "bufio" 19 | "context" 20 | "fmt" 21 | "net/http" 22 | "os" 23 | 24 | "buf.build/gen/go/connectrpc/eliza/connectrpc/go/connectrpc/eliza/v1/elizav1connect" 25 | elizav1 "buf.build/gen/go/connectrpc/eliza/protocolbuffers/go/connectrpc/eliza/v1" 26 | "connectrpc.com/connect" 27 | ) 28 | 29 | func main() { 30 | client := elizav1connect.NewElizaServiceClient(http.DefaultClient, "http://127.0.0.1:18181/", 31 | connect.WithHTTPGet(), 32 | connect.WithProtoJSON()) 33 | 34 | fmt.Print("What is your name? ") 35 | input := bufio.NewReader(os.Stdin) 36 | str, err := input.ReadString('\n') 37 | if err != nil { 38 | _, _ = fmt.Fprintln(os.Stderr, err) 39 | os.Exit(1) 40 | } 41 | 42 | stream, err := client.Introduce(context.Background(), connect.NewRequest(&elizav1.IntroduceRequest{ 43 | Name: str, 44 | })) 45 | if err != nil { 46 | _, _ = fmt.Fprintln(os.Stderr, err) 47 | os.Exit(1) 48 | } 49 | for stream.Receive() { 50 | fmt.Println("eliza: ", stream.Msg().GetSentence()) 51 | } 52 | if err := stream.Err(); err != nil { 53 | _, _ = fmt.Fprintln(os.Stderr, err) 54 | os.Exit(1) 55 | } 56 | 57 | fmt.Println() 58 | 59 | for { 60 | fmt.Print("you: ") 61 | input := bufio.NewReader(os.Stdin) 62 | str, err := input.ReadString('\n') 63 | if err != nil { 64 | _, _ = fmt.Fprintln(os.Stderr, err) 65 | os.Exit(1) 66 | } 67 | 68 | resp, err := client.Say(context.Background(), connect.NewRequest(&elizav1.SayRequest{Sentence: str})) 69 | if err != nil { 70 | _, _ = fmt.Fprintln(os.Stderr, err) 71 | os.Exit(1) 72 | } 73 | fmt.Println("eliza: ", resp.Msg.GetSentence()) 74 | fmt.Println() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/examples/pets/README.md: -------------------------------------------------------------------------------- 1 | # Pet Store 2 | 3 | This is an example app that makes use of all of the protocol translation features 4 | of Vanguard. 5 | 6 | It is built on top of the [PetStore API][petstore] that is 7 | available as an OpenAPI spec. This example includes a Protobuf definition that 8 | matches the "pets" entry points of the PetStore. (The "store" and "users" entry 9 | points are not included.) 10 | 11 | This example includes two components: 12 | 13 | 1. `pets-fe`: The Pets _Frontend_ is a server that accepts requests in any a 14 | variety of protocols: REST, Connect, gRPC, or gRPC-Web. It uses the Vanguard 15 | middleware to wrap a reverse proxy, so it translates the requests to an RPC 16 | protocol and then forwards them to the `pets-be` backend service. 17 | 18 | This server listens on three ports: 19 | * **30301**: Requests to this port will be translated to the Connect protocol 20 | before being sent to the backend. 21 | * **30302**: Requests to this port will be translated to the gRPC protocol 22 | before being sent to the backend. It will also transcode requests that use 23 | the JSON format to the Protobuf binary format. 24 | * **30303**: Requests to this port will be translated to the gRPC-Web protocol 25 | before being sent to the backend. It will also transcode requests that use 26 | the JSON format to the Protobuf binary format. 27 | 28 | This server traces incoming requests to stdout. 29 | 30 | 2. `pets-be`: The Pets _Backend_ is an RPC server that accepts RPC requests from 31 | the frontend. It also uses the Vanguard middleware to wrap a reverse proxy, so 32 | it translates RPC requests into REST requests that are forwarded to the public 33 | example API server at [petstore.swagger.io][petstore]. 34 | 35 | This server listens for requests on port 30304. 36 | 37 | This server traces both incoming (RPC) and outgoing (REST) requests. 38 | 39 | You can try out these servers by running both and then issuing the following `curl` 40 | command: 41 | 42 | ```shell 43 | curl 'http://localhost:30303/pet/findByStatus?status=available' -v -X GET 44 | ``` 45 | 46 | The above sends a simple REST request to the frontend. This will get translated to 47 | gRPC-Web and sent to the backend. The backend will then turn it _back_ into a REST 48 | request and send to petstore.swagger.io. And all of this activity can be seen in 49 | the log output from the servers. 50 | 51 | [petstore]: https://petstore.swagger.io/ -------------------------------------------------------------------------------- /internal/proto/vanguard/test/v1/content.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard.test.v1; 18 | 19 | import "google/api/annotations.proto"; 20 | import "google/api/httpbody.proto"; 21 | import "google/protobuf/empty.proto"; 22 | import "google/protobuf/timestamp.proto"; 23 | 24 | service ContentService { 25 | // Index returns a html index page at the given path. 26 | rpc Index(IndexRequest) returns (google.api.HttpBody) { 27 | option (google.api.http) = {get: "/{page=**}"}; 28 | } 29 | // Upload a file to the given path. 30 | rpc Upload(stream UploadRequest) returns (google.protobuf.Empty) { 31 | option (google.api.http) = { 32 | post: "/{filename=**}:upload" 33 | body: "file" 34 | }; 35 | } 36 | // Download a file from the given path. 37 | rpc Download(DownloadRequest) returns (stream DownloadResponse) { 38 | option (google.api.http) = { 39 | get: "/{filename=**}:download" 40 | response_body: "file" 41 | }; 42 | } 43 | // Subscribe to updates for changes to content. 44 | rpc Subscribe(stream SubscribeRequest) returns (stream SubscribeResponse); 45 | } 46 | 47 | message IndexRequest { 48 | // The path to the page to index. 49 | string page = 1; 50 | } 51 | 52 | message UploadRequest { 53 | // The path to the file to upload. 54 | string filename = 1; 55 | // The file contents to upload. 56 | google.api.HttpBody file = 2; 57 | } 58 | 59 | message DownloadRequest { 60 | // The path to the file to download. 61 | string filename = 1; 62 | } 63 | 64 | message DownloadResponse { 65 | // The file contents. 66 | google.api.HttpBody file = 1; 67 | } 68 | 69 | message SubscribeRequest { 70 | repeated string filename_patterns = 1; 71 | } 72 | 73 | message SubscribeResponse { 74 | string filename_changed = 1; 75 | google.protobuf.Timestamp update_time = 2; 76 | bool deleted = 3; 77 | } 78 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We'd love your help making `vanguard-go` better. 4 | 5 | If you'd like to add new exported APIs, please [open an issue][open-issue] 6 | describing your proposal — discussing API changes ahead of time makes 7 | pull request review much smoother. In your issue, pull request, and any other 8 | communications, please remember to treat your fellow contributors with 9 | respect! 10 | 11 | Note that for a contribution to be accepted, you must sign off on all commits 12 | in order to affirm that they comply with the [Developer Certificate of Origin][dco]. 13 | Make sure to configure `git` with the same name and E-Mail as your GitHub account, 14 | and run `git commit` with the `-s` flag to sign. If necessary, a bot will remind 15 | you to sign your commits when you open your pull request, and provide helpful tips. 16 | 17 | ## Setup 18 | 19 | [Fork][fork], then clone the repository: 20 | 21 | ```bash 22 | git clone git@github.com:your_github_username/vanguard-go.git 23 | cd vanguard-go 24 | git remote add upstream https://github.com/connectrpc/vanguard-go.git 25 | git fetch upstream 26 | ``` 27 | 28 | You will need an up-to-date installation of the Go programming language. 29 | 30 | To run tests, linting, and generate code, use the default Make target. 31 | Make sure it succeeds when you first check out the repository: 32 | 33 | ```bash 34 | make 35 | ``` 36 | 37 | ## Making Changes 38 | 39 | Start by creating a new branch for your changes: 40 | 41 | ```bash 42 | git checkout main 43 | git fetch upstream 44 | git rebase upstream/main 45 | git checkout -b cool_new_feature 46 | ``` 47 | 48 | Make your changes, then ensure that `make` still passes. 49 | When you're satisfied with your changes, push them to your fork. 50 | 51 | ```bash 52 | git commit -a 53 | git push origin cool_new_feature 54 | ``` 55 | 56 | Then use the GitHub UI to open a pull request. 57 | 58 | At this point, you're waiting on us to review your changes. We *try* to respond 59 | to issues and pull requests within a few business days, and we may suggest some 60 | improvements or alternatives. Once your changes are approved, one of the 61 | project maintainers will merge them. 62 | 63 | We're much more likely to approve your changes if you: 64 | 65 | - Add tests for new functionality. 66 | - Write a [good commit message][commit-message]. 67 | - Maintain backward compatibility. 68 | 69 | [fork]: https://github.com/connectrpc/vanguard-go/fork 70 | [open-issue]: https://github.com/connectrpc/vanguard-go/issues/new 71 | [dco]: https://developercertificate.org 72 | [commit-message]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 73 | 74 | -------------------------------------------------------------------------------- /path_scanner.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "unicode/utf8" 19 | ) 20 | 21 | // pathScanner holds the state of the scanner. 22 | type pathScanner struct { 23 | input string // the string being scanned. 24 | start int // start position of this token. 25 | pos int // current position in the input. 26 | width int // width of last rune read from input. 27 | } 28 | 29 | const eof = -1 30 | 31 | func (s *pathScanner) next() rune { 32 | var char rune 33 | if s.pos >= len(s.input) { 34 | s.width = 0 35 | return eof 36 | } 37 | char, s.width = utf8.DecodeRuneInString(s.input[s.pos:]) 38 | s.pos += s.width 39 | return char 40 | } 41 | func (s *pathScanner) current() rune { 42 | var char rune 43 | if s.width > 0 { 44 | char, _ = utf8.DecodeRuneInString(s.input[s.pos-s.width:]) 45 | } else if s.pos > len(s.input) { 46 | char = eof 47 | } 48 | return char 49 | } 50 | func (s *pathScanner) backup() { 51 | s.pos -= s.width 52 | s.width = 0 53 | } 54 | func (s *pathScanner) captureRun(isValid func(r rune) bool) string { 55 | for isValid(s.next()) { 56 | continue 57 | } 58 | s.backup() 59 | return s.capture() 60 | } 61 | func (s *pathScanner) consume(expected rune) bool { 62 | if s.next() == expected { 63 | s.discard() 64 | return true 65 | } 66 | return false 67 | } 68 | func (s *pathScanner) discard() { 69 | s.start = s.pos 70 | } 71 | func (s *pathScanner) capture() string { 72 | value := s.input[s.start:s.pos] 73 | s.discard() 74 | return value 75 | } 76 | 77 | func isIdentStart(char rune) bool { 78 | return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char == '_' 79 | } 80 | func isDigit(char rune) bool { 81 | return char >= '0' && char <= '9' 82 | } 83 | func isIdent(char rune) bool { 84 | return isIdentStart(char) || isDigit(char) 85 | } 86 | func isFieldPath(char rune) bool { 87 | return isIdent(char) || char == '.' 88 | } 89 | func isLiteral(char rune) bool { 90 | // Allow [-_.~0-9a-zA-Z] and % for escaped characters. 91 | return isVariable(char) || char == '%' 92 | } 93 | 94 | // isVariable is used to determine if a character is allowed in a single variable segment. 95 | // 96 | // See: https://github.com/googleapis/googleapis/blob/master/google/api/http.proto#L251C34-L251C38 97 | func isVariable(char rune) bool { 98 | // Allow [-_.~0-9a-zA-Z]. 99 | return isFieldPath(char) || char == '-' || char == '~' 100 | } 101 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | errcheck: 3 | check-type-assertions: true 4 | exhaustruct: 5 | include: 6 | - 'github\.com/bufbuild/vanguard\..*[cC]onfig' 7 | forbidigo: 8 | forbid: 9 | - '^fmt\.Print' 10 | - '^log\.' 11 | - '^print$' 12 | - '^println$' 13 | - '^panic$' 14 | godox: 15 | # TODO, OPT, etc. comments are fine to commit. Use FIXME comments for 16 | # temporary hacks, and use godox to prevent committing them. 17 | keywords: [FIXME] 18 | varnamelen: 19 | ignore-decls: 20 | - T any 21 | - i int 22 | - wg sync.WaitGroup 23 | - sb strings.Builder 24 | - sb *strings.Builder 25 | - ok bool 26 | - op *operation 27 | - op Operation 28 | - t *ttStream 29 | - rw *responseWriter 30 | linters: 31 | enable-all: true 32 | disable: 33 | - cyclop # covered by gocyclo 34 | - depguard # unnecessary for small libraries 35 | - funlen # rely on code review to limit function length 36 | - gocognit # dubious "cognitive overhead" quantification 37 | - gofumpt # prefer standard gofmt 38 | - goimports # rely on gci instead 39 | - inamedparam # convention is not followed 40 | - ireturn # "accept interfaces, return structs" isn't ironclad 41 | - lll # don't want hard limits for line length 42 | - maintidx # covered by gocyclo 43 | - mnd # status codes are clearer than constants 44 | - nlreturn # generous whitespace violates house style 45 | - nonamedreturns # named returns are fine; it's *bare* returns that are bad 46 | - protogetter # too many false positives 47 | - tenv # replaced by usetesting 48 | - testpackage # internal tests are fine 49 | - wrapcheck # don't _always_ need to wrap errors 50 | - wsl # generous whitespace violates house style 51 | issues: 52 | exclude-dirs-use-default: false 53 | exclude: 54 | # Don't ban use of fmt.Errorf to create new errors, but the remaining 55 | # checks from err113 are useful. 56 | - "do not define dynamic errors, use wrapped static errors instead: .*" 57 | exclude-rules: 58 | - path: internal/examples/.*/.*\.go 59 | linters: 60 | - forbidigo # log.Fatal, fmt.Printf used in example programs 61 | - gosec 62 | - gochecknoglobals 63 | - path: ".*_test.go" 64 | linters: 65 | - dupl # allow duplicate string literals for testing 66 | - forcetypeassert 67 | - nilerr # allow encoding error and returning nil 68 | - path: vanguard_examples_test.go 69 | linters: 70 | - gocritic # allow log.Fatal for examples 71 | - path: handler.go 72 | linters: 73 | - contextcheck # use request context 74 | - path: params.go 75 | linters: 76 | - goconst # allow string literals for WKT names 77 | - path: protocol.go 78 | linters: 79 | - gochecknoglobals # allow Protocol global helpers 80 | text: "(allProtocols|protocolToString) is a global variable" 81 | -------------------------------------------------------------------------------- /internal/examples/pets/cmd/pets-be/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package main 16 | 17 | import ( 18 | "errors" 19 | "log" 20 | "net" 21 | "net/http" 22 | "net/http/httputil" 23 | "net/url" 24 | "time" 25 | 26 | "connectrpc.com/grpcreflect" 27 | "connectrpc.com/vanguard" 28 | "connectrpc.com/vanguard/internal/examples/pets/internal" 29 | "connectrpc.com/vanguard/internal/examples/pets/internal/gen/io/swagger/petstore/v2/petstorev2connect" 30 | "golang.org/x/net/http2" 31 | "golang.org/x/net/http2/h2c" 32 | ) 33 | 34 | func main() { 35 | // Create a reverse proxy, to forward requests to https://petstore.swagger.io/v2/. 36 | proxy := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "https", Host: "petstore.swagger.io", Path: "/v2/"}) 37 | // Note: we will trace proxied requests to stdout, so that you can see the details 38 | // of the transformation with actual requests by running this program and sending 39 | // RPC requests to it. 40 | proxy.Transport = internal.TraceTransport(http.DefaultTransport) 41 | director := proxy.Director 42 | proxy.Director = func(r *http.Request) { 43 | director(r) 44 | r.Host = r.URL.Host 45 | } 46 | 47 | // Wrap the proxy handler with Vanguard, so it can accept Connect, gRPC, or gRPC-Web 48 | // and transform the requests to REST+JSON. 49 | handler, err := vanguard.NewTranscoder([]*vanguard.Service{vanguard.NewService( 50 | petstorev2connect.PetServiceName, 51 | proxy, 52 | vanguard.WithTargetProtocols(vanguard.ProtocolREST), 53 | )}) 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | 58 | serveMux := http.NewServeMux() 59 | // Similar to above, we trace incoming requests to stdout. That way you can see the 60 | // original RPC request and the proxied REST request to see Vanguard in action. 61 | serveMux.Handle("/", internal.TraceHandler(handler)) 62 | // We add gRPC reflection support so that you can use tools like `buf curl` or `grpcurl` 63 | // with this server. 64 | serveMux.Handle(grpcreflect.NewHandlerV1(grpcreflect.NewStaticReflector(petstorev2connect.PetServiceName))) 65 | 66 | listener, err := net.Listen("tcp", "127.0.0.1:30304") 67 | if err != nil { 68 | log.Fatal(err) 69 | } 70 | svr := &http.Server{ 71 | Addr: ":http", 72 | // We use h2c to support HTTP/2 without TLS (and thus support the gRPC protocol). 73 | Handler: h2c.NewHandler(serveMux, &http2.Server{}), 74 | ReadHeaderTimeout: 15 * time.Second, 75 | } 76 | err = svr.Serve(listener) 77 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 78 | log.Fatal(err) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /internal/examples/connect-grpc/cmd/server/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package main 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "fmt" 21 | "io" 22 | "net" 23 | "net/http" 24 | "os" 25 | 26 | "buf.build/gen/go/connectrpc/eliza/grpc/go/connectrpc/eliza/v1/elizav1grpc" 27 | elizav1 "buf.build/gen/go/connectrpc/eliza/protocolbuffers/go/connectrpc/eliza/v1" 28 | "connectrpc.com/vanguard/vanguardgrpc" 29 | "golang.org/x/net/http2" 30 | "golang.org/x/net/http2/h2c" 31 | "google.golang.org/grpc" 32 | ) 33 | 34 | func main() { 35 | // Setup the gRPC server 36 | server := grpc.NewServer() 37 | elizav1grpc.RegisterElizaServiceServer(server, elizaImpl{}) 38 | 39 | // Now wrap it with a Vanguard transcoder to upgrade it to 40 | // also supporting Connect and gRPC-Web (and even REST, 41 | // if the gRPC service schemas have HTTP annotations). 42 | handler, err := vanguardgrpc.NewTranscoder(server) 43 | if err != nil { 44 | _, _ = fmt.Fprintln(os.Stderr, err) 45 | os.Exit(1) 46 | } 47 | 48 | listener, err := net.Listen("tcp", "127.0.0.1:18181") 49 | if err != nil { 50 | _, _ = fmt.Fprintln(os.Stderr, err) 51 | os.Exit(1) 52 | } 53 | 54 | // We use the h2c package in order to support HTTP/2 without TLS, 55 | // so we can handle gRPC requests, which requires HTTP/2, in 56 | // addition to Connect and gRPC-Web (which work with HTTP 1.1). 57 | err = http.Serve(listener, h2c.NewHandler(handler, &http2.Server{})) 58 | if !errors.Is(err, http.ErrServerClosed) { 59 | _, _ = fmt.Fprintln(os.Stderr, err) 60 | os.Exit(1) 61 | } 62 | } 63 | 64 | type elizaImpl struct { 65 | elizav1grpc.UnimplementedElizaServiceServer 66 | } 67 | 68 | func (e elizaImpl) Say(_ context.Context, _ *elizav1.SayRequest) (*elizav1.SayResponse, error) { 69 | // Our example therapist isn't very sophisticated. 70 | return &elizav1.SayResponse{Sentence: "Tell me more about that."}, nil 71 | } 72 | 73 | func (e elizaImpl) Converse(server elizav1grpc.ElizaService_ConverseServer) error { 74 | for { 75 | _, err := server.Recv() 76 | if errors.Is(err, io.EOF) { 77 | return nil 78 | } 79 | if err := server.Send(&elizav1.ConverseResponse{ 80 | Sentence: "Fascinating. Tell me more.", 81 | }); err != nil { 82 | return err 83 | } 84 | } 85 | } 86 | 87 | func (e elizaImpl) Introduce(_ *elizav1.IntroduceRequest, server elizav1grpc.ElizaService_IntroduceServer) error { 88 | if err := server.Send(&elizav1.IntroduceResponse{ 89 | Sentence: "Hello", 90 | }); err != nil { 91 | return err 92 | } 93 | return server.Send(&elizav1.IntroduceResponse{ 94 | Sentence: "How are you today?", 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "net/http" 21 | 22 | "connectrpc.com/connect" 23 | ) 24 | 25 | var ( 26 | errNoTimeout = errors.New("no timeout") 27 | errNotFound = &httpError{code: http.StatusNotFound} 28 | ) 29 | 30 | func asConnectError(err error) *connect.Error { 31 | var ce *connect.Error 32 | if errors.As(err, &ce) { 33 | return ce 34 | } 35 | return connect.NewError(connect.CodeInternal, err) 36 | } 37 | 38 | type httpError struct { 39 | code int 40 | header http.Header 41 | err error 42 | } 43 | 44 | func newHTTPError(statusCode int, msgFormat string, args ...any) *httpError { 45 | return &httpError{ 46 | code: statusCode, 47 | err: fmt.Errorf(msgFormat, args...), 48 | } 49 | } 50 | 51 | func (e *httpError) Error() string { 52 | if e.err != nil { 53 | return e.err.Error() 54 | } 55 | return http.StatusText(e.code) 56 | } 57 | 58 | func (e *httpError) Unwrap() error { 59 | return e.err 60 | } 61 | 62 | func (e *httpError) EncodeHeaders(header http.Header) { 63 | if e == nil { 64 | return 65 | } 66 | for key, vals := range e.header { 67 | for _, val := range vals { 68 | header.Add(key, val) 69 | } 70 | } 71 | } 72 | 73 | func (e *httpError) Encode(writer http.ResponseWriter) { 74 | if e == nil { 75 | writer.WriteHeader(http.StatusOK) 76 | return 77 | } 78 | e.EncodeHeaders(writer.Header()) 79 | http.Error(writer, e.Error(), e.code) 80 | } 81 | 82 | func asHTTPError(err error) *httpError { 83 | if err == nil { 84 | return nil 85 | } 86 | var httpErr *httpError 87 | if errors.As(err, &httpErr) { 88 | return httpErr 89 | } 90 | var ce *connect.Error 91 | if errors.As(err, &ce) { 92 | return &httpError{ 93 | code: httpStatusCodeFromRPC(ce.Code()), 94 | header: ce.Meta(), 95 | err: err, 96 | } 97 | } 98 | return &httpError{code: http.StatusInternalServerError, err: err} 99 | } 100 | 101 | func protocolError(msg string, args ...any) error { 102 | return fmt.Errorf("protocol error: "+msg, args...) 103 | } 104 | 105 | func bufferLimitError(limit int64) error { 106 | return sizeLimitError("max buffer size", limit) 107 | } 108 | 109 | func contentLengthError(limit int64) error { 110 | return sizeLimitError("content length", limit) 111 | } 112 | 113 | func sizeLimitError(what string, limit int64) error { 114 | return connect.NewError(connect.CodeResourceExhausted, fmt.Errorf("%s (%d) exceeded", what, limit)) 115 | } 116 | 117 | func malformedRequestError(err error) error { 118 | // Adds 400 Bad Request / InvalidArgument status codes to error 119 | return connect.NewError(connect.CodeInvalidArgument, err) 120 | } 121 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # See https://tech.davis-hansson.com/p/make/ 2 | SHELL := bash 3 | .DELETE_ON_ERROR: 4 | .SHELLFLAGS := -eu -o pipefail -c 5 | .DEFAULT_GOAL := all 6 | MAKEFLAGS += --warn-undefined-variables 7 | MAKEFLAGS += --no-builtin-rules 8 | MAKEFLAGS += --no-print-directory 9 | BIN := .tmp/bin 10 | COPYRIGHT_YEARS := 2023-2025 11 | LICENSE_IGNORE := -e testdata/ 12 | BUF_VERSION ?= 1.52.1 13 | # Set to use a different compiler. For example, `GO=go1.18rc1 make test`. 14 | GO ?= go 15 | 16 | .PHONY: help 17 | help: ## Describe useful make targets 18 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "%-30s %s\n", $$1, $$2}' 19 | 20 | .PHONY: all 21 | all: ## Build, test, and lint (default) 22 | $(MAKE) test 23 | $(MAKE) lint 24 | 25 | .PHONY: clean 26 | clean: ## Delete intermediate build artifacts 27 | @# -X only removes untracked files, -d recurses into directories, -f actually removes files/dirs 28 | git clean -Xdf 29 | 30 | .PHONY: test 31 | test: build ## Run unit tests 32 | $(GO) test -vet=off -race -cover ./... 33 | cd internal/examples/pets && $(GO) test -vet=off -race -cover ./... 34 | cd internal/examples/connect-grpc && $(GO) test -vet=off -race -cover ./... 35 | 36 | .PHONY: build 37 | build: generate ## Build all packages 38 | $(GO) build ./... 39 | 40 | .PHONY: generate 41 | generate: $(BIN)/buf $(BIN)/license-header ## Regenerate code and licenses 42 | $(BIN)/buf generate internal/proto 43 | cd internal/examples/pets && ../../../$(BIN)/buf generate internal/proto 44 | @# We want to operate on a list of modified and new files, excluding 45 | @# deleted and ignored files. git-ls-files can't do this alone. comm -23 takes 46 | @# two files and prints the union, dropping lines common to both (-3) and 47 | @# those only in the second file (-2). We make one git-ls-files call for 48 | @# the modified, cached, and new (--others) files, and a second for the 49 | @# deleted files. 50 | comm -23 \ 51 | <(git ls-files --cached --modified --others --no-empty-directory --exclude-standard | sort -u | grep -v $(LICENSE_IGNORE) ) \ 52 | <(git ls-files --deleted | sort -u) | \ 53 | xargs $(BIN)/license-header \ 54 | --license-type apache \ 55 | --copyright-holder "Buf Technologies, Inc." \ 56 | --year-range "$(COPYRIGHT_YEARS)" 57 | 58 | .PHONY: lint 59 | lint: $(BIN)/golangci-lint $(BIN)/buf ## Lint 60 | $(GO) vet ./... 61 | $(BIN)/golangci-lint run 62 | $(BIN)/buf lint 63 | 64 | .PHONY: lintfix 65 | lintfix: $(BIN)/golangci-lint ## Automatically fix some lint errors 66 | $(BIN)/golangci-lint run --fix 67 | 68 | .PHONY: install 69 | install: ## Install all binaries 70 | $(GO) install ./... 71 | 72 | .PHONY: upgrade 73 | upgrade: ## Upgrade dependencies 74 | go get -u -t ./... 75 | go mod tidy -v 76 | 77 | .PHONY: checkgenerate 78 | checkgenerate: 79 | @# Used in CI to verify that `make generate` doesn't produce a diff. 80 | test -z "$$(git status --porcelain | tee /dev/stderr)" 81 | 82 | $(BIN)/buf: Makefile 83 | @mkdir -p $(@D) 84 | GOBIN=$(abspath $(@D)) $(GO) install \ 85 | github.com/bufbuild/buf/cmd/buf@v$(BUF_VERSION) 86 | 87 | $(BIN)/license-header: Makefile 88 | @mkdir -p $(@D) 89 | GOBIN=$(abspath $(@D)) $(GO) install \ 90 | github.com/bufbuild/buf/private/pkg/licenseheader/cmd/license-header@v$(BUF_VERSION) 91 | 92 | $(BIN)/golangci-lint: Makefile 93 | @mkdir -p $(@D) 94 | GOBIN=$(abspath $(@D)) $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.7 95 | -------------------------------------------------------------------------------- /compression.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "bytes" 19 | "compress/gzip" 20 | "io" 21 | "sync" 22 | 23 | "connectrpc.com/connect" 24 | ) 25 | 26 | type compressionMap map[string]*compressionPool 27 | 28 | func (m compressionMap) intersection(names []string) []string { 29 | length := len(names) 30 | if len(m) < length { 31 | length = len(m) 32 | } 33 | if length == 0 { 34 | // If either set is empty, the intersection is empty. 35 | // We don't use nil since it is used in places as a sentinel. 36 | return make([]string, 0) 37 | } 38 | intersection := make([]string, 0, length) 39 | for _, name := range names { 40 | if _, ok := m[name]; ok { 41 | intersection = append(intersection, name) 42 | } 43 | } 44 | return intersection 45 | } 46 | 47 | type compressionPool struct { 48 | name string 49 | decompressors sync.Pool 50 | compressors sync.Pool 51 | } 52 | 53 | func newCompressionPool( 54 | name string, 55 | newCompressor func() connect.Compressor, 56 | newDecompressor func() connect.Decompressor, 57 | ) *compressionPool { 58 | return &compressionPool{ 59 | name: name, 60 | compressors: sync.Pool{ 61 | New: func() any { return newCompressor() }, 62 | }, 63 | decompressors: sync.Pool{ 64 | New: func() any { return newDecompressor() }, 65 | }, 66 | } 67 | } 68 | 69 | func (p *compressionPool) Name() string { 70 | if p == nil { 71 | return "" 72 | } 73 | return p.name 74 | } 75 | 76 | func (p *compressionPool) compress(dst, src *bytes.Buffer) error { 77 | if p == nil { 78 | _, err := io.Copy(dst, src) 79 | return err 80 | } 81 | if src.Len() == 0 { 82 | return nil 83 | } 84 | comp, _ := p.compressors.Get().(connect.Compressor) 85 | defer p.compressors.Put(comp) 86 | 87 | comp.Reset(dst) 88 | _, err := src.WriteTo(comp) 89 | if err != nil { 90 | return err 91 | } 92 | return comp.Close() 93 | } 94 | 95 | func (p *compressionPool) decompress(dst, src *bytes.Buffer) error { 96 | if p == nil { 97 | _, err := io.Copy(dst, src) 98 | return err 99 | } 100 | if src.Len() == 0 { 101 | return nil 102 | } 103 | decomp, _ := p.decompressors.Get().(connect.Decompressor) 104 | defer p.decompressors.Put(decomp) 105 | 106 | if err := decomp.Reset(src); err != nil { 107 | return err 108 | } 109 | if _, err := dst.ReadFrom(decomp); err != nil { 110 | return err 111 | } 112 | return decomp.Close() 113 | } 114 | 115 | // defaultGzipCompressor is a factory for Compressor instances used by default 116 | // for the "gzip" encoding type. 117 | func defaultGzipCompressor() connect.Compressor { 118 | return gzip.NewWriter(io.Discard) 119 | } 120 | 121 | // defaultGzipDecompressor is a factory for Decompressor instances used by 122 | // default for the "gzip" encoding type. 123 | func defaultGzipDecompressor() connect.Decompressor { 124 | return &gzip.Reader{} 125 | } 126 | -------------------------------------------------------------------------------- /internal/examples/fileserver/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package main 16 | 17 | import ( 18 | "bytes" 19 | "context" 20 | "flag" 21 | "html/template" 22 | "io/fs" 23 | "log" 24 | "mime" 25 | "net/http" 26 | "os" 27 | "path/filepath" 28 | 29 | "connectrpc.com/connect" 30 | "connectrpc.com/vanguard" 31 | testv1 "connectrpc.com/vanguard/internal/gen/vanguard/test/v1" 32 | "connectrpc.com/vanguard/internal/gen/vanguard/test/v1/testv1connect" 33 | "google.golang.org/genproto/googleapis/api/httpbody" 34 | ) 35 | 36 | func main() { 37 | flagset := flag.NewFlagSet("fileserver", flag.ExitOnError) 38 | port := flagset.String("p", "8100", "port to serve on") 39 | directory := flagset.String("d", ".", "the directory of static file to host") 40 | if err := flagset.Parse(os.Args[1:]); err != nil { 41 | log.Fatal(err) 42 | } 43 | 44 | // Create Connect handler. 45 | serviceHandler := &ContentService{ 46 | FS: os.DirFS(*directory), 47 | } 48 | // And wrap it with Vanguard. 49 | service := vanguard.NewService(testv1connect.NewContentServiceHandler(serviceHandler)) 50 | handler, err := vanguard.NewTranscoder([]*vanguard.Service{service}) 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | // Now handler also supports REST requests, translated to Connect 55 | // using the HTTP annotations on the ContentService definition. 56 | log.Printf("Serving %s on HTTP port: %s\n", *directory, *port) 57 | log.Fatal(http.ListenAndServe(":"+*port, handler)) 58 | } 59 | 60 | var indexHTMLTemplate = template.Must(template.New("http").Parse(` 61 | 62 | 63 | 64 | {{.Title}} 65 | 66 | 67 |
 68 |   {{- if ne .Title "."}}
 69 |   ..
 70 |   {{- end}}
 71 |   {{- range $path, $name := .Files}}
 72 |   {{$name}}
 73 |   {{- end}}
 74 |   
75 | 76 | 77 | `)) 78 | 79 | type ContentService struct { 80 | testv1connect.UnimplementedContentServiceHandler 81 | fs.FS 82 | } 83 | 84 | func (c *ContentService) Index(_ context.Context, req *connect.Request[testv1.IndexRequest]) (*connect.Response[httpbody.HttpBody], error) { 85 | name := req.Msg.GetPage() 86 | log.Printf("Index: %v", name) 87 | if name == "/" || name == "" { 88 | name = "." 89 | } 90 | 91 | file, err := c.Open(name) 92 | if err != nil { 93 | return nil, err 94 | } 95 | stat, err := file.Stat() 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | contentType := "text/html" 101 | var data []byte 102 | if !stat.IsDir() { 103 | contentType = mime.TypeByExtension(filepath.Ext(name)) 104 | data, err = fs.ReadFile(c.FS, name) 105 | if err != nil { 106 | return nil, err 107 | } 108 | } else { 109 | tmplData := struct { 110 | Title string 111 | Files map[string]string 112 | }{ 113 | Title: name, 114 | Files: make(map[string]string), 115 | } 116 | entries, err := fs.ReadDir(c.FS, name) 117 | if err != nil { 118 | return nil, err 119 | } 120 | for _, entry := range entries { 121 | tmplData.Files[filepath.Join(name, entry.Name())] = entry.Name() 122 | } 123 | var buf bytes.Buffer 124 | if err := indexHTMLTemplate.Execute(&buf, tmplData); err != nil { 125 | return nil, err 126 | } 127 | data = buf.Bytes() 128 | } 129 | 130 | return connect.NewResponse(&httpbody.HttpBody{ 131 | ContentType: contentType, 132 | Data: data, 133 | }), nil 134 | } 135 | -------------------------------------------------------------------------------- /vanguardgrpc/vanguardgrpc_examples_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguardgrpc_test 16 | 17 | import ( 18 | "context" 19 | "log" 20 | "net/http/httptest" 21 | "os" 22 | "strings" 23 | "time" 24 | 25 | "connectrpc.com/connect" 26 | "connectrpc.com/vanguard" 27 | testv1 "connectrpc.com/vanguard/internal/gen/vanguard/test/v1" 28 | "connectrpc.com/vanguard/internal/gen/vanguard/test/v1/testv1connect" 29 | "connectrpc.com/vanguard/vanguardgrpc" 30 | "google.golang.org/grpc" 31 | "google.golang.org/grpc/encoding" 32 | "google.golang.org/protobuf/encoding/protojson" 33 | "google.golang.org/protobuf/types/known/timestamppb" 34 | ) 35 | 36 | func ExampleNewTranscoder_connectToGRPC() { 37 | log := log.New(os.Stdout, "" /* prefix */, 0 /* flags */) 38 | 39 | // Configure gRPC servers to support JSON. 40 | encoding.RegisterCodec(vanguardgrpc.NewCodec(&vanguard.JSONCodec{ 41 | MarshalOptions: protojson.MarshalOptions{EmitUnpopulated: true}, 42 | UnmarshalOptions: protojson.UnmarshalOptions{DiscardUnknown: true}, 43 | })) 44 | 45 | // Now create a gRPC server that provides the test LibraryService. 46 | svc := &libraryRPC{} 47 | grpcServer := grpc.NewServer() 48 | testv1.RegisterLibraryServiceServer(grpcServer, svc) 49 | 50 | // Create a vanguard handler for all services registered in grpcServer 51 | handler, err := vanguardgrpc.NewTranscoder(grpcServer) 52 | if err != nil { 53 | log.Println("error:", err) 54 | return 55 | } 56 | 57 | // Create the server. 58 | // (This is a httptest.Server, but it could be any http.Server) 59 | server := httptest.NewUnstartedServer(handler) 60 | server.EnableHTTP2 = true // HTTP/2 required for gRPC 61 | server.StartTLS() 62 | defer server.Close() 63 | 64 | // Create a connect client and call the service. 65 | client := testv1connect.NewLibraryServiceClient(server.Client(), server.URL, connect.WithProtoJSON()) 66 | 67 | // Call the service using Connect, translated by the middleware to gRPC. 68 | rsp, err := client.GetBook( 69 | context.Background(), 70 | connect.NewRequest(&testv1.GetBookRequest{ 71 | Name: "shelves/top/books/1", 72 | }), 73 | ) 74 | if err != nil { 75 | log.Println("error:", err) 76 | return 77 | } 78 | log.Println(rsp.Msg.GetTitle()) 79 | // Output: Do Androids Dream of Electric Sheep? 80 | } 81 | 82 | type libraryRPC struct { 83 | testv1.UnimplementedLibraryServiceServer 84 | } 85 | 86 | func (s *libraryRPC) GetBook(_ context.Context, req *testv1.GetBookRequest) (*testv1.Book, error) { 87 | return &testv1.Book{ 88 | Name: req.GetName(), 89 | Parent: strings.Join(strings.Split(req.GetName(), "/")[:2], "/"), 90 | CreateTime: timestamppb.New(time.Date(1968, 1, 1, 0, 0, 0, 0, time.UTC)), 91 | Title: "Do Androids Dream of Electric Sheep?", 92 | Author: "Philip K. Dick", 93 | Description: "Have you seen Blade Runner?", 94 | Labels: map[string]string{ 95 | "genre": "science fiction", 96 | }, 97 | }, nil 98 | } 99 | 100 | func (s *libraryRPC) CreateBook(_ context.Context, req *testv1.CreateBookRequest) (*testv1.Book, error) { 101 | book := req.GetBook() 102 | return &testv1.Book{ 103 | Name: strings.Join([]string{req.GetParent(), "books", req.GetBookId()}, "/"), 104 | Parent: req.GetParent(), 105 | CreateTime: timestamppb.New(time.Date(1968, 1, 1, 0, 0, 0, 0, time.UTC)), 106 | Title: book.GetTitle(), 107 | Author: book.GetAuthor(), 108 | Description: book.GetDescription(), 109 | Labels: book.GetLabels(), 110 | }, nil 111 | } 112 | -------------------------------------------------------------------------------- /internal/examples/pets/internal/proto/io/swagger/petstore/v2/pets.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | // The service defined herein comes from v2 of the Petstore service, which 18 | // is used as an example for Swagger/OpenAPI. The Swagger spec can be found 19 | // here: https://petstore.swagger.io/v2/swagger.json 20 | // A human-friendly HTML view of this API is also available at 21 | // https://petstore.swagger.io. 22 | // 23 | // This file defines only the "pet" service. The spec for this site also 24 | // includes "store" and "user" services which are not supported via these 25 | // RPC definitions. 26 | package io.swagger.petstore.v2; 27 | 28 | import "google/api/annotations.proto"; 29 | import "google/protobuf/empty.proto"; 30 | 31 | service PetService { 32 | rpc GetPetByID(PetID) returns (Pet) { 33 | option (google.api.http).get = "/pet/{pet_id}"; 34 | option idempotency_level = NO_SIDE_EFFECTS; 35 | } 36 | rpc UpdatePetWithForm(UpdatePetWithFormReq) returns (google.protobuf.Empty) { 37 | option (google.api.http).post = "/pet/{pet_id}"; 38 | option (google.api.http).body = "*"; 39 | } 40 | rpc DeletePet(PetID) returns (google.protobuf.Empty) { 41 | option (google.api.http).delete = "/pet/{pet_id}"; 42 | } 43 | rpc UploadFile(UploadFileReq) returns (ApiResponse) { 44 | option (google.api.http).post = "/pet/{pet_id}/uploadImage"; 45 | option (google.api.http).body = "*"; 46 | } 47 | rpc AddPet(Pet) returns (Pet) { 48 | option (google.api.http).post = "/pet"; 49 | option (google.api.http).body = "*"; 50 | } 51 | rpc UpdatePet(Pet) returns (Pet) { 52 | option (google.api.http).put = "/pet"; 53 | option (google.api.http).body = "*"; 54 | } 55 | rpc FindPetsByTag(TagReq) returns (Pets) { 56 | option deprecated = true; 57 | option (google.api.http).get = "/pet/findByTags"; 58 | option (google.api.http).response_body = "pets"; 59 | option idempotency_level = NO_SIDE_EFFECTS; 60 | } 61 | rpc FindPetsByStatus(StatusReq) returns (Pets) { 62 | option (google.api.http).get = "/pet/findByStatus"; 63 | option (google.api.http).response_body = "pets"; 64 | option idempotency_level = NO_SIDE_EFFECTS; 65 | } 66 | } 67 | 68 | message Category { 69 | int64 id = 1; 70 | string name = 2; 71 | } 72 | 73 | message Tag { 74 | int64 id = 1; 75 | string name = 2; 76 | } 77 | 78 | enum Status { 79 | // These do not use standard naming practices in order to match 80 | // the JSON format of the Pet Store Open API schema, which uses 81 | // lower-case names for these constants. 82 | 83 | unknown = 0; 84 | available = 1; 85 | pending = 2; 86 | sold = 3; 87 | } 88 | 89 | message PetID { 90 | int64 pet_id = 1; 91 | } 92 | 93 | message Pet { 94 | int64 id = 1; 95 | Category category = 2; 96 | string name = 3; 97 | repeated string photo_urls = 4; 98 | repeated Tag tags = 5; 99 | string status = 6; 100 | } 101 | 102 | message UpdatePetWithFormReq { 103 | int64 pet_id = 1; 104 | string name = 2; 105 | string status = 3; 106 | } 107 | 108 | message UploadFileReq { 109 | int64 pet_id = 1; 110 | string additional_metadata = 2; 111 | string file = 3; 112 | } 113 | 114 | message PetBody { 115 | int64 pet_id = 1; 116 | string body = 2; 117 | } 118 | 119 | message StatusReq { 120 | repeated string status = 1; 121 | } 122 | 123 | message TagReq { 124 | repeated string tag = 1; 125 | } 126 | 127 | message Pets { 128 | repeated Pet pets = 1; 129 | } 130 | 131 | message ApiResponse { 132 | int32 code = 1; 133 | string type = 2; 134 | string message = 3; 135 | } -------------------------------------------------------------------------------- /vanguardgrpc/vanguardgrpc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | // Package vanguardgrpc provides convenience functions to make it easy to 16 | // wrap your [grpc.Server] with a [vanguard.Transcoder], to upgrade it to 17 | // supporting Connect, gRPC-Web, and REST+JSON protocols. 18 | package vanguardgrpc 19 | 20 | import ( 21 | "fmt" 22 | 23 | "connectrpc.com/vanguard" 24 | "google.golang.org/grpc" 25 | "google.golang.org/grpc/encoding" 26 | "google.golang.org/protobuf/proto" 27 | ) 28 | 29 | // NewTranscoder returns a Vanguard handler that wraps the given gRPC server. All 30 | // services registered with the server will be supported by the returned handler. 31 | // The Vanguard handler will be configured to transcode incoming requests to the 32 | // gRPC protocol. 33 | // 34 | // The returned handler will allow data in the "proto" codec through, but must 35 | // transcode other codecs to "proto". If a gRPC Codec has been registered with the 36 | // name "json" (via [encoding.RegisterCodec]) then the Vanguard handler will pass 37 | // JSON requests through unchanged as well. 38 | // 39 | // For maximum efficiency, especially if REST and/or Connect clients are expected, 40 | // a JSON codec should be registered before calling this function. If the server 41 | // program does not already register such a codec, it may do so via the following: 42 | // 43 | // encoding.RegisterCodec(vanguardgrpc.NewCodec(&vanguard.JSONCodec{ 44 | // // These fields can be used to customize the serialization and 45 | // // de-serialization behavior. The options presented below are 46 | // // highly recommended. 47 | // MarshalOptions: protojson.MarshalOptions{ 48 | // EmitUnpopulated: true, 49 | // }, 50 | // UnmarshalOptions: protojson.UnmarshalOptions{ 51 | // DiscardUnknown: true, 52 | // }, 53 | // }) 54 | func NewTranscoder(server *grpc.Server, opts ...vanguard.TranscoderOption) (*vanguard.Transcoder, error) { 55 | codecs := make([]string, 1, 2) 56 | codecs[0] = vanguard.CodecProto 57 | if encoding.GetCodec(vanguard.CodecJSON) != nil { 58 | codecs = append(codecs, vanguard.CodecJSON) 59 | } 60 | svcInfo := server.GetServiceInfo() 61 | services := make([]*vanguard.Service, 0, len(svcInfo)) 62 | for svcName := range svcInfo { 63 | services = append(services, vanguard.NewService(svcName, server)) 64 | } 65 | allOptions := make([]vanguard.TranscoderOption, 0, 1+len(opts)) 66 | allOptions = append(allOptions, vanguard.WithDefaultServiceOptions( 67 | vanguard.WithTargetCodecs(codecs...), 68 | vanguard.WithTargetProtocols(vanguard.ProtocolGRPC), 69 | )) 70 | allOptions = append(allOptions, opts...) 71 | return vanguard.NewTranscoder(services, allOptions...) 72 | } 73 | 74 | // NewCodec returns a gRPC [encoding.Codec] that uses the given 75 | // Vanguard Codec as its backing implementation. In particular, this 76 | // can be combined with [vanguard.JSONCodec] to easily create a gRPC 77 | // Codec to support the "json" message format. 78 | func NewCodec(codec vanguard.Codec) encoding.Codec { 79 | return &grpcCodec{codec: codec} 80 | } 81 | 82 | type grpcCodec struct { 83 | codec vanguard.Codec 84 | } 85 | 86 | func (g *grpcCodec) Marshal(v any) ([]byte, error) { 87 | msg, ok := v.(proto.Message) 88 | if !ok { 89 | return nil, fmt.Errorf("value is not a proto.Message: %T", v) 90 | } 91 | return g.codec.MarshalAppend(nil, msg) 92 | } 93 | 94 | func (g *grpcCodec) Unmarshal(data []byte, v any) error { 95 | msg, ok := v.(proto.Message) 96 | if !ok { 97 | return fmt.Errorf("value is not a proto.Message: %T", v) 98 | } 99 | return g.codec.Unmarshal(data, msg) 100 | } 101 | 102 | func (g *grpcCodec) Name() string { 103 | return g.codec.Name() 104 | } 105 | -------------------------------------------------------------------------------- /type_resolver_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "crypto/rand" 19 | "encoding/hex" 20 | "fmt" 21 | "testing" 22 | 23 | "connectrpc.com/vanguard/internal/gen/vanguard/test/v1/testv1connect" 24 | "github.com/stretchr/testify/assert" 25 | "github.com/stretchr/testify/require" 26 | "google.golang.org/protobuf/proto" 27 | "google.golang.org/protobuf/reflect/protodesc" 28 | "google.golang.org/protobuf/reflect/protoreflect" 29 | "google.golang.org/protobuf/reflect/protoregistry" 30 | "google.golang.org/protobuf/types/descriptorpb" 31 | "google.golang.org/protobuf/types/known/timestamppb" 32 | ) 33 | 34 | func TestCanUseGlobalTypes(t *testing.T) { 35 | t.Parallel() 36 | t.Run("service_descriptor_from_global_files", func(t *testing.T) { 37 | t.Parallel() 38 | desc, err := protoregistry.GlobalFiles.FindDescriptorByName(testv1connect.LibraryServiceName) 39 | require.NoError(t, err) 40 | svcDesc, ok := desc.(protoreflect.ServiceDescriptor) 41 | require.True(t, ok) 42 | 43 | assert.True(t, canUseGlobalTypes(svcDesc)) 44 | }) 45 | t.Run("service_descriptor_not_from_global_files", func(t *testing.T) { 46 | t.Parallel() 47 | prefix := randomPrefix(t) 48 | file, err := makeFile(prefix) 49 | require.NoError(t, err) 50 | 51 | svcDesc := file.Services().ByName("BlahService") 52 | require.NotNil(t, svcDesc) 53 | 54 | assert.False(t, canUseGlobalTypes(svcDesc)) 55 | }) 56 | t.Run("service_descriptor_from_global_files_without_types", func(t *testing.T) { 57 | t.Parallel() 58 | prefix := randomPrefix(t) 59 | file, err := makeFile(prefix) 60 | require.NoError(t, err) 61 | 62 | svcDesc := file.Services().ByName("BlahService") 63 | require.NotNil(t, svcDesc) 64 | 65 | err = protoregistry.GlobalFiles.RegisterFile(file) 66 | require.NoError(t, err) 67 | 68 | // Now file is in GlobalFiles, but the request and response types are NOT in GlobalTypes. 69 | // So this still returns false. 70 | assert.False(t, canUseGlobalTypes(svcDesc)) 71 | }) 72 | } 73 | 74 | func randomPrefix(t *testing.T) string { 75 | t.Helper() 76 | var prefixBytes [12]byte 77 | _, err := rand.Read(prefixBytes[:]) 78 | require.NoError(t, err) 79 | return "x" + hex.EncodeToString(prefixBytes[:]) 80 | } 81 | 82 | func makeFile(prefix string) (protoreflect.FileDescriptor, error) { 83 | pathPrefix := prefix 84 | if pathPrefix != "" { 85 | pathPrefix += "/" 86 | } 87 | pkgPrefix := prefix 88 | if pkgPrefix != "" { 89 | pkgPrefix += "." 90 | } 91 | var reg protoregistry.Files 92 | err := reg.RegisterFile((*timestamppb.Timestamp)(nil).ProtoReflect().Descriptor().ParentFile()) 93 | if err != nil { 94 | return nil, fmt.Errorf("failed to register dependency: %w", err) 95 | } 96 | return protodesc.NewFile( 97 | &descriptorpb.FileDescriptorProto{ 98 | Name: proto.String(pathPrefix + "foo/bar/baz/v1/blah.proto"), 99 | Package: proto.String(pkgPrefix + "foo.bar.baz.v1"), 100 | Dependency: []string{"google/protobuf/timestamp.proto"}, 101 | MessageType: []*descriptorpb.DescriptorProto{ 102 | { 103 | Name: proto.String("Blah"), 104 | }, 105 | { 106 | Name: proto.String("Blarg"), 107 | }, 108 | { 109 | Name: proto.String("Blech"), 110 | }, 111 | { 112 | Name: proto.String("Bleep"), 113 | }, 114 | { 115 | Name: proto.String("Blue"), // not actually used by service below 116 | }, 117 | }, 118 | Service: []*descriptorpb.ServiceDescriptorProto{ 119 | { 120 | Name: proto.String("BlahService"), 121 | Method: []*descriptorpb.MethodDescriptorProto{ 122 | { 123 | Name: proto.String("Do"), 124 | InputType: proto.String("." + pkgPrefix + "foo.bar.baz.v1.Blah"), 125 | OutputType: proto.String("." + pkgPrefix + "foo.bar.baz.v1.Blarg"), 126 | }, 127 | { 128 | Name: proto.String("Dont"), 129 | InputType: proto.String("." + pkgPrefix + "foo.bar.baz.v1.Blech"), 130 | OutputType: proto.String("." + pkgPrefix + "foo.bar.baz.v1.Bleep"), 131 | }, 132 | }, 133 | }, 134 | }, 135 | }, 136 | ®, 137 | ) 138 | } 139 | -------------------------------------------------------------------------------- /internal/examples/pets/cmd/pets-fe/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package main 16 | 17 | import ( 18 | "context" 19 | "crypto/tls" 20 | "errors" 21 | "fmt" 22 | "log" 23 | "net" 24 | "net/http" 25 | "net/http/httputil" 26 | "net/url" 27 | "strings" 28 | "time" 29 | 30 | "connectrpc.com/grpcreflect" 31 | "connectrpc.com/vanguard" 32 | "connectrpc.com/vanguard/internal/examples/pets/internal" 33 | "connectrpc.com/vanguard/internal/examples/pets/internal/gen/io/swagger/petstore/v2/petstorev2connect" 34 | "golang.org/x/net/http2" 35 | "golang.org/x/net/http2/h2c" 36 | "golang.org/x/sync/errgroup" 37 | ) 38 | 39 | func main() { 40 | // We use three separate handlers to serve with three different ports. 41 | // Each is configured to transform requests to a different protocol. 42 | serverOptions := map[int][]vanguard.ServiceOption{ 43 | // All requests to port 30301 will get translated to the Connect protocol 44 | // and then sent to the pets-be server. 45 | 30301: { 46 | vanguard.WithTargetProtocols(vanguard.ProtocolConnect), 47 | }, 48 | // All requests to port 30302 will get translated to the gRPC protocol 49 | // and JSON data will get transcoded to the Protobuf binary format. 50 | 30302: { 51 | vanguard.WithTargetProtocols(vanguard.ProtocolGRPC), 52 | vanguard.WithTargetCodecs(vanguard.CodecProto), 53 | }, 54 | // And requests to port 30303 will get translated to gRPC-Web, Protobuf binary. 55 | 30303: { 56 | vanguard.WithTargetProtocols(vanguard.ProtocolGRPCWeb), 57 | vanguard.WithTargetCodecs(vanguard.CodecProto), 58 | }, 59 | } 60 | 61 | // This server proxies requests to the backend (after translating to a particular RPC protocol). 62 | proxy := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "http", Host: "127.0.0.1:30304"}) 63 | // The gRPC protocol *requires* HTTP/2 and can't work with HTTP 1.1. 64 | // So we make the proxy smart enough to always use H2C (to use HTTP/2 65 | // without TLS) when the protocol is gRPC. 66 | proxy.Transport = h2cIfGRPCTransport{h2c: &http2.Transport{ 67 | AllowHTTP: true, 68 | DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { 69 | return (&net.Dialer{}).DialContext(ctx, network, addr) 70 | }, 71 | }} 72 | 73 | listeners := make([]net.Listener, 0, len(serverOptions)) 74 | svrs := make([]*http.Server, 0, len(serverOptions)) 75 | for serverPort, opts := range serverOptions { 76 | listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", serverPort)) 77 | if err != nil { 78 | log.Fatal(err) 79 | } 80 | 81 | // Wrap the proxy handler with Vanguard, so it can accept any kind of request and then 82 | // transform it to a particular protocol (based on the port used to send the request) 83 | // before sending to the pets-be server. 84 | handler, err := vanguard.NewTranscoder( 85 | []*vanguard.Service{vanguard.NewService(petstorev2connect.PetServiceName, proxy, opts...)}, 86 | ) 87 | if err != nil { 88 | log.Fatal(err) 89 | } 90 | 91 | serveMux := http.NewServeMux() 92 | // Note: the handler will trace incoming requests to stdout. This provides insight 93 | // into the protocol transformation, when compared to the corresponding requests 94 | // in the output of the pets-be backend server. 95 | serveMux.Handle("/", internal.TraceHandler(handler)) 96 | serveMux.Handle(grpcreflect.NewHandlerV1(grpcreflect.NewStaticReflector(petstorev2connect.PetServiceName))) 97 | 98 | listeners = append(listeners, listener) 99 | svrs = append(svrs, &http.Server{ 100 | Addr: ":http", 101 | Handler: h2c.NewHandler(serveMux, &http2.Server{}), 102 | ReadHeaderTimeout: 15 * time.Second, 103 | }) 104 | } 105 | 106 | // Start the HTTP servers for all three ports. 107 | grp, _ := errgroup.WithContext(context.Background()) 108 | for i := range svrs { 109 | listener := listeners[i] 110 | svr := svrs[i] 111 | grp.Go(func() error { 112 | err := svr.Serve(listener) 113 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 114 | return fmt.Errorf("server failed: %w", err) 115 | } 116 | return nil 117 | }) 118 | } 119 | if err := grp.Wait(); err != nil { 120 | log.Fatal(err) 121 | } 122 | } 123 | 124 | // h2cIfGRPCTransport is a round tripper that will use its configured 125 | // h2c transport for requests in the gRPC protocol. It will use 126 | // http.DefaultTransport for other requests. 127 | type h2cIfGRPCTransport struct { 128 | h2c http.RoundTripper 129 | } 130 | 131 | func (h h2cIfGRPCTransport) RoundTrip(request *http.Request) (*http.Response, error) { 132 | if request.Header.Get("Content-Type") == "application/grpc" || 133 | strings.HasPrefix(request.Header.Get("Content-Type"), "application/grpc+") { 134 | // For gRPC, we must use HTTP/2. 135 | return h.h2c.RoundTrip(request) 136 | } 137 | // Otherwise, we can use HTTP 1.1. 138 | return http.DefaultTransport.RoundTrip(request) 139 | } 140 | -------------------------------------------------------------------------------- /type_resolver.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "google.golang.org/protobuf/reflect/protoreflect" 19 | "google.golang.org/protobuf/reflect/protoregistry" 20 | "google.golang.org/protobuf/types/dynamicpb" 21 | ) 22 | 23 | // TypeResolver can resolve message and extension types and is used to instantiate 24 | // messages as needed for the middleware to serialize/de-serialize request and 25 | // response payloads. 26 | // 27 | // Implementations of this interface should be comparable, so they can be used as 28 | // map keys. Typical implementations are pointers to structs, which are suitable. 29 | type TypeResolver interface { 30 | protoregistry.MessageTypeResolver 31 | protoregistry.ExtensionTypeResolver 32 | } 33 | 34 | type fallbackResolver []TypeResolver 35 | 36 | func (f fallbackResolver) FindMessageByName(message protoreflect.FullName) (protoreflect.MessageType, error) { 37 | var lastErr error 38 | for _, res := range f { 39 | msgType, err := res.FindMessageByName(message) 40 | if err == nil { 41 | return msgType, nil 42 | } 43 | lastErr = err 44 | } 45 | if lastErr == nil { 46 | return nil, protoregistry.NotFound 47 | } 48 | return nil, lastErr 49 | } 50 | 51 | func (f fallbackResolver) FindMessageByURL(url string) (protoreflect.MessageType, error) { 52 | var lastErr error 53 | for _, res := range f { 54 | msgType, err := res.FindMessageByURL(url) 55 | if err == nil { 56 | return msgType, nil 57 | } 58 | lastErr = err 59 | } 60 | if lastErr == nil { 61 | return nil, protoregistry.NotFound 62 | } 63 | return nil, lastErr 64 | } 65 | 66 | func (f fallbackResolver) FindExtensionByName(field protoreflect.FullName) (protoreflect.ExtensionType, error) { 67 | var lastErr error 68 | for _, res := range f { 69 | extType, err := res.FindExtensionByName(field) 70 | if err == nil { 71 | return extType, nil 72 | } 73 | lastErr = err 74 | } 75 | if lastErr == nil { 76 | return nil, protoregistry.NotFound 77 | } 78 | return nil, lastErr 79 | } 80 | 81 | func (f fallbackResolver) FindExtensionByNumber(message protoreflect.FullName, field protoreflect.FieldNumber) (protoreflect.ExtensionType, error) { 82 | var lastErr error 83 | for _, res := range f { 84 | extType, err := res.FindExtensionByNumber(message, field) 85 | if err == nil { 86 | return extType, nil 87 | } 88 | lastErr = err 89 | } 90 | if lastErr == nil { 91 | return nil, protoregistry.NotFound 92 | } 93 | return nil, lastErr 94 | } 95 | 96 | func resolverForService(service protoreflect.ServiceDescriptor) TypeResolver { 97 | if canUseGlobalTypes(service) { 98 | return protoregistry.GlobalTypes 99 | } 100 | return resolverForFile(service.ParentFile()) 101 | } 102 | 103 | func resolverForFile(file protoreflect.FileDescriptor) TypeResolver { 104 | if file == nil { 105 | // Can't create a bespoke resolver for this file. 106 | return protoregistry.GlobalTypes 107 | } 108 | var files protoregistry.Files 109 | if err := addFileRecursive(file, &files); err != nil { 110 | // Failed to create a bespoke resolver for this file. 111 | return protoregistry.GlobalTypes 112 | } 113 | // Even with a bespoke resolver, we'll still fall back to global 114 | // types to help satisfy extensions and message types inside of 115 | // google.protobuf.Any messages (such as error details). 116 | return fallbackResolver{dynamicpb.NewTypes(&files), protoregistry.GlobalTypes} 117 | } 118 | 119 | func addFileRecursive(file protoreflect.FileDescriptor, files *protoregistry.Files) error { 120 | if _, err := files.FindFileByPath(file.Path()); err == nil { 121 | // already registered 122 | return nil 123 | } 124 | if err := files.RegisterFile(file); err != nil { 125 | return err 126 | } 127 | imports := file.Imports() 128 | for i, length := 0, imports.Len(); i < length; i++ { 129 | depFile := imports.Get(i).FileDescriptor 130 | if err := addFileRecursive(depFile, files); err != nil { 131 | return err 132 | } 133 | } 134 | return nil 135 | } 136 | 137 | func canUseGlobalTypes(svcDesc protoreflect.ServiceDescriptor) bool { 138 | file := svcDesc.ParentFile() 139 | if file == nil { 140 | return false 141 | } 142 | registeredFile, err := protoregistry.GlobalFiles.FindFileByPath(file.Path()) 143 | if err != nil || registeredFile != file { 144 | return false 145 | } 146 | // It is possible for code to register files in the global registry but fail to 147 | // register corresponding types in protoregistry.GlobalTypes. So before we return 148 | // true, make sure that all of the service's request and response messages can 149 | // actually be satisfied by the global types registry. 150 | methods := svcDesc.Methods() 151 | for i, length := 0, methods.Len(); i < length; i++ { 152 | methodDesc := methods.Get(i) 153 | if _, err := protoregistry.GlobalTypes.FindMessageByName(methodDesc.Input().FullName()); err != nil { 154 | return false 155 | } 156 | if _, err := protoregistry.GlobalTypes.FindMessageByName(methodDesc.Output().FullName()); err != nil { 157 | return false 158 | } 159 | } 160 | return true 161 | } 162 | -------------------------------------------------------------------------------- /protocol_grpc_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "math" 21 | "net/http/httptest" 22 | "testing" 23 | "testing/quick" 24 | "time" 25 | "unicode/utf8" 26 | 27 | "connectrpc.com/connect" 28 | "github.com/google/go-cmp/cmp" 29 | "github.com/stretchr/testify/assert" 30 | "github.com/stretchr/testify/require" 31 | "google.golang.org/protobuf/testing/protocmp" 32 | "google.golang.org/protobuf/types/known/wrapperspb" 33 | ) 34 | 35 | func TestGRPCErrorWriter(t *testing.T) { 36 | t.Parallel() 37 | 38 | err := fmt.Errorf("test error: %s", "Hello, 世界") 39 | cerr := connect.NewWireError(connect.CodeUnauthenticated, err) 40 | rec := httptest.NewRecorder() 41 | grpcWriteEndToTrailers(&responseEnd{err: cerr}, rec.Header()) 42 | 43 | assert.Equal(t, "16", rec.Header().Get("Grpc-Status")) 44 | assert.Equal(t, "test error: Hello, %E4%B8%96%E7%95%8C", rec.Header().Get("Grpc-Message")) 45 | // if error has no details, no need to generate this response trailer 46 | assert.Equal(t, "", rec.Header().Get("Grpc-Status-Details-Bin")) 47 | assert.Empty(t, rec.Body.Bytes()) 48 | 49 | got := grpcExtractErrorFromTrailer(rec.Header()) 50 | compareErrors(t, cerr, got) 51 | 52 | // Now again, but this time an error with details 53 | errDetail, err := connect.NewErrorDetail(&wrapperspb.StringValue{Value: "foo"}) 54 | require.NoError(t, err) 55 | cerr.AddDetail(errDetail) 56 | rec = httptest.NewRecorder() 57 | grpcWriteEndToTrailers(&responseEnd{err: cerr}, rec.Header()) 58 | 59 | assert.Equal(t, "16", rec.Header().Get("Grpc-Status")) 60 | assert.Equal(t, "test error: Hello, %E4%B8%96%E7%95%8C", rec.Header().Get("Grpc-Message")) 61 | assert.Equal(t, "CBASGXRlc3QgZXJyb3I6IEhlbGxvLCDkuJbnlYwaOAovdHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUucHJvdG9idWYuU3RyaW5nVmFsdWUSBQoDZm9v", rec.Header().Get("Grpc-Status-Details-Bin")) 62 | assert.Empty(t, rec.Body.Bytes()) 63 | 64 | got = grpcExtractErrorFromTrailer(rec.Header()) 65 | compareErrors(t, cerr, got) 66 | } 67 | 68 | func TestGRPCEncodeTimeoutQuick(t *testing.T) { 69 | t.Parallel() 70 | // Ensure that the error case is actually unreachable. 71 | encode := func(d time.Duration) bool { 72 | v := grpcEncodeTimeout(d) 73 | return v != "" 74 | } 75 | if err := quick.Check(encode, nil); err != nil { 76 | t.Error(err) 77 | } 78 | } 79 | 80 | func TestGRPCPercentEncodingQuick(t *testing.T) { 81 | t.Parallel() 82 | roundtrip := func(input string) bool { 83 | if !utf8.ValidString(input) { 84 | return true 85 | } 86 | encoded := grpcPercentEncode(input) 87 | decoded, _ := grpcPercentDecode(encoded) 88 | return decoded == input 89 | } 90 | if err := quick.Check(roundtrip, nil /* config */); err != nil { 91 | t.Error(err) 92 | } 93 | } 94 | 95 | func TestGRPCPercentEncoding(t *testing.T) { 96 | t.Parallel() 97 | for _, input := range []string{ 98 | "foo", 99 | "foo bar", 100 | `foo%bar`, 101 | "fiancée", 102 | } { 103 | t.Run(input, func(t *testing.T) { 104 | t.Parallel() 105 | assert.True(t, utf8.ValidString(input), "input invalid UTF-8") 106 | encoded := grpcPercentEncode(input) 107 | t.Logf("%q encoded as %q", input, encoded) 108 | decoded, _ := grpcPercentDecode(encoded) 109 | assert.Equal(t, decoded, input) 110 | }) 111 | } 112 | } 113 | 114 | func TestGRPCDecodeTimeout(t *testing.T) { 115 | t.Parallel() 116 | _, err := grpcDecodeTimeout("") 117 | require.ErrorIs(t, err, errNoTimeout) 118 | 119 | _, err = grpcDecodeTimeout("foo") 120 | assert.Error(t, err) //nolint:testifylint 121 | _, err = grpcDecodeTimeout("12xS") 122 | assert.Error(t, err) //nolint:testifylint 123 | _, err = grpcDecodeTimeout("999999999n") // 9 digits 124 | assert.Error(t, err) //nolint:testifylint 125 | assert.False(t, errors.Is(err, errNoTimeout)) //nolint:testifylint 126 | _, err = grpcDecodeTimeout("99999999H") // 8 digits but overflows time.Duration 127 | assert.ErrorIs(t, err, errNoTimeout) //nolint:testifylint 128 | 129 | duration, err := grpcDecodeTimeout("45S") 130 | require.NoError(t, err) 131 | assert.Equal(t, 45*time.Second, duration) 132 | 133 | const long = "99999999S" 134 | duration, err = grpcDecodeTimeout(long) // 8 digits, shouldn't overflow 135 | require.NoError(t, err) 136 | assert.Equal(t, 99999999*time.Second, duration) 137 | } 138 | 139 | func TestGRPCEncodeTimeout(t *testing.T) { 140 | t.Parallel() 141 | timeout := grpcEncodeTimeout(time.Hour + time.Second) 142 | assert.Equal(t, "3601000m", timeout) 143 | timeout = grpcEncodeTimeout(time.Duration(math.MaxInt64)) 144 | assert.Equal(t, "2562047H", timeout) 145 | timeout = grpcEncodeTimeout(-1 * time.Hour) 146 | assert.Equal(t, "0n", timeout) 147 | } 148 | 149 | func compareErrors(t *testing.T, got, want *connect.Error) { 150 | t.Helper() 151 | assert.Equal(t, want.Code(), got.Code(), "wrong code") 152 | assert.Equal(t, want.Message(), got.Message(), "wrong message") 153 | wantDetails := want.Details() 154 | gotDetails := got.Details() 155 | if !assert.Len(t, wantDetails, len(gotDetails), "wrong number of details") { 156 | return 157 | } 158 | for i, wantDetail := range wantDetails { 159 | gotDetail := gotDetails[i] 160 | if assert.Equal(t, wantDetail.Type(), gotDetail.Type(), "wrong detail type at index %d", i) { 161 | wantedMsg, err := wantDetail.Value() 162 | require.NoError(t, err, "failed to deserialize wanted detail at index %d", i) 163 | gotMsg, err := gotDetail.Value() 164 | require.NoError(t, err, "failed to deserialize got detail at index %d", i) 165 | require.Empty(t, cmp.Diff(wantedMsg, gotMsg, protocmp.Transform())) 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /internal/examples/pets/internal/trace.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package internal 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "fmt" 21 | "io" 22 | "net/http" 23 | "strings" 24 | "sync/atomic" 25 | ) 26 | 27 | var idSource atomic.Int64 28 | 29 | func TraceHandler(handler http.Handler) http.Handler { 30 | return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { 31 | reqID := idSource.Add(1) 32 | trc := &tracer{reqID: reqID} 33 | if request.RemoteAddr != "" { 34 | trc.traceReq("(from %s)", request.RemoteAddr) 35 | } 36 | traceRequest(trc, request) 37 | request.Body = &traceReader{r: request.Body, trace: trc.traceReq} 38 | request = request.WithContext(context.WithValue(request.Context(), traceRequestID{}, reqID)) 39 | tw := &traceWriter{w: responseWriter, trace: trc.traceResp} 40 | defer tw.traceTrailers() 41 | handler.ServeHTTP(tw, request) 42 | }) 43 | } 44 | 45 | func TraceTransport(transport http.RoundTripper) http.RoundTripper { 46 | return traceTransport{transport} 47 | } 48 | 49 | type traceTransport struct { 50 | transport http.RoundTripper 51 | } 52 | 53 | type traceRequestID struct{} 54 | 55 | func (t traceTransport) RoundTrip(request *http.Request) (*http.Response, error) { 56 | reqID, ok := request.Context().Value(traceRequestID{}).(int64) 57 | if !ok { 58 | reqID = idSource.Add(1) 59 | } 60 | trc := &tracer{reqID: reqID, prefix: " "} 61 | traceRequest(trc, request) 62 | request.Body = &traceReader{r: request.Body, trace: trc.traceReq} 63 | resp, err := t.transport.RoundTrip(request) 64 | if err != nil { 65 | trc.traceResp("ERROR: %v", err) 66 | return nil, err 67 | } 68 | trc.traceResp("%s", resp.Status) 69 | traceHeaders(trc.traceResp, resp.Header) 70 | resp.Body = &traceReader{ 71 | r: resp.Body, 72 | trace: trc.traceResp, 73 | onEnd: func() { 74 | traceTrailers(trc.traceResp, resp.Trailer) 75 | }, 76 | } 77 | return resp, nil 78 | } 79 | 80 | func traceRequest(trc *tracer, req *http.Request) { 81 | var queryString string 82 | if req.URL.RawQuery != "" { 83 | queryString = "?" + req.URL.RawQuery 84 | } 85 | scheme := req.URL.Scheme 86 | if scheme == "" { 87 | scheme = "http" 88 | } 89 | trc.traceReq("%s %s://%s%s%s %s", req.Method, scheme, req.Host, req.URL.Path, queryString, req.Proto) 90 | traceHeaders(trc.traceReq, req.Header) 91 | trc.traceReq("") 92 | } 93 | 94 | func traceHeaders(trace func(string, ...any), header http.Header) { 95 | for k, v := range header { 96 | for _, val := range v { 97 | trace("%s: %s", k, val) 98 | } 99 | } 100 | } 101 | 102 | func traceTrailers(trace func(string, ...any), trailer http.Header) { 103 | if len(trailer) == 0 { 104 | return 105 | } 106 | trace("") 107 | traceHeaders(trace, trailer) 108 | } 109 | 110 | type traceReader struct { 111 | r io.ReadCloser 112 | trace func(string, ...any) 113 | done bool 114 | onEnd func() 115 | } 116 | 117 | func (t *traceReader) Read(p []byte) (n int, err error) { 118 | n, err = t.r.Read(p) 119 | if n > 0 { 120 | t.trace("(%d bytes)", n) 121 | } 122 | if err != nil && !t.done { 123 | t.done = true 124 | if errors.Is(err, io.EOF) { 125 | t.trace("(EOF)") 126 | if t.onEnd != nil { 127 | t.onEnd() 128 | } 129 | } else { 130 | t.trace("(%v!)", err) 131 | } 132 | } 133 | return n, err 134 | } 135 | 136 | func (t *traceReader) Close() error { 137 | return t.r.Close() 138 | } 139 | 140 | type traceWriter struct { 141 | w http.ResponseWriter 142 | trace func(string, ...any) 143 | wroteHeaders bool 144 | trailersSnapshot []string 145 | done bool 146 | } 147 | 148 | func (t *traceWriter) Header() http.Header { 149 | return t.w.Header() 150 | } 151 | 152 | func (t *traceWriter) Write(bytes []byte) (n int, err error) { 153 | if !t.wroteHeaders { 154 | t.WriteHeader(http.StatusOK) 155 | } 156 | n, err = t.w.Write(bytes) 157 | if n > 0 { 158 | t.trace("(%d bytes)", n) 159 | } 160 | if err != nil && !t.done { 161 | t.done = true 162 | t.trace("(%v!)", err) 163 | } 164 | return n, err 165 | } 166 | 167 | func (t *traceWriter) WriteHeader(statusCode int) { 168 | if t.wroteHeaders { 169 | return 170 | } 171 | t.wroteHeaders = true 172 | trailers := t.Header().Values("Trailer") 173 | t.trailersSnapshot = make([]string, 0, len(trailers)) 174 | for _, trailer := range trailers { 175 | for _, k := range strings.Split(trailer, ",") { 176 | t.trailersSnapshot = append(t.trailersSnapshot, strings.TrimSpace(k)) 177 | } 178 | } 179 | t.w.WriteHeader(statusCode) 180 | t.trace("%d %s", statusCode, http.StatusText(statusCode)) 181 | traceHeaders(t.trace, t.Header()) 182 | t.trace("") 183 | } 184 | 185 | func (t *traceWriter) traceTrailers() { 186 | if t.done { 187 | return 188 | } 189 | trailers := http.Header{} 190 | for k, v := range t.Header() { 191 | if strings.HasPrefix(k, http.TrailerPrefix) { 192 | trailers[strings.TrimPrefix(k, http.TrailerPrefix)] = v 193 | } 194 | } 195 | for _, k := range t.trailersSnapshot { 196 | vals := t.Header().Values(k) 197 | if len(vals) > 0 { 198 | trailers[k] = vals 199 | } 200 | } 201 | traceTrailers(t.trace, trailers) 202 | } 203 | 204 | func (t *traceWriter) Flush() { 205 | if flusher, ok := t.w.(http.Flusher); ok { 206 | flusher.Flush() 207 | } 208 | } 209 | 210 | type tracer struct { 211 | reqID int64 212 | prefix string 213 | } 214 | 215 | func (trc *tracer) traceReq(msg string, args ...interface{}) { 216 | fmt.Printf("%s#%04d>> %s\n", trc.prefix, trc.reqID, fmt.Sprintf(msg, args...)) 217 | } 218 | 219 | func (trc *tracer) traceResp(msg string, args ...interface{}) { 220 | fmt.Printf("%s#%04d<< %s\n", trc.prefix, trc.reqID, fmt.Sprintf(msg, args...)) 221 | } 222 | -------------------------------------------------------------------------------- /regress_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard_test 16 | 17 | import ( 18 | "context" 19 | "crypto/tls" 20 | "fmt" 21 | "net" 22 | "net/http" 23 | "net/http/httptest" 24 | "net/http/httputil" 25 | "net/url" 26 | "testing" 27 | 28 | "buf.build/gen/go/connectrpc/eliza/connectrpc/go/connectrpc/eliza/v1/elizav1connect" 29 | elizav1 "buf.build/gen/go/connectrpc/eliza/protocolbuffers/go/connectrpc/eliza/v1" 30 | "connectrpc.com/connect" 31 | "connectrpc.com/vanguard" 32 | "connectrpc.com/vanguard/internal/gen/vanguard/test/v1/testv1connect" 33 | "github.com/stretchr/testify/require" 34 | "golang.org/x/net/http2" 35 | "golang.org/x/net/http2/h2c" 36 | "google.golang.org/protobuf/reflect/protoreflect" 37 | "google.golang.org/protobuf/reflect/protoregistry" 38 | ) 39 | 40 | // Reproducer test case provided in https://github.com/connectrpc/vanguard-go/issues/148 41 | func TestIssue148(t *testing.T) { 42 | t.Parallel() 43 | 44 | // GRPC-web server (process 1) 45 | mux := http.NewServeMux() 46 | mux.Handle(elizav1connect.NewElizaServiceHandler(&handler{t: t})) 47 | grpcwebServer := httptest.NewUnstartedServer( 48 | h2c.NewHandler(mux, &http2.Server{}), 49 | ) 50 | grpcwebServer.EnableHTTP2 = true 51 | grpcwebServer.Start() 52 | t.Cleanup(grpcwebServer.Close) 53 | 54 | // Vanguard based proxy (process 2) 55 | webURL, err := url.Parse(grpcwebServer.URL) 56 | require.NoError(t, err) 57 | proxy := httputil.NewSingleHostReverseProxy(webURL) 58 | proxy.FlushInterval = -1 // shouldn't be necessary 59 | schema, err := protoregistry.GlobalFiles.FindDescriptorByName("connectrpc.eliza.v1.ElizaService") 60 | require.NoError(t, err) 61 | schemaService, ok := schema.(protoreflect.ServiceDescriptor) 62 | require.True(t, ok) 63 | transcoder, err := vanguard.NewTranscoder( 64 | []*vanguard.Service{vanguard.NewServiceWithSchema(schemaService, proxy)}, 65 | vanguard.WithDefaultServiceOptions( 66 | vanguard.WithTargetProtocols(vanguard.ProtocolGRPCWeb), 67 | vanguard.WithTargetCodecs(vanguard.CodecProto), 68 | ), 69 | vanguard.WithUnknownHandler(proxy), 70 | ) 71 | require.NoError(t, err) 72 | vanguardServer := httptest.NewUnstartedServer( 73 | h2c.NewHandler( 74 | transcoder, 75 | &http2.Server{}, 76 | ), 77 | ) 78 | vanguardServer.EnableHTTP2 = true 79 | vanguardServer.Start() 80 | t.Cleanup(vanguardServer.Close) 81 | 82 | clientCases := map[string][]connect.ClientOption{ 83 | "proto": {connect.WithGRPC()}, 84 | "json": {connect.WithGRPC(), connect.WithProtoJSON()}, 85 | } 86 | for caseName := range clientCases { 87 | clientOpts := clientCases[caseName] 88 | t.Run(caseName, func(t *testing.T) { 89 | t.Parallel() 90 | 91 | // grpc client using h2c (process 3) 92 | h2cClient := &http.Client{ 93 | Transport: &http2.Transport{ 94 | AllowHTTP: true, 95 | DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { 96 | return (&net.Dialer{}).DialContext(ctx, network, addr) 97 | }, 98 | }, 99 | } 100 | 101 | client := elizav1connect.NewElizaServiceClient(h2cClient, vanguardServer.URL, clientOpts...) 102 | unaryResp, err := client.Say(context.Background(), connect.NewRequest(&elizav1.SayRequest{Sentence: "foo"})) 103 | require.NoError(t, err) 104 | require.Equal(t, "echo foo", unaryResp.Msg.Sentence) 105 | 106 | serverStream, err := client.Introduce(context.Background(), connect.NewRequest(&elizav1.IntroduceRequest{Name: "foo"})) 107 | require.NoError(t, err) 108 | var responses []string 109 | for serverStream.Receive() { 110 | responses = append(responses, serverStream.Msg().Sentence) 111 | } 112 | require.NoError(t, serverStream.Err()) 113 | require.Equal(t, []string{"hi", "hi again"}, responses) 114 | }) 115 | } 116 | } 117 | 118 | type handler struct { 119 | elizav1connect.UnimplementedElizaServiceHandler 120 | t testing.TB 121 | } 122 | 123 | func (h *handler) Say(_ context.Context, req *connect.Request[elizav1.SayRequest]) (*connect.Response[elizav1.SayResponse], error) { 124 | if req.Peer().Protocol != connect.ProtocolGRPCWeb { 125 | return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("only accepts grpc-web, got %q", req.Peer().Protocol)) 126 | } 127 | return connect.NewResponse(&elizav1.SayResponse{Sentence: "echo " + req.Msg.Sentence}), nil 128 | } 129 | 130 | func (h *handler) Introduce(_ context.Context, req *connect.Request[elizav1.IntroduceRequest], resp *connect.ServerStream[elizav1.IntroduceResponse]) error { 131 | if req.Peer().Protocol != connect.ProtocolGRPCWeb { 132 | return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("only accepts grpc-web, got %q", req.Peer().Protocol)) 133 | } 134 | _ = resp.Send(&elizav1.IntroduceResponse{Sentence: "hi"}) 135 | _ = resp.Send(&elizav1.IntroduceResponse{Sentence: "hi again"}) 136 | return nil 137 | } 138 | 139 | func TestPanicOnProxyError(t *testing.T) { 140 | t.Parallel() 141 | rpcHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 142 | w.WriteHeader(http.StatusBadGateway) 143 | }) 144 | service := vanguard.NewService(testv1connect.LibraryServiceName, rpcHandler, vanguard.WithTargetProtocols(vanguard.ProtocolGRPC), 145 | vanguard.WithTargetCodecs(vanguard.CodecProto)) 146 | handler, err := vanguard.NewTranscoder([]*vanguard.Service{service}) 147 | require.NoError(t, err) 148 | 149 | expectedCode := http.StatusServiceUnavailable 150 | req := httptest.NewRequest(http.MethodPost, "/vanguard.test.v1.LibraryService/GetBook", http.NoBody) 151 | req.Proto = "HTTP/1.1" 152 | req.ProtoMajor, req.ProtoMinor = 1, 1 153 | req.Header.Add("Content-Type", "application/proto") 154 | req.Header.Add("Connect-Protocol-Version", "1") 155 | respWriter := httptest.NewRecorder() 156 | handler.ServeHTTP(respWriter, req) 157 | resp := respWriter.Result() 158 | err = resp.Body.Close() 159 | require.NoError(t, err) 160 | require.Equal(t, expectedCode, resp.StatusCode) 161 | } 162 | -------------------------------------------------------------------------------- /internal/proto/vanguard/test/v1/test.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard.test.v1; 18 | 19 | import "google/protobuf/duration.proto"; 20 | import "google/protobuf/field_mask.proto"; 21 | import "google/protobuf/struct.proto"; 22 | import "google/protobuf/timestamp.proto"; 23 | import "google/protobuf/wrappers.proto"; 24 | 25 | message ParameterValues { 26 | // scalar types 27 | double double_value = 1; 28 | float float_value = 2; 29 | int32 int32_value = 3; 30 | int64 int64_value = 4; 31 | uint32 uint32_value = 5; 32 | uint64 uint64_value = 6; 33 | sint32 sint32_value = 7; 34 | sint64 sint64_value = 8; 35 | fixed32 fixed32_value = 9; 36 | fixed64 fixed64_value = 10; 37 | sfixed32 sfixed32_value = 11; 38 | sfixed64 sfixed64_value = 12; 39 | bool bool_value = 13; 40 | string string_value = 14; 41 | bytes bytes_value = 15; 42 | 43 | // scalar wrappers 44 | google.protobuf.Timestamp timestamp = 16; 45 | google.protobuf.Duration duration = 17; 46 | google.protobuf.BoolValue bool_value_wrapper = 18; 47 | google.protobuf.Int32Value int32_value_wrapper = 19; 48 | google.protobuf.Int64Value int64_value_wrapper = 20; 49 | google.protobuf.UInt32Value uint32_value_wrapper = 21; 50 | google.protobuf.UInt64Value uint64_value_wrapper = 22; 51 | google.protobuf.FloatValue float_value_wrapper = 23; 52 | google.protobuf.DoubleValue double_value_wrapper = 24; 53 | google.protobuf.BytesValue bytes_value_wrapper = 25; 54 | google.protobuf.StringValue string_value_wrapper = 26; 55 | google.protobuf.FieldMask field_mask = 27; 56 | 57 | // enum types 58 | enum Enum { 59 | ENUM_UNSPECIFIED = 0; 60 | ENUM_VALUE = 1; 61 | } 62 | Enum enum_value = 28; 63 | 64 | // complex types 65 | repeated Enum enum_list = 29; 66 | repeated double double_list = 30; 67 | repeated google.protobuf.DoubleValue double_value_list = 31; 68 | oneof oneof { 69 | double oneof_double_value = 33; 70 | google.protobuf.DoubleValue oneof_double_value_wrapper = 34; 71 | Enum oneof_enum_value = 35; 72 | } 73 | message Nested { 74 | double double_value = 1; 75 | google.protobuf.DoubleValue double_value_wrapper = 2; 76 | enum Enum { 77 | ENUM_UNSPECIFIED = 0; 78 | ENUM_VALUE = 1; 79 | } 80 | Enum enum_value = 3; 81 | } 82 | Nested nested = 36; 83 | ParameterValues recursive = 37; 84 | 85 | // unsupported 86 | map string_map = 38; 87 | map string_value_map = 39; 88 | map enum_map = 40; 89 | map nested_map = 41; 90 | google.protobuf.Struct struct_value = 42; 91 | google.protobuf.Value value = 43; 92 | repeated ParameterValues recursive_list = 44; 93 | } 94 | 95 | message AllTypes { 96 | // scalar types 97 | double double_value = 1; 98 | float float_value = 2; 99 | int32 int32_value = 3; 100 | int64 int64_value = 4; 101 | uint32 uint32_value = 5; 102 | uint64 uint64_value = 6; 103 | sint32 sint32_value = 7; 104 | sint64 sint64_value = 8; 105 | fixed32 fixed32_value = 9; 106 | fixed64 fixed64_value = 10; 107 | sfixed32 sfixed32_value = 11; 108 | sfixed64 sfixed64_value = 12; 109 | bool bool_value = 13; 110 | string string_value = 14; 111 | bytes bytes_value = 15; 112 | 113 | // repeated types 114 | repeated double double_list = 16; 115 | repeated float float_list = 17; 116 | repeated int32 int32_list = 18; 117 | repeated int64 int64_list = 19; 118 | repeated uint32 uint32_list = 20; 119 | repeated uint64 uint64_list = 21; 120 | repeated sint32 sint32_list = 22; 121 | repeated sint64 sint64_list = 23; 122 | repeated fixed32 fixed32_list = 24; 123 | repeated fixed64 fixed64_list = 25; 124 | repeated sfixed32 sfixed32_list = 26; 125 | repeated sfixed64 sfixed64_list = 27; 126 | repeated bool bool_list = 28; 127 | repeated string string_list = 29; 128 | repeated bytes bytes_list = 30; 129 | 130 | // map key types 131 | map int32_to_string_map = 31; 132 | map int64_to_string_map = 32; 133 | map uint32_to_string_map = 33; 134 | map uint64_to_string_map = 34; 135 | map sint32_to_string_map = 35; 136 | map sint64_to_string_map = 36; 137 | map fixed32_to_string_map = 37; 138 | map fixed64_to_string_map = 38; 139 | map sfixed32_to_string_map = 39; 140 | map sfixed64_to_string_map = 40; 141 | map bool_to_string_map = 41; 142 | map string_to_string_map = 42; 143 | 144 | // map value types 145 | map double_map = 43; 146 | map float_map = 44; 147 | map int32_map = 45; 148 | map int64_map = 46; 149 | map uint32_map = 47; 150 | map uint64_map = 48; 151 | map sint32_map = 49; 152 | map sint64_map = 50; 153 | map fixed32_map = 51; 154 | map fixed64_map = 52; 155 | map sfixed32_map = 53; 156 | map sfixed64_map = 54; 157 | map bool_map = 55; 158 | map string_map = 56; 159 | map bytes_map = 57; 160 | 161 | // explicit presence types 162 | optional double opt_double_value = 58; 163 | optional float opt_float_value = 59; 164 | optional int32 opt_int32_value = 60; 165 | optional int64 opt_int64_value = 61; 166 | optional uint32 opt_uint32_value = 62; 167 | optional uint64 opt_uint64_value = 63; 168 | optional sint32 opt_sint32_value = 64; 169 | optional sint64 opt_sint64_value = 65; 170 | optional fixed32 opt_fixed32_value = 66; 171 | optional fixed64 opt_fixed64_value = 67; 172 | optional sfixed32 opt_sfixed32_value = 68; 173 | optional sfixed64 opt_sfixed64_value = 69; 174 | optional bool opt_bool_value = 70; 175 | optional string opt_string_value = 71; 176 | optional bytes opt_bytes_value = 72; 177 | 178 | // named types 179 | enum Enum { 180 | ENUM_UNSPECIFIED = 0; 181 | ENUM_ONE = 1; 182 | ENUM_TWO = 2; 183 | } 184 | AllTypes msg_value = 73; 185 | Enum enum_value = 74; 186 | optional AllTypes opt_msg_value = 75; 187 | optional Enum opt_enum_value = 76; 188 | repeated AllTypes msg_list = 77; 189 | repeated Enum enum_list = 78; 190 | map msg_map = 79; 191 | map enum_map = 80; 192 | 193 | // oneof 194 | oneof option { 195 | double double_option = 81; 196 | float float_option = 82; 197 | int32 int32_option = 83; 198 | int64 int64_option = 84; 199 | uint32 uint32_option = 85; 200 | uint64 uint64_option = 86; 201 | sint32 sint32_option = 87; 202 | sint64 sint64_option = 88; 203 | fixed32 fixed32_option = 89; 204 | fixed64 fixed64_option = 90; 205 | sfixed32 sfixed32_option = 91; 206 | sfixed64 sfixed64_option = 92; 207 | bool bool_option = 93; 208 | string string_option = 94; 209 | bytes bytes_option = 95; 210 | AllTypes msg_option = 96; 211 | Enum enum_option = 97; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /vanguard_examples_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard_test 16 | 17 | import ( 18 | "bytes" 19 | "context" 20 | "errors" 21 | "io" 22 | "log" 23 | "net/http" 24 | "net/http/httptest" 25 | "os" 26 | "regexp" 27 | "strconv" 28 | "strings" 29 | "time" 30 | 31 | "connectrpc.com/connect" 32 | "connectrpc.com/vanguard" 33 | testv1 "connectrpc.com/vanguard/internal/gen/vanguard/test/v1" 34 | "connectrpc.com/vanguard/internal/gen/vanguard/test/v1/testv1connect" 35 | "google.golang.org/protobuf/encoding/protojson" 36 | "google.golang.org/protobuf/proto" 37 | "google.golang.org/protobuf/types/known/timestamppb" 38 | ) 39 | 40 | func Example_restClientToRpcServer() { 41 | // This example shows Vanguard adding REST support to an RPC server built 42 | // with Connect. (To add REST, gRPC-Web, and Connect support to servers built 43 | // with grpc-go, use the connectrpc.com/vanguard/vanguardgrpc sub-package.) 44 | logger := log.New(os.Stdout, "" /* prefix */, 0 /* flags */) 45 | 46 | // libraryRPC is an implementation of the testv1connect.LibraryService RPC 47 | // server. It's a pure RPC server, without any hand-written translation to or 48 | // from RESTful semantics. 49 | svc := &libraryRPC{} 50 | rpcRoute, rpcHandler := testv1connect.NewLibraryServiceHandler(svc) 51 | 52 | // Using Vanguard, the server can also accept RESTful requests. The Vanguard 53 | // Transcoder handles both REST and RPC traffic, so there's no need to mount 54 | // the RPC-only handler. 55 | services := []*vanguard.Service{vanguard.NewService(rpcRoute, rpcHandler)} 56 | transcoder, err := vanguard.NewTranscoder(services) 57 | if err != nil { 58 | logger.Println(err) 59 | return 60 | } 61 | 62 | // We can use any server that works with http.Handlers. Since this is a 63 | // testable example, we're using httptest. 64 | server := httptest.NewServer(transcoder) 65 | defer server.Close() 66 | 67 | // With the server running, we can make a RESTful call. 68 | client := server.Client() 69 | book := &testv1.Book{ 70 | Title: "2001: A Space Odyssey", 71 | Author: "Arthur C. Clarke", 72 | Description: "A space voyage to Jupiter awakens the crew's intelligence.", 73 | Labels: map[string]string{ 74 | "genre": "science fiction", 75 | }, 76 | } 77 | body, err := protojson.Marshal(book) 78 | if err != nil { 79 | logger.Println(err) 80 | return 81 | } 82 | 83 | req, err := http.NewRequestWithContext( 84 | context.Background(), http.MethodPost, 85 | server.URL+"/v1/shelves/top/books", 86 | bytes.NewReader(body), 87 | ) 88 | if err != nil { 89 | logger.Println(err) 90 | return 91 | } 92 | req.Header.Set("Content-Type", "application/json") 93 | req.URL.RawQuery = "book_id=2&request_id=123" 94 | 95 | rsp, err := client.Do(req) 96 | if err != nil { 97 | logger.Println(err) 98 | return 99 | } 100 | defer rsp.Body.Close() 101 | logger.Println(rsp.Status) 102 | logger.Println(rsp.Header.Get("Content-Type")) 103 | 104 | body, err = io.ReadAll(rsp.Body) 105 | if err != nil { 106 | logger.Println(err) 107 | return 108 | } 109 | if err := protojson.Unmarshal(body, book); err != nil { 110 | logger.Println(err) 111 | return 112 | } 113 | logger.Println(book.GetAuthor()) 114 | // Output: 200 OK 115 | // application/json 116 | // Arthur C. Clarke 117 | } 118 | 119 | func Example_rpcClientToRestServer() { 120 | // This example shows Vanguard adding RPC support to an REST server. This 121 | // lets organizations use RPC clients in new codebases without rewriting 122 | // existing REST services. 123 | logger := log.New(os.Stdout, "" /* prefix */, 0 /* flags */) 124 | 125 | // libraryREST is an http.Handler that implements a RESTful server. The 126 | // implementation doesn't use Protobuf or RPC directly. 127 | restHandler := &libraryREST{} 128 | 129 | // Using Vanguard, the server can also accept RPC traffic. The Vanguard 130 | // Transcoder handles both REST and RPC traffic, so there's no need to mount 131 | // the REST-only handler. 132 | services := []*vanguard.Service{vanguard.NewService( 133 | testv1connect.LibraryServiceName, 134 | restHandler, 135 | // This tells vanguard that the service implementation only supports REST. 136 | vanguard.WithTargetProtocols(vanguard.ProtocolREST), 137 | )} 138 | transcoder, err := vanguard.NewTranscoder(services) 139 | if err != nil { 140 | logger.Println(err) 141 | return 142 | } 143 | 144 | // We can serve RPC and REST traffic using any server that works with 145 | // http.Handlers. Since this is a testable example, we're using httptest. 146 | server := httptest.NewServer(transcoder) 147 | defer server.Close() 148 | 149 | // With the server running, we can make an RPC call using a generated client. 150 | client := testv1connect.NewLibraryServiceClient(server.Client(), server.URL) 151 | rsp, err := client.GetBook( 152 | context.Background(), 153 | connect.NewRequest(&testv1.GetBookRequest{ 154 | Name: "shelves/top/books/123", 155 | }), 156 | ) 157 | if err != nil { 158 | logger.Println(err) 159 | return 160 | } 161 | logger.Println(rsp.Msg.GetDescription()) 162 | // Output: Have you seen Blade Runner? 163 | } 164 | 165 | type libraryREST struct { 166 | libraryRPC 167 | } 168 | 169 | func (s *libraryREST) ServeHTTP(rsp http.ResponseWriter, req *http.Request) { 170 | urlPath := []byte(req.URL.Path) 171 | ctx := req.Context() 172 | var msg proto.Message 173 | var err error 174 | switch req.Method { 175 | case http.MethodGet: 176 | switch { 177 | case regexp.MustCompile("/v1/shelves/.*/books/.*").Match(urlPath): 178 | got, gotErr := s.GetBook(ctx, connect.NewRequest(&testv1.GetBookRequest{ 179 | Name: req.URL.Path[len("/v1/"):], 180 | })) 181 | msg, err = got.Msg, gotErr 182 | default: 183 | err = connect.NewError(connect.CodeNotFound, errors.New("method not found")) 184 | } 185 | case http.MethodPost: 186 | switch { 187 | case regexp.MustCompile("/v1/shelves/.*").Match(urlPath): 188 | var book testv1.Book 189 | body, _ := io.ReadAll(req.Body) 190 | _ = protojson.Unmarshal(body, &book) 191 | got, gotErr := s.CreateBook(ctx, connect.NewRequest(&testv1.CreateBookRequest{ 192 | Parent: req.URL.Path[len("/v1/"):], 193 | BookId: req.URL.Query().Get("book_id"), 194 | Book: &book, 195 | RequestId: req.URL.Query().Get("request_id"), 196 | })) 197 | msg, err = got.Msg, gotErr 198 | default: 199 | err = connect.NewError(connect.CodeNotFound, errors.New("method not found")) 200 | } 201 | default: 202 | err = connect.NewError(connect.CodeNotFound, errors.New("method not found")) 203 | } 204 | rsp.Header().Set("Content-Type", "application/json") 205 | var body []byte 206 | if err != nil { 207 | code := connect.CodeInternal 208 | if ce := (*connect.Error)(nil); errors.As(err, &ce) { 209 | code = ce.Code() 210 | } 211 | body = []byte(`{"code":` + strconv.Itoa(int(code)) + 212 | `, "message":"` + err.Error() + `"}`) 213 | } else { 214 | body, _ = protojson.Marshal(msg) 215 | } 216 | rsp.WriteHeader(http.StatusOK) 217 | _, _ = rsp.Write(body) 218 | } 219 | 220 | type libraryRPC struct { 221 | testv1connect.UnimplementedLibraryServiceHandler 222 | } 223 | 224 | func (s *libraryRPC) GetBook(_ context.Context, req *connect.Request[testv1.GetBookRequest]) (*connect.Response[testv1.Book], error) { 225 | msg := req.Msg 226 | rsp := connect.NewResponse(&testv1.Book{ 227 | Name: msg.GetName(), 228 | Parent: strings.Join(strings.Split(msg.GetName(), "/")[:2], "/"), 229 | CreateTime: timestamppb.New(time.Date(1968, 1, 1, 0, 0, 0, 0, time.UTC)), 230 | Title: "Do Androids Dream of Electric Sheep?", 231 | Author: "Philip K. Dick", 232 | Description: "Have you seen Blade Runner?", 233 | Labels: map[string]string{ 234 | "genre": "science fiction", 235 | }, 236 | }) 237 | return rsp, nil 238 | } 239 | 240 | func (s *libraryRPC) CreateBook(_ context.Context, req *connect.Request[testv1.CreateBookRequest]) (*connect.Response[testv1.Book], error) { 241 | msg := req.Msg 242 | book := req.Msg.GetBook() 243 | rsp := connect.NewResponse(&testv1.Book{ 244 | Name: strings.Join([]string{msg.GetParent(), "books", msg.GetBookId()}, "/"), 245 | Parent: msg.GetParent(), 246 | CreateTime: timestamppb.New(time.Date(1968, 1, 1, 0, 0, 0, 0, time.UTC)), 247 | Title: book.GetTitle(), 248 | Author: book.GetAuthor(), 249 | Description: book.GetDescription(), 250 | Labels: book.GetLabels(), 251 | }) 252 | return rsp, nil 253 | } 254 | -------------------------------------------------------------------------------- /internal/proto/vanguard/test/v1/library.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard.test.v1; 18 | 19 | import "google/api/annotations.proto"; 20 | import "google/protobuf/empty.proto"; 21 | import "google/protobuf/field_mask.proto"; 22 | import "google/protobuf/timestamp.proto"; 23 | 24 | service LibraryService { 25 | // Gets a book. 26 | rpc GetBook(GetBookRequest) returns (Book) { 27 | option (google.api.http) = {get: "/v1/{name=shelves/*/books/*}"}; 28 | option idempotency_level = NO_SIDE_EFFECTS; 29 | } 30 | // Creates a book, and returns the new Book. 31 | rpc CreateBook(CreateBookRequest) returns (Book) { 32 | option (google.api.http) = { 33 | post: "/v1/{parent=shelves/*}/books" 34 | body: "book" 35 | }; 36 | } 37 | // Lists books in a shelf. 38 | rpc ListBooks(ListBooksRequest) returns (ListBooksResponse) { 39 | // List method maps to HTTP GET. 40 | option (google.api.http) = { 41 | // The `parent` captures the parent resource name, such as 42 | // "shelves/shelf1". 43 | get: "/v1/{parent=shelves/*}/books" 44 | }; 45 | } 46 | // Creates a shelf. 47 | rpc CreateShelf(CreateShelfRequest) returns (Shelf) { 48 | option (google.api.http) = { 49 | post: "/v1/shelves" 50 | body: "shelf" 51 | }; 52 | } 53 | // Lists shelves. 54 | rpc ListShelves(ListShelvesRequest) returns (ListShelvesResponse) { 55 | // List method maps to HTTP GET. 56 | option (google.api.http) = { 57 | get: "/v1/shelves" 58 | }; 59 | } 60 | // Updates a book. 61 | rpc UpdateBook(UpdateBookRequest) returns (Book) { 62 | // Update maps to HTTP PATCH. Resource name is mapped to a URL path. 63 | // Resource is contained in the HTTP request body. 64 | option (google.api.http) = { 65 | // Note the URL template variable which captures the resource name of the 66 | // book to update. 67 | patch: "/v1/{book.name=shelves/*/books/*}" 68 | body: "book" 69 | }; 70 | } 71 | // Deletes a book. 72 | rpc DeleteBook(DeleteBookRequest) returns (google.protobuf.Empty) { 73 | // Delete maps to HTTP DELETE. Resource name maps to the URL path. 74 | // There is no request body. 75 | option (google.api.http) = { 76 | // Note the URL template variable capturing the multi-segment name of the 77 | // book resource to be deleted, such as "shelves/shelf1/books/book2" 78 | delete: "/v1/{name=shelves/*/books/*}" 79 | }; 80 | } 81 | // Search books in a shelf. 82 | rpc SearchBooks(SearchBooksRequest) returns (SearchBooksResponse) { 83 | // Search over a multiple shelves with "shelves/-" wildcard. 84 | option (google.api.http) = {get: "/v2/{parent=shelves/*}/books:search"}; 85 | option idempotency_level = NO_SIDE_EFFECTS; 86 | } 87 | rpc MoveBooks(MoveBooksRequest) returns (MoveBooksResponse) { 88 | option (google.api.http) = { 89 | post: "/v2/{new_parent=shelves/*}/books:move" 90 | body: "books" 91 | }; 92 | } 93 | rpc CheckoutBooks(CheckoutBooksRequest) returns (Checkout) { 94 | option (google.api.http) = { 95 | post: "/v2/checkouts" 96 | body: "book_names" 97 | }; 98 | } 99 | rpc ReturnBooks(ReturnBooksRequest) returns (google.protobuf.Empty) { 100 | option (google.api.http) = { 101 | put: "/v2/checkouts/{id}" 102 | body:"*" 103 | }; 104 | } 105 | rpc GetCheckout(GetCheckoutRequest) returns (Checkout) { 106 | option (google.api.http) = { 107 | get: "/v2/checkouts/{id}" 108 | response_body: "books" 109 | }; 110 | option idempotency_level = NO_SIDE_EFFECTS; 111 | } 112 | rpc ListCheckouts(ListCheckoutsRequest) returns (ListCheckoutsResponse) { 113 | option (google.api.http) = { 114 | get: "/v2/{name=shelves/*/books/*}:checkouts" 115 | response_body: "checkouts" 116 | }; 117 | option idempotency_level = NO_SIDE_EFFECTS; 118 | } 119 | } 120 | 121 | message Book { 122 | // Resource name of the book. It must have the format of "shelves/*/books/*". 123 | // For example: "shelves/shelf1/books/book2". 124 | string name = 1; 125 | string parent = 2; 126 | google.protobuf.Timestamp create_time = 3; 127 | google.protobuf.Timestamp update_time = 4; 128 | // The title of the book. 129 | string title = 5; 130 | // The name of the author. 131 | string author = 6; 132 | // The description of the book. 133 | string description = 7; 134 | // Resource labels to represent user provided metadata. 135 | map labels = 8; 136 | } 137 | 138 | message GetBookRequest { 139 | // Resource name of a book. For example: "shelves/shelf1/books/book2". 140 | string name = 1; 141 | } 142 | 143 | message CreateBookRequest { 144 | // Resource name of the parent resource where to create the book. 145 | // For example: "shelves/shelf1". 146 | string parent = 1; 147 | // The book id to use for this book. 148 | string book_id = 3; 149 | // The Book resource to be created. Client must not set the `Book.name` field. 150 | Book book = 2; 151 | // A unique request ID for server to detect duplicated requests. 152 | string request_id = 4; 153 | } 154 | 155 | message ListBooksRequest { 156 | // The parent resource name, for example, "shelves/shelf1". 157 | string parent = 1; 158 | 159 | // The maximum number of items to return. 160 | int32 page_size = 2; 161 | 162 | // The next_page_token value returned from a previous List request, if any. 163 | string page_token = 3; 164 | } 165 | 166 | message ListBooksResponse { 167 | // The field name should match the noun "books" in the method name. There 168 | // will be a maximum number of items returned based on the page_size field 169 | // in the request. 170 | repeated Book books = 1; 171 | 172 | // Token to retrieve the next page of results, or empty if there are no 173 | // more results in the list. 174 | string next_page_token = 2; 175 | } 176 | 177 | message Shelf {} 178 | 179 | message CreateShelfRequest { 180 | Shelf shelf = 1; 181 | } 182 | 183 | message ListShelvesRequest { 184 | // The maximum number of items to return. 185 | int32 page_size = 1; 186 | 187 | // The next_page_token value returned from a previous List request, if any. 188 | string page_token = 2; 189 | } 190 | 191 | message ListShelvesResponse { 192 | // The list of shelves. 193 | repeated Shelf shelves = 1; 194 | // Token to retrieve the next page of results, or empty if there are no 195 | // more results in the list. 196 | string next_page_token = 2; 197 | } 198 | message UpdateBookRequest { 199 | // The book resource which replaces the resource on the server. 200 | Book book = 1; 201 | 202 | // The update mask applies to the resource. For the `FieldMask` definition, 203 | // see 204 | // https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#fieldmask 205 | google.protobuf.FieldMask update_mask = 2; 206 | } 207 | 208 | message DeleteBookRequest { 209 | // The resource name of the book to be deleted, for example: 210 | // "shelves/shelf1/books/book2" 211 | string name = 1; 212 | } 213 | 214 | message SearchBooksRequest { 215 | // The parent resource name, for example, "shelves/shelf1". 216 | string parent = 1; 217 | 218 | // The query string in search query syntax. 219 | string query = 2; 220 | 221 | // The maximum number of items to return. 222 | int32 page_size = 3; 223 | 224 | // The next_page_token value returned from a previous List request, if any. 225 | string page_token = 4; 226 | } 227 | 228 | message SearchBooksResponse { 229 | // The field name should match the noun "books" in the method name. There 230 | // will be a maximum number of items returned based on the page_size field 231 | // in the request. 232 | repeated Book books = 1; 233 | 234 | // Token to retrieve the next page of results, or empty if there are no 235 | // more results in the list. 236 | string next_page_token = 2; 237 | } 238 | 239 | message MoveBooksRequest { 240 | string new_parent = 1; 241 | repeated string books = 2; 242 | } 243 | 244 | message MoveBooksResponse { 245 | } 246 | 247 | message Checkout { 248 | uint64 id = 1; 249 | repeated Book books = 2; 250 | } 251 | 252 | message CheckoutBooksRequest { 253 | repeated string book_names = 2; 254 | } 255 | 256 | message GetCheckoutRequest { 257 | uint64 id = 1; 258 | } 259 | 260 | message ReturnBooksRequest { 261 | uint64 id = 1; 262 | repeated string book_names = 2; 263 | } 264 | 265 | message ListCheckoutsRequest { 266 | string name = 1; 267 | } 268 | 269 | message ListCheckoutsResponse { 270 | repeated Checkout checkouts = 1; 271 | } 272 | -------------------------------------------------------------------------------- /protocol_http_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "bytes" 19 | "encoding/json" 20 | "fmt" 21 | "net/http" 22 | "net/http/httptest" 23 | "net/url" 24 | "testing" 25 | 26 | "connectrpc.com/connect" 27 | testv1 "connectrpc.com/vanguard/internal/gen/vanguard/test/v1" 28 | "github.com/stretchr/testify/assert" 29 | "github.com/stretchr/testify/require" 30 | "google.golang.org/genproto/googleapis/api/httpbody" 31 | "google.golang.org/genproto/googleapis/rpc/errdetails" 32 | "google.golang.org/genproto/googleapis/rpc/status" 33 | "google.golang.org/protobuf/encoding/protojson" 34 | "google.golang.org/protobuf/proto" 35 | "google.golang.org/protobuf/types/known/anypb" 36 | ) 37 | 38 | func TestHTTPErrorWriter(t *testing.T) { 39 | t.Parallel() 40 | 41 | err := fmt.Errorf("test error: %s", "Hello, 世界") 42 | cerr := connect.NewWireError(connect.CodeUnauthenticated, err) 43 | rec := httptest.NewRecorder() 44 | httpWriteError(rec, cerr) 45 | 46 | assert.Equal(t, http.StatusUnauthorized, rec.Code) 47 | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) 48 | assert.Equal(t, "identity", rec.Header().Get("Content-Encoding")) 49 | var out bytes.Buffer 50 | require.NoError(t, json.Compact(&out, rec.Body.Bytes())) 51 | assert.JSONEq(t, `{"code":16,"message":"test error: Hello, 世界","details":[]}`, out.String()) 52 | 53 | body := bytes.NewBuffer(rec.Body.Bytes()) 54 | got := httpErrorFromResponse(http.StatusUnauthorized, "application/json", body) 55 | assert.Equal(t, cerr, got) 56 | } 57 | 58 | func TestHTTPErrorFromResponse(t *testing.T) { 59 | t.Parallel() 60 | t.Run("empty", func(t *testing.T) { 61 | t.Parallel() 62 | var body bytes.Buffer 63 | got := httpErrorFromResponse(http.StatusOK, "", &body) 64 | assert.Nil(t, got) 65 | }) 66 | t.Run("jsonStatus", func(t *testing.T) { 67 | t.Parallel() 68 | errorInfo, err := anypb.New(&errdetails.ErrorInfo{ 69 | Reason: "user is not authorized", 70 | Domain: "vanguard.connectrpc.com", 71 | Metadata: map[string]string{"key1": "value1"}, 72 | }) 73 | require.NoError(t, err) 74 | stat := status.Status{ 75 | Code: int32(connect.CodeUnauthenticated), 76 | Message: "auth error", 77 | Details: []*anypb.Any{errorInfo}, 78 | } 79 | out, err := protojson.Marshal(&stat) 80 | require.NoError(t, err) 81 | got := httpErrorFromResponse(http.StatusUnauthorized, "application/json", bytes.NewBuffer(out)) 82 | assert.Equal(t, connect.CodeUnauthenticated, got.Code()) 83 | assert.Equal(t, "auth error", got.Message()) 84 | }) 85 | t.Run("invalidStatus", func(t *testing.T) { 86 | t.Parallel() 87 | body := bytes.NewBufferString("unauthorized") 88 | got := httpErrorFromResponse(http.StatusUnauthorized, "application/json", body) 89 | assert.Equal(t, connect.CodeUnauthenticated, got.Code()) 90 | assert.Equal(t, "Unauthorized", got.Message()) 91 | assert.Len(t, got.Details(), 1) 92 | value, err := got.Details()[0].Value() 93 | require.NoError(t, err) 94 | httpBody, ok := value.(*httpbody.HttpBody) 95 | assert.True(t, ok) 96 | assert.Equal(t, "application/json", httpBody.GetContentType()) 97 | assert.Equal(t, []byte("unauthorized"), httpBody.GetData()) 98 | }) 99 | t.Run("invalidAny", func(t *testing.T) { 100 | t.Parallel() 101 | stat := status.Status{ 102 | Code: int32(connect.CodeUnauthenticated), 103 | Message: "auth error", 104 | } 105 | out, err := protojson.Marshal(&stat) 106 | require.NoError(t, err) 107 | out = append(out[:len(out)-1], []byte(`,"details":{"@type":"foo","value":"bar"}`)...) 108 | got := httpErrorFromResponse(http.StatusUnauthorized, "application/json", bytes.NewBuffer(out)) 109 | t.Log(got) 110 | assert.Equal(t, connect.CodeUnauthenticated, got.Code()) 111 | assert.Equal(t, "Unauthorized", got.Message()) 112 | assert.Len(t, got.Details(), 1) 113 | value, err := got.Details()[0].Value() 114 | require.NoError(t, err) 115 | httpBody, ok := value.(*httpbody.HttpBody) 116 | assert.True(t, ok) 117 | assert.Equal(t, "application/json", httpBody.GetContentType()) 118 | assert.Equal(t, out, httpBody.GetData()) 119 | }) 120 | } 121 | 122 | func TestHTTPEncodePathValues(t *testing.T) { 123 | t.Parallel() 124 | 125 | testCases := []struct { 126 | input proto.Message 127 | tmpl string 128 | reqFieldPath string 129 | wantPath string 130 | wantQuery url.Values 131 | wantErr string 132 | }{{ 133 | input: &testv1.ParameterValues{StringValue: "books/1"}, 134 | tmpl: "/v1/{string_value=books/*}:get", 135 | wantPath: "/v1/books/1:get", 136 | wantQuery: url.Values{}, 137 | }, { 138 | input: &testv1.ParameterValues{StringValue: "books/1/2/3/4"}, 139 | tmpl: "/v1/{string_value=books/**}:get", 140 | wantPath: "/v1/books/1/2/3/4:get", 141 | wantQuery: url.Values{}, 142 | }, { 143 | input: &testv1.ParameterValues{ 144 | StringValue: "books/1", 145 | Recursive: &testv1.ParameterValues{ 146 | StringValue: "Title", 147 | }, 148 | }, 149 | tmpl: "/v1/{string_value=books/*}:create", 150 | reqFieldPath: "recursive", 151 | wantPath: "/v1/books/1:create", 152 | wantQuery: url.Values{}, 153 | }, { 154 | input: &testv1.ParameterValues{ 155 | StringValue: "books/1", 156 | Recursive: &testv1.ParameterValues{ 157 | StringValue: "Title", 158 | }, 159 | Nested: &testv1.ParameterValues_Nested{ 160 | EnumValue: testv1.ParameterValues_Nested_ENUM_VALUE, 161 | }, 162 | BoolValue: true, 163 | DoubleList: []float64{ 164 | 1.0, 165 | 2.0, 166 | 3.0, 167 | }, 168 | }, 169 | tmpl: "/v2/{string_value=books/*}/{double_list}:create", 170 | reqFieldPath: "recursive", 171 | wantErr: "unexpected path variable \"double_list\": cannot be a repeated field", 172 | }, { 173 | input: &testv1.ParameterValues{ 174 | DoubleList: []float64{ 175 | 1.0, 176 | 2.0, 177 | 3.0, 178 | }, 179 | }, 180 | tmpl: "/v2/{double_list=**}", 181 | wantErr: "unexpected path variable \"double_list\": cannot be a repeated field", 182 | }, { 183 | // Map fields are not supported as URL values. 184 | input: &testv1.ParameterValues{ 185 | StringMap: map[string]string{"key1": "value1"}, 186 | }, 187 | tmpl: "/v2/mapfields", 188 | wantErr: "unexpected field string_map: cannot be URL encoded", 189 | }, { 190 | input: &testv1.ParameterValues{ 191 | Recursive: &testv1.ParameterValues{ 192 | StringMap: map[string]string{"key1": "value1"}, 193 | }, 194 | }, 195 | tmpl: "/v2/mapfields", 196 | wantErr: "unexpected field recursive.string_map: cannot be URL encoded", 197 | }, { 198 | // Repeated message fields are not supported as URL values. 199 | input: &testv1.ParameterValues{ 200 | RecursiveList: []*testv1.ParameterValues{ 201 | {StringValue: "value2"}, 202 | }, 203 | }, 204 | tmpl: "/v2/repeatedmsgs", 205 | wantErr: "unexpected field recursive_list: cannot be URL encoded", 206 | }, { 207 | // Single capture should be escaped for the URL. 208 | input: &testv1.ParameterValues{StringValue: "/世界"}, 209 | tmpl: "/v1/{string_value=*}:get", 210 | wantPath: "/v1/%2F%E4%B8%96%E7%95%8C:get", 211 | wantQuery: url.Values{}, 212 | }, { 213 | // Single capture should be escaped for the URL. 214 | input: &testv1.ParameterValues{StringValue: "%2F%2f 世界"}, 215 | tmpl: "/v1/{string_value=*}:get", 216 | wantPath: "/v1/%252F%252f%20%E4%B8%96%E7%95%8C:get", 217 | wantQuery: url.Values{}, 218 | }, { 219 | // Multi capture variables should be escaped for the URL. 220 | input: &testv1.ParameterValues{StringValue: "books/%2F%2f 世界"}, 221 | tmpl: "/v1/{string_value=books/*}:get", 222 | wantPath: "/v1/books/%2F%2F%20%E4%B8%96%E7%95%8C:get", 223 | wantQuery: url.Values{}, 224 | }, { 225 | // Non capture variables should be left as is. 226 | input: &testv1.ParameterValues{}, 227 | tmpl: "/v2/*", 228 | wantPath: "/v2/*", 229 | wantQuery: url.Values{}, 230 | }, { 231 | // Same with multicatpure. 232 | input: &testv1.ParameterValues{}, 233 | tmpl: "/v2/**", 234 | wantPath: "/v2/**", 235 | wantQuery: url.Values{}, 236 | }, { 237 | input: &testv1.ParameterValues{StringValue: "books/1"}, 238 | reqFieldPath: "unknownQueryParam", 239 | tmpl: "/v1/{string_value=books/*}:get", 240 | wantPath: "/v1/books/1:get", 241 | wantQuery: url.Values{}, 242 | wantErr: "unknown field in field path \"unknownQueryParam\": element \"unknownQueryParam\" does not correspond to any field of type vanguard.test.v1.ParameterValues", 243 | }} 244 | for _, testCase := range testCases { 245 | t.Run(testCase.tmpl, func(t *testing.T) { 246 | t.Parallel() 247 | segments, variables, err := parsePathTemplate(testCase.tmpl) 248 | if err != nil { 249 | assert.Equal(t, testCase.wantErr, err.Error()) 250 | return 251 | } 252 | require.NoError(t, err) 253 | config := &methodConfig{ 254 | descriptor: &fakeMethodDescriptor{ 255 | name: testCase.tmpl, 256 | in: testCase.input.ProtoReflect().Descriptor(), 257 | }, 258 | } 259 | input := testCase.input.ProtoReflect() 260 | target, err := makeTarget(config, "POST", testCase.reqFieldPath, "*", segments, variables) 261 | if err != nil { 262 | assert.Equal(t, testCase.wantErr, err.Error()) 263 | return 264 | } 265 | require.NoError(t, err) 266 | 267 | path, query, err := httpEncodePathValues(input, target) 268 | if err != nil { 269 | assert.Equal(t, testCase.wantErr, err.Error()) 270 | return 271 | } 272 | require.NoError(t, err) 273 | 274 | assert.Equal(t, testCase.wantPath, path) 275 | assert.Equal(t, testCase.wantQuery, query) 276 | }) 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /router_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "fmt" 19 | "net/http" 20 | "strings" 21 | "testing" 22 | 23 | "github.com/stretchr/testify/assert" 24 | "github.com/stretchr/testify/require" 25 | "google.golang.org/protobuf/reflect/protoreflect" 26 | ) 27 | 28 | func TestRouteTrie_Insert(t *testing.T) { 29 | t.Parallel() 30 | _ = initTrie(t) 31 | } 32 | 33 | func TestRouteTrie_FindTarget(t *testing.T) { 34 | t.Parallel() 35 | testCases := []struct { 36 | path string 37 | expectedPath string // if blank, path is expected to NOT match 38 | expectedVars map[string]string 39 | }{ 40 | { 41 | path: "/bob/lob/law", 42 | }, 43 | { 44 | path: "/foo/bar/baz", 45 | expectedPath: "/foo/bar/{name}", 46 | expectedVars: map[string]string{"name": "baz"}, 47 | }, 48 | { 49 | path: "/foo/bob/lob/law", 50 | }, 51 | { 52 | path: "/foo/bar/baz/buzz", 53 | expectedPath: "/foo/bar/baz/buzz", 54 | }, 55 | { 56 | path: "/foo/bar/baz/baz/buzz", 57 | expectedPath: "/foo/bar/{name}/baz/{child}", 58 | expectedVars: map[string]string{"name": "baz", "child": "buzz"}, 59 | }, 60 | { 61 | path: "/foo/bar/1/baz/2/buzz/3", 62 | expectedPath: "/foo/bar/{name}/baz/{child.id}/buzz/{child.thing.id}", 63 | expectedVars: map[string]string{"name": "1", "child.id": "2", "child.thing.id": "3"}, 64 | }, 65 | { 66 | path: "/foo/bar/baz/123", 67 | }, 68 | { 69 | path: "/foo/bar/baz/123/buzz", 70 | expectedPath: "/foo/bar/*/{thing.id}/{cat=**}", 71 | expectedVars: map[string]string{"thing.id": "123", "cat": "buzz"}, 72 | }, 73 | { 74 | path: "/foo/bar/baz/123/buzz/buzz", 75 | expectedPath: "/foo/bar/*/{thing.id}/{cat=**}", 76 | expectedVars: map[string]string{"thing.id": "123", "cat": "buzz/buzz"}, 77 | }, 78 | { 79 | path: "/foo/bar/baz/123/buzz/buzz:do", 80 | expectedPath: "/foo/bar/*/{thing.id}/{cat=**}:do", 81 | expectedVars: map[string]string{"thing.id": "123", "cat": "buzz/buzz"}, 82 | }, 83 | { 84 | path: "/foo/bar/baz/123/fizz/buzz/frob/nitz:do", 85 | expectedPath: "/foo/bar/*/{thing.id}/{cat=**}:do", 86 | expectedVars: map[string]string{"thing.id": "123", "cat": "fizz/buzz/frob/nitz"}, 87 | }, 88 | { 89 | path: "/foo/bar/baz/123/buzz/buzz:cancel", 90 | expectedPath: "/foo/bar/*/{thing.id}/{cat=**}:cancel", 91 | expectedVars: map[string]string{"thing.id": "123", "cat": "buzz/buzz"}, 92 | }, 93 | { 94 | path: "foo/bar/baz/123/buzz/buzz:blah", 95 | }, 96 | { 97 | path: "/foo/bob/bar/baz/123/details", 98 | expectedPath: "/foo/bob/{book_id={author}/{isbn}/*}/details", 99 | expectedVars: map[string]string{"book_id": "bar/baz/123", "author": "bar", "isbn": "baz"}, 100 | }, 101 | { 102 | path: "/foo/bob/bar/baz/123/details:do", 103 | }, 104 | { 105 | path: "/foo/blah/A/B/C/foo/D/E/F/G/foo/H/I/J/K/L/M:details", 106 | expectedPath: "/foo/blah/{longest_var={long_var.a={medium.a={short.aa}/*/{short.ab}/foo}/*}/{long_var.b={medium.b={short.ba}/*/{short.bb}/foo}/{last=**}}}:details", 107 | expectedVars: map[string]string{ 108 | "longest_var": "A/B/C/foo/D/E/F/G/foo/H/I/J/K/L/M", 109 | "long_var.a": "A/B/C/foo/D", 110 | "medium.a": "A/B/C/foo", 111 | "short.aa": "A", 112 | "short.ab": "C", 113 | "long_var.b": "E/F/G/foo/H/I/J/K/L/M", 114 | "medium.b": "E/F/G/foo", 115 | "short.ba": "E", 116 | "short.bb": "G", 117 | "last": "H/I/J/K/L/M", 118 | }, 119 | }, 120 | { 121 | // No trailing slash in the path, so this should not match. 122 | path: "/trailing:slash", 123 | }, 124 | { 125 | // Trailing slash in the path, so this should match. 126 | path: "/trailing/:slash", 127 | expectedPath: "/trailing/**:slash", 128 | }, 129 | { 130 | // Trailing verb, should not match. 131 | path: "/verb:", 132 | }, 133 | { 134 | // No trailing verb, should match. 135 | path: "/verb", 136 | expectedPath: "/verb", 137 | }, 138 | { 139 | // Var capture use path unescaping. 140 | path: "/foo/bar/baz/%2f/%2A/%2f", 141 | expectedPath: "/foo/bar/*/{thing.id}/{cat=**}", 142 | expectedVars: map[string]string{"thing.id": "/", "cat": "*/%2F"}, 143 | }, 144 | } 145 | 146 | trie := initTrie(t) 147 | 148 | for _, testCase := range testCases { 149 | t.Run(testCase.path, func(t *testing.T) { 150 | t.Parallel() 151 | var present, absent []string 152 | if testCase.expectedPath != "" { 153 | present = []string{http.MethodGet, http.MethodPost} 154 | absent = []string{http.MethodDelete, http.MethodPut} 155 | } else { 156 | absent = []string{http.MethodGet, http.MethodPost, http.MethodDelete, http.MethodPut} 157 | } 158 | for _, method := range present { 159 | t.Run(method, func(t *testing.T) { 160 | t.Parallel() 161 | target, vars, _ := trie.match(testCase.path, method) 162 | require.NotNil(t, target) 163 | require.Equal(t, protoreflect.Name(fmt.Sprintf("%s %s", method, testCase.expectedPath)), target.config.descriptor.Name()) 164 | require.Equal(t, len(testCase.expectedVars), len(vars)) 165 | for _, varMatch := range vars { 166 | names := make([]string, len(varMatch.fields)) 167 | for i, fld := range varMatch.fields { 168 | names[i] = string(fld.Name()) 169 | } 170 | name := strings.Join(names, ".") 171 | expectedValue, ok := testCase.expectedVars[name] 172 | assert.True(t, ok, name) 173 | require.Equal(t, expectedValue, varMatch.value, name) 174 | } 175 | }) 176 | } 177 | for _, method := range absent { 178 | t.Run(method, func(t *testing.T) { 179 | t.Parallel() 180 | target, _, _ := trie.match(testCase.path, method) 181 | require.Nil(t, target) 182 | }) 183 | } 184 | }) 185 | } 186 | } 187 | 188 | func BenchmarkTrieMatch(b *testing.B) { 189 | trie := initTrie(b) 190 | path := "/foo/blah/A/B/C/foo/D/E/F/G/foo/H/I/J/K/L/M:details" 191 | var ( 192 | method *routeTarget 193 | vars []routeTargetVarMatch 194 | ) 195 | b.ReportAllocs() 196 | b.ResetTimer() 197 | for range b.N { 198 | method, vars, _ = trie.match(path, http.MethodPost) 199 | if method == nil { 200 | b.Fatal("method not found") 201 | } 202 | } 203 | b.StopTimer() 204 | assert.NotNil(b, method) 205 | assert.Len(b, vars, 10) 206 | } 207 | 208 | func initTrie(tb testing.TB) *routeTrie { 209 | tb.Helper() 210 | var trie routeTrie 211 | for _, route := range []string{ 212 | "/foo/bar/baz/buzz", 213 | "/foo/bar/{name}", 214 | "/foo/bar/{name}/baz/{child}", 215 | "/foo/bar/{name}/baz/{child.id}/buzz/{child.thing.id}", 216 | "/foo/bar/*/{thing.id}/{cat=**}", 217 | "/foo/bar/*/{thing.id}/{cat=**}:do", 218 | "/foo/bar/*/{thing.id}/{cat=**}:cancel", 219 | "/foo/bob/{book_id={author}/{isbn}/*}/details", 220 | "/foo/blah/{longest_var={long_var.a={medium.a={short.aa}/*/{short.ab}/foo}/*}/{long_var.b={medium.b={short.ba}/*/{short.bb}/foo}/{last=**}}}:details", 221 | "/foo%2Fbar/%2A/%2A%2a/{starstar=%2A%2a/**}:%2c", 222 | "/trailing/**:slash", 223 | "/verb", 224 | } { 225 | segments, variables, err := parsePathTemplate(route) 226 | require.NoError(tb, err) 227 | 228 | for _, method := range []string{http.MethodGet, http.MethodPost} { 229 | config := &methodConfig{ 230 | descriptor: &fakeMethodDescriptor{ 231 | name: fmt.Sprintf("%s %s", method, route), 232 | }, 233 | } 234 | target, err := makeTarget(config, "POST", "*", "*", segments, variables) 235 | require.NoError(tb, err) 236 | err = trie.insert(method, target, segments) 237 | require.NoError(tb, err) 238 | } 239 | } 240 | return &trie 241 | } 242 | 243 | type fakeMethodDescriptor struct { 244 | protoreflect.MethodDescriptor 245 | name string 246 | in, out protoreflect.MessageDescriptor 247 | } 248 | 249 | func (f *fakeMethodDescriptor) Name() protoreflect.Name { 250 | return protoreflect.Name(f.name) 251 | } 252 | 253 | func (f *fakeMethodDescriptor) Input() protoreflect.MessageDescriptor { 254 | if f.in == nil { 255 | f.in = &fakeMessageDescriptor{} 256 | } 257 | return f.in 258 | } 259 | 260 | func (f *fakeMethodDescriptor) Output() protoreflect.MessageDescriptor { 261 | if f.out == nil { 262 | f.out = &fakeMessageDescriptor{} 263 | } 264 | return f.out 265 | } 266 | 267 | type fakeMessageDescriptor struct { 268 | protoreflect.MessageDescriptor 269 | fields protoreflect.FieldDescriptors 270 | } 271 | 272 | func (f *fakeMessageDescriptor) Fields() protoreflect.FieldDescriptors { 273 | if f.fields == nil { 274 | f.fields = &fakeFieldDescriptors{} 275 | } 276 | return f.fields 277 | } 278 | 279 | type fakeFieldDescriptors struct { 280 | protoreflect.FieldDescriptors 281 | fields map[protoreflect.Name]protoreflect.FieldDescriptor 282 | } 283 | 284 | func (f *fakeFieldDescriptors) ByName(name protoreflect.Name) protoreflect.FieldDescriptor { 285 | fld := f.fields[name] 286 | if fld == nil { 287 | if f.fields == nil { 288 | f.fields = map[protoreflect.Name]protoreflect.FieldDescriptor{} 289 | } 290 | fld = &fakeFieldDescriptor{name: name} 291 | f.fields[name] = fld 292 | } 293 | return fld 294 | } 295 | 296 | type fakeFieldDescriptor struct { 297 | name protoreflect.Name 298 | msg protoreflect.MessageDescriptor 299 | kind protoreflect.Kind 300 | protoreflect.FieldDescriptor 301 | } 302 | 303 | func (f *fakeFieldDescriptor) Name() protoreflect.Name { 304 | return f.name 305 | } 306 | 307 | func (f *fakeFieldDescriptor) Cardinality() protoreflect.Cardinality { 308 | return protoreflect.Optional 309 | } 310 | 311 | func (f *fakeFieldDescriptor) Kind() protoreflect.Kind { 312 | if f.kind > 0 { 313 | return f.kind 314 | } 315 | if f.msg != nil { 316 | return protoreflect.MessageKind 317 | } 318 | return protoreflect.StringKind 319 | } 320 | 321 | func (f *fakeFieldDescriptor) Message() protoreflect.MessageDescriptor { 322 | if f.msg == nil { 323 | f.msg = &fakeMessageDescriptor{} 324 | } 325 | return f.msg 326 | } 327 | func (f *fakeFieldDescriptor) IsList() bool { 328 | return false 329 | } 330 | -------------------------------------------------------------------------------- /path_parser_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "net/url" 19 | "testing" 20 | 21 | "github.com/stretchr/testify/assert" 22 | "github.com/stretchr/testify/require" 23 | ) 24 | 25 | func TestPath_ParsePathTemplate(t *testing.T) { 26 | t.Parallel() 27 | 28 | testCases := []struct { 29 | tmpl string 30 | wantPath []string 31 | wantVerb string 32 | wantVars []pathVariable 33 | expectedErr string 34 | }{{ 35 | // no error, but lots of encoding for special/reserved characters 36 | tmpl: "/my%2fcool-blog~about%2Cstuff%5Bwat%5D/{var={abc=%2F%2A}/{def=%2F%2A/**}}:baz", 37 | wantPath: []string{"my%2Fcool-blog~about%2Cstuff%5Bwat%5D", "%2F%2A", "%2F%2A", "**"}, 38 | wantVerb: "baz", 39 | wantVars: []pathVariable{ 40 | {fieldPath: "var", start: 1, end: -1}, 41 | {fieldPath: "abc", start: 1, end: 2}, 42 | {fieldPath: "def", start: 2, end: -1}, 43 | }, 44 | }, { 45 | tmpl: "/{1}", 46 | expectedErr: "syntax error at column 3: expected identifier", 47 | }, { 48 | tmpl: "/{field.1}", 49 | expectedErr: "syntax error at column 9: expected identifier", 50 | }, { 51 | tmpl: "/{_}", 52 | wantPath: []string{"*"}, 53 | wantVars: []pathVariable{ 54 | {fieldPath: "_", start: 0, end: 1}, 55 | }, 56 | }, { 57 | tmpl: "/{-}", 58 | expectedErr: "syntax error at column 3: expected identifier", 59 | }, { 60 | tmpl: "/{field-}", 61 | expectedErr: "syntax error at column 8: expected '}', got '-'", 62 | }, { 63 | tmpl: "/foo/bar/baz?abc=def", 64 | expectedErr: "syntax error at column 13: unexpected '?'", // no query string allowed 65 | }, { 66 | tmpl: "/foo/bar/baz buzz", 67 | expectedErr: "syntax error at column 13: unexpected ' '", // no whitespace allowed 68 | }, { 69 | tmpl: "foo/bar/baz", 70 | expectedErr: "syntax error at column 1: expected '/', got 'f'", // must start with slash 71 | }, { 72 | tmpl: "/foo/bar/", 73 | expectedErr: "syntax error at column 9: expected path value", // must not end in slash 74 | }, { 75 | tmpl: "/foo/bar:baz/buzz", 76 | expectedErr: "syntax error at column 13: unexpected '/'", // ":baz" verb can only come at the very end 77 | }, { 78 | tmpl: "/foo/{bar/baz}/buzz", 79 | expectedErr: "syntax error at column 10: expected '}', got '/'", // invalid field path 80 | }, { 81 | tmpl: "/foo/bar:baz%12xyz%abcde", 82 | wantPath: []string{"foo", "bar"}, 83 | wantVerb: "baz%12xyz%ABcde", 84 | }, { 85 | tmpl: "/{hello}/world", 86 | wantPath: []string{"*", "world"}, 87 | wantVars: []pathVariable{ 88 | {fieldPath: "hello", start: 0, end: 1}, 89 | }, 90 | }, { 91 | tmpl: "/foo/bar%55:baz%1", 92 | expectedErr: "syntax error at column 17: invalid URL escape \"%1\"", 93 | }, { 94 | tmpl: "/foo/bar*", 95 | expectedErr: "syntax error at column 9: unexpected '*'", // wildcard must be entire path component 96 | }, { 97 | tmpl: "/foo/bar/***", 98 | expectedErr: "syntax error at column 12: unexpected '*'", // no such thing as triple-wildcard 99 | }, { 100 | tmpl: "/foo/**/bar", 101 | expectedErr: "double wildcard '**' must be the final path segment", // double-wildcard must be at end 102 | }, { 103 | tmpl: "/{a}/{a}", // TODO: allow this? 104 | expectedErr: "duplicate variable \"a\"", 105 | }, { 106 | tmpl: "/f/bar", 107 | wantPath: []string{"f", "bar"}, 108 | }, { 109 | tmpl: "/v1/{name=shelves/*/books/*}", 110 | wantPath: []string{"v1", "shelves", "*", "books", "*"}, 111 | wantVars: []pathVariable{ 112 | {fieldPath: "name", start: 1, end: 5}, 113 | }, 114 | }, { 115 | tmpl: "/v1/{parent=shelves/*}/books", 116 | wantPath: []string{"v1", "shelves", "*", "books"}, 117 | wantVars: []pathVariable{ 118 | {fieldPath: "parent", start: 1, end: 3}, 119 | }, 120 | }, { 121 | tmpl: "/v1/{book.name=shelves/*/books/*}", 122 | wantPath: []string{"v1", "shelves", "*", "books", "*"}, 123 | wantVars: []pathVariable{ 124 | {fieldPath: "book.name", start: 1, end: 5}, 125 | }, 126 | }, { 127 | tmpl: "/v1:watch", 128 | wantPath: []string{"v1"}, 129 | wantVerb: "watch", 130 | }, { 131 | tmpl: "/v3/events:clear", 132 | wantPath: []string{"v3", "events"}, 133 | wantVerb: "clear", 134 | }, { 135 | tmpl: "/v3/{name=events/*}:cancel", 136 | wantPath: []string{"v3", "events", "*"}, 137 | wantVerb: "cancel", 138 | wantVars: []pathVariable{ 139 | {fieldPath: "name", start: 1, end: 3}, 140 | }, 141 | }, { 142 | tmpl: "/foo/bar/baz/buzz", 143 | wantPath: []string{"foo", "bar", "baz", "buzz"}, 144 | }, { 145 | tmpl: "/foo/bar/{name}", 146 | wantPath: []string{"foo", "bar", "*"}, 147 | wantVars: []pathVariable{ 148 | {fieldPath: "name", start: 2, end: 3}, 149 | }, 150 | }, { 151 | tmpl: "/foo/bar/{name}/baz/{child}", 152 | wantPath: []string{"foo", "bar", "*", "baz", "*"}, 153 | wantVars: []pathVariable{ 154 | {fieldPath: "name", start: 2, end: 3}, 155 | {fieldPath: "child", start: 4, end: 5}, 156 | }, 157 | }, { 158 | tmpl: "/foo/bar/{name}/baz/{child.id}/buzz/{child.thing.id}", 159 | wantPath: []string{"foo", "bar", "*", "baz", "*", "buzz", "*"}, 160 | wantVars: []pathVariable{ 161 | {fieldPath: "name", start: 2, end: 3}, 162 | {fieldPath: "child.id", start: 4, end: 5}, 163 | {fieldPath: "child.thing.id", start: 6, end: 7}, 164 | }, 165 | }, { 166 | tmpl: "/foo/bar/*/{thing.id}/{cat=**}", 167 | wantPath: []string{"foo", "bar", "*", "*", "**"}, 168 | wantVars: []pathVariable{ 169 | {fieldPath: "thing.id", start: 3, end: 4}, 170 | {fieldPath: "cat", start: 4, end: -1}, 171 | }, 172 | }, { 173 | tmpl: "/foo/bar/*/{thing.id}/{cat=**}:do", 174 | wantPath: []string{"foo", "bar", "*", "*", "**"}, 175 | wantVerb: "do", 176 | wantVars: []pathVariable{ 177 | {fieldPath: "thing.id", start: 3, end: 4}, 178 | {fieldPath: "cat", start: 4, end: -1}, 179 | }, 180 | }, { 181 | tmpl: "/foo/bar/*/{thing.id}/{cat=**}:cancel", 182 | wantPath: []string{"foo", "bar", "*", "*", "**"}, 183 | wantVerb: "cancel", 184 | wantVars: []pathVariable{ 185 | {fieldPath: "thing.id", start: 3, end: 4}, 186 | {fieldPath: "cat", start: 4, end: -1}, 187 | }, 188 | }, { 189 | tmpl: "/foo/bob/{book_id={author}/{isbn}/*}/details", 190 | wantPath: []string{"foo", "bob", "*", "*", "*", "details"}, 191 | wantVars: []pathVariable{ 192 | {fieldPath: "book_id", start: 2, end: 5}, 193 | {fieldPath: "author", start: 2, end: 3}, 194 | {fieldPath: "isbn", start: 3, end: 4}, 195 | }, 196 | }, { 197 | tmpl: "/foo/blah/{longest_var={long_var.a={medium.a={short.aa}/*/{short.ab}/foo}/*}/{long_var.b={medium.b={short.ba}/*/{short.bb}/foo}/{last=**}}}:details", 198 | wantPath: []string{ 199 | "foo", "blah", 200 | "*", // 2 logest_var, long_var.a, medium.a, short.aa 201 | "*", // 3 202 | "*", // 4 short.ab 203 | "foo", // 5 204 | "*", // 6 205 | "*", // 7 long_var.b, medium.b, short.ba 206 | "*", // 8 207 | "*", // 9 short.bb 208 | "foo", // 10 209 | "**", // 11 last 210 | }, 211 | wantVerb: "details", 212 | wantVars: []pathVariable{ 213 | {fieldPath: "longest_var", start: 2, end: -1}, 214 | {fieldPath: "long_var.a", start: 2, end: 7}, 215 | {fieldPath: "medium.a", start: 2, end: 6}, 216 | {fieldPath: "short.aa", start: 2, end: 3}, 217 | {fieldPath: "short.ab", start: 4, end: 5}, 218 | {fieldPath: "long_var.b", start: 7, end: -1}, 219 | {fieldPath: "medium.b", start: 7, end: 11}, 220 | {fieldPath: "short.ba", start: 7, end: 8}, 221 | {fieldPath: "short.bb", start: 9, end: 10}, 222 | {fieldPath: "last", start: 11, end: -1}, 223 | }, 224 | }, { 225 | tmpl: "/foo%2Fbar/%2A/%2A%2a/{starstar=%2A%2a/**}:%2c", 226 | wantPath: []string{"foo%2Fbar", "%2A", "%2A%2A", "%2A%2A", "**"}, 227 | wantVerb: "%2C", 228 | wantVars: []pathVariable{ 229 | {fieldPath: "starstar", start: 3, end: -1}, 230 | }, 231 | }} 232 | for _, testCase := range testCases { 233 | t.Run(testCase.tmpl, func(t *testing.T) { 234 | t.Parallel() 235 | segments, variables, err := parsePathTemplate(testCase.tmpl) 236 | if testCase.expectedErr != "" { 237 | assert.ErrorContains(t, err, testCase.expectedErr) 238 | return 239 | } 240 | t.Log(segments) 241 | require.NoError(t, err) 242 | assert.ElementsMatch(t, testCase.wantPath, segments.path, "path mismatch") 243 | assert.Equal(t, testCase.wantVerb, segments.verb, "verb mismatch") 244 | assert.ElementsMatch(t, testCase.wantVars, variables, "variables mismatch") 245 | }) 246 | } 247 | } 248 | 249 | func TestPath_SafeLiterals(t *testing.T) { 250 | t.Parallel() 251 | literalvalues := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._%25~" 252 | for _, r := range literalvalues { 253 | if !isLiteral(r) { 254 | t.Errorf("isLiteral(%q) = false, want true", r) 255 | } 256 | } 257 | unescaped, err := url.PathUnescape(literalvalues) 258 | require.NoError(t, err) 259 | escaped := url.PathEscape(unescaped) 260 | assert.Equal(t, literalvalues, escaped) 261 | } 262 | 263 | func TestPath_Escaping(t *testing.T) { 264 | t.Parallel() 265 | testCases := []struct { 266 | input string 267 | mode pathEncoding 268 | want string 269 | wantEscaped string 270 | wantErr string 271 | }{{ 272 | input: "foo", 273 | mode: pathEncodeSingle, 274 | want: "foo", 275 | }, { 276 | input: "foo%2Fbar", 277 | mode: pathEncodeSingle, 278 | want: "foo/bar", 279 | }, { 280 | input: "foo%252Fbar", 281 | mode: pathEncodeSingle, 282 | want: "foo%2Fbar", 283 | }, { 284 | input: "foo%2Fbar", 285 | mode: pathEncodeMulti, 286 | want: "foo%2Fbar", 287 | }, { 288 | input: "foo%2fbar", 289 | mode: pathEncodeMulti, 290 | want: "foo%2Fbar", 291 | wantEscaped: "foo%2Fbar", 292 | }} 293 | for _, testCase := range testCases { 294 | t.Run(testCase.input, func(t *testing.T) { 295 | t.Parallel() 296 | dec, err := pathUnescape(testCase.input, testCase.mode) 297 | if err != nil { 298 | assert.EqualError(t, err, testCase.wantErr) 299 | return 300 | } 301 | require.NoError(t, err) 302 | assert.Equal(t, testCase.want, dec) 303 | enc := pathEscape(dec, testCase.mode) 304 | if testCase.wantEscaped != "" { 305 | assert.Equal(t, testCase.wantEscaped, enc) 306 | } else { 307 | assert.Equal(t, testCase.input, enc) 308 | } 309 | }) 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /path_parser.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "errors" 19 | "fmt" 20 | "net/url" 21 | "strconv" 22 | "strings" 23 | ) 24 | 25 | // pathSegments holds the path segments for a method. 26 | // The verb is the final segment, if any. Wildcards segments are annotated by 27 | // '*' and '**' path values. Each segment is URL unescaped. 28 | type pathSegments struct { 29 | path []string // segment path values. 30 | verb string // final segment verb, if any. 31 | } 32 | 33 | // String returns the URL path representation of the segments. 34 | func (s pathSegments) String() string { 35 | var out strings.Builder 36 | for _, value := range s.path { 37 | out.WriteByte('/') 38 | if value != "*" && value != "**" { 39 | value = url.PathEscape(value) 40 | } 41 | out.WriteString(value) 42 | } 43 | if s.verb != "" { 44 | out.WriteByte(':') 45 | out.WriteString(url.PathEscape(s.verb)) 46 | } 47 | return out.String() 48 | } 49 | 50 | // pathVariable holds the path variables for a method. 51 | // The start and end fields are the start and end path segments, inclusive-exclusive. 52 | // If the end is -1, the variable is unbounded, representing a '**' wildcard capture. 53 | type pathVariable struct { 54 | fieldPath string // field path for the variable. 55 | start, end int // start and end path segments, inclusive-exclusive, -1 for unbounded. 56 | } 57 | 58 | // parsePathTemplate parsers a methods template into path segments and variables. 59 | // 60 | // The grammar for the path template is given in the protobuf definition 61 | // in [google/api/http.proto]. 62 | // 63 | // Template = "/" Segments [ Verb ] ; 64 | // Segments = Segment { "/" Segment } ; 65 | // Segment = "*" | "**" | LITERAL | Variable ; 66 | // Variable = "{" FieldPath [ "=" Segments ] "}" ; 67 | // FieldPath = IDENT { "." IDENT } ; 68 | // Verb = ":" LITERAL ; 69 | // 70 | // [google/api/http.proto]: https://github.com/googleapis/googleapis/blob/ecb1cf0a0021267dd452289fc71c75674ae29fe3/google/api/http.proto#L227-L235 71 | func parsePathTemplate(template string) ( 72 | pathSegments, []pathVariable, error, 73 | ) { 74 | parser := &pathParser{scan: pathScanner{input: template}} 75 | if err := parser.parseTemplate(); err != nil { 76 | return pathSegments{}, nil, err 77 | } 78 | return parser.segments, parser.variables, nil 79 | } 80 | 81 | // pathParser holds the state for the recursive descent path template parser. 82 | type pathParser struct { 83 | scan pathScanner // scanner for the input. 84 | seenVars map[string]bool // set of field paths. 85 | seenDoubleStar bool // true if we've seen a double star wildcard. 86 | segments pathSegments // output segments. 87 | variables []pathVariable // output variables. 88 | } 89 | 90 | func (p *pathParser) currentChar() string { 91 | if char := p.scan.current(); char != eof { 92 | return strconv.QuoteRune(char) 93 | } 94 | return "EOF" 95 | } 96 | func (p *pathParser) errSyntax(msg string) error { 97 | return fmt.Errorf("syntax error at column %v: %s", p.scan.pos, msg) 98 | } 99 | func (p *pathParser) errUnexpected() error { 100 | return p.errSyntax("unexpected " + p.currentChar()) 101 | } 102 | func (p *pathParser) errExpected(expected rune) error { 103 | return p.errSyntax("expected " + strconv.QuoteRune(expected) + ", got " + p.currentChar()) 104 | } 105 | 106 | func (p *pathParser) parseTemplate() error { 107 | if !p.scan.consume('/') { 108 | return p.errExpected('/') // empty path is not allowed. 109 | } 110 | if err := p.parseSegments(); err != nil { 111 | return err 112 | } 113 | switch p.scan.next() { 114 | case ':': 115 | p.scan.discard() 116 | return p.parseVerb() 117 | case eof: 118 | return nil 119 | default: 120 | return p.errUnexpected() 121 | } 122 | } 123 | 124 | func (p *pathParser) parseVerb() error { 125 | literal, err := p.parseLiteral() 126 | if err != nil { 127 | return err 128 | } 129 | p.segments.verb = literal 130 | if !p.scan.consume(eof) { 131 | return p.errUnexpected() 132 | } 133 | return nil 134 | } 135 | 136 | func (p *pathParser) parseSegments() error { 137 | for { 138 | if err := p.parseSegment(); err != nil { 139 | return err 140 | } 141 | if p.scan.next() != '/' { 142 | p.scan.backup() 143 | return nil 144 | } 145 | p.scan.discard() 146 | if p.seenDoubleStar { 147 | return errors.New("double wildcard '**' must be the final path segment") 148 | } 149 | } 150 | } 151 | 152 | // parseLiteral parses a URL path segment in URL path escaped form. 153 | func (p *pathParser) parseLiteral() (string, error) { 154 | literal := p.scan.captureRun(isLiteral) 155 | if literal == "" { 156 | p.scan.next() 157 | return "", p.errUnexpected() 158 | } 159 | unescaped, err := pathUnescape(literal, pathEncodeSingle) 160 | if err != nil { 161 | return "", p.errSyntax(err.Error()) 162 | } 163 | return pathEscape(unescaped, pathEncodeSingle), nil 164 | } 165 | 166 | func (p *pathParser) parseSegment() error { 167 | var segment string 168 | switch p.scan.next() { 169 | case '*': 170 | if p.scan.next() == '*' { 171 | p.seenDoubleStar = true 172 | } else { 173 | p.scan.backup() 174 | } 175 | segment = p.scan.capture() 176 | case '{': 177 | p.scan.discard() 178 | return p.parseVariable() 179 | default: 180 | if !isLiteral(p.scan.current()) { 181 | return p.errSyntax("expected path value") 182 | } 183 | literal, err := p.parseLiteral() 184 | if err != nil { 185 | return err 186 | } 187 | segment = literal 188 | } 189 | p.segments.path = append(p.segments.path, segment) 190 | return nil 191 | } 192 | 193 | func (p *pathParser) parseFieldPath() (string, error) { 194 | for { 195 | if !isIdentStart(p.scan.next()) { 196 | return "", p.errSyntax("expected identifier") 197 | } 198 | for isIdent(p.scan.next()) { 199 | continue 200 | } 201 | if p.scan.current() != '.' { 202 | p.scan.backup() 203 | return p.scan.capture(), nil 204 | } 205 | } 206 | } 207 | 208 | func (p *pathParser) parseVariable() error { 209 | fieldPath, err := p.parseFieldPath() 210 | if err != nil { 211 | return err 212 | } 213 | if p.seenVars[fieldPath] { 214 | return fmt.Errorf("duplicate variable %q", fieldPath) 215 | } 216 | if p.seenVars == nil { 217 | p.seenVars = make(map[string]bool) 218 | } 219 | p.seenVars[fieldPath] = true 220 | 221 | variable := pathVariable{fieldPath: fieldPath, start: len(p.segments.path)} 222 | 223 | switch p.scan.next() { 224 | case '}': 225 | p.scan.discard() 226 | p.segments.path = append(p.segments.path, "*") // default capture. 227 | case '=': 228 | p.scan.discard() 229 | if err := p.parseSegments(); err != nil { 230 | return err 231 | } 232 | if !p.scan.consume('}') { 233 | return p.errExpected('}') 234 | } 235 | default: 236 | return p.errExpected('}') 237 | } 238 | variable.end = len(p.segments.path) 239 | if p.seenDoubleStar { 240 | variable.end = -1 // double star wildcard. 241 | } 242 | p.variables = append(p.variables, variable) 243 | return nil 244 | } 245 | 246 | const upperhex = "0123456789ABCDEF" 247 | 248 | func ishex(char byte) bool { 249 | switch { 250 | case '0' <= char && char <= '9': 251 | return true 252 | case 'a' <= char && char <= 'f': 253 | return true 254 | case 'A' <= char && char <= 'F': 255 | return true 256 | } 257 | return false 258 | } 259 | func unhex(char byte) byte { 260 | switch { 261 | case '0' <= char && char <= '9': 262 | return char - '0' 263 | case 'a' <= char && char <= 'f': 264 | return char - 'a' + 10 265 | case 'A' <= char && char <= 'F': 266 | return char - 'A' + 10 267 | } 268 | return 0 269 | } 270 | 271 | // pathEncoding is the encoding used for path variables. 272 | // Single encoding is used for single segment capture variables, 273 | // while multi encoding is used for multi segment capture variables. 274 | // On multi encoding variables, '/' is not escaped and is preserved 275 | // as '%2F' if encoded in the path. 276 | // 277 | // See: https://github.com/googleapis/googleapis/blob/1769846666fbeb0f9ece6ad929ddc0d563cccd8d/google/api/http.proto#L249-L264 278 | type pathEncoding int 279 | 280 | const ( 281 | pathEncodeSingle pathEncoding = iota 282 | pathEncodeMulti 283 | ) 284 | 285 | func pathShouldEscape(char byte, _ pathEncoding) bool { 286 | return !isVariable(rune(char)) 287 | } 288 | func pathIsHexSlash(input string) bool { 289 | if len(input) < 3 { 290 | return false 291 | } 292 | return input[0] == '%' && input[1] == '2' && (input[2] == 'f' || input[2] == 'F') 293 | } 294 | 295 | func pathEscape(input string, mode pathEncoding) string { 296 | // Count the number of characters that possibly escaping. 297 | hexCount := 0 298 | for i := range len(input) { 299 | if pathShouldEscape(input[i], mode) { 300 | hexCount++ 301 | } 302 | } 303 | if hexCount == 0 { 304 | return input 305 | } 306 | 307 | var sb strings.Builder 308 | sb.Grow(len(input) + 2*hexCount) 309 | for i := 0; i < len(input); i++ { 310 | switch char := input[i]; { 311 | case char == '%' && mode == pathEncodeMulti && pathIsHexSlash(input[i:]): 312 | sb.WriteString("%2F") 313 | i += 2 314 | case pathShouldEscape(char, mode): 315 | sb.WriteByte('%') 316 | sb.WriteByte(upperhex[char>>4]) 317 | sb.WriteByte(upperhex[char&15]) 318 | default: 319 | sb.WriteByte(char) 320 | } 321 | } 322 | return sb.String() 323 | } 324 | func validateHex(input string) error { 325 | if len(input) < 3 || input[0] != '%' || !ishex(input[1]) || !ishex(input[2]) { 326 | if len(input) > 3 { 327 | input = input[:3] 328 | } 329 | return url.EscapeError(input) 330 | } 331 | return nil 332 | } 333 | func pathUnescape(input string, mode pathEncoding) (string, error) { 334 | // Count %, check that they're well-formed. 335 | percentCount := 0 336 | for i := 0; i < len(input); { 337 | switch input[i] { 338 | case '%': 339 | percentCount++ 340 | if err := validateHex(input[i:]); err != nil { 341 | return "", err 342 | } 343 | i += 3 344 | default: 345 | i++ 346 | } 347 | } 348 | if percentCount == 0 { 349 | return input, nil 350 | } 351 | 352 | var sb strings.Builder 353 | sb.Grow(len(input) - 2*percentCount) 354 | for i := 0; i < len(input); i++ { 355 | switch input[i] { 356 | case '%': 357 | if mode == pathEncodeMulti && pathIsHexSlash(input[i:]) { 358 | // Multi doesn't escape /, so we don't escape. 359 | sb.WriteString("%2F") 360 | } else { 361 | sb.WriteByte(unhex(input[i+1])<<4 | unhex(input[i+2])) 362 | } 363 | i += 2 364 | default: 365 | sb.WriteByte(input[i]) 366 | } 367 | } 368 | return sb.String(), nil 369 | } 370 | -------------------------------------------------------------------------------- /internal/gen/vanguard/test/v1/testv1connect/content.connect.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | // Code generated by protoc-gen-connect-go. DO NOT EDIT. 16 | // 17 | // Source: vanguard/test/v1/content.proto 18 | 19 | package testv1connect 20 | 21 | import ( 22 | connect "connectrpc.com/connect" 23 | v1 "connectrpc.com/vanguard/internal/gen/vanguard/test/v1" 24 | context "context" 25 | errors "errors" 26 | httpbody "google.golang.org/genproto/googleapis/api/httpbody" 27 | emptypb "google.golang.org/protobuf/types/known/emptypb" 28 | http "net/http" 29 | strings "strings" 30 | ) 31 | 32 | // This is a compile-time assertion to ensure that this generated file and the connect package are 33 | // compatible. If you get a compiler error that this constant is not defined, this code was 34 | // generated with a version of connect newer than the one compiled into your binary. You can fix the 35 | // problem by either regenerating this code with an older version of connect or updating the connect 36 | // version compiled into your binary. 37 | const _ = connect.IsAtLeastVersion1_13_0 38 | 39 | const ( 40 | // ContentServiceName is the fully-qualified name of the ContentService service. 41 | ContentServiceName = "vanguard.test.v1.ContentService" 42 | ) 43 | 44 | // These constants are the fully-qualified names of the RPCs defined in this package. They're 45 | // exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. 46 | // 47 | // Note that these are different from the fully-qualified method names used by 48 | // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to 49 | // reflection-formatted method names, remove the leading slash and convert the remaining slash to a 50 | // period. 51 | const ( 52 | // ContentServiceIndexProcedure is the fully-qualified name of the ContentService's Index RPC. 53 | ContentServiceIndexProcedure = "/vanguard.test.v1.ContentService/Index" 54 | // ContentServiceUploadProcedure is the fully-qualified name of the ContentService's Upload RPC. 55 | ContentServiceUploadProcedure = "/vanguard.test.v1.ContentService/Upload" 56 | // ContentServiceDownloadProcedure is the fully-qualified name of the ContentService's Download RPC. 57 | ContentServiceDownloadProcedure = "/vanguard.test.v1.ContentService/Download" 58 | // ContentServiceSubscribeProcedure is the fully-qualified name of the ContentService's Subscribe 59 | // RPC. 60 | ContentServiceSubscribeProcedure = "/vanguard.test.v1.ContentService/Subscribe" 61 | ) 62 | 63 | // ContentServiceClient is a client for the vanguard.test.v1.ContentService service. 64 | type ContentServiceClient interface { 65 | // Index returns a html index page at the given path. 66 | Index(context.Context, *connect.Request[v1.IndexRequest]) (*connect.Response[httpbody.HttpBody], error) 67 | // Upload a file to the given path. 68 | Upload(context.Context) *connect.ClientStreamForClient[v1.UploadRequest, emptypb.Empty] 69 | // Download a file from the given path. 70 | Download(context.Context, *connect.Request[v1.DownloadRequest]) (*connect.ServerStreamForClient[v1.DownloadResponse], error) 71 | // Subscribe to updates for changes to content. 72 | Subscribe(context.Context) *connect.BidiStreamForClient[v1.SubscribeRequest, v1.SubscribeResponse] 73 | } 74 | 75 | // NewContentServiceClient constructs a client for the vanguard.test.v1.ContentService service. By 76 | // default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, 77 | // and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the 78 | // connect.WithGRPC() or connect.WithGRPCWeb() options. 79 | // 80 | // The URL supplied here should be the base URL for the Connect or gRPC server (for example, 81 | // http://api.acme.com or https://acme.com/grpc). 82 | func NewContentServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ContentServiceClient { 83 | baseURL = strings.TrimRight(baseURL, "/") 84 | contentServiceMethods := v1.File_vanguard_test_v1_content_proto.Services().ByName("ContentService").Methods() 85 | return &contentServiceClient{ 86 | index: connect.NewClient[v1.IndexRequest, httpbody.HttpBody]( 87 | httpClient, 88 | baseURL+ContentServiceIndexProcedure, 89 | connect.WithSchema(contentServiceMethods.ByName("Index")), 90 | connect.WithClientOptions(opts...), 91 | ), 92 | upload: connect.NewClient[v1.UploadRequest, emptypb.Empty]( 93 | httpClient, 94 | baseURL+ContentServiceUploadProcedure, 95 | connect.WithSchema(contentServiceMethods.ByName("Upload")), 96 | connect.WithClientOptions(opts...), 97 | ), 98 | download: connect.NewClient[v1.DownloadRequest, v1.DownloadResponse]( 99 | httpClient, 100 | baseURL+ContentServiceDownloadProcedure, 101 | connect.WithSchema(contentServiceMethods.ByName("Download")), 102 | connect.WithClientOptions(opts...), 103 | ), 104 | subscribe: connect.NewClient[v1.SubscribeRequest, v1.SubscribeResponse]( 105 | httpClient, 106 | baseURL+ContentServiceSubscribeProcedure, 107 | connect.WithSchema(contentServiceMethods.ByName("Subscribe")), 108 | connect.WithClientOptions(opts...), 109 | ), 110 | } 111 | } 112 | 113 | // contentServiceClient implements ContentServiceClient. 114 | type contentServiceClient struct { 115 | index *connect.Client[v1.IndexRequest, httpbody.HttpBody] 116 | upload *connect.Client[v1.UploadRequest, emptypb.Empty] 117 | download *connect.Client[v1.DownloadRequest, v1.DownloadResponse] 118 | subscribe *connect.Client[v1.SubscribeRequest, v1.SubscribeResponse] 119 | } 120 | 121 | // Index calls vanguard.test.v1.ContentService.Index. 122 | func (c *contentServiceClient) Index(ctx context.Context, req *connect.Request[v1.IndexRequest]) (*connect.Response[httpbody.HttpBody], error) { 123 | return c.index.CallUnary(ctx, req) 124 | } 125 | 126 | // Upload calls vanguard.test.v1.ContentService.Upload. 127 | func (c *contentServiceClient) Upload(ctx context.Context) *connect.ClientStreamForClient[v1.UploadRequest, emptypb.Empty] { 128 | return c.upload.CallClientStream(ctx) 129 | } 130 | 131 | // Download calls vanguard.test.v1.ContentService.Download. 132 | func (c *contentServiceClient) Download(ctx context.Context, req *connect.Request[v1.DownloadRequest]) (*connect.ServerStreamForClient[v1.DownloadResponse], error) { 133 | return c.download.CallServerStream(ctx, req) 134 | } 135 | 136 | // Subscribe calls vanguard.test.v1.ContentService.Subscribe. 137 | func (c *contentServiceClient) Subscribe(ctx context.Context) *connect.BidiStreamForClient[v1.SubscribeRequest, v1.SubscribeResponse] { 138 | return c.subscribe.CallBidiStream(ctx) 139 | } 140 | 141 | // ContentServiceHandler is an implementation of the vanguard.test.v1.ContentService service. 142 | type ContentServiceHandler interface { 143 | // Index returns a html index page at the given path. 144 | Index(context.Context, *connect.Request[v1.IndexRequest]) (*connect.Response[httpbody.HttpBody], error) 145 | // Upload a file to the given path. 146 | Upload(context.Context, *connect.ClientStream[v1.UploadRequest]) (*connect.Response[emptypb.Empty], error) 147 | // Download a file from the given path. 148 | Download(context.Context, *connect.Request[v1.DownloadRequest], *connect.ServerStream[v1.DownloadResponse]) error 149 | // Subscribe to updates for changes to content. 150 | Subscribe(context.Context, *connect.BidiStream[v1.SubscribeRequest, v1.SubscribeResponse]) error 151 | } 152 | 153 | // NewContentServiceHandler builds an HTTP handler from the service implementation. It returns the 154 | // path on which to mount the handler and the handler itself. 155 | // 156 | // By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf 157 | // and JSON codecs. They also support gzip compression. 158 | func NewContentServiceHandler(svc ContentServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { 159 | contentServiceMethods := v1.File_vanguard_test_v1_content_proto.Services().ByName("ContentService").Methods() 160 | contentServiceIndexHandler := connect.NewUnaryHandler( 161 | ContentServiceIndexProcedure, 162 | svc.Index, 163 | connect.WithSchema(contentServiceMethods.ByName("Index")), 164 | connect.WithHandlerOptions(opts...), 165 | ) 166 | contentServiceUploadHandler := connect.NewClientStreamHandler( 167 | ContentServiceUploadProcedure, 168 | svc.Upload, 169 | connect.WithSchema(contentServiceMethods.ByName("Upload")), 170 | connect.WithHandlerOptions(opts...), 171 | ) 172 | contentServiceDownloadHandler := connect.NewServerStreamHandler( 173 | ContentServiceDownloadProcedure, 174 | svc.Download, 175 | connect.WithSchema(contentServiceMethods.ByName("Download")), 176 | connect.WithHandlerOptions(opts...), 177 | ) 178 | contentServiceSubscribeHandler := connect.NewBidiStreamHandler( 179 | ContentServiceSubscribeProcedure, 180 | svc.Subscribe, 181 | connect.WithSchema(contentServiceMethods.ByName("Subscribe")), 182 | connect.WithHandlerOptions(opts...), 183 | ) 184 | return "/vanguard.test.v1.ContentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 185 | switch r.URL.Path { 186 | case ContentServiceIndexProcedure: 187 | contentServiceIndexHandler.ServeHTTP(w, r) 188 | case ContentServiceUploadProcedure: 189 | contentServiceUploadHandler.ServeHTTP(w, r) 190 | case ContentServiceDownloadProcedure: 191 | contentServiceDownloadHandler.ServeHTTP(w, r) 192 | case ContentServiceSubscribeProcedure: 193 | contentServiceSubscribeHandler.ServeHTTP(w, r) 194 | default: 195 | http.NotFound(w, r) 196 | } 197 | }) 198 | } 199 | 200 | // UnimplementedContentServiceHandler returns CodeUnimplemented from all methods. 201 | type UnimplementedContentServiceHandler struct{} 202 | 203 | func (UnimplementedContentServiceHandler) Index(context.Context, *connect.Request[v1.IndexRequest]) (*connect.Response[httpbody.HttpBody], error) { 204 | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("vanguard.test.v1.ContentService.Index is not implemented")) 205 | } 206 | 207 | func (UnimplementedContentServiceHandler) Upload(context.Context, *connect.ClientStream[v1.UploadRequest]) (*connect.Response[emptypb.Empty], error) { 208 | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("vanguard.test.v1.ContentService.Upload is not implemented")) 209 | } 210 | 211 | func (UnimplementedContentServiceHandler) Download(context.Context, *connect.Request[v1.DownloadRequest], *connect.ServerStream[v1.DownloadResponse]) error { 212 | return connect.NewError(connect.CodeUnimplemented, errors.New("vanguard.test.v1.ContentService.Download is not implemented")) 213 | } 214 | 215 | func (UnimplementedContentServiceHandler) Subscribe(context.Context, *connect.BidiStream[v1.SubscribeRequest, v1.SubscribeResponse]) error { 216 | return connect.NewError(connect.CodeUnimplemented, errors.New("vanguard.test.v1.ContentService.Subscribe is not implemented")) 217 | } 218 | -------------------------------------------------------------------------------- /codec.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "bytes" 19 | "encoding/json" 20 | "fmt" 21 | 22 | "google.golang.org/protobuf/encoding/protojson" 23 | "google.golang.org/protobuf/proto" 24 | "google.golang.org/protobuf/reflect/protoreflect" 25 | ) 26 | 27 | // Codec is a message encoding format. It handles unmarshalling 28 | // messages from bytes and back. 29 | type Codec interface { 30 | // Name returns the name of this codec. This is used in content-type 31 | // strings to indicate this codec in the various RPC protocols. 32 | Name() string 33 | // MarshalAppend marshals the given message to bytes, appended to the 34 | // given base byte slice. The given slice may be empty, but its 35 | // capacity should be used when marshalling to bytes to reduce 36 | // additional allocations. 37 | MarshalAppend(base []byte, msg proto.Message) ([]byte, error) 38 | // Unmarshal unmarshals the given data into the given target message. 39 | Unmarshal(data []byte, msg proto.Message) error 40 | } 41 | 42 | // StableCodec is an encoding format that can produce stable, deterministic 43 | // output when marshalling data. This stable form is the result of the 44 | // MarshalAppendStable method. So the codec's MarshalAppend method is 45 | // free to produce unstable/non-deterministic output, if useful for 46 | // improved performance. The performance penalty of stable output will 47 | // only be taken when necessary. 48 | // 49 | // This is used to encode messages that end up in the URL query string, 50 | // for the Connect protocol when unary methods use the HTTP GET method. 51 | // If the codec in use does not implement StableCodec then HTTP GET 52 | // methods will not be used; a Transcoder will send all unary RPCs that use the 53 | // Connect protocol and that codec as POST requests. 54 | type StableCodec interface { 55 | Codec 56 | 57 | // MarshalAppendStable is the same as MarshalAppend except that the 58 | // bytes produced must be deterministic and stable. Ideally, the 59 | // produced bytes represent a *canonical* encoding. But this is not 60 | // required as many codecs (including binary Protobuf and JSON) do 61 | // not have a well-defined canonical encoding format. 62 | MarshalAppendStable(b []byte, msg proto.Message) ([]byte, error) 63 | // IsBinary returns true for non-text formats. This is used to decide 64 | // whether the message query string parameter should be base64-encoded. 65 | IsBinary() bool 66 | } 67 | 68 | // RESTCodec is a Codec with additional methods for marshalling and unmarshalling 69 | // individual fields of a message. This is necessary to support query string 70 | // variables and request and response bodies whose value is a specific field, not 71 | // an entire message. The extra methods are only used by the REST protocol. 72 | type RESTCodec interface { 73 | Codec 74 | 75 | // MarshalAppendField marshals just the given field of the given message to 76 | // bytes, and appends it to the given base byte slice. 77 | MarshalAppendField(base []byte, msg proto.Message, field protoreflect.FieldDescriptor) ([]byte, error) 78 | // UnmarshalField unmarshals the given data into the given field of the given 79 | // message. 80 | UnmarshalField(data []byte, msg proto.Message, field protoreflect.FieldDescriptor) error 81 | } 82 | 83 | // JSONCodec implements [Codec], [StableCodec], and [RESTCodec] for the JSON 84 | // format. It uses the [protojson] package for its implementation. 85 | type JSONCodec struct { 86 | MarshalOptions protojson.MarshalOptions 87 | UnmarshalOptions protojson.UnmarshalOptions 88 | } 89 | 90 | var _ StableCodec = JSONCodec{} 91 | var _ RESTCodec = JSONCodec{} 92 | 93 | // NewJSONCodec is the default codec factory used for the codec named 94 | // "json". The given resolver is used to unmarshal extensions and also to 95 | // marshal and unmarshal instances of google.protobuf.Any. 96 | // 97 | // By default, the returned codec is configured to emit unpopulated fields 98 | // when marshalling and to discard unknown fields when unmarshalling. 99 | func NewJSONCodec(res TypeResolver) *JSONCodec { 100 | return &JSONCodec{ 101 | MarshalOptions: protojson.MarshalOptions{Resolver: res, EmitUnpopulated: true}, 102 | UnmarshalOptions: protojson.UnmarshalOptions{Resolver: res, DiscardUnknown: true}, 103 | } 104 | } 105 | 106 | // Name returns "json". Implements [Codec]. 107 | func (j JSONCodec) Name() string { 108 | return CodecJSON 109 | } 110 | 111 | // IsBinary returns false, indicating that JSON is a text format. Implements 112 | // [StableCodec]. 113 | func (j JSONCodec) IsBinary() bool { 114 | return false 115 | } 116 | 117 | // MarshalAppend implements [Codec]. 118 | func (j JSONCodec) MarshalAppend(base []byte, msg proto.Message) ([]byte, error) { 119 | return j.MarshalOptions.MarshalAppend(base, msg) 120 | } 121 | 122 | // MarshalAppendStable implements [StableCodec]. 123 | func (j JSONCodec) MarshalAppendStable(base []byte, msg proto.Message) ([]byte, error) { 124 | data, err := j.MarshalOptions.MarshalAppend(base, msg) 125 | if err != nil { 126 | return nil, err 127 | } 128 | return jsonStabilize(data) 129 | } 130 | 131 | // MarshalAppendField implements [RESTCodec]. 132 | func (j JSONCodec) MarshalAppendField(base []byte, msg proto.Message, field protoreflect.FieldDescriptor) ([]byte, error) { 133 | if field.Message() != nil && field.Cardinality() != protoreflect.Repeated { 134 | return j.MarshalAppend(base, msg.ProtoReflect().Get(field).Message().Interface()) 135 | } 136 | opts := j.MarshalOptions // copy marshal options, so we might modify them 137 | msgReflect := msg.ProtoReflect() 138 | if !msgReflect.Has(field) { 139 | if field.HasPresence() { 140 | // At this point in a request flow, we should have already used the message 141 | // to populate the URI path and query string, so it should be safe to mutate 142 | // it. In the response flow, nothing looks at the message except the 143 | // marshalling step. So, again, mutation should be okay. 144 | msgReflect.Set(field, msgReflect.Get(field)) 145 | } else { 146 | // Setting the field (like above) won't help due to implicit presence. 147 | // So instead, force the default value to be marshalled. 148 | opts.EmitUnpopulated = true 149 | } 150 | } 151 | 152 | // We could possibly manually perform the marshaling, but that is 153 | // a decent bit of protojson to reproduce (lot of new code to test 154 | // and to maintain) and risks inadvertently diverging from protojson. 155 | wholeMessage, err := opts.MarshalAppend(base, msg) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | // We have to dig a repeated field out of the message we just marshalled. 161 | dec := json.NewDecoder(bytes.NewReader(wholeMessage)) 162 | tok, err := dec.Token() 163 | if err != nil { 164 | return nil, err 165 | } 166 | if tok != json.Delim('{') { 167 | return nil, fmt.Errorf("JSON should be an object and begin with '{'; instead got %v", tok) 168 | } 169 | fieldName := j.fieldName(field) 170 | for dec.More() { 171 | keyTok, err := dec.Token() 172 | if err != nil { 173 | return nil, err 174 | } 175 | key, ok := keyTok.(string) 176 | if !ok { 177 | return nil, fmt.Errorf("JSON object key should be a string; instead got %T", keyTok) 178 | } 179 | var val json.RawMessage 180 | if err := dec.Decode(&val); err != nil { 181 | return nil, err 182 | } 183 | if key == fieldName { 184 | return val, nil 185 | } 186 | } 187 | return nil, fmt.Errorf("JSON does not contain key %s", fieldName) 188 | } 189 | 190 | // UnmarshalField implements [RESTCodec]. 191 | func (j JSONCodec) UnmarshalField(data []byte, msg proto.Message, field protoreflect.FieldDescriptor) error { 192 | if field.Message() != nil && field.Cardinality() != protoreflect.Repeated { 193 | return j.Unmarshal(data, msg.ProtoReflect().Mutable(field).Message().Interface()) 194 | } 195 | // It would be nice if we could weave a bufferPool to here... 196 | fieldName := j.fieldName(field) 197 | buf := bytes.NewBuffer(make([]byte, 0, len(fieldName)+len(data)+3)) 198 | buf.WriteByte('{') 199 | if err := json.NewEncoder(buf).Encode(fieldName); err != nil { 200 | return err 201 | } 202 | buf.WriteByte(':') 203 | buf.Write(data) 204 | buf.WriteByte('}') 205 | // We could possibly manually perform the unmarshaling, but that is 206 | // a decent bit of protojson to reproduce (lot of new code to test 207 | // and to maintain) and risks inadvertently diverging from protojson. 208 | return j.Unmarshal(buf.Bytes(), msg) 209 | } 210 | 211 | // Unmarshal implements [Codec]. 212 | func (j JSONCodec) Unmarshal(bytes []byte, msg proto.Message) error { 213 | return j.UnmarshalOptions.Unmarshal(bytes, msg) 214 | } 215 | 216 | func (j JSONCodec) fieldName(field protoreflect.FieldDescriptor) string { 217 | if !j.MarshalOptions.UseProtoNames { 218 | return field.JSONName() 219 | } 220 | if field.IsExtension() { 221 | // unlikely... 222 | return "[" + string(field.FullName()) + "]" 223 | } 224 | return string(field.Name()) 225 | } 226 | 227 | // ProtoCodec implements [Codec] and [StableCodec] for the binary Protobuf 228 | // format. It uses the [proto] package for its implementation. 229 | type ProtoCodec struct { 230 | unmarshal proto.UnmarshalOptions 231 | } 232 | 233 | var _ StableCodec = (*ProtoCodec)(nil) 234 | 235 | // NewProtoCodec is the default codec factory used for the codec name "proto". 236 | // The given resolver is used to unmarshal extensions. 237 | func NewProtoCodec(res TypeResolver) *ProtoCodec { 238 | return &ProtoCodec{ 239 | unmarshal: proto.UnmarshalOptions{Resolver: res}, 240 | } 241 | } 242 | 243 | // Name returns "proto". Implements [Codec]. 244 | func (p *ProtoCodec) Name() string { 245 | return CodecProto 246 | } 247 | 248 | // IsBinary returns true, indicating that Protobuf is a binary format. Implements 249 | // [StableCodec]. 250 | func (p *ProtoCodec) IsBinary() bool { 251 | return true 252 | } 253 | 254 | // MarshalAppend implements [Codec]. 255 | func (p *ProtoCodec) MarshalAppend(base []byte, msg proto.Message) ([]byte, error) { 256 | return proto.MarshalOptions{}.MarshalAppend(base, msg) 257 | } 258 | 259 | // MarshalAppendStable implements [StableCodec]. 260 | func (p *ProtoCodec) MarshalAppendStable(base []byte, msg proto.Message) ([]byte, error) { 261 | return proto.MarshalOptions{Deterministic: true}.MarshalAppend(base, msg) 262 | } 263 | 264 | // Unmarshal implements [Codec]. 265 | func (p *ProtoCodec) Unmarshal(bytes []byte, msg proto.Message) error { 266 | return p.unmarshal.Unmarshal(bytes, msg) 267 | } 268 | 269 | func jsonStabilize(data []byte) ([]byte, error) { 270 | // Because json.Compact only removes whitespace, never elongating data, it is 271 | // safe to use the same backing slice as source and destination. This is safe 272 | // for the same reason that copy is safe even when the two slices overlap. 273 | buf := bytes.NewBuffer(data[:0]) 274 | if err := json.Compact(buf, data); err != nil { 275 | return nil, err 276 | } 277 | return buf.Bytes(), nil 278 | } 279 | 280 | type codecMap map[string]func(TypeResolver) Codec 281 | 282 | func (m codecMap) get(name string, resolver TypeResolver) Codec { 283 | if m == nil { 284 | return nil 285 | } 286 | codecFn, ok := m[name] 287 | if !ok { 288 | return nil 289 | } 290 | return codecFn(resolver) 291 | } 292 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023-2025 Buf Technologies, Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /internal/gen/vanguard/test/v1/content_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 16 | // versions: 17 | // - protoc-gen-go-grpc v1.5.1 18 | // - protoc (unknown) 19 | // source: vanguard/test/v1/content.proto 20 | 21 | package testv1 22 | 23 | import ( 24 | context "context" 25 | httpbody "google.golang.org/genproto/googleapis/api/httpbody" 26 | grpc "google.golang.org/grpc" 27 | codes "google.golang.org/grpc/codes" 28 | status "google.golang.org/grpc/status" 29 | emptypb "google.golang.org/protobuf/types/known/emptypb" 30 | ) 31 | 32 | // This is a compile-time assertion to ensure that this generated file 33 | // is compatible with the grpc package it is being compiled against. 34 | // Requires gRPC-Go v1.64.0 or later. 35 | const _ = grpc.SupportPackageIsVersion9 36 | 37 | const ( 38 | ContentService_Index_FullMethodName = "/vanguard.test.v1.ContentService/Index" 39 | ContentService_Upload_FullMethodName = "/vanguard.test.v1.ContentService/Upload" 40 | ContentService_Download_FullMethodName = "/vanguard.test.v1.ContentService/Download" 41 | ContentService_Subscribe_FullMethodName = "/vanguard.test.v1.ContentService/Subscribe" 42 | ) 43 | 44 | // ContentServiceClient is the client API for ContentService service. 45 | // 46 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 47 | type ContentServiceClient interface { 48 | // Index returns a html index page at the given path. 49 | Index(ctx context.Context, in *IndexRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error) 50 | // Upload a file to the given path. 51 | Upload(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[UploadRequest, emptypb.Empty], error) 52 | // Download a file from the given path. 53 | Download(ctx context.Context, in *DownloadRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DownloadResponse], error) 54 | // Subscribe to updates for changes to content. 55 | Subscribe(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SubscribeRequest, SubscribeResponse], error) 56 | } 57 | 58 | type contentServiceClient struct { 59 | cc grpc.ClientConnInterface 60 | } 61 | 62 | func NewContentServiceClient(cc grpc.ClientConnInterface) ContentServiceClient { 63 | return &contentServiceClient{cc} 64 | } 65 | 66 | func (c *contentServiceClient) Index(ctx context.Context, in *IndexRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error) { 67 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 68 | out := new(httpbody.HttpBody) 69 | err := c.cc.Invoke(ctx, ContentService_Index_FullMethodName, in, out, cOpts...) 70 | if err != nil { 71 | return nil, err 72 | } 73 | return out, nil 74 | } 75 | 76 | func (c *contentServiceClient) Upload(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[UploadRequest, emptypb.Empty], error) { 77 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 78 | stream, err := c.cc.NewStream(ctx, &ContentService_ServiceDesc.Streams[0], ContentService_Upload_FullMethodName, cOpts...) 79 | if err != nil { 80 | return nil, err 81 | } 82 | x := &grpc.GenericClientStream[UploadRequest, emptypb.Empty]{ClientStream: stream} 83 | return x, nil 84 | } 85 | 86 | // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 87 | type ContentService_UploadClient = grpc.ClientStreamingClient[UploadRequest, emptypb.Empty] 88 | 89 | func (c *contentServiceClient) Download(ctx context.Context, in *DownloadRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DownloadResponse], error) { 90 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 91 | stream, err := c.cc.NewStream(ctx, &ContentService_ServiceDesc.Streams[1], ContentService_Download_FullMethodName, cOpts...) 92 | if err != nil { 93 | return nil, err 94 | } 95 | x := &grpc.GenericClientStream[DownloadRequest, DownloadResponse]{ClientStream: stream} 96 | if err := x.ClientStream.SendMsg(in); err != nil { 97 | return nil, err 98 | } 99 | if err := x.ClientStream.CloseSend(); err != nil { 100 | return nil, err 101 | } 102 | return x, nil 103 | } 104 | 105 | // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 106 | type ContentService_DownloadClient = grpc.ServerStreamingClient[DownloadResponse] 107 | 108 | func (c *contentServiceClient) Subscribe(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[SubscribeRequest, SubscribeResponse], error) { 109 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 110 | stream, err := c.cc.NewStream(ctx, &ContentService_ServiceDesc.Streams[2], ContentService_Subscribe_FullMethodName, cOpts...) 111 | if err != nil { 112 | return nil, err 113 | } 114 | x := &grpc.GenericClientStream[SubscribeRequest, SubscribeResponse]{ClientStream: stream} 115 | return x, nil 116 | } 117 | 118 | // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 119 | type ContentService_SubscribeClient = grpc.BidiStreamingClient[SubscribeRequest, SubscribeResponse] 120 | 121 | // ContentServiceServer is the server API for ContentService service. 122 | // All implementations must embed UnimplementedContentServiceServer 123 | // for forward compatibility. 124 | type ContentServiceServer interface { 125 | // Index returns a html index page at the given path. 126 | Index(context.Context, *IndexRequest) (*httpbody.HttpBody, error) 127 | // Upload a file to the given path. 128 | Upload(grpc.ClientStreamingServer[UploadRequest, emptypb.Empty]) error 129 | // Download a file from the given path. 130 | Download(*DownloadRequest, grpc.ServerStreamingServer[DownloadResponse]) error 131 | // Subscribe to updates for changes to content. 132 | Subscribe(grpc.BidiStreamingServer[SubscribeRequest, SubscribeResponse]) error 133 | mustEmbedUnimplementedContentServiceServer() 134 | } 135 | 136 | // UnimplementedContentServiceServer must be embedded to have 137 | // forward compatible implementations. 138 | // 139 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 140 | // pointer dereference when methods are called. 141 | type UnimplementedContentServiceServer struct{} 142 | 143 | func (UnimplementedContentServiceServer) Index(context.Context, *IndexRequest) (*httpbody.HttpBody, error) { 144 | return nil, status.Errorf(codes.Unimplemented, "method Index not implemented") 145 | } 146 | func (UnimplementedContentServiceServer) Upload(grpc.ClientStreamingServer[UploadRequest, emptypb.Empty]) error { 147 | return status.Errorf(codes.Unimplemented, "method Upload not implemented") 148 | } 149 | func (UnimplementedContentServiceServer) Download(*DownloadRequest, grpc.ServerStreamingServer[DownloadResponse]) error { 150 | return status.Errorf(codes.Unimplemented, "method Download not implemented") 151 | } 152 | func (UnimplementedContentServiceServer) Subscribe(grpc.BidiStreamingServer[SubscribeRequest, SubscribeResponse]) error { 153 | return status.Errorf(codes.Unimplemented, "method Subscribe not implemented") 154 | } 155 | func (UnimplementedContentServiceServer) mustEmbedUnimplementedContentServiceServer() {} 156 | func (UnimplementedContentServiceServer) testEmbeddedByValue() {} 157 | 158 | // UnsafeContentServiceServer may be embedded to opt out of forward compatibility for this service. 159 | // Use of this interface is not recommended, as added methods to ContentServiceServer will 160 | // result in compilation errors. 161 | type UnsafeContentServiceServer interface { 162 | mustEmbedUnimplementedContentServiceServer() 163 | } 164 | 165 | func RegisterContentServiceServer(s grpc.ServiceRegistrar, srv ContentServiceServer) { 166 | // If the following call pancis, it indicates UnimplementedContentServiceServer was 167 | // embedded by pointer and is nil. This will cause panics if an 168 | // unimplemented method is ever invoked, so we test this at initialization 169 | // time to prevent it from happening at runtime later due to I/O. 170 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 171 | t.testEmbeddedByValue() 172 | } 173 | s.RegisterService(&ContentService_ServiceDesc, srv) 174 | } 175 | 176 | func _ContentService_Index_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 177 | in := new(IndexRequest) 178 | if err := dec(in); err != nil { 179 | return nil, err 180 | } 181 | if interceptor == nil { 182 | return srv.(ContentServiceServer).Index(ctx, in) 183 | } 184 | info := &grpc.UnaryServerInfo{ 185 | Server: srv, 186 | FullMethod: ContentService_Index_FullMethodName, 187 | } 188 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 189 | return srv.(ContentServiceServer).Index(ctx, req.(*IndexRequest)) 190 | } 191 | return interceptor(ctx, in, info, handler) 192 | } 193 | 194 | func _ContentService_Upload_Handler(srv interface{}, stream grpc.ServerStream) error { 195 | return srv.(ContentServiceServer).Upload(&grpc.GenericServerStream[UploadRequest, emptypb.Empty]{ServerStream: stream}) 196 | } 197 | 198 | // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 199 | type ContentService_UploadServer = grpc.ClientStreamingServer[UploadRequest, emptypb.Empty] 200 | 201 | func _ContentService_Download_Handler(srv interface{}, stream grpc.ServerStream) error { 202 | m := new(DownloadRequest) 203 | if err := stream.RecvMsg(m); err != nil { 204 | return err 205 | } 206 | return srv.(ContentServiceServer).Download(m, &grpc.GenericServerStream[DownloadRequest, DownloadResponse]{ServerStream: stream}) 207 | } 208 | 209 | // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 210 | type ContentService_DownloadServer = grpc.ServerStreamingServer[DownloadResponse] 211 | 212 | func _ContentService_Subscribe_Handler(srv interface{}, stream grpc.ServerStream) error { 213 | return srv.(ContentServiceServer).Subscribe(&grpc.GenericServerStream[SubscribeRequest, SubscribeResponse]{ServerStream: stream}) 214 | } 215 | 216 | // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. 217 | type ContentService_SubscribeServer = grpc.BidiStreamingServer[SubscribeRequest, SubscribeResponse] 218 | 219 | // ContentService_ServiceDesc is the grpc.ServiceDesc for ContentService service. 220 | // It's only intended for direct use with grpc.RegisterService, 221 | // and not to be introspected or modified (even as a copy) 222 | var ContentService_ServiceDesc = grpc.ServiceDesc{ 223 | ServiceName: "vanguard.test.v1.ContentService", 224 | HandlerType: (*ContentServiceServer)(nil), 225 | Methods: []grpc.MethodDesc{ 226 | { 227 | MethodName: "Index", 228 | Handler: _ContentService_Index_Handler, 229 | }, 230 | }, 231 | Streams: []grpc.StreamDesc{ 232 | { 233 | StreamName: "Upload", 234 | Handler: _ContentService_Upload_Handler, 235 | ClientStreams: true, 236 | }, 237 | { 238 | StreamName: "Download", 239 | Handler: _ContentService_Download_Handler, 240 | ServerStreams: true, 241 | }, 242 | { 243 | StreamName: "Subscribe", 244 | Handler: _ContentService_Subscribe_Handler, 245 | ServerStreams: true, 246 | ClientStreams: true, 247 | }, 248 | }, 249 | Metadata: "vanguard/test/v1/content.proto", 250 | } 251 | -------------------------------------------------------------------------------- /protocol_http.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023-2025 Buf Technologies, Inc. 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 | package vanguard 16 | 17 | import ( 18 | "bytes" 19 | "errors" 20 | "fmt" 21 | "net/http" 22 | "net/url" 23 | "strconv" 24 | "strings" 25 | 26 | "connectrpc.com/connect" 27 | "google.golang.org/genproto/googleapis/api/annotations" 28 | httpbody "google.golang.org/genproto/googleapis/api/httpbody" 29 | "google.golang.org/genproto/googleapis/rpc/status" 30 | "google.golang.org/protobuf/encoding/protojson" 31 | "google.golang.org/protobuf/proto" 32 | "google.golang.org/protobuf/reflect/protoreflect" 33 | "google.golang.org/protobuf/types/known/anypb" 34 | ) 35 | 36 | //nolint:gochecknoglobals 37 | var httpStatusCodeFromRPCIndex = [...]int{ 38 | http.StatusOK, // 0 OK 39 | 499, // 1 Canceled 40 | http.StatusInternalServerError, // 2 Unknown 41 | http.StatusBadRequest, // 3 InvalidArgument 42 | http.StatusGatewayTimeout, // 4 DeadlineExceeded 43 | http.StatusNotFound, // 5 NotFound 44 | http.StatusConflict, // 6 AlreadyExists 45 | http.StatusForbidden, // 7 PermissionDenied 46 | http.StatusTooManyRequests, // 8 ResourceExhausted 47 | http.StatusBadRequest, // 9 FailedPrecondition 48 | http.StatusConflict, // 10 Aborted 49 | http.StatusBadRequest, // 11 OutOfRange 50 | http.StatusNotImplemented, // 12 Unimplemented 51 | http.StatusInternalServerError, // 13 Internal 52 | http.StatusServiceUnavailable, // 14 Unavailable 53 | http.StatusInternalServerError, // 15 DataLoss 54 | http.StatusUnauthorized, // 16 Unauthenticated 55 | } 56 | 57 | func httpStatusCodeFromRPC(code connect.Code) int { 58 | if int(code) > len(httpStatusCodeFromRPCIndex) { 59 | return http.StatusInternalServerError 60 | } 61 | return httpStatusCodeFromRPCIndex[code] 62 | } 63 | 64 | func httpStatusCodeToRPC(code int) connect.Code { 65 | switch code { 66 | case http.StatusOK: 67 | return 0 // OK 68 | case http.StatusBadRequest: 69 | return connect.CodeInternal // Internal 70 | case http.StatusUnauthorized: 71 | return connect.CodeUnauthenticated // Unauthenticated 72 | case http.StatusForbidden: 73 | return connect.CodePermissionDenied // PermissionDenied 74 | case http.StatusNotFound: 75 | return connect.CodeUnimplemented // Unimplemented 76 | case http.StatusTooManyRequests, 77 | http.StatusBadGateway, 78 | http.StatusServiceUnavailable, 79 | http.StatusGatewayTimeout: 80 | return connect.CodeUnavailable // Unavailable 81 | default: 82 | return connect.CodeUnknown // Unknown 83 | } 84 | } 85 | 86 | func httpWriteError(rsp http.ResponseWriter, err error) { 87 | codec := protojson.MarshalOptions{ 88 | EmitUnpopulated: true, 89 | } 90 | cerr := asConnectError(err) 91 | statusCode := httpStatusCodeFromRPC(cerr.Code()) 92 | stat := grpcStatusFromError(cerr) 93 | 94 | hdr := rsp.Header() 95 | hdr.Set("Content-Type", "application/json") 96 | hdr.Set("Content-Encoding", "identity") 97 | bin, err := codec.MarshalAppend(nil, stat) 98 | if err != nil { 99 | statusCode = http.StatusInternalServerError 100 | hdr.Set("Content-Type", "application/json") 101 | bin = []byte(`{"code": 12, "message":"` + err.Error() + `"}`) 102 | } 103 | rsp.WriteHeader(statusCode) 104 | _, _ = rsp.Write(bin) 105 | } 106 | 107 | func httpErrorFromResponse(statusCode int, contentType string, src *bytes.Buffer) *connect.Error { 108 | if statusCode == http.StatusOK { 109 | return nil 110 | } 111 | codec := protojson.UnmarshalOptions{} 112 | var stat status.Status 113 | if err := codec.Unmarshal(src.Bytes(), &stat); err != nil { 114 | body, err := anypb.New(&httpbody.HttpBody{ 115 | ContentType: contentType, 116 | Data: src.Bytes(), 117 | }) 118 | if err != nil { 119 | return connect.NewError(connect.CodeInternal, err) 120 | } 121 | stat.Details = append(stat.Details, body) 122 | stat.Code = int32(httpStatusCodeToRPC(statusCode)) //nolint:gosec 123 | stat.Message = http.StatusText(statusCode) 124 | } 125 | connectErr := connect.NewWireError( 126 | connect.Code(stat.GetCode()), //nolint:gosec // No information loss. 127 | errors.New(stat.GetMessage()), 128 | ) 129 | for _, msg := range stat.GetDetails() { 130 | errDetail, _ := connect.NewErrorDetail(msg) 131 | connectErr.AddDetail(errDetail) 132 | } 133 | return connectErr 134 | } 135 | 136 | func httpSplitVar(variable string, multi bool) []string { 137 | if !multi { 138 | return []string{pathEscape(variable, pathEncodeSingle)} 139 | } 140 | values := strings.Split(variable, "/") 141 | for i, value := range values { 142 | values[i] = pathEscape(value, pathEncodeMulti) 143 | } 144 | return values 145 | } 146 | 147 | // httpEncodePathValues encodes the given message for the route target. 148 | // The path and query are returned. 149 | func httpEncodePathValues(input protoreflect.Message, target *routeTarget) ( 150 | path string, query url.Values, err error, 151 | ) { 152 | // Copy segments to build URL 153 | segments := make([]string, len(target.path)) 154 | copy(segments, target.path) 155 | 156 | // Count the number of times each field path is used. 157 | // Singular fields can be referenced multiple times. 158 | // Repeated fields can be referenced multiple times up to the number of elements. 159 | // Map fields are not supported. 160 | // A field count of 0 indicates that the field has not been used. 161 | fieldPathCounts := make(map[string]int) 162 | 163 | // Find path variables and set the path segments, ensuring that the path 164 | // matches the pattern. 165 | for _, variable := range target.vars { 166 | fieldIndex := fieldPathCounts[variable.fieldPath] 167 | fieldPathCounts[variable.fieldPath]++ 168 | 169 | value, err := getParameter(input, variable.fields, fieldIndex) 170 | if err != nil { 171 | return "", nil, err 172 | } 173 | variableSize := variable.size() 174 | 175 | values := httpSplitVar(value, variableSize != 1) 176 | 177 | if variableSize > 1 && len(values) != variableSize { 178 | return "", nil, fmt.Errorf( 179 | "expected field %s to match pattern %q: instead got %q", 180 | variable.fieldPath, strings.Join(variable.index(segments), "/"), value, 181 | ) 182 | } 183 | 184 | for i, part := range values { 185 | segmentIndex := variable.start + i 186 | if segmentIndex >= len(segments) { 187 | segments = append(segments, part) // nozero 188 | continue 189 | } 190 | 191 | segment := segments[segmentIndex] 192 | if segment == "*" || segment == "**" { 193 | segments[segmentIndex] = part 194 | } else if segment != part { 195 | return "", nil, fmt.Errorf( 196 | "expected field %s to match pattern %q: instead got %q", 197 | variable.fieldPath, strings.Join(variable.index(segments), "/"), value, 198 | ) 199 | } 200 | } 201 | } 202 | 203 | // Encode the path URL. 204 | var pathSize int 205 | for _, segment := range segments { 206 | pathSize += len(segment) + 1 207 | } 208 | var pathURL strings.Builder 209 | pathURL.Grow(pathSize) 210 | for _, segment := range segments { 211 | pathURL.WriteByte('/') 212 | pathURL.WriteString(segment) 213 | } 214 | if target.verb != "" { 215 | pathURL.WriteByte(':') 216 | pathURL.WriteString(target.verb) 217 | } 218 | path = pathURL.String() 219 | 220 | if target.requestBodyFieldPath == "*" { 221 | // No query values are included in the URL. 222 | return path, nil, nil 223 | } else if target.requestBodyFieldPath != "" { 224 | // Exclude the request body path from the query. 225 | // A count of negative one means *all* values (even for repeated fields) are already used. 226 | fieldPathCounts[target.requestBodyFieldPath] = -1 227 | } 228 | 229 | // Build the query by traversing the fields in the message. 230 | // Any non path or request body fields are included in the query. 231 | query = url.Values{} 232 | 233 | fields := make([]protoreflect.FieldDescriptor, 0, 3) 234 | var fieldRanger func(field protoreflect.FieldDescriptor, value protoreflect.Value) bool 235 | var fieldError error 236 | fieldRanger = func(field protoreflect.FieldDescriptor, value protoreflect.Value) bool { 237 | fields = append(fields, field) 238 | defer func() { fields = fields[:len(fields)-1] }() // pop 239 | // The field path is resolved to the proto format for lookups, 240 | // but the query values are encoded in their JSON representation. 241 | fieldPath := resolveFieldDescriptorsToPath(fields, false) 242 | fieldIndex := fieldPathCounts[fieldPath] 243 | 244 | switch { 245 | case !isParameterType(field): 246 | if fieldIndex != 0 { 247 | break 248 | } 249 | if field.IsMap() || field.IsList() || 250 | field.Kind() != protoreflect.MessageKind { 251 | fieldError = fmt.Errorf( 252 | "unexpected field %s: cannot be URL encoded", 253 | fieldPath, 254 | ) 255 | return false 256 | } 257 | // Recurse into the message fields. 258 | value.Message().Range(fieldRanger) 259 | if fieldError != nil { 260 | return false 261 | } 262 | fieldIndex++ 263 | case field.IsList(): 264 | if fieldIndex < 0 { 265 | break 266 | } 267 | listValue := value.List() 268 | for fieldIndex < listValue.Len() { 269 | value := listValue.Get(fieldIndex) 270 | encoded, err := marshalFieldValue(field, value) 271 | if err != nil { 272 | fieldError = err 273 | return false 274 | } 275 | query.Add( 276 | // Query values are encoded in their JSON representation. 277 | resolveFieldDescriptorsToPath(fields, true), 278 | string(encoded), 279 | ) 280 | fieldIndex++ 281 | } 282 | case fieldIndex == 0: 283 | encoded, err := marshalFieldValue(field, value) 284 | if err != nil { 285 | fieldError = err 286 | return false 287 | } 288 | query.Add( 289 | // Query values are encoded in their JSON representation. 290 | resolveFieldDescriptorsToPath(fields, true), 291 | string(encoded), 292 | ) 293 | fieldIndex++ 294 | } 295 | 296 | fieldPathCounts[fieldPath] = fieldIndex 297 | return true 298 | } 299 | input.Range(fieldRanger) 300 | if fieldError != nil { 301 | return "", nil, fieldError 302 | } 303 | return path, query, nil 304 | } 305 | 306 | func httpExtractTrailers(headers http.Header, knownTrailerKeys headerKeys) http.Header { 307 | var trailers http.Header 308 | for key, vals := range headers { 309 | if strings.HasPrefix(key, http.TrailerPrefix) { 310 | if trailers == nil { 311 | trailers = make(http.Header, len(knownTrailerKeys)) 312 | } 313 | trailers[strings.TrimPrefix(key, http.TrailerPrefix)] = vals 314 | delete(headers, key) 315 | continue 316 | } 317 | if _, expected := knownTrailerKeys[key]; expected { 318 | if trailers == nil { 319 | trailers = make(http.Header, len(knownTrailerKeys)) 320 | } 321 | trailers[key] = vals 322 | delete(headers, key) 323 | continue 324 | } 325 | } 326 | return trailers 327 | } 328 | 329 | func httpMergeTrailers(header http.Header, trailer http.Header) { 330 | for key, vals := range trailer { 331 | if !strings.HasPrefix(key, http.TrailerPrefix) { 332 | key = http.TrailerPrefix + key 333 | } 334 | for _, val := range vals { 335 | header.Add(key, val) 336 | } 337 | } 338 | } 339 | 340 | func httpExtractContentLength(headers http.Header) (int, error) { 341 | contentLenStr := headers.Get("Content-Length") 342 | if contentLenStr == "" { 343 | return -1, nil 344 | } 345 | i, err := strconv.Atoi(contentLenStr) 346 | if err != nil { 347 | return 0, fmt.Errorf("could not parse content-length %q: %w", contentLenStr, err) 348 | } 349 | if i < 0 { 350 | return 0, fmt.Errorf("content-length %d should not be negative", i) 351 | } 352 | headers.Del("Content-Length") 353 | return i, nil 354 | } 355 | 356 | func getHTTPRuleExtension(desc protoreflect.MethodDescriptor) (*annotations.HttpRule, bool) { 357 | opts := desc.Options() 358 | if !proto.HasExtension(opts, annotations.E_Http) { 359 | return nil, false 360 | } 361 | rule, ok := proto.GetExtension(opts, annotations.E_Http).(*annotations.HttpRule) 362 | return rule, ok 363 | } 364 | --------------------------------------------------------------------------------