├── .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 |
--------------------------------------------------------------------------------