diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fd0a64ef..dc0a0ac7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,7 +2,7 @@ name: ci on: push: branches: [main, v2] - tags: ['v*'] + tags: ['v*', 'cmd/connect-go-v2-migrate/v*'] pull_request: branches: [main, v2] schedule: @@ -77,3 +77,19 @@ jobs: check-latest: true - name: Run Slow Tests run: make slowtest + migrate: + name: migrate + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + - name: Install Go + uses: actions/setup-go@v7 + with: + # only the latest + go-version: 1.27.x + check-latest: true + - name: Test Migration Tool + run: make testmigrate diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml index 9893e7fd..e45c069c 100644 --- a/.github/workflows/windows.yaml +++ b/.github/workflows/windows.yaml @@ -35,4 +35,4 @@ jobs: shell: bash run: | go build ./... - go test -vet=off -race ./... + go test -vet=off -timeout 30m ./... diff --git a/.golangci.yml b/.golangci.yml index 6c4c77b4..994d7132 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -74,7 +74,15 @@ linters: # We need our duplex HTTP call to have access to the context. - linters: - containedctx - path: duplex_http_call.go + path: connecthttp/duplex_http_call.go + # The in-process stream needs to capture the call context for cancellation. + - linters: + - containedctx + path: connectinprocess/stream.go + # The test conn wraps a stream and holds the call context to inspect headers. + - linters: + - containedctx + path: connecthttp/interceptor_ext_test.go # blockUntilResponseReady returns the shared response, whose body is # closed once by CloseRead rather than at each call site. - linters: @@ -84,7 +92,7 @@ linters: - linters: - gochecknoglobals - gochecknoinits - path: example_init_test.go + path: connecthttp/example_init_test.go # We purposefully do an ineffectual assignment for an example. - linters: - ineffassign @@ -98,57 +106,84 @@ linters: - linters: - errcheck - gosec - path: error_writer_example_test.go + path: connecthttp/error_writer_example_test.go # It should be crystal clear that Connect uses plain *http.Clients. - linters: - revive - staticcheck - path: client_example_test.go + path: connecthttp/client_example_test.go # Don't complain about timeout management or lack of output assertions in examples. - linters: - gosec - testableexamples - path: handler_example_test.go + path: connecthttp/handler_example_test.go # No output assertions needed for these examples. - linters: - testableexamples - path: error_writer_example_test.go + path: connecthttp/error_writer_example_test.go - linters: - testableexamples - path: error_not_modified_example_test.go + path: connecthttp/error_not_modified_example_test.go - linters: - testableexamples - path: error_example_test.go + path: connecthttp/error_example_test.go # In examples, it's okay to use http.ListenAndServe. - linters: - gosec - path: error_not_modified_example_test.go + path: connecthttp/error_not_modified_example_test.go # There are many instances where we want to keep unused parameters # as a matter of style or convention, for example when a context.Context # is the first parameter, we choose to just globally ignore this. - linters: - revive text: '^unused-parameter: ' + # The interface is an unexported v1 internal interface. + - linters: + - interfacebloat + path: connecthttp/protocol.go # We want to return explicit nils in protocol_grpc.go - linters: - revive - path: protocol_grpc.go + path: connecthttp/protocol_grpc.go text: '^if-return: ' + # The gRPC error marshaling keeps its nested structure for clarity. + - linters: + - nestif + path: connecthttp/protocol_grpc.go + # connectWireDetail embeds *anypb.Any, keep the explicit selector. + - linters: + - staticcheck + path: connecthttp/protocol_connect.*\.go + text: 'QF1008: ' # We want to return explicit nils in protocol_connect.go - linters: - revive - path: protocol_connect.go + path: connecthttp/protocol_connect.go text: '^if-return: ' # We want to return explicit nils in error_writer.go - linters: - revive - path: error_writer.go + path: connecthttp/error_writer.go text: '^if-return: ' # We want to set http.Server's logger - linters: - forbidigo path: internal/memhttp text: use of `log.(New|Logger|Lshortfile)` forbidden by pattern .* + # The migration CLI writes its report to stdout. + - linters: + - forbidigo + path: cmd/connect-go-v2-migrate + text: use of `fmt.Print.*` forbidden by pattern .* + # The migration CLI keeps its rewrite rules in static lookup tables. + - linters: + - gochecknoglobals + path: cmd/connect-go-v2-migrate + # The interceptor example sets a *log.Logger on its stream wrapper. + - linters: + - forbidigo + path: connecthttp/interceptor_example_test.go + text: use of `log.Logger` forbidden by pattern .* # We want to show examples with http.Get - linters: - noctx @@ -161,10 +196,18 @@ linters: - linters: - canonicalheader path: .*_test.go + # TestServer is a large table-driven test. + - linters: + - gocyclo + path: connecthttp/connect_ext_test.go + # The benchmark builds a fresh option slice per client. + - linters: + - gocritic + path: connecthttp/bench_test.go # Allow Code pointer receiver for UnmarshalText method - linters: - recvcheck - path: code.go + path: connect.go # Avoid false positives for int overflow in tests - linters: - gosec diff --git a/Makefile b/Makefile index 3e4c3833..b79691c1 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ clean: ## Delete intermediate build artifacts git clean -Xdf .PHONY: test -test: shorttest slowtest +test: shorttest slowtest testmigrate .PHONY: shorttest shorttest: build ## Run unit tests @@ -52,6 +52,10 @@ slowtest: build runconformance: build ## Run conformance test suite cd internal/conformance && ./runconformance.sh +.PHONY: testmigrate +testmigrate: ## Run connect-go-v2-migrate test suite + cd ./cmd/connect-go-v2-migrate && go test ./... + .PHONY: bench bench: BENCH ?= .* bench: build ## Run benchmarks for root package @@ -60,28 +64,35 @@ bench: build ## Run benchmarks for root package .PHONY: build build: generate ## Build all packages go build ./... + cd ./cmd/connect-go-v2-migrate && go build -o /dev/null . .PHONY: install install: ## Install all binaries go install ./... + cd ./cmd/connect-go-v2-migrate && go install ./... .PHONY: lint lint: $(BIN)/golangci-lint $(BIN)/buf ## Lint Go and protobuf go vet ./... golangci-lint run --modules-download-mode=readonly --timeout=3m0s + cd ./cmd/connect-go-v2-migrate && go vet ./... + cd ./cmd/connect-go-v2-migrate && golangci-lint run --modules-download-mode=readonly --timeout=3m0s buf lint buf format -d --exit-code .PHONY: lintfix lintfix: $(BIN)/golangci-lint $(BIN)/buf ## Automatically fix some lint errors golangci-lint run --fix --modules-download-mode=readonly --timeout=3m0s + cd ./cmd/connect-go-v2-migrate && golangci-lint run --fix --modules-download-mode=readonly --timeout=3m0s buf format -w .PHONY: generate generate: $(BIN)/buf $(BIN)/protoc-gen-go $(BIN)/protoc-gen-connect-go $(BIN)/license-header ## Regenerate code and licenses go mod tidy cd ./internal/conformance && go mod tidy + cd ./cmd/connect-go-v2-migrate && go mod tidy buf generate + cd internal/conformance && buf generate --template buf.gen.yaml buf.build/connectrpc/conformance cd ./cmd/protoc-gen-connect-go/internal && \ find ./testdata -maxdepth 1 -type d \( ! -name testdata \) | xargs -n 1 -I % bash -c "cd '%' && buf generate" license-header \ @@ -92,6 +103,7 @@ generate: $(BIN)/buf $(BIN)/protoc-gen-go $(BIN)/protoc-gen-connect-go $(BIN)/li .PHONY: upgrade upgrade: ## Upgrade dependencies go get -u -t ./... && go mod tidy -v + cd ./cmd/connect-go-v2-migrate && go get -u -t ./... && go mod tidy -v .PHONY: checkgenerate checkgenerate: diff --git a/README.md b/README.md index 31bd414a..9dedc66b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ on [connectrpc.com][docs] (especially the [Getting Started] guide for Go), the Curious what all this looks like in practice? From a [Protobuf schema](internal/proto/connect/ping/v1/ping.proto), we generate [a small RPC -package](internal/gen/simple/connect/ping/v1/pingv1connect/ping.connect.go). Using that +package](internal/gen/connect/ping/v1/pingv1connect/ping.connect.go). Using that package, we can build a server. This example is available at [internal/example](internal/example): ```go @@ -63,10 +63,11 @@ import ( "log" "net/http" - "connectrpc.com/connect" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" - "connectrpc.com/validate" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" + "connectrpc.com/validate/v2" ) type PingServer struct { @@ -80,16 +81,13 @@ func (ps *PingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv } func main() { + // Register services on a *connect.Server, then mount it with connecthttp. + // Interceptors are arguments to NewServer. Validation via Protovalidate is + // almost always recommended. + server := connect.NewServer(validate.NewServerInterceptor()) + pingv1connect.RegisterPingServiceHandler(server, &PingServer{}) mux := http.NewServeMux() - // The generated constructors return a path and a plain net/http - // handler. - mux.Handle( - pingv1connect.NewPingServiceHandler( - &PingServer{}, - // Validation via Protovalidate is almost always recommended - connect.WithInterceptors(validate.NewInterceptor()), - ), - ) + connecthttp.Mount(mux, server) p := new(http.Protocols) p.SetHTTP1(true) // For gRPC clients, it's convenient to support HTTP/2 without TLS. @@ -116,17 +114,19 @@ import ( "log" "net/http" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" ) func main() { - client := pingv1connect.NewPingServiceClient( - http.DefaultClient, - "http://localhost:8080/", + client := connect.NewClient( + connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080"), ) + pingClient := pingv1connect.NewPingServiceClient(client) req := &pingv1.PingRequest{Number: 42} - res, err := client.Ping(context.Background(), req) + res, err := pingClient.Ping(context.Background(), req) if err != nil { log.Fatalln(err) } @@ -138,6 +138,13 @@ Of course, `http.ListenAndServe` and `http.DefaultClient` aren't fit for production use! See Connect's [deployment docs][docs-deployment] for a guide to configuring timeouts, connection pools, observability, and h2c. +## Migrating from v1 + +If you are migrating from v1 to v2, check out our [migration guide](./docs/v2-migration.md). + +This project follows semantic versioning. The module `/v2` suffix is part of +the module `connectrpc.com/connect/v2`. + ## Ecosystem * [grpchealth]: gRPC-compatible health checks for connect-go @@ -148,16 +155,24 @@ configuring timeouts, connection pools, observability, and h2c. * [Buf Studio]: web UI for ad-hoc RPCs * [conformance]: Connect, gRPC, and gRPC-Web interoperability tests -## Status: Stable +## Status -This module is stable. It supports: +This module, `connectrpc.com/connect/v2`, is in beta. +The `v2` module will be published on the `main` branch of the repository when released. + +## Support and versioning + +`connect-go` supports: * The two most recent major releases of Go (the same versions of Go that continue to [receive security patches][go-support-policy]). * [APIv2] of Protocol Buffers in Go (`google.golang.org/protobuf`). -Within those parameters, `connect` follows semantic versioning. We will -_not_ make breaking changes in the 1.x series of releases. +Within those parameters, `connect-go` follows semantic versioning. + +Module `connectrpc.com/connect` is the `v1` module. It remains stable and +supported indefinitely. The `v1` module lives on the `v1` branch. +See the [v2 guide](docs/v2-guide.md) for an overview of what changed and why. ## Legal diff --git a/RELEASE.md b/RELEASE.md index de7e70a5..653adea4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,6 +1,6 @@ # Releasing connect-go -This document outlines how to create a release of connect-go. +This document outlines how to create a release of connect-go v2 from the main branch. v1 releases follow the same steps on the `v1` branch. 1. Clone the repo, ensuring you have the latest main. @@ -9,8 +9,8 @@ This document outlines how to create a release of connect-go. * If there are features being released, remove the `-dev` suffix, set the MINOR number to be 1 more than the MINOR number of the [latest release], and set the PATCH number to `0`. In the common case, the diff here will just be to remove the `-dev` suffix. ```patch - -const Version = "1.14.0-dev" - +const Version = "1.14.0" + -const Version = "2.0.0-dev" + +const Version = "2.0.0" ``` 3. Check for any changes in [cmd/protoc-gen-connect-go/main.go](cmd/protoc-gen-connect-go/main.go) that require a version restriction. A constant `IsAtLeastVersionX_Y_Z` should be defined in [connect.go](connect.go) if generated code has begun to use a new API. Make sure the generated code references this constant. If a new constant has been added since the last release, ensure that the name of the constant matches the version being released ([Example PR #496](https://github.com/connectrpc/connect-go/pull/496)). @@ -35,10 +35,24 @@ This document outlines how to create a release of connect-go. 8. On a new branch, open [connect.go](connect.go) and change the `Version` to increment the minor tag and append the `-dev` suffix. Use the next minor release - we never anticipate bugs and patch releases. ```patch - -const Version = "1.14.0" - +const Version = "1.15.0-dev" + -const Version = "2.0.0" + +const Version = "2.1.0-dev" ``` 9. Open a PR titled "Back to development" ([Example PR #662](https://github.com/connectrpc/connect-go/pull/662)). Once it's reviewed and CI passes, merge it. [latest release]: https://github.com/connectrpc/connect-go/releases/latest + +# Releasing connect-go-v2-migrate + +`cmd/connect-go-v2-migrate` is its own Go module, `connectrpc.com/connect/v2/cmd/connect-go-v2-migrate`. + +1. Using the Github UI, create a new release. + - Under “Choose a tag”, type in “cmd/connect-go-v2-migrate/vX.Y.Z” to create a new tag for the release upon publish. The directory prefix is what keeps it from colliding with the library’s tags in this repository. The version is the module’s own and starts at `v1.0.0`. + - Target the main branch. + - Title the Release “connect-go-v2-migrate vX.Y.Z”. + - Do not click “set as latest release”. That badge is picked by date, so it should stay on the most recent connect-go release. + - Set the last connect-go-v2-migrate release as the “Previous tag”, so the generated notes cover this module rather than the library. + - Click “Generate release notes” to autogenerate release notes. + +2. Publish the release. diff --git a/buf.gen.yaml b/buf.gen.yaml index 45beb82a..6f9231f3 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -3,17 +3,12 @@ managed: enabled: true override: - file_option: go_package_prefix - value: connectrpc.com/connect/internal/gen + value: connectrpc.com/connect/v2/internal/gen plugins: - local: protoc-gen-go out: internal/gen opt: paths=source_relative - local: protoc-gen-connect-go - out: internal/gen/generics + out: internal/gen opt: paths=source_relative - - local: protoc-gen-connect-go - out: internal/gen/simple - opt: - - paths=source_relative - - simple clean: true diff --git a/buffer_pool.go b/buffer_pool.go deleted file mode 100644 index 006ad5be..00000000 --- a/buffer_pool.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "bytes" - "sync" -) - -const ( - initialBufferSize = 512 - maxRecycleBufferSize = 8 * 1024 * 1024 // if >8MiB, don't hold onto a buffer -) - -type bufferPool struct { - sync.Pool -} - -func newBufferPool() *bufferPool { - return &bufferPool{ - Pool: sync.Pool{ - New: func() any { - return bytes.NewBuffer(make([]byte, 0, initialBufferSize)) - }, - }, - } -} - -func (b *bufferPool) Get() *bytes.Buffer { - if buf, ok := b.Pool.Get().(*bytes.Buffer); ok { - return buf - } - return bytes.NewBuffer(make([]byte, 0, initialBufferSize)) -} - -func (b *bufferPool) Put(buffer *bytes.Buffer) { - if buffer.Cap() > maxRecycleBufferSize { - return - } - buffer.Reset() - b.Pool.Put(buffer) -} diff --git a/client.go b/client.go deleted file mode 100644 index d44e6e4a..00000000 --- a/client.go +++ /dev/null @@ -1,392 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" -) - -// Client is a reusable, concurrency-safe client for a single procedure. -// Depending on the procedure's type, use the CallUnary, CallClientStream, -// CallServerStream, or CallBidiStream method. -// -// By default, clients use the Connect protocol with the binary Protobuf Codec, -// ask for gzipped responses, and send uncompressed requests. To use the gRPC -// or gRPC-Web protocols, use the [WithGRPC] or [WithGRPCWeb] options. -type Client[Req, Res any] struct { - config *clientConfig - callUnary func(context.Context, *Request[Req]) (*Response[Res], error) - protocolClient protocolClient - err error -} - -// NewClient constructs a new Client. -func NewClient[Req, Res any](httpClient HTTPClient, url string, options ...ClientOption) *Client[Req, Res] { - client := &Client[Req, Res]{} - config, err := newClientConfig(url, options) - if err != nil { - client.err = err - return client - } - client.config = config - protocolClient, protocolErr := client.config.Protocol.NewClient( - &protocolClientParams{ - CompressionName: config.RequestCompressionName, - CompressionPools: newReadOnlyCompressionPools( - config.CompressionPools, - config.CompressionNames, - ), - Codec: config.Codec, - Protobuf: config.protobuf(), - CompressMinBytes: config.CompressMinBytes, - HTTPClient: httpClient, - URL: config.URL, - BufferPool: config.BufferPool, - ReadMaxBytes: config.ReadMaxBytes, - SendMaxBytes: config.SendMaxBytes, - EnableGet: config.EnableGet, - GetURLMaxBytes: config.GetURLMaxBytes, - GetUseFallback: config.GetUseFallback, - }, - ) - if protocolErr != nil { - client.err = protocolErr - return client - } - client.protocolClient = protocolClient - // Rather than applying unary interceptors along the hot path, we can do it - // once at client creation. - unarySpec := config.newSpec(StreamTypeUnary) - unaryFunc := UnaryFunc(func(ctx context.Context, request AnyRequest) (AnyResponse, error) { - conn := client.protocolClient.NewConn(ctx, unarySpec, request.Header()) - conn.onRequestSend(func(r *http.Request) { - request.setRequestMethod(r.Method) - callInfo, ok := clientCallInfoForContext(ctx) - if ok { - callInfo.method = r.Method - callInfo.responseSource = conn - } - }) - // Send always returns an io.EOF unless the error is from the client-side. - // We want the user to continue to call Receive in those cases to get the - // full error from the server-side. - if err := conn.Send(request.Any()); err != nil && !errors.Is(err, io.EOF) { - _ = conn.CloseRequest() - _ = conn.CloseResponse() - return nil, err - } - if err := conn.CloseRequest(); err != nil { - _ = conn.CloseResponse() - return nil, err - } - response, err := receiveUnaryResponse[Res](conn, config.Initializer) - if err != nil { - _ = conn.CloseResponse() - return nil, err - } - return response, conn.CloseResponse() - }) - if interceptor := config.Interceptor; interceptor != nil { - // interceptor is the full chain of all interceptors provided - unaryFunc = interceptor.WrapUnary(unaryFunc) - } - client.callUnary = func(ctx context.Context, request *Request[Req]) (*Response[Res], error) { - // To make the specification, peer, and RPC headers visible to the full - // interceptor chain (as though they were supplied by the caller), we'll - // add them here. - request.spec = unarySpec - request.peer = client.protocolClient.Peer() - protocolClient.WriteRequestHeader(StreamTypeUnary, request.Header()) - - // Also set them in the context if there's a call info present - callInfo, callInfoOk := clientCallInfoForContext(ctx) - if callInfoOk { - callInfo.peer = request.Peer() - callInfo.spec = request.Spec() - // A client could have set request headers in the call info OR the request wrapper - // So if a callInfo exists in context, merge any headers from there into the request wrapper - // so that all headers are sent in the request - mergeHeaders(request.Header(), callInfo.requestHeader) - - // Copy the call info into a sentinel value. This is so we can compare - // the sentinel value against the call info in context. If they're different, - // we can stop the request. This protects against changing the context in interceptors. - ctx = context.WithValue(ctx, sentinelContextKey{}, callInfo) - } - - response, err := unaryFunc(ctx, request) - if err != nil { - return nil, err - } - typed, ok := response.(*Response[Res]) - if !ok { - return nil, errorf(CodeInternal, "unexpected client response type %T", response) - } - return typed, nil - } - return client -} - -// CallUnary calls a request-response procedure. -func (c *Client[Req, Res]) CallUnary(ctx context.Context, request *Request[Req]) (*Response[Res], error) { - if c.err != nil { - return nil, c.err - } - return c.callUnary(ctx, request) -} - -// CallClientStream calls a client streaming procedure. -// -// Request headers can be sent via the [ClientStreamForClient.RequestHeader] method on the stream. Note that the -// request headers are not sent automatically when this method is invoked and instead require an explicit call to -// [ClientStreamForClient.Send]. -func (c *Client[Req, Res]) CallClientStream(ctx context.Context) *ClientStreamForClient[Req, Res] { - if c.err != nil { - return &ClientStreamForClient[Req, Res]{err: c.err} - } - return &ClientStreamForClient[Req, Res]{ - conn: c.newConn(ctx, StreamTypeClient, nil), - initializer: c.config.Initializer, - } -} - -// CallClientStreamSimple calls a client streaming procedure. -// -// Request headers should be set in a [CallInfo] object inside the context using [NewClientContext]. These headers are -// transmitted when this method is called and do not require an explicit call to [ClientStreamForClientSimple.Send]. -// -// In addition, when calling [ClientStreamForClientSimple.CloseAndReceive] on the returned stream, the returned response -// is the response type defined for the stream and _not_ a Connect [Response] wrapper type. As a result, any response -// headers and trailers should be read from the [CallInfo] object in context. -func (c *Client[Req, Res]) CallClientStreamSimple(ctx context.Context) (*ClientStreamForClientSimple[Req, Res], error) { - if c.err != nil { - return &ClientStreamForClientSimple[Req, Res]{ - stream: &ClientStreamForClient[Req, Res]{err: c.err}, - }, c.err - } - - stream := &ClientStreamForClientSimple[Req, Res]{ - stream: &ClientStreamForClient[Req, Res]{ - conn: c.newConn(ctx, StreamTypeClient, nil), - initializer: c.config.Initializer, - }, - } - if err := stream.Send(nil); err != nil { - return nil, err - } - return stream, nil -} - -// CallServerStream calls a server streaming procedure. -func (c *Client[Req, Res]) CallServerStream(ctx context.Context, request *Request[Req]) (*ServerStreamForClient[Res], error) { - if c.err != nil { - return nil, c.err - } - conn := c.newConn(ctx, StreamTypeServer, func(r *http.Request) { - request.method = r.Method - }) - request.peer = conn.Peer() - request.spec = conn.Spec() - - mergeHeaders(conn.RequestHeader(), request.header) - - // Send always returns an io.EOF unless the error is from the client-side. - // We want the user to continue to call Receive in those cases to get the - // full error from the server-side. - if err := conn.Send(request.Msg); err != nil && !errors.Is(err, io.EOF) { - _ = conn.CloseRequest() - _ = conn.CloseResponse() - return nil, err - } - if err := conn.CloseRequest(); err != nil { - return nil, err - } - return &ServerStreamForClient[Res]{ - conn: conn, - initializer: c.config.Initializer, - }, nil -} - -// CallBidiStream calls a bidirectional streaming procedure. -// -// Request headers can be sent via the [BidiStreamForClient.RequestHeader] method. Note that the -// request headers are not sent automatically when this method is invoked and instead require an explicit call to -// [BidiStreamForClient.Send]. -func (c *Client[Req, Res]) CallBidiStream(ctx context.Context) *BidiStreamForClient[Req, Res] { - if c.err != nil { - return &BidiStreamForClient[Req, Res]{err: c.err} - } - return &BidiStreamForClient[Req, Res]{ - conn: c.newConn(ctx, StreamTypeBidi, nil), - initializer: c.config.Initializer, - } -} - -// CallBidiStreamSimple calls a bidirectional streaming procedure. -// -// Request headers should be set in a [CallInfo] object inside the context using [NewClientContext]. These headers -// are transmitted when this method is called and do not require an explicit call to [BidiStreamForClient.Send]. -// -// Likewise, response headers and trailers should be read from the [CallInfo] object in context. -func (c *Client[Req, Res]) CallBidiStreamSimple(ctx context.Context) (*BidiStreamForClientSimple[Req, Res], error) { - if c.err != nil { - return &BidiStreamForClientSimple[Req, Res]{ - stream: &BidiStreamForClient[Req, Res]{err: c.err}, - }, c.err - } - - stream := &BidiStreamForClientSimple[Req, Res]{ - stream: &BidiStreamForClient[Req, Res]{ - conn: c.newConn(ctx, StreamTypeBidi, nil), - initializer: c.config.Initializer, - }, - } - - if err := stream.Send(nil); err != nil { - return nil, err - } - return stream, nil -} - -func (c *Client[Req, Res]) newConn(ctx context.Context, streamType StreamType, onRequestSend func(r *http.Request)) StreamingClientConn { - callInfo, callInfoOk := clientCallInfoForContext(ctx) - // Set values in the context if there's a call info present - if callInfoOk { - // Copy the call info into a sentinel value. This is so we can compare - // the sentinel value against the call info in context. If they're different, - // we can stop the request. This protects against changing the context in interceptors. - ctx = context.WithValue(ctx, sentinelContextKey{}, callInfo) - } - newConn := func(ctx context.Context, spec Spec) StreamingClientConn { - header := make(http.Header, 8) // arbitrary power of two, prevent immediate resizing - c.protocolClient.WriteRequestHeader(streamType, header) - conn := c.protocolClient.NewConn(ctx, spec, header) - conn.onRequestSend(onRequestSend) - return conn - } - if interceptor := c.config.Interceptor; interceptor != nil { - newConn = interceptor.WrapStreamingClient(newConn) - } - conn := newConn(ctx, c.config.newSpec(streamType)) - - // Set values in the context if there's a call info present - if callInfoOk { - callInfo.peer = conn.Peer() - callInfo.spec = conn.Spec() - callInfo.responseSource = conn - - // Merge any callInfo request headers first, then do the request, - // so that context headers show first in the list of headers. - mergeHeaders(conn.RequestHeader(), callInfo.RequestHeader()) - } - - return conn -} - -type clientConfig struct { - URL *url.URL - Protocol protocol - Procedure string - Schema any - Initializer maybeInitializer - CompressMinBytes int - Interceptor Interceptor - CompressionPools map[string]*compressionPool - CompressionNames []string - Codec Codec - RequestCompressionName string - BufferPool *bufferPool - ReadMaxBytes int - SendMaxBytes int - EnableGet bool - GetURLMaxBytes int - GetUseFallback bool - IdempotencyLevel IdempotencyLevel -} - -func newClientConfig(rawURL string, options []ClientOption) (*clientConfig, *Error) { - url, err := parseRequestURL(rawURL) - if err != nil { - return nil, err - } - protoPath := extractProtoPath(url.Path) - config := clientConfig{ - URL: url, - Protocol: &protocolConnect{}, - Procedure: protoPath, - CompressionPools: make(map[string]*compressionPool), - BufferPool: newBufferPool(), - } - withProtoBinaryCodec().applyToClient(&config) - withGzip().applyToClient(&config) - for _, opt := range options { - opt.applyToClient(&config) - } - if err := config.validate(); err != nil { - return nil, err - } - return &config, nil -} - -func (c *clientConfig) validate() *Error { - if c.Codec == nil || c.Codec.Name() == "" { - return errorf(CodeUnknown, "no codec configured") - } - if c.RequestCompressionName != "" && c.RequestCompressionName != compressionIdentity { - if _, ok := c.CompressionPools[c.RequestCompressionName]; !ok { - return errorf(CodeUnknown, "unknown compression %q", c.RequestCompressionName) - } - } - return nil -} - -func (c *clientConfig) protobuf() Codec { - if c.Codec.Name() == codecNameProto { - return c.Codec - } - return &protoBinaryCodec{} -} - -func (c *clientConfig) newSpec(t StreamType) Spec { - return Spec{ - StreamType: t, - Procedure: c.Procedure, - Schema: c.Schema, - IsClient: true, - IdempotencyLevel: c.IdempotencyLevel, - } -} - -func parseRequestURL(rawURL string) (*url.URL, *Error) { - url, err := url.ParseRequestURI(rawURL) - if err == nil { - return url, nil - } - if !strings.Contains(rawURL, "://") { - // URL doesn't have a scheme, so the user is likely accustomed to - // grpc-go's APIs. - err = fmt.Errorf( - "URL %q missing scheme: use http:// or https:// (unlike grpc-go)", - rawURL, - ) - } - return nil, NewError(CodeUnavailable, err) -} diff --git a/client_ext_test.go b/client_ext_test.go deleted file mode 100644 index ff42df12..00000000 --- a/client_ext_test.go +++ /dev/null @@ -1,990 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect_test - -import ( - "bytes" - "context" - "crypto/rand" - "errors" - "fmt" - "io" - "log" - "net" - "net/http" - "net/http/httptest" - "runtime" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "connectrpc.com/connect" - "connectrpc.com/connect/internal/assert" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/generics/connect/ping/v1/pingv1connect" - "connectrpc.com/connect/internal/memhttp/memhttptest" - "google.golang.org/protobuf/encoding/protowire" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/reflect/protoreflect" - "google.golang.org/protobuf/reflect/protoregistry" - "google.golang.org/protobuf/types/dynamicpb" -) - -func TestNewClient_InitFailure(t *testing.T) { - t.Parallel() - client := pingv1connect.NewPingServiceClient( - http.DefaultClient, - "http://127.0.0.1:8080", - // This triggers an error during initialization, so each call will short circuit returning an error. - connect.WithSendCompression("invalid"), - ) - validateExpectedError := func(t *testing.T, err error) { - t.Helper() - assert.NotNil(t, err) - var connectErr *connect.Error - assert.True(t, errors.As(err, &connectErr)) - assert.Equal(t, connectErr.Message(), `unknown compression "invalid"`) - } - - t.Run("unary", func(t *testing.T) { - t.Parallel() - _, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{})) - validateExpectedError(t, err) - }) - - t.Run("bidi", func(t *testing.T) { - t.Parallel() - bidiStream := client.CumSum(t.Context()) - err := bidiStream.Send(&pingv1.CumSumRequest{}) - validateExpectedError(t, err) - }) - - t.Run("client_stream", func(t *testing.T) { - t.Parallel() - clientStream := client.Sum(t.Context()) - err := clientStream.Send(&pingv1.SumRequest{}) - validateExpectedError(t, err) - }) - - t.Run("server_stream", func(t *testing.T) { - t.Parallel() - _, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{Number: 3})) - validateExpectedError(t, err) - }) -} - -func TestClientPeer(t *testing.T) { - t.Parallel() - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{})) - server := memhttptest.NewServer(t, mux) - - run := func(t *testing.T, unaryHTTPMethod string, opts ...connect.ClientOption) { - t.Helper() - client := pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - connect.WithClientOptions(opts...), - connect.WithInterceptors(&assertPeerInterceptor{t}), - ) - ctx := t.Context() - t.Run("unary", func(t *testing.T) { - unaryReq := connect.NewRequest[pingv1.PingRequest](nil) - _, err := client.Ping(ctx, unaryReq) - assert.Nil(t, err) - assert.Equal(t, unaryHTTPMethod, unaryReq.HTTPMethod()) - text := strings.Repeat(".", 256) - r, err := client.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Text: text})) - assert.Nil(t, err) - assert.Equal(t, r.Msg.GetText(), text) - }) - t.Run("client_stream", func(t *testing.T) { - clientStream := client.Sum(ctx) - t.Cleanup(func() { - _, closeErr := clientStream.CloseAndReceive() - assert.Nil(t, closeErr) - }) - assert.NotZero(t, clientStream.Peer().Addr) - assert.NotZero(t, clientStream.Peer().Protocol) - err := clientStream.Send(&pingv1.SumRequest{}) - assert.Nil(t, err) - }) - t.Run("server_stream", func(t *testing.T) { - serverStream, err := client.CountUp(ctx, connect.NewRequest(&pingv1.CountUpRequest{})) - t.Cleanup(func() { - assert.Nil(t, serverStream.Close()) - }) - assert.Nil(t, err) - }) - t.Run("bidi_stream", func(t *testing.T) { - bidiStream := client.CumSum(ctx) - t.Cleanup(func() { - assert.Nil(t, bidiStream.CloseRequest()) - assert.Nil(t, bidiStream.CloseResponse()) - }) - assert.NotZero(t, bidiStream.Peer().Addr) - assert.NotZero(t, bidiStream.Peer().Protocol) - err := bidiStream.Send(&pingv1.CumSumRequest{}) - assert.Nil(t, err) - }) - } - - t.Run("connect", func(t *testing.T) { - t.Parallel() - run(t, http.MethodPost) - }) - t.Run("connect+get", func(t *testing.T) { - t.Parallel() - run(t, http.MethodGet, - connect.WithHTTPGet(), - connect.WithSendGzip(), - ) - }) - t.Run("grpc", func(t *testing.T) { - t.Parallel() - run(t, http.MethodPost, connect.WithGRPC()) - }) - t.Run("grpcweb", func(t *testing.T) { - t.Parallel() - run(t, http.MethodPost, connect.WithGRPCWeb()) - }) -} - -func TestGetNotModified(t *testing.T) { - t.Parallel() - - const etag = "some-etag" - // Handlers should automatically set Vary to include request headers that are - // part of the RPC protocol. - expectVary := []string{"Accept-Encoding"} - - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(¬ModifiedPingServer{etag: etag})) - server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - connect.WithHTTPGet(), - ) - ctx := t.Context() - // unconditional request - unaryReq := connect.NewRequest(&pingv1.PingRequest{}) - res, err := client.Ping(ctx, unaryReq) - assert.Nil(t, err) - assert.Equal(t, res.Header().Get("Etag"), etag) - assert.Equal(t, res.Header().Values("Vary"), expectVary) - assert.Equal(t, http.MethodGet, unaryReq.HTTPMethod()) - - unaryReq = connect.NewRequest(&pingv1.PingRequest{}) - unaryReq.Header().Set("If-None-Match", etag) - _, err = client.Ping(ctx, unaryReq) - assert.NotNil(t, err) - assert.Equal(t, connect.CodeOf(err), connect.CodeUnknown) - assert.True(t, connect.IsNotModifiedError(err)) - var connectErr *connect.Error - assert.True(t, errors.As(err, &connectErr)) - assert.Equal(t, connectErr.Meta().Get("Etag"), etag) - assert.Equal(t, connectErr.Meta().Values("Vary"), expectVary) - assert.Equal(t, http.MethodGet, unaryReq.HTTPMethod()) -} - -func TestNotModifiedOnlyForGet(t *testing.T) { - t.Parallel() - - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(&alwaysNotModifiedPingServer{})) - server := memhttptest.NewServer(t, http.HandlerFunc(func(respWriter http.ResponseWriter, req *http.Request) { - req.URL.RawQuery = "cache-buster=1" - mux.ServeHTTP(respWriter, req) - })) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - - _, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{})) - assert.NotNil(t, err) - assert.Equal(t, connect.CodeOf(err), connect.CodeUnknown) - var connectErr *connect.Error - assert.True(t, errors.As(err, &connectErr)) - assert.Equal(t, connectErr.Message(), "not modified") -} - -func TestGetNoContentHeaders(t *testing.T) { - t.Parallel() - - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(&pingServer{})) - server := memhttptest.NewServer(t, http.HandlerFunc(func(respWriter http.ResponseWriter, req *http.Request) { - if len(req.Header.Values("content-type")) > 0 || - len(req.Header.Values("content-encoding")) > 0 || - len(req.Header.Values("content-length")) > 0 { - http.Error(respWriter, "GET request should not include content headers", http.StatusBadRequest) - } - mux.ServeHTTP(respWriter, req) - })) - client := pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - connect.WithHTTPGet(), - ) - ctx := t.Context() - - unaryReq := connect.NewRequest(&pingv1.PingRequest{}) - _, err := client.Ping(ctx, unaryReq) - assert.Nil(t, err) - assert.Equal(t, http.MethodGet, unaryReq.HTTPMethod()) -} - -type urlSizeRecordingTransport struct { - base http.RoundTripper - urlLen int -} - -func (t *urlSizeRecordingTransport) RoundTrip(req *http.Request) (*http.Response, error) { - t.urlLen = len(req.URL.String()) - return t.base.RoundTrip(req) -} - -func TestGetURLSizeBoundary(t *testing.T) { - t.Parallel() - - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(&pingServer{})) - server := memhttptest.NewServer(t, mux) - ctx := t.Context() - call := func(httpClient connect.HTTPClient, options ...connect.ClientOption) (*connect.Request[pingv1.PingRequest], error) { - client := pingv1connect.NewPingServiceClient( - httpClient, - server.URL(), - append([]connect.ClientOption{connect.WithHTTPGet()}, options...)..., - ) - request := connect.NewRequest(&pingv1.PingRequest{Text: "boundary"}) - _, err := client.Ping(ctx, request) - return request, err - } - - transport := &urlSizeRecordingTransport{base: server.Client().Transport} - unlimited, err := call(&http.Client{Transport: transport}) - assert.Nil(t, err) - assert.Equal(t, unlimited.HTTPMethod(), http.MethodGet) - urlSize := transport.urlLen - - atLimit, err := call(server.Client(), connect.WithHTTPGetMaxURLSize(urlSize, false)) - assert.Nil(t, err) - assert.Equal(t, atLimit.HTTPMethod(), http.MethodGet) - - atLimitWithFallback, err := call(server.Client(), connect.WithHTTPGetMaxURLSize(urlSize, true)) - assert.Nil(t, err) - assert.Equal(t, atLimitWithFallback.HTTPMethod(), http.MethodGet) - - overLimit, err := call(server.Client(), connect.WithHTTPGetMaxURLSize(urlSize-1, true)) - assert.Nil(t, err) - assert.Equal(t, overLimit.HTTPMethod(), http.MethodPost) -} - -func TestConnectionDropped(t *testing.T) { - t.Parallel() - ctx := t.Context() - for _, protocol := range []string{connect.ProtocolConnect, connect.ProtocolGRPC, connect.ProtocolGRPCWeb} { - var opts []connect.ClientOption - switch protocol { - case connect.ProtocolGRPC: - opts = []connect.ClientOption{connect.WithGRPC()} - case connect.ProtocolGRPCWeb: - opts = []connect.ClientOption{connect.WithGRPCWeb()} - } - t.Run(protocol, func(t *testing.T) { - t.Parallel() - httpClient := httpClientFunc(func(_ *http.Request) (*http.Response, error) { - return nil, io.EOF - }) - client := pingv1connect.NewPingServiceClient( - httpClient, - "http://1.2.3.4", - opts..., - ) - t.Run("unary", func(t *testing.T) { - t.Parallel() - req := connect.NewRequest[pingv1.PingRequest](nil) - _, err := client.Ping(ctx, req) - assert.NotNil(t, err) - if !assert.Equal(t, connect.CodeOf(err), connect.CodeUnavailable) { - t.Logf("err = %v\n%#v", err, err) - } - }) - t.Run("stream", func(t *testing.T) { - t.Parallel() - req := connect.NewRequest[pingv1.CountUpRequest](nil) - svrStream, err := client.CountUp(ctx, req) - if err == nil { - t.Cleanup(func() { - assert.Nil(t, svrStream.Close()) - }) - if !assert.False(t, svrStream.Receive()) { - return - } - err = svrStream.Err() - } - assert.NotNil(t, err) - if !assert.Equal(t, connect.CodeOf(err), connect.CodeUnavailable) { - t.Logf("err = %v\n%#v", err, err) - } - }) - }) - } -} - -func TestSpecSchema(t *testing.T) { - t.Parallel() - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{}, - connect.WithInterceptors(&assertSchemaInterceptor{t}), - )) - server := memhttptest.NewServer(t, mux) - ctx := t.Context() - client := pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - connect.WithInterceptors(&assertSchemaInterceptor{t}), - ) - t.Run("unary", func(t *testing.T) { - t.Parallel() - unaryReq := connect.NewRequest[pingv1.PingRequest](nil) - _, err := client.Ping(ctx, unaryReq) - assert.NotNil(t, unaryReq.Spec().Schema) - assert.Nil(t, err) - text := strings.Repeat(".", 256) - r, err := client.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Text: text})) - assert.Nil(t, err) - assert.Equal(t, r.Msg.GetText(), text) - }) - t.Run("bidi_stream", func(t *testing.T) { - t.Parallel() - bidiStream := client.CumSum(ctx) - t.Cleanup(func() { - assert.Nil(t, bidiStream.CloseRequest()) - assert.Nil(t, bidiStream.CloseResponse()) - }) - assert.NotZero(t, bidiStream.Spec().Schema) - err := bidiStream.Send(&pingv1.CumSumRequest{}) - assert.Nil(t, err) - }) -} - -func TestDynamicClient(t *testing.T) { - t.Parallel() - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{})) - server := memhttptest.NewServer(t, mux) - ctx := t.Context() - initializer := func(spec connect.Spec, msg any) error { - dynamic, ok := msg.(*dynamicpb.Message) - if !ok { - return nil - } - desc, ok := spec.Schema.(protoreflect.MethodDescriptor) - if !ok { - return fmt.Errorf("invalid schema type %T for %T message", spec.Schema, dynamic) - } - if spec.IsClient { - *dynamic = *dynamicpb.NewMessage(desc.Output()) - } else { - *dynamic = *dynamicpb.NewMessage(desc.Input()) - } - return nil - } - t.Run("unary", func(t *testing.T) { - t.Parallel() - desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.Ping") - assert.Nil(t, err) - methodDesc, ok := desc.(protoreflect.MethodDescriptor) - assert.True(t, ok) - client := connect.NewClient[dynamicpb.Message, dynamicpb.Message]( - server.Client(), - server.URL()+"/connect.ping.v1.PingService/Ping", - connect.WithSchema(methodDesc), - connect.WithIdempotency(connect.IdempotencyNoSideEffects), - connect.WithResponseInitializer(initializer), - ) - msg := dynamicpb.NewMessage(methodDesc.Input()) - msg.Set( - methodDesc.Input().Fields().ByName("number"), - protoreflect.ValueOfInt64(42), - ) - res, err := client.CallUnary(ctx, connect.NewRequest(msg)) - assert.Nil(t, err) - got := res.Msg.Get(methodDesc.Output().Fields().ByName("number")).Int() - assert.Equal(t, got, 42) - }) - t.Run("clientStream", func(t *testing.T) { - t.Parallel() - desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.Sum") - assert.Nil(t, err) - methodDesc, ok := desc.(protoreflect.MethodDescriptor) - assert.True(t, ok) - client := connect.NewClient[dynamicpb.Message, dynamicpb.Message]( - server.Client(), - server.URL()+"/connect.ping.v1.PingService/Sum", - connect.WithSchema(methodDesc), - connect.WithResponseInitializer(initializer), - ) - stream := client.CallClientStream(ctx) - msg := dynamicpb.NewMessage(methodDesc.Input()) - msg.Set( - methodDesc.Input().Fields().ByName("number"), - protoreflect.ValueOfInt64(42), - ) - assert.Nil(t, stream.Send(msg)) - assert.Nil(t, stream.Send(msg)) - rsp, err := stream.CloseAndReceive() - if !assert.Nil(t, err) { - return - } - got := rsp.Msg.Get(methodDesc.Output().Fields().ByName("sum")).Int() - assert.Equal(t, got, 42*2) - }) - t.Run("clientStreamSimple", func(t *testing.T) { - t.Parallel() - desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.Sum") - assert.Nil(t, err) - methodDesc, ok := desc.(protoreflect.MethodDescriptor) - assert.True(t, ok) - connected := make(chan struct{}) - transport := server.Transport() - dialContext := transport.DialContext - transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - close(connected) - return dialContext(ctx, network, addr) - } - client := connect.NewClient[dynamicpb.Message, dynamicpb.Message]( - &http.Client{Transport: transport}, - server.URL()+"/connect.ping.v1.PingService/Sum", - connect.WithSchema(methodDesc), - connect.WithResponseInitializer(initializer), - ) - stream, err := client.CallClientStreamSimple(ctx) - assert.Nil(t, err) - select { - case <-connected: - break - case <-time.After(time.Second): - t.Error("CallClientStreamSimple did not eagerly send headers") - } - msg := dynamicpb.NewMessage(methodDesc.Input()) - msg.Set( - methodDesc.Input().Fields().ByName("number"), - protoreflect.ValueOfInt64(42), - ) - assert.Nil(t, stream.Send(msg)) - assert.Nil(t, stream.Send(msg)) - rsp, err := stream.CloseAndReceive() - if !assert.Nil(t, err) { - return - } - got := rsp.Get(methodDesc.Output().Fields().ByName("sum")).Int() - assert.Equal(t, got, 42*2) - }) - t.Run("serverStream", func(t *testing.T) { - t.Parallel() - desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.CountUp") - assert.Nil(t, err) - methodDesc, ok := desc.(protoreflect.MethodDescriptor) - assert.True(t, ok) - client := connect.NewClient[dynamicpb.Message, dynamicpb.Message]( - server.Client(), - server.URL()+"/connect.ping.v1.PingService/CountUp", - connect.WithSchema(methodDesc), - connect.WithResponseInitializer(initializer), - ) - msg := dynamicpb.NewMessage(methodDesc.Input()) - msg.Set( - methodDesc.Input().Fields().ByName("number"), - protoreflect.ValueOfInt64(2), - ) - req := connect.NewRequest(msg) - stream, err := client.CallServerStream(ctx, req) - if !assert.Nil(t, err) { - return - } - for i := 1; stream.Receive(); i++ { - out := stream.Msg() - got := out.Get(methodDesc.Output().Fields().ByName("number")).Int() - assert.Equal(t, got, int64(i)) - } - assert.Nil(t, stream.Close()) - }) - t.Run("bidi", func(t *testing.T) { - t.Parallel() - desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.CumSum") - assert.Nil(t, err) - methodDesc, ok := desc.(protoreflect.MethodDescriptor) - assert.True(t, ok) - client := connect.NewClient[dynamicpb.Message, dynamicpb.Message]( - server.Client(), - server.URL()+"/connect.ping.v1.PingService/CumSum", - connect.WithSchema(methodDesc), - connect.WithResponseInitializer(initializer), - ) - stream := client.CallBidiStream(ctx) - msg := dynamicpb.NewMessage(methodDesc.Input()) - msg.Set( - methodDesc.Input().Fields().ByName("number"), - protoreflect.ValueOfInt64(42), - ) - assert.Nil(t, stream.Send(msg)) - assert.Nil(t, stream.CloseRequest()) - out, err := stream.Receive() - if assert.Nil(t, err) { - return - } - got := out.Get(methodDesc.Output().Fields().ByName("number")).Int() - assert.Equal(t, got, 42) - }) - t.Run("bidiSimple", func(t *testing.T) { - t.Parallel() - desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.CumSum") - assert.Nil(t, err) - methodDesc, ok := desc.(protoreflect.MethodDescriptor) - assert.True(t, ok) - connected := make(chan struct{}) - transport := server.Transport() - dialContext := transport.DialContext - transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - close(connected) - return dialContext(ctx, network, addr) - } - client := connect.NewClient[dynamicpb.Message, dynamicpb.Message]( - &http.Client{Transport: transport}, - server.URL()+"/connect.ping.v1.PingService/CumSum", - connect.WithSchema(methodDesc), - connect.WithResponseInitializer(initializer), - ) - stream, err := client.CallBidiStreamSimple(ctx) - assert.Nil(t, err) - select { - case <-connected: - break - case <-time.After(time.Second): - t.Error("CallBidiStreamSimple did not eagerly send headers") - } - msg := dynamicpb.NewMessage(methodDesc.Input()) - msg.Set( - methodDesc.Input().Fields().ByName("number"), - protoreflect.ValueOfInt64(42), - ) - assert.Nil(t, stream.Send(msg)) - assert.Nil(t, stream.CloseRequest()) - out, err := stream.Receive() - if assert.Nil(t, err) { - return - } - got := out.Get(methodDesc.Output().Fields().ByName("number")).Int() - assert.Equal(t, got, 42) - }) - t.Run("option", func(t *testing.T) { - t.Parallel() - desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.Ping") - assert.Nil(t, err) - methodDesc, ok := desc.(protoreflect.MethodDescriptor) - assert.True(t, ok) - optionCalled := false - client := connect.NewClient[dynamicpb.Message, dynamicpb.Message]( - server.Client(), - server.URL()+"/connect.ping.v1.PingService/Ping", - connect.WithSchema(methodDesc), - connect.WithIdempotency(connect.IdempotencyNoSideEffects), - connect.WithResponseInitializer( - func(spec connect.Spec, msg any) error { - assert.NotNil(t, spec) - assert.NotNil(t, msg) - dynamic, ok := msg.(*dynamicpb.Message) - if !assert.True(t, ok) { - return fmt.Errorf("unexpected message type: %T", msg) - } - *dynamic = *dynamicpb.NewMessage(methodDesc.Output()) - optionCalled = true - return nil - }, - ), - ) - msg := dynamicpb.NewMessage(methodDesc.Input()) - msg.Set( - methodDesc.Input().Fields().ByName("number"), - protoreflect.ValueOfInt64(42), - ) - res, err := client.CallUnary(ctx, connect.NewRequest(msg)) - assert.Nil(t, err) - got := res.Msg.Get(methodDesc.Output().Fields().ByName("number")).Int() - assert.Equal(t, got, 42) - assert.True(t, optionCalled) - }) -} - -func TestClientDeadlineHandling(t *testing.T) { - t.Parallel() - if testing.Short() { - t.Skip("skipping slow test") - } - - // Note that these tests are not able to reproduce issues with the race - // detector enabled. That's partly why the makefile only runs "slow" - // tests with the race detector disabled. - - _, handler := pingv1connect.NewPingServiceHandler(pingServer{}) - svr := httptest.NewUnstartedServer(http.HandlerFunc(func(respWriter http.ResponseWriter, req *http.Request) { - if req.Context().Err() != nil { - return - } - handler.ServeHTTP(respWriter, req) - })) - svr.Config.ErrorLog = log.New(io.Discard, "", 0) //nolint:forbidigo - p := new(http.Protocols) - p.SetHTTP1(true) - p.SetUnencryptedHTTP2(true) - svr.Config.Protocols = p - svr.Start() - t.Cleanup(svr.Close) - - clientProtos := new(http.Protocols) - clientProtos.SetUnencryptedHTTP2(true) - client := svr.Client() - transport, ok := client.Transport.(*http.Transport) - assert.True(t, ok) - transport.Protocols = clientProtos - - // This case creates a new connection for each RPC to verify that timeouts during dialing - // won't cause issues. This is historically easier to reproduce, so it uses a smaller - // duration, no concurrency, and fewer iterations. This is important because if we used - // a new connection for each RPC in the bigger test scenario below, we'd encounter other - // issues related to overwhelming the loopback interface and exhausting ephemeral ports. - t.Run("dial", func(t *testing.T) { - t.Parallel() - transport, ok := client.Transport.(*http.Transport) - if !assert.True(t, ok) { - t.FailNow() - } - testClientDeadlineBruteForceLoop(t, - 5*time.Second, 5, 1, - func(ctx context.Context) (string, rpcErrors) { - httpClient := &http.Client{ - Transport: transport.Clone(), - } - client := pingv1connect.NewPingServiceClient(httpClient, svr.URL) - _, err := client.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Text: "foo"})) - // Close all connections and make sure to give a little time for the OS to - // release socket resources to prevent resource exhaustion (such as running - // out of ephemeral ports). - httpClient.CloseIdleConnections() - time.Sleep(time.Millisecond / 2) - return pingv1connect.PingServicePingProcedure, rpcErrors{recvErr: err} - }, - ) - }) - - // This case creates significantly more load than the above one, but uses a normal - // client so pools and re-uses connections. It also uses all stream types to send - // messages, to make sure that all stream implementations handle deadlines correctly. - // The I/O errors related to deadlines are historically harder to reproduce, so it - // throws a lot more effort into reproducing, particularly a longer duration for - // which it will run. It also uses larger messages (by packing requests with - // unrecognized fields) and compression, to make it more likely to encounter the - // deadline in the middle of read and write operations. - t.Run("read-write", func(t *testing.T) { - t.Parallel() - - var extraField []byte - extraField = protowire.AppendTag(extraField, 999, protowire.BytesType) - extraData := make([]byte, 16*1024) - // use good random data so it's not very compressible - if _, err := rand.Read(extraData); err != nil { - t.Fatalf("failed to generate extra payload: %v", err) - return - } - extraField = protowire.AppendBytes(extraField, extraData) - - clientConnect := pingv1connect.NewPingServiceClient(client, svr.URL, connect.WithSendGzip()) - clientGRPC := pingv1connect.NewPingServiceClient(client, svr.URL, connect.WithSendGzip(), connect.WithGRPCWeb()) - var count atomic.Int32 - testClientDeadlineBruteForceLoop(t, - 20*time.Second, 200, runtime.GOMAXPROCS(0), - func(ctx context.Context) (string, rpcErrors) { - var procedure string - var errs rpcErrors - rpcNum := count.Add(1) - var client pingv1connect.PingServiceClient - if rpcNum&4 == 0 { - client = clientConnect - } else { - client = clientGRPC - } - switch rpcNum & 3 { - case 0: - procedure = pingv1connect.PingServicePingProcedure - _, errs.recvErr = client.Ping(ctx, connect.NewRequest(addUnrecognizedBytes(&pingv1.PingRequest{Text: "foo"}, extraField))) - case 1: - procedure = pingv1connect.PingServiceSumProcedure - stream := client.Sum(ctx) - for range 3 { - errs.sendErr = stream.Send(addUnrecognizedBytes(&pingv1.SumRequest{Number: 1}, extraField)) - if errs.sendErr != nil { - break - } - } - _, errs.recvErr = stream.CloseAndReceive() - case 2: - procedure = pingv1connect.PingServiceCountUpProcedure - var stream *connect.ServerStreamForClient[pingv1.CountUpResponse] - stream, errs.recvErr = client.CountUp(ctx, connect.NewRequest(addUnrecognizedBytes(&pingv1.CountUpRequest{Number: 3}, extraField))) - if errs.recvErr == nil { - for stream.Receive() { - } - errs.recvErr = stream.Err() - errs.closeRecvErr = stream.Close() - } - case 3: - procedure = pingv1connect.PingServiceCumSumProcedure - stream := client.CumSum(ctx) - for range 3 { - errs.sendErr = stream.Send(addUnrecognizedBytes(&pingv1.CumSumRequest{Number: 1}, extraField)) - _, errs.recvErr = stream.Receive() - if errs.recvErr != nil { - break - } - } - errs.closeSendErr = stream.CloseRequest() - errs.closeRecvErr = stream.CloseResponse() - } - return procedure, errs - }, - ) - }) -} - -func testClientDeadlineBruteForceLoop( - t *testing.T, - duration time.Duration, - iterationsPerDeadline int, - parallelism int, - loopBody func(ctx context.Context) (string, rpcErrors), -) { - t.Helper() - testContext, testCancel := context.WithTimeout(t.Context(), duration) - defer testCancel() - var rpcCount atomic.Int64 - - var wg sync.WaitGroup - for goroutine := range parallelism { - wg.Go(func() { - // We try a range of timeouts since the timing issue is sensitive - // to execution environment (e.g. CPU, memory, and network speeds). - // So the lower timeout values may be more likely to trigger an issue - // in faster environments; higher timeouts for slower environments. - const minTimeout = 10 * time.Microsecond - const maxTimeout = 2 * time.Millisecond - for { - for timeout := minTimeout; timeout <= maxTimeout; timeout += 10 * time.Microsecond { - for range iterationsPerDeadline { - if testContext.Err() != nil { - return - } - ctx, cancel := context.WithTimeout(t.Context(), timeout) - // We are intentionally not inheriting from testContext, which signals when the - // test loop should stop and return but need not influence the RPC deadline. - proc, errs := loopBody(ctx) //nolint:contextcheck - rpcCount.Add(1) - cancel() - type errCase struct { - err error - name string - allowEOF bool - } - errCases := []errCase{ - { - err: errs.sendErr, - name: "send error", - allowEOF: true, - }, - { - err: errs.recvErr, - name: "receive error", - }, - { - err: errs.closeSendErr, - name: "close-send error", - }, - { - err: errs.closeRecvErr, - name: "close-receive error", - }, - } - for _, errCase := range errCases { - err := errCase.err - if err == nil { - // operation completed before timeout, try again - continue - } - if errCase.allowEOF && errors.Is(err, io.EOF) { - continue - } - - if !assert.Equal(t, connect.CodeOf(err), connect.CodeDeadlineExceeded) { - var buf bytes.Buffer - _, _ = fmt.Fprintf(&buf, "actual %v from %s: %v\n%#v", errCase.name, proc, err, err) - for { - err = errors.Unwrap(err) - if err == nil { - break - } - _, _ = fmt.Fprintf(&buf, "\n caused by: %#v", err) - } - t.Log(buf.String()) - testCancel() - } - } - } - } - t.Logf("goroutine %d: repeating duration loop", goroutine) - } - }) - } - wg.Wait() - t.Logf("Issued %d RPCs.", rpcCount.Load()) -} - -type notModifiedPingServer struct { - pingv1connect.UnimplementedPingServiceHandler - - etag string -} - -func (s *notModifiedPingServer) Ping( - _ context.Context, - req *connect.Request[pingv1.PingRequest], -) (*connect.Response[pingv1.PingResponse], error) { - if req.HTTPMethod() == http.MethodGet && req.Header().Get("If-None-Match") == s.etag { - return nil, connect.NewNotModifiedError(http.Header{"Etag": []string{s.etag}}) - } - resp := connect.NewResponse(&pingv1.PingResponse{}) - resp.Header().Set("Etag", s.etag) - return resp, nil -} - -type alwaysNotModifiedPingServer struct { - pingv1connect.UnimplementedPingServiceHandler -} - -func (*alwaysNotModifiedPingServer) Ping( - _ context.Context, - _ *connect.Request[pingv1.PingRequest], -) (*connect.Response[pingv1.PingResponse], error) { - return nil, connect.NewNotModifiedError(nil) -} - -type assertPeerInterceptor struct { - tb testing.TB -} - -func (a *assertPeerInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { - return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - assert.NotZero(a.tb, req.Peer().Addr) - assert.NotZero(a.tb, req.Peer().Protocol) - return next(ctx, req) - } -} - -func (a *assertPeerInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { - return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { - conn := next(ctx, spec) - assert.NotZero(a.tb, conn.Peer().Addr) - assert.NotZero(a.tb, conn.Peer().Protocol) - assert.NotZero(a.tb, conn.Spec()) - return conn - } -} - -func (a *assertPeerInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { - return func(ctx context.Context, conn connect.StreamingHandlerConn) error { - assert.NotZero(a.tb, conn.Peer().Addr) - assert.NotZero(a.tb, conn.Peer().Protocol) - assert.NotZero(a.tb, conn.Spec()) - return next(ctx, conn) - } -} - -type assertSchemaInterceptor struct { - tb testing.TB -} - -func (a *assertSchemaInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { - return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - if !assert.NotNil(a.tb, req.Spec().Schema) { - return next(ctx, req) - } - methodDesc, ok := req.Spec().Schema.(protoreflect.MethodDescriptor) - if assert.True(a.tb, ok) { - procedure := fmt.Sprintf("/%s/%s", methodDesc.Parent().FullName(), methodDesc.Name()) - assert.Equal(a.tb, procedure, req.Spec().Procedure) - } - return next(ctx, req) - } -} - -func (a *assertSchemaInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { - return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { - conn := next(ctx, spec) - if !assert.NotNil(a.tb, spec.Schema) { - return conn - } - methodDescriptor, ok := spec.Schema.(protoreflect.MethodDescriptor) - if assert.True(a.tb, ok) { - procedure := fmt.Sprintf("/%s/%s", methodDescriptor.Parent().FullName(), methodDescriptor.Name()) - assert.Equal(a.tb, procedure, spec.Procedure) - } - return conn - } -} - -func (a *assertSchemaInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { - return func(ctx context.Context, conn connect.StreamingHandlerConn) error { - if !assert.NotNil(a.tb, conn.Spec().Schema) { - return next(ctx, conn) - } - methodDesc, ok := conn.Spec().Schema.(protoreflect.MethodDescriptor) - if assert.True(a.tb, ok) { - procedure := fmt.Sprintf("/%s/%s", methodDesc.Parent().FullName(), methodDesc.Name()) - assert.Equal(a.tb, procedure, conn.Spec().Procedure) - } - return next(ctx, conn) - } -} - -type rpcErrors struct { - sendErr error - recvErr error - closeSendErr error - closeRecvErr error -} - -func addUnrecognizedBytes[M proto.Message](msg M, data []byte) M { - msg.ProtoReflect().SetUnknown(data) - return msg -} - -type httpClientFunc func(*http.Request) (*http.Response, error) - -func (fn httpClientFunc) Do(req *http.Request) (*http.Response, error) { - return fn(req) -} diff --git a/client_get_fallback_test.go b/client_get_fallback_test.go deleted file mode 100644 index a1e5bdfb..00000000 --- a/client_get_fallback_test.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "context" - "net/http" - "strings" - "testing" - - "connectrpc.com/connect/internal/assert" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/memhttp/memhttptest" -) - -func TestClientUnaryGetFallback(t *testing.T) { - t.Parallel() - mux := http.NewServeMux() - mux.Handle("/connect.ping.v1.PingService/Ping", NewUnaryHandler( - "/connect.ping.v1.PingService/Ping", - func(ctx context.Context, r *Request[pingv1.PingRequest]) (*Response[pingv1.PingResponse], error) { - return NewResponse(&pingv1.PingResponse{ - Number: r.Msg.GetNumber(), - Text: r.Msg.GetText(), - }), nil - }, - WithIdempotency(IdempotencyNoSideEffects), - )) - server := memhttptest.NewServer(t, mux) - - client := NewClient[pingv1.PingRequest, pingv1.PingResponse]( - server.Client(), - server.URL()+"/connect.ping.v1.PingService/Ping", - WithHTTPGet(), - WithHTTPGetMaxURLSize(1, true), - WithSendGzip(), - ) - ctx := t.Context() - - _, err := client.CallUnary(ctx, NewRequest[pingv1.PingRequest](nil)) - assert.Nil(t, err) - - text := strings.Repeat(".", 256) - r, err := client.CallUnary(ctx, NewRequest(&pingv1.PingRequest{Text: text})) - assert.Nil(t, err) - assert.Equal(t, r.Msg.GetText(), text) -} diff --git a/client_stream.go b/client_stream.go deleted file mode 100644 index 83e2af0b..00000000 --- a/client_stream.go +++ /dev/null @@ -1,453 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "errors" - "io" - "net/http" -) - -var ( - // errNoStreamInitialized signals that a no stream has been initialized when - // attempting to access stream-related methods. - errNoStreamInitialized = errors.New("no stream initialized") -) - -// ClientStreamForClient is the client's view of a client streaming RPC. -// -// It's returned from [Client].CallClientStream, but doesn't currently have an -// exported constructor function. -// -// When using this stream, request headers should be set via the [ClientStreamForClient.RequestHeader] method. -// -// Send is not safe to call concurrently. -type ClientStreamForClient[Req, Res any] struct { - conn StreamingClientConn - initializer maybeInitializer - // Error from client construction. If non-nil, return for all calls. - err error -} - -// Spec returns the specification for the RPC. -func (c *ClientStreamForClient[_, _]) Spec() Spec { - return c.conn.Spec() -} - -// Peer describes the server for the RPC. -func (c *ClientStreamForClient[_, _]) Peer() Peer { - return c.conn.Peer() -} - -// RequestHeader returns the request headers. Headers are sent to the server with the -// first call to Send. -// -// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the -// Connect and gRPC protocols. Applications shouldn't write them. -func (c *ClientStreamForClient[Req, Res]) RequestHeader() http.Header { - if c.err != nil { - return http.Header{} - } - return c.conn.RequestHeader() -} - -// Send a message to the server. The first call to Send also sends the request -// headers. -// -// If the server returns an error, Send returns an error that wraps [io.EOF]. -// Clients should check for case using the standard library's [errors.Is] and -// unmarshal the error using CloseAndReceive. -func (c *ClientStreamForClient[Req, Res]) Send(request *Req) error { - if c.err != nil { - return c.err - } - if request == nil { - return c.conn.Send(nil) - } - return c.conn.Send(request) -} - -// CloseAndReceive closes the send side of the stream and waits for the -// response. -func (c *ClientStreamForClient[Req, Res]) CloseAndReceive() (*Response[Res], error) { - if c.err != nil { - return nil, c.err - } - if err := c.conn.CloseRequest(); err != nil { - _ = c.conn.CloseResponse() - return nil, err - } - response, err := receiveUnaryResponse[Res](c.conn, c.initializer) - if err != nil { - _ = c.conn.CloseResponse() - return nil, err - } - return response, c.conn.CloseResponse() -} - -// Conn exposes the underlying StreamingClientConn. This may be useful if -// you'd prefer to wrap the connection in a different high-level API. -func (c *ClientStreamForClient[Req, Res]) Conn() (StreamingClientConn, error) { - return c.conn, c.err -} - -// ClientStreamForClientSimple is the client's view of a client streaming RPC. -// -// It's returned from [Client.CallClientStreamSimple], but doesn't currently have an -// exported constructor function. -// -// Usage of this stream requires that request headers be set in a [CallInfo] object in context via [NewClientContext]. -// In addition, the response returned by [ClientStreamForClientSimple.CloseAndReceive] is the response type defined for -// the stream and _not_ a Connect [Response] wrapper type. As a result, response headers/trailers should be read from -// the [CallInfo] object in context. -// -// Send is not safe to call concurrently. -type ClientStreamForClientSimple[Req, Res any] struct { - stream *ClientStreamForClient[Req, Res] -} - -// Spec returns the specification for the RPC. -func (c *ClientStreamForClientSimple[_, _]) Spec() Spec { - if c.stream == nil { - return Spec{} - } - return c.stream.Spec() -} - -// Peer describes the server for the RPC. -func (c *ClientStreamForClientSimple[_, _]) Peer() Peer { - if c.stream == nil { - return Peer{} - } - return c.stream.Peer() -} - -// Send a message to the server. The first call to Send also sends the request -// headers. -// -// If the server returns an error, Send returns an error that wraps [io.EOF]. -// Clients should check for case using the standard library's [errors.Is] and -// unmarshal the error using CloseAndReceive. -func (c *ClientStreamForClientSimple[Req, Res]) Send(request *Req) error { - if c.stream == nil { - return errNoStreamInitialized - } - return c.stream.Send(request) -} - -// CloseAndReceive closes the send side of the stream and waits for the -// response. -func (c *ClientStreamForClientSimple[Req, Res]) CloseAndReceive() (*Res, error) { - if c.stream == nil { - return nil, errNoStreamInitialized - } - res, err := c.stream.CloseAndReceive() - if err != nil { - return nil, err - } - return res.Msg, nil -} - -// ServerStreamForClient is the client's view of a server streaming RPC. -// -// It's returned from [Client].CallServerStream, but doesn't currently have an -// exported constructor function. -// -// Receive is not safe to call concurrently. -type ServerStreamForClient[Res any] struct { - conn StreamingClientConn - initializer maybeInitializer - msg *Res - // Error from client construction. If non-nil, return for all calls. - constructErr error - // Error from conn.Receive(). - receiveErr error -} - -// Receive advances the stream to the next message, which will then be -// available through the Msg method. It returns false when the stream stops, -// either by reaching the end or by encountering an unexpected error. After -// Receive returns false, the Err method will return any unexpected error -// encountered. -func (s *ServerStreamForClient[Res]) Receive() bool { - if s.constructErr != nil || s.receiveErr != nil { - return false - } - s.msg = new(Res) - if err := s.initializer.maybe(s.conn.Spec(), s.msg); err != nil { - s.receiveErr = err - return false - } - s.receiveErr = s.conn.Receive(s.msg) - return s.receiveErr == nil -} - -// Msg returns the most recent message unmarshaled by a call to Receive. -func (s *ServerStreamForClient[Res]) Msg() *Res { - if s.msg == nil { - s.msg = new(Res) - } - return s.msg -} - -// Err returns the first non-EOF error that was encountered by Receive. -func (s *ServerStreamForClient[Res]) Err() error { - if s.constructErr != nil { - return s.constructErr - } - if s.receiveErr != nil && !errors.Is(s.receiveErr, io.EOF) { - return s.receiveErr - } - return nil -} - -// ResponseHeader returns the headers received from the server. It blocks until -// the first call to Receive returns. -func (s *ServerStreamForClient[Res]) ResponseHeader() http.Header { - if s.constructErr != nil { - return http.Header{} - } - return s.conn.ResponseHeader() -} - -// ResponseTrailer returns the trailers received from the server. Trailers -// aren't fully populated until Receive() returns an error wrapping io.EOF. -func (s *ServerStreamForClient[Res]) ResponseTrailer() http.Header { - if s.constructErr != nil { - return http.Header{} - } - return s.conn.ResponseTrailer() -} - -// Close the receive side of the stream. -// -// Close is non-blocking. To gracefully close the stream and allow for -// connection resuse ensure all messages have been received before calling -// Close. All messages are received when Receive returns false. -func (s *ServerStreamForClient[Res]) Close() error { - if s.constructErr != nil { - return s.constructErr - } - return s.conn.CloseResponse() -} - -// Conn exposes the underlying StreamingClientConn. This may be useful if -// you'd prefer to wrap the connection in a different high-level API. -func (s *ServerStreamForClient[Res]) Conn() (StreamingClientConn, error) { - return s.conn, s.constructErr -} - -// BidiStreamForClient is the client's view of a bidirectional streaming RPC. -// -// It's returned from [Client].CallBidiStream, but doesn't currently have an -// exported constructor function. -// -// Send and Receive may be called from separate goroutines concurrently, but -// neither may be called concurrently with itself. -type BidiStreamForClient[Req, Res any] struct { - conn StreamingClientConn - initializer maybeInitializer - // Error from client construction. If non-nil, return for all calls. - err error -} - -// Spec returns the specification for the RPC. -func (b *BidiStreamForClient[_, _]) Spec() Spec { - return b.conn.Spec() -} - -// Peer describes the server for the RPC. -func (b *BidiStreamForClient[_, _]) Peer() Peer { - return b.conn.Peer() -} - -// RequestHeader returns the request headers. Headers are sent with the first -// call to Send. -// -// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the -// Connect and gRPC protocols. Applications shouldn't write them. -func (b *BidiStreamForClient[Req, Res]) RequestHeader() http.Header { - if b.err != nil { - return http.Header{} - } - return b.conn.RequestHeader() -} - -// Send a message to the server. The first call to Send also sends the request -// headers. To send just the request headers, without a body, call Send with a -// nil pointer. -// -// If the server returns an error, Send returns an error that wraps [io.EOF]. -// Clients should check for EOF using the standard library's [errors.Is] and -// call Receive to retrieve the error. -func (b *BidiStreamForClient[Req, Res]) Send(msg *Req) error { - if b.err != nil { - return b.err - } - if msg == nil { - return b.conn.Send(nil) - } - return b.conn.Send(msg) -} - -// CloseRequest closes the send side of the stream. -func (b *BidiStreamForClient[Req, Res]) CloseRequest() error { - if b.err != nil { - return b.err - } - return b.conn.CloseRequest() -} - -// Receive a message. When the server is done sending messages and no other -// errors have occurred, Receive will return an error that wraps [io.EOF]. -func (b *BidiStreamForClient[Req, Res]) Receive() (*Res, error) { - if b.err != nil { - return nil, b.err - } - var msg Res - if err := b.initializer.maybe(b.conn.Spec(), &msg); err != nil { - return nil, err - } - if err := b.conn.Receive(&msg); err != nil { - return nil, err - } - return &msg, nil -} - -// CloseResponse closes the receive side of the stream. -// -// CloseResponse is non-blocking. To gracefully close the stream and allow for -// connection resuse ensure all messages have been received before calling -// CloseResponse. All messages are received when Receive returns an error -// wrapping [io.EOF]. -func (b *BidiStreamForClient[Req, Res]) CloseResponse() error { - if b.err != nil { - return b.err - } - return b.conn.CloseResponse() -} - -// ResponseHeader returns the headers received from the server. It blocks until -// the first call to Receive returns. -func (b *BidiStreamForClient[Req, Res]) ResponseHeader() http.Header { - if b.err != nil { - return http.Header{} - } - return b.conn.ResponseHeader() -} - -// ResponseTrailer returns the trailers received from the server. Trailers -// aren't fully populated until Receive() returns an error wrapping [io.EOF]. -func (b *BidiStreamForClient[Req, Res]) ResponseTrailer() http.Header { - if b.err != nil { - return http.Header{} - } - return b.conn.ResponseTrailer() -} - -// Conn exposes the underlying StreamingClientConn. This may be useful if -// you'd prefer to wrap the connection in a different high-level API. -func (b *BidiStreamForClient[Req, Res]) Conn() (StreamingClientConn, error) { - return b.conn, b.err -} - -// BidiStreamForClientSimple is the client's view of a bidirectional streaming RPC. -// -// It's returned from [Client].CallBidiStream, but doesn't currently have an -// exported constructor function. -// -// Send and Receive may be called from separate goroutines concurrently, but -// neither may be called concurrently with itself. -type BidiStreamForClientSimple[Req, Res any] struct { - stream *BidiStreamForClient[Req, Res] -} - -// Spec returns the specification for the RPC. -func (b *BidiStreamForClientSimple[_, _]) Spec() Spec { - if b.stream == nil { - return Spec{} - } - return b.stream.Spec() -} - -// Peer describes the server for the RPC. -func (b *BidiStreamForClientSimple[_, _]) Peer() Peer { - if b.stream == nil { - return Peer{} - } - return b.stream.Peer() -} - -// Send a message to the server. The first call to Send also sends the request -// headers. To send just the request headers, without a body, call Send with a -// nil pointer. -// -// If the server returns an error, Send returns an error that wraps [io.EOF]. -// Clients should check for EOF using the standard library's [errors.Is] and -// call Receive to retrieve the error. -func (b *BidiStreamForClientSimple[Req, Res]) Send(msg *Req) error { - if b.stream == nil { - return errNoStreamInitialized - } - return b.stream.Send(msg) -} - -// CloseRequest closes the send side of the stream. -func (b *BidiStreamForClientSimple[Req, Res]) CloseRequest() error { - if b.stream == nil { - return errNoStreamInitialized - } - return b.stream.CloseRequest() -} - -// Receive a message. When the server is done sending messages and no other -// errors have occurred, Receive will return an error that wraps [io.EOF]. -func (b *BidiStreamForClientSimple[Req, Res]) Receive() (*Res, error) { - if b.stream == nil { - return nil, errNoStreamInitialized - } - return b.stream.Receive() -} - -// CloseResponse closes the receive side of the stream. -// -// CloseResponse is non-blocking. To gracefully close the stream and allow for -// connection resuse ensure all messages have been received before calling -// CloseResponse. All messages are received when Receive returns an error -// wrapping [io.EOF]. -func (b *BidiStreamForClientSimple[Req, Res]) CloseResponse() error { - if b.stream == nil { - return errNoStreamInitialized - } - return b.stream.CloseResponse() -} - -// ResponseHeader returns the headers received from the server. It blocks until -// the first call to Receive returns. -func (b *BidiStreamForClientSimple[Req, Res]) ResponseHeader() http.Header { - if b.stream == nil { - return make(http.Header) - } - return b.stream.ResponseHeader() -} - -// ResponseTrailer returns the trailers received from the server. Trailers -// aren't fully populated until Receive() returns an error wrapping [io.EOF]. -func (b *BidiStreamForClientSimple[Req, Res]) ResponseTrailer() http.Header { - if b.stream == nil { - return make(http.Header) - } - return b.stream.ResponseTrailer() -} diff --git a/client_stream_test.go b/client_stream_test.go deleted file mode 100644 index 4ae8a6c4..00000000 --- a/client_stream_test.go +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "errors" - "fmt" - "net/http" - "testing" - - "connectrpc.com/connect/internal/assert" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" -) - -const expectedStreamErrorMessage = "no stream initialized" - -func TestClientStreamForClient_InitErrNoPanics(t *testing.T) { - t.Parallel() - initErr := errors.New("client init failure") - clientStream := &ClientStreamForClient[pingv1.PingRequest, pingv1.PingResponse]{err: initErr} - assert.ErrorIs(t, clientStream.Send(&pingv1.PingRequest{}), initErr) - verifyHeaders(t, clientStream.RequestHeader()) - res, err := clientStream.CloseAndReceive() - assert.Nil(t, res) - assert.ErrorIs(t, err, initErr) - conn, err := clientStream.Conn() - assert.NotNil(t, err) - assert.Nil(t, conn) -} - -func TestClientStreamForClientSimple_InitErrNoPanics(t *testing.T) { - t.Parallel() - initErr := errors.New("client init failure") - clientStream := &ClientStreamForClientSimple[pingv1.PingRequest, pingv1.PingResponse]{ - stream: &ClientStreamForClient[pingv1.PingRequest, pingv1.PingResponse]{err: initErr}, - } - assert.ErrorIs(t, clientStream.Send(&pingv1.PingRequest{}), initErr) - res, err := clientStream.CloseAndReceive() - assert.Nil(t, res) - assert.ErrorIs(t, err, initErr) - assert.NotNil(t, err) -} - -func TestClientStreamForClientSimple_NilStreamNoPanics(t *testing.T) { - t.Parallel() - clientStream := &ClientStreamForClientSimple[pingv1.PingRequest, pingv1.PingResponse]{} - // Should not panic - clientStream.Peer() - clientStream.Spec() - err := clientStream.Send(&pingv1.PingRequest{}) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedStreamErrorMessage) - res, err := clientStream.CloseAndReceive() - assert.Nil(t, res) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedStreamErrorMessage) -} - -func TestServerStreamForClient_InitErrNoPanics(t *testing.T) { - t.Parallel() - initErr := errors.New("client init failure") - serverStream := &ServerStreamForClient[pingv1.PingResponse]{constructErr: initErr} - assert.ErrorIs(t, serverStream.Err(), initErr) - assert.ErrorIs(t, serverStream.Close(), initErr) - assert.NotNil(t, serverStream.Msg()) - assert.False(t, serverStream.Receive()) - verifyHeaders(t, serverStream.ResponseHeader()) - verifyHeaders(t, serverStream.ResponseTrailer()) - conn, err := serverStream.Conn() - assert.NotNil(t, err) - assert.Nil(t, conn) -} - -func TestServerStreamForClient(t *testing.T) { - t.Parallel() - stream := &ServerStreamForClient[pingv1.PingResponse]{ - conn: &nopStreamingClientConn{}, - } - // Ensure that each call to Receive allocates a new message. This helps - // vtprotobuf, which doesn't automatically zero messages before unmarshaling - // (see https://connectrpc.com/connect/issues/345), and it's also - // less error-prone for users. - assert.True(t, stream.Receive()) - first := fmt.Sprintf("%p", stream.Msg()) - assert.True(t, stream.Receive()) - second := fmt.Sprintf("%p", stream.Msg()) - assert.NotEqual(t, first, second) - conn, err := stream.Conn() - assert.Nil(t, err) - assert.NotNil(t, conn) -} - -func TestBidiStreamForClient_InitErrNoPanics(t *testing.T) { - t.Parallel() - initErr := errors.New("client init failure") - bidiStream := &BidiStreamForClient[pingv1.CumSumRequest, pingv1.CumSumResponse]{err: initErr} - res, err := bidiStream.Receive() - assert.Nil(t, res) - assert.ErrorIs(t, err, initErr) - verifyHeaders(t, bidiStream.RequestHeader()) - verifyHeaders(t, bidiStream.ResponseHeader()) - verifyHeaders(t, bidiStream.ResponseTrailer()) - assert.ErrorIs(t, bidiStream.Send(&pingv1.CumSumRequest{}), initErr) - assert.ErrorIs(t, bidiStream.CloseRequest(), initErr) - assert.ErrorIs(t, bidiStream.CloseResponse(), initErr) - conn, err := bidiStream.Conn() - assert.NotNil(t, err) - assert.Nil(t, conn) -} - -func TestBidiStreamForClientSimple_InitErrNoPanics(t *testing.T) { - t.Parallel() - initErr := errors.New("client init failure") - bidiStream := &BidiStreamForClientSimple[pingv1.CumSumRequest, pingv1.CumSumResponse]{ - stream: &BidiStreamForClient[pingv1.CumSumRequest, pingv1.CumSumResponse]{err: initErr}, - } - res, err := bidiStream.Receive() - assert.Nil(t, res) - assert.ErrorIs(t, err, initErr) - verifyHeaders(t, bidiStream.ResponseHeader()) - verifyHeaders(t, bidiStream.ResponseTrailer()) - assert.ErrorIs(t, bidiStream.Send(&pingv1.CumSumRequest{}), initErr) - assert.ErrorIs(t, bidiStream.CloseRequest(), initErr) - assert.ErrorIs(t, bidiStream.CloseResponse(), initErr) - assert.NotNil(t, err) -} - -func TestBidiStreamForClientSimple_NilStreamNoPanics(t *testing.T) { - t.Parallel() - bidiStream := &BidiStreamForClientSimple[pingv1.PingRequest, pingv1.PingResponse]{} - // Should not panic - bidiStream.Peer() - bidiStream.Spec() - bidiStream.ResponseHeader() - bidiStream.ResponseTrailer() - err := bidiStream.Send(&pingv1.PingRequest{}) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedStreamErrorMessage) - res, err := bidiStream.Receive() - assert.Nil(t, res) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedStreamErrorMessage) - err = bidiStream.CloseRequest() - assert.Nil(t, res) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedStreamErrorMessage) - err = bidiStream.CloseResponse() - assert.Nil(t, res) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedStreamErrorMessage) -} - -func verifyHeaders(t *testing.T, headers http.Header) { - t.Helper() - assert.Equal(t, headers, http.Header{}) - - // Verify set/del don't panic - headers.Set("A", "b") - headers.Del("A") -} - -type nopStreamingClientConn struct { - StreamingClientConn -} - -func (c *nopStreamingClientConn) Receive(msg any) error { - return nil -} - -func (c *nopStreamingClientConn) Spec() Spec { - return Spec{} -} diff --git a/cmd/connect-go-v2-migrate/bufgen.go b/cmd/connect-go-v2-migrate/bufgen.go new file mode 100644 index 00000000..4e5cc075 --- /dev/null +++ b/cmd/connect-go-v2-migrate/bufgen.go @@ -0,0 +1,350 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "path" + "regexp" + "strings" +) + +const ( + connectV2Module = "connectrpc.com/connect/v2" + connectLocalPlugin = "protoc-gen-connect-go" + connectRemotePluginVersion = "v2.0.0" +) + +// connectRemotePluginRef matches connect-go remotes plugins. +var connectRemotePluginRef = regexp.MustCompile(`^(` + bsrHostPattern + `)/connectrpc/(go|gosimple)(?::(\S+))?$`) + +// Plugin entry kinds returned by connectPluginItem. +const ( + kindLocal = "local" // a local binary on $PATH + kindGotool = "gotool" // a `go tool`/`go run` command resolved through go.mod + kindRemote = "remote" // a buf.build remote plugin +) + +// isBufGenFile reports whether base names a Buf generation template +// (buf.gen.yaml, buf.gen.yml, or a buf.gen..yaml variant). +func isBufGenFile(base string) bool { + if !strings.HasSuffix(base, ".yaml") && !strings.HasSuffix(base, ".yml") { + return false + } + return base == "buf.gen.yaml" || base == "buf.gen.yml" || strings.HasPrefix(base, "buf.gen.") +} + +// RewriteBufGen applies the v1->v2 buf.gen.yaml migration: strip the v1 +// `simple` option from any connect-go plugin entry, pin a remote plugin to the +// v2 release, and warn when a local plugin needs reinstalling from /v2. Other +// lines are left untouched; the returned error is always nil. +func RewriteBufGen(filename string, src []byte) ([]byte, Report, error) { + report := Report{} + // Edits are keyed by line index and applied in one final pass, so block + // boundaries stay valid throughout the scan; `lines` is never mutated. + lines := strings.Split(string(src), "\n") + edits := lineEdits{replace: map[int]string{}, delete: map[int]bool{}} + + for index := 0; index < len(lines); index++ { + kind, ref, ok := connectPluginItem(lines[index]) + if !ok { + continue + } + // The block runs until a sibling item or a dedent to the dash's indent. + dashIndent := indentOf(lines[index]) + end := index + 1 + for end < len(lines) { + if line := lines[end]; strings.TrimSpace(line) != "" && indentOf(line) <= dashIndent { + break + } + end++ + } + stripSimpleOpt(lines, index+1, end, &edits, &report) + if kind == kindLocal { + // A v1 `path:` override can reroute through `go run`/`go tool`. + if pathKind, pathRef, ok := pathOverride(lines, index+1, end); ok { + kind, ref = pathKind, pathRef + } + } + switch kind { + case kindLocal: + report.warnAtLinef(filename, index+1, ruleBufgenReinstall, "reinstall the generator with `go install %s/cmd/%s@latest`. The v1 and v2 plugins share the binary name %q, so reinstalling from the /v2 module switches generation to v2.", connectV2Module, connectLocalPlugin, connectLocalPlugin) + case kindGotool: + report.warnAtLinef(filename, index+1, ruleBufgenGoMod, "the plugin runs via go.mod (%s). Update the tool dependency to the v2 module with `go get -tool %s/cmd/%s` then `go mod tidy`. The buf.gen.yaml entry stays the same.", ref, connectV2Module, connectLocalPlugin) + case kindRemote: + pinRemotePluginV2(filename, lines, index, ref, &edits, &report) + } + index = end - 1 + } + + if !report.Changed { + return src, report, nil + } + return []byte(strings.Join(edits.apply(lines), "\n")), report, nil +} + +// pinRemotePluginV2 pins a v1 remote plugin reference (versioned or not) to the +// v2 release. References already at v2 are left alone. +func pinRemotePluginV2(filename string, lines []string, index int, ref string, edits *lineEdits, report *Report) { + match := connectRemotePluginRef.FindStringSubmatch(ref) + if match == nil { + return + } + host, simple, version := match[1], match[2] == "gosimple", match[3] + if !simple && strings.HasPrefix(version, "v2") { + return + } + pinned := host + "/connectrpc/go:" + connectRemotePluginVersion + edits.replace[index] = strings.Replace(lines[index], ref, pinned, 1) + if simple { + report.bump("bufgen_replace_gosimple") + } else { + report.bump("bufgen_pin_remote_v2") + } + // TODO: drop once connect-go v2.0.0 ships and the plugin is on the BSR. + report.warnAtLinef(filename, index+1, ruleBufgenRemoteUnpublished, "%s is not published yet. Until connect-go %s is released, generate with the local plugin instead: `go install %s/cmd/%s@latest` and a `local: %s` entry.", pinned, connectRemotePluginVersion, connectV2Module, connectLocalPlugin, connectLocalPlugin) +} + +// lineEdits records pending line replacements or deletions keyed by line index. +type lineEdits struct { + replace map[int]string + delete map[int]bool +} + +// apply produces the edited line slice: deletions dropped, replacements +// substituted, the rest copied verbatim. +func (e lineEdits) apply(lines []string) []string { + out := make([]string, 0, len(lines)) + for index, line := range lines { + if e.delete[index] { + continue + } + if replacement, ok := e.replace[index]; ok { + out = append(out, replacement) + continue + } + out = append(out, line) + } + return out +} + +// connectPluginItem reports whether line is a plugins sequence item naming the +// connect-go generator, returning its kind and raw reference. It recognises the +// v2 (`local:`/`remote:`) and v1 (`name:`/`plugin:`) syntaxes; a `local:` flow +// sequence routed through `go run`/`go tool` is reported as kindGotool. +func connectPluginItem(line string) (kind, ref string, ok bool) { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "- ") { + return "", "", false + } + rest := strings.TrimSpace(trimmed[len("- "):]) + switch { + case strings.HasPrefix(rest, "local:"): + return classifyLocalPlugin(yamlScalar(rest[len("local:"):])) + case strings.HasPrefix(rest, "remote:"): + if value := yamlScalar(rest[len("remote:"):]); isConnectRemoteRef(value) { + return kindRemote, value, true + } + case strings.HasPrefix(rest, "name:"): // v1 syntax + if value := yamlScalar(rest[len("name:"):]); value == "connect-go" { + return kindLocal, value, true + } + case strings.HasPrefix(rest, "plugin:"): // v1 syntax: local name or remote ref + value := yamlScalar(rest[len("plugin:"):]) + switch { + case isConnectRemoteRef(value): + return kindRemote, value, true + case value == "connect-go" || value == connectLocalPlugin: + return kindLocal, value, true + } + } + return "", "", false +} + +// isConnectRemoteRef reports whether value references either connect-go remote +// plugin, bare or version-tagged. +func isConnectRemoteRef(value string) bool { + return connectRemotePluginRef.MatchString(value) +} + +// pathOverride scans a v1 plugin block for a `path:` whose value is a command +// (YAML flow sequence) naming the connect-go generator. A plain string path +// keeps the entry a local binary. +func pathOverride(lines []string, start, end int) (kind, ref string, ok bool) { + for index := start; index < end; index++ { + trimmed := strings.TrimSpace(lines[index]) + if !strings.HasPrefix(trimmed, "path:") { + continue + } + return classifyLocalPlugin(yamlScalar(trimmed[len("path:"):])) + } + return "", "", false +} + +// classifyLocalPlugin classifies a `local:` value (bare binary name or a flow +// sequence command) as the connect-go plugin. A `go tool`/`go run` indirection +// is reported as kindGotool. +func classifyLocalPlugin(value string) (kind, ref string, ok bool) { + value = strings.TrimSpace(value) + if value == connectLocalPlugin { + return kindLocal, value, true + } + if !strings.HasPrefix(value, "[") || !strings.HasSuffix(value, "]") { + return "", "", false + } + command := splitFlowSequence(value) + if len(command) == 0 || path.Base(command[len(command)-1]) != connectLocalPlugin { + return "", "", false + } + if len(command) >= 2 && command[0] == "go" && (command[1] == "tool" || command[1] == "run") { + return kindGotool, value, true + } + return kindLocal, value, true +} + +// splitFlowSequence parses a YAML flow sequence ("[a, b, c]") into trimmed, +// unquoted elements. +func splitFlowSequence(value string) []string { + inner := strings.TrimSuffix(strings.TrimPrefix(value, "["), "]") + var elements []string + for part := range strings.SplitSeq(inner, ",") { + if trimmed := yamlScalar(part); trimmed != "" { + elements = append(elements, trimmed) + } + } + return elements +} + +// stripSimpleOpt removes the v1 `simple` option from the plugin block in +// [start, end), handling both the inline (opt: a,simple=true) and list (opt: +// with `- simple=true` items) forms. +func stripSimpleOpt(lines []string, start, end int, edits *lineEdits, report *Report) { + for index := start; index < end; index++ { + optIndent, value, isOpt := optLine(lines[index]) + if !isOpt { + continue + } + if value != "" { + stripInlineSimple(index, optIndent, value, edits, report) + return + } + stripListSimple(lines, index, optIndent, end, edits, report) + return + } +} + +// stripInlineSimple rewrites `opt: a,simple=x,b` to `opt: a,b`, or deletes the +// whole line when `simple` was the only option. +func stripInlineSimple(index, indent int, value string, edits *lineEdits, report *Report) { + kept := make([]string, 0) + dropped := false + for token := range strings.SplitSeq(value, ",") { + if isSimpleToken(token) { + dropped = true + continue + } + kept = append(kept, strings.TrimSpace(token)) + } + if !dropped { + return + } + report.bump("bufgen_remove_simple") + if len(kept) == 0 { + edits.delete[index] = true + return + } + edits.replace[index] = strings.Repeat(" ", indent) + "opt: " + strings.Join(kept, ",") +} + +// stripListSimple removes `- simple=x` entries from an opt list and, if that +// empties the list, the `opt:` header too. +func stripListSimple(lines []string, optIndex, optIndent, end int, edits *lineEdits, report *Report) { + var itemCount, simpleCount int + simpleLines := make([]int, 0) + for index := optIndex + 1; index < end; index++ { + line := lines[index] + if strings.TrimSpace(line) == "" { + continue + } + if indentOf(line) <= optIndent { + break // dedented out of the opt list + } + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + continue + } + if !strings.HasPrefix(trimmed, "- ") { + break // not a list item, opt block ended + } + itemCount++ + if isSimpleToken(yamlScalar(trimmed[len("- "):])) { + simpleCount++ + simpleLines = append(simpleLines, index) + } + } + if simpleCount == 0 { + return + } + report.bump("bufgen_remove_simple") + for _, index := range simpleLines { + edits.delete[index] = true + } + if simpleCount == itemCount { + // Every option was `simple`. Drop the now-empty `opt:` header too. + edits.delete[optIndex] = true + } +} + +// optLine reports whether line is an `opt:` key. It returns the key's +// indentation and the inline value (empty for the list form `opt:`). +func optLine(line string) (indent int, value string, ok bool) { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "opt:") { + return 0, "", false + } + return indentOf(line), yamlScalar(trimmed[len("opt:"):]), true +} + +// isSimpleToken reports whether a single opt token is the v1 `simple` flag, +// with or without a value (simple, simple=true, simple=false). +func isSimpleToken(token string) bool { + token = strings.TrimSpace(token) + return token == "simple" || strings.HasPrefix(token, "simple=") +} + +// yamlScalar trims whitespace, surrounding quotes, and any trailing line +// comment from a scalar value. A quoted value is returned verbatim so a "#" +// inside the quotes is not mistaken for a comment. +func yamlScalar(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') { + if end := strings.IndexByte(value[1:], value[0]); end >= 0 { + return value[1 : 1+end] + } + } + if hash := strings.Index(value, " #"); hash >= 0 { + value = strings.TrimSpace(value[:hash]) + } + return value +} + +// indentOf counts leading spaces. YAML forbids tabs for indentation, so a tab +// stops the count, which is fine for our purposes. +func indentOf(line string) int { + count := 0 + for count < len(line) && line[count] == ' ' { + count++ + } + return count +} diff --git a/cmd/connect-go-v2-migrate/bufgen_test.go b/cmd/connect-go-v2-migrate/bufgen_test.go new file mode 100644 index 00000000..d6679790 --- /dev/null +++ b/cmd/connect-go-v2-migrate/bufgen_test.go @@ -0,0 +1,511 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + "testing" +) + +// TestRewriteBufGen exercises RewriteBufGen over inline templates. The +// file-pair golden cases live in the testscript harness (testdata/script/ +// bufgen_*.txtar). +func TestRewriteBufGen(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in string + want string // empty means "expect unchanged (byte-identical to in)" + wantChanged bool + wantWarn string // substring expected in some warning (empty means none required) + }{ + { + name: "inline_strip_simple_keeps_rest", + in: `version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: paths=source_relative,simple=true +`, + want: `version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: paths=source_relative +`, + wantChanged: true, + wantWarn: "reinstall the generator", + }, + { + name: "inline_only_simple_drops_opt_line", + in: `version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: simple=true +`, + want: `version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen +`, + wantChanged: true, + }, + { + name: "list_strip_simple_keeps_rest", + in: `version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: + - paths=source_relative + - simple=true +`, + want: `version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: + - paths=source_relative +`, + wantChanged: true, + }, + { + name: "list_only_simple_drops_opt_header", + in: `version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: + - simple=true +`, + want: `version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen +`, + wantChanged: true, + }, + { + name: "remote_plugin_pinned_and_strips", + in: `version: v2 +plugins: + - remote: buf.build/connectrpc/go:v1.18.1 + opt: simple=true +`, + want: `version: v2 +plugins: + - remote: buf.build/connectrpc/go:v2.0.0 +`, + wantChanged: true, + }, + { + name: "remote_plugin_unversioned_pinned", + in: `version: v2 +plugins: + - remote: buf.build/connectrpc/go + out: gen +`, + want: `version: v2 +plugins: + - remote: buf.build/connectrpc/go:v2.0.0 + out: gen +`, + wantChanged: true, + }, + { + name: "remote_plugin_already_v2_is_noop", + in: `version: v2 +plugins: + - remote: buf.build/connectrpc/go:v2.0.0 + out: gen +`, + want: "", + wantChanged: false, + }, + { + // v2 folds the simple API into the default generator, so the + // gosimple plugin migrates onto connectrpc/go, not a gosimple v2. + name: "remote_gosimple_replaced_by_default_plugin", + in: `version: v2 +plugins: + - remote: buf.build/connectrpc/gosimple:v1.18.1 + out: gen +`, + want: `version: v2 +plugins: + - remote: buf.build/connectrpc/go:v2.0.0 + out: gen +`, + wantChanged: true, + }, + { + name: "remote_gosimple_unversioned_replaced", + in: `version: v2 +plugins: + - remote: buf.build/connectrpc/gosimple + out: gen +`, + want: `version: v2 +plugins: + - remote: buf.build/connectrpc/go:v2.0.0 + out: gen +`, + wantChanged: true, + }, + { + name: "v1_plugin_gosimple_replaced", + in: `version: v1 +plugins: + - plugin: buf.build/connectrpc/gosimple:v1.18.1 + out: gen +`, + want: `version: v1 +plugins: + - plugin: buf.build/connectrpc/go:v2.0.0 + out: gen +`, + wantChanged: true, + }, + { + // Private BSR instances use the same plugin path under another host, + // and the rewrite must keep that host rather than jump to buf.build. + name: "remote_private_host_keeps_host", + in: `version: v2 +plugins: + - remote: buf.example.com/connectrpc/go:v1.18.1 + out: gen +`, + want: `version: v2 +plugins: + - remote: buf.example.com/connectrpc/go:v2.0.0 + out: gen +`, + wantChanged: true, + }, + { + name: "remote_private_host_gosimple_replaced", + in: `version: v2 +plugins: + - remote: bsr.internal.acme.dev/connectrpc/gosimple:v1.18.1 + out: gen +`, + want: `version: v2 +plugins: + - remote: bsr.internal.acme.dev/connectrpc/go:v2.0.0 + out: gen +`, + wantChanged: true, + }, + { + name: "remote_private_host_already_v2_is_noop", + in: `version: v2 +plugins: + - remote: buf.example.com/connectrpc/go:v2.0.0 + out: gen +`, + want: "", + wantChanged: false, + }, + { + // A hostless reference is not a BSR plugin and must be left alone. + name: "remote_hostless_ref_ignored", + in: `version: v2 +plugins: + - remote: connectrpc/go:v1.18.1 + out: gen +`, + want: "", + wantChanged: false, + }, + { + name: "remote_pin_warns_plugin_not_published", + in: `version: v2 +plugins: + - remote: buf.build/connectrpc/go:v1.18.1 + out: gen +`, + want: `version: v2 +plugins: + - remote: buf.build/connectrpc/go:v2.0.0 + out: gen +`, + wantChanged: true, + wantWarn: "is not published yet", + }, + { + name: "gotool_warns_about_gomod_and_strips", + in: `version: v2 +plugins: + - local: [go, tool, protoc-gen-connect-go] + out: gen + opt: paths=source_relative,simple=true +`, + want: `version: v2 +plugins: + - local: [go, tool, protoc-gen-connect-go] + out: gen + opt: paths=source_relative +`, + wantChanged: true, + wantWarn: "go.mod", + }, + { + name: "no_simple_local_warns_only_no_change", + in: `version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: paths=source_relative +`, + want: "", // unchanged + wantChanged: false, + wantWarn: "reinstall the generator", + }, + { + name: "preserves_other_plugins_and_comments", + in: `version: v2 +plugins: + - local: protoc-gen-go # base types + out: gen + opt: paths=source_relative + - local: protoc-gen-connect-go + out: gen + opt: paths=source_relative,simple=true +`, + want: `version: v2 +plugins: + - local: protoc-gen-go # base types + out: gen + opt: paths=source_relative + - local: protoc-gen-connect-go + out: gen + opt: paths=source_relative +`, + wantChanged: true, + }, + { + name: "no_connect_plugin_is_noop", + in: `version: v2 +plugins: + - local: protoc-gen-go + out: gen + opt: paths=source_relative,simple=true +`, + want: "", + wantChanged: false, + }, + { + name: "v1_name_local_warns_reinstall", + in: `version: v1 +managed: + enabled: true + go_package_prefix: + default: connect-examples-go/internal/gen +plugins: + - name: go + out: internal/gen + opt: paths=source_relative + - name: connect-go + out: internal/gen + opt: paths=source_relative +`, + want: "", // unchanged + wantChanged: false, + wantWarn: "reinstall the generator", + }, + { + name: "v1_name_strip_simple", + in: `version: v1 +plugins: + - name: connect-go + out: gen + opt: paths=source_relative,simple=true +`, + want: `version: v1 +plugins: + - name: connect-go + out: gen + opt: paths=source_relative +`, + wantChanged: true, + wantWarn: "reinstall the generator", + }, + { + name: "v1_plugin_local_warns_reinstall", + in: `version: v1 +plugins: + - plugin: connect-go + out: gen + opt: paths=source_relative +`, + want: "", // unchanged + wantChanged: false, + wantWarn: "reinstall the generator", + }, + { + name: "v1_plugin_remote_pinned_and_strips", + in: `version: v1 +plugins: + - plugin: buf.build/connectrpc/go:v1.18.1 + out: gen + opt: simple=true +`, + want: `version: v1 +plugins: + - plugin: buf.build/connectrpc/go:v2.0.0 + out: gen +`, + wantChanged: true, + }, + { + name: "v1_plugin_remote_unversioned_pinned", + in: `version: v1 +plugins: + - plugin: buf.build/connectrpc/go + out: gen +`, + want: `version: v1 +plugins: + - plugin: buf.build/connectrpc/go:v2.0.0 + out: gen +`, + wantChanged: true, + }, + { + name: "v1_plugin_remote_already_v2_is_noop", + in: `version: v1 +plugins: + - plugin: buf.build/connectrpc/go:v2.0.0 + out: gen +`, + want: "", + wantChanged: false, + }, + { + name: "v1_path_go_run_warns_gomod", + in: `version: v1 +plugins: + - name: connect-go + out: gen + opt: paths=source_relative,simple=true + path: [go, run, connectrpc.com/connect/cmd/protoc-gen-connect-go] +`, + want: `version: v1 +plugins: + - name: connect-go + out: gen + opt: paths=source_relative + path: [go, run, connectrpc.com/connect/cmd/protoc-gen-connect-go] +`, + wantChanged: true, + wantWarn: "go.mod", + }, + { + name: "v1_path_binary_keeps_reinstall_warning", + in: `version: v1 +plugins: + - name: connect-go + out: gen + path: bin/protoc-gen-connect-go +`, + want: "", // unchanged + wantChanged: false, + wantWarn: "reinstall the generator", + }, + { + name: "v1_name_go_is_noop", + in: `version: v1 +plugins: + - name: go + out: gen + opt: paths=source_relative,simple=true +`, + want: "", + wantChanged: false, + }, + { + name: "simple_false_also_stripped", + in: `version: v2 +plugins: + - local: protoc-gen-connect-go + opt: simple=false,paths=source_relative +`, + want: `version: v2 +plugins: + - local: protoc-gen-connect-go + opt: paths=source_relative +`, + wantChanged: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + got, report, err := RewriteBufGen("buf.gen.yaml", []byte(test.in)) + if err != nil { + t.Fatalf("RewriteBufGen: %v", err) + } + if report.Changed != test.wantChanged { + t.Errorf("Changed = %v, want %v (report %s)", report.Changed, test.wantChanged, report.Summary()) + } + want := test.want + if want == "" { + want = test.in + } + if string(got) != want { + t.Errorf("output mismatch\n--- want ---\n%s\n--- got ---\n%s", want, string(got)) + } + if test.wantWarn != "" { + found := false + for _, warning := range report.Warnings { + if strings.Contains(warning.Msg, test.wantWarn) { + found = true + break + } + } + if !found { + t.Errorf("expected a warning containing %q; got %v", test.wantWarn, report.Warnings) + } + } + // Idempotency: a second pass must not change the (already-migrated) + // output bytes. + got2, _, err := RewriteBufGen("buf.gen.yaml", got) + if err != nil { + t.Fatalf("second-pass RewriteBufGen: %v", err) + } + if string(got2) != string(got) { + t.Errorf("not idempotent; second pass changed output\n--- first ---\n%s\n--- second ---\n%s", string(got), string(got2)) + } + }) + } +} + +func TestYamlScalar(t *testing.T) { + t.Parallel() + tests := []struct{ in, want string }{ + {"simple=true", "simple=true"}, + {"paths=source_relative # note", "paths=source_relative"}, + {`"a #b"`, "a #b"}, + {`'a #b'`, "a #b"}, + {`"quoted"`, "quoted"}, + {" spaced ", "spaced"}, + } + for _, test := range tests { + if got := yamlScalar(test.in); got != test.want { + t.Errorf("yamlScalar(%q) = %q, want %q", test.in, got, test.want) + } + } +} diff --git a/cmd/connect-go-v2-migrate/construction.go b/cmd/connect-go-v2-migrate/construction.go new file mode 100644 index 00000000..bf77d0ba --- /dev/null +++ b/cmd/connect-go-v2-migrate/construction.go @@ -0,0 +1,552 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "go/ast" + "go/printer" + "go/token" + "path" + "strings" +) + +// rewriteServerConstruction rewrites the inline +// mux.Handle(connect.NewHandler(svc, opts...)) shape to the v2 +// server/register/mount form. Consecutive matches on the same mux with +// identical options share one *connect.Server (v2 carries interceptors per +// server). Candidate blocks are collected before mutation so a node is never +// rewritten mid-traversal. +func rewriteServerConstruction(file *ast.File, state *rewriteState, report *Report) { + ictx := newEcosystemContext(file) + stubs := connectStubAliases(file) + interceptorVars := interceptorOptionVars(file, state.connectAlias) + readLimitVars := readLimitOptionVars(file, state.connectAlias) + var blocks []*ast.BlockStmt + walk(file, func(n ast.Node) { + if block, ok := n.(*ast.BlockStmt); ok { + blocks = append(blocks, block) + } + }) + for _, block := range blocks { + // One server per (mux, options) group; a second group on the same mux is + // flagged, since v2 applies interceptors per server. + seenMux := map[string]string{} + for index := 0; index < len(block.List); index++ { + match, ok := matchConstruction(block.List[index], state, ictx, interceptorVars, stubs) + if !ok { + continue + } + muxKey := exprString(match.mux) + if prior, ok := seenMux[muxKey]; ok && prior != match.groupKey { + report.warnAtf(block.List[index].Pos(), ruleHandlerConstruction, "handlers on %s have differing options, so each group gets its own *connect.Server. v2 interceptors apply per server.", muxKey) + } else if !ok { + seenMux[muxKey] = match.groupKey + } + // Extend the run with consecutive matches that can share the server. + run := []constructionMatch{match} + for next := index + 1; next < len(block.List); next++ { + nextMatch, ok := matchConstruction(block.List[next], state, ictx, interceptorVars, stubs) + if !ok || nextMatch.groupKey != match.groupKey { + break + } + run = append(run, nextMatch) + } + repl := buildConstructionReplacement(block, run, state, report, ictx, readLimitVars) + block.List = append(block.List[:index:index], append(repl, block.List[index+len(run):]...)...) + index += len(repl) - 1 + } + } +} + +// constructionMatch holds the parsed parts of one +// `.Handle((svc, opts...))` statement. groupKey is the printed +// (mux, options) pair; statements with equal keys can share a server. +type constructionMatch struct { + mux ast.Expr + pkgIdent *ast.Ident + registerName string + counter string + svc ast.Expr + interceptors []ast.Expr + otherOpts []ast.Expr + groupKey string + optsSpread bool + pos token.Pos +} + +// matchConstruction matches `.Handle(connect.NewHandler(svc, opts...))` +// (or grpchealth.NewHandler) and returns its parsed parts without mutating it. +func matchConstruction(stmt ast.Stmt, state *rewriteState, ictx ecosystemContext, interceptorVars, stubs map[string]bool) (constructionMatch, bool) { + exprStmt, isExprStmt := stmt.(*ast.ExprStmt) + if !isExprStmt { + return constructionMatch{}, false + } + handleCall, isCall := exprStmt.X.(*ast.CallExpr) + if !isCall || len(handleCall.Args) != 1 { + return constructionMatch{}, false + } + handleSel, isSel := handleCall.Fun.(*ast.SelectorExpr) + if !isSel || handleSel.Sel.Name != "Handle" { + return constructionMatch{}, false + } + ctorCall, isCtorCall := handleCall.Args[0].(*ast.CallExpr) + if !isCtorCall || len(ctorCall.Args) == 0 { + return constructionMatch{}, false + } + ctorSel, isCtorSel := ctorCall.Fun.(*ast.SelectorExpr) + if !isCtorSel { + return constructionMatch{}, false + } + pkgIdent, isIdent := ctorSel.X.(*ast.Ident) + if !isIdent { + return constructionMatch{}, false + } + // grpchealth.NewHandler -> grpchealth.Register; generated NewHandler + // -> RegisterHandler. grpchealth matches first because a user alias may + // also end in "connect". + var registerName, counter string + switch { + case ictx.healthAlias != "" && pkgIdent.Name == ictx.healthAlias && ctorSel.Sel.Name == "NewHandler": + registerName, counter = "Register", "grpchealth_register" + case stubs[pkgIdent.Name]: + generatedName, isHandlerCtor := registerHandlerName(ctorSel.Sel.Name) + if !isHandlerCtor { + return constructionMatch{}, false + } + registerName, counter = generatedName, "server_construction" + default: + return constructionMatch{}, false + } + + interceptors, otherOpts := splitHandlerOptions(ctorCall.Args[1:], state, interceptorVars) + var key strings.Builder + key.WriteString(exprString(handleSel.X)) + for _, opt := range ctorCall.Args[1:] { + key.WriteString("|") + key.WriteString(exprString(opt)) + } + return constructionMatch{ + mux: handleSel.X, + pkgIdent: pkgIdent, + registerName: registerName, + counter: counter, + svc: ctorCall.Args[0], + interceptors: interceptors, + otherOpts: otherOpts, + groupKey: key.String(), + optsSpread: ctorCall.Ellipsis.IsValid(), + pos: ctorCall.Pos(), + }, true +} + +// buildConstructionReplacement emits the v2 statements for a run of matches +// sharing one server: a connect.NewServer assignment, one Register call per +// match, and a single connecthttp.Mount. +func buildConstructionReplacement(block *ast.BlockStmt, run []constructionMatch, state *rewriteState, report *Report, ictx ecosystemContext, readLimitVars map[string]bool) []ast.Stmt { + first := run[0] + mapped := make([]ast.Expr, 0, len(first.interceptors)) + for _, interceptor := range first.interceptors { + mapped = append(mapped, mapInterceptor(interceptor, report, ictx, "Server")) + } + + serverName := uniqueIdent(block, "server", "srv") + stmts := make([]ast.Stmt, 0, len(run)+2) + stmts = append(stmts, &ast.AssignStmt{ + Lhs: []ast.Expr{ast.NewIdent(serverName)}, + Tok: token.DEFINE, + Rhs: []ast.Expr{callExpr(state.connectV2Alias, "NewServer", mapped...)}, + }) + for _, match := range run { + stmts = append(stmts, &ast.ExprStmt{X: &ast.CallExpr{ + Fun: &ast.SelectorExpr{X: match.pkgIdent, Sel: ast.NewIdent(match.registerName)}, + Args: []ast.Expr{ast.NewIdent(serverName), match.svc}, + }}) + report.bump(match.counter) + } + mountOpts := first.otherOpts + if presence := findReadLimit(mountOpts, first.optsSpread, state, readLimitVars); presence != readLimitSet { + mountOpts = injectReadLimit(mountOpts, state) + reportReadLimit(report, presence, first.pos) + report.bump("read_limit_pinned") + } + mountArgs := append([]ast.Expr{first.mux, ast.NewIdent(serverName)}, mountOpts...) + stmts = append(stmts, &ast.ExprStmt{X: callExpr(state.connectHTTPAlias, "Mount", mountArgs...)}) + + state.usedV2 = true + state.usedConnectHTTP = true + return stmts +} + +// readLimitPresence reports whether an option list already pins the read limit. +type readLimitPresence int + +const ( + readLimitAbsent readLimitPresence = iota + readLimitSet + readLimitHidden +) + +// findReadLimit classifies opts, treating a trailing spread as hidden. +func findReadLimit(opts []ast.Expr, spread bool, state *rewriteState, vars map[string]bool) readLimitPresence { + for _, opt := range opts { + if call, ok := opt.(*ast.CallExpr); ok && isConnectSelector(call.Fun, state.connectAlias, "WithReadMaxBytes") { + return readLimitSet + } + if id, ok := opt.(*ast.Ident); ok && vars[id.Name] { + return readLimitSet + } + } + if spread { + return readLimitHidden + } + return readLimitAbsent +} + +// readLimitOptionVars returns local variables assigned a connect.WithReadMaxBytes value. +func readLimitOptionVars(file *ast.File, connectAlias string) map[string]bool { + vars := map[string]bool{} + walk(file, func(n ast.Node) { + assign, ok := n.(*ast.AssignStmt) + if !ok || len(assign.Lhs) != len(assign.Rhs) { + return + } + for i, rhs := range assign.Rhs { + call, isCall := rhs.(*ast.CallExpr) + if !isCall || !isConnectSelector(call.Fun, connectAlias, "WithReadMaxBytes") { + continue + } + if id, ok := assign.Lhs[i].(*ast.Ident); ok { + vars[id.Name] = true + } + } + }) + return vars +} + +// injectReadLimit prepends an unlimited read limit, preserving the v1 default. +// It goes first so a later option, including one inside a spread, still wins. +func injectReadLimit(opts []ast.Expr, state *rewriteState) []ast.Expr { + zero := &ast.BasicLit{Kind: token.INT, Value: "0"} + pinned := callExpr(state.connectHTTPAlias, "WithReadMaxBytes", zero) + return append([]ast.Expr{pinned}, opts...) +} + +// reportReadLimit warns that the call was pinned and how to take the v2 default. +func reportReadLimit(report *Report, presence readLimitPresence, pos token.Pos) { + const base = "v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default." + if presence == readLimitHidden { + report.warnAtf(pos, ruleReadLimitDefault, "%s The pin goes first, ahead of options this tool cannot read into, so a limit set there still wins.", base) + return + } + report.warnAtf(pos, ruleReadLimitDefault, "%s", base) +} + +// splitHandlerOptions partitions constructor options into interceptors (inline +// connect.WithInterceptors args, or a variable named in interceptorVars that +// holds such a value) and the rest. Interceptors move to +// connect.NewServer/NewClient, the rest to connecthttp.Mount/NewTransport. +func splitHandlerOptions(opts []ast.Expr, state *rewriteState, interceptorVars map[string]bool) (interceptors, other []ast.Expr) { + for _, opt := range opts { + if call, ok := opt.(*ast.CallExpr); ok && isConnectSelector(call.Fun, state.connectAlias, "WithInterceptors") { + interceptors = append(interceptors, call.Args...) + continue + } + if id, ok := opt.(*ast.Ident); ok && interceptorVars[id.Name] { + interceptors = append(interceptors, opt) + continue + } + other = append(other, opt) + } + return interceptors, other +} + +// interceptorOptionVars returns the names of local variables assigned a +// connect.WithInterceptors(...) value. +func interceptorOptionVars(file *ast.File, connectAlias string) map[string]bool { + vars := map[string]bool{} + walk(file, func(n ast.Node) { + assign, ok := n.(*ast.AssignStmt) + if !ok || len(assign.Lhs) != len(assign.Rhs) { + return + } + for i, rhs := range assign.Rhs { + call, isCall := rhs.(*ast.CallExpr) + if !isCall || !isConnectSelector(call.Fun, connectAlias, "WithInterceptors") { + continue + } + if id, isIdent := assign.Lhs[i].(*ast.Ident); isIdent && id.Name != "_" { + vars[id.Name] = true + } + } + }) + return vars +} + +// mapInterceptor rewrites a known v1 interceptor constructor to its v2 +// side-specific form (validate.NewInterceptor -> validate.NewInterceptor). +// side is "Server" or "Client". Unknown or error-returning interceptors +// (custom ones, and otelconnect) are left untouched and flagged. +func mapInterceptor(interceptor ast.Expr, report *Report, ictx ecosystemContext, side string) ast.Expr { + warnCustom := func() ast.Expr { + report.warnAtf(interceptor.Pos(), ruleInterceptorMigration, "interceptor %s stays in the connect.New%s(...) list unmigrated. Its v2 type is connect.%sInterceptor.", exprString(interceptor), side, side) + return interceptor + } + call, isCall := interceptor.(*ast.CallExpr) + if !isCall { + return warnCustom() + } + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel { + return warnCustom() + } + pkg, isIdent := sel.X.(*ast.Ident) + if !isIdent { + return warnCustom() + } + switch { + case pkg.Name == ictx.validateAlias && sel.Sel.Name == newInterceptorName: + sel.Sel.Name = "New" + side + "Interceptor" + report.bump("interceptor_validate_v2") + return call + case pkg.Name == ictx.otelAlias && sel.Sel.Name == newInterceptorName: + report.warnAtf(interceptor.Pos(), ruleInterceptorMigration, "otelconnect.NewInterceptor stays in the connect.New%s(...) list unmigrated. v2 uses otelconnect.New%sInterceptor (connectrpc.com/otelconnect/v2), which returns an error and is assigned before the constructor.", side, side) + return call + default: + return warnCustom() + } +} + +// rewriteClientConstruction rewrites the v1 generated client constructor +// connect.NewClient(httpClient, baseURL, opts...) to the v2 form that +// takes a *connect.Client built from a transport. Interceptor options move to +// connect.NewClient, the rest to connecthttp.NewTransport. +func rewriteClientConstruction(file *ast.File, state *rewriteState, report *Report) { + ictx := newEcosystemContext(file) + stubs := connectStubAliases(file) + interceptorVars := interceptorOptionVars(file, state.connectAlias) + readLimitVars := readLimitOptionVars(file, state.connectAlias) + walk(file, func(n ast.Node) { + call, isCall := n.(*ast.CallExpr) + if !isCall || len(call.Args) < 2 { + return + } + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel { + return + } + // grpcreflect.NewClient shares the v1 (httpClient, baseURL, opts...) shape. + counter := "client_construction" + if isReflectClientSelector(sel, ictx) { + counter = "grpcreflect_client" + } else if !isClientConstructorSelector(sel, stubs) { + return + } + interceptors, otherOpts := splitHandlerOptions(call.Args[2:], state, interceptorVars) + mapped := make([]ast.Expr, 0, len(interceptors)) + for _, interceptor := range interceptors { + mapped = append(mapped, mapInterceptor(interceptor, report, ictx, "Client")) + } + if presence := findReadLimit(otherOpts, call.Ellipsis.IsValid(), state, readLimitVars); presence != readLimitSet { + otherOpts = injectReadLimit(otherOpts, state) + reportReadLimit(report, presence, call.Pos()) + report.bump("read_limit_pinned") + } + transportArgs := append([]ast.Expr{call.Args[0], call.Args[1]}, otherOpts...) + transport := callExpr(state.connectHTTPAlias, "NewTransport", transportArgs...) + // The spread belongs on the transport, which now owns the option list; + // left on the outer call it would spread the client instead. + if call.Ellipsis.IsValid() { + transport.Ellipsis = call.Ellipsis + call.Ellipsis = token.NoPos + } + newClient := callExpr(state.connectV2Alias, "NewClient", append([]ast.Expr{transport}, mapped...)...) + call.Args = []ast.Expr{newClient} + state.usedV2 = true + state.usedConnectHTTP = true + report.bump(counter) + }) +} + +func isReflectClientSelector(sel *ast.SelectorExpr, ictx ecosystemContext) bool { + pkg, ok := sel.X.(*ast.Ident) + return ok && ictx.reflectAlias != "" && pkg.Name == ictx.reflectAlias && sel.Sel.Name == "NewClient" +} + +// connectStubAliases returns the local names bound to imported generated connect +// stub packages (path's last element ends in "connect"). Matching against this +// set avoids firing on an unrelated identifier or a dot/blank import. +func connectStubAliases(file *ast.File) map[string]bool { + aliases := map[string]bool{} + for _, imp := range file.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + if !strings.HasSuffix(path.Base(importPath), "connect") { + continue + } + name := path.Base(importPath) + if imp.Name != nil { + if imp.Name.Name == "_" || imp.Name.Name == "." { + continue + } + name = imp.Name.Name + } + aliases[name] = true + } + return aliases +} + +// isClientConstructorSelector reports whether sel is a generated +// connect.NewClient constructor. +func isClientConstructorSelector(sel *ast.SelectorExpr, stubs map[string]bool) bool { + pkg, ok := sel.X.(*ast.Ident) + if !ok || !stubs[pkg.Name] { + return false + } + name := sel.Sel.Name + return strings.HasPrefix(name, "New") && strings.HasSuffix(name, "Client") && len(name) > len("NewClient") +} + +// isStubConstructorSelector reports whether sel is a generated connect +// constructor: connect.NewClient or connect.NewHandler. +func isStubConstructorSelector(sel *ast.SelectorExpr, stubs map[string]bool) bool { + pkg, ok := sel.X.(*ast.Ident) + if !ok || !stubs[pkg.Name] { + return false + } + name := sel.Sel.Name + if !strings.HasPrefix(name, "New") { + return false + } + return (strings.HasSuffix(name, "Client") && len(name) > len("NewClient")) || + (strings.HasSuffix(name, "Handler") && len(name) > len("NewHandler")) +} + +// usesConnectStub reports whether the file calls any generated connect +// constructor, so files that touch connect only through stubs are still processed. +func usesConnectStub(file *ast.File) bool { + stubs := connectStubAliases(file) + found := false + walk(file, func(n ast.Node) { + if found { + return + } + if call, ok := n.(*ast.CallExpr); ok { + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && isStubConstructorSelector(sel, stubs) { + found = true + } + } + }) + return found +} + +// firstStubDependentPos returns the position of the first pattern whose rewrite +// depends on regenerated v2 bindings (generated constructors, Request/Response +// wrappers and constructors, or stream types), and whether one was found. +func firstStubDependentPos(file *ast.File, connectAlias string) (token.Pos, bool) { + stubs := connectStubAliases(file) + var pos token.Pos + walk(file, func(n ast.Node) { + if pos.IsValid() { + return + } + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return + } + if isStubConstructorSelector(sel, stubs) { + pos = sel.Pos() + return + } + if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == connectAlias { + switch sel.Sel.Name { + case "Request", "Response", "NewRequest", "NewResponse", + "ClientStream", "ServerStream", "BidiStream": + pos = sel.Pos() + } + } + }) + return pos, pos.IsValid() +} + +// registerHandlerName maps NewHandler to RegisterHandler, returning +// false for names that don't fit that shape. +func registerHandlerName(ctor string) (string, bool) { + if !strings.HasPrefix(ctor, "New") || !strings.HasSuffix(ctor, "Handler") || len(ctor) <= len("NewHandler") { + return "", false + } + return "Register" + strings.TrimPrefix(ctor, "New"), true +} + +// newInterceptorName is the v1 constructor (validate, otelconnect) that v2 +// splits into server and client forms. +const newInterceptorName = "NewInterceptor" + +// ecosystemContext holds the local import names for the recognised ecosystem +// packages (empty when the file does not import one). +type ecosystemContext struct { + validateAlias string + otelAlias string + authnAlias string + healthAlias string + reflectAlias string + vanguardAlias string + vanguardGRPCAlias string +} + +func newEcosystemContext(file *ast.File) ecosystemContext { + return ecosystemContext{ + validateAlias: importLocalName(file, "connectrpc.com/validate"), + otelAlias: importLocalName(file, "connectrpc.com/otelconnect"), + authnAlias: importLocalName(file, "connectrpc.com/authn"), + healthAlias: importLocalName(file, "connectrpc.com/grpchealth"), + reflectAlias: importLocalName(file, "connectrpc.com/grpcreflect"), + vanguardAlias: importLocalName(file, "connectrpc.com/vanguard"), + vanguardGRPCAlias: importLocalName(file, "connectrpc.com/vanguard/vanguardgrpc"), + } +} + +// importLocalName returns the local name a file uses for importPath (explicit +// alias, else the path's last element), or "" if it isn't imported. +func importLocalName(file *ast.File, importPath string) string { + for _, imp := range file.Imports { + if strings.Trim(imp.Path.Value, `"`) != importPath { + continue + } + if imp.Name != nil { + // Dot/blank imports bind no usable qualifier. + if imp.Name.Name == "_" || imp.Name.Name == "." { + return "" + } + return imp.Name.Name + } + return path.Base(importPath) + } + return "" +} + +func callExpr(pkg, fn string, args ...ast.Expr) *ast.CallExpr { + return &ast.CallExpr{ + Fun: &ast.SelectorExpr{X: ast.NewIdent(pkg), Sel: ast.NewIdent(fn)}, + Args: args, + } +} + +func exprString(expr ast.Expr) string { + var buf bytes.Buffer + if err := printer.Fprint(&buf, token.NewFileSet(), expr); err != nil { + return "" + } + return buf.String() +} diff --git a/cmd/connect-go-v2-migrate/construction_test.go b/cmd/connect-go-v2-migrate/construction_test.go new file mode 100644 index 00000000..0234b9e8 --- /dev/null +++ b/cmd/connect-go-v2-migrate/construction_test.go @@ -0,0 +1,126 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +func parseFileForTest(t *testing.T, src string) *ast.File { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "x.go", src, parser.ParseComments) + if err != nil { + t.Fatalf("parse: %v", err) + } + return file +} + +// TestConnectStubAliases checks which imports are treated as connect stub +// packages: a normal or aliased import whose path ends in "connect" qualifies, +// while dot and blank imports (which bind no usable qualifier) and unrelated +// packages do not. +func TestConnectStubAliases(t *testing.T) { + t.Parallel() + src := `package app + +import ( + "example.com/gen/ping/v1/pingv1connect" + pc "example.com/gen/pong/v1/pongv1connect" + . "example.com/gen/dot/v1/dotv1connect" + _ "example.com/gen/blank/v1/blankv1connect" + "fmt" +) +` + aliases := connectStubAliases(parseFileForTest(t, src)) + for name, want := range map[string]bool{ + "pingv1connect": true, // default name + "pc": true, // explicit alias + "dotv1connect": false, // dot import: symbols are unqualified + "blankv1connect": false, // blank import: never referenced + "fmt": false, // unrelated + } { + if got := aliases[name]; got != want { + t.Errorf("connectStubAliases[%q] = %v, want %v", name, got, want) + } + } +} + +// TestStubConstructorSelectorGating checks that a generated-constructor shape is +// matched only when its package qualifier is an imported connect stub. A +// same-named local value (e.g. a variable called "myconnect") must not be +// mistaken for a stub package. +func TestStubConstructorSelectorGating(t *testing.T) { + t.Parallel() + newSel := func(pkg, name string) *ast.SelectorExpr { + return &ast.SelectorExpr{X: ast.NewIdent(pkg), Sel: ast.NewIdent(name)} + } + stubs := map[string]bool{"pingv1connect": true} + tests := []struct { + name string + sel *ast.SelectorExpr + wantStub bool + wantClnt bool + }{ + {name: "imported stub client", sel: newSel("pingv1connect", "NewPingServiceClient"), wantStub: true, wantClnt: true}, + {name: "imported stub handler", sel: newSel("pingv1connect", "NewPingServiceHandler"), wantStub: true, wantClnt: false}, + {name: "local var ending in connect", sel: newSel("myconnect", "NewPingServiceClient"), wantStub: false, wantClnt: false}, + {name: "bare NewClient", sel: newSel("pingv1connect", "NewClient"), wantStub: false, wantClnt: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := isStubConstructorSelector(test.sel, stubs); got != test.wantStub { + t.Errorf("isStubConstructorSelector = %v, want %v", got, test.wantStub) + } + if got := isClientConstructorSelector(test.sel, stubs); got != test.wantClnt { + t.Errorf("isClientConstructorSelector = %v, want %v", got, test.wantClnt) + } + }) + } +} + +// TestImportLocalName checks the local name resolved for an import, including +// dot and blank imports, which bind no qualifier and so report "". +func TestImportLocalName(t *testing.T) { + t.Parallel() + src := `package app + +import ( + "connectrpc.com/grpchealth" + v "connectrpc.com/validate" + . "connectrpc.com/grpcreflect" + _ "connectrpc.com/otelconnect" +) +` + file := parseFileForTest(t, src) + tests := []struct { + path string + want string + }{ + {path: "connectrpc.com/grpchealth", want: "grpchealth"}, + {path: "connectrpc.com/validate", want: "v"}, + {path: "connectrpc.com/grpcreflect", want: ""}, // dot import + {path: "connectrpc.com/otelconnect", want: ""}, // blank import + {path: "connectrpc.com/missing", want: ""}, // not imported + } + for _, test := range tests { + if got := importLocalName(file, test.path); got != test.want { + t.Errorf("importLocalName(%q) = %q, want %q", test.path, got, test.want) + } + } +} diff --git a/cmd/connect-go-v2-migrate/diff.go b/cmd/connect-go-v2-migrate/diff.go new file mode 100644 index 00000000..457ce686 --- /dev/null +++ b/cmd/connect-go-v2-migrate/diff.go @@ -0,0 +1,182 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "strings" +) + +// diffContext is the number of unchanged context lines shown around each change. +const diffContext = 3 + +// ANSI colors for diff output, used only when color is requested. +const ( + colorReset = "\x1b[0m" + colorRed = "\x1b[31m" + colorGreen = "\x1b[32m" + colorCyan = "\x1b[36m" +) + +// diffLine is one line of the edit script: a tag (' ' unchanged, '-' removed, +// '+' added), the text, and the 1-based line numbers it occupies in the old (a) +// and new (b) files (0 when the line is absent from that side). +type diffLine struct { + tag byte + text string + aNum int + bNum int +} + +// unifiedDiff renders the change between a and b as a unified diff (context +// lines, @@ hunk headers, -/+ lines colorized when color is true) for CLI output. +func unifiedDiff(name string, a, b []byte, color bool) string { + lines := editScript(strings.Split(string(a), "\n"), strings.Split(string(b), "\n")) + + var builder strings.Builder + builder.WriteString("--- ") + builder.WriteString(name) + builder.WriteString("\n+++ ") + builder.WriteString(name) + builder.WriteString("\n") + for _, hunk := range hunks(lines) { + writeHunk(&builder, lines[hunk[0]:hunk[1]], color) + } + return builder.String() +} + +// editScript produces the full tagged line sequence via a longest-common- +// subsequence diff. +func editScript(aLines, bLines []string) []diffLine { + aLen, bLen := len(aLines), len(bLines) + // lcs[i][j] = LCS length of aLines[i:] and bLines[j:], filled bottom-up so + // the walk below runs forward. + lcs := make([][]int, aLen+1) + for i := range lcs { + lcs[i] = make([]int, bLen+1) + } + for i := aLen - 1; i >= 0; i-- { + for bIdx := bLen - 1; bIdx >= 0; bIdx-- { + if aLines[i] == bLines[bIdx] { + lcs[i][bIdx] = lcs[i+1][bIdx+1] + 1 + continue + } + lcs[i][bIdx] = max(lcs[i+1][bIdx], lcs[i][bIdx+1]) + } + } + + var lines []diffLine + aIdx, bIdx := 0, 0 + for aIdx < aLen && bIdx < bLen { + switch { + case aLines[aIdx] == bLines[bIdx]: + lines = append(lines, diffLine{tag: ' ', text: aLines[aIdx], aNum: aIdx + 1, bNum: bIdx + 1}) + aIdx++ + bIdx++ + case lcs[aIdx+1][bIdx] >= lcs[aIdx][bIdx+1]: + lines = append(lines, diffLine{tag: '-', text: aLines[aIdx], aNum: aIdx + 1}) + aIdx++ + default: + lines = append(lines, diffLine{tag: '+', text: bLines[bIdx], bNum: bIdx + 1}) + bIdx++ + } + } + for ; aIdx < aLen; aIdx++ { + lines = append(lines, diffLine{tag: '-', text: aLines[aIdx], aNum: aIdx + 1}) + } + for ; bIdx < bLen; bIdx++ { + lines = append(lines, diffLine{tag: '+', text: bLines[bIdx], bNum: bIdx + 1}) + } + return lines +} + +// hunks groups the edit script into [start,end) ranges, each a run of changes +// plus diffContext lines on either side; touching windows merge into one hunk. +func hunks(lines []diffLine) [][2]int { + count := len(lines) + visible := make([]bool, count) + for i := range lines { + if lines[i].tag == ' ' { + continue + } + low := max(i-diffContext, 0) + high := min(i+diffContext, count-1) + for k := low; k <= high; k++ { + visible[k] = true + } + } + + var ranges [][2]int + for i := 0; i < count; { + if !visible[i] { + i++ + continue + } + start := i + for i < count && visible[i] { + i++ + } + ranges = append(ranges, [2]int{start, i}) + } + return ranges +} + +func writeHunk(builder *strings.Builder, lines []diffLine, color bool) { + aStart, aCount, bStart, bCount := bounds(lines) + header := fmt.Sprintf("@@ -%d,%d +%d,%d @@", aStart, aCount, bStart, bCount) + if color { + header = colorCyan + header + colorReset + } + builder.WriteString(header) + builder.WriteString("\n") + for _, line := range lines { + builder.WriteString(colorize(line, color)) + builder.WriteString("\n") + } +} + +// bounds returns the start line and count for each side of a hunk's @@ header. +func bounds(lines []diffLine) (aStart, aCount, bStart, bCount int) { + for _, line := range lines { + if line.aNum != 0 { + if aCount == 0 { + aStart = line.aNum + } + aCount++ + } + if line.bNum != 0 { + if bCount == 0 { + bStart = line.bNum + } + bCount++ + } + } + return aStart, aCount, bStart, bCount +} + +func colorize(line diffLine, color bool) string { + text := string(line.tag) + line.text + if !color { + return text + } + switch line.tag { + case '-': + return colorRed + text + colorReset + case '+': + return colorGreen + text + colorReset + default: + return text + } +} diff --git a/cmd/connect-go-v2-migrate/diff_test.go b/cmd/connect-go-v2-migrate/diff_test.go new file mode 100644 index 00000000..5605843f --- /dev/null +++ b/cmd/connect-go-v2-migrate/diff_test.go @@ -0,0 +1,81 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + "testing" +) + +// TestUnifiedDiffContext checks that a change is shown with surrounding context +// lines and a hunk header, while unchanged lines far from any change are elided. +func TestUnifiedDiffContext(t *testing.T) { + t.Parallel() + lines := []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo"} + old := strings.Join(lines, "\n") + changed := append([]string(nil), lines...) + changed[5] = "FOXTROT" // change the middle line + out := unifiedDiff("file.go", []byte(old), []byte(strings.Join(changed, "\n")), false) + + for _, want := range []string{ + "@@ ", // hunk header + "-foxtrot", // removed line + "+FOXTROT", // added line + " delta", // context before (3 lines) + " echo", // + " golf", // context after + " india", // + } { + if !strings.Contains(out, want) { + t.Errorf("diff missing %q\n%s", want, out) + } + } + // Lines beyond the context window are elided. + for _, unwanted := range []string{"alpha", "bravo", "kilo"} { + if strings.Contains(out, unwanted) { + t.Errorf("diff should have elided %q\n%s", unwanted, out) + } + } +} + +// TestUnifiedDiffColor checks that color wraps removed/added/header lines in the +// ANSI codes only when requested. +func TestUnifiedDiffColor(t *testing.T) { + t.Parallel() + old := []byte("keep\nold\n") + updated := []byte("keep\nnew\n") + + plain := unifiedDiff("file.go", old, updated, false) + if strings.Contains(plain, "\x1b[") { + t.Errorf("color=false should emit no ANSI codes:\n%q", plain) + } + + colored := unifiedDiff("file.go", old, updated, true) + for _, want := range []string{colorRed, colorGreen, colorCyan, colorReset} { + if !strings.Contains(colored, want) { + t.Errorf("color=true missing escape %q:\n%q", want, colored) + } + } +} + +// TestWantColor covers the NO_COLOR opt-out. The TTY branch depends on the +// environment (tests run with a non-terminal stdout), so both paths here +// resolve to no color; the assertion pins the NO_COLOR contract. +func TestWantColor(t *testing.T) { + t.Setenv("NO_COLOR", "1") + if wantColor() { + t.Error("wantColor() with NO_COLOR set = true, want false") + } +} diff --git a/cmd/connect-go-v2-migrate/discover.go b/cmd/connect-go-v2-migrate/discover.go new file mode 100644 index 00000000..1ad76d66 --- /dev/null +++ b/cmd/connect-go-v2-migrate/discover.go @@ -0,0 +1,711 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "go/ast" + "go/token" + "go/types" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/tools/go/packages" +) + +const ( + generatedMarker = "DO NOT EDIT" + connectStubMarker = "protoc-gen-connect-go" + connectV1Path = "connectrpc.com/connect" +) + +// bsrHostPattern matches a registry host, not pinned to buf.build. +const bsrHostPattern = `[^/:]+\.[^/:]+` + +// bsrConnectModule matches a connect generated SDK. +var bsrConnectModule = regexp.MustCompile(`^` + bsrHostPattern + `/gen/go/[^/]+/[^/]+/connectrpc/(?:go|gosimple)$`) + +// isBSRConnectModule reports whether modulePath is a BSR Go SDK generated by the +// connect-go plugin, so `go get @v2` resolves to the connect-v2 build. +func isBSRConnectModule(modulePath string) bool { + return bsrConnectModule.MatchString(modulePath) +} + +// fileContent pairs a path with its bytes. ready is false when the source binds +// a stub still on v1, deferring its stub-dependent rewrites. +type fileContent struct { + path string + content []byte + ready bool +} + +// project is the result of discovering what to migrate. +type project struct { + templates []fileContent // buf.gen.yaml / buf.gen.*.yaml + sources []fileContent // hand-written .go that uses connect (generated excluded) + hasV1Gen bool // generated connect code (local or dependency) still imports v1 + v1GenDirs []string // directories holding the main module's own v1 generated code + // sdkModules are BSR-generated SDK dependencies still on v1; `go get @v2` + // pulls the connect-v2 build. + sdkModules []string + // externalGenModules ship v1 generated connect code but have no reliable + // version query, so they are surfaced as a caution. + externalGenModules []string + // ecosystemModules are the v2 paths for imported connectrpc.com satellites + // (otelconnect, grpcreflect, ...). + ecosystemModules []string + // packagesLoaded is how many packages loaded Go syntax; zero means no module + // loaded (run outside a module, or above nested modules). + packagesLoaded int + // goFilesScanned counts non-generated Go files examined, so the report can + // tell "no Go code" from "Go code, none using connect". + goFilesScanned int + // nestedModuleDirs holds go.mod directories below the roots, populated only + // when packagesLoaded is zero. + nestedModuleDirs []string + // handlerStreams resolves a handler RPC name to its generated v2 stream type. + handlerStreams *handlerStreamResolver + // mockV1Pkgs import connect v1 but are not connect stubs (mockery); their own + // tool regenerates them, so they are a follow-up, not a blocker. + mockV1Pkgs []string + // goModRequires maps the main module's required module paths to versions, + // used to advise a `go get` for v2 modules go.mod is missing. Nil when no + // main module go.mod was found or parsed. + goModRequires map[string]string +} + +// discover loads the Go package graph and walks each package's import map: a +// file is a candidate when it imports connect directly or through a package that +// does. Generated code is never a source (the "DO NOT EDIT" marker or a +// buf.gen.yaml out: dir); a generated file still on v1 flips hasV1Gen. Buf +// templates are found with a filesystem walk. +func discover(roots []string) (project, error) { + templates, genDirs, err := discoverTemplates(roots) + if err != nil { + return project{}, err + } + proj := project{templates: templates} + + mode := packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | + packages.NeedImports | packages.NeedDeps | packages.NeedSyntax | + packages.NeedTypes | packages.NeedTypesInfo | packages.NeedModule + // Tests:true so _test.go files (where most client streaming lives) are + // rewritten too; the seen set below de-duplicates package variants. + pkgs, err := loadGoPackages(roots, packages.Config{Mode: mode, Tests: true}) + if err != nil { + return project{}, err + } + // Count packages that loaded Go syntax; zero means no Go code to migrate. + for _, pkg := range pkgs { + if len(pkg.Syntax) > 0 { + proj.packagesLoaded++ + } + } + proj.goModRequires = mainModuleRequires(pkgs) + // A handler implements its service interface without importing the generated + // connect package, so cross-module stubs are absent from the closure above. + // Pull in those dependency connect packages so their handler streams resolve. + resolverPkgs := make([]*packages.Package, 0, len(pkgs)) + resolverPkgs = append(resolverPkgs, pkgs...) + resolverPkgs = append(resolverPkgs, loadDependencyConnectPackages(pkgs, mode)...) + proj.handlerStreams = buildHandlerStreamResolver(resolverPkgs) + + // Sweep the whole graph for packages that import connect and for generated + // packages still on v1 (the blocker), categorizing the latter by module so + // the regenerate advice fits. + mainDir := mainModuleDir(pkgs) + stubPkgs := map[string]bool{} + v1StubPkgs := map[string]bool{} + mockV1Pkgs := map[string]bool{} + localGenDirs := map[string]bool{} + sdkModules := map[string]bool{} + externalGenModules := map[string]bool{} + packages.Visit(pkgs, func(pkg *packages.Package) bool { + if pkg.Imports[connectV1Path] != nil || pkg.Imports[connectV2Module] != nil { + stubPkgs[pkg.PkgPath] = true + } + for index, file := range pkg.Syntax { + if !fileImports(file, connectV1Path) { + continue + } + // A v1 connect stub blocks its consumers until regeneration; a v1 mock + // is a follow-up its own tool handles. + switch { + case isConnectStubAST(file): + proj.hasV1Gen = true + v1StubPkgs[pkg.PkgPath] = true + categorizeV1Stub(pkg, index, mainDir, localGenDirs, sdkModules, externalGenModules) + case isGeneratedAST(file): + mockV1Pkgs[pkg.PkgPath] = true + } + } + return true + }, nil) + + // Collect rewrite candidates from the requested packages only (dependencies + // are read, not edited), plus the ecosystem modules they import. + ecosystem := map[string]bool{} + seen := map[string]bool{} + for _, pkg := range pkgs { + for importPath := range pkg.Imports { + if mod := ecosystemV2Module(importPath); mod != "" { + ecosystem[mod] = true + } + } + for index, file := range pkg.Syntax { + if index >= len(pkg.CompiledGoFiles) { + continue + } + path := pkg.CompiledGoFiles[index] + if seen[path] { + continue + } + seen[path] = true + if isGeneratedAST(file) || underGenDir(path, genDirs) { + continue + } + proj.goFilesScanned++ + // A file is a rewrite candidate if it uses connect or imports a + // connectrpc.com ecosystem module (authn, grpchealth, ...): the + // ecosystem reshape must reach files that import only a satellite. + if !fileUsesConnect(file, stubPkgs) && !fileImportsEcosystem(file) { + continue + } + content, readErr := os.ReadFile(path) + if readErr != nil { + return project{}, readErr + } + // Ready unless it binds a stub still on v1 (per stub package). + proj.sources = append(proj.sources, fileContent{ + path: path, + content: content, + ready: !importsV1Stub(file, v1StubPkgs), + }) + } + } + + for pkg := range mockV1Pkgs { + proj.mockV1Pkgs = append(proj.mockV1Pkgs, pkg) + } + sort.Strings(proj.mockV1Pkgs) + + for dir := range localGenDirs { + proj.v1GenDirs = append(proj.v1GenDirs, dir) + } + for mod := range sdkModules { + proj.sdkModules = append(proj.sdkModules, mod) + } + for mod := range externalGenModules { + proj.externalGenModules = append(proj.externalGenModules, mod) + } + for mod := range ecosystem { + proj.ecosystemModules = append(proj.ecosystemModules, mod) + } + sort.Strings(proj.sdkModules) + sort.Strings(proj.externalGenModules) + sort.Strings(proj.ecosystemModules) + // No packages loaded: find nested modules to point the user at, since + // "./..." stops at module boundaries. + if proj.packagesLoaded == 0 { + proj.nestedModuleDirs = findModuleDirs(roots) + } + sort.Strings(proj.v1GenDirs) + sort.Slice(proj.sources, func(i, j int) bool { return proj.sources[i].path < proj.sources[j].path }) + return proj, nil +} + +// categorizeV1Stub records where a v1 connect stub lives so the report can give +// the right regenerate advice: a local dir, a BSR SDK to `go get @v2`, or +// another dependency. +func categorizeV1Stub(pkg *packages.Package, index int, mainDir string, localGenDirs, sdkModules, externalGenModules map[string]bool) { + switch { + case pkg.Module != nil && pkg.Module.Main: + if abs, ok := stubDir(pkg, index); ok { + localGenDirs[abs] = true + } + case pkg.Module == nil: + // No module attribution: treat as local only when inside the main module + // tree; otherwise there is no module path to advise `go get @v2` on. + if abs, ok := stubDir(pkg, index); ok && mainDir != "" && underDir(abs, mainDir) { + localGenDirs[abs] = true + } + case isBSRConnectModule(pkg.Module.Path): + sdkModules[pkg.Module.Path] = true + default: + externalGenModules[pkg.Module.Path] = true + } +} + +// mainModuleDir returns the main module's directory among pkgs, or "". +func mainModuleDir(pkgs []*packages.Package) string { + if mod := mainModule(pkgs); mod != nil { + return mod.Dir + } + return "" +} + +// mainModule returns the main module of the loaded packages, or nil. +func mainModule(pkgs []*packages.Package) *packages.Module { + for _, pkg := range pkgs { + if pkg.Module != nil && pkg.Module.Main { + return pkg.Module + } + } + return nil +} + +// mainModuleRequires parses the main module's go.mod and returns its required +// module paths and versions, or nil when it cannot be found or parsed. +func mainModuleRequires(pkgs []*packages.Package) map[string]string { + mod := mainModule(pkgs) + if mod == nil || mod.GoMod == "" { + return nil + } + data, err := os.ReadFile(mod.GoMod) + if err != nil { + return nil + } + file, err := modfile.Parse(mod.GoMod, data, nil) + if err != nil { + return nil + } + requires := make(map[string]string, len(file.Require)) + for _, require := range file.Require { + requires[require.Mod.Path] = require.Mod.Version + } + return requires +} + +// stubDir returns the absolute directory of the stub package's file at index. +func stubDir(pkg *packages.Package, index int) (string, bool) { + if index >= len(pkg.CompiledGoFiles) { + return "", false + } + abs, err := filepath.Abs(filepath.Dir(pkg.CompiledGoFiles[index])) + if err != nil { + return "", false + } + return abs, true +} + +// underDir reports whether path is dir itself or nested within it. +func underDir(path, dir string) bool { + rel, err := filepath.Rel(dir, path) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} + +// loadDependencyConnectPackages loads dependency modules that likely ship +// connect stubs absent from the import closure of pkgs (a replaced module or a +// BSR connect SDK), each as its own main module so its connect packages get +// full type information. +func loadDependencyConnectPackages(pkgs []*packages.Package, mode packages.LoadMode) []*packages.Package { + dirs := map[string]bool{} + packages.Visit(pkgs, func(pkg *packages.Package) bool { + mod := pkg.Module + if mod == nil || mod.Main { + return true + } + // The connect library itself is never a source of user stubs; skip it so + // a local replace of connect doesn't pull its internal test stubs into + // the handler stream resolver. + if mod.Path == connectV1Path || mod.Path == connectV2Module { + return true + } + if mod.Replace == nil && !isBSRConnectModule(mod.Path) { + return true + } + dir := mod.Dir + if mod.Replace != nil && mod.Replace.Dir != "" { + dir = mod.Replace.Dir + } + if dir != "" { + dirs[dir] = true + } + return true + }, nil) + var out []*packages.Package + for dir := range dirs { + loaded, err := packages.Load(&packages.Config{Mode: mode, Dir: dir}, "./...") + if err != nil { + continue + } + out = append(out, loaded...) + } + return out +} + +// buildHandlerStreamResolver scans loaded connect packages for generated handler +// stream types (ServerStream). The service prefix isn't in the +// handler signature, so this type-directed step supplies what the AST pass can't. +func buildHandlerStreamResolver(pkgs []*packages.Package) *handlerStreamResolver { + resolver := &handlerStreamResolver{} + seen := map[string]bool{} + packages.Visit(pkgs, func(pkg *packages.Package) bool { + if pkg.Types == nil || !strings.HasSuffix(pkg.PkgPath, "connect") { + return true + } + scope := pkg.Types.Scope() + for _, name := range scope.Names() { + if !token.IsExported(name) || !strings.HasSuffix(name, "ServerStream") { + continue + } + typeName, isType := scope.Lookup(name).(*types.TypeName) + if !isType { + continue + } + key := pkg.PkgPath + "." + name + if seen[key] { + continue + } + seen[key] = true + named, isNamed := typeName.Type().(*types.Named) + if !isNamed { + continue + } + resolver.types = append(resolver.types, handlerStreamType{ + pkgPath: pkg.PkgPath, + pkgName: pkg.Types.Name(), + name: name, + messages: streamMessageNames(named), + }) + } + return true + }, nil) + return resolver +} + +// streamMessageNames returns the sorted base names of the proto message types a +// generated stream type carries, read from the pointer params/results of its +// Send/Receive methods. +func streamMessageNames(named *types.Named) []string { + set := map[string]bool{} + for method := range named.Methods() { + if method.Name() != "Send" && method.Name() != "Receive" { + continue + } + sig, ok := method.Type().(*types.Signature) + if !ok { + continue + } + for _, tuple := range []*types.Tuple{sig.Params(), sig.Results()} { + for variable := range tuple.Variables() { + if ptr, ok := variable.Type().(*types.Pointer); ok { + if elem, ok := ptr.Elem().(*types.Named); ok { + set[elem.Obj().Name()] = true + } + } + } + } + } + names := make([]string, 0, len(set)) + for name := range set { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// findModuleDirs returns the directories holding go.mod files under the roots, +// used when packages.Load found nothing. +func findModuleDirs(roots []string) []string { + seen := map[string]bool{} + var dirs []string + for _, root := range roots { + _ = walkProject(walkRoot(root), func(path string) error { + if filepath.Base(path) != "go.mod" { + return nil + } + dir := filepath.Dir(path) + if !seen[dir] { + seen[dir] = true + dirs = append(dirs, dir) + } + return nil + }) + } + sort.Strings(dirs) + return dirs +} + +// loadGoPackages loads the requested roots, each resolved to its enclosing +// module and loaded with that module as the working directory (so a nested +// module migrates as if run from inside it). Roots sharing a module load +// together. base is cloned per module with Dir filled in. +func loadGoPackages(roots []string, base packages.Config) ([]*packages.Package, error) { + if len(roots) == 0 { + roots = []string{"."} + } + patternsByDir := map[string][]string{} + var dirOrder []string + for _, root := range roots { + dir, pattern := resolveRoot(root) + if _, seen := patternsByDir[dir]; !seen { + dirOrder = append(dirOrder, dir) + } + patternsByDir[dir] = append(patternsByDir[dir], pattern) + } + var all []*packages.Package + for _, dir := range dirOrder { + cfg := base // copy; Dir is per-module + cfg.Dir = dir + pkgs, err := packages.Load(&cfg, patternsByDir[dir]...) + if err != nil { + return nil, err + } + all = append(all, pkgs...) + } + return all, nil +} + +// resolveRoot maps a root to its module directory and packages.Load pattern: a +// directory/"..." becomes a recursive pattern, a .go file a "file=" query. With +// no enclosing module the dir is "" so packages.Load reports it. +func resolveRoot(root string) (dir, pattern string) { + if strings.HasSuffix(root, ".go") { + abs, err := filepath.Abs(root) + if err != nil { + return "", root + } + return moduleDir(filepath.Dir(abs)), "file=" + abs + } + abs, err := filepath.Abs(walkRoot(root)) + if err != nil { + return "", cwdPattern(root) + } + module := moduleDir(abs) + if module == "" { + return "", cwdPattern(root) + } + rel, err := filepath.Rel(module, abs) + if err != nil { + return "", cwdPattern(root) + } + if rel = filepath.ToSlash(rel); rel == "." { + return module, "./..." + } + return module, "./" + rel + "/..." +} + +// moduleDir returns the nearest ancestor of dir (inclusive) holding a go.mod. +func moduleDir(dir string) string { + for { + if info, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil && !info.IsDir() { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} + +// cwdPattern maps a root to a packages.Load pattern relative to the current +// directory, rooting wildcards at "./" and making directories recursive. +func cwdPattern(root string) string { + switch { + case strings.Contains(root, "..."): + root = filepath.ToSlash(root) + if filepath.IsAbs(root) || strings.HasPrefix(root, "./") || strings.HasPrefix(root, "../") { + return root + } + return "./" + root + case root == ".": + return "./..." + default: + clean := filepath.ToSlash(filepath.Clean(root)) + if !filepath.IsAbs(clean) && !strings.HasPrefix(clean, "./") { + clean = "./" + clean + } + return clean + "/..." + } +} + +// walkRoot is the directory to walk for Buf templates: a "..." wildcard is +// reduced to the directory it spans, everything else walked as given. +func walkRoot(root string) string { + if before, _, found := strings.Cut(root, "..."); found { + return filepath.Clean(before) // Clean("") == ".", so "..." spans the cwd + } + return root +} + +func fileImports(file *ast.File, importPath string) bool { + for _, spec := range file.Imports { + if path, err := strconv.Unquote(spec.Path.Value); err == nil && path == importPath { + return true + } + } + return false +} + +// importsV1Stub reports whether the file imports a connect stub still on v1. +func importsV1Stub(file *ast.File, v1StubPkgs map[string]bool) bool { + for _, spec := range file.Imports { + if path, err := strconv.Unquote(spec.Path.Value); err == nil && v1StubPkgs[path] { + return true + } + } + return false +} + +// fileUsesConnect reports whether the file imports connect directly or through a +// package that does, so stub-only usage is still caught. +// fileImportsEcosystem reports whether the file imports a connectrpc.com +// ecosystem module (authn, grpchealth, grpcreflect, vanguard, ...) that has a +// v2 migration, so its imports and call sites can be reshaped even when it does +// not import connect directly. +func fileImportsEcosystem(file *ast.File) bool { + for _, spec := range file.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + continue + } + if ecosystemV2Module(path) != "" { + return true + } + } + return false +} + +func fileUsesConnect(file *ast.File, stubPkgs map[string]bool) bool { + for _, spec := range file.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + continue + } + if path == connectV1Path || path == connectV2Module || stubPkgs[path] { + return true + } + } + return false +} + +func isGeneratedAST(file *ast.File) bool { + return headerContains(file, generatedMarker) +} + +func isConnectStubAST(file *ast.File) bool { + return headerContains(file, connectStubMarker) +} + +// headerContains reports whether a comment before the package clause has marker. +func headerContains(file *ast.File, marker string) bool { + for _, group := range file.Comments { + if group.End() > file.Package { + break // past the header + } + if strings.Contains(group.Text(), marker) { + return true + } + } + return false +} + +// discoverTemplates walks the roots for Buf templates, returning them and the +// absolute set of their plugin out: directories (excluded from rewriting). +func discoverTemplates(roots []string) ([]fileContent, map[string]bool, error) { + var templates []fileContent + genDirs := map[string]bool{} + visited := map[string]bool{} + for _, root := range roots { + err := walkProject(walkRoot(root), func(path string) error { + if !isBufGenFile(filepath.Base(path)) { + return nil + } + abs, err := filepath.Abs(path) + if err != nil { + return err + } + if visited[abs] { + return nil + } + visited[abs] = true + content, err := os.ReadFile(path) + if err != nil { + return err + } + templates = append(templates, fileContent{path: path, content: content}) + for _, dir := range bufGenOutDirs(path, content) { + genDirs[dir] = true + } + return nil + }) + if err != nil { + return nil, nil, err + } + } + return templates, genDirs, nil +} + +// underGenDir reports whether path lives inside a buf.gen.yaml out: directory. +func underGenDir(path string, genDirs map[string]bool) bool { + abs, err := filepath.Abs(path) + if err != nil { + return false + } + for dir := range genDirs { + if abs != dir && dirContains(dir, abs) { + return true + } + } + return false +} + +// bufGenOutDirs extracts a buf.gen.yaml's plugin out: directories as absolute +// paths relative to the template's directory. An out: dir that contains the +// template's own directory (the common `out: .` source_relative form) is +// dropped; those generated files are caught by their "DO NOT EDIT" marker. +func bufGenOutDirs(templatePath string, content []byte) []string { + base, err := filepath.Abs(filepath.Dir(templatePath)) + if err != nil { + return nil + } + var dirs []string + for line := range strings.SplitSeq(string(content), "\n") { + trimmed := strings.TrimSpace(line) + // Match the plugin `out:` key in either `- out: gen` or `out: gen` form. + trimmed = strings.TrimPrefix(trimmed, "- ") + if !strings.HasPrefix(trimmed, "out:") { + continue + } + value := yamlScalar(trimmed[len("out:"):]) + if value == "" { + continue + } + abs, err := filepath.Abs(filepath.Join(base, value)) + if err != nil || dirContains(abs, base) { + continue + } + dirs = append(dirs, abs) + } + return dirs +} + +// dirContains reports whether outer is inner or an ancestor of it. +func dirContains(outer, inner string) bool { + rel, err := filepath.Rel(outer, inner) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) +} diff --git a/cmd/connect-go-v2-migrate/discover_test.go b/cmd/connect-go-v2-migrate/discover_test.go new file mode 100644 index 00000000..a880e834 --- /dev/null +++ b/cmd/connect-go-v2-migrate/discover_test.go @@ -0,0 +1,339 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/tools/go/packages" +) + +// TestIsConnectStubAST checks which generated files gate the regenerate-first +// phase: protoc-gen-connect-go stubs do, while other generated files that import +// connect (a mockery mock) do not. Both are still generated code, so +// isGeneratedAST reports true for each. +func TestIsConnectStubAST(t *testing.T) { + t.Parallel() + tests := []struct { + name string + header string + wantStub bool + wantGen bool + }{ + { + name: "connect stub", + header: "// Code generated by protoc-gen-connect-go. DO NOT EDIT.", + wantStub: true, + wantGen: true, + }, + { + name: "mockery mock", + header: "// Code generated by mockery. DO NOT EDIT.", + wantStub: false, + wantGen: true, + }, + { + name: "mockgen mock", + header: "// Code generated by MockGen. DO NOT EDIT.", + wantStub: false, + wantGen: true, + }, + { + name: "hand written", + header: "// Package app is hand written.", + wantStub: false, + wantGen: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + src := test.header + "\n\npackage app\n\nimport _ \"connectrpc.com/connect\"\n" + file, err := parser.ParseFile(token.NewFileSet(), "x.go", src, parser.ParseComments) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got := isConnectStubAST(file); got != test.wantStub { + t.Errorf("isConnectStubAST = %v, want %v", got, test.wantStub) + } + if got := isGeneratedAST(file); got != test.wantGen { + t.Errorf("isGeneratedAST = %v, want %v", got, test.wantGen) + } + }) + } +} + +// TestIsBSRConnectModule covers which BSR modules get the @v2 advice: only the +// connect-go plugin's output (.../connectrpc/go), not other plugins under the +// same gen/go prefix, and not non-BSR modules. Self-hosted BSR instances use +// the same path layout under a different host, so they match too. +func TestIsBSRConnectModule(t *testing.T) { + t.Parallel() + tests := []struct { + module string + want bool + }{ + {module: "buf.build/gen/go/parca-dev/parca/connectrpc/go", want: true}, + {module: "buf.build/gen/go/acme/api/connectrpc/go", want: true}, + {module: "buf.build/gen/go/acme/api/protocolbuffers/go", want: false}, // proto messages, versioned separately + {module: "buf.build/gen/go/acme/api/grpc/go", want: false}, // a different plugin + {module: "github.com/grafana/pyroscope/api", want: false}, // not a BSR module + {module: "connectrpc.com/connect", want: false}, + // Self-hosted BSR instances share the layout under another host. + {module: "buf.example.com/gen/go/acme/api/connectrpc/go", want: true}, + {module: "bsr.internal.acme.dev/gen/go/acme/api/connectrpc/go", want: true}, + {module: "buf.example.com/gen/go/acme/api/protocolbuffers/go", want: false}, + // A hostless or malformed path must not match. + {module: "gen/go/acme/api/connectrpc/go", want: false}, + {module: "buf.build/gen/go/acme/api/connectrpc/go/extra", want: false}, + {module: "buf.build/gen/go/acme/connectrpc/go", want: false}, + {module: "notahost/gen/go/acme/api/connectrpc/go", want: false}, + } + for _, test := range tests { + if got := isBSRConnectModule(test.module); got != test.want { + t.Errorf("isBSRConnectModule(%q) = %v, want %v", test.module, got, test.want) + } + } +} + +// TestCwdPattern covers the no-module fallback mapping from a root to a +// packages.Load pattern relative to the current directory, including the "..." +// wildcards a user is likely to type (e.g. "connect-go-v2-migrate ./..."). +// Those must stay rooted at "./", not become a "./.../..." path that does not +// exist. +func TestCwdPattern(t *testing.T) { + t.Parallel() + // An absolute path passes through unchanged. Build it for the host OS so + // the case stays valid on Windows, where "/tmp/x/..." is not absolute. + absRoot := filepath.ToSlash(filepath.Join(t.TempDir(), "x", "...")) + tests := []struct { + root string + want string + }{ + {root: ".", want: "./..."}, + {root: "./...", want: "./..."}, + {root: "...", want: "./..."}, + {root: "./pkg/...", want: "./pkg/..."}, + {root: "pkg/...", want: "./pkg/..."}, + {root: "pkg", want: "./pkg/..."}, + {root: absRoot, want: absRoot}, + } + for _, test := range tests { + t.Run(test.root, func(t *testing.T) { + t.Parallel() + if got := cwdPattern(test.root); got != test.want { + t.Errorf("cwdPattern(%q) = %q, want %q", test.root, got, test.want) + } + }) + } +} + +// TestBufGenOutDirs covers which plugin out: directories become rewrite +// exclusions. A dedicated subdirectory (out: gen) is excluded, but an out: dir +// that contains the module itself (out: ., the source_relative layout that +// interleaves generated and hand-written files) must NOT be excluded. Excluding +// it marked the whole module as generated and hid every Go file. +func TestBufGenOutDirs(t *testing.T) { + t.Parallel() + tests := []struct { + name string + out string + wantLen int + wantSuffix string + }{ + {name: "dedicated dir", out: "gen", wantLen: 1, wantSuffix: "/gen"}, + {name: "nested dir", out: "internal/gen", wantLen: 1, wantSuffix: "/internal/gen"}, + {name: "interleaved dot", out: ".", wantLen: 0}, + {name: "interleaved dot slash", out: "./", wantLen: 0}, + {name: "parent", out: "..", wantLen: 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + content := []byte("version: v2\nplugins:\n - out: " + test.out + "\n") + got := bufGenOutDirs(filepath.Join("proj", "buf.gen.yaml"), content) + if len(got) != test.wantLen { + t.Fatalf("bufGenOutDirs(out: %s) = %q, want %d dir(s)", test.out, got, test.wantLen) + } + if test.wantLen == 1 && !strings.HasSuffix(filepath.ToSlash(got[0]), test.wantSuffix) { + t.Errorf("bufGenOutDirs(out: %s) = %q, want suffix %q", test.out, got[0], test.wantSuffix) + } + }) + } +} + +// TestDiscoverTemplatesSkipsTestdata verifies that buf.gen.yaml files under a +// testdata directory are ignored: they are test fixtures, not real generation +// configs, and rewriting them would corrupt the fixtures. +func TestDiscoverTemplatesSkipsTestdata(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeFile(t, filepath.Join(root, "buf.gen.yaml"), "version: v2\n") + writeFile(t, filepath.Join(root, "testdata", "buf.gen.yaml"), "version: v2\n") + + templates, _, err := discoverTemplates([]string{root}) + if err != nil { + t.Fatal(err) + } + if len(templates) != 1 { + t.Fatalf("found %d templates, want 1 (testdata skipped)", len(templates)) + } + if want := filepath.Join(root, "buf.gen.yaml"); templates[0].path != want { + t.Errorf("returned %s, want the root template %s", templates[0].path, want) + } +} + +// TestUnderDir covers the main-module containment check used to classify +// no-module stub packages: a directory at or below the root is "under" it, +// while a sibling (even one sharing a name prefix) or a parent is not. +func TestUnderDir(t *testing.T) { + t.Parallel() + root := filepath.FromSlash("/home/u/proj") + tests := []struct { + path string + want bool + }{ + {path: filepath.FromSlash("/home/u/proj"), want: true}, + {path: filepath.FromSlash("/home/u/proj/gen/foo"), want: true}, + {path: filepath.FromSlash("/home/u/projector"), want: false}, // shared prefix, not nested + {path: filepath.FromSlash("/home/u/other"), want: false}, + {path: filepath.FromSlash("/home/u"), want: false}, + } + for _, test := range tests { + if got := underDir(test.path, root); got != test.want { + t.Errorf("underDir(%q, %q) = %v, want %v", test.path, root, got, test.want) + } + } +} + +// TestCategorizeV1Stub covers the regenerate advice each kind of v1 stub package +// drives. The main module is local. A BSR connect SDK is a `go get @v2`. Another +// dependency is an external update. A package with no module attribution is local +// only when it lives inside the main module tree, not lumped in with the main +// module (which would tell the user to regenerate code they do not own). +func TestCategorizeV1Stub(t *testing.T) { + t.Parallel() + mainDir := t.TempDir() + outsideDir := t.TempDir() + pkg := func(mod *packages.Module, dir string) *packages.Package { + return &packages.Package{Module: mod, CompiledGoFiles: []string{filepath.Join(dir, "stub.go")}} + } + + local := map[string]bool{} + sdk := map[string]bool{} + external := map[string]bool{} + categorizeV1Stub(pkg(&packages.Module{Main: true}, filepath.Join(mainDir, "gen")), 0, mainDir, local, sdk, external) + categorizeV1Stub(pkg(&packages.Module{Path: "buf.build/gen/go/acme/api/connectrpc/go"}, mainDir), 0, mainDir, local, sdk, external) + categorizeV1Stub(pkg(&packages.Module{Path: "github.com/acme/dep"}, outsideDir), 0, mainDir, local, sdk, external) + categorizeV1Stub(pkg(nil, filepath.Join(mainDir, "vendored")), 0, mainDir, local, sdk, external) // no module, inside main + categorizeV1Stub(pkg(nil, outsideDir), 0, mainDir, local, sdk, external) // no module, outside main + + if want := filepath.Join(mainDir, "gen"); !local[want] { + t.Errorf("main-module stub dir %q not recorded as local: %v", want, local) + } + if want := filepath.Join(mainDir, "vendored"); !local[want] { + t.Errorf("no-module stub under main %q not recorded as local: %v", want, local) + } + if local[outsideDir] { + t.Errorf("no-module stub outside main must not be recorded as local: %v", local) + } + if !sdk["buf.build/gen/go/acme/api/connectrpc/go"] { + t.Errorf("BSR connect SDK not recorded: %v", sdk) + } + if !external["github.com/acme/dep"] { + t.Errorf("external dependency module not recorded: %v", external) + } +} + +// writeFile writes content to path, creating parent directories as needed. +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestResolveRoot covers resolving a root to its enclosing module and the +// pattern within it: pointing at a module directory (or a "..." wildcard over +// it) loads that module from its own root, and a subdirectory scopes the +// pattern under it. This is the behaviour that makes +// "connect-go-v2-migrate ./trivy" act like "cd trivy && connect-go-v2-migrate". +func TestResolveRoot(t *testing.T) { + t.Parallel() + // A module at module/ with a subpackage at module/sub/, none above it. + module := t.TempDir() + if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte("module example.com/m\n\ngo 1.25\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(module, "sub"), 0o755); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + root string + wantDir string + wantPattern string + }{ + {name: "module dir", root: module, wantDir: module, wantPattern: "./..."}, + {name: "module wildcard", root: module + "/...", wantDir: module, wantPattern: "./..."}, + {name: "subdir", root: filepath.Join(module, "sub"), wantDir: module, wantPattern: "./sub/..."}, + {name: "subdir wildcard", root: module + "/sub/...", wantDir: module, wantPattern: "./sub/..."}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + dir, pattern := resolveRoot(test.root) + if dir != test.wantDir || pattern != test.wantPattern { + t.Errorf("resolveRoot(%q) = (%q, %q), want (%q, %q)", test.root, dir, pattern, test.wantDir, test.wantPattern) + } + }) + } +} + +// TestWalkRoot covers the directory the template scan walks for a requested +// root: a "..." wildcard is reduced to the directory it spans, since it is not +// a real filesystem path. +func TestWalkRoot(t *testing.T) { + t.Parallel() + tests := []struct { + root string + want string + }{ + {root: ".", want: "."}, + {root: "./...", want: "."}, + {root: "...", want: "."}, + {root: "pkg/...", want: "pkg"}, + {root: "./pkg/...", want: "pkg"}, + {root: "pkg", want: "pkg"}, + {root: "main.go", want: "main.go"}, + } + for _, test := range tests { + t.Run(test.root, func(t *testing.T) { + t.Parallel() + if got := walkRoot(test.root); got != test.want { + t.Errorf("walkRoot(%q) = %q, want %q", test.root, got, test.want) + } + }) + } +} diff --git a/cmd/connect-go-v2-migrate/ecosystem.go b/cmd/connect-go-v2-migrate/ecosystem.go new file mode 100644 index 00000000..5396e8d2 --- /dev/null +++ b/cmd/connect-go-v2-migrate/ecosystem.go @@ -0,0 +1,133 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "go/ast" + "go/token" + "strings" + + "golang.org/x/tools/go/ast/astutil" +) + +// ecosystemImports maps v1 ecosystem import paths to their /v2 paths. +// Subpackages need their own entry (rewrites match the exact path). +var ecosystemImports = [...][2]string{ + {"connectrpc.com/validate", "connectrpc.com/validate/v2"}, + {"connectrpc.com/otelconnect", "connectrpc.com/otelconnect/v2"}, + {"connectrpc.com/authn", "connectrpc.com/authn/v2"}, + {"connectrpc.com/grpchealth", "connectrpc.com/grpchealth/v2"}, + {"connectrpc.com/grpcreflect", "connectrpc.com/grpcreflect/v2"}, + {"connectrpc.com/vanguard", "connectrpc.com/vanguard/v2"}, + {"connectrpc.com/vanguard/vanguardgrpc", "connectrpc.com/vanguard/v2/vanguardgrpc"}, +} + +// ecosystemV2Module returns the v2 module path to `go get` for a v1 ecosystem +// import, or "" if it's not one. It truncates at "/v2" so a subpackage import +// resolves to its module root (vanguard/vanguardgrpc -> vanguard/v2). +func ecosystemV2Module(importPath string) string { + for _, mod := range ecosystemImports { + if importPath != mod[0] { + continue + } + v2 := mod[1] + if index := strings.Index(v2, "/v2"); index >= 0 { + return v2[:index+len("/v2")] + } + return v2 + } + return "" +} + +// hasEcosystemImport reports whether the file imports any v1 ecosystem package, +// so files touching connect only through one are still processed. +func hasEcosystemImport(file *ast.File) bool { + for _, imp := range file.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + for _, mod := range ecosystemImports { + if importPath == mod[0] { + return true + } + } + } + return false +} + +// firstEcosystemImportPos returns the position of the first v1 ecosystem import. +func firstEcosystemImportPos(file *ast.File) token.Pos { + for _, imp := range file.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + for _, mod := range ecosystemImports { + if importPath == mod[0] { + return imp.Pos() + } + } + } + return token.NoPos +} + +// rewriteEcosystemImports flips every v1 ecosystem import to its /v2 module, +// once the generated bindings are v2. +func rewriteEcosystemImports(fset *token.FileSet, file *ast.File, state *rewriteState, report *Report) { + if !state.stubsReady { + return + } + for _, mod := range ecosystemImports { + if astutil.RewriteImport(fset, file, mod[0], mod[1]) { + report.bump("import_ecosystem_v2") + } + } +} + +// warnEcosystemCalls flags ecosystem call sites whose v2 form the tool can't +// produce mechanically, naming the replacement. +func warnEcosystemCalls(file *ast.File, report *Report) { + ectx := newEcosystemContext(file) + walk(file, func(n ast.Node) { + call, isCall := n.(*ast.CallExpr) + if !isCall { + return + } + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel { + return + } + pkg, isIdent := sel.X.(*ast.Ident) + if !isIdent || pkg.Name == "" { + return + } + // The construction pass already warns interceptor options it relocated + // (e.g. otelconnect.NewInterceptor moved into connect.NewServer); don't + // add a second diagnostic at the same call site. + if report.warnedAt(call.Pos()) { + return + } + name := sel.Sel.Name + switch { + case pkg.Name == ectx.authnAlias && name == "NewMiddleware": + report.warnAtf(call.Pos(), ruleEcosystemMigration, "authn.NewMiddleware -> authn.NewServerInterceptor(authFunc) passed to connect.NewServer. AuthFunc takes (ctx, connect.Spec, *connect.Header). See docs/v2-migration.md") + case pkg.Name == ectx.reflectAlias && (name == "NewHandlerV1" || name == "NewHandlerV1Alpha" || name == "NewStaticReflector" || name == "NewReflector"): + report.warnAtf(call.Pos(), ruleEcosystemMigration, "grpcreflect.%s -> grpcreflect.Register(server) serves v1 and v1alpha and lists the server's registered services by default. See docs/v2-migration.md", name) + case pkg.Name == ectx.vanguardAlias && (name == "NewTranscoder" || name == "NewService" || name == "NewServiceWithSchema"): + report.warnAtf(call.Pos(), ruleEcosystemMigration, "vanguard.%s -> vanguard.Mount(mux, server) mounts REST routes for registered methods with google.api.http annotations. See docs/v2-migration.md", name) + case pkg.Name == ectx.vanguardGRPCAlias && name == "NewTranscoder": + report.warnAtf(call.Pos(), ruleEcosystemMigration, "vanguardgrpc.NewTranscoder -> vanguardgrpc.NewServiceRegistrar(server). See docs/v2-migration.md") + case pkg.Name == ectx.otelAlias && name == newInterceptorName: + report.warnAtf(call.Pos(), ruleEcosystemMigration, "otelconnect.NewInterceptor -> otelconnect.NewServerInterceptor or otelconnect.NewClientInterceptor, depending on use. Both return an error.") + case pkg.Name == ectx.validateAlias && name == newInterceptorName: + report.warnAtf(call.Pos(), ruleEcosystemMigration, "validate.NewInterceptor -> validate.NewServerInterceptor or validate.NewClientInterceptor, depending on use") + } + }) +} diff --git a/cmd/connect-go-v2-migrate/ecosystem_test.go b/cmd/connect-go-v2-migrate/ecosystem_test.go new file mode 100644 index 00000000..55ddfd1b --- /dev/null +++ b/cmd/connect-go-v2-migrate/ecosystem_test.go @@ -0,0 +1,109 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + "testing" +) + +// TestEcosystemWarnings checks that ecosystem call sites the tool can't +// reshape mechanically produce a warning naming the v2 destination. The +// mechanical reshapes (grpchealth.NewHandler, grpcreflect.NewClient) are +// covered by the golden cases under testdata/ecosystem_*. +func TestEcosystemWarnings(t *testing.T) { + t.Parallel() + tests := []struct { + name string + importPath string + body string + wantWarn string + }{ + { + name: "authn_middleware", + importPath: "connectrpc.com/authn", + body: `var _ = authn.NewMiddleware(nil)`, + wantWarn: "authn.NewServerInterceptor", + }, + { + name: "grpcreflect_handler_v1", + importPath: "connectrpc.com/grpcreflect", + body: `var _, _ = grpcreflect.NewHandlerV1(nil)`, + wantWarn: "grpcreflect.Register", + }, + { + name: "grpcreflect_handler_v1alpha", + importPath: "connectrpc.com/grpcreflect", + body: `var _, _ = grpcreflect.NewHandlerV1Alpha(nil)`, + wantWarn: "grpcreflect.Register", + }, + { + name: "grpcreflect_static_reflector", + importPath: "connectrpc.com/grpcreflect", + body: `var _ = grpcreflect.NewStaticReflector("acme.user.v1.UserService")`, + wantWarn: "grpcreflect.Register", + }, + { + name: "vanguard_transcoder", + importPath: "connectrpc.com/vanguard", + body: `var _, _ = vanguard.NewTranscoder(nil)`, + wantWarn: "vanguard.Mount", + }, + { + name: "vanguard_service", + importPath: "connectrpc.com/vanguard", + body: `var _ = vanguard.NewService("acme.user.v1.UserService", nil)`, + wantWarn: "vanguard.Mount", + }, + { + name: "vanguardgrpc_transcoder", + importPath: "connectrpc.com/vanguard/vanguardgrpc", + body: `var _, _ = vanguardgrpc.NewTranscoder(nil)`, + wantWarn: "vanguardgrpc.NewServiceRegistrar", + }, + { + name: "otelconnect_interceptor", + importPath: "connectrpc.com/otelconnect", + body: `var _, _ = otelconnect.NewInterceptor()`, + wantWarn: "otelconnect.NewServerInterceptor or otelconnect.NewClientInterceptor", + }, + { + name: "validate_interceptor_assigned", + importPath: "connectrpc.com/validate", + body: `var _ = validate.NewInterceptor()`, + wantWarn: "validate.NewServerInterceptor or validate.NewClientInterceptor", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + src := "package p\n\nimport \"" + test.importPath + "\"\n\n" + test.body + "\n" + _, report, err := Rewrite("input.go", []byte(src), true) + if err != nil { + t.Fatalf("Rewrite: %v", err) + } + found := false + for _, warning := range report.Warnings { + if warning.Rule == ruleEcosystemMigration && strings.Contains(warning.Msg, test.wantWarn) { + found = true + break + } + } + if !found { + t.Errorf("expected an %s warning containing %q; got %v", ruleEcosystemMigration, test.wantWarn, report.Warnings) + } + }) + } +} diff --git a/cmd/connect-go-v2-migrate/errordetail.go b/cmd/connect-go-v2-migrate/errordetail.go new file mode 100644 index 00000000..2433d6f1 --- /dev/null +++ b/cmd/connect-go-v2-migrate/errordetail.go @@ -0,0 +1,142 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "go/ast" + "go/token" +) + +// rewriteErrorDetails retargets the v1 NewErrorDetail/AddDetail guard to the +// v2 API: +// +// if detail, derr := connect.NewErrorDetail(info); derr == nil { cErr.AddDetail(detail) } +// -> if detail, derr := connectproto.NewErrorDetail(info); derr == nil { cErr = cErr.WithDetail(detail) } +// +// Only this tightly-coupled guard form is safe to rewrite; other +// NewErrorDetail/AddDetail uses are left for rewriteMovedSymbols to warn about. +func rewriteErrorDetails(file *ast.File, state *rewriteState, report *Report) { + walk(file, func(n ast.Node) { + ifStmt, ok := n.(*ast.IfStmt) + if !ok { + return + } + guard, ok := matchDetailGuard(ifStmt, state.connectAlias) + if !ok { + return + } + guard.constructorPkg.Name = "connectproto" + state.addImport("connectrpc.com/connect/v2/connectproto", "connectproto") + detail := &ast.Ident{Name: guard.detailName} + ifStmt.Body.List[0] = withDetailAssign(guard.target, detail, ifStmt.Body.List[0].Pos()) + report.bump("retarget_error_detail") + }) +} + +// detailGuard holds the pieces of a matched NewErrorDetail/AddDetail guard. +type detailGuard struct { + constructorPkg *ast.Ident // the connect package ident of NewErrorDetail + target *ast.Ident // the error value receiving AddDetail + detailName string // the detail variable bound by the guard +} + +// matchDetailGuard recognises `if d, e := connect.NewErrorDetail(msg); e == nil +// { target.AddDetail(d) }`. +func matchDetailGuard(ifStmt *ast.IfStmt, connectAlias string) (detailGuard, bool) { + var guard detailGuard + if ifStmt.Else != nil { + return guard, false + } + init, isAssign := ifStmt.Init.(*ast.AssignStmt) + if !isAssign || init.Tok != token.DEFINE || len(init.Lhs) != 2 || len(init.Rhs) != 1 { + return guard, false + } + detailName, detailOK := identName(init.Lhs[0]) + errName, errOK := identName(init.Lhs[1]) + if !detailOK || !errOK || detailName == "_" { + return guard, false + } + call, isCall := init.Rhs[0].(*ast.CallExpr) + if !isCall || !isConnectSelector(call.Fun, connectAlias, "NewErrorDetail") || len(call.Args) != 1 { + return guard, false + } + pkgIdent := call.Fun.(*ast.SelectorExpr).X.(*ast.Ident) //nolint:errcheck,forcetypeassert // isConnectSelector guarantees the selector and ident shapes. + if !isNilCheck(ifStmt.Cond, errName) { + return guard, false + } + if len(ifStmt.Body.List) != 1 { + return guard, false + } + exprStmt, isExpr := ifStmt.Body.List[0].(*ast.ExprStmt) + if !isExpr { + return guard, false + } + addCall, isAddCall := exprStmt.X.(*ast.CallExpr) + if !isAddCall || len(addCall.Args) != 1 { + return guard, false + } + addSel, isAddSel := addCall.Fun.(*ast.SelectorExpr) + if !isAddSel || addSel.Sel.Name != "AddDetail" { + return guard, false + } + // Target must be a bare identifier to safely duplicate across the assignment. + targetIdent, targetOK := addSel.X.(*ast.Ident) + if !targetOK { + return guard, false + } + if argName, argOK := identName(addCall.Args[0]); !argOK || argName != detailName { + return guard, false + } + return detailGuard{constructorPkg: pkgIdent, target: targetIdent, detailName: detailName}, true +} + +// withDetailAssign builds `target = target.WithDetail(message)` anchored at pos. +func withDetailAssign(target *ast.Ident, message ast.Expr, pos token.Pos) *ast.AssignStmt { + lhs := &ast.Ident{NamePos: pos, Name: target.Name} + receiver := &ast.Ident{NamePos: pos, Name: target.Name} + return &ast.AssignStmt{ + Lhs: []ast.Expr{lhs}, + TokPos: pos, + Tok: token.ASSIGN, + Rhs: []ast.Expr{&ast.CallExpr{ + Fun: &ast.SelectorExpr{ + X: receiver, + Sel: &ast.Ident{Name: "WithDetail"}, + }, + Args: []ast.Expr{message}, + }}, + } +} + +func isNilCheck(cond ast.Expr, name string) bool { + binary, ok := cond.(*ast.BinaryExpr) + if !ok || binary.Op != token.EQL { + return false + } + left, leftOK := identName(binary.X) + right, rightOK := identName(binary.Y) + if !leftOK || !rightOK { + return false + } + return (left == name && right == identNil) || (left == identNil && right == name) +} + +func identName(expr ast.Expr) (string, bool) { + ident, ok := expr.(*ast.Ident) + if !ok { + return "", false + } + return ident.Name, true +} diff --git a/cmd/connect-go-v2-migrate/go.mod b/cmd/connect-go-v2-migrate/go.mod new file mode 100644 index 00000000..8e29ea15 --- /dev/null +++ b/cmd/connect-go-v2-migrate/go.mod @@ -0,0 +1,10 @@ +module connectrpc.com/connect/v2/cmd/connect-go-v2-migrate + +go 1.25.0 + +require ( + golang.org/x/mod v0.38.0 + golang.org/x/tools v0.48.0 +) + +require golang.org/x/sync v0.22.0 // indirect diff --git a/cmd/connect-go-v2-migrate/go.sum b/cmd/connect-go-v2-migrate/go.sum new file mode 100644 index 00000000..2a37fb76 --- /dev/null +++ b/cmd/connect-go-v2-migrate/go.sum @@ -0,0 +1,8 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= diff --git a/cmd/connect-go-v2-migrate/main.go b/cmd/connect-go-v2-migrate/main.go new file mode 100644 index 00000000..9e9a29d5 --- /dev/null +++ b/cmd/connect-go-v2-migrate/main.go @@ -0,0 +1,769 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Command connect-go-v2-migrate rewrites Go source code to migrate from +// connectrpc.com/connect to connectrpc.com/connect/v2. It applies AST +// transformations to update v1 code automatically, emitting warnings for +// patterns that require manual intervention. +// +// Usage: connect-go-v2-migrate [-w] [-json] [-version] [paths...]. Paths default to the +// current directory. Without -w the tool is a dry run that prints diffs. +package main + +import ( + "cmp" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "runtime/debug" + "slices" + "strings" +) + +const migratingGuideURL = "https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md" + +func usage(flags *flag.FlagSet) { + out := flags.Output() + fmt.Fprint(out, `connect-go-v2-migrate rewrites Go code from connectrpc.com/connect (v1) to +connectrpc.com/connect/v2, applying the mechanical parts of the migration: + + - unwraps *connect.Request[T] and *connect.Response[T] in handler and client + signatures + - removes .Msg access and connect.NewRequest or connect.NewResponse wrappers + - converts connect.NewError while preserving the v1 wire message + - adds context.Context to streaming Send and Receive calls + - updates connect-go Buf options for v2 + +The tool reports warnings for code that needs a manual v2 update. + +It loads Go packages to find files that use connect directly or through +generated stubs. It also scans Buf templates named buf.gen.yaml or +buf.gen.*.yaml. Generated code is never edited. This includes files with a +"DO NOT EDIT" header and files under a buf.gen.yaml out directory. + +Migration runs in two phases. When v1 generated code is present, the tool only +updates Buf templates and prints the steps to generate v2 bindings. Run it +again after generation to rewrite Go call sites. + +usage: connect-go-v2-migrate [-w] [-json] [-version] [paths...] + +Paths may be files or directories. They default to the current directory ("."). +By default the tool is a dry run that prints unified diffs for changed files. +Pass -w to write changes to disk. + +Flags: +`) + flags.PrintDefaults() + fmt.Fprintf(out, "\nFull migration guide: %s\n", migratingGuideURL) +} + +// toolVersion reports the module version stamped into the binary. +func toolVersion() string { + info, ok := debug.ReadBuildInfo() + if !ok || info.Main.Version == "" { + return "(unknown)" + } + return info.Main.Version +} + +func main() { + os.Exit(runMain(os.Args[1:])) +} + +func runMain(args []string) int { + flags := flag.NewFlagSet("connect-go-v2-migrate", flag.ContinueOnError) + write := flags.Bool("w", false, "write rewrites back to disk (default: dry-run, print diffs).") + jsonOut := flags.Bool("json", false, "emit a structured JSON report instead of text.") + showVersion := flags.Bool("version", false, "print the tool version and exit.") + flags.Usage = func() { usage(flags) } + if err := flags.Parse(args); err != nil { + return 2 + } + if *showVersion { + fmt.Println("connect-go-v2-migrate", toolVersion()) + return 0 + } + + roots := flags.Args() + if len(roots) == 0 { + roots = []string{"."} + } + + proj, err := discover(roots) + if err != nil { + fmt.Fprintf(os.Stderr, "discover: %v\n", err) + return 1 + } + + run := results{scanned: proj.goFilesScanned + len(proj.templates)} + for _, template := range proj.templates { + processFile(template, RewriteBufGen, *write, &run) + } + // Generator-install guidance drives the phase-1 steps, not code diagnostics. + run.installNotes, run.diagnostics = splitInstallNotes(run.diagnostics) + + // Migration is per stub package: a source whose stubs are already v2 is + // rewritten now; one still binding a v1 stub has its stub-dependent rewrites + // deferred until that stub is regenerated. + anyReady := false + for _, source := range proj.sources { + if source.ready { + anyReady = true + break + } + } + // Pure regenerate-first (v1 stubs, nothing migratable yet) skips the sources. + if anyReady || !proj.hasV1Gen { + for _, source := range proj.sources { + ready := source.ready + rewrite := func(path string, content []byte) ([]byte, Report, error) { + return Rewrite(path, content, ready, withHandlerStreams(proj.handlerStreams)) + } + processFile(source, rewrite, *write, &run) + } + } + if anyReady && run.flippedResult { + // Type-directed post-pass over the rewritten overlay: strip the .Msg + // selectors a return-type flip left dangling, including in _test.go files. + // Skipped when no return type flipped, since nothing can dangle then. + stripDanglingMsgPass(roots, *write, &run) + } + + // Sort warnings so the reported diagnostics are deterministic. + slices.SortStableFunc(run.diagnostics, func(left, right Diagnostic) int { + return cmp.Or( + cmp.Compare(left.File, right.File), + cmp.Compare(left.Line, right.Line), + cmp.Compare(left.Column, right.Column), + cmp.Compare(left.Rule, right.Rule), + cmp.Compare(left.Message, right.Message), + ) + }) + + printReport(&run, &proj, *write, *jsonOut, anyReady, wantColor()) + if run.errored > 0 { + return 1 + } + return 0 +} + +// rewriteResult is a file the tool changed, kept for the REWRITES section. +type rewriteResult struct { + path string + src []byte + out []byte + summary string +} + +const ( + categoryManual = "manual_update" + categoryDeferred = "deferred_update" +) + +// Diagnostic is one issue for the user, with a display path and a stable rule. +type Diagnostic struct { + Category string `json:"category"` + File string `json:"file"` + Line int `json:"line"` + Column int `json:"column"` + Message string `json:"message"` + Rule string `json:"rule"` +} + +// results accumulates a whole run so the output can be grouped into sections. +type results struct { + scanned int + rewrites []rewriteResult + diagnostics []Diagnostic + // installNotes are generator-install diagnostics that drive the phase-1 + // steps instead of being reported as code issues. + installNotes []Diagnostic + // flippedResult is set when a rewrite unwrapped a response return type, + // the only edit that can leave a caller's .Msg dangling in another file. + flippedResult bool + errored int +} + +func splitInstallNotes(diagnostics []Diagnostic) (notes, rest []Diagnostic) { + for _, diag := range diagnostics { + if diag.Rule == ruleBufgenReinstall || diag.Rule == ruleBufgenGoMod { + notes = append(notes, diag) + continue + } + rest = append(rest, diag) + } + return notes, rest +} + +// processFile rewrites one file and accumulates its outcome into run, writing +// to disk when -w is set. Printing is deferred to printReport. +func processFile(file fileContent, rewrite func(string, []byte) ([]byte, Report, error), write bool, run *results) { + out, report, err := rewrite(file.path, file.content) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: rewrite: %v\n", file.path, err) + run.errored++ + return + } + if report.Counts["result_unwrap_response"] > 0 { + run.flippedResult = true + } + if report.Changed { + // A failed write must not be reported as applied. + recorded := true + if write { + if err := writeFilePreservingMode(file.path, out); err != nil { + fmt.Fprintf(os.Stderr, "%s: write: %v\n", file.path, err) + run.errored++ + recorded = false + } + } + if recorded { + run.rewrites = append(run.rewrites, rewriteResult{ + path: file.path, src: file.content, out: out, summary: report.Summary(), + }) + } + } + for _, warning := range report.Warnings { + run.diagnostics = append(run.diagnostics, toDiagnostic(file.path, warning)) + } +} + +// writeFilePreservingMode writes data to path, keeping its current permissions. +// os.WriteFile applies perm only on create, so 0644 only affects a new file. +func writeFilePreservingMode(path string, data []byte) error { + mode := os.FileMode(0o644) + if info, err := os.Stat(path); err == nil { + mode = info.Mode().Perm() + } + return os.WriteFile(path, data, mode) +} + +// stripDanglingMsgPass runs the type-directed .Msg post-pass over an overlay of +// the per-file rewrites and merges its edits into run, updating an existing +// rewrite or adding one for a file (often a _test.go) the pass never touched. +func stripDanglingMsgPass(roots []string, write bool, run *results) { + overlay := map[string][]byte{} + for _, rewrite := range run.rewrites { + if strings.HasSuffix(rewrite.path, ".go") { + overlay[rewrite.path] = rewrite.out + } + } + edits, err := stripDanglingMsg(roots, overlay) + if err != nil { + // Non-fatal: the per-file rewrites stand even if the post-pass can't + // re-type-check an incomplete migration. + fmt.Fprintf(os.Stderr, "strip dangling .Msg: %v\n", err) + return + } + for path, edit := range edits { + if write { + if err := writeFilePreservingMode(path, edit.content); err != nil { + fmt.Fprintf(os.Stderr, "%s: write: %v\n", path, err) + run.errored++ + continue + } + } + mergeMsgEdit(run, path, edit) + } +} + +// mergeMsgEdit folds one .Msg edit into the run, replacing an existing +// rewrite's output or recording a fresh one. +func mergeMsgEdit(run *results, path string, edit msgEdit) { + for i := range run.rewrites { + if run.rewrites[i].path == path { + run.rewrites[i].out = edit.content + run.rewrites[i].summary += fmt.Sprintf(" strip_dangling_msg=%d", edit.count) + return + } + } + run.rewrites = append(run.rewrites, rewriteResult{ + path: path, src: edit.base, out: edit.content, + summary: fmt.Sprintf("strip_dangling_msg=%d", edit.count), + }) +} + +// toDiagnostic converts a Warning into a Diagnostic, falling back to the +// processed file when the warning is position-less. +func toDiagnostic(fallback string, warning Warning) Diagnostic { + file := warning.File + if file == "" { + file = fallback + } + category := categoryManual + if warning.Kind == WarningDeferred { + category = categoryDeferred + } + return Diagnostic{ + Category: category, + File: displayPath(file), + Line: warning.Line, + Column: warning.Col, + Message: warning.Msg, + Rule: warning.Rule, + } +} + +func printReport(run *results, proj *project, write, jsonOut, migrated, color bool) { + if jsonOut { + printJSON(run, proj, write) + return + } + printText(run, proj, write, migrated, color) +} + +func wantColor() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + info, err := os.Stdout.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} + +// printText writes the diffs and diagnostics, then a footer with the scan +// summary (last on purpose, so the counts survive a long scrollback). With v1 +// stubs present and nothing migratable it is the regenerate-first report. +func printText(run *results, proj *project, write, migrated, color bool) { + if proj.hasV1Gen && !migrated { + printPhase1Text(run, proj, write, color) + return + } + + body := false + if len(run.rewrites) > 0 { + lead := "Proposed rewrites (rerun with -w to apply):" + if write { + lead = "Applied rewrites:" + } + fmt.Printf("%s\n", lead) + for _, rewrite := range run.rewrites { + fmt.Printf(" %s: %s\n", displayPath(rewrite.path), rewrite.summary) + if !write { + fmt.Println(unifiedDiff(displayPath(rewrite.path), rewrite.src, rewrite.out, color)) + } + } + body = true + } + + if manual := byCategory(run.diagnostics, categoryManual); len(manual) > 0 { + if body { + fmt.Println() + } + fmt.Print("The following issues require manual code changes:\n") + for _, diag := range manual { + fmt.Printf(" %s\n", formatDiagnostic(diag)) + } + body = true + } + + if deferred := byCategory(run.diagnostics, categoryDeferred); len(deferred) > 0 { + if body { + fmt.Println() + } + fmt.Print("Deferred until their connect stubs are regenerated to v2:\n") + for _, diag := range deferred { + fmt.Printf(" %s\n", formatDiagnostic(diag)) + } + fmt.Print("Regenerate those stubs (buf generate) and re-run connect-go-v2-migrate.\n") + body = true + } + + // Mocks aren't connect stubs, their own tool regenerates them on v2. + if len(proj.mockV1Pkgs) > 0 { + if body { + fmt.Println() + } + fmt.Print("Generated mocks still import connect v1. Regenerate them after migrating:\n") + for _, pkg := range proj.mockV1Pkgs { + fmt.Printf(" %s\n", pkg) + } + body = true + } + + if body { + fmt.Println() + } + fmt.Printf("%s %s\n", scanCounts(proj), rewriteStatus(len(run.rewrites), write)) + + // Explain a zero result: ran outside a module, or found no connect usage. + switch { + case proj.packagesLoaded == 0: + for _, line := range noGoPackagesLines(proj) { + fmt.Println(line) + } + case proj.goFilesScanned > 0 && len(proj.sources) == 0 && len(run.rewrites) == 0 && len(run.diagnostics) == 0: + fmt.Println("None of the scanned Go files import connectrpc.com/connect, so there is nothing to migrate.") + } + + // The migrated code needs the v2 modules in go.mod; prompt for any missing. + if missing := missingV2ModulesAdvice(run, proj); len(missing) > 0 { + fmt.Print("\ngo.mod is missing the v2 modules. Pull them in and tidy:\n\n") + fmt.Printf(" %s\n", goGetCommand(missing)) + fmt.Print(" go mod tidy\n") + } + + if len(run.rewrites) > 0 || len(run.diagnostics) > 0 { + fmt.Printf("Full migration guide: %s\n", migratingGuideURL) + } +} + +// printPhase1Text renders the regenerate-first report: while the generated code +// targets v1, the only edits are Buf template updates, and the report walks the +// user through switching generation to v2 and re-running. +func printPhase1Text(run *results, proj *project, write, color bool) { + summary := scanCounts(proj) + " The generated Connect code still targets v1, so no Go source changes are proposed yet." + for _, line := range wrapLines(summary, 78) { + fmt.Println(line) + } + + // The main module's own generated code is regenerated locally. + if len(proj.v1GenDirs) > 0 { + fmt.Print("\nGenerated v1 Connect code:\n") + for _, dir := range proj.v1GenDirs { + fmt.Printf(" %s\n", displayPath(dir)) + } + } + // BSR-generated SDK dependencies are updated through go get, not regenerated. + if len(proj.sdkModules) > 0 { + fmt.Print("\nGenerated v1 Connect SDKs (the go get @v2 below updates these):\n") + for _, mod := range proj.sdkModules { + fmt.Printf(" %s\n", mod) + } + } + // Other dependencies shipping v1 connect code have no reliable version query. + if len(proj.externalGenModules) > 0 { + fmt.Print("\nDependencies shipping v1 Connect code (update each to a connect-v2 build):\n") + for _, mod := range proj.externalGenModules { + fmt.Printf(" %s\n", mod) + } + } + + if len(run.rewrites) > 0 { + lead := "Proposed Buf template updates (rerun with -w to apply):" + if write { + lead = "Applied Buf template updates:" + } + fmt.Printf("\n%s\n", lead) + for _, rewrite := range run.rewrites { + fmt.Printf(" %s: %s\n", displayPath(rewrite.path), rewrite.summary) + if !write { + fmt.Println(unifiedDiff(displayPath(rewrite.path), rewrite.src, rewrite.out, color)) + } + } + } + + // Sources are skipped in this phase, so any manual diagnostic here belongs to + // a Buf template. Show it before the steps: it can change how they are run. + if manual := byCategory(run.diagnostics, categoryManual); len(manual) > 0 { + fmt.Print("\nThe following issues require a manual update:\n") + for _, diag := range manual { + fmt.Printf(" %s\n", formatDiagnostic(diag)) + } + } + + fmt.Print("\nFirst, move the dependencies and generated code to v2:\n\n") + for number, step := range phase1Steps(run, proj, write) { + fmt.Printf(" %d. %s\n", number+1, step.cmd) + if step.note != "" { + for _, line := range wrapLines("("+step.note+")", 72) { + fmt.Printf(" %s\n", line) + } + } + } + fmt.Print("\nThen re-run connect-go-v2-migrate to work through the Go source changes: it\nrewrites the call sites against the v2 stubs and reports anything that needs\na manual update.\n") + fmt.Printf("\nFull migration guide: %s\n", migratingGuideURL) +} + +// wrapLines greedily wraps text on spaces so no line exceeds width. +func wrapLines(text string, width int) []string { + var lines []string + line := "" + for word := range strings.FieldsSeq(text) { + switch { + case line == "": + line = word + case len(line)+1+len(word) <= width: + line += " " + word + default: + lines = append(lines, line) + line = word + } + } + if line != "" { + lines = append(lines, line) + } + return lines +} + +// step is one numbered action in the phase-1 switch-to-v2 instructions. +type step struct { + cmd string + note string +} + +// phase1Steps builds the move-to-v2 instructions: a `go get -u` for the v2 core, +// SDK dependencies (@v2), and ecosystem modules, plus local regeneration steps +// when the main module generates its own connect code. +func phase1Steps(run *results, proj *project, write bool) []step { + var steps []step + if !write && len(run.rewrites) > 0 { + steps = append(steps, step{cmd: "connect-go-v2-migrate -w (applies the Buf template update above)"}) + } + steps = append(steps, step{ + cmd: goGetCommand(goGetModules(proj)), + note: "pulls the v2 core, generated SDKs, and ecosystem modules into go.mod", + }) + if len(proj.v1GenDirs) > 0 { + steps = append(steps, localGenSteps(run)...) + } + return steps +} + +// goGetModules is the v2 module set to `go get -u`: the connect core, every SDK +// dependency at @v2, and the ecosystem modules. +func goGetModules(proj *project) []string { + mods := make([]string, 0, 1+len(proj.sdkModules)+len(proj.ecosystemModules)) + mods = append(mods, connectV2Module) + for _, mod := range proj.sdkModules { + mods = append(mods, mod+"@v2") + } + return append(mods, proj.ecosystemModules...) +} + +// missingV2Modules returns the v2 modules from goGetModules that the main +// module's go.mod does not require yet. Reports nothing when no go.mod was +// parsed rather than guessing. +func missingV2Modules(proj *project) []string { + if proj.goModRequires == nil { + return nil + } + var missing []string + for _, mod := range goGetModules(proj) { + path, isSDK := strings.CutSuffix(mod, "@v2") + version, ok := proj.goModRequires[path] + if !ok || (isSDK && !strings.HasPrefix(version, "v2.")) { + missing = append(missing, mod) + } + } + return missing +} + +// missingV2ModulesAdvice returns the modules a `go get` hint should name, or +// nil when there is no connect work in scope or go.mod already has them all. +func missingV2ModulesAdvice(run *results, proj *project) []string { + if len(run.rewrites) == 0 && len(run.diagnostics) == 0 && len(proj.sources) == 0 { + return nil + } + return missingV2Modules(proj) +} + +// goGetCommand renders a copy-pasteable `go get -u`, one module per line. +func goGetCommand(mods []string) string { + var builder strings.Builder + builder.WriteString("go get -u") + for _, mod := range mods { + builder.WriteString(" \\\n ") + builder.WriteString(mod) + } + return builder.String() +} + +// localGenSteps are the regeneration steps for a project that generates its own +// connect code: switch the plugin to v2 and regenerate. +func localGenSteps(run *results) []step { + var steps []step + seen := map[string]bool{} + for _, note := range run.installNotes { + if seen[note.Rule] { + continue + } + seen[note.Rule] = true + switch note.Rule { + case ruleBufgenReinstall: + steps = append(steps, step{ + cmd: fmt.Sprintf("go install %s/cmd/%s@latest", connectV2Module, connectLocalPlugin), + note: fmt.Sprintf("%s runs the local %s binary. v1 and v2 share the binary name, so reinstalling from the /v2 module switches generation to v2", note.File, connectLocalPlugin), + }) + case ruleBufgenGoMod: + steps = append(steps, step{ + cmd: fmt.Sprintf("go get -tool %s/cmd/%s && go mod tidy", connectV2Module, connectLocalPlugin), + note: note.File + " runs the plugin through go.mod. The template entry stays the same", + }) + } + } + if len(run.installNotes) == 0 && len(run.rewrites) == 0 { + steps = append(steps, step{ + cmd: fmt.Sprintf("go install %s/cmd/%s@latest", connectV2Module, connectLocalPlugin), + note: "see docs/v2-migration.md if you generate with a remote plugin or a go.mod tool", + }) + } + return append(steps, step{cmd: "buf generate"}) +} + +// jsonReport is the schema emitted by the -json flag. +// +//nolint:tagliatelle // snake_case is the published JSON schema +type jsonReport struct { + Summary jsonSummary `json:"summary"` + Diagnostics []Diagnostic `json:"diagnostics"` + BufTemplates []string `json:"buf_templates,omitempty"` + IgnoredPaths []string `json:"ignored_paths,omitempty"` + NextSteps []string `json:"next_steps,omitempty"` + DocumentationURL string `json:"documentation_url,omitempty"` +} + +//nolint:tagliatelle // snake_case is the published JSON schema +type jsonSummary struct { + FilesScanned int `json:"files_scanned"` + RewritesApplied int `json:"rewrites_applied"` + FilesNeedingFollowUp int `json:"files_needing_follow_up"` +} + +func printJSON(run *results, proj *project, write bool) { + report := jsonReport{ + Summary: jsonSummary{ + FilesScanned: run.scanned, + RewritesApplied: len(run.rewrites), + FilesNeedingFollowUp: distinctFiles(run.diagnostics), + }, + Diagnostics: run.diagnostics, + DocumentationURL: migratingGuideURL, + } + if report.Diagnostics == nil { + report.Diagnostics = []Diagnostic{} + } + for _, template := range proj.templates { + report.BufTemplates = append(report.BufTemplates, displayPath(template.path)) + } + missing := missingV2ModulesAdvice(run, proj) + switch { + case proj.hasV1Gen: + // Regenerate-first phase: name the ignored v1 trees and the move-to-v2 steps. + // Display-relative, matching the text report and keeping output stable. + for _, dir := range proj.v1GenDirs { + report.IgnoredPaths = append(report.IgnoredPaths, displayPath(dir)) + } + for _, step := range phase1Steps(run, proj, write) { + report.NextSteps = append(report.NextSteps, step.cmd) + } + report.NextSteps = append(report.NextSteps, "re-run connect-go-v2-migrate") + case len(missing) > 0: + report.NextSteps = append(report.NextSteps, goGetCommand(missing), "go mod tidy") + case proj.packagesLoaded == 0: + // No Go module loaded: surface the nested modules to run inside. + for _, dir := range proj.nestedModuleDirs { + report.NextSteps = append(report.NextSteps, fmt.Sprintf("cd %s && connect-go-v2-migrate", displayPath(dir))) + } + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) // keep "->" and quotes literal in messages + if err := encoder.Encode(report); err != nil { + fmt.Fprintf(os.Stderr, "encode json: %v\n", err) + } +} + +func byCategory(diagnostics []Diagnostic, category string) []Diagnostic { + var out []Diagnostic + for _, diag := range diagnostics { + if diag.Category == category { + out = append(out, diag) + } + } + return out +} + +func distinctFiles(diagnostics []Diagnostic) int { + seen := map[string]bool{} + for _, diag := range diagnostics { + seen[diag.File] = true + } + return len(seen) +} + +// scanCounts renders the "Scanned N Go file(s) and M Buf template(s)." prefix, +// keeping the two input kinds distinct. +func scanCounts(proj *project) string { + return fmt.Sprintf("Scanned %s and %s.", + plural(proj.goFilesScanned, "Go file"), + plural(len(proj.templates), "Buf template")) +} + +func plural(count int, noun string) string { + if count == 1 { + return "1 " + noun + } + return fmt.Sprintf("%d %ss", count, noun) +} + +// noGoPackagesLines explains why no Go packages loaded, naming any nested +// modules since "./..." stops at module boundaries. +func noGoPackagesLines(proj *project) []string { + if len(proj.nestedModuleDirs) > 0 { + lines := make([]string, 0, 2+len(proj.nestedModuleDirs)) + lines = append(lines, + `No Go packages were loaded. "./..." does not cross module boundaries, and`, + "each directory below has its own go.mod, so run the tool from inside them:") + for _, dir := range proj.nestedModuleDirs { + lines = append(lines, fmt.Sprintf(" cd %s && connect-go-v2-migrate", displayPath(dir))) + } + return lines + } + return []string{"No Go packages were loaded. Run connect-go-v2-migrate from inside a Go module (a directory with a go.mod)."} +} + +func rewriteStatus(count int, write bool) string { + switch { + case count == 0: + return "No automatic rewrites were applied." + case write: + return fmt.Sprintf("%d rewrite(s) applied.", count) + default: + return fmt.Sprintf("%d rewrite(s) ready (rerun with -w to apply).", count) + } +} + +// formatDiagnostic renders a diagnostic in file:line:col: format. +func formatDiagnostic(diag Diagnostic) string { + if diag.Line > 0 { + return fmt.Sprintf("%s:%d:%d: %s", diag.File, diag.Line, diag.Column, diag.Message) + } + return fmt.Sprintf("%s: %s", diag.File, diag.Message) +} + +// displayPath renders a path relative to the working directory with a leading +// "./". Absolute paths outside the working directory are left as-is. +func displayPath(path string) string { + if path == "" { + return path + } + if filepath.IsAbs(path) { + if cwd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(cwd, path); err == nil && !strings.HasPrefix(rel, "..") { + path = rel + } + } + } + if filepath.IsAbs(path) { + return path + } + // Forward slashes keep output stable across platforms. + path = filepath.ToSlash(path) + if strings.HasPrefix(path, "./") || strings.HasPrefix(path, "../") { + return path + } + return "./" + path +} diff --git a/cmd/connect-go-v2-migrate/main_test.go b/cmd/connect-go-v2-migrate/main_test.go new file mode 100644 index 00000000..735ea4fc --- /dev/null +++ b/cmd/connect-go-v2-migrate/main_test.go @@ -0,0 +1,129 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// migrateExecEnv marks a re-exec of the test binary as a `migrate` run: +// script `exec migrate` commands re-run this binary with it set, and +// TestMain dispatches to the real tool instead of the tests. +const migrateExecEnv = "MIGRATE_TEST_EXEC" + +func TestMain(m *testing.M) { + if os.Getenv(migrateExecEnv) == "1" { + os.Exit(runMain(os.Args[1:])) + } + os.Exit(m.Run()) +} + +func TestProcessFileWriteFailure(t *testing.T) { + t.Parallel() + // A path under a directory that does not exist makes os.WriteFile fail. + badPath := filepath.Join(t.TempDir(), "missing", "x.go") + var run results + processFile(fileContent{path: badPath, content: []byte("package q\n")}, changedRewrite, true, &run) + if len(run.rewrites) != 0 { + t.Errorf("a failed write must not be recorded as a rewrite; got %d", len(run.rewrites)) + } + if run.errored != 1 { + t.Errorf("a failed write must increment errored; got %d", run.errored) + } +} + +func TestProcessFileDryRunRecords(t *testing.T) { + t.Parallel() + var run results + processFile(fileContent{path: "x.go", content: []byte("package q\n")}, changedRewrite, false, &run) + if len(run.rewrites) != 1 { + t.Errorf("dry-run should record the proposed rewrite; got %d", len(run.rewrites)) + } + if run.errored != 0 { + t.Errorf("dry-run should not error; got %d", run.errored) + } +} + +func TestWriteFilePreservingMode(t *testing.T) { + t.Parallel() + for _, mode := range []os.FileMode{0o600, 0o644, 0o664, 0o755} { + path := filepath.Join(t.TempDir(), "src.go") + if err := os.WriteFile(path, []byte("before"), mode); err != nil { + t.Fatal(err) + } + // Chmod explicitly: the umask would otherwise clear bits at creation. + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } + if err := writeFilePreservingMode(path, []byte("after")); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != mode { + t.Errorf("mode = %v, want %v", got, mode) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(content) != "after" { + t.Errorf("content = %q, want %q", content, "after") + } + } + + fresh := filepath.Join(t.TempDir(), "new.go") + if err := writeFilePreservingMode(fresh, []byte("x")); err != nil { + t.Fatal(err) + } + info, err := os.Stat(fresh) + if err != nil { + t.Fatal(err) + } + // The umask may clear bits on creation, so assert only that nothing beyond + // 0644 was granted. + if got := info.Mode().Perm(); got&^0o644 != 0 { + t.Errorf("new file mode = %v, want no bits beyond %v", got, os.FileMode(0o644)) + } +} + +func TestVersionFlag(t *testing.T) { + t.Parallel() + if got := runMain([]string{"-version"}); got != 0 { + t.Errorf("runMain(-version) = %d, want 0", got) + } + if toolVersion() == "" { + t.Error("toolVersion() is empty") + } +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + return os.WriteFile(dst, data, 0o644) +} + +func changedRewrite(string, []byte) ([]byte, Report, error) { + return []byte("package p\n"), Report{Changed: true}, nil +} diff --git a/cmd/connect-go-v2-migrate/msgpass.go b/cmd/connect-go-v2-migrate/msgpass.go new file mode 100644 index 00000000..d6c89d2f --- /dev/null +++ b/cmd/connect-go-v2-migrate/msgpass.go @@ -0,0 +1,170 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "go/ast" + "go/types" + "os" + "sort" + + "golang.org/x/tools/go/packages" +) + +// msgEdit holds one file's dangling-.Msg result: the content offsets index +// into, the content after stripping, and the number of strips. +type msgEdit struct { + base []byte + content []byte + count int +} + +// stripDanglingMsg removes `.Msg` selectors left dangling after a return-type +// flip to the bare message *T (e.g. a caller's `got.Msg` in another file, +// including _test.go, that the per-file pass never sees). +// +// It is a type-directed post-pass: reload the project with the per-file rewrites +// as an in-memory overlay (and Tests:true), then delete every X.Msg whose +// receiver type no longer has a Msg field or method. Edits are byte splices at +// token offsets, so formatting and comments are preserved. overlay and the +// returned map are keyed by absolute path; the result holds only changed files. +func stripDanglingMsg(roots []string, overlay map[string][]byte) (map[string]msgEdit, error) { + base := packages.Config{ + Mode: packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | + packages.NeedImports | packages.NeedDeps | packages.NeedSyntax | + packages.NeedTypes | packages.NeedTypesInfo, + Tests: true, + Overlay: overlay, + } + pkgs, err := loadGoPackages(roots, base) + if err != nil { + return nil, err + } + + // Collect byte ranges to delete, grouped by file. A file can appear in + // several package variants (normal and test); take the union of their ranges, + // since each strip is only emitted for a type that definitely lacks Msg. + deletions := map[string]map[offsetRange]bool{} + for _, pkg := range pkgs { + if pkg.TypesInfo == nil { + continue + } + for _, file := range pkg.Syntax { + path := pkg.Fset.Position(file.Pos()).Filename + if isGeneratedAST(file) { + continue + } + ranges := danglingMsgRanges(file, pkg) + if len(ranges) == 0 { + continue + } + set := deletions[path] + if set == nil { + set = map[offsetRange]bool{} + deletions[path] = set + } + for _, r := range ranges { + set[r] = true + } + } + } + + edits := map[string]msgEdit{} + for path, set := range deletions { + if len(set) == 0 { + continue + } + ranges := make([]offsetRange, 0, len(set)) + for r := range set { + ranges = append(ranges, r) + } + content, ok := overlay[path] + if !ok { + disk, readErr := os.ReadFile(path) + if readErr != nil { + return nil, readErr + } + content = disk + } + edits[path] = msgEdit{base: content, content: applyDeletions(content, ranges), count: len(ranges)} + } + return edits, nil +} + +// offsetRange is a half-open byte range [start, end) to delete from a file. +type offsetRange struct { + start, end int +} + +// danglingMsgRanges returns the byte ranges of `.Msg` selectors in file whose +// receiver type no longer has a Msg field or method. +func danglingMsgRanges(file *ast.File, pkg *packages.Package) []offsetRange { + // Skip `.Msg` that is the function being called (stream.Msg()): stripping it + // would leave dangling parens. Only field-access .Msg is stripped. + calledMsg := map[*ast.SelectorExpr]bool{} + ast.Inspect(file, func(node ast.Node) bool { + if call, ok := node.(*ast.CallExpr); ok { + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == identMsg { + calledMsg[sel] = true + } + } + return true + }) + var ranges []offsetRange + ast.Inspect(file, func(node ast.Node) bool { + sel, ok := node.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != identMsg || calledMsg[sel] { + return true + } + recv := pkg.TypesInfo.TypeOf(sel.X) + if recv == nil || !typeMissingMsg(recv, pkg.Types) { + return true + } + start := pkg.Fset.Position(sel.X.End()).Offset + end := pkg.Fset.Position(sel.Sel.End()).Offset + ranges = append(ranges, offsetRange{start: start, end: end}) + return true + }) + return ranges +} + +// typeMissingMsg reports whether typ has neither a field nor a method named Msg. +// A nil or invalid type returns false, so the selector is left alone without +// confident type information. +func typeMissingMsg(typ types.Type, pkg *types.Package) bool { + if typ == nil || typ == types.Typ[types.Invalid] { + return false + } + if basic, ok := typ.Underlying().(*types.Basic); ok && basic.Kind() == types.Invalid { + return false + } + obj, _, _ := types.LookupFieldOrMethod(typ, true, pkg, identMsg) + return obj == nil +} + +// applyDeletions removes the given byte ranges from content, back to front so +// earlier offsets stay valid. +func applyDeletions(content []byte, ranges []offsetRange) []byte { + sorted := append([]offsetRange(nil), ranges...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].start > sorted[j].start }) + out := append([]byte(nil), content...) + for _, r := range sorted { + if r.start < 0 || r.end > len(out) || r.start > r.end { + continue + } + out = append(out[:r.start], out[r.end:]...) + } + return out +} diff --git a/cmd/connect-go-v2-migrate/reshape_warn_test.go b/cmd/connect-go-v2-migrate/reshape_warn_test.go new file mode 100644 index 00000000..87e2c88a --- /dev/null +++ b/cmd/connect-go-v2-migrate/reshape_warn_test.go @@ -0,0 +1,215 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + "testing" +) + +// TestHandlerGroupSplitWarning checks that consecutive handlers whose options +// differ (and so cannot share one *connect.Server, since v2 interceptors apply +// per server) produce a warning asking the user to review the grouping. +func TestHandlerGroupSplitWarning(t *testing.T) { + t.Parallel() + src := `package example + +import ( + "net/http" + + "connectrpc.com/connect" + "connectrpc.com/connect/internal/gen/connect/ping/v1/pingv1connect" + "connectrpc.com/grpchealth" + "connectrpc.com/validate" +) + +type PingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func run() error { + checker := grpchealth.NewStaticChecker(pingv1connect.PingServiceName) + mux := http.NewServeMux() + mux.Handle(pingv1connect.NewPingServiceHandler( + &PingServer{}, + connect.WithInterceptors(validate.NewInterceptor()), + )) + mux.Handle(grpchealth.NewHandler(checker)) + return http.ListenAndServe(":8080", mux) +} +` + _, report, err := Rewrite("input.go", []byte(src), true) + if err != nil { + t.Fatalf("Rewrite: %v", err) + } + found := false + for _, warning := range report.Warnings { + if warning.Rule == ruleHandlerConstruction && strings.Contains(warning.Msg, "differing options") { + found = true + break + } + } + if !found { + t.Errorf("expected a %s warning about differing options; got %v", ruleHandlerConstruction, report.Warnings) + } +} + +// TestOptionTypeRenameDeclined checks the T2.2 guard: an option-slice helper +// that carries an interceptor is not HTTP-only, so its []connect.HandlerOption +// type is left unchanged (no option_type_to_connecthttp rename) and the existing +// reshape warning still fires, sending the human to move the interceptor. +func TestOptionTypeRenameDeclined(t *testing.T) { + t.Parallel() + src := `package p + +import "connectrpc.com/connect" + +func opts(ic connect.Interceptor) []connect.HandlerOption { + return []connect.HandlerOption{connect.WithReadMaxBytes(1), connect.WithInterceptors(ic)} +} +` + got, report, err := Rewrite("input.go", []byte(src), true) + if err != nil { + t.Fatalf("Rewrite: %v", err) + } + if !strings.Contains(string(got), "[]connect.HandlerOption") { + t.Errorf("expected []connect.HandlerOption to be left unchanged; got:\n%s", got) + } + if count, ok := report.Counts["option_type_to_connecthttp"]; ok { + t.Errorf("expected no option_type_to_connecthttp rename; got %d", count) + } + found := false + for _, warning := range report.Warnings { + if warning.Rule == ruleConnectHTTPOption || warning.Rule == ruleServerInterceptor { + found = true + break + } + } + if !found { + t.Errorf("expected a reshape warning for the interceptor-bearing options; got %v", report.Warnings) + } +} + +// TestReshapedSymbolWarnings checks that v1 symbols whose v2 home changed +// shape (so they can't be mechanically flipped) produce a warning naming the +// v2 destination rather than being silently left behind. +func TestReshapedSymbolWarnings(t *testing.T) { + t.Parallel() + tests := []struct { + name string + body string + wantWarn string + }{ + { + name: "new_error_detail", + body: `var _ = connect.NewErrorDetail`, + wantWarn: "WithDetail", + }, + { + name: "is_wire_error", + body: `var _ = connect.IsWireError`, + wantWarn: "IsRemote", + }, + { + name: "new_wire_error", + body: `var _ = connect.NewWireError`, + wantWarn: "WithRemote", + }, + { + name: "with_accept_compression", + body: `var _ = connect.WithAcceptCompression`, + wantWarn: "connecthttp.WithCompressor", + }, + { + name: "with_interceptors", + body: `var _ = connect.WithInterceptors`, + wantWarn: "connect.NewServer", + }, + { + name: "new_not_modified_error", + body: `var _ = connect.NewNotModifiedError`, + wantWarn: "connecthttp.NewNotModifiedError", + }, + { + name: "with_recover", + body: `var _ = connect.WithRecover`, + wantWarn: "connect.ServerInterceptor that recovers panics", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + src := "package p\n\nimport \"connectrpc.com/connect\"\n\n" + test.body + "\n" + _, report, err := Rewrite("input.go", []byte(src), true) + if err != nil { + t.Fatalf("Rewrite: %v", err) + } + found := false + for _, warning := range report.Warnings { + if strings.Contains(warning.Msg, test.wantWarn) { + found = true + break + } + } + if !found { + t.Errorf("expected a warning containing %q; got %v", test.wantWarn, report.Warnings) + } + }) + } +} + +// TestMovedSymbolRewrites checks that v1 symbols that relocated to connecthttp +// verbatim (only the package qualifier changes) are rewritten mechanically +// instead of warned. +func TestMovedSymbolRewrites(t *testing.T) { + t.Parallel() + tests := []struct { + name string + body string + want string + }{ + { + name: "new_error_writer", + body: `var _ = connect.NewErrorWriter`, + want: "connecthttp.NewErrorWriter", + }, + { + name: "error_writer_type", + body: `var _ *connect.ErrorWriter`, + want: "*connecthttp.ErrorWriter", + }, + { + name: "is_not_modified_error", + body: `var _ = connect.IsNotModifiedError`, + want: "connecthttp.IsNotModifiedError", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + src := "package p\n\nimport \"connectrpc.com/connect\"\n\n" + test.body + "\n" + out, report, err := Rewrite("input.go", []byte(src), true) + if err != nil { + t.Fatalf("Rewrite: %v", err) + } + if !strings.Contains(string(out), test.want) { + t.Errorf("expected output containing %q; got:\n%s", test.want, out) + } + if len(report.Warnings) != 0 { + t.Errorf("expected no warnings; got %v", report.Warnings) + } + }) + } +} diff --git a/cmd/connect-go-v2-migrate/rewrite.go b/cmd/connect-go-v2-migrate/rewrite.go new file mode 100644 index 00000000..d9e861e4 --- /dev/null +++ b/cmd/connect-go-v2-migrate/rewrite.go @@ -0,0 +1,1946 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "slices" + "sort" + "strconv" + "strings" + + "golang.org/x/tools/go/ast/astutil" + "golang.org/x/tools/imports" +) + +const ( + identMsg = "Msg" + identError = "Error" + identErrorf = "Errorf" + identHeader = "Header" + identNil = "nil" +) + +const ( + // WarningManual is work the user must do by hand, printed with its message. + WarningManual WarningKind = iota + // WarningDeferred is a stub-dependent rewrite, printed as a bare position. + WarningDeferred +) + +// Diagnostic rule identifiers, surfaced as stable handles in JSON output. +const ( + ruleAwaitingV2Bindings = "awaiting_v2_bindings" + ruleRemoveV1Import = "remove_v1_import" + ruleRequestMetadata = "request_metadata_migration" + ruleHandlerConstruction = "handler_construction" + ruleConnectHTTPOption = "connecthttp_option_migration" + ruleErrorAPI = "error_api_migration" + ruleServerInterceptor = "server_interceptor_migration" + ruleInterceptorMigration = "interceptor_migration" + ruleStreamParamType = "stream_param_type" + ruleStreamParamAmbiguous = "stream_param_ambiguous" + ruleEcosystemMigration = "ecosystem_migration" + ruleBufgenReinstall = "bufgen_reinstall_plugin" + ruleBufgenGoMod = "bufgen_update_go_mod" + ruleBufgenRemoteUnpublished = "bufgen_remote_unpublished" + ruleReadLimitDefault = "read_limit_default" +) + +var ( + // optionTypeNames are the v1 connect option interface types that v2 unified + // into the single connecthttp.Option. + optionTypeNames = map[string]bool{ + "HandlerOption": true, + "ClientOption": true, + "Option": true, + } + // movedToConnectHTTP lists v1 connect symbols that relocated to connecthttp + // verbatim, so connect. becomes connecthttp.. + movedToConnectHTTP = map[string]bool{ + "WithCompressMinBytes": true, + "WithReadMaxBytes": true, + "WithSendMaxBytes": true, + "WithRequireConnectProtocolHeader": true, + "WithSendGzip": true, + "WithSendCompression": true, + "WithHTTPGet": true, + "WithHTTPGetMaxURLSize": true, + "WithProtoJSON": true, + "WithCodec": true, + "ErrorWriter": true, + "NewErrorWriter": true, + "IsNotModifiedError": true, + } + // reshapedToConnectHTTP maps v1 connect symbols whose connecthttp v2 form + // changed name or signature, so the tool warns instead of rewriting. + reshapedToConnectHTTP = map[string]string{ + "HandlerOption": "connecthttp.Option (interceptors go to connect.NewServer, HTTP options to connecthttp.Mount)", + "ClientOption": "connecthttp.Option (interceptors go to connect.NewClient, HTTP options to connecthttp.NewTransport)", + "WithAcceptCompression": "connecthttp.WithCompressor to register a connect.Compressor (see connectgzip), then connecthttp.WithAcceptCompression(name) to advertise it", + "WithCompression": "connecthttp.WithCompressor(connect.Compressor) (see connectgzip); the (name, decompressor, compressor) signature changed", + "WithConditionalHandlerOptions": "connecthttp.WithConditionalOptions(func(connect.Spec) []connecthttp.Option); the callback signature changed", + "NewNotModifiedError": "connecthttp.NewNotModifiedError (takes no http.Header argument)", + } + // connectProtocolOptions is the set of v1 protocol-selecting options. They keep + // their names in connecthttp (connect.WithGRPC() becomes connecthttp.WithGRPC()), + // so only the package qualifier changes. Values mirror connect.ProtocolName*, + // inlined to keep this module free of a connect dependency. + connectProtocolOptions = map[string]string{ + "WithGRPC": "grpc", + "WithGRPCWeb": "grpcweb", + } + + // reshapedErrorAPI maps v1 connect error helpers that v2 folded into *connect.Error + // methods. They stay in core but changed shape, so the tool warns. + reshapedErrorAPI = map[string]string{ + "NewErrorDetail": "connectproto.NewErrorDetail(msg), then attach with (*connect.Error).WithDetail(detail)", + "IsWireError": "errors.As(err, &cerr) into a *connect.Error, then cerr.IsRemote()", + "NewWireError": "connect.NewError(code, msg).WithRemote()", + } + + // reshapedConstruction maps v1 connect options that became positional args to + // connect.NewServer / connect.NewClient in v2, so the tool warns. + reshapedConstruction = map[string]string{ + "WithInterceptors": "interceptors pass to connect.NewServer(...) or connect.NewClient(...). The v2 type is connect.ServerInterceptor or connect.ClientInterceptor.", + "WithRecover": "reimplement as a connect.ServerInterceptor that recovers panics. Re-panic http.ErrAbortHandler so the server still aborts the response.", + } + + // renamedConnectCore lists v1 connect symbols that v2 renamed but kept in the + // core package, so only the selector changes. + renamedConnectCore = map[string]string{ + "CallInfoForHandlerContext": "CallInfoForServerContext", + } +) + +// WarningKind groups diagnostics for the sectioned output. +type WarningKind int + +// Warning is a diagnostic anchored to a source position (file:line:col), or +// position-less when File is empty. +type Warning struct { + File string + Line int + Col int + Msg string + Kind WarningKind + Rule string +} + +// Report describes what a [Rewrite] invocation did to a single file. +type Report struct { + Changed bool + Counts map[string]int // transformation name -> times fired + Warnings []Warning // patterns detected but not rewritten + // fset resolves positions for warnAt; nil for non-Go reports (buf.gen.yaml). + fset *token.FileSet +} + +// Summary returns a stable, single-line summary of the counts. +func (r *Report) Summary() string { + if !r.Changed { + return "unchanged" + } + keys := make([]string, 0, len(r.Counts)) + for k := range r.Counts { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%d", k, r.Counts[k])) + } + return strings.Join(parts, " ") +} + +func (r *Report) bump(name string) { + if r.Counts == nil { + r.Counts = map[string]int{} + } + r.Counts[name]++ + r.Changed = true +} + +// warnAtf records a diagnostic anchored at pos (file:line:col when resolvable). +func (r *Report) warnAtf(pos token.Pos, rule, format string, args ...any) { + warning := Warning{Rule: rule, Msg: fmt.Sprintf(format, args...)} + if r.fset != nil && pos.IsValid() { + position := r.fset.Position(pos) + warning.File, warning.Line, warning.Col = position.Filename, position.Line, position.Column + } + r.Warnings = append(r.Warnings, warning) +} + +// warnedAt reports whether a warning was already recorded at pos, so a later +// pass can avoid emitting a duplicate diagnostic for the same call site. +func (r *Report) warnedAt(pos token.Pos) bool { + if r.fset == nil || !pos.IsValid() { + return false + } + position := r.fset.Position(pos) + for _, warning := range r.Warnings { + if warning.File == position.Filename && warning.Line == position.Line && warning.Col == position.Column { + return true + } + } + return false +} + +// deferAtf records a deferred (stub-dependent) diagnostic anchored at pos. +func (r *Report) deferAtf(pos token.Pos, rule, format string, args ...any) { + before := len(r.Warnings) + r.warnAtf(pos, rule, format, args...) + r.Warnings[before].Kind = WarningDeferred +} + +// warnAtLinef records a diagnostic at an explicit file and 1-based line. +func (r *Report) warnAtLinef(file string, line int, rule, format string, args ...any) { + r.Warnings = append(r.Warnings, Warning{File: file, Line: line, Col: 1, Rule: rule, Msg: fmt.Sprintf(format, args...)}) +} + +// rewriteOption configures an optional input to Rewrite. +type rewriteOption func(*rewriteState) + +// withHandlerStreams supplies the resolved handler stream types. +func withHandlerStreams(resolver *handlerStreamResolver) rewriteOption { + return func(state *rewriteState) { state.handlerStreams = resolver } +} + +// Rewrite applies the v1->v2 mechanical rewrites to src, returning the output +// and a [Report]. Errors are syntax errors only; type mismatches surface as +// warnings. When stubsReady is false (generated bindings are still v1), +// stub-dependent rewrites are deferred and flagged. +func Rewrite(filename string, src []byte, stubsReady bool, opts ...rewriteOption) ([]byte, Report, error) { + report := Report{} + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, filename, src, parser.ParseComments) + if err != nil { + return nil, report, err + } + report.fset = fset + + state := newRewriteState(file) + state.stubsReady = stubsReady + for _, opt := range opts { + opt(state) + } + // Nothing to do unless the file uses connect directly, via a generated + // stub, or via an ecosystem package. + if !state.hasConnectV1Import() && !usesConnectStub(file) && !hasEcosystemImport(file) { + return src, report, nil + } + + // Stub-dependent rewrites wait for v2 bindings; flag the file meanwhile. + if !state.stubsReady { + if pos, ok := firstStubDependentPos(file, state.connectAlias); ok { + report.deferAtf(pos, ruleAwaitingV2Bindings, "stub-dependent rewrite (handler/client signatures, .Msg, NewRequest/NewResponse, streams, construction)") + } + if pos := firstEcosystemImportPos(file); pos.IsValid() { + report.deferAtf(pos, ruleAwaitingV2Bindings, "ecosystem package rewrite (/v2 import paths, construction)") + } + rewriteStubIndependent(file, state, &report) + return finishRewrite(filename, src, fset, file, state, &report) + } + + // Process every function-bearing node, merging the outer scope's unwrap set + // into each closure (minus names it shadows), so `req.Msg` referenced from a + // callback is still unwrapped. + processed := map[*ast.FuncLit]bool{} + var processFunc func(funcType *ast.FuncType, body *ast.BlockStmt, methodName, recvName string, outerMsg, outerServerReqs map[string]bool) + processFunc = func(funcType *ast.FuncType, body *ast.BlockStmt, methodName, recvName string, outerMsg, outerServerReqs map[string]bool) { + // serverReqs are *connect.Request[T] params; their .Header()/.Spec() map + // to the server CallInfo. Client response holders also lose .Msg but read + // response metadata, so the two sets stay apart. + serverReqs := rewriteFuncSignature(funcType, state, &report) + if body == nil { + return + } + clientRequests := scanClientRequestHolders(body, state.connectAlias) + clientResponses := scanClientResponseHolders(body, state.connectAlias, clientRequests) + msgHolders := map[string]bool{} + for name := range serverReqs { + msgHolders[name] = true + } + for name := range clientResponses { + msgHolders[name] = true + } + mergedMsg := mergeUnwrappedScopes(outerMsg, funcType, msgHolders) + mergedServerReqs := mergeUnwrappedScopes(outerServerReqs, funcType, serverReqs) + rewriteFuncBody(body, state, &report, mergedMsg, mergedServerReqs, clientRequests, clientResponses, contextParamName(funcType)) + rewriteStreams(funcType, body, state, &report, methodName, recvName) + // Recurse into nested closures with the merged sets as their outer scope, + // marking them processed so the file-level walk skips them. + walkFuncBody(body, func(n ast.Node) { + lit, ok := n.(*ast.FuncLit) + if !ok || lit.Body == nil { + return + } + processed[lit] = true + processFunc(lit.Type, lit.Body, "", "", mergedMsg, mergedServerReqs) + }) + } + walk(file, func(n ast.Node) { + switch node := n.(type) { + case *ast.FuncDecl: + processFunc(node.Type, node.Body, node.Name.Name, receiverTypeName(node.Recv), nil, nil) + case *ast.FuncLit: + if processed[node] { + return + } + processFunc(node.Type, node.Body, "", "", nil, nil) + case *ast.InterfaceType: + // Interface methods have a signature but no body. + for _, field := range node.Methods.List { + if funcType, ok := field.Type.(*ast.FuncType); ok { + rewriteFuncSignature(funcType, state, &report) + } + } + } + }) + + // Flip the connect.Request[T]/Response[T] types rewriteFuncSignature didn't + // reach: function-type literals, struct fields, var and type declarations. + flipMessageWrapperTypes(file, state, &report) + + // Reshape server/client construction to the v2 *connect.Server / *connect.Client + // forms. Runs before the package-split pass so relocated options still flip. + rewriteServerConstruction(file, state, &report) + rewriteClientConstruction(file, state, &report) + + // Warn on ecosystem call sites the construction passes didn't reshape. Runs + // after them so already-renamed interceptors aren't re-flagged. + warnEcosystemCalls(file, &report) + + rewriteStubIndependent(file, state, &report) + return finishRewrite(filename, src, fset, file, state, &report) +} + +// rewriteStubIndependent applies the rewrites that don't depend on the +// generated stub API: NewResponse/NewRequest stripping, NewError/Code/Errorf +// translation, residual qualifier flips, and the package-split option moves. +func rewriteStubIndependent(file *ast.File, state *rewriteState, report *Report) { + // Rename HTTP-only []connect.XOption helper types first, so the moved-symbol + // pass no longer sees a connect.HandlerOption selector to warn about. + renameHTTPOnlyOptionTypes(file, state, report) + + // Collapse the NewErrorDetail/AddDetail guard into WithDetail first, so it + // isn't also reported as an unported reshaped symbol. + rewriteErrorDetails(file, state, report) + + // File-wide expression rewrites (NewResponse/NewRequest/Code*/NewError/Errorf); + // many occur outside function bodies. + rewriteFileExprs(file, state, report) + + // Flip any remaining qualifier-only `connect.` selectors the + // position-specific passes above missed. + flipRemainingConnectSelectors(file, state, report) + + rewriteMovedSymbols(file, state, report) +} + +// finishRewrite finalizes imports, prints the file, and runs goimports, +// warning (with the residual symbols) if the v1 import survived. +func finishRewrite(filename string, src []byte, fset *token.FileSet, file *ast.File, state *rewriteState, report *Report) ([]byte, Report, error) { + if state.usedV2 { + ensureConnectV2Import(fset, file, state) + report.bump("import_add_connectv2") + } + if state.usedConnectHTTP { + ensureConnectHTTPImport(fset, file, state) + report.bump("import_add_connecthttp") + } + // Stream reshapes introduce errors.Is / io.EOF (AddImport is a no-op when present). + if state.usedErrors { + astutil.AddImport(fset, file, "errors") + } + if state.usedIO { + astutil.AddImport(fset, file, "io") + } + // Imports a rewrite introduced (the connect package of a handler stream type); + // name it only when the package name differs from the path's last segment. + for path, name := range state.imports { + if name == path[strings.LastIndex(path, "/")+1:] { + astutil.AddImport(fset, file, path) + } else { + astutil.AddNamedImport(fset, file, name, path) + } + } + rewriteEcosystemImports(fset, file, state, report) + // Drop the v1 import once v2 took over its "connect" name or the alias is no + // longer referenced. Leftover connect.X (warned-only reshaped symbols) keeps + // it so the file still compiles against v1 until they are ported. + if state.hadV1Import && + ((state.usedV2 && state.connectAlias == state.connectV2Alias) || + !fileReferencesIdent(file, state.connectAlias)) { + removeConnectV1Import(fset, file, state) + report.bump("import_drop_v1") + } + // Capture residual v1 symbols before printing so the retained-import warning + // can name what's left. + residual := residualConnectSymbols(file, state.connectAlias) + + if !report.Changed { + warnIfV1Retained(report, file, state.hadV1Import, residual) + return src, *report, nil + } + + // Repair the vertical gap a position-cleared closure body leaves above it. + normalizeReshapedClosures(file) + + var buf bytes.Buffer + printerConfig := printer.Config{Mode: printer.UseSpaces | printer.TabIndent, Tabwidth: 8} + if err := (&printerConfig).Fprint(&buf, fset, file); err != nil { + return nil, *report, fmt.Errorf("print: %w", err) + } + // goimports sorts/groups imports and drops unused ones. FormatOnly stays off + // but we never ask it to ADD imports (we add what we need explicitly), so it + // won't reach into the user environment for synthetic test inputs. + out, err := imports.Process(filename, buf.Bytes(), &imports.Options{ + Comments: true, + TabIndent: true, + TabWidth: 8, + Fragment: false, + FormatOnly: false, + }) + if err != nil { + return nil, *report, fmt.Errorf("imports.Process: %w", err) + } + warnIfV1Retained(report, file, importsConnectV1(out), residual) + if bytes.Equal(out, src) { + // Formatter normalized the input with no net change; keep warnings only. + return src, Report{Warnings: report.Warnings}, nil + } + return out, *report, nil +} + +// importsConnectV1 reports whether out still imports v1 connectrpc.com/connect +// (the trailing quote excludes the /v2 path). +func importsConnectV1(out []byte) bool { + return bytes.Contains(out, []byte(`"connectrpc.com/connect"`)) +} + +// warnIfV1Retained warns, at the surviving v1 import, naming the residual +// symbols left to port by hand. +func warnIfV1Retained(report *Report, file *ast.File, retained bool, residual []string) { + if !retained { + return + } + references := "has remaining v1 references" + if len(residual) > 0 { + references = "still uses " + strings.Join(residual, ", ") + } + report.warnAtf(v1ImportPos(file), ruleRemoveV1Import, "connectrpc.com/connect (v1) import retained because it %s.", references) +} + +// v1ImportPos returns the position of the v1 connect import spec, or NoPos. +func v1ImportPos(file *ast.File) token.Pos { + for _, spec := range file.Imports { + if path, err := strconv.Unquote(spec.Path.Value); err == nil && path == "connectrpc.com/connect" { + return spec.Pos() + } + } + return token.NoPos +} + +// rewriteState tracks per-file import aliases, which imports are present, and +// which v2/connecthttp references a run introduced. +type rewriteState struct { + connectAlias string // v1 connect package name (default "connect") + connectV2Alias string // v2 connect package name (default "connect") + connectHTTPAlias string // connecthttp package name (default "connecthttp") + hadV1Import bool + hadV2Import bool + hadConnectHTTP bool + usedV2 bool // a rewrite produced a v2 connect reference + usedConnectHTTP bool // a rewrite produced a connecthttp reference + usedErrors bool // a stream reshape introduced errors.Is + usedIO bool // a stream reshape introduced io.EOF + stubsReady bool // generated bindings are v2; stub-dependent rewrites may run + // handlerStreams resolves a handler RPC name to its v2 stream type; nil on + // the AST-only path, where the stream parameter is warned instead. + handlerStreams *handlerStreamResolver + // imports maps path->package name for imports a rewrite introduces; + // finishRewrite adds them before formatting. + imports map[string]string +} + +// addImport records that path (named pkg) must be imported. +func (s *rewriteState) addImport(path, pkg string) { + if s.imports == nil { + s.imports = map[string]string{} + } + s.imports[path] = pkg +} + +func newRewriteState(file *ast.File) *rewriteState { + state := &rewriteState{ + connectAlias: "connect", + connectV2Alias: "connect", + connectHTTPAlias: "connecthttp", + } + for _, imp := range file.Imports { + path := strings.Trim(imp.Path.Value, `"`) + switch path { + case "connectrpc.com/connect": + state.hadV1Import = true + if imp.Name != nil && imp.Name.Name != "_" && imp.Name.Name != "." { + state.connectAlias = imp.Name.Name + } + case "connectrpc.com/connect/v2": + state.hadV2Import = true + if imp.Name != nil && imp.Name.Name != "_" && imp.Name.Name != "." { + state.connectV2Alias = imp.Name.Name + } + case "connectrpc.com/connect/v2/connecthttp": + state.hadConnectHTTP = true + if imp.Name != nil && imp.Name.Name != "_" && imp.Name.Name != "." { + state.connectHTTPAlias = imp.Name.Name + } + } + } + return state +} + +func (s *rewriteState) hasConnectV1Import() bool { + return s.hadV1Import +} + +// rewriteFuncSignature unwraps *connect.Request[T] params to *T and +// *connect.Response[U] results to *U. Returns the set of parameter names +// whose types were unwrapped so the body pass can drop their .Msg accesses. +func rewriteFuncSignature(funcType *ast.FuncType, state *rewriteState, report *Report) map[string]bool { + unwrapped := map[string]bool{} + if funcType.Params != nil { + for _, field := range funcType.Params.List { + if newType, ok := unwrapConnectGeneric(field.Type, state.connectAlias, "Request"); ok { + field.Type = newType + report.bump("param_unwrap_request") + for _, name := range field.Names { + unwrapped[name.Name] = true + } + } + } + } + if funcType.Results != nil { + for _, field := range funcType.Results.List { + if newType, ok := unwrapConnectGeneric(field.Type, state.connectAlias, "Response"); ok { + field.Type = newType + report.bump("result_unwrap_response") + } + } + } + return unwrapped +} + +// flipMessageWrapperTypes replaces every connect.Request[T]/Response[T] type +// with T, in the positions rewriteFuncSignature doesn't reach. Composite-literal +// types are skipped (rewriteExpr unwraps those to the inner value). +func flipMessageWrapperTypes(file *ast.File, state *rewriteState, report *Report) { + astutil.Apply(file, func(cursor *astutil.Cursor) bool { + arg, ok := messageWrapperArg(cursor.Node(), state.connectAlias) + if !ok { + return true + } + if lit, ok := cursor.Parent().(*ast.CompositeLit); ok && lit.Type == cursor.Node() { + return true + } + cursor.Replace(arg) + report.bump("flip_message_wrapper") + return true + }, nil) +} + +// messageWrapperArg returns the message type argument of a connect.Request[T] or +// connect.Response[T] index expression. +func messageWrapperArg(node ast.Node, connectAlias string) (ast.Expr, bool) { + index, ok := node.(*ast.IndexExpr) + if !ok { + return nil, false + } + if isConnectSelector(index.X, connectAlias, "Request") || isConnectSelector(index.X, connectAlias, "Response") { + return index.Index, true + } + return nil, false +} + +// unwrapConnectGeneric turns *connect.[T] into *T. +func unwrapConnectGeneric(typ ast.Expr, connectAlias, typeName string) (ast.Expr, bool) { + star, isStar := typ.(*ast.StarExpr) + if !isStar { + return typ, false + } + idx, isIndex := star.X.(*ast.IndexExpr) + if !isIndex { + return typ, false + } + sel, isSel := idx.X.(*ast.SelectorExpr) + if !isSel { + return typ, false + } + ident, isIdent := sel.X.(*ast.Ident) + if !isIdent { + return typ, false + } + if ident.Name != connectAlias { + return typ, false + } + if sel.Sel.Name != typeName { + return typ, false + } + return &ast.StarExpr{X: idx.Index}, true +} + +// rewriteFuncBody drops `.Msg` from expressions rooted at a value in msgHolders +// (server request params and client response holders), rewrites server +// request-header access to the v2 CallInfo, and warns on response-holder +// metadata access (which moves to the client CallInfo). +func rewriteFuncBody(body *ast.BlockStmt, state *rewriteState, report *Report, msgHolders, serverRequests, clientRequests, clientResponses map[string]bool, ctxName string) { + if len(msgHolders) == 0 && len(clientRequests) == 0 { + return + } + if len(msgHolders) > 0 { + // Drop the .Msg from `x.Msg.<...>` chains. + walkFuncBody(body, func(n ast.Node) { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return + } + root := rootIdent(sel) + if root == nil || !msgHolders[root.Name] { + return + } + inner, isSelector := sel.X.(*ast.SelectorExpr) + if !isSelector || inner.Sel.Name != identMsg { + return + } + innerRoot := rootIdent(inner) + if innerRoot == nil || !msgHolders[innerRoot.Name] { + return + } + sel.X = inner.X + report.bump("body_drop_msg") + }) + + serverResponses := scanServerResponseHolders(body, state.connectAlias) + rewriteRequestMetadata(body, state, report, serverRequests, serverResponses, ctxName) + rewriteBareMsg(body, msgHolders, report) + } + // A client request header set here seeds a context; the response pass then + // warns rather than inserting a second NewClientContext. + requestHeaderSet := false + if len(clientRequests) > 0 { + requestHeaderSet = rewriteClientRequestHeaders(body, state, report, clientRequests, ctxName) + } + if len(clientResponses) > 0 { + rewriteClientResponseMetadata(body, state, report, clientResponses, ctxName, requestHeaderSet) + } +} + +// rewriteRequestMetadata moves server-side metadata access to the v2 CallInfo. +// Client response info is done in rewriteClientResponseMetadata and .Spec() is +// warned. +func rewriteRequestMetadata(body *ast.BlockStmt, state *rewriteState, report *Report, serverRequests, serverResponses map[string]bool, ctxName string) { + infoName := "" + walkFuncBody(body, func(n ast.Node) { + call, ok := n.(*ast.CallExpr) + if !ok { + return + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return + } + root := rootIdent(sel) + if root == nil { + return + } + name := root.Name + // .Spec() has no CallInfo equivalent; it becomes a handler argument. + if sel.Sel.Name == "Spec" && serverRequests[name] { + report.warnAtf(call.Pos(), ruleRequestMetadata, "%s.Spec() arrives as a separate handler argument in v2", name) + return + } + // Map the v1 metadata access to its v2 CallInfo method and report key. + var method, bumpKey, warnMsg string + switch { + case sel.Sel.Name == identHeader && serverRequests[name]: + method, bumpKey = "RequestHeader", "server_header_rewrite" + warnMsg = "%s.Header() reads request headers via the connect.CallInfoForServerContext(ctx) info's RequestHeader() in v2" + case sel.Sel.Name == identHeader && serverResponses[name]: + method, bumpKey = "ResponseHeader", "server_response_metadata_rewrite" + warnMsg = "%s.Header() sets response headers via the connect.CallInfoForServerContext(ctx) info's ResponseHeader() in v2" + case sel.Sel.Name == "Trailer" && serverResponses[name]: + method, bumpKey = "ResponseTrailer", "server_response_metadata_rewrite" + warnMsg = "%s.Trailer() sets response trailers via the connect.CallInfoForServerContext(ctx) info's ResponseTrailer() in v2" + default: + return + } + if ctxName == "" { + report.warnAtf(call.Pos(), ruleRequestMetadata, warnMsg, name) + return + } + if infoName == "" { + infoName = ensureServerCallInfo(body, state, ctxName) + } + call.Fun = &ast.SelectorExpr{X: ast.NewIdent(infoName), Sel: ast.NewIdent(method)} + report.bump(bumpKey) + }) +} + +// ensureServerCallInfo returns the name of a server CallInfo variable usable +// from body, seeding `info, _ := connect.CallInfoForServerContext(ctx)` at the +// top of body when no earlier pass already did. CallInfoForServerContext +// returns (*CallInfo, bool), so metadata access needs a hoisted variable +// rather than an inline chained call. +func ensureServerCallInfo(body *ast.BlockStmt, state *rewriteState, ctxName string) string { + if name, ok := serverCallInfoSeedName(body, state.connectV2Alias); ok { + return name + } + name := uniqueIdent(body, "info", "callInfo") + state.usedV2 = true + seed := &ast.AssignStmt{ + Lhs: []ast.Expr{ast.NewIdent(name), ast.NewIdent("_")}, + Tok: token.DEFINE, + Rhs: []ast.Expr{&ast.CallExpr{ + Fun: &ast.SelectorExpr{ + X: ast.NewIdent(state.connectV2Alias), + Sel: ast.NewIdent("CallInfoForServerContext"), + }, + Args: []ast.Expr{ast.NewIdent(ctxName)}, + }}, + } + body.List = append([]ast.Stmt{seed}, body.List...) + return name +} + +// serverCallInfoSeedName returns the variable holding an existing top-level +// `, := connect.CallInfoForServerContext(...)` seed in body. +func serverCallInfoSeedName(body *ast.BlockStmt, connectV2Alias string) (string, bool) { + for _, stmt := range body.List { + assign, isAssign := stmt.(*ast.AssignStmt) + if !isAssign || assign.Tok != token.DEFINE || len(assign.Lhs) != 2 || len(assign.Rhs) != 1 { + continue + } + call, ok := assign.Rhs[0].(*ast.CallExpr) + if !ok { + continue + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "CallInfoForServerContext" { + continue + } + if id, ok := sel.X.(*ast.Ident); !ok || id.Name != connectV2Alias { + continue + } + if id, ok := assign.Lhs[0].(*ast.Ident); ok && id.Name != "_" { + return id.Name, true + } + } + return "", false +} + +func contextParamName(funcType *ast.FuncType) string { + if funcType == nil || funcType.Params == nil { + return "" + } + for _, field := range funcType.Params.List { + if len(field.Names) == 0 { + continue + } + if isContextType(field.Type) { + // A blank `_ context.Context` isn't usable as a value; treat it as + // no context so callers warn rather than emit ...ForContext(_). + if name := field.Names[0].Name; name != "_" { + return name + } + return "" + } + } + return "" +} + +// receiverTypeName returns a method receiver's base type name ("Ingester" for +// (i Ingester) and (i *Ingester[T])), or "" for no receiver. +func receiverTypeName(recv *ast.FieldList) string { + if recv == nil || len(recv.List) == 0 { + return "" + } + expr := recv.List[0].Type + if star, isStar := expr.(*ast.StarExpr); isStar { + expr = star.X + } + switch typ := expr.(type) { + case *ast.Ident: + return typ.Name + case *ast.IndexExpr: + if id, isIdent := typ.X.(*ast.Ident); isIdent { + return id.Name + } + case *ast.IndexListExpr: + if id, isIdent := typ.X.(*ast.Ident); isIdent { + return id.Name + } + } + return "" +} + +func isContextType(expr ast.Expr) bool { + sel, ok := expr.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Context" { + return false + } + ident, ok := sel.X.(*ast.Ident) + return ok && ident.Name == "context" +} + +// rewriteClientRequestHeaders moves a client's req.Header() writes to a +// connect.NewClientContext info. It reports whether any request header is +// mutated here, so the response pass can avoid seeding a second context. +func rewriteClientRequestHeaders(body *ast.BlockStmt, state *rewriteState, report *Report, requestVars map[string]bool, ctxName string) bool { + // Only request holders whose headers are mutated need a client context. + withHeaders := map[string]bool{} + for name := range requestVars { + if bodyContainsRequestHeader(body, map[string]bool{name: true}) { + withHeaders[name] = true + } + } + if len(withHeaders) == 0 { + return false + } + // Without a usable context, or with multiple header-mutating requests, one + // shared NewClientContext would conflate their metadata; warn instead. + if ctxName == "" || len(withHeaders) > 1 { + for name := range withHeaders { + report.warnAtf(firstCallPos(body, name, identHeader), ruleRequestMetadata, "%s.Header() writes client request headers via connect.NewClientContext(ctx) and info.RequestHeader() in v2", name) + } + return true + } + infoName := uniqueIdent(body, "info", "callInfo") + if first := firstStmtWithRequestHeader(body, withHeaders); first >= 0 { + seed := newClientContextSeed(state, ctxName, infoName) + body.List = append(body.List[:first], append([]ast.Stmt{seed}, body.List[first:]...)...) + report.bump("client_context_insert") + } + walkFuncBody(body, func(n ast.Node) { + call, ok := n.(*ast.CallExpr) + if !ok { + return + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != identHeader { + return + } + root := rootIdent(sel) + if root == nil || !withHeaders[root.Name] { + return + } + call.Fun = &ast.SelectorExpr{ + X: ast.NewIdent(infoName), + Sel: ast.NewIdent("RequestHeader"), + } + state.usedV2 = true + report.bump("client_header_rewrite") + }) + return true +} + +// rewriteClientResponseMetadata moves a client's res.Header()/res.Trailer() +// reads to a connect.NewClientContext info seeded before the call. A single +// response holder with a usable context is rewritten; other shapes (no context, +// several holders, or a request header already seeding a context) are warned. +func rewriteClientResponseMetadata(body *ast.BlockStmt, state *rewriteState, report *Report, responseHolders map[string]bool, ctxName string, requestHeaderSet bool) { + const warnMsg = "%s response metadata reads via connect.NewClientContext(ctx) and info.ResponseHeader()/ResponseTrailer() in v2" + // withMeta maps each response holder that reads metadata to the position of + // its first .Header()/.Trailer() call. + withMeta := map[string]token.Pos{} + for name := range responseHolders { + if pos := firstCallPos(body, name, identHeader, "Trailer"); pos.IsValid() { + withMeta[name] = pos + } + } + if len(withMeta) == 0 { + return + } + // A usable context, a single holder, and no request seed are required to + // inject one NewClientContext without conflating metadata; warn otherwise. + if ctxName == "" || len(withMeta) > 1 || requestHeaderSet { + for name, pos := range withMeta { + report.warnAtf(pos, ruleRequestMetadata, warnMsg, name) + } + return + } + var holder string + for name := range withMeta { + holder = name + } + insertAt := holderAssignIndex(body, holder) + if insertAt < 0 { + report.warnAtf(withMeta[holder], ruleRequestMetadata, warnMsg, holder) + return + } + infoName := uniqueIdent(body, "info", "callInfo") + seed := newClientContextSeed(state, ctxName, infoName) + body.List = append(body.List[:insertAt], append([]ast.Stmt{seed}, body.List[insertAt:]...)...) + report.bump("client_context_insert") + walkFuncBody(body, func(n ast.Node) { + call, ok := n.(*ast.CallExpr) + if !ok { + return + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return + } + root := rootIdent(sel) + if root == nil || root.Name != holder { + return + } + switch sel.Sel.Name { + case identHeader: + call.Fun = &ast.SelectorExpr{X: ast.NewIdent(infoName), Sel: ast.NewIdent("ResponseHeader")} + report.bump("client_response_metadata_rewrite") + case "Trailer": + call.Fun = &ast.SelectorExpr{X: ast.NewIdent(infoName), Sel: ast.NewIdent("ResponseTrailer")} + report.bump("client_response_metadata_rewrite") + } + }) +} + +// bodyHasNewClientContext reports whether the body already seeds a client +// context, so a later pass does not insert a second one. +func bodyHasNewClientContext(body *ast.BlockStmt, connectV2Alias string) bool { + found := false + walkFuncBody(body, func(n ast.Node) { + if found { + return + } + call, ok := n.(*ast.CallExpr) + if !ok { + return + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "NewClientContext" { + return + } + if id, ok := sel.X.(*ast.Ident); ok && id.Name == connectV2Alias { + found = true + } + }) + return found +} + +// newClientContextSeed builds `ctx, info := connect.NewClientContext(ctx)`. +func newClientContextSeed(state *rewriteState, ctxName, infoName string) *ast.AssignStmt { + state.usedV2 = true + return &ast.AssignStmt{ + Lhs: []ast.Expr{ast.NewIdent(ctxName), ast.NewIdent(infoName)}, + Tok: token.DEFINE, + Rhs: []ast.Expr{&ast.CallExpr{ + Fun: &ast.SelectorExpr{X: ast.NewIdent(state.connectV2Alias), Sel: ast.NewIdent("NewClientContext")}, + Args: []ast.Expr{ast.NewIdent(ctxName)}, + }}, + } +} + +// firstCallPos returns the position of the first name.() call for one +// of methods, or token.NoPos. +func firstCallPos(body *ast.BlockStmt, name string, methods ...string) token.Pos { + var pos token.Pos + walkFuncBody(body, func(n ast.Node) { + if pos.IsValid() { + return + } + call, ok := n.(*ast.CallExpr) + if !ok { + return + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || !slices.Contains(methods, sel.Sel.Name) { + return + } + if root := rootIdent(sel); root != nil && root.Name == name { + pos = call.Pos() + } + }) + return pos +} + +// holderAssignIndex returns the index in body.List of the top-level statement +// that assigns name, or -1. +func holderAssignIndex(body *ast.BlockStmt, name string) int { + for index, stmt := range body.List { + assign, ok := stmt.(*ast.AssignStmt) + if !ok { + continue + } + for _, lhs := range assign.Lhs { + if id, ok := lhs.(*ast.Ident); ok && id.Name == name { + return index + } + } + } + return -1 +} + +func firstStmtWithRequestHeader(body *ast.BlockStmt, requestVars map[string]bool) int { + for i, stmt := range body.List { + if bodyContainsRequestHeader(stmt, requestVars) { + return i + } + } + return -1 +} + +func bodyContainsRequestHeader(node ast.Node, requestVars map[string]bool) bool { + found := false + walkFuncBody(node, func(n ast.Node) { + if found { + return + } + call, ok := n.(*ast.CallExpr) + if !ok { + return + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != identHeader { + return + } + root := rootIdent(sel) + found = root != nil && requestVars[root.Name] + }) + return found +} + +func uniqueIdent(body *ast.BlockStmt, preferred ...string) string { + used := map[string]bool{} + walkFuncBody(body, func(n ast.Node) { + if id, ok := n.(*ast.Ident); ok { + used[id.Name] = true + } + }) + for _, name := range preferred { + if !used[name] { + return name + } + } + for i := 2; ; i++ { + name := fmt.Sprintf("%s%d", preferred[len(preferred)-1], i) + if !used[name] { + return name + } + } +} + +// rewriteBareMsg replaces bare `x.Msg` with `x` in common expression positions, +// complementing rewriteFuncBody, which handles `x.Msg.<...>` chains. +func rewriteBareMsg(body *ast.BlockStmt, unwrapped map[string]bool, report *Report) { + replace := func(expr *ast.Expr) { + sel, ok := (*expr).(*ast.SelectorExpr) + if !ok || sel.Sel.Name != identMsg { + return + } + id, ok := sel.X.(*ast.Ident) + if !ok || !unwrapped[id.Name] { + return + } + *expr = id + report.bump("body_drop_msg") + } + walkFuncBody(body, func(n ast.Node) { + switch node := n.(type) { + case *ast.AssignStmt: + for i := range node.Rhs { + replace(&node.Rhs[i]) + } + case *ast.ReturnStmt: + for i := range node.Results { + replace(&node.Results[i]) + } + case *ast.CallExpr: + for i := range node.Args { + replace(&node.Args[i]) + } + case *ast.BinaryExpr: + replace(&node.X) + replace(&node.Y) + case *ast.UnaryExpr: + replace(&node.X) + case *ast.IndexExpr: + replace(&node.Index) + case *ast.KeyValueExpr: + replace(&node.Value) + case *ast.CompositeLit: + for i := range node.Elts { + replace(&node.Elts[i]) + } + case *ast.SendStmt: + replace(&node.Value) + } + }) +} + +// rewriteFileExprs applies the context-independent expression rewrites +// (rewriteExpr) across the whole file. +// +//nolint:gocyclo // dispatch table over many AST shapes +func rewriteFileExprs(file *ast.File, state *rewriteState, report *Report) { + walkExpr := func(exprPtr *ast.Expr) { + rewriteExpr(exprPtr, state, report) + } + walk(file, func(n ast.Node) { + switch node := n.(type) { + case *ast.CallExpr: + walkExpr(&node.Fun) + for i := range node.Args { + walkExpr(&node.Args[i]) + } + case *ast.AssignStmt: + for i := range node.Rhs { + walkExpr(&node.Rhs[i]) + } + for i := range node.Lhs { + walkExpr(&node.Lhs[i]) + } + case *ast.ReturnStmt: + for i := range node.Results { + walkExpr(&node.Results[i]) + } + case *ast.ValueSpec: + for i := range node.Values { + walkExpr(&node.Values[i]) + } + if node.Type != nil { + walkExpr(&node.Type) + } + case *ast.Field: + walkExpr(&node.Type) + case *ast.KeyValueExpr: + walkExpr(&node.Key) + walkExpr(&node.Value) + case *ast.CompositeLit: + if node.Type != nil { + walkExpr(&node.Type) + } + for i := range node.Elts { + walkExpr(&node.Elts[i]) + } + case *ast.IndexExpr: + walkExpr(&node.X) + walkExpr(&node.Index) + case *ast.IndexListExpr: + walkExpr(&node.X) + for i := range node.Indices { + walkExpr(&node.Indices[i]) + } + case *ast.StarExpr: + walkExpr(&node.X) + case *ast.UnaryExpr: + walkExpr(&node.X) + case *ast.BinaryExpr: + walkExpr(&node.X) + walkExpr(&node.Y) + case *ast.ParenExpr: + walkExpr(&node.X) + case *ast.SwitchStmt: + if node.Tag != nil { + walkExpr(&node.Tag) + } + case *ast.CaseClause: + for i := range node.List { + walkExpr(&node.List[i]) + } + case *ast.IfStmt: + if node.Cond != nil { + walkExpr(&node.Cond) + } + case *ast.ForStmt: + if node.Cond != nil { + walkExpr(&node.Cond) + } + case *ast.ExprStmt: + walkExpr(&node.X) + case *ast.SendStmt: + walkExpr(&node.Chan) + walkExpr(&node.Value) + } + }) +} + +// rewriteProtocolOption flips connect.WithGRPC()/WithGRPCWeb() to the +// connecthttp package (the name is unchanged in v2), reporting whether it matched. +func rewriteProtocolOption(expr ast.Expr, state *rewriteState, report *Report) bool { + call, isCall := expr.(*ast.CallExpr) + if !isCall || len(call.Args) != 0 { + return false + } + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel { + return false + } + pkg, isIdent := sel.X.(*ast.Ident) + if !isIdent || pkg.Name != state.connectAlias { + return false + } + if _, ok := connectProtocolOptions[sel.Sel.Name]; !ok { + return false + } + pkg.Name = state.connectHTTPAlias + state.usedConnectHTTP = true + report.bump("option_protocol") + return true +} + +func rewriteExpr(exprPtr *ast.Expr, state *rewriteState, report *Report) { + expr := *exprPtr + + // connect.NewResponse(x) / connect.NewRequest(x) -> x (v2 passes the bare + // message). Stub-dependent, so gated on stubsReady. + if call, ok := expr.(*ast.CallExpr); ok && state.stubsReady && len(call.Args) == 1 { + if isConnectSelector(call.Fun, state.connectAlias, "NewResponse") { + *exprPtr = call.Args[0] + report.bump("strip_new_response") + return + } + if isConnectSelector(call.Fun, state.connectAlias, "NewRequest") { + *exprPtr = call.Args[0] + report.bump("strip_new_request") + return + } + } + + // Struct-literal wrapper form: &connect.Response[T]{Msg: x} -> x. Stub-dependent. + if state.stubsReady { + if msg, rule, ok := unwrapConnectMessageLiteral(expr, state.connectAlias); ok { + *exprPtr = msg + report.bump(rule) + return + } + } + + if rewriteProtocolOption(expr, state, report) { + return + } + + // connect.NewError(c, err); see convertNewErrorInPlace for the cases. + if call, ok := expr.(*ast.CallExpr); ok && len(call.Args) == 2 && isConnectSelector(call.Fun, state.connectAlias, "NewError") { + if convertNewErrorInPlace(call, state) { + state.usedV2 = true + report.bump("convert_new_error") + } + return + } + + // Qualifier-only flips (Errorf, CodeOf, Code, Error type). + if call, ok := expr.(*ast.CallExpr); ok && isConnectSelector(call.Fun, state.connectAlias, identErrorf) { + rewriteSelectorPkg(call.Fun, state) + state.usedV2 = true + report.bump("convert_errorf") + return + } + if call, ok := expr.(*ast.CallExpr); ok && isConnectSelector(call.Fun, state.connectAlias, "CodeOf") { + rewriteSelectorPkg(call.Fun, state) + state.usedV2 = true + report.bump("convert_code_of") + return + } + if sel, ok := expr.(*ast.SelectorExpr); ok { + if isConnectIdent(sel.X, state.connectAlias) && strings.HasPrefix(sel.Sel.Name, "Code") { + rewriteSelectorPkg(expr, state) + state.usedV2 = true + report.bump("convert_code_const") + return + } + if isConnectIdent(sel.X, state.connectAlias) && sel.Sel.Name == identError { + rewriteSelectorPkg(expr, state) + state.usedV2 = true + report.bump("convert_error_type") + return + } + } +} + +// convertNewErrorInPlace mutates a v1 connect.NewError(code, err) call to v2: +// +// - errors.New("s") -> connect.NewError(code, "s") +// - fmt.Errorf(f, args...) -> connect.Errorf(code, f, args...) +// - fmt.Errorf(... %w ...) -> connect.NewError(code, fmt.Errorf(...).Error()) +// - nil -> connect.NewError(code, "") +// - other err expr -> connect.NewError(code, err.Error()) +// +// %w and the fallback keep err.Error() on the wire (matching v1; WithCause would +// hide it). An already-string message arg is left alone (false), so the rewrite +// is idempotent. +func convertNewErrorInPlace(call *ast.CallExpr, state *rewriteState) bool { + errArg := call.Args[1] + if isMessageStringExpr(errArg) { + return false + } + // The remaining branches migrate a v1 argument, so flip the qualifier to v2. + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + if id, ok := sel.X.(*ast.Ident); ok { + id.Name = state.connectV2Alias + } + } + if id, ok := errArg.(*ast.Ident); ok && id.Name == identNil { + // v1 allowed a nil error for a code-only error; v2 needs a string. + pos := errArg.Pos() + call.Args[1] = &ast.BasicLit{ValuePos: pos, Kind: token.STRING, Value: `""`} + return true + } + if inner, ok := errArg.(*ast.CallExpr); ok && rewriteNewErrorCallArg(call, inner) { + return true + } + // Fallback: connect.NewError(code, err.Error()), anchored to errArg's position. + pos := errArg.Pos() + call.Args[1] = &ast.CallExpr{ + Fun: &ast.SelectorExpr{ + X: errArg, + Sel: &ast.Ident{NamePos: pos, Name: identError}, + }, + Lparen: pos, + Rparen: pos, + } + return true +} + +// isMessageStringExpr reports whether expr is already a v2 message string: a +// string literal or a zero-arg x.Error() call. +func isMessageStringExpr(expr ast.Expr) bool { + if lit, ok := expr.(*ast.BasicLit); ok && lit.Kind == token.STRING { + return true + } + call, ok := expr.(*ast.CallExpr) + if !ok || len(call.Args) != 0 { + return false + } + sel, ok := call.Fun.(*ast.SelectorExpr) + return ok && sel.Sel.Name == identError +} + +// rewriteNewErrorCallArg handles a v1 connect.NewError whose error argument is +// itself errors.New or fmt.Errorf, mutating call in place. False leaves it for +// the caller's .Error() fallback. +func rewriteNewErrorCallArg(call, inner *ast.CallExpr) bool { + if len(inner.Args) == 1 && isErrorsNew(inner.Fun) { + call.Args[1] = inner.Args[0] + return true + } + if !isFmtErrorf(inner.Fun) { + return false + } + if containsErrorWrap(inner.Args) { + // v2's Errorf is plain Sprintf (%w is literal); splitting into WithCause + // would drop the cause from the wire. Use the .Error() fallback instead. + return false + } + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + sel.Sel.Name = identErrorf + } + newArgs := make([]ast.Expr, 0, 1+len(inner.Args)) + newArgs = append(newArgs, call.Args[0]) + newArgs = append(newArgs, inner.Args...) + call.Args = newArgs + return true +} + +func isErrorsNew(fun ast.Expr) bool { + sel, ok := fun.(*ast.SelectorExpr) + if !ok { + return false + } + id, ok := sel.X.(*ast.Ident) + if !ok { + return false + } + return id.Name == "errors" && sel.Sel.Name == "New" +} + +func isFmtErrorf(fun ast.Expr) bool { + sel, ok := fun.(*ast.SelectorExpr) + if !ok { + return false + } + id, ok := sel.X.(*ast.Ident) + if !ok { + return false + } + return id.Name == "fmt" && sel.Sel.Name == identErrorf +} + +// containsErrorWrap reports whether an fmt.Errorf format string contains %w. A +// non-literal format conservatively returns true (forcing the .Error() path). +func containsErrorWrap(args []ast.Expr) bool { + if len(args) == 0 { + return false + } + lit, ok := args[0].(*ast.BasicLit) + if !ok { + return true + } + return strings.Contains(lit.Value, "%w") +} + +// unwrapConnectMessageLiteral returns the Msg value of a &connect.Request/Response[T]{Msg: x} +// literal so the wrapper can be replaced by the bare message. +func unwrapConnectMessageLiteral(expr ast.Expr, connectAlias string) (msg ast.Expr, rule string, ok bool) { + unary, isUnary := expr.(*ast.UnaryExpr) + if !isUnary || unary.Op != token.AND { + return nil, "", false + } + lit, isLit := unary.X.(*ast.CompositeLit) + if !isLit { + return nil, "", false + } + index, isIndex := lit.Type.(*ast.IndexExpr) + if !isIndex { + return nil, "", false + } + switch { + case isConnectSelector(index.X, connectAlias, "Response"): + rule = "strip_response_literal" + case isConnectSelector(index.X, connectAlias, "Request"): + rule = "strip_request_literal" + default: + return nil, "", false + } + for _, elt := range lit.Elts { + keyValue, isKeyValue := elt.(*ast.KeyValueExpr) + if !isKeyValue { + continue + } + if key, isIdent := keyValue.Key.(*ast.Ident); isIdent && key.Name == "Msg" { + return keyValue.Value, rule, true + } + } + // No Msg field (&connect.Response[T]{}): flip the wrapper type to give &T{}. + lit.Type = index.Index + return expr, rule, true +} + +func isConnectSelector(fun ast.Expr, connectAlias, name string) bool { + sel, ok := fun.(*ast.SelectorExpr) + if !ok { + return false + } + return isConnectIdent(sel.X, connectAlias) && sel.Sel.Name == name +} + +func isConnectIdent(expr ast.Expr, connectAlias string) bool { + id, ok := expr.(*ast.Ident) + if !ok { + return false + } + return id.Name == connectAlias +} + +// rewriteSelectorPkg flips a selector's v1 connect qualifier to v2, accepting a +// SelectorExpr or a CallExpr whose Fun is one. +func rewriteSelectorPkg(expr ast.Expr, state *rewriteState) { + var sel *ast.SelectorExpr + switch node := expr.(type) { + case *ast.SelectorExpr: + sel = node + case *ast.CallExpr: + funSel, isSel := node.Fun.(*ast.SelectorExpr) + if !isSel { + return + } + sel = funSel + default: + return + } + ident, isIdent := sel.X.(*ast.Ident) + if !isIdent { + return + } + if ident.Name != state.connectAlias { + return + } + ident.Name = state.connectV2Alias +} + +// ensureConnectV2Import adds the connectrpc.com/connect/v2 import if absent. +func ensureConnectV2Import(fset *token.FileSet, file *ast.File, state *rewriteState) { + if state.hadV2Import { + return + } + if state.connectV2Alias == "connect" { + astutil.AddImport(fset, file, "connectrpc.com/connect/v2") + } else { + astutil.AddNamedImport(fset, file, state.connectV2Alias, "connectrpc.com/connect/v2") + } + state.hadV2Import = true +} + +// ensureConnectHTTPImport adds the connecthttp import if absent. +func ensureConnectHTTPImport(fset *token.FileSet, file *ast.File, state *rewriteState) { + if state.hadConnectHTTP { + return + } + if state.connectHTTPAlias == "connecthttp" { + astutil.AddImport(fset, file, "connectrpc.com/connect/v2/connecthttp") + } else { + astutil.AddNamedImport(fset, file, state.connectHTTPAlias, "connectrpc.com/connect/v2/connecthttp") + } + state.hadConnectHTTP = true +} + +func removeConnectV1Import(fset *token.FileSet, file *ast.File, state *rewriteState) { + astutil.DeleteImport(fset, file, "connectrpc.com/connect") + state.hadV1Import = false +} + +// fileReferencesIdent reports whether alias is still used as a selector +// qualifier anywhere in the file. +func fileReferencesIdent(file *ast.File, alias string) bool { + found := false + walk(file, func(n ast.Node) { + if found { + return + } + sel, isSel := n.(*ast.SelectorExpr) + if !isSel { + return + } + ident, isIdent := sel.X.(*ast.Ident) + if !isIdent { + return + } + if ident.Name == alias { + found = true + } + }) + return found +} + +// residualConnectSymbols returns the sorted `connect.` selectors still under +// the v1 alias (the warned-only symbols that kept the v1 import alive). +func residualConnectSymbols(file *ast.File, alias string) []string { + seen := map[string]bool{} + walk(file, func(n ast.Node) { + sel, isSel := n.(*ast.SelectorExpr) + if !isSel { + return + } + if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == alias { + seen[alias+"."+sel.Sel.Name] = true + } + }) + symbols := make([]string, 0, len(seen)) + for symbol := range seen { + symbols = append(symbols, symbol) + } + sort.Strings(symbols) + return symbols +} + +// flipRemainingConnectSelectors flips the v1->v2 qualifier on the connect +// symbols that need only that, anywhere the position-specific passes missed: +// Code/Code/CodeOf, Error, Errorf, and Encode/DecodeBinaryHeader. Symbols +// that need restructuring (Request/Response/NewError/...) are handled earlier. +func flipRemainingConnectSelectors(file *ast.File, state *rewriteState, report *Report) { + walk(file, func(n ast.Node) { + sel, isSel := n.(*ast.SelectorExpr) + if !isSel { + return + } + ident, isIdent := sel.X.(*ast.Ident) + if !isIdent || ident.Name != state.connectAlias { + return + } + switch { + case strings.HasPrefix(sel.Sel.Name, "Code"): // Code, CodeOf, Code + case sel.Sel.Name == identError || sel.Sel.Name == identErrorf: + case sel.Sel.Name == "EncodeBinaryHeader" || sel.Sel.Name == "DecodeBinaryHeader": + default: + return + } + ident.Name = state.connectV2Alias + state.usedV2 = true + report.bump("flip_residual_connect") + }) +} + +// rewriteMovedSymbols handles the package split: relocated options flip to +// connecthttp, reshaped ones become warnings, core renames keep the connect +// qualifier. +func rewriteMovedSymbols(file *ast.File, state *rewriteState, report *Report) { + warnedReshaped := map[string]bool{} + walk(file, func(n ast.Node) { + sel, isSel := n.(*ast.SelectorExpr) + if !isSel { + return + } + ident, isIdent := sel.X.(*ast.Ident) + if !isIdent || ident.Name != state.connectAlias { + return + } + name := sel.Sel.Name + switch { + case renamedConnectCore[name] != "": + sel.Sel.Name = renamedConnectCore[name] + state.usedV2 = true + report.bump("rename_connect_core") + case movedToConnectHTTP[name]: + ident.Name = state.connectHTTPAlias + state.usedConnectHTTP = true + report.bump("option_to_connecthttp") + case reshapedToConnectHTTP[name] != "": + if !warnedReshaped[name] { + report.warnAtf(sel.Pos(), ruleConnectHTTPOption, "connect.%s -> %s", name, reshapedToConnectHTTP[name]) + warnedReshaped[name] = true + } + case reshapedErrorAPI[name] != "": + if !warnedReshaped[name] { + report.warnAtf(sel.Pos(), ruleErrorAPI, "connect.%s -> %s", name, reshapedErrorAPI[name]) + warnedReshaped[name] = true + } + case reshapedConstruction[name] != "": + if !warnedReshaped[name] { + report.warnAtf(sel.Pos(), ruleServerInterceptor, "connect.%s -> %s", name, reshapedConstruction[name]) + warnedReshaped[name] = true + } + } + }) +} + +// renameHTTPOnlyOptionTypes renames a helper's []connect.XOption result type +// (and the literals it returns) to []connecthttp.Option, but only when every +// element is a recognized HTTP option. A set carrying connect.WithInterceptors +// or anything unrecognized is left for the reshaped-construction warning. It +// runs before the element-flipping passes, so it matches the v1 element names. +func renameHTTPOnlyOptionTypes(file *ast.File, state *rewriteState, report *Report) { + walk(file, func(n ast.Node) { + funcType, body := funcTypeAndBody(n) + if funcType == nil || body == nil { + return + } + resultSel := soleOptionSliceResult(funcType, state.connectAlias) + if resultSel == nil { + return + } + lits, ok := returnedOptionLiterals(body, state.connectAlias) + if !ok || len(lits) == 0 { + return + } + for _, lit := range lits { + if !optionLiteralHTTPOnly(lit, state.connectAlias) { + return + } + } + renameOptionTypeSelector(resultSel, state) + for _, lit := range lits { + if litSel := optionSliceEltSelector(lit.Type, state.connectAlias); litSel != nil { + renameOptionTypeSelector(litSel, state) + } + } + state.usedConnectHTTP = true + report.bump("option_type_to_connecthttp") + }) +} + +// funcTypeAndBody returns the signature and body of a FuncDecl or FuncLit. +func funcTypeAndBody(n ast.Node) (*ast.FuncType, *ast.BlockStmt) { + switch node := n.(type) { + case *ast.FuncDecl: + return node.Type, node.Body + case *ast.FuncLit: + return node.Type, node.Body + } + return nil, nil +} + +// soleOptionSliceResult returns the connect.XOption selector of a function +// whose only result is a []connect.XOption slice, or nil. +func soleOptionSliceResult(funcType *ast.FuncType, connectAlias string) *ast.SelectorExpr { + if funcType.Results == nil || len(funcType.Results.List) != 1 { + return nil + } + field := funcType.Results.List[0] + if len(field.Names) > 1 { + return nil + } + return optionSliceEltSelector(field.Type, connectAlias) +} + +// optionSliceEltSelector returns the connect.XOption selector of a +// []connect.XOption type expression, or nil. +func optionSliceEltSelector(typ ast.Expr, connectAlias string) *ast.SelectorExpr { + arr, isArray := typ.(*ast.ArrayType) + if !isArray || arr.Len != nil { + return nil + } + sel, isSel := arr.Elt.(*ast.SelectorExpr) + if !isSel { + return nil + } + id, isIdent := sel.X.(*ast.Ident) + if !isIdent || id.Name != connectAlias || !optionTypeNames[sel.Sel.Name] { + return nil + } + return sel +} + +// returnedOptionLiterals collects the []connect.XOption literals a helper +// returns. ok is false if any return yields something else (variable, call, +// append), whose elements can't be proven HTTP-only. +func returnedOptionLiterals(body *ast.BlockStmt, connectAlias string) (lits []*ast.CompositeLit, ok bool) { + ok = true + walkFuncBody(body, func(n ast.Node) { + ret, isRet := n.(*ast.ReturnStmt) + if !isRet { + return + } + if len(ret.Results) != 1 { + ok = false + return + } + switch result := ret.Results[0].(type) { + case *ast.CompositeLit: + if optionSliceEltSelector(result.Type, connectAlias) == nil { + ok = false + return + } + lits = append(lits, result) + case *ast.Ident: + if result.Name != identNil { + ok = false + } + default: + ok = false + } + }) + return lits, ok +} + +// optionLiteralHTTPOnly reports whether every element of the literal is a +// recognized HTTP option (an empty literal qualifies). +func optionLiteralHTTPOnly(lit *ast.CompositeLit, connectAlias string) bool { + for _, elt := range lit.Elts { + if !isHTTPOnlyOptionElement(elt, connectAlias) { + return false + } + } + return true +} + +// isHTTPOnlyOptionElement reports whether elt is a call to a v1 connect option +// that moves, renames, or is a protocol selector. WithInterceptors, custom +// options, and bare variables are not recognized. +func isHTTPOnlyOptionElement(elt ast.Expr, connectAlias string) bool { + call, isCall := elt.(*ast.CallExpr) + if !isCall { + return false + } + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel { + return false + } + id, isIdent := sel.X.(*ast.Ident) + if !isIdent || id.Name != connectAlias { + return false + } + name := sel.Sel.Name + return movedToConnectHTTP[name] || connectProtocolOptions[name] != "" +} + +// renameOptionTypeSelector flips a connect.XOption type selector to +// connecthttp.Option in place. +func renameOptionTypeSelector(sel *ast.SelectorExpr, state *rewriteState) { + if id, isIdent := sel.X.(*ast.Ident); isIdent { + id.Name = state.connectHTTPAlias + } + sel.Sel.Name = "Option" +} + +// scanClientResponseHolders finds locals holding a v1 client call result +// (resp, err := client.Foo(ctx, connect.NewRequest(req))). In v2 the call +// returns the bare response, so resp loses its .Msg. +func scanClientResponseHolders(body *ast.BlockStmt, connectAlias string, requestHolders map[string]bool) map[string]bool { + holders := map[string]bool{} + walkFuncBody(body, func(n ast.Node) { + assign, isAssign := n.(*ast.AssignStmt) + if !isAssign || len(assign.Lhs) == 0 || len(assign.Rhs) == 0 { + return + } + call, isCall := assign.Rhs[0].(*ast.CallExpr) + if !isCall { + return + } + if !callContainsConnectNewRequest(call, connectAlias, requestHolders) { + return + } + first, isIdent := assign.Lhs[0].(*ast.Ident) + if !isIdent || first.Name == "_" { + return + } + holders[first.Name] = true + }) + return holders +} + +// scanClientRequestHolders finds locals initialized from connect.NewRequest, so +// later req.Header() calls on them can be rewritten to the v2 CallInfo. +// scanServerResponseHolders returns the names of variables bound to a +// connect.NewResponse(...) result. NewResponse is server-only, so these hold +// server response metadata; their .Header()/.Trailer() move to the server +// CallInfo. +func scanServerResponseHolders(body *ast.BlockStmt, connectAlias string) map[string]bool { + holders := map[string]bool{} + bind := func(names []ast.Expr, values []ast.Expr) { + for i, value := range values { + call, isCall := value.(*ast.CallExpr) + if !isCall || !isConnectSelector(call.Fun, connectAlias, "NewResponse") || i >= len(names) { + continue + } + if id, ok := names[i].(*ast.Ident); ok && id.Name != "_" { + holders[id.Name] = true + } + } + } + walkFuncBody(body, func(n ast.Node) { + switch node := n.(type) { + case *ast.AssignStmt: + bind(node.Lhs, node.Rhs) + case *ast.ValueSpec: + idents := make([]ast.Expr, len(node.Names)) + for i, name := range node.Names { + idents[i] = name + } + bind(idents, node.Values) + } + }) + return holders +} + +func scanClientRequestHolders(body *ast.BlockStmt, connectAlias string) map[string]bool { + holders := map[string]bool{} + walkFuncBody(body, func(n ast.Node) { + switch node := n.(type) { + case *ast.AssignStmt: + for i, rhs := range node.Rhs { + if !isConnectNewRequestCall(rhs, connectAlias) || i >= len(node.Lhs) { + continue + } + if id, ok := node.Lhs[i].(*ast.Ident); ok && id.Name != "_" { + holders[id.Name] = true + } + } + case *ast.ValueSpec: + for i, rhs := range node.Values { + if !isConnectNewRequestCall(rhs, connectAlias) || i >= len(node.Names) { + continue + } + if name := node.Names[i].Name; name != "_" { + holders[name] = true + } + } + } + }) + return holders +} + +func isConnectNewRequestCall(expr ast.Expr, connectAlias string) bool { + call, ok := expr.(*ast.CallExpr) + return ok && isConnectSelector(call.Fun, connectAlias, "NewRequest") +} + +func callContainsConnectNewRequest(call *ast.CallExpr, connectAlias string, requestHolders map[string]bool) bool { + _, isMethodCall := call.Fun.(*ast.SelectorExpr) + for i, arg := range call.Args { + if inner, ok := arg.(*ast.CallExpr); ok && isConnectSelector(inner.Fun, connectAlias, "NewRequest") { + return true + } + // A request holder in a non-first method-call argument (client.Method(ctx, + // req)) signals a client RPC; the position/method check avoids matching + // local helpers like buildWrapper(req). + if id, ok := arg.(*ast.Ident); ok && isMethodCall && i > 0 && requestHolders[id.Name] { + return true + } + } + return false +} + +// rootIdent returns the leftmost Ident in a selector chain, or nil. +func rootIdent(expr ast.Expr) *ast.Ident { + for { + switch x := expr.(type) { + case *ast.Ident: + return x + case *ast.SelectorExpr: + expr = x.X + default: + return nil + } + } +} + +// walk is ast.Inspect for visitors that never prune. +func walk(node ast.Node, fn func(ast.Node)) { + ast.Inspect(node, func(n ast.Node) bool { + if n != nil { + fn(n) + } + return true + }) +} + +// mergeUnwrappedScopes unions local with outer, dropping names shadowed by +// funcType's parameters. The result is safe to mutate; outer is not. +func mergeUnwrappedScopes(outer map[string]bool, funcType *ast.FuncType, local map[string]bool) map[string]bool { + merged := map[string]bool{} + for name := range local { + merged[name] = true + } + if len(outer) == 0 { + return merged + } + shadow := map[string]bool{} + if funcType != nil && funcType.Params != nil { + for _, field := range funcType.Params.List { + for _, name := range field.Names { + shadow[name.Name] = true + } + } + } + for name := range outer { + if shadow[name] { + continue + } + merged[name] = true + } + return merged +} + +// walkFuncBody is like [walk] but does not descend into nested [*ast.FuncLit] +// bodies; the visitor still sees each FuncLit itself. +func walkFuncBody(node ast.Node, visit func(ast.Node)) { + ast.Inspect(node, func(cur ast.Node) bool { + if cur == nil { + return true + } + if _, isLit := cur.(*ast.FuncLit); isLit && cur != node { + visit(cur) + return false + } + visit(cur) + return true + }) +} diff --git a/cmd/connect-go-v2-migrate/script_test.go b/cmd/connect-go-v2-migrate/script_test.go new file mode 100644 index 00000000..f8b0730b --- /dev/null +++ b/cmd/connect-go-v2-migrate/script_test.go @@ -0,0 +1,415 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + + "golang.org/x/tools/txtar" +) + +// TestScripts runs the txtar archives in testdata/script, one per test, +// following the script-test pattern the Go project uses for cmd/go. An +// archive's comment is the script and its file sections are materialized +// into a fresh work dir: a module seeded with go.mod and go.sum from +// testdata/build unless the archive ships its own. Golden output lives in +// out.txt sections; UPDATE=1 rewrites mismatched goldens in place. +// +// A script is a sequence of commands, one per line: +// +// exec migrate|go args... run a command in the work dir +// cmp actual golden byte-compare files (actual may be stdout) +// stdout 'regex' match the last exec's stdout +// grep 'regex' file match a work-dir file +// stubs v2|v1generic|v1simple copy shared generated stubs into gen/ +// +// A leading ! inverts exec, stdout, and grep. # starts a comment. +func TestScripts(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("materializes modules and shells out to go build; skipped under -short") + } + repoRoot, err := filepath.Abs("../..") + if err != nil { + t.Fatalf("repo root: %v", err) + } + scaffold, err := filepath.Abs(filepath.Join("testdata", "build")) + if err != nil { + t.Fatalf("scaffold dir: %v", err) + } + testBinary, err := os.Executable() + if err != nil { + t.Fatalf("test binary path: %v", err) + } + env := scriptEnv(t, scaffold) + paths, err := filepath.Glob(filepath.Join("testdata", "script", "*.txtar")) + if err != nil { + t.Fatalf("glob scripts: %v", err) + } + if len(paths) == 0 { + t.Fatal("no testdata/script/*.txtar files found") + } + for _, path := range paths { + t.Run(strings.TrimSuffix(filepath.Base(path), ".txtar"), func(t *testing.T) { + t.Parallel() + state := &scriptState{ + t: t, + path: path, + env: env, + testBinary: testBinary, + scaffold: scaffold, + repoRoot: repoRoot, + update: os.Getenv("UPDATE") != "", + } + state.run() + }) + } +} + +// scriptState is one script's execution state: the materialized work dir, the +// stdout of the most recent exec, and the parsed archive for golden updates. +type scriptState struct { + t *testing.T + path string + env []string + testBinary string + scaffold string + repoRoot string + update bool + + archive *txtar.Archive + workDir string + stdout []byte + ranExec bool + updated bool +} + +func (s *scriptState) run() { + data, err := os.ReadFile(s.path) + if err != nil { + s.t.Fatal(err) + } + s.archive = txtar.Parse(data) + s.workDir = s.t.TempDir() + s.materialize() + for lineNum, line := range strings.Split(string(s.archive.Comment), "\n") { + tokens, err := tokenize(line) + if err != nil { + s.t.Fatalf("%s:%d: %v", s.path, lineNum+1, err) + } + if len(tokens) == 0 { + continue + } + neg := false + if tokens[0] == "!" { + neg, tokens = true, tokens[1:] + if len(tokens) == 0 { + s.t.Fatalf("%s:%d: ! requires a command", s.path, lineNum+1) + } + } + fail := func(format string, args ...any) { + s.t.Helper() + s.t.Fatalf("%s:%d: %s: %s", s.path, lineNum+1, line, fmt.Sprintf(format, args...)) + } + switch cmd, args := tokens[0], tokens[1:]; cmd { + case "exec": + s.cmdExec(fail, neg, args) + case "cmp": + s.cmdCmp(fail, neg, args) + case "stdout": + s.cmdStdout(fail, neg, args) + case "grep": + s.cmdGrep(fail, neg, args) + case "stubs": + s.cmdStubs(fail, neg, args) + default: + fail("unknown command %q", cmd) + } + } + if s.updated { + if err := os.WriteFile(s.path, txtar.Format(s.archive), 0o644); err != nil { + s.t.Fatalf("update %s: %v", s.path, err) + } + } +} + +// materialize extracts the archive's file sections into the work dir and +// seeds the shared module scaffold unless the case ships its own go.mod +// (nested or self-contained module fixtures manage their own deps). The +// scaffold go.mod's replace target is resolved to this checkout so +// connectrpc.com/connect/v2 builds locally. +func (s *scriptState) materialize() { + hasGoMod := false + for _, file := range s.archive.Files { + if filepath.Base(file.Name) == "go.mod" { + hasGoMod = true + } + dst := filepath.Join(s.workDir, filepath.FromSlash(file.Name)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + s.t.Fatal(err) + } + if err := os.WriteFile(dst, file.Data, 0o644); err != nil { + s.t.Fatal(err) + } + } + if hasGoMod { + return + } + gomod, err := os.ReadFile(filepath.Join(s.scaffold, "go.mod.txt")) + if err != nil { + s.t.Fatal(err) + } + gomod = []byte(strings.ReplaceAll(string(gomod), "REPLACE_DIR", s.repoRoot)) + if err := os.WriteFile(filepath.Join(s.workDir, "go.mod"), gomod, 0o644); err != nil { + s.t.Fatal(err) + } + if err := copyFile(filepath.Join(s.scaffold, "go.sum"), filepath.Join(s.workDir, "go.sum")); err != nil { + s.t.Fatal(err) + } +} + +// cmdExec runs `migrate` (this test binary re-exec'd through TestMain) or any +// other program in the work dir, capturing stdout for later assertions. +func (s *scriptState) cmdExec(fail func(string, ...any), neg bool, args []string) { + if len(args) == 0 { + fail("usage: exec program [args...]") + } + program, env := args[0], s.env + if program == "migrate" { + program = s.testBinary + env = append(append([]string{}, env...), migrateExecEnv+"=1") + } + cmd := exec.CommandContext(s.t.Context(), program, args[1:]...) + cmd.Dir = s.workDir + cmd.Env = env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + s.stdout = stdout.Bytes() + s.ranExec = true + if (err != nil) != neg { + want := "success" + if neg { + want = "failure" + } + fail("want %s, got %v\nstdout:\n%s\nstderr:\n%s", want, err, stdout.String(), stderr.String()) + } +} + +// cmdCmp byte-compares an actual file (or the literal `stdout`) against a +// golden file from the archive. Under UPDATE=1 a mismatched golden's archive +// section is rewritten instead of failing. +func (s *scriptState) cmdCmp(fail func(string, ...any), neg bool, args []string) { + if neg || len(args) != 2 { + fail("usage: cmp actual golden") + } + actual := s.stdout + if args[0] != "stdout" { + var err error + if actual, err = os.ReadFile(filepath.Join(s.workDir, filepath.FromSlash(args[0]))); err != nil { + fail("%v", err) + } + } else if !s.ranExec { + fail("stdout requires a prior exec") + } + golden, err := os.ReadFile(filepath.Join(s.workDir, filepath.FromSlash(args[1]))) + if err != nil { + fail("%v", err) + } + if bytes.Equal(actual, golden) { + return + } + if s.update { + for i := range s.archive.Files { + if s.archive.Files[i].Name == args[1] { + // txtar sections are always newline-terminated, so an actual + // without a trailing newline can never round-trip; surface it. + if len(actual) > 0 && actual[len(actual)-1] != '\n' { + fail("cannot update golden: output does not end in a newline") + } + s.archive.Files[i].Data = actual + s.updated = true + return + } + } + fail("cannot update golden: %s is not in the archive", args[1]) + } + fail("files differ:\n%s", unifiedDiff(args[1], golden, actual, false)) +} + +func (s *scriptState) cmdStdout(fail func(string, ...any), neg bool, args []string) { + if len(args) != 1 { + fail("usage: [!] stdout 'regex'") + } + if !s.ranExec { + fail("stdout requires a prior exec") + } + if s.match(fail, args[0], s.stdout) == neg { + fail("stdout match = %v, want %v\nstdout:\n%s", !neg, neg, s.stdout) + } +} + +func (s *scriptState) cmdGrep(fail func(string, ...any), neg bool, args []string) { + if len(args) != 2 { + fail("usage: [!] grep 'regex' file") + } + content, err := os.ReadFile(filepath.Join(s.workDir, filepath.FromSlash(args[1]))) + if err != nil { + fail("%v", err) + } + if s.match(fail, args[0], content) == neg { + fail("%s match = %v, want %v\ncontent:\n%s", args[1], !neg, neg, content) + } +} + +// cmdStubs copies the shared generated stubs for the requested connect version +// into the module's gen/ tree, choosing whether the tool sees v1 or v2 stubs: +// +// stubs v2 connect v2 stubs (the tool rewrites against them) +// stubs v1generic connect v1 stubs, generic form (regenerate-first advice) +// stubs v1simple connect v1 stubs, simple form +func (s *scriptState) cmdStubs(fail func(string, ...any), neg bool, args []string) { + if neg || len(args) != 1 { + fail("usage: stubs v2|v1generic|v1simple") + } + var tree string + switch args[0] { + case "v2": + tree = "genv2" + case "v1generic": + tree = "genv1generic" + case "v1simple": + tree = "genv1simple" + default: + fail("unknown stubs %q: want v2, v1generic, or v1simple", args[0]) + } + const pbRel = "connect/ping/v1/ping.pb.go" + const connectRel = "connect/ping/v1/pingv1connect/ping.connect.go" + if err := copyFile( + filepath.Join(s.scaffold, "gen", pbRel), + filepath.Join(s.workDir, "gen", pbRel), + ); err != nil { + fail("%v", err) + } + if err := copyFile( + filepath.Join(s.scaffold, tree, connectRel), + filepath.Join(s.workDir, "gen", connectRel), + ); err != nil { + fail("%v", err) + } +} + +// match reports whether the pattern matches content. Patterns compile in +// multiline mode, so ^ and $ anchor per line. +func (s *scriptState) match(fail func(string, ...any), pattern string, content []byte) bool { + re, err := regexp.Compile("(?m)" + pattern) + if err != nil { + fail("bad regexp %q: %v", pattern, err) + } + return re.Match(content) +} + +// scriptEnv builds the environment for script commands. Scripts run with a +// bare environment plus the Go toolchain's cache and module settings, so go +// build and go/packages resolve offline from the host cache. +func scriptEnv(t *testing.T, scaffold string) []string { + t.Helper() + env := []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=/no-home", + "SCAFFOLD=" + scaffold, + // Keep diff output deterministic regardless of how the harness wires + // stdout: the tool colorizes only without NO_COLOR and on a TTY. + "NO_COLOR=1", + } + for _, name := range []string{ + "GOMODCACHE", "GOCACHE", "GOPATH", "GOPROXY", + "GOSUMDB", "GOFLAGS", "GOTOOLCHAIN", "GO111MODULE", + } { + if val, ok := os.LookupEnv(name); ok { + env = append(env, name+"="+val) + } else if val := goEnv(t.Context(), name); val != "" { + env = append(env, name+"="+val) + } + } + return env +} + +// tokenize splits a script line into fields. A field may be single-quoted, +// with ” inside quotes reading as a literal quote; # starts a comment. +// Environment expansion is not supported, so $ in a token is an error. +func tokenize(line string) ([]string, error) { + var tokens []string + rest := strings.TrimSpace(line) + for rest != "" { + var token string + switch rest[0] { + case '#': + return tokens, nil + case '\'': + body := rest[1:] + var builder strings.Builder + for { + closing := strings.IndexByte(body, '\'') + if closing < 0 { + return nil, errors.New("unterminated quote") + } + builder.WriteString(body[:closing]) + body = body[closing+1:] + if !strings.HasPrefix(body, "'") { + break + } + builder.WriteByte('\'') + body = body[1:] + } + token, rest = builder.String(), body + default: + end := strings.IndexAny(rest, " \t") + if end < 0 { + end = len(rest) + } + token, rest = rest[:end], rest[end:] + if strings.Contains(token, "$") { + return nil, fmt.Errorf("environment expansion is not supported: %q", token) + } + if strings.Contains(token, "'") { + return nil, fmt.Errorf("quotes must start a token: %q", token) + } + } + tokens = append(tokens, token) + rest = strings.TrimLeft(rest, " \t") + } + return tokens, nil +} + +// goEnv returns a single `go env` value, used as a fallback when a Go setting +// is not present in the process environment. +func goEnv(ctx context.Context, name string) string { + out, err := exec.CommandContext(ctx, "go", "env", name).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/cmd/connect-go-v2-migrate/streams.go b/cmd/connect-go-v2-migrate/streams.go new file mode 100644 index 00000000..2b2468bf --- /dev/null +++ b/cmd/connect-go-v2-migrate/streams.go @@ -0,0 +1,1194 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "go/ast" + "go/token" + "sort" + "strings" + + "golang.org/x/tools/go/ast/astutil" +) + +// handlerStreamType is a resolved v2 generated handler stream type. +type handlerStreamType struct { + pkgPath string + pkgName string + name string // ServerStream + messages []string // sorted base names of the request/response messages +} + +// handlerStreamResolver resolves a handler's RPC name to its v2 stream type, +// matching on both the name and the message types because the name alone is +// ambiguous ("Sum" is a suffix of CumSumServerStream). +type handlerStreamResolver struct { + types []handlerStreamType +} + +// lookup returns the v2 handler stream type for an RPC name and its message +// types. recvType disambiguates when several services share an RPC name and +// messages; unresolved matches are returned as ambiguous for the caller's warning. +func (r *handlerStreamResolver) lookup(method, recvType string, messages []string) (match handlerStreamType, ambiguous []handlerStreamType, ok bool) { + if r == nil || method == "" || len(messages) == 0 { + return handlerStreamType{}, nil, false + } + suffix := method + "ServerStream" + want := sortedStrings(messages) + var matches []handlerStreamType + for _, candidate := range r.types { + if strings.HasSuffix(candidate.name, suffix) && equalStrings(candidate.messages, want) { + matches = append(matches, candidate) + } + } + switch len(matches) { + case 0: + return handlerStreamType{}, nil, false + case 1: + return matches[0], nil, true + } + if picked, ok := disambiguateByReceiver(matches, suffix, recvType); ok { + return picked, nil, true + } + return handlerStreamType{}, matches, false +} + +// disambiguateByReceiver picks the candidate whose service name matches recvType +// (with or without the "Service" suffix), returning false unless exactly one does. +func disambiguateByReceiver(matches []handlerStreamType, suffix, recvType string) (handlerStreamType, bool) { + if recvType == "" { + return handlerStreamType{}, false + } + var match handlerStreamType + count := 0 + for _, candidate := range matches { + service := strings.TrimSuffix(candidate.name, suffix) + if strings.EqualFold(service, recvType) || + strings.EqualFold(strings.TrimSuffix(service, "Service"), recvType) { + match = candidate + count++ + } + } + if count != 1 { + return handlerStreamType{}, false + } + return match, true +} + +// candidateNames returns the package-qualified type names, sorted. +func candidateNames(types []handlerStreamType) []string { + names := make([]string, 0, len(types)) + for _, candidate := range types { + qualified := candidate.name + if candidate.pkgName != "" { + qualified = candidate.pkgName + "." + candidate.name + } + names = append(names, qualified) + } + sort.Strings(names) + return names +} + +func sortedStrings(in []string) []string { + out := append([]string(nil), in...) + sort.Strings(out) + return out +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +const ( + identReceive = "Receive" + identSend = "Send" + identErr = "err" + identStreamErr = "Err" +) + +// streamMethodNames are the v1 stream methods that mark a variable as a stream. +var streamMethodNames = map[string]bool{ + identSend: true, + identReceive: true, + "CloseSend": true, + "CloseRequest": true, + "CloseResponse": true, + "CloseAndReceive": true, +} + +// rewriteStreams applies every streaming rewrite to one function body. +// methodName is the enclosing function's name (or "" for a closure). +func rewriteStreams(funcType *ast.FuncType, body *ast.BlockStmt, state *rewriteState, report *Report, methodName, recvName string) { + if body == nil { + return + } + streamVars, params := collectStreamVars(funcType, body, state.connectAlias) + if len(streamVars) == 0 { + return + } + for name, param := range params { + migrated, ambiguous := migrateHandlerStreamParam(param, methodName, recvName, state, report) + switch { + case migrated: + continue + case len(ambiguous) > 0: + report.warnAtf(param.pos, ruleStreamParamAmbiguous, "stream parameter %q (connect.%s) matches multiple v2 handler stream types because several services share this RPC name and messages: %s. Pick the one for this service by hand.", name, param.typeName, strings.Join(candidateNames(ambiguous), ", ")) + default: + report.warnAtf(param.pos, ruleStreamParamType, "stream parameter %q has v1 type connect.%s. Its v2 type is the generated handler stream type for this RPC.", name, param.typeName) + } + } + holders := map[string]bool{} + rewriteStreamBlocks(body, streamVars, funcType, state, report, holders) + dropMsgForHolders(body, holders, report) + threadStreamMethods(body, streamVars, report) + rewriteStreamMetadata(body, streamVars, params, state, report, contextParamName(funcType)) +} + +func isStreamMetadataMethod(name string) bool { + return name == "RequestHeader" || name == "ResponseHeader" || name == "ResponseTrailer" +} + +// rewriteStreamMetadata moves a stream's RequestHeader/ResponseHeader/ +// ResponseTrailer access to the v2 CallInfo: handler streams read inline from +// the server CallInfo; client streams seed a NewClientContext. +func rewriteStreamMetadata(body *ast.BlockStmt, streamVars map[string]bool, params map[string]streamParam, state *rewriteState, report *Report, ctxName string) { + infoName := "" + walkFuncBody(body, func(n ast.Node) { + call, ok := n.(*ast.CallExpr) + if !ok { + return + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || !isStreamMetadataMethod(sel.Sel.Name) { + return + } + recv, isIdent := sel.X.(*ast.Ident) + if !isIdent { + return + } + if _, isHandler := params[recv.Name]; !isHandler { + return + } + if ctxName == "" { + report.warnAtf(call.Pos(), ruleRequestMetadata, "stream %s.%s() moves to the connect.CallInfoForServerContext(ctx) info's %s() in v2", recv.Name, sel.Sel.Name, sel.Sel.Name) + return + } + if infoName == "" { + infoName = ensureServerCallInfo(body, state, ctxName) + } + call.Fun = &ast.SelectorExpr{X: ast.NewIdent(infoName), Sel: ast.NewIdent(sel.Sel.Name)} + report.bump("stream_metadata_rewrite") + }) + rewriteClientStreamMetadata(body, streamVars, params, state, report, ctxName) +} + +// rewriteClientStreamMetadata seeds a connect.NewClientContext before a client +// stream is opened and rewrites its metadata reads to the seeded info. A single +// client stream with a usable, not-yet-seeded context qualifies; others warn. +func rewriteClientStreamMetadata(body *ast.BlockStmt, streamVars map[string]bool, params map[string]streamParam, state *rewriteState, report *Report, ctxName string) { + const warnMsg = "client stream %s metadata moves to connect.NewClientContext(ctx) and info.RequestHeader()/ResponseHeader()/ResponseTrailer() in v2" + withMeta := map[string]token.Pos{} + for name := range streamVars { + if _, isHandler := params[name]; isHandler { + continue + } + if pos := firstCallPos(body, name, "RequestHeader", "ResponseHeader", "ResponseTrailer"); pos.IsValid() { + withMeta[name] = pos + } + } + if len(withMeta) == 0 { + return + } + if ctxName == "" || len(withMeta) > 1 || bodyHasNewClientContext(body, state.connectV2Alias) { + for name, pos := range withMeta { + report.warnAtf(pos, ruleRequestMetadata, warnMsg, name) + } + return + } + var holder string + for name := range withMeta { + holder = name + } + insertAt := holderAssignIndex(body, holder) + if insertAt < 0 { + report.warnAtf(withMeta[holder], ruleRequestMetadata, warnMsg, holder) + return + } + infoName := uniqueIdent(body, "info", "callInfo") + seed := newClientContextSeed(state, ctxName, infoName) + body.List = append(body.List[:insertAt], append([]ast.Stmt{seed}, body.List[insertAt:]...)...) + report.bump("client_context_insert") + walkFuncBody(body, func(n ast.Node) { + call, ok := n.(*ast.CallExpr) + if !ok { + return + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || !isStreamMetadataMethod(sel.Sel.Name) { + return + } + if root := rootIdent(sel); root != nil && root.Name == holder { + call.Fun = &ast.SelectorExpr{X: ast.NewIdent(infoName), Sel: ast.NewIdent(sel.Sel.Name)} + report.bump("client_stream_metadata_rewrite") + } + }) +} + +// streamParam is a server handler stream parameter. +type streamParam struct { + typeName string + pos token.Pos + field *ast.Field + messages []string +} + +// migrateHandlerStreamParam rewrites a handler's v1 stream parameter to the +// generated v2 handler stream type and records the import. It returns false when +// the RPC doesn't resolve, with any ambiguous matches for the caller's warning. +func migrateHandlerStreamParam(param streamParam, methodName, recvName string, state *rewriteState, report *Report) (bool, []handlerStreamType) { + resolved, ambiguous, ok := state.handlerStreams.lookup(methodName, recvName, param.messages) + if !ok { + return false, ambiguous + } + param.field.Type = &ast.SelectorExpr{X: ast.NewIdent(resolved.pkgName), Sel: ast.NewIdent(resolved.name)} + state.addImport(resolved.pkgPath, resolved.pkgName) + report.bump("stream_handler_param") + return true, nil +} + +// collectStreamVars returns every stream variable name in the function, plus +// the subset that are server handler parameters. +func collectStreamVars(funcType *ast.FuncType, body *ast.BlockStmt, connectAlias string) (map[string]bool, map[string]streamParam) { + params := collectStreamParams(funcType, connectAlias) + vars := map[string]bool{} + for name := range params { + vars[name] = true + } + called := map[string]bool{} + walkFuncBody(body, func(n ast.Node) { + call, ok := n.(*ast.CallExpr) + if !ok { + return + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || !streamMethodNames[sel.Sel.Name] { + return + } + if id, ok := sel.X.(*ast.Ident); ok { + called[id.Name] = true + } + }) + for name := range called { + if !vars[name] && assignedFromMethodCall(body, name) { + vars[name] = true + } + } + return vars, params +} + +// collectStreamParams maps each v1 connect stream-generic parameter to its info. +func collectStreamParams(funcType *ast.FuncType, connectAlias string) map[string]streamParam { + out := map[string]streamParam{} + if funcType == nil || funcType.Params == nil { + return out + } + for _, field := range funcType.Params.List { + typeName, ok := streamGenericName(field.Type, connectAlias) + if !ok { + continue + } + messages := streamTypeArgNames(field.Type) + for _, name := range field.Names { + out[name.Name] = streamParam{typeName: typeName, pos: name.Pos(), field: field, messages: messages} + } + } + return out +} + +// streamTypeArgNames returns the base names of a *connect.XxxStream[...] type's +// message type arguments, e.g. ["CumSumRequest", "CumSumResponse"]. +func streamTypeArgNames(typ ast.Expr) []string { + star, isStar := typ.(*ast.StarExpr) + if !isStar { + return nil + } + var args []ast.Expr + switch indexed := star.X.(type) { + case *ast.IndexExpr: + args = []ast.Expr{indexed.Index} + case *ast.IndexListExpr: + args = indexed.Indices + default: + return nil + } + var names []string + for _, arg := range args { + switch typeArg := arg.(type) { + case *ast.SelectorExpr: + names = append(names, typeArg.Sel.Name) + case *ast.Ident: + names = append(names, typeArg.Name) + } + } + return names +} + +// streamGenericName reports the v1 stream type name for a +// *connect.XxxStream[...] parameter type. +func streamGenericName(typ ast.Expr, connectAlias string) (string, bool) { + star, isStar := typ.(*ast.StarExpr) + if !isStar { + return "", false + } + var ( + sel *ast.SelectorExpr + isSel bool + ) + switch indexed := star.X.(type) { + case *ast.IndexExpr: + sel, isSel = indexed.X.(*ast.SelectorExpr) + case *ast.IndexListExpr: + sel, isSel = indexed.X.(*ast.SelectorExpr) + default: + return "", false + } + if !isSel { + return "", false + } + if id, isIdent := sel.X.(*ast.Ident); !isIdent || id.Name != connectAlias { + return "", false + } + switch sel.Sel.Name { + case "ClientStream", "ServerStream", "BidiStream": + return sel.Sel.Name, true + } + return "", false +} + +// isSelectorCall reports whether expr is a method/qualified call x.M(...). +func isSelectorCall(expr ast.Expr) bool { + call, isCall := expr.(*ast.CallExpr) + if !isCall { + return false + } + _, isSel := call.Fun.(*ast.SelectorExpr) + return isSel +} + +// assignedFromMethodCall reports whether name is ever assigned a method call's +// result, via `:=`/`=` or a `var name = call` declaration, so the method-set +// heuristic ignores unrelated values with a Send method. +func assignedFromMethodCall(body *ast.BlockStmt, name string) bool { + found := false + walkFuncBody(body, func(n ast.Node) { + if found { + return + } + switch node := n.(type) { + case *ast.AssignStmt: + for i, lhs := range node.Lhs { + id, isIdent := lhs.(*ast.Ident) + if !isIdent || id.Name != name { + continue + } + rhs := node.Rhs[0] + if len(node.Rhs) == len(node.Lhs) { + rhs = node.Rhs[i] + } + if isSelectorCall(rhs) { + found = true + return + } + } + case *ast.ValueSpec: + for i, id := range node.Names { + if id.Name == name && i < len(node.Values) && isSelectorCall(node.Values[i]) { + found = true + return + } + } + } + }) + return found +} + +// rewriteStreamBlocks performs statement-list surgery on every block in the +// function (but not nested closures). +func rewriteStreamBlocks(body *ast.BlockStmt, streamVars map[string]bool, funcType *ast.FuncType, state *rewriteState, report *Report, holders map[string]bool) { + walkFuncBody(body, func(n ast.Node) { + block, ok := n.(*ast.BlockStmt) + if !ok { + return + } + block.List, _ = rewriteStmtList(block.List, streamVars, funcType, state, report, holders) + }) +} + +func rewriteStmtList(list []ast.Stmt, streamVars map[string]bool, funcType *ast.FuncType, state *rewriteState, report *Report, holders map[string]bool) ([]ast.Stmt, bool) { + out := make([]ast.Stmt, 0, len(list)) + changed := false + for i := 0; i < len(list); i++ { + stmt := list[i] + if newStmts, ok := tryConstructorTuple(stmt, streamVars, funcType, report); ok { + out = append(out, newStmts...) + changed = true + continue + } + if newStmts, holder, ok := tryCloseAndReceive(stmt, streamVars, funcType, report); ok { + out = append(out, newStmts...) + if holder != "" { + holders[holder] = true + } + changed = true + continue + } + forStmt, isFor := stmt.(*ast.ForStmt) + if !isFor { + out = append(out, stmt) + continue + } + streamName, isBoolLoop := boolLoopStreamName(forStmt, streamVars) + if !isBoolLoop { + out = append(out, stmt) + continue + } + if i+1 < len(list) { + if name, body, ok := matchStreamErrCheck(list[i+1], streamName); ok { + // Foldable `if err := stream.Err(); err != nil`: it becomes the + // loop's non-EOF error handler. + out = append(out, reshapeBoolLoop(forStmt, streamName, name, body, funcType, state, report)) + i++ + changed = true + continue + } + } + if hasTrailingStreamErr(list[i+1:], streamName) { + // Non-foldable post-loop stream.Err() (a success-path check or bare + // return): hoist the terminal error so that code keeps working. + out = append(out, reshapeBoolLoopTrailingErr(forStmt, streamName, list[i+1:], state, report)...) + changed = true + continue + } + out = append(out, reshapeBoolLoop(forStmt, streamName, identErr, nil, funcType, state, report)) + changed = true + } + return out, changed +} + +// normalizeReshapedClosures repairs the vertical gap a position-cleared closure +// body leaves above it: it clears brace/paren positions from each cleared +// closure up to its enclosing function so the printer emits no dangling `})` +// or stray blank line. +func normalizeReshapedClosures(file *ast.File) { + parent := parentMap(file) + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.FuncLit) + if !ok || lit.Body == nil || lit.Body.Rbrace != token.NoPos { + return true + } + for cur := ast.Node(lit); cur != nil; cur = parent[cur] { + switch node := cur.(type) { + case *ast.BlockStmt: + node.Lbrace, node.Rbrace = token.NoPos, token.NoPos + case *ast.CallExpr: + node.Lparen, node.Rparen, node.Ellipsis = token.NoPos, token.NoPos, token.NoPos + case *ast.FuncDecl: + return true // reached the enclosing function, stop climbing + } + } + return true + }) +} + +func parentMap(file *ast.File) map[ast.Node]ast.Node { + parent := map[ast.Node]ast.Node{} + var stack []ast.Node + ast.Inspect(file, func(node ast.Node) bool { + if node == nil { + stack = stack[:len(stack)-1] + return true + } + if len(stack) > 0 { + parent[node] = stack[len(stack)-1] + } + stack = append(stack, node) + return true + }) + return parent +} + +// anchorPositions pins a synthesized subtree to one existing position so the +// printer slots it next to its neighbors without a blank-line gap (unlike +// clearPositions, which can orphan nearby free-floating comments). +func anchorPositions(node ast.Node, pos token.Pos) { + setPositions(node, pos) +} + +func setPositions(node ast.Node, pos token.Pos) { + ast.Inspect(node, func(n ast.Node) bool { + setExprPos(n, pos) + setStmtPos(n, pos) + return true + }) +} + +func setExprPos(n ast.Node, pos token.Pos) { + switch node := n.(type) { + case *ast.Ident: + node.NamePos = pos + case *ast.BasicLit: + node.ValuePos = pos + case *ast.CallExpr: + node.Lparen, node.Rparen = pos, pos + // Only move Ellipsis on a real spread; a position on a non-spread call + // makes the printer emit a spurious `...`. + if node.Ellipsis.IsValid() { + node.Ellipsis = pos + } + case *ast.BinaryExpr: + node.OpPos = pos + case *ast.UnaryExpr: + node.OpPos = pos + case *ast.StarExpr: + node.Star = pos + case *ast.ParenExpr: + node.Lparen, node.Rparen = pos, pos + case *ast.IndexExpr: + node.Lbrack, node.Rbrack = pos, pos + case *ast.CompositeLit: + node.Lbrace, node.Rbrace = pos, pos + case *ast.KeyValueExpr: + node.Colon = pos + case *ast.FuncLit: + node.Type.Func = pos + } +} + +func setStmtPos(n ast.Node, pos token.Pos) { + switch node := n.(type) { + case *ast.AssignStmt: + node.TokPos = pos + case *ast.ReturnStmt: + node.Return = pos + case *ast.BranchStmt: + node.TokPos = pos + case *ast.IfStmt: + node.If = pos + case *ast.ForStmt: + node.For = pos + case *ast.RangeStmt: + node.For, node.TokPos = pos, pos + case *ast.BlockStmt: + node.Lbrace, node.Rbrace = pos, pos + case *ast.IncDecStmt: + node.TokPos = pos + case *ast.GenDecl: + node.TokPos, node.Lparen, node.Rparen = pos, pos, pos + case *ast.SendStmt: + node.Arrow = pos + case *ast.DeferStmt: + node.Defer = pos + case *ast.GoStmt: + node.Go = pos + case *ast.LabeledStmt: + node.Colon = pos + case *ast.SwitchStmt: + node.Switch = pos + case *ast.TypeSwitchStmt: + node.Switch = pos + case *ast.SelectStmt: + node.Select = pos + case *ast.CaseClause: + node.Case, node.Colon = pos, pos + case *ast.CommClause: + node.Case, node.Colon = pos, pos + } +} + +// tryConstructorTuple adds the v2 `, err` result plus an error check to a +// sole-LHS stream constructor binding, in either the `stream := client.M(ctx)` +// or the type-inferred `var stream = client.M(ctx)` form. A binding that already +// has an error result (two LHS) is left alone. +func tryConstructorTuple(stmt ast.Stmt, streamVars map[string]bool, funcType *ast.FuncType, report *Report) ([]ast.Stmt, bool) { + switch node := stmt.(type) { + case *ast.AssignStmt: + if node.Tok != token.DEFINE || len(node.Lhs) != 1 || len(node.Rhs) != 1 { + return nil, false + } + id, isIdent := node.Lhs[0].(*ast.Ident) + if !isIdent || !streamVars[id.Name] || !isSelectorCall(node.Rhs[0]) { + return nil, false + } + node.Lhs = append(node.Lhs, errResultIdent(node.Pos())) + case *ast.DeclStmt: + gen, isGen := node.Decl.(*ast.GenDecl) + if !isGen || gen.Tok != token.VAR || len(gen.Specs) != 1 { + return nil, false + } + // An explicit type would make `var stream, err T = call` ill-typed. + spec, isSpec := gen.Specs[0].(*ast.ValueSpec) + if !isSpec || spec.Type != nil || len(spec.Names) != 1 || len(spec.Values) != 1 { + return nil, false + } + if !streamVars[spec.Names[0].Name] || !isSelectorCall(spec.Values[0]) { + return nil, false + } + spec.Names = append(spec.Names, errResultIdent(node.Pos())) + default: + return nil, false + } + check := errCheck(funcType, identErr) + anchorPositions(check, stmt.End()) + report.bump("stream_client_ctor") + return []ast.Stmt{stmt, check}, true +} + +// errResultIdent builds the err identifier appended as the new error result, +// anchored at pos so the printer keeps it on the binding's line. +func errResultIdent(pos token.Pos) *ast.Ident { + id := ast.NewIdent(identErr) + id.NamePos = pos + return id +} + +// tryCloseAndReceive handles `res, err := stream.CloseAndReceive()`, which v2 +// keeps but with no context argument and a bare-message result. It returns the +// response holder name so its .Msg accesses can be dropped. +func tryCloseAndReceive(stmt ast.Stmt, streamVars map[string]bool, _ *ast.FuncType, report *Report) ([]ast.Stmt, string, bool) { + assign, isAssign := stmt.(*ast.AssignStmt) + if !isAssign || len(assign.Rhs) != 1 { + return nil, "", false + } + call, isCall := assign.Rhs[0].(*ast.CallExpr) + if !isCall { + return nil, "", false + } + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel || sel.Sel.Name != "CloseAndReceive" { + return nil, "", false + } + streamName, isIdent := sel.X.(*ast.Ident) + if !isIdent || !streamVars[streamName.Name] { + return nil, "", false + } + assign.Rhs[0] = methodCall(streamName.Name, "CloseAndReceive") + anchorPositions(assign.Rhs[0], assign.Pos()) + report.bump("stream_close_and_receive") + holder := "" + if id, ok := assign.Lhs[0].(*ast.Ident); ok && id.Name != "_" { + holder = id.Name + } + return []ast.Stmt{assign}, holder, true +} + +// boolLoopStreamName reports the stream variable of a v1 +// `for stream.Receive() { ... }` loop. +func boolLoopStreamName(forStmt *ast.ForStmt, streamVars map[string]bool) (string, bool) { + if forStmt.Init != nil || forStmt.Post != nil || forStmt.Cond == nil { + return "", false + } + call, isCall := forStmt.Cond.(*ast.CallExpr) + if !isCall || len(call.Args) != 0 { + return "", false + } + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel || sel.Sel.Name != identReceive { + return "", false + } + id, isIdent := sel.X.(*ast.Ident) + if !isIdent || !streamVars[id.Name] { + return "", false + } + return id.Name, true +} + +// matchStreamErrCheck recognises an `if err := stream.Err(); err != nil` (or +// init-less `if stream.Err() != nil`) check after a v1 receive loop, returning +// the error variable name and the non-EOF handler statements. +func matchStreamErrCheck(stmt ast.Stmt, streamName string) (string, []ast.Stmt, bool) { + ifStmt, isIf := stmt.(*ast.IfStmt) + if !isIf || ifStmt.Body == nil { + return "", nil, false + } + if init, isAssign := ifStmt.Init.(*ast.AssignStmt); isAssign { + if len(init.Lhs) != 1 || len(init.Rhs) != 1 || !isStreamErrCall(init.Rhs[0], streamName) { + return "", nil, false + } + id, isIdent := init.Lhs[0].(*ast.Ident) + if !isIdent || !isNotNilCheck(ifStmt.Cond, id.Name) { + return "", nil, false + } + replaceStreamErr(ifStmt.Body.List, streamName, id.Name) + return id.Name, ifStmt.Body.List, true + } + // The `!= nil` shape is required so an `if stream.Err() == nil` success + // block is not mistaken for the error handler. + bin, isBinary := ifStmt.Cond.(*ast.BinaryExpr) + if !isBinary || bin.Op != token.NEQ || !isStreamErrCall(bin.X, streamName) || !isNilIdent(bin.Y) { + return "", nil, false + } + handler := ifStmt.Body.List + replaceStreamErr(handler, streamName, identErr) + return identErr, handler, true +} + +func isNilIdent(expr ast.Expr) bool { + id, ok := expr.(*ast.Ident) + return ok && id.Name == identNil +} + +// isNotNilCheck reports whether cond is `name != nil`. +func isNotNilCheck(cond ast.Expr, name string) bool { + bin, ok := cond.(*ast.BinaryExpr) + if !ok || bin.Op != token.NEQ { + return false + } + x, isIdent := bin.X.(*ast.Ident) + return isIdent && x.Name == name && isNilIdent(bin.Y) +} + +func isStreamErrCall(expr ast.Expr, streamName string) bool { + call, isCall := expr.(*ast.CallExpr) + if !isCall || len(call.Args) != 0 { + return false + } + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel || sel.Sel.Name != identStreamErr { + return false + } + id, isIdent := sel.X.(*ast.Ident) + return isIdent && id.Name == streamName +} + +// replaceStreamErr rewrites every stream.Err() call in stmts to errName, since +// the reshaped v2 loop has no Err() method. +func replaceStreamErr(stmts []ast.Stmt, streamName, errName string) { + for _, stmt := range stmts { + astutil.Apply(stmt, nil, func(c *astutil.Cursor) bool { + if expr, ok := c.Node().(ast.Expr); ok && isStreamErrCall(expr, streamName) { + c.Replace(ast.NewIdent(errName)) + } + return true + }) + } +} + +// hasTrailingStreamErr reports whether any statement references stream.Err(). +func hasTrailingStreamErr(stmts []ast.Stmt, streamName string) bool { + for _, stmt := range stmts { + found := false + ast.Inspect(stmt, func(n ast.Node) bool { + if found { + return false + } + if expr, isExpr := n.(ast.Expr); isExpr && isStreamErrCall(expr, streamName) { + found = true + return false + } + return true + }) + if found { + return true + } + } + return false +} + +// replaceStreamErrExprs rewrites every stream.Err() call to errName in place, +// in any position (not just return results, unlike replaceStreamErr). +func replaceStreamErrExprs(stmts []ast.Stmt, streamName, errName string) { + for _, stmt := range stmts { + astutil.Apply(stmt, nil, func(cursor *astutil.Cursor) bool { + if expr, isExpr := cursor.Node().(ast.Expr); isExpr && isStreamErrCall(expr, streamName) { + cursor.Replace(ast.NewIdent(errName)) + } + return true + }) + } +} + +// reshapeBoolLoopTrailingErr reshapes a v1 receive loop whose terminal error is +// inspected afterwards in a form matchStreamErrCheck can't fold (a success-path +// `if stream.Err() == nil`, a bare `return stream.Err()`). It hoists +// `var error` before the loop, captures the receive error and breaks, +// normalizes io.EOF to nil (v1 stream.Err() is nil on clean completion), and +// rewrites the post-loop stream.Err() references in rest (mutated in place). +func reshapeBoolLoopTrailingErr(forStmt *ast.ForStmt, streamName string, rest []ast.Stmt, state *rewriteState, report *Report) []ast.Stmt { + msgName := "_" + if hasStreamMsg(forStmt.Body, streamName) { + msgName = uniqueIdent(forStmt.Body, "msg") + replaceStreamMsg(forStmt.Body, streamName, msgName) + } + // The hoisted error spans the loop and the post-loop code, so it must be + // unique across both; the loop-local receive error stays "err". + scope := &ast.BlockStmt{List: append(append([]ast.Stmt(nil), forStmt.Body.List...), rest...)} + streamErrName := uniqueIdent(scope, "streamErr") + localErr := uniqueIdent(forStmt.Body, identErr) + + recv := &ast.AssignStmt{ + Lhs: []ast.Expr{ast.NewIdent(msgName), ast.NewIdent(localErr)}, + Tok: token.DEFINE, + Rhs: []ast.Expr{methodCall(streamName, identReceive)}, + } + captureBreak := &ast.IfStmt{ + Cond: notNil(localErr), + Body: &ast.BlockStmt{List: []ast.Stmt{ + &ast.AssignStmt{Lhs: []ast.Expr{ast.NewIdent(streamErrName)}, Tok: token.ASSIGN, Rhs: []ast.Expr{ast.NewIdent(localErr)}}, + &ast.BranchStmt{Tok: token.BREAK}, + }}, + } + declSpec := &ast.GenDecl{ + Tok: token.VAR, + Specs: []ast.Spec{&ast.ValueSpec{Names: []*ast.Ident{ast.NewIdent(streamErrName)}, Type: ast.NewIdent("error")}}, + } + decl := &ast.DeclStmt{Decl: declSpec} + normalize := &ast.IfStmt{ + Cond: &ast.CallExpr{ + Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")}, + Args: []ast.Expr{ast.NewIdent(streamErrName), &ast.SelectorExpr{X: ast.NewIdent("io"), Sel: ast.NewIdent("EOF")}}, + }, + Body: &ast.BlockStmt{List: []ast.Stmt{ + &ast.AssignStmt{Lhs: []ast.Expr{ast.NewIdent(streamErrName)}, Tok: token.ASSIGN, Rhs: []ast.Expr{ast.NewIdent(identNil)}}, + }}, + } + + replaceStreamErrExprs(rest, streamName, streamErrName) + + state.usedErrors = true + state.usedIO = true + report.bump("stream_recv_loop") + report.bump("stream_err_after_loop") + + anchorPositions(decl, forStmt.For) + // Clear the parens anchoring set, else the single var prints as a group. + declSpec.Lparen, declSpec.Rparen = token.NoPos, token.NoPos + anchorPositions(recv, forStmt.For) + anchorPositions(captureBreak, forStmt.For) + anchorPositions(normalize, forStmt.Body.Rbrace) + newFor := &ast.ForStmt{ + For: forStmt.For, + Body: &ast.BlockStmt{Lbrace: forStmt.Body.Lbrace, List: append([]ast.Stmt{recv, captureBreak}, forStmt.Body.List...), Rbrace: forStmt.Body.Rbrace}, + } + return []ast.Stmt{decl, newFor, normalize} +} + +// renameStmtsIdent renames identifier references oldName to newName across +// stmts, leaving selector field names untouched. +func renameStmtsIdent(stmts []ast.Stmt, oldName, newName string) { + for _, stmt := range stmts { + renameIdent(stmt, oldName, newName) + } +} + +func renameIdent(node ast.Node, oldName, newName string) { + ast.Inspect(node, func(n ast.Node) bool { + switch typed := n.(type) { + case *ast.SelectorExpr: + renameIdent(typed.X, oldName, newName) // receiver only, not the field + return false + case *ast.Ident: + if typed.Name == oldName { + typed.Name = newName + } + } + return true + }) +} + +// reshapeBoolLoop turns a v1 `for stream.Receive() { ... }` loop into a v2 +// `for { msg, err := stream.Receive() ... }` loop with an inline error check. +func reshapeBoolLoop(forStmt *ast.ForStmt, streamName, errName string, handler []ast.Stmt, funcType *ast.FuncType, state *rewriteState, report *Report) ast.Stmt { + msgName := "_" + if hasStreamMsg(forStmt.Body, streamName) { + msgName = uniqueIdent(forStmt.Body, "msg") + replaceStreamMsg(forStmt.Body, streamName, msgName) + } + // If the body already binds errName, pick a fresh name to avoid redeclaring + // it and rename the handler's references to match. + if uniqueIdent(forStmt.Body, errName) != errName { + scope := &ast.BlockStmt{List: append(append([]ast.Stmt(nil), forStmt.Body.List...), handler...)} + unique := uniqueIdent(scope, errName) + renameStmtsIdent(handler, errName, unique) + errName = unique + } + if handler == nil { + handler = defaultStreamErrHandler(funcType, errName) + } + recv := &ast.AssignStmt{ + Lhs: []ast.Expr{ast.NewIdent(msgName), ast.NewIdent(errName)}, + Tok: token.DEFINE, + Rhs: []ast.Expr{methodCall(streamName, identReceive)}, + } + eofBreak := &ast.IfStmt{ + Cond: &ast.CallExpr{ + Fun: &ast.SelectorExpr{X: ast.NewIdent("errors"), Sel: ast.NewIdent("Is")}, + Args: []ast.Expr{ast.NewIdent(errName), &ast.SelectorExpr{X: ast.NewIdent("io"), Sel: ast.NewIdent("EOF")}}, + }, + Body: &ast.BlockStmt{List: []ast.Stmt{&ast.BranchStmt{Tok: token.BREAK}}}, + } + errIf := &ast.IfStmt{ + Cond: notNil(errName), + Body: &ast.BlockStmt{List: append([]ast.Stmt{eofBreak}, handler...)}, + } + state.usedErrors = true + state.usedIO = true + report.bump("stream_recv_loop") + anchorPositions(recv, forStmt.For) + anchorPositions(errIf, forStmt.For) + newBody := append([]ast.Stmt{recv, errIf}, forStmt.Body.List...) + return &ast.ForStmt{ + For: forStmt.For, + Body: &ast.BlockStmt{Lbrace: forStmt.Body.Lbrace, List: newBody, Rbrace: forStmt.Body.Rbrace}, + } +} + +func hasStreamMsg(body *ast.BlockStmt, streamName string) bool { + found := false + walkFuncBody(body, func(n ast.Node) { + if found || !isStreamMsgCall(n, streamName) { + return + } + found = true + }) + return found +} + +func replaceStreamMsg(body *ast.BlockStmt, streamName, msgName string) { + replace := func(expr *ast.Expr) { + if isStreamMsgCall(*expr, streamName) { + *expr = ast.NewIdent(msgName) + } + } + walkFuncBody(body, func(n ast.Node) { + switch node := n.(type) { + case *ast.SelectorExpr: + replace(&node.X) + case *ast.CallExpr: + replace(&node.Fun) + for i := range node.Args { + replace(&node.Args[i]) + } + case *ast.AssignStmt: + for i := range node.Rhs { + replace(&node.Rhs[i]) + } + case *ast.BinaryExpr: + replace(&node.X) + replace(&node.Y) + case *ast.IndexExpr: + replace(&node.X) + replace(&node.Index) + case *ast.ReturnStmt: + for i := range node.Results { + replace(&node.Results[i]) + } + } + }) +} + +func isStreamMsgCall(node ast.Node, streamName string) bool { + call, isCall := node.(*ast.CallExpr) + if !isCall || len(call.Args) != 0 { + return false + } + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel || sel.Sel.Name != identMsg { + return false + } + id, isIdent := sel.X.(*ast.Ident) + return isIdent && id.Name == streamName +} + +// threadStreamMethods renames CloseRequest/CloseResponse to CloseSend/Close on +// stream variables. (Send/Receive keep their v1 shape; the loop reshape handles +// the bool-to-error Receive change.) +func threadStreamMethods(body *ast.BlockStmt, streamVars map[string]bool, report *Report) { + walkFuncBody(body, func(n ast.Node) { + call, isCall := n.(*ast.CallExpr) + if !isCall { + return + } + sel, isSel := call.Fun.(*ast.SelectorExpr) + if !isSel { + return + } + id, isIdent := sel.X.(*ast.Ident) + if !isIdent || !streamVars[id.Name] { + return + } + switch sel.Sel.Name { + case "CloseRequest": + sel.Sel = ast.NewIdent("CloseSend") + report.bump("stream_close_rename") + case "CloseResponse": + sel.Sel = ast.NewIdent("Close") + report.bump("stream_close_rename") + } + }) +} + +// dropMsgForHolders removes .Msg from selector chains and bare references for +// the named response holders (such as a CloseAndReceive response). +func dropMsgForHolders(body *ast.BlockStmt, holders map[string]bool, report *Report) { + if len(holders) == 0 { + return + } + walkFuncBody(body, func(n ast.Node) { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return + } + inner, ok := sel.X.(*ast.SelectorExpr) + if !ok || inner.Sel.Name != identMsg { + return + } + root := rootIdent(inner) + if root == nil || !holders[root.Name] { + return + } + sel.X = inner.X + report.bump("body_drop_msg") + }) + rewriteBareMsg(body, holders, report) +} + +// defaultStreamErrHandler builds the non-EOF receive-error handler: return the +// error, or t.Fatal it for a test function with no error result. +func defaultStreamErrHandler(funcType *ast.FuncType, errName string) []ast.Stmt { + if !hasResults(funcType) { + if name := testingParamName(funcType); name != "" { + return []ast.Stmt{&ast.ExprStmt{X: methodCall(name, "Fatal", ast.NewIdent(errName))}} + } + } + return []ast.Stmt{&ast.ReturnStmt{Results: zeroReturnResults(funcType, errName)}} +} + +func hasResults(funcType *ast.FuncType) bool { + return funcType != nil && funcType.Results != nil && len(funcType.Results.List) > 0 +} + +// testingParamName returns the name of a *testing.{T,B,F}/testing.TB parameter, +// or "". +func testingParamName(funcType *ast.FuncType) string { + if funcType == nil || funcType.Params == nil { + return "" + } + for _, field := range funcType.Params.List { + if !isTestingType(field.Type) || len(field.Names) == 0 { + continue + } + if name := field.Names[0].Name; name != "_" { + return name + } + } + return "" +} + +func isTestingType(expr ast.Expr) bool { + if star, ok := expr.(*ast.StarExpr); ok { + expr = star.X + } + sel, ok := expr.(*ast.SelectorExpr) + if !ok { + return false + } + id, ok := sel.X.(*ast.Ident) + if !ok || id.Name != "testing" { + return false + } + switch sel.Sel.Name { + case "T", "B", "F", "TB": + return true + } + return false +} + +// errCheck builds `if err != nil { }` (see defaultStreamErrHandler). +func errCheck(funcType *ast.FuncType, errName string) ast.Stmt { + return &ast.IfStmt{ + Cond: notNil(errName), + Body: &ast.BlockStmt{List: defaultStreamErrHandler(funcType, errName)}, + } +} + +func notNil(name string) ast.Expr { + return &ast.BinaryExpr{X: ast.NewIdent(name), Op: token.NEQ, Y: ast.NewIdent(identNil)} +} + +func methodCall(recv, method string, args ...ast.Expr) *ast.CallExpr { + return &ast.CallExpr{ + Fun: &ast.SelectorExpr{X: ast.NewIdent(recv), Sel: ast.NewIdent(method)}, + Args: args, + } +} + +// zeroReturnResults builds an error-return result list: a zero value for every +// non-error result, errName in the trailing error position. +func zeroReturnResults(funcType *ast.FuncType, errName string) []ast.Expr { + if funcType == nil || funcType.Results == nil || len(funcType.Results.List) == 0 { + return []ast.Expr{ast.NewIdent(errName)} + } + var types []ast.Expr + for _, field := range funcType.Results.List { + count := len(field.Names) + if count == 0 { + count = 1 + } + for range count { + types = append(types, field.Type) + } + } + out := make([]ast.Expr, len(types)) + for i, typ := range types { + if i == len(types)-1 && isErrorType(typ) { + out[i] = ast.NewIdent(errName) + continue + } + out[i] = zeroValueExpr(typ) + } + return out +} + +func isErrorType(typ ast.Expr) bool { + id, ok := typ.(*ast.Ident) + return ok && id.Name == "error" +} + +func zeroValueExpr(typ ast.Expr) ast.Expr { + ident, ok := typ.(*ast.Ident) + if !ok { + return ast.NewIdent(identNil) // pointers, slices, maps, chans, funcs, interfaces + } + switch ident.Name { + case "string": + return &ast.BasicLit{Kind: token.STRING, Value: `""`} + case "bool": + return ast.NewIdent("false") + case "int", "int8", "int16", "int32", "int64", + "uint", "uint8", "uint16", "uint32", "uint64", "uintptr", + "byte", "rune", "float32", "float64", "complex64", "complex128": + return &ast.BasicLit{Kind: token.INT, Value: "0"} + default: + return ast.NewIdent(identNil) // named types fall back to nil; may need a manual fix + } +} diff --git a/cmd/connect-go-v2-migrate/streams_test.go b/cmd/connect-go-v2-migrate/streams_test.go new file mode 100644 index 00000000..81b89470 --- /dev/null +++ b/cmd/connect-go-v2-migrate/streams_test.go @@ -0,0 +1,162 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + "testing" +) + +// TestStreamWarnings checks the streaming reshapes that the tool cannot perform +// mechanically and instead surfaces as warnings: the handler stream parameter +// type, whose generated v2 name the AST rewriter can't infer. +func TestStreamWarnings(t *testing.T) { + t.Parallel() + tests := []struct { + name string + body string + wantWarn string + }{ + { + name: "handler_param_type", + body: `func h(ctx context.Context, stream *connect.BidiStream[in, out]) error { + return stream.Send(&out{}) +}`, + wantWarn: "connect.BidiStream", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + src := "package p\n\nimport (\n\t\"context\"\n\n\t\"connectrpc.com/connect\"\n)\n\ntype in struct{}\ntype out struct{}\n\n" + test.body + "\n" + _, report, err := Rewrite("input.go", []byte(src), true) + if err != nil { + t.Fatalf("Rewrite: %v", err) + } + found := false + for _, warning := range report.Warnings { + if strings.Contains(warning.Msg, test.wantWarn) { + found = true + break + } + } + if !found { + t.Errorf("expected a warning containing %q; got %v", test.wantWarn, report.Warnings) + } + }) + } +} + +// TestHandlerStreamLookupDisambiguation checks that when two services share an +// RPC name and message types (a querier and an ingester exposing the same +// streaming RPC, say), the handler's receiver type breaks the tie, and an +// unrecognized receiver leaves the parameter a warning rather than guessing. +func TestHandlerStreamLookupDisambiguation(t *testing.T) { + t.Parallel() + msgs := []string{"MergeProfilesStacktracesRequest", "MergeProfilesStacktracesResponse"} + resolver := &handlerStreamResolver{ + types: []handlerStreamType{ + {pkgName: "ingestv1connect", name: "IngesterServiceMergeProfilesStacktracesServerStream", messages: msgs}, + {pkgName: "ingestv1connect", name: "QuerierServiceMergeProfilesStacktracesServerStream", messages: msgs}, + }, + } + // No receiver hint: ambiguous. The lookup fails and returns both candidates + // so the caller can name them. + if _, ambiguous, ok := resolver.lookup("MergeProfilesStacktraces", "", msgs); ok { + t.Errorf("expected ambiguous lookup to fail without a receiver hint") + } else if len(ambiguous) != 2 { + t.Errorf("expected 2 ambiguous candidates; got %d", len(ambiguous)) + } + // The receiver type names the service, breaking the tie. + for recv, want := range map[string]string{ + "Ingester": "IngesterServiceMergeProfilesStacktracesServerStream", + "querier": "QuerierServiceMergeProfilesStacktracesServerStream", // case-insensitive + } { + got, ambiguous, ok := resolver.lookup("MergeProfilesStacktraces", recv, msgs) + if !ok || got.name != want { + t.Errorf("receiver %q should resolve %q; got %q ok=%v", recv, want, got.name, ok) + } + if len(ambiguous) != 0 { + t.Errorf("a resolved lookup should report no ambiguity; got %v", ambiguous) + } + } + // An unrecognized receiver stays ambiguous. + if _, ambiguous, ok := resolver.lookup("MergeProfilesStacktraces", "Server", msgs); ok { + t.Errorf("expected unrecognized receiver to remain ambiguous") + } else if len(ambiguous) != 2 { + t.Errorf("expected 2 ambiguous candidates for an unrecognized receiver; got %d", len(ambiguous)) + } + // A unique match still resolves without any receiver hint. + single := &handlerStreamResolver{types: []handlerStreamType{ + {name: "IngesterServicePushServerStream", messages: []string{"PushRequest", "PushResponse"}}, + }} + if _, _, ok := single.lookup("Push", "", []string{"PushRequest", "PushResponse"}); !ok { + t.Errorf("unique match should resolve without a receiver hint") + } +} + +// TestStreamParamAmbiguousWarning checks that when a handler's stream parameter +// matches several services' generated stream types and the receiver doesn't +// name one of them (a shared internal helper, say *Store), the warning says it +// is ambiguous and names the candidates, rather than the generic "its v2 type +// is the generated handler stream type". +func TestStreamParamAmbiguousWarning(t *testing.T) { + t.Parallel() + resolver := &handlerStreamResolver{ + types: []handlerStreamType{ + {pkgPath: "x/ingestv1connect", pkgName: "ingestv1connect", name: "IngesterServicePushServerStream", messages: []string{"in", "out"}}, + {pkgPath: "x/querierv1connect", pkgName: "querierv1connect", name: "QuerierServicePushServerStream", messages: []string{"in", "out"}}, + }, + } + src := `package p + +import ( + "context" + + "connectrpc.com/connect" +) + +type in struct{} +type out struct{} +type store struct{} + +func (s *store) Push(ctx context.Context, stream *connect.BidiStream[in, out]) error { + return stream.Send(&out{}) +} +` + _, report, err := Rewrite("input.go", []byte(src), true, withHandlerStreams(resolver)) + if err != nil { + t.Fatalf("Rewrite: %v", err) + } + var warning *Warning + for i := range report.Warnings { + if report.Warnings[i].Rule == ruleStreamParamAmbiguous { + warning = &report.Warnings[i] + break + } + } + if warning == nil { + t.Fatalf("expected a %s warning; got %v", ruleStreamParamAmbiguous, report.Warnings) + } + for _, want := range []string{ + "matches multiple v2 handler stream types", + "ingestv1connect.IngesterServicePushServerStream", + "querierv1connect.QuerierServicePushServerStream", + } { + if !strings.Contains(warning.Msg, want) { + t.Errorf("ambiguity warning missing %q; got %q", want, warning.Msg) + } + } +} diff --git a/cmd/connect-go-v2-migrate/testdata/.gitattributes b/cmd/connect-go-v2-migrate/testdata/.gitattributes new file mode 100644 index 00000000..0aac50e3 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/.gitattributes @@ -0,0 +1,9 @@ +# Script archives embed Go sources and golden output that the tests compare +# byte-for-byte against rewriter output, which emits LF. Force LF on checkout +# so the comparisons hold on Windows runners (Git for Windows defaults to +# autocrlf=true and would otherwise yield CRLF). +*.txtar text eol=lf +*.go text eol=lf +*.txt text eol=lf +*.yaml text eol=lf +*.yml text eol=lf diff --git a/cmd/connect-go-v2-migrate/testdata/build/buf.gen.yaml b/cmd/connect-go-v2-migrate/testdata/build/buf.gen.yaml new file mode 100644 index 00000000..82fc7e4b --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/build/buf.gen.yaml @@ -0,0 +1,42 @@ +# Generates the build-harness scaffold stubs from the repo's +# internal/proto/connect/ping/v1/ping.proto with the test module's import +# prefix (example.com/app/gen). The four trees cover every state the migration +# tool must handle: +# +# gen/ shared *.pb.go (version-independent) +# genv1generic/ connect v1 stubs, generic form (connect.Request[T] wrappers) +# genv1simple/ connect v1 stubs, simple form (raw message types; `simple` opt) +# genv2/ connect v2 stubs +# +# The v1 and v2 connect plugins share the binary name protoc-gen-connect-go, so +# they are built under distinct names and referenced explicitly. Regenerate: +# +# tmp=$(mktemp -d) +# GOBIN=$tmp go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.20.0 +# mv $tmp/protoc-gen-connect-go $tmp/protoc-gen-connect-go-v1 +# go build -o $tmp/protoc-gen-connect-go-v2 ./cmd/protoc-gen-connect-go +# PATH="$tmp:$PATH" buf generate \ +# --template cmd/connect-go-v2-migrate/testdata/build/buf.gen.yaml \ +# --path internal/proto/connect/ping/v1/ping.proto \ +# -o cmd/connect-go-v2-migrate/testdata/build +version: v2 +managed: + enabled: true + override: + - file_option: go_package_prefix + value: example.com/app/gen +plugins: + - local: protoc-gen-go + out: gen + opt: paths=source_relative + - local: protoc-gen-connect-go-v1 + out: genv1generic + opt: paths=source_relative + - local: protoc-gen-connect-go-v1 + out: genv1simple + opt: + - paths=source_relative + - simple + - local: protoc-gen-connect-go-v2 + out: genv2 + opt: paths=source_relative diff --git a/cmd/connect-go-v2-migrate/testdata/build/gen/connect/ping/v1/ping.pb.go b/cmd/connect-go-v2-migrate/testdata/build/gen/connect/ping/v1/ping.pb.go new file mode 100644 index 00000000..a8d324f3 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/build/gen/connect/ping/v1/ping.pb.go @@ -0,0 +1,592 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The canonical location for this file is +// https://github.com/connectrpc/connect-go/blob/main/internal/proto/connect/ping/v1/ping.proto. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: connect/ping/v1/ping.proto + +// The connect.ping.v1 package contains an echo service designed to test the +// connect-go implementation. + +package pingv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type PingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"` + Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingRequest) Reset() { + *x = PingRequest{} + mi := &file_connect_ping_v1_ping_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingRequest) ProtoMessage() {} + +func (x *PingRequest) ProtoReflect() protoreflect.Message { + mi := &file_connect_ping_v1_ping_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. +func (*PingRequest) Descriptor() ([]byte, []int) { + return file_connect_ping_v1_ping_proto_rawDescGZIP(), []int{0} +} + +func (x *PingRequest) GetNumber() int64 { + if x != nil { + return x.Number + } + return 0 +} + +func (x *PingRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type PingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"` + Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingResponse) Reset() { + *x = PingResponse{} + mi := &file_connect_ping_v1_ping_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingResponse) ProtoMessage() {} + +func (x *PingResponse) ProtoReflect() protoreflect.Message { + mi := &file_connect_ping_v1_ping_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingResponse.ProtoReflect.Descriptor instead. +func (*PingResponse) Descriptor() ([]byte, []int) { + return file_connect_ping_v1_ping_proto_rawDescGZIP(), []int{1} +} + +func (x *PingResponse) GetNumber() int64 { + if x != nil { + return x.Number + } + return 0 +} + +func (x *PingResponse) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type FailRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FailRequest) Reset() { + *x = FailRequest{} + mi := &file_connect_ping_v1_ping_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FailRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FailRequest) ProtoMessage() {} + +func (x *FailRequest) ProtoReflect() protoreflect.Message { + mi := &file_connect_ping_v1_ping_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FailRequest.ProtoReflect.Descriptor instead. +func (*FailRequest) Descriptor() ([]byte, []int) { + return file_connect_ping_v1_ping_proto_rawDescGZIP(), []int{2} +} + +func (x *FailRequest) GetCode() int32 { + if x != nil { + return x.Code + } + return 0 +} + +type FailResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FailResponse) Reset() { + *x = FailResponse{} + mi := &file_connect_ping_v1_ping_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FailResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FailResponse) ProtoMessage() {} + +func (x *FailResponse) ProtoReflect() protoreflect.Message { + mi := &file_connect_ping_v1_ping_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FailResponse.ProtoReflect.Descriptor instead. +func (*FailResponse) Descriptor() ([]byte, []int) { + return file_connect_ping_v1_ping_proto_rawDescGZIP(), []int{3} +} + +type SumRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SumRequest) Reset() { + *x = SumRequest{} + mi := &file_connect_ping_v1_ping_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SumRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SumRequest) ProtoMessage() {} + +func (x *SumRequest) ProtoReflect() protoreflect.Message { + mi := &file_connect_ping_v1_ping_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SumRequest.ProtoReflect.Descriptor instead. +func (*SumRequest) Descriptor() ([]byte, []int) { + return file_connect_ping_v1_ping_proto_rawDescGZIP(), []int{4} +} + +func (x *SumRequest) GetNumber() int64 { + if x != nil { + return x.Number + } + return 0 +} + +type SumResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sum int64 `protobuf:"varint,1,opt,name=sum,proto3" json:"sum,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SumResponse) Reset() { + *x = SumResponse{} + mi := &file_connect_ping_v1_ping_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SumResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SumResponse) ProtoMessage() {} + +func (x *SumResponse) ProtoReflect() protoreflect.Message { + mi := &file_connect_ping_v1_ping_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SumResponse.ProtoReflect.Descriptor instead. +func (*SumResponse) Descriptor() ([]byte, []int) { + return file_connect_ping_v1_ping_proto_rawDescGZIP(), []int{5} +} + +func (x *SumResponse) GetSum() int64 { + if x != nil { + return x.Sum + } + return 0 +} + +type CountUpRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CountUpRequest) Reset() { + *x = CountUpRequest{} + mi := &file_connect_ping_v1_ping_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CountUpRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountUpRequest) ProtoMessage() {} + +func (x *CountUpRequest) ProtoReflect() protoreflect.Message { + mi := &file_connect_ping_v1_ping_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CountUpRequest.ProtoReflect.Descriptor instead. +func (*CountUpRequest) Descriptor() ([]byte, []int) { + return file_connect_ping_v1_ping_proto_rawDescGZIP(), []int{6} +} + +func (x *CountUpRequest) GetNumber() int64 { + if x != nil { + return x.Number + } + return 0 +} + +type CountUpResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CountUpResponse) Reset() { + *x = CountUpResponse{} + mi := &file_connect_ping_v1_ping_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CountUpResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CountUpResponse) ProtoMessage() {} + +func (x *CountUpResponse) ProtoReflect() protoreflect.Message { + mi := &file_connect_ping_v1_ping_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CountUpResponse.ProtoReflect.Descriptor instead. +func (*CountUpResponse) Descriptor() ([]byte, []int) { + return file_connect_ping_v1_ping_proto_rawDescGZIP(), []int{7} +} + +func (x *CountUpResponse) GetNumber() int64 { + if x != nil { + return x.Number + } + return 0 +} + +type CumSumRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CumSumRequest) Reset() { + *x = CumSumRequest{} + mi := &file_connect_ping_v1_ping_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CumSumRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CumSumRequest) ProtoMessage() {} + +func (x *CumSumRequest) ProtoReflect() protoreflect.Message { + mi := &file_connect_ping_v1_ping_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CumSumRequest.ProtoReflect.Descriptor instead. +func (*CumSumRequest) Descriptor() ([]byte, []int) { + return file_connect_ping_v1_ping_proto_rawDescGZIP(), []int{8} +} + +func (x *CumSumRequest) GetNumber() int64 { + if x != nil { + return x.Number + } + return 0 +} + +type CumSumResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sum int64 `protobuf:"varint,1,opt,name=sum,proto3" json:"sum,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CumSumResponse) Reset() { + *x = CumSumResponse{} + mi := &file_connect_ping_v1_ping_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CumSumResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CumSumResponse) ProtoMessage() {} + +func (x *CumSumResponse) ProtoReflect() protoreflect.Message { + mi := &file_connect_ping_v1_ping_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CumSumResponse.ProtoReflect.Descriptor instead. +func (*CumSumResponse) Descriptor() ([]byte, []int) { + return file_connect_ping_v1_ping_proto_rawDescGZIP(), []int{9} +} + +func (x *CumSumResponse) GetSum() int64 { + if x != nil { + return x.Sum + } + return 0 +} + +var File_connect_ping_v1_ping_proto protoreflect.FileDescriptor + +const file_connect_ping_v1_ping_proto_rawDesc = "" + + "\n" + + "\x1aconnect/ping/v1/ping.proto\x12\x0fconnect.ping.v1\"9\n" + + "\vPingRequest\x12\x16\n" + + "\x06number\x18\x01 \x01(\x03R\x06number\x12\x12\n" + + "\x04text\x18\x02 \x01(\tR\x04text\":\n" + + "\fPingResponse\x12\x16\n" + + "\x06number\x18\x01 \x01(\x03R\x06number\x12\x12\n" + + "\x04text\x18\x02 \x01(\tR\x04text\"!\n" + + "\vFailRequest\x12\x12\n" + + "\x04code\x18\x01 \x01(\x05R\x04code\"\x0e\n" + + "\fFailResponse\"$\n" + + "\n" + + "SumRequest\x12\x16\n" + + "\x06number\x18\x01 \x01(\x03R\x06number\"\x1f\n" + + "\vSumResponse\x12\x10\n" + + "\x03sum\x18\x01 \x01(\x03R\x03sum\"(\n" + + "\x0eCountUpRequest\x12\x16\n" + + "\x06number\x18\x01 \x01(\x03R\x06number\")\n" + + "\x0fCountUpResponse\x12\x16\n" + + "\x06number\x18\x01 \x01(\x03R\x06number\"'\n" + + "\rCumSumRequest\x12\x16\n" + + "\x06number\x18\x01 \x01(\x03R\x06number\"\"\n" + + "\x0eCumSumResponse\x12\x10\n" + + "\x03sum\x18\x01 \x01(\x03R\x03sum2\x87\x03\n" + + "\vPingService\x12H\n" + + "\x04Ping\x12\x1c.connect.ping.v1.PingRequest\x1a\x1d.connect.ping.v1.PingResponse\"\x03\x90\x02\x01\x12E\n" + + "\x04Fail\x12\x1c.connect.ping.v1.FailRequest\x1a\x1d.connect.ping.v1.FailResponse\"\x00\x12D\n" + + "\x03Sum\x12\x1b.connect.ping.v1.SumRequest\x1a\x1c.connect.ping.v1.SumResponse\"\x00(\x01\x12P\n" + + "\aCountUp\x12\x1f.connect.ping.v1.CountUpRequest\x1a .connect.ping.v1.CountUpResponse\"\x000\x01\x12O\n" + + "\x06CumSum\x12\x1e.connect.ping.v1.CumSumRequest\x1a\x1f.connect.ping.v1.CumSumResponse\"\x00(\x010\x01B\xaa\x01\n" + + "\x13com.connect.ping.v1B\tPingProtoP\x01Z*example.com/app/gen/connect/ping/v1;pingv1\xa2\x02\x03CPX\xaa\x02\x0fConnect.Ping.V1\xca\x02\x0fConnect\\Ping\\V1\xe2\x02\x1bConnect\\Ping\\V1\\GPBMetadata\xea\x02\x11Connect::Ping::V1b\x06proto3" + +var ( + file_connect_ping_v1_ping_proto_rawDescOnce sync.Once + file_connect_ping_v1_ping_proto_rawDescData []byte +) + +func file_connect_ping_v1_ping_proto_rawDescGZIP() []byte { + file_connect_ping_v1_ping_proto_rawDescOnce.Do(func() { + file_connect_ping_v1_ping_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_connect_ping_v1_ping_proto_rawDesc), len(file_connect_ping_v1_ping_proto_rawDesc))) + }) + return file_connect_ping_v1_ping_proto_rawDescData +} + +var file_connect_ping_v1_ping_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_connect_ping_v1_ping_proto_goTypes = []any{ + (*PingRequest)(nil), // 0: connect.ping.v1.PingRequest + (*PingResponse)(nil), // 1: connect.ping.v1.PingResponse + (*FailRequest)(nil), // 2: connect.ping.v1.FailRequest + (*FailResponse)(nil), // 3: connect.ping.v1.FailResponse + (*SumRequest)(nil), // 4: connect.ping.v1.SumRequest + (*SumResponse)(nil), // 5: connect.ping.v1.SumResponse + (*CountUpRequest)(nil), // 6: connect.ping.v1.CountUpRequest + (*CountUpResponse)(nil), // 7: connect.ping.v1.CountUpResponse + (*CumSumRequest)(nil), // 8: connect.ping.v1.CumSumRequest + (*CumSumResponse)(nil), // 9: connect.ping.v1.CumSumResponse +} +var file_connect_ping_v1_ping_proto_depIdxs = []int32{ + 0, // 0: connect.ping.v1.PingService.Ping:input_type -> connect.ping.v1.PingRequest + 2, // 1: connect.ping.v1.PingService.Fail:input_type -> connect.ping.v1.FailRequest + 4, // 2: connect.ping.v1.PingService.Sum:input_type -> connect.ping.v1.SumRequest + 6, // 3: connect.ping.v1.PingService.CountUp:input_type -> connect.ping.v1.CountUpRequest + 8, // 4: connect.ping.v1.PingService.CumSum:input_type -> connect.ping.v1.CumSumRequest + 1, // 5: connect.ping.v1.PingService.Ping:output_type -> connect.ping.v1.PingResponse + 3, // 6: connect.ping.v1.PingService.Fail:output_type -> connect.ping.v1.FailResponse + 5, // 7: connect.ping.v1.PingService.Sum:output_type -> connect.ping.v1.SumResponse + 7, // 8: connect.ping.v1.PingService.CountUp:output_type -> connect.ping.v1.CountUpResponse + 9, // 9: connect.ping.v1.PingService.CumSum:output_type -> connect.ping.v1.CumSumResponse + 5, // [5:10] is the sub-list for method output_type + 0, // [0:5] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_connect_ping_v1_ping_proto_init() } +func file_connect_ping_v1_ping_proto_init() { + if File_connect_ping_v1_ping_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_connect_ping_v1_ping_proto_rawDesc), len(file_connect_ping_v1_ping_proto_rawDesc)), + NumEnums: 0, + NumMessages: 10, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_connect_ping_v1_ping_proto_goTypes, + DependencyIndexes: file_connect_ping_v1_ping_proto_depIdxs, + MessageInfos: file_connect_ping_v1_ping_proto_msgTypes, + }.Build() + File_connect_ping_v1_ping_proto = out.File + file_connect_ping_v1_ping_proto_goTypes = nil + file_connect_ping_v1_ping_proto_depIdxs = nil +} diff --git a/cmd/connect-go-v2-migrate/testdata/build/generate.sh b/cmd/connect-go-v2-migrate/testdata/build/generate.sh new file mode 100755 index 00000000..cdd82683 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/build/generate.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Regenerates the build-harness scaffold stubs in this directory from the repo's +# ping.proto, via buf.gen.yaml. The connect v1 and protobuf plugin versions come +# from go.mod.txt. The v2 connect plugin is built from this repo's local source +# (./cmd/protoc-gen-connect-go) so a refresh tracks HEAD. Set BUF to reuse an +# existing buf binary instead of installing the pinned one. +set -euo pipefail +cd "$(dirname "$0")" +build_dir="$(pwd)" +repo_root="$(cd ../../../.. && pwd)" +GO="${GO:-go}" +BUF_VERSION="1.69.0" + +connect_version="$(awk '$1 == "connectrpc.com/connect" { print $2 }' go.mod.txt)" +protobuf_version="$(awk '$1 == "google.golang.org/protobuf" { print $2 }' go.mod.txt)" + +bindir="$(mktemp -d)" +trap 'rm -rf "$bindir"' EXIT + +# The v1 and v2 connect plugins share the binary name, so build them under +# distinct names that buf.gen.yaml references. +GOBIN="$bindir" $GO install "google.golang.org/protobuf/cmd/protoc-gen-go@${protobuf_version}" +GOBIN="$bindir" $GO install "connectrpc.com/connect/cmd/protoc-gen-connect-go@${connect_version}" +mv "$bindir/protoc-gen-connect-go" "$bindir/protoc-gen-connect-go-v1" +(cd "$repo_root" && $GO build -o "$bindir/protoc-gen-connect-go-v2" ./cmd/protoc-gen-connect-go) + +buf_bin="${BUF:-}" +if [ -z "$buf_bin" ]; then + GOBIN="$bindir" $GO install "github.com/bufbuild/buf/cmd/buf@v${BUF_VERSION}" + buf_bin="$bindir/buf" +fi + +cd "$repo_root" +PATH="$bindir:$PATH" "$buf_bin" generate \ + --template "${build_dir}/buf.gen.yaml" \ + --path internal/proto/connect/ping/v1/ping.proto \ + -o "${build_dir}" + +echo "Regenerated scaffold stubs in ${build_dir}" diff --git a/internal/gen/generics/connect/ping/v1/pingv1connect/ping.connect.go b/cmd/connect-go-v2-migrate/testdata/build/genv1generic/connect/ping/v1/pingv1connect/ping.connect.go similarity index 99% rename from internal/gen/generics/connect/ping/v1/pingv1connect/ping.connect.go rename to cmd/connect-go-v2-migrate/testdata/build/genv1generic/connect/ping/v1/pingv1connect/ping.connect.go index f3fb2602..f35aca90 100644 --- a/internal/gen/generics/connect/ping/v1/pingv1connect/ping.connect.go +++ b/cmd/connect-go-v2-migrate/testdata/build/genv1generic/connect/ping/v1/pingv1connect/ping.connect.go @@ -15,7 +15,7 @@ // The canonical location for this file is // https://github.com/connectrpc/connect-go/blob/main/internal/proto/connect/ping/v1/ping.proto. -// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// Code generated by protoc-gen-connect-go-v1. DO NOT EDIT. // // Source: connect/ping/v1/ping.proto @@ -25,9 +25,9 @@ package pingv1connect import ( connect "connectrpc.com/connect" - v1 "connectrpc.com/connect/internal/gen/connect/ping/v1" context "context" errors "errors" + v1 "example.com/app/gen/connect/ping/v1" http "net/http" strings "strings" ) diff --git a/internal/gen/simple/connect/ping/v1/pingv1connect/ping.connect.go b/cmd/connect-go-v2-migrate/testdata/build/genv1simple/connect/ping/v1/pingv1connect/ping.connect.go similarity index 99% rename from internal/gen/simple/connect/ping/v1/pingv1connect/ping.connect.go rename to cmd/connect-go-v2-migrate/testdata/build/genv1simple/connect/ping/v1/pingv1connect/ping.connect.go index dae04ae5..c3a2b3a0 100644 --- a/internal/gen/simple/connect/ping/v1/pingv1connect/ping.connect.go +++ b/cmd/connect-go-v2-migrate/testdata/build/genv1simple/connect/ping/v1/pingv1connect/ping.connect.go @@ -15,7 +15,7 @@ // The canonical location for this file is // https://github.com/connectrpc/connect-go/blob/main/internal/proto/connect/ping/v1/ping.proto. -// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// Code generated by protoc-gen-connect-go-v1. DO NOT EDIT. // // Source: connect/ping/v1/ping.proto @@ -25,9 +25,9 @@ package pingv1connect import ( connect "connectrpc.com/connect" - v1 "connectrpc.com/connect/internal/gen/connect/ping/v1" context "context" errors "errors" + v1 "example.com/app/gen/connect/ping/v1" http "net/http" strings "strings" ) diff --git a/cmd/connect-go-v2-migrate/testdata/build/genv2/connect/ping/v1/pingv1connect/ping.connect.go b/cmd/connect-go-v2-migrate/testdata/build/genv2/connect/ping/v1/pingv1connect/ping.connect.go new file mode 100644 index 00000000..67dace8e --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/build/genv2/connect/ping/v1/pingv1connect/ping.connect.go @@ -0,0 +1,393 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The canonical location for this file is +// https://github.com/connectrpc/connect-go/blob/main/internal/proto/connect/ping/v1/ping.proto. + +// Code generated by protoc-gen-connect-go-v2. DO NOT EDIT. +// +// Source: connect/ping/v1/ping.proto + +// The connect.ping.v1 package contains an echo service designed to test the +// connect-go implementation. +package pingv1connect + +import ( + connect "connectrpc.com/connect/v2" + context "context" + v1 "example.com/app/gen/connect/ping/v1" + sync "sync" +) + +const ( + // PingServiceName is the fully-qualified name of the PingService service. + PingServiceName = "connect.ping.v1.PingService" +) + +// These constants are the procedure names of the RPCs defined in this package. They're exposed at +// runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // PingServicePingProcedure is the procedure name of the PingService's Ping RPC. + PingServicePingProcedure = "/connect.ping.v1.PingService/Ping" + // PingServiceFailProcedure is the procedure name of the PingService's Fail RPC. + PingServiceFailProcedure = "/connect.ping.v1.PingService/Fail" + // PingServiceSumProcedure is the procedure name of the PingService's Sum RPC. + PingServiceSumProcedure = "/connect.ping.v1.PingService/Sum" + // PingServiceCountUpProcedure is the procedure name of the PingService's CountUp RPC. + PingServiceCountUpProcedure = "/connect.ping.v1.PingService/CountUp" + // PingServiceCumSumProcedure is the procedure name of the PingService's CumSum RPC. + PingServiceCumSumProcedure = "/connect.ping.v1.PingService/CumSum" +) + +var ( + pingServicePingSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: v1.File_connect_ping_v1_ping_proto.Services().ByName("PingService").Methods().ByName("Ping"), + Procedure: PingServicePingProcedure, + IdempotencyLevel: connect.IdempotencyNoSideEffects, + } + }) + pingServiceFailSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: v1.File_connect_ping_v1_ping_proto.Services().ByName("PingService").Methods().ByName("Fail"), + Procedure: PingServiceFailProcedure, + } + }) + pingServiceSumSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeClient, + Schema: v1.File_connect_ping_v1_ping_proto.Services().ByName("PingService").Methods().ByName("Sum"), + Procedure: PingServiceSumProcedure, + } + }) + pingServiceCountUpSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeServer, + Schema: v1.File_connect_ping_v1_ping_proto.Services().ByName("PingService").Methods().ByName("CountUp"), + Procedure: PingServiceCountUpProcedure, + } + }) + pingServiceCumSumSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeBidi, + Schema: v1.File_connect_ping_v1_ping_proto.Services().ByName("PingService").Methods().ByName("CumSum"), + Procedure: PingServiceCumSumProcedure, + } + }) +) + +// PingServiceClient is a client for the connect.ping.v1.PingService service. +type PingServiceClient interface { + // Ping sends a ping to the server to determine if it's reachable. + Ping(context.Context, *v1.PingRequest) (*v1.PingResponse, error) + // Fail always fails. + Fail(context.Context, *v1.FailRequest) (*v1.FailResponse, error) + // Sum calculates the sum of the numbers sent on the stream. + Sum(context.Context) (PingServiceSumClientStream, error) + // CountUp returns a stream of the numbers up to the given request. + CountUp(context.Context, *v1.CountUpRequest) (PingServiceCountUpClientStream, error) + // CumSum determines the cumulative sum of all the numbers sent on the stream. + CumSum(context.Context) (PingServiceCumSumClientStream, error) +} + +// NewPingServiceClient constructs a client for the connect.ping.v1.PingService service. Multiple +// service clients may share a single connect.Client. +func NewPingServiceClient(client *connect.Client) PingServiceClient { + return &pingServiceClient{client: client} +} + +// PingServiceSumClientStream is the client stream for the PingService's Sum RPC. +type PingServiceSumClientStream struct { + stream connect.ClientStream +} + +// SendHeaders opens the stream and flushes the request headers without a message. The first Send or +// Receive does this implicitly. +func (s PingServiceSumClientStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a request message to the server. +func (s PingServiceSumClientStream) Send(req *v1.SumRequest) error { + return s.stream.Send(req) +} + +// CloseAndReceive closes the request side of the stream and returns the single response message. It +// reads the stream to completion to release its resources. +func (s PingServiceSumClientStream) CloseAndReceive() (*v1.SumResponse, error) { + if err := s.stream.CloseSend(); err != nil { + return nil, err + } + var res v1.SumResponse + if err := s.stream.Receive(&res); err != nil { + return nil, err + } + return &res, nil +} + +// PingServiceCountUpClientStream is the client stream for the PingService's CountUp RPC. +type PingServiceCountUpClientStream struct { + stream connect.ClientStream +} + +// Receive returns the next response message from the server. +func (s PingServiceCountUpClientStream) Receive() (*v1.CountUpResponse, error) { + var res v1.CountUpResponse + if err := s.stream.Receive(&res); err != nil { + return nil, err + } + return &res, nil +} + +// Close releases the stream's resources. It is idempotent and is typically deferred to clean up a +// stream abandoned before io.EOF. +func (s PingServiceCountUpClientStream) Close() error { + return s.stream.Close() +} + +// PingServiceCumSumClientStream is the client stream for the PingService's CumSum RPC. +type PingServiceCumSumClientStream struct { + stream connect.ClientStream +} + +// SendHeaders opens the stream and flushes the request headers without a message. The first Send or +// Receive does this implicitly. +func (s PingServiceCumSumClientStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a request message to the server. +func (s PingServiceCumSumClientStream) Send(req *v1.CumSumRequest) error { + return s.stream.Send(req) +} + +// CloseSend closes the request side of the stream. +func (s PingServiceCumSumClientStream) CloseSend() error { + return s.stream.CloseSend() +} + +// Receive returns the next response message from the server. +func (s PingServiceCumSumClientStream) Receive() (*v1.CumSumResponse, error) { + var res v1.CumSumResponse + if err := s.stream.Receive(&res); err != nil { + return nil, err + } + return &res, nil +} + +// Close releases the stream's resources. It is idempotent and is typically deferred to clean up a +// stream abandoned before io.EOF. +func (s PingServiceCumSumClientStream) Close() error { + return s.stream.Close() +} + +// PingServiceHandler is an implementation of the connect.ping.v1.PingService service. +type PingServiceHandler interface { + // Ping sends a ping to the server to determine if it's reachable. + Ping(context.Context, *v1.PingRequest) (*v1.PingResponse, error) + // Fail always fails. + Fail(context.Context, *v1.FailRequest) (*v1.FailResponse, error) + // Sum calculates the sum of the numbers sent on the stream. + Sum(context.Context, PingServiceSumServerStream) (*v1.SumResponse, error) + // CountUp returns a stream of the numbers up to the given request. + CountUp(context.Context, *v1.CountUpRequest, PingServiceCountUpServerStream) error + // CumSum determines the cumulative sum of all the numbers sent on the stream. + CumSum(context.Context, PingServiceCumSumServerStream) error +} + +// RegisterPingServiceHandler registers svc as the connect.ping.v1.PingService implementation on +// server. +func RegisterPingServiceHandler(server *connect.Server, svc PingServiceHandler) { + adapter := pingServiceHandler{svc: svc} + server.Register( + connect.Method{Spec: pingServicePingSpec(), Handler: adapter.ping}, + connect.Method{Spec: pingServiceFailSpec(), Handler: adapter.fail}, + connect.Method{Spec: pingServiceSumSpec(), Handler: adapter.sum}, + connect.Method{Spec: pingServiceCountUpSpec(), Handler: adapter.countUp}, + connect.Method{Spec: pingServiceCumSumSpec(), Handler: adapter.cumSum}, + ) +} + +// PingServiceSumServerStream is the server stream for the PingService's Sum RPC. +type PingServiceSumServerStream struct { + stream connect.ServerStream +} + +// Receive returns the next request message from the client. +func (s PingServiceSumServerStream) Receive() (*v1.SumRequest, error) { + var req v1.SumRequest + if err := s.stream.Receive(&req); err != nil { + return nil, err + } + return &req, nil +} + +// PingServiceCountUpServerStream is the server stream for the PingService's CountUp RPC. +type PingServiceCountUpServerStream struct { + stream connect.ServerStream +} + +// SendHeaders flushes the response headers without a message. The first Send does this implicitly. +func (s PingServiceCountUpServerStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a response message to the client. +func (s PingServiceCountUpServerStream) Send(res *v1.CountUpResponse) error { + return s.stream.Send(res) +} + +// PingServiceCumSumServerStream is the server stream for the PingService's CumSum RPC. +type PingServiceCumSumServerStream struct { + stream connect.ServerStream +} + +// Receive returns the next request message from the client. +func (s PingServiceCumSumServerStream) Receive() (*v1.CumSumRequest, error) { + var req v1.CumSumRequest + if err := s.stream.Receive(&req); err != nil { + return nil, err + } + return &req, nil +} + +// SendHeaders flushes the response headers without a message. The first Send does this implicitly. +func (s PingServiceCumSumServerStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a response message to the client. +func (s PingServiceCumSumServerStream) Send(res *v1.CumSumResponse) error { + return s.stream.Send(res) +} + +// UnimplementedPingServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedPingServiceHandler struct{} + +func (UnimplementedPingServiceHandler) Ping(context.Context, *v1.PingRequest) (*v1.PingResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connect.ping.v1.PingService.Ping is not implemented") +} + +func (UnimplementedPingServiceHandler) Fail(context.Context, *v1.FailRequest) (*v1.FailResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connect.ping.v1.PingService.Fail is not implemented") +} + +func (UnimplementedPingServiceHandler) Sum(context.Context, PingServiceSumServerStream) (*v1.SumResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connect.ping.v1.PingService.Sum is not implemented") +} + +func (UnimplementedPingServiceHandler) CountUp(context.Context, *v1.CountUpRequest, PingServiceCountUpServerStream) error { + return connect.NewError(connect.CodeUnimplemented, "connect.ping.v1.PingService.CountUp is not implemented") +} + +func (UnimplementedPingServiceHandler) CumSum(context.Context, PingServiceCumSumServerStream) error { + return connect.NewError(connect.CodeUnimplemented, "connect.ping.v1.PingService.CumSum is not implemented") +} + +type pingServiceClient struct { + client *connect.Client +} + +func (c *pingServiceClient) Ping(ctx context.Context, req *v1.PingRequest) (*v1.PingResponse, error) { + var res v1.PingResponse + if err := c.client.CallUnary(ctx, pingServicePingSpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c *pingServiceClient) Fail(ctx context.Context, req *v1.FailRequest) (*v1.FailResponse, error) { + var res v1.FailResponse + if err := c.client.CallUnary(ctx, pingServiceFailSpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c *pingServiceClient) Sum(ctx context.Context) (PingServiceSumClientStream, error) { + stream, err := c.client.CallClientStream(ctx, pingServiceSumSpec()) + if err != nil { + return PingServiceSumClientStream{}, err + } + return PingServiceSumClientStream{stream: stream}, nil +} + +func (c *pingServiceClient) CountUp(ctx context.Context, req *v1.CountUpRequest) (PingServiceCountUpClientStream, error) { + stream, err := c.client.CallServerStream(ctx, pingServiceCountUpSpec(), req) + if err != nil { + return PingServiceCountUpClientStream{}, err + } + return PingServiceCountUpClientStream{stream: stream}, nil +} + +func (c *pingServiceClient) CumSum(ctx context.Context) (PingServiceCumSumClientStream, error) { + stream, err := c.client.CallClientStream(ctx, pingServiceCumSumSpec()) + if err != nil { + return PingServiceCumSumClientStream{}, err + } + return PingServiceCumSumClientStream{stream: stream}, nil +} + +type pingServiceHandler struct{ svc PingServiceHandler } + +func (h pingServiceHandler) ping(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.PingRequest + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.Ping(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) +} + +func (h pingServiceHandler) fail(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.FailRequest + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.Fail(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) +} + +func (h pingServiceHandler) sum(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + res, err := h.svc.Sum(ctx, PingServiceSumServerStream{stream: stream}) + if err != nil { + return err + } + return stream.Send(res) +} + +func (h pingServiceHandler) countUp(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.CountUpRequest + if err := stream.Receive(&req); err != nil { + return err + } + return h.svc.CountUp(ctx, &req, PingServiceCountUpServerStream{stream: stream}) +} + +func (h pingServiceHandler) cumSum(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + return h.svc.CumSum(ctx, PingServiceCumSumServerStream{stream: stream}) +} diff --git a/cmd/connect-go-v2-migrate/testdata/build/go.mod.txt b/cmd/connect-go-v2-migrate/testdata/build/go.mod.txt new file mode 100644 index 00000000..fe36e0a7 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/build/go.mod.txt @@ -0,0 +1,11 @@ +module example.com/app + +go 1.25.0 + +require ( + connectrpc.com/connect v1.20.0 + connectrpc.com/connect/v2 v2.0.0 + google.golang.org/protobuf v1.36.11 +) + +replace connectrpc.com/connect/v2 => REPLACE_DIR diff --git a/cmd/connect-go-v2-migrate/testdata/build/go.sum b/cmd/connect-go-v2-migrate/testdata/build/go.sum new file mode 100644 index 00000000..1ac57354 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/build/go.sum @@ -0,0 +1,6 @@ +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/cmd/connect-go-v2-migrate/testdata/build/ping.go b/cmd/connect-go-v2-migrate/testdata/build/ping.go new file mode 100644 index 00000000..1cd0e97a --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/build/ping.go @@ -0,0 +1,40 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "errors" + "io" + "net/http" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + return connect.NewResponse(&pingv1.PingResponse{Number: req.Msg.Number, Text: req.Msg.Text}), nil +} + +func setup() http.Handler { + mux := http.NewServeMux() + mux.Handle(pingv1connect.NewPingServiceHandler(&pingServer{})) + return mux +} diff --git a/cmd/connect-go-v2-migrate/testdata/script/binary_header.txtar b/cmd/connect-go-v2-migrate/testdata/script/binary_header.txtar new file mode 100644 index 00000000..f47bd571 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/binary_header.txtar @@ -0,0 +1,65 @@ +# Encode/DecodeBinaryHeader keep the connect qualifier; only the import flips. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + encoded := connect.EncodeBinaryHeader([]byte(req.Msg.Text)) + decoded, err := connect.DecodeBinaryHeader(encoded) + if err != nil { + return nil, err + } + return connect.NewResponse(&pingv1.PingResponse{Text: string(decoded)}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: body_drop_msg=1 flip_residual_connect=2 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,7 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,12 +12,12 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { +- encoded := connect.EncodeBinaryHeader([]byte(req.Msg.Text)) ++func (s *pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { ++ encoded := connect.EncodeBinaryHeader([]byte(req.Text)) + decoded, err := connect.DecodeBinaryHeader(encoded) + if err != nil { + return nil, err + } +- return connect.NewResponse(&pingv1.PingResponse{Text: string(decoded)}), nil ++ return &pingv1.PingResponse{Text: string(decoded)}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/bsr_sdk.txtar b/cmd/connect-go-v2-migrate/testdata/script/bsr_sdk.txtar new file mode 100644 index 00000000..e315290a --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/bsr_sdk.txtar @@ -0,0 +1,122 @@ +# A project whose connect stubs come from a generated BSR SDK dependency: +# the migration is a go get of the v2 modules, not buf generate. +exec migrate +cmp stdout out.txt +stdout 'Generated v1 Connect SDKs' +stdout 'connectrpc.com/connect/v2' +! stdout 'buf generate' +! stdout 'Generated v1 Connect code:' +exec go build ./... +-- bsrsdk/go.mod -- +module buf.build/gen/go/example/test/connectrpc/go + +go 1.25 + +require connectrpc.com/connect v1.18.1 +-- bsrsdk/test.connect.go -- +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// This stands in for a BSR-generated SDK: a generated package that imports the +// v1 connect runtime. The migration can't regenerate it; the user pulls the v2 +// build with `go get @v2`. +package testv1connect + +import "connectrpc.com/connect" + +// PingRequest is a stand-in generated message. +type PingRequest struct{ Name string } + +// PingResponse is a stand-in generated message. +type PingResponse struct{ Greeting string } + +// ServiceClient is the generated v1 client interface. +type ServiceClient interface { + Ping(*connect.Request[PingRequest]) (*connect.Response[PingResponse], error) +} + +// NewServiceClient builds the v1 client. +func NewServiceClient(baseURL string) ServiceClient { return nil } +-- client.go -- +package app + +import ( + testv1connect "buf.build/gen/go/example/test/connectrpc/go" + "connectrpc.com/connect" + "connectrpc.com/otelconnect" +) + +var _ = otelconnect.Name + +// ping calls the BSR-generated client with the v1 request wrapper. +func ping() (*testv1connect.PingResponse, error) { + client := testv1connect.NewServiceClient("http://localhost:8080") + res, err := client.Ping(connect.NewRequest(&testv1connect.PingRequest{Name: "x"})) + if err != nil { + return nil, err + } + return res.Msg, nil +} +-- connectv1/connect.go -- +// Package connect is a minimal v1 stub for the fixture. +package connect + +// Request wraps a request message. +type Request[T any] struct{ Msg *T } + +// Response wraps a response message. +type Response[T any] struct{ Msg *T } + +// NewRequest boxes a message into a Request. +func NewRequest[T any](msg *T) *Request[T] { return &Request[T]{Msg: msg} } +-- connectv1/go.mod -- +module connectrpc.com/connect + +go 1.25 +-- go.mod -- +module example.com/app + +go 1.25 + +require ( + buf.build/gen/go/example/test/connectrpc/go v1.0.0 + connectrpc.com/connect v1.18.1 + connectrpc.com/otelconnect v0.7.0 +) + +replace connectrpc.com/connect => ./connectv1 + +replace buf.build/gen/go/example/test/connectrpc/go => ./bsrsdk + +replace connectrpc.com/otelconnect => ./otelconnect +-- otelconnect/go.mod -- +module connectrpc.com/otelconnect + +go 1.25 +-- otelconnect/otelconnect.go -- +// Package otelconnect is a minimal stub of the connectrpc.com/otelconnect +// ecosystem module, so the fixture exercises the ecosystem entry in the go get +// advice. +package otelconnect + +// Name is a stand-in exported symbol. +const Name = "otelconnect" +-- out.txt -- +Scanned 1 Go file and 0 Buf templates. The generated Connect code still +targets v1, so no Go source changes are proposed yet. + +Generated v1 Connect SDKs (the go get @v2 below updates these): + buf.build/gen/go/example/test/connectrpc/go + +First, move the dependencies and generated code to v2: + + 1. go get -u \ + connectrpc.com/connect/v2 \ + buf.build/gen/go/example/test/connectrpc/go@v2 \ + connectrpc.com/otelconnect/v2 + (pulls the v2 core, generated SDKs, and ecosystem modules into go.mod) + +Then re-run connect-go-v2-migrate to work through the Go source changes: it +rewrites the call sites against the v2 stubs and reports anything that needs +a manual update. + +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/bufgen_gosimple.txtar b/cmd/connect-go-v2-migrate/testdata/script/bufgen_gosimple.txtar new file mode 100644 index 00000000..6ef0c36c --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/bufgen_gosimple.txtar @@ -0,0 +1,38 @@ +# buf.gen.yaml with the v1 gosimple plugin: v2 makes the simple API the default +# generator, so the entry moves to connectrpc/go rather than a gosimple v2. +exec migrate +cmp stdout out.txt +exec migrate -w +cmp buf.gen.yaml want.yaml +exec go build ./... +-- doc.go -- +package app +-- buf.gen.yaml -- +version: v2 +plugins: + - remote: buf.build/connectrpc/gosimple:v1.18.1 + out: gen +-- want.yaml -- +version: v2 +plugins: + - remote: buf.build/connectrpc/go:v2.0.0 + out: gen +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./buf.gen.yaml: bufgen_replace_gosimple=1 +--- ./buf.gen.yaml ++++ ./buf.gen.yaml +@@ -1,5 +1,5 @@ + version: v2 + plugins: +- - remote: buf.build/connectrpc/gosimple:v1.18.1 ++ - remote: buf.build/connectrpc/go:v2.0.0 + out: gen + + + +The following issues require manual code changes: + ./buf.gen.yaml:3:1: buf.build/connectrpc/go:v2.0.0 is not published yet. Until connect-go v2.0.0 is released, generate with the local plugin instead: `go install connectrpc.com/connect/v2/cmd/protoc-gen-connect-go@latest` and a `local: protoc-gen-connect-go` entry. + +Scanned 1 Go file and 1 Buf template. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/bufgen_gotool.txtar b/cmd/connect-go-v2-migrate/testdata/script/bufgen_gotool.txtar new file mode 100644 index 00000000..ee100e09 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/bufgen_gotool.txtar @@ -0,0 +1,42 @@ +# buf.gen.yaml with go-tool plugins: the connect plugin's simple opt is dropped. +exec migrate +cmp stdout out.txt +exec migrate -w +cmp buf.gen.yaml want.yaml +exec go build ./... +-- doc.go -- +package app +-- buf.gen.yaml -- +version: v2 +plugins: + - local: [go, tool, protoc-gen-go] + out: gen + opt: paths=source_relative + - local: [go, tool, protoc-gen-connect-go] + out: gen + opt: paths=source_relative,simple=true +-- want.yaml -- +version: v2 +plugins: + - local: [go, tool, protoc-gen-go] + out: gen + opt: paths=source_relative + - local: [go, tool, protoc-gen-connect-go] + out: gen + opt: paths=source_relative +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./buf.gen.yaml: bufgen_remove_simple=1 +--- ./buf.gen.yaml ++++ ./buf.gen.yaml +@@ -5,5 +5,5 @@ + opt: paths=source_relative + - local: [go, tool, protoc-gen-connect-go] + out: gen +- opt: paths=source_relative,simple=true ++ opt: paths=source_relative + + + +Scanned 1 Go file and 1 Buf template. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/bufgen_list_comment.txtar b/cmd/connect-go-v2-migrate/testdata/script/bufgen_list_comment.txtar new file mode 100644 index 00000000..ee490448 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/bufgen_list_comment.txtar @@ -0,0 +1,40 @@ +# Regression: a comment interleaved in the opt list must not stop the simple strip. +exec migrate +cmp stdout out.txt +exec migrate -w +cmp buf.gen.yaml want.yaml +exec go build ./... +-- doc.go -- +package app +-- buf.gen.yaml -- +version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: + - paths=source_relative + # keep this comment + - simple=true +-- want.yaml -- +version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: + - paths=source_relative + # keep this comment +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./buf.gen.yaml: bufgen_remove_simple=1 +--- ./buf.gen.yaml ++++ ./buf.gen.yaml +@@ -5,5 +5,4 @@ + opt: + - paths=source_relative + # keep this comment +- - simple=true + + + +Scanned 1 Go file and 1 Buf template. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/bufgen_local_yaml.txtar b/cmd/connect-go-v2-migrate/testdata/script/bufgen_local_yaml.txtar new file mode 100644 index 00000000..9d1c5114 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/bufgen_local_yaml.txtar @@ -0,0 +1,46 @@ +# buf.gen.yaml local plugins: the connect plugin's simple opt is dropped. +exec migrate +cmp stdout out.txt +exec migrate -w +cmp buf.gen.yaml want.yaml +exec go build ./... +-- doc.go -- +package app +-- buf.gen.yaml -- +version: v2 +managed: + enabled: true +plugins: + - local: protoc-gen-go + out: gen + opt: paths=source_relative + - local: protoc-gen-connect-go + out: gen + opt: paths=source_relative,simple=true +-- want.yaml -- +version: v2 +managed: + enabled: true +plugins: + - local: protoc-gen-go + out: gen + opt: paths=source_relative + - local: protoc-gen-connect-go + out: gen + opt: paths=source_relative +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./buf.gen.yaml: bufgen_remove_simple=1 +--- ./buf.gen.yaml ++++ ./buf.gen.yaml +@@ -7,5 +7,5 @@ + opt: paths=source_relative + - local: protoc-gen-connect-go + out: gen +- opt: paths=source_relative,simple=true ++ opt: paths=source_relative + + + +Scanned 1 Go file and 1 Buf template. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/bufgen_local_yml.txtar b/cmd/connect-go-v2-migrate/testdata/script/bufgen_local_yml.txtar new file mode 100644 index 00000000..f45d6eec --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/bufgen_local_yml.txtar @@ -0,0 +1,38 @@ +# buf.gen.yml: list-form opt has the simple entry dropped. +exec migrate +cmp stdout out.txt +exec migrate -w +cmp buf.gen.yml want.yml +exec go build ./... +-- doc.go -- +package app +-- buf.gen.yml -- +version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: + - paths=source_relative + - simple=true +-- want.yml -- +version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: + - paths=source_relative +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./buf.gen.yml: bufgen_remove_simple=1 +--- ./buf.gen.yml ++++ ./buf.gen.yml +@@ -4,5 +4,4 @@ + out: gen + opt: + - paths=source_relative +- - simple=true + + + +Scanned 1 Go file and 1 Buf template. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/bufgen_remote.txtar b/cmd/connect-go-v2-migrate/testdata/script/bufgen_remote.txtar new file mode 100644 index 00000000..f1d697cf --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/bufgen_remote.txtar @@ -0,0 +1,39 @@ +# buf.gen.yaml remote plugin: bumped to connectrpc/go v2 and simple opt dropped. +exec migrate +cmp stdout out.txt +exec migrate -w +cmp buf.gen.yaml want.yaml +exec go build ./... +-- doc.go -- +package app +-- buf.gen.yaml -- +version: v2 +plugins: + - remote: buf.build/connectrpc/go:v1.18.1 + out: gen + opt: simple=true +-- want.yaml -- +version: v2 +plugins: + - remote: buf.build/connectrpc/go:v2.0.0 + out: gen +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./buf.gen.yaml: bufgen_pin_remote_v2=1 bufgen_remove_simple=1 +--- ./buf.gen.yaml ++++ ./buf.gen.yaml +@@ -1,6 +1,5 @@ + version: v2 + plugins: +- - remote: buf.build/connectrpc/go:v1.18.1 ++ - remote: buf.build/connectrpc/go:v2.0.0 + out: gen +- opt: simple=true + + + +The following issues require manual code changes: + ./buf.gen.yaml:3:1: buf.build/connectrpc/go:v2.0.0 is not published yet. Until connect-go v2.0.0 is released, generate with the local plugin instead: `go install connectrpc.com/connect/v2/cmd/protoc-gen-connect-go@latest` and a `local: protoc-gen-connect-go` entry. + +Scanned 1 Go file and 1 Buf template. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/bufgen_remote_phase1.txtar b/cmd/connect-go-v2-migrate/testdata/script/bufgen_remote_phase1.txtar new file mode 100644 index 00000000..425fb79d --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/bufgen_remote_phase1.txtar @@ -0,0 +1,70 @@ +# Regenerate-first with a remote plugin: the template is pinned to v2 and the +# report must still surface the not-published warning, because the steps below +# tell the user to run buf generate against that pin. +stubs v1generic +exec migrate +cmp stdout out.txt +stdout 'not published yet' +stdout 'no Go source changes are proposed yet' +stdout 'Proposed Buf template updates' +stdout 'no Go source changes are proposed yet' +exec go build ./... +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + return connect.NewResponse(&pingv1.PingResponse{Number: req.Msg.Number, Text: req.Msg.Text}), nil +} +-- buf.gen.yaml -- +version: v2 +plugins: + - remote: buf.build/connectrpc/go:v1.18.1 + out: gen +-- out.txt -- +Scanned 1 Go file and 1 Buf template. The generated Connect code still targets +v1, so no Go source changes are proposed yet. + +Generated v1 Connect code: + ./gen/connect/ping/v1/pingv1connect + +Proposed Buf template updates (rerun with -w to apply): + ./buf.gen.yaml: bufgen_pin_remote_v2=1 +--- ./buf.gen.yaml ++++ ./buf.gen.yaml +@@ -1,5 +1,5 @@ + version: v2 + plugins: +- - remote: buf.build/connectrpc/go:v1.18.1 ++ - remote: buf.build/connectrpc/go:v2.0.0 + out: gen + + + +The following issues require a manual update: + ./buf.gen.yaml:3:1: buf.build/connectrpc/go:v2.0.0 is not published yet. Until connect-go v2.0.0 is released, generate with the local plugin instead: `go install connectrpc.com/connect/v2/cmd/protoc-gen-connect-go@latest` and a `local: protoc-gen-connect-go` entry. + +First, move the dependencies and generated code to v2: + + 1. connect-go-v2-migrate -w (applies the Buf template update above) + 2. go get -u \ + connectrpc.com/connect/v2 + (pulls the v2 core, generated SDKs, and ecosystem modules into go.mod) + 3. buf generate + +Then re-run connect-go-v2-migrate to work through the Go source changes: it +rewrites the call sites against the v2 stubs and reports anything that needs +a manual update. + +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/bufgen_v1_local.txtar b/cmd/connect-go-v2-migrate/testdata/script/bufgen_v1_local.txtar new file mode 100644 index 00000000..07d21aa4 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/bufgen_v1_local.txtar @@ -0,0 +1,37 @@ +# A v1 buf.gen.yaml: local connect plugin opt updated, version stays v1. +exec migrate +cmp stdout out.txt +exec migrate -w +cmp buf.gen.yaml want.yaml +exec go build ./... +-- doc.go -- +package app +-- buf.gen.yaml -- +version: v1 +managed: + enabled: true + go_package_prefix: + default: connect-examples-go/internal/gen +plugins: + - name: go + out: internal/gen + opt: paths=source_relative + - name: connect-go + out: internal/gen + opt: paths=source_relative +-- want.yaml -- +version: v1 +managed: + enabled: true + go_package_prefix: + default: connect-examples-go/internal/gen +plugins: + - name: go + out: internal/gen + opt: paths=source_relative + - name: connect-go + out: internal/gen + opt: paths=source_relative +-- out.txt -- +Scanned 1 Go file and 1 Buf template. No automatic rewrites were applied. +None of the scanned Go files import connectrpc.com/connect, so there is nothing to migrate. diff --git a/cmd/connect-go-v2-migrate/testdata/script/bufgen_v1_remote.txtar b/cmd/connect-go-v2-migrate/testdata/script/bufgen_v1_remote.txtar new file mode 100644 index 00000000..7f5110a5 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/bufgen_v1_remote.txtar @@ -0,0 +1,56 @@ +# A v1 buf.gen.yaml: remote connect plugin bumped to v2, version stays v1. +exec migrate +cmp stdout out.txt +exec migrate -w +cmp buf.gen.yaml want.yaml +exec go build ./... +-- doc.go -- +package app +-- buf.gen.yaml -- +version: v1 +managed: + enabled: true +plugins: + - plugin: buf.build/protocolbuffers/go + out: gen + opt: paths=source_relative + - plugin: buf.build/connectrpc/go:v1.18.1 + out: gen + opt: + - paths=source_relative + - simple=true +-- want.yaml -- +version: v1 +managed: + enabled: true +plugins: + - plugin: buf.build/protocolbuffers/go + out: gen + opt: paths=source_relative + - plugin: buf.build/connectrpc/go:v2.0.0 + out: gen + opt: + - paths=source_relative +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./buf.gen.yaml: bufgen_pin_remote_v2=1 bufgen_remove_simple=1 +--- ./buf.gen.yaml ++++ ./buf.gen.yaml +@@ -5,9 +5,8 @@ + - plugin: buf.build/protocolbuffers/go + out: gen + opt: paths=source_relative +- - plugin: buf.build/connectrpc/go:v1.18.1 ++ - plugin: buf.build/connectrpc/go:v2.0.0 + out: gen + opt: + - paths=source_relative +- - simple=true + + + +The following issues require manual code changes: + ./buf.gen.yaml:8:1: buf.build/connectrpc/go:v2.0.0 is not published yet. Until connect-go v2.0.0 is released, generate with the local plugin instead: `go install connectrpc.com/connect/v2/cmd/protoc-gen-connect-go@latest` and a `local: protoc-gen-connect-go` entry. + +Scanned 1 Go file and 1 Buf template. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/call_info_handler_context.txtar b/cmd/connect-go-v2-migrate/testdata/script/call_info_handler_context.txtar new file mode 100644 index 00000000..926f86fd --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/call_info_handler_context.txtar @@ -0,0 +1,59 @@ +# The v1 connect.CallInfoForHandlerContext renames to connect.CallInfoForServerContext. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + info, _ := connect.CallInfoForHandlerContext(ctx) + token := info.RequestHeader().Get("Authorization") + return connect.NewResponse(&pingv1.PingResponse{Text: token}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 rename_connect_core=1 result_unwrap_response=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,7 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,9 +12,9 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { +- info, _ := connect.CallInfoForHandlerContext(ctx) ++func (s *pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { ++ info, _ := connect.CallInfoForServerContext(ctx) + token := info.RequestHeader().Get("Authorization") +- return connect.NewResponse(&pingv1.PingResponse{Text: token}), nil ++ return &pingv1.PingResponse{Text: token}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_construction.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_construction.txtar new file mode 100644 index 00000000..7ae7c229 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_construction.txtar @@ -0,0 +1,53 @@ +# Client construction: NewClient/transport wrapping. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- client_construction.go -- +package example + +import ( + "net/http" + + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +func newClient() pingv1connect.PingServiceClient { + return pingv1connect.NewPingServiceClient( + http.DefaultClient, + "http://localhost:8080/", + ) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./client_construction.go: client_construction=1 import_add_connecthttp=1 import_add_connectv2=1 read_limit_pinned=1 +--- ./client_construction.go ++++ ./client_construction.go +@@ -3,13 +3,14 @@ + import ( + "net/http" + ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + + func newClient() pingv1connect.PingServiceClient { +- return pingv1connect.NewPingServiceClient( +- http.DefaultClient, +- "http://localhost:8080/", ++ return pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, ++ "http://localhost:8080/", connecthttp.WithReadMaxBytes(0))), + ) + } + + + +The following issues require manual code changes: + ./client_construction.go:10:9: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_construction_protocol.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_construction_protocol.txtar new file mode 100644 index 00000000..bbfedd7a --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_construction_protocol.txtar @@ -0,0 +1,54 @@ +# Client construction with a protocol option (WithGRPC moves to connecthttp). +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- client_construction_protocol.go -- +package example + +import ( + "net/http" + + "connectrpc.com/connect" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +// newClient passes a v1 protocol-selecting option. In v2 the protocol option +// keeps its name on the connecthttp transport, so connect.WithGRPC() becomes +// connecthttp.WithGRPC() inside connecthttp.NewTransport. +func newClient() pingv1connect.PingServiceClient { + return pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080/", connect.WithGRPC()) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./client_construction_protocol.go: client_construction=1 import_add_connecthttp=1 import_add_connectv2=1 import_drop_v1=1 option_protocol=1 read_limit_pinned=1 +--- ./client_construction_protocol.go ++++ ./client_construction_protocol.go +@@ -3,7 +3,8 @@ + import ( + "net/http" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + +@@ -11,6 +12,6 @@ + // keeps its name on the connecthttp transport, so connect.WithGRPC() becomes + // connecthttp.WithGRPC() inside connecthttp.NewTransport. + func newClient() pingv1connect.PingServiceClient { +- return pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080/", connect.WithGRPC()) ++ return pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080/", connecthttp.WithReadMaxBytes(0), connecthttp.WithGRPC()))) + } + + + +The following issues require manual code changes: + ./client_construction_protocol.go:14:9: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_construction_readlimit_var.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_construction_readlimit_var.txtar new file mode 100644 index 00000000..9a36baa2 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_construction_readlimit_var.txtar @@ -0,0 +1,49 @@ +# A read limit held in a local variable still counts as set, so the migrator +# leaves the call alone rather than pinning it to unlimited. +stubs v2 +exec migrate +cmp stdout out.txt +! stdout 'read_limit_pinned' +! stdout 'WithReadMaxBytes\(0\)' +exec migrate -w +exec go build ./... +-- readlimit_var.go -- +package app + +import ( + "net/http" + + "connectrpc.com/connect" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +func newClient() pingv1connect.PingServiceClient { + limit := connect.WithReadMaxBytes(1 << 20) + return pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080/", limit) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./readlimit_var.go: client_construction=1 import_add_connecthttp=1 import_add_connectv2=1 import_drop_v1=1 option_to_connecthttp=1 +--- ./readlimit_var.go ++++ ./readlimit_var.go +@@ -3,12 +3,13 @@ + import ( + "net/http" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + + func newClient() pingv1connect.PingServiceClient { +- limit := connect.WithReadMaxBytes(1 << 20) +- return pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080/", limit) ++ limit := connecthttp.WithReadMaxBytes(1 << 20) ++ return pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080/", limit))) + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_construction_spread.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_construction_spread.txtar new file mode 100644 index 00000000..eabfec9d --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_construction_spread.txtar @@ -0,0 +1,51 @@ +# A v1 client built from a spread option slice. The spread must move onto the +# transport, which now owns the option list; left on the outer call it would +# spread the client itself and fail to compile. The []connect.ClientOption +# parameter type still needs a manual update, so this is golden-only. +stubs v2 +exec migrate +cmp stdout out.txt +# The pin goes before the spread, so a limit inside opts is applied later and wins. +stdout 'NewTransport\(http.DefaultClient, "http://localhost:8080/", connecthttp.WithReadMaxBytes\(0\), opts...\)' +stdout 'connect.ClientOption -> connecthttp.Option' +-- spread.go -- +package app + +import ( + "net/http" + + "connectrpc.com/connect" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +func newClient(opts []connect.ClientOption) pingv1connect.PingServiceClient { + return pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080/", opts...) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./spread.go: client_construction=1 import_add_connecthttp=1 import_add_connectv2=1 import_drop_v1=1 read_limit_pinned=1 +--- ./spread.go ++++ ./spread.go +@@ -3,11 +3,12 @@ + import ( + "net/http" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + + func newClient(opts []connect.ClientOption) pingv1connect.PingServiceClient { +- return pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080/", opts...) ++ return pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080/", connecthttp.WithReadMaxBytes(0), opts...))) + } + + + +The following issues require manual code changes: + ./spread.go:10:23: connect.ClientOption -> connecthttp.Option (interceptors go to connect.NewClient, HTTP options to connecthttp.NewTransport) + ./spread.go:11:9: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. The pin goes first, ahead of options this tool cannot read into, so a limit set there still wins. + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_header.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_header.txtar new file mode 100644 index 00000000..443caf2c --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_header.txtar @@ -0,0 +1,63 @@ +# A request header set migrates to connect.NewClientContext/RequestHeader(). +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +func call(ctx context.Context, client pingv1connect.PingServiceClient, requestID string) error { + req := connect.NewRequest(&pingv1.PingRequest{Text: "hello"}) + req.Header().Set("X-Request-Id", requestID) + res, err := client.Ping(ctx, req) + if err != nil { + return err + } + _ = res.Msg.Text + return nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: body_drop_msg=1 client_context_insert=1 client_header_rewrite=1 import_add_connectv2=1 import_drop_v1=1 strip_new_request=1 +--- ./service.go ++++ ./service.go +@@ -3,19 +3,20 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + + func call(ctx context.Context, client pingv1connect.PingServiceClient, requestID string) error { +- req := connect.NewRequest(&pingv1.PingRequest{Text: "hello"}) +- req.Header().Set("X-Request-Id", requestID) ++ req := &pingv1.PingRequest{Text: "hello"} ++ ctx, info := connect.NewClientContext(ctx) ++ info.RequestHeader().Set("X-Request-Id", requestID) + res, err := client.Ping(ctx, req) + if err != nil { + return err + } +- _ = res.Msg.Text ++ _ = res.Text + return nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_header_ident_collision.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_header_ident_collision.txtar new file mode 100644 index 00000000..be9b0a53 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_header_ident_collision.txtar @@ -0,0 +1,57 @@ +# NewClientContext renames its info var to dodge an existing identifier. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +func call(ctx context.Context, client pingv1connect.PingServiceClient) error { + info := "trace" + callInfo := "span" + req := connect.NewRequest(&pingv1.PingRequest{Text: "hello"}) + req.Header().Set("X-Trace", info+callInfo) + _, err := client.Ping(ctx, req) + return err +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: client_context_insert=1 client_header_rewrite=1 import_add_connectv2=1 import_drop_v1=1 strip_new_request=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,7 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -11,8 +11,9 @@ + func call(ctx context.Context, client pingv1connect.PingServiceClient) error { + info := "trace" + callInfo := "span" +- req := connect.NewRequest(&pingv1.PingRequest{Text: "hello"}) +- req.Header().Set("X-Trace", info+callInfo) ++ req := &pingv1.PingRequest{Text: "hello"} ++ ctx, callInfo2 := connect.NewClientContext(ctx) ++ callInfo2.RequestHeader().Set("X-Trace", info+callInfo) + _, err := client.Ping(ctx, req) + return err + } + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_header_multi.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_header_multi.txtar new file mode 100644 index 00000000..1981f6dc --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_header_multi.txtar @@ -0,0 +1,61 @@ +# Two header-setting requests in one function defeat the NewClientContext +# rewrite; left for a manual update (golden-only). +stubs v2 +exec migrate +cmp stdout out.txt +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +func call(ctx context.Context, client pingv1connect.PingServiceClient) error { + reqA := connect.NewRequest(&pingv1.PingRequest{}) + reqA.Header().Set("X-Tenant", "a") + if _, err := client.Ping(ctx, reqA); err != nil { + return err + } + reqB := connect.NewRequest(&pingv1.FailRequest{}) + reqB.Header().Set("X-Tenant", "b") + _, err := client.Fail(ctx, reqB) + return err +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 strip_new_request=2 +--- ./service.go ++++ ./service.go +@@ -3,18 +3,17 @@ + import ( + "context" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + + func call(ctx context.Context, client pingv1connect.PingServiceClient) error { +- reqA := connect.NewRequest(&pingv1.PingRequest{}) ++ reqA := &pingv1.PingRequest{} + reqA.Header().Set("X-Tenant", "a") + if _, err := client.Ping(ctx, reqA); err != nil { + return err + } +- reqB := connect.NewRequest(&pingv1.FailRequest{}) ++ reqB := &pingv1.FailRequest{} + reqB.Header().Set("X-Tenant", "b") + _, err := client.Fail(ctx, reqB) + return err + + +The following issues require manual code changes: + ./service.go:13:2: reqA.Header() writes client request headers via connect.NewClientContext(ctx) and info.RequestHeader() in v2 + ./service.go:18:2: reqB.Header() writes client request headers via connect.NewClientContext(ctx) and info.RequestHeader() in v2 + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_response_helper.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_response_helper.txtar new file mode 100644 index 00000000..04f2ca78 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_response_helper.txtar @@ -0,0 +1,63 @@ +# A local helper parameter *connect.Request[T] unwraps to *T. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" +) + +type wrapper struct { + Msg string +} + +func buildWrapper(req *connect.Request[pingv1.PingRequest]) *wrapper { + return &wrapper{} +} + +func use(ctx context.Context) string { + req := connect.NewRequest(&pingv1.PingRequest{}) + w := buildWrapper(req) + return w.Msg +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 param_unwrap_request=1 strip_new_request=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,6 @@ + import ( + "context" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + ) + +@@ -11,12 +10,12 @@ + Msg string + } + +-func buildWrapper(req *connect.Request[pingv1.PingRequest]) *wrapper { ++func buildWrapper(req *pingv1.PingRequest) *wrapper { + return &wrapper{} + } + + func use(ctx context.Context) string { +- req := connect.NewRequest(&pingv1.PingRequest{}) ++ req := &pingv1.PingRequest{} + w := buildWrapper(req) + return w.Msg + } + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_stream_bidi.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_stream_bidi.txtar new file mode 100644 index 00000000..2d60ed2f --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_stream_bidi.txtar @@ -0,0 +1,101 @@ +# Bidi consumer (CumSum): single-return becomes (stream, err); CloseRequest/CloseResponse -> CloseSend/Close. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "errors" + "io" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { + return nil +} + +func runCumSum(ctx context.Context, client pingv1connect.PingServiceClient) error { + stream := client.CumSum(ctx) + for i := int64(1); i <= 3; i++ { + if err := stream.Send(&pingv1.CumSumRequest{Number: i}); err != nil { + return err + } + } + if err := stream.CloseRequest(); err != nil { + return err + } + for { + res, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + _ = res + } + return stream.CloseResponse() +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 stream_client_ctor=1 stream_close_rename=2 stream_handler_param=1 +--- ./service.go ++++ ./service.go +@@ -5,7 +5,6 @@ + "errors" + "io" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -14,18 +13,21 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { ++func (s *pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { + return nil + } + + func runCumSum(ctx context.Context, client pingv1connect.PingServiceClient) error { +- stream := client.CumSum(ctx) ++ stream, err := client.CumSum(ctx) ++ if err != nil { ++ return err ++ } + for i := int64(1); i <= 3; i++ { + if err := stream.Send(&pingv1.CumSumRequest{Number: i}); err != nil { + return err + } + } +- if err := stream.CloseRequest(); err != nil { ++ if err := stream.CloseSend(); err != nil { + return err + } + for { +@@ -38,6 +40,6 @@ + } + _ = res + } +- return stream.CloseResponse() ++ return stream.Close() + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_stream_client.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_stream_client.txtar new file mode 100644 index 00000000..d9a9e4a7 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_stream_client.txtar @@ -0,0 +1,84 @@ +# Client-streaming consumer (Sum): single-return becomes (stream, err) and res.Msg -> res. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { + return connect.NewResponse(&pingv1.SumResponse{}), nil +} + +func runSum(ctx context.Context, client pingv1connect.PingServiceClient) (*pingv1.SumResponse, error) { + stream := client.Sum(ctx) + for i := int64(1); i <= 3; i++ { + if err := stream.Send(&pingv1.SumRequest{Number: i}); err != nil { + return nil, err + } + } + res, err := stream.CloseAndReceive() + if err != nil { + return nil, err + } + return res.Msg, nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: body_drop_msg=1 import_drop_v1=1 result_unwrap_response=1 stream_client_ctor=1 stream_close_and_receive=1 stream_handler_param=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,6 @@ + import ( + "context" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,12 +11,15 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { +- return connect.NewResponse(&pingv1.SumResponse{}), nil ++func (s *pingServer) Sum(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { ++ return &pingv1.SumResponse{}, nil + } + + func runSum(ctx context.Context, client pingv1connect.PingServiceClient) (*pingv1.SumResponse, error) { +- stream := client.Sum(ctx) ++ stream, err := client.Sum(ctx) ++ if err != nil { ++ return nil, err ++ } + for i := int64(1); i <= 3; i++ { + if err := stream.Send(&pingv1.SumRequest{Number: i}); err != nil { + return nil, err +@@ -27,6 +29,6 @@ + if err != nil { + return nil, err + } +- return res.Msg, nil ++ return res, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_stream_header.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_stream_header.txtar new file mode 100644 index 00000000..2d258cbd --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_stream_header.txtar @@ -0,0 +1,109 @@ +# Client stream metadata: seeds connect.NewClientContext and rewrites the +# stream's RequestHeader/ResponseHeader/ResponseTrailer to the seeded info. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "errors" + "io" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { + return nil +} + +func runCumSum(ctx context.Context, client pingv1connect.PingServiceClient) error { + stream := client.CumSum(ctx) + stream.RequestHeader().Set("X-Tenant", "acme") + if err := stream.Send(&pingv1.CumSumRequest{Number: 1}); err != nil { + return err + } + if err := stream.CloseRequest(); err != nil { + return err + } + for { + res, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + _ = res + } + _ = stream.ResponseHeader().Get("X-Trace") + _ = stream.ResponseTrailer().Get("X-Trace") + return stream.CloseResponse() +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: client_context_insert=1 client_stream_metadata_rewrite=3 import_add_connectv2=1 import_drop_v1=1 stream_client_ctor=1 stream_close_rename=2 stream_handler_param=1 +--- ./service.go ++++ ./service.go +@@ -5,7 +5,7 @@ + "errors" + "io" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -14,17 +14,21 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { ++func (s *pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { + return nil + } + + func runCumSum(ctx context.Context, client pingv1connect.PingServiceClient) error { +- stream := client.CumSum(ctx) +- stream.RequestHeader().Set("X-Tenant", "acme") ++ ctx, info := connect.NewClientContext(ctx) ++ stream, err := client.CumSum(ctx) ++ if err != nil { ++ return err ++ } ++ info.RequestHeader().Set("X-Tenant", "acme") + if err := stream.Send(&pingv1.CumSumRequest{Number: 1}); err != nil { + return err + } +- if err := stream.CloseRequest(); err != nil { ++ if err := stream.CloseSend(); err != nil { + return err + } + for { +@@ -37,8 +41,8 @@ + } + _ = res + } +- _ = stream.ResponseHeader().Get("X-Trace") +- _ = stream.ResponseTrailer().Get("X-Trace") +- return stream.CloseResponse() ++ _ = info.ResponseHeader().Get("X-Trace") ++ _ = info.ResponseTrailer().Get("X-Trace") ++ return stream.Close() + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/client_stream_server.txtar b/cmd/connect-go-v2-migrate/testdata/script/client_stream_server.txtar new file mode 100644 index 00000000..ef3e8338 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/client_stream_server.txtar @@ -0,0 +1,80 @@ +# Server-streaming consumer: NewRequest unwraps; the bool Receive loop becomes the v2 form. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +func runCountUp(ctx context.Context, client pingv1connect.PingServiceClient) error { + stream, err := client.CountUp(ctx, connect.NewRequest(&pingv1.CountUpRequest{Number: 3})) + if err != nil { + return err + } + for stream.Receive() { + fmt.Println(stream.Msg().Number) + } + if err := stream.Err(); err != nil { + return err + } + return stream.Close() +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 stream_recv_loop=1 strip_new_request=1 +--- ./service.go ++++ ./service.go +@@ -2,24 +2,30 @@ + + import ( + "context" ++ "errors" + "fmt" ++ "io" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + + func runCountUp(ctx context.Context, client pingv1connect.PingServiceClient) error { +- stream, err := client.CountUp(ctx, connect.NewRequest(&pingv1.CountUpRequest{Number: 3})) ++ stream, err := client.CountUp(ctx, &pingv1.CountUpRequest{Number: 3}) + if err != nil { + return err + } +- for stream.Receive() { +- fmt.Println(stream.Msg().Number) +- } +- if err := stream.Err(); err != nil { +- return err ++ for { ++ msg, err := stream.Receive() ++ if err != nil { ++ if errors.Is(err, io.EOF) { ++ break ++ } ++ return err ++ } ++ fmt.Println(msg.Number) + } ++ + return stream.Close() + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/code_const.txtar b/cmd/connect-go-v2-migrate/testdata/script/code_const.txtar new file mode 100644 index 00000000..e962c638 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/code_const.txtar @@ -0,0 +1,56 @@ +# connect.Code constants and CodeOf migrate, flipping the import to v2. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { + if connect.CodeOf(ctx.Err()) == connect.CodeNotFound { + return nil, connect.NewError(connect.CodeNotFound, "missing") + } + return nil, connect.NewError(connect.CodeInternal, "internal") +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=4 convert_code_of=1 flip_residual_connect=4 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,7 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,7 +12,7 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { ++func (s *pingServer) Fail(ctx context.Context, req *pingv1.FailRequest) (*pingv1.FailResponse, error) { + if connect.CodeOf(ctx.Err()) == connect.CodeNotFound { + return nil, connect.NewError(connect.CodeNotFound, "missing") + } + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/dangling_msg.txtar b/cmd/connect-go-v2-migrate/testdata/script/dangling_msg.txtar new file mode 100644 index 00000000..33bcacf6 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/dangling_msg.txtar @@ -0,0 +1,39 @@ +# The dangling-.Msg post-pass: after toPb's return type flips to the bare +# message, the _test.go caller's got.Msg selector is stripped. +stubs v2 +exec migrate -w +exec go build ./... +! grep 'connect\.Response' pkg/helper.go +grep '\*pingv1\.PingResponse' pkg/helper.go +! grep 'got\.Msg' pkg/helper_test.go +grep 'got\.Text' pkg/helper_test.go +exec migrate +! stdout 'Proposed rewrites' +-- pkg/helper.go -- +package pkg + +import ( + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" +) + +// toPb returns the v1 response wrapper. After migration it returns the bare +// message, so callers that reach through .Msg are left dangling. +func toPb() (*connect.Response[pingv1.PingResponse], error) { + return connect.NewResponse(&pingv1.PingResponse{Text: "x"}), nil +} +-- pkg/helper_test.go -- +package pkg + +import "testing" + +func TestToPb(t *testing.T) { + got, err := toPb() + if err != nil { + t.Fatal(err) + } + if got.Msg.Text != "x" { + t.Fatalf("text = %q", got.Msg.Text) + } + _ = got.Msg +} diff --git a/cmd/connect-go-v2-migrate/testdata/script/drop_v1_import_after_codec.txtar b/cmd/connect-go-v2-migrate/testdata/script/drop_v1_import_after_codec.txtar new file mode 100644 index 00000000..85be6434 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/drop_v1_import_after_codec.txtar @@ -0,0 +1,44 @@ +# After WithCodec moves to connecthttp, the now-unused v1 connect import is dropped. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package p + +import ( + "fmt" + + "connectrpc.com/connect" +) + +func codec() { + opt := connect.WithCodec(nil) + fmt.Println(opt) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_add_connecthttp=1 import_drop_v1=1 option_to_connecthttp=1 +--- ./service.go ++++ ./service.go +@@ -3,11 +3,11 @@ + import ( + "fmt" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2/connecthttp" + ) + + func codec() { +- opt := connect.WithCodec(nil) ++ opt := connecthttp.WithCodec(nil) + fmt.Println(opt) + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/ecosystem_authn.txtar b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_authn.txtar new file mode 100644 index 00000000..2d5ec4f1 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_authn.txtar @@ -0,0 +1,52 @@ +# connectrpc.com/authn import flips to /v2 (golden-only: authn v2 module unavailable). +exec migrate +cmp stdout out.txt +-- ecosystem_authn.go -- +package example + +import ( + "context" + "net/http" + + "connectrpc.com/authn" +) + +func authenticate(_ context.Context, req *http.Request) (any, error) { + token, ok := authn.BearerToken(req) + if !ok { + return nil, authn.Errorf("missing bearer token") + } + return token, nil +} + +func wrap(mux *http.ServeMux) http.Handler { + middleware := authn.NewMiddleware(authenticate) + return middleware.Wrap(mux) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./ecosystem_authn.go: import_ecosystem_v2=1 +--- ./ecosystem_authn.go ++++ ./ecosystem_authn.go +@@ -4,7 +4,7 @@ + "context" + "net/http" + +- "connectrpc.com/authn" ++ "connectrpc.com/authn/v2" + ) + + func authenticate(_ context.Context, req *http.Request) (any, error) { + + +The following issues require manual code changes: + ./ecosystem_authn.go:19:16: authn.NewMiddleware -> authn.NewServerInterceptor(authFunc) passed to connect.NewServer. AuthFunc takes (ctx, connect.Spec, *connect.Header). See docs/v2-migration.md + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). + +go.mod is missing the v2 modules. Pull them in and tidy: + + go get -u \ + connectrpc.com/authn/v2 + go mod tidy +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/ecosystem_grpchealth.txtar b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_grpchealth.txtar new file mode 100644 index 00000000..fb72227a --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_grpchealth.txtar @@ -0,0 +1,56 @@ +# connectrpc.com/grpchealth import flips to /v2 (golden-only: grpchealth v2 unavailable). +exec migrate +cmp stdout out.txt +-- ecosystem_grpchealth.go -- +package example + +import ( + "net/http" + + "connectrpc.com/grpchealth" +) + +func run() error { + checker := grpchealth.NewStaticChecker("acme.user.v1.UserService") + mux := http.NewServeMux() + mux.Handle(grpchealth.NewHandler(checker)) + return http.ListenAndServe(":8080", mux) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./ecosystem_grpchealth.go: grpchealth_register=1 import_add_connecthttp=1 import_add_connectv2=1 import_ecosystem_v2=1 read_limit_pinned=1 +--- ./ecosystem_grpchealth.go ++++ ./ecosystem_grpchealth.go +@@ -3,13 +3,17 @@ + import ( + "net/http" + +- "connectrpc.com/grpchealth" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" ++ "connectrpc.com/grpchealth/v2" + ) + + func run() error { + checker := grpchealth.NewStaticChecker("acme.user.v1.UserService") + mux := http.NewServeMux() +- mux.Handle(grpchealth.NewHandler(checker)) ++ server := connect.NewServer() ++ grpchealth.Register(server, checker) ++ connecthttp.Mount(mux, server, connecthttp.WithReadMaxBytes(0)) + return http.ListenAndServe(":8080", mux) + } + + + +The following issues require manual code changes: + ./ecosystem_grpchealth.go:12:13: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). + +go.mod is missing the v2 modules. Pull them in and tidy: + + go get -u \ + connectrpc.com/grpchealth/v2 + go mod tidy +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/ecosystem_grpcreflect_client.txtar b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_grpcreflect_client.txtar new file mode 100644 index 00000000..a72b49e8 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_grpcreflect_client.txtar @@ -0,0 +1,48 @@ +# grpcreflect client import flips to /v2 (golden-only: grpcreflect v2 unavailable). +exec migrate +cmp stdout out.txt +-- ecosystem_grpcreflect_client.go -- +package example + +import ( + "net/http" + + "connectrpc.com/grpcreflect" +) + +func newReflectClient() *grpcreflect.Client { + return grpcreflect.NewClient(http.DefaultClient, "http://localhost:8080") +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./ecosystem_grpcreflect_client.go: grpcreflect_client=1 import_add_connecthttp=1 import_add_connectv2=1 import_ecosystem_v2=1 read_limit_pinned=1 +--- ./ecosystem_grpcreflect_client.go ++++ ./ecosystem_grpcreflect_client.go +@@ -3,10 +3,12 @@ + import ( + "net/http" + +- "connectrpc.com/grpcreflect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" ++ "connectrpc.com/grpcreflect/v2" + ) + + func newReflectClient() *grpcreflect.Client { +- return grpcreflect.NewClient(http.DefaultClient, "http://localhost:8080") ++ return grpcreflect.NewClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080", connecthttp.WithReadMaxBytes(0)))) + } + + + +The following issues require manual code changes: + ./ecosystem_grpcreflect_client.go:10:9: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). + +go.mod is missing the v2 modules. Pull them in and tidy: + + go get -u \ + connectrpc.com/grpcreflect/v2 + go mod tidy +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/ecosystem_grpcreflect_handler.txtar b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_grpcreflect_handler.txtar new file mode 100644 index 00000000..1a5425e3 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_grpcreflect_handler.txtar @@ -0,0 +1,46 @@ +# grpcreflect handler import flips to /v2 (golden-only: grpcreflect v2 unavailable). +exec migrate +cmp stdout out.txt +-- ecosystem_grpcreflect_handler.go -- +package example + +import ( + "net/http" + + "connectrpc.com/grpcreflect" +) + +func registerReflection(mux *http.ServeMux) { + reflector := grpcreflect.NewStaticReflector("acme.user.v1.UserService") + mux.Handle(grpcreflect.NewHandlerV1(reflector)) + mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector)) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./ecosystem_grpcreflect_handler.go: import_ecosystem_v2=1 +--- ./ecosystem_grpcreflect_handler.go ++++ ./ecosystem_grpcreflect_handler.go +@@ -3,7 +3,7 @@ + import ( + "net/http" + +- "connectrpc.com/grpcreflect" ++ "connectrpc.com/grpcreflect/v2" + ) + + func registerReflection(mux *http.ServeMux) { + + +The following issues require manual code changes: + ./ecosystem_grpcreflect_handler.go:10:15: grpcreflect.NewStaticReflector -> grpcreflect.Register(server) serves v1 and v1alpha and lists the server's registered services by default. See docs/v2-migration.md + ./ecosystem_grpcreflect_handler.go:11:13: grpcreflect.NewHandlerV1 -> grpcreflect.Register(server) serves v1 and v1alpha and lists the server's registered services by default. See docs/v2-migration.md + ./ecosystem_grpcreflect_handler.go:12:13: grpcreflect.NewHandlerV1Alpha -> grpcreflect.Register(server) serves v1 and v1alpha and lists the server's registered services by default. See docs/v2-migration.md + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). + +go.mod is missing the v2 modules. Pull them in and tidy: + + go get -u \ + connectrpc.com/grpcreflect/v2 + go mod tidy +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/ecosystem_otel_dup.txtar b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_otel_dup.txtar new file mode 100644 index 00000000..5ee2eae0 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_otel_dup.txtar @@ -0,0 +1,68 @@ +# Regression: otelconnect.NewInterceptor in a handler's WithInterceptors is +# warned once (by the construction pass), not duplicated by the ecosystem pass. +# Golden-only: otelconnect has no v2 build yet. +stubs v2 +exec migrate +cmp stdout out.txt +-- service.go -- +package app + +import ( + "net/http" + + "connectrpc.com/connect" + "connectrpc.com/otelconnect" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func run() error { + mux := http.NewServeMux() + mux.Handle(pingv1connect.NewPingServiceHandler(&pingServer{}, connect.WithInterceptors(otelconnect.NewInterceptor()))) + return http.ListenAndServe(":8080", mux) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_add_connecthttp=1 import_add_connectv2=1 import_drop_v1=1 import_ecosystem_v2=1 read_limit_pinned=1 server_construction=1 +--- ./service.go ++++ ./service.go +@@ -3,8 +3,9 @@ + import ( + "net/http" + +- "connectrpc.com/connect" +- "connectrpc.com/otelconnect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" ++ "connectrpc.com/otelconnect/v2" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + +@@ -14,7 +15,9 @@ + + func run() error { + mux := http.NewServeMux() +- mux.Handle(pingv1connect.NewPingServiceHandler(&pingServer{}, connect.WithInterceptors(otelconnect.NewInterceptor()))) ++ server := connect.NewServer(otelconnect.NewInterceptor()) ++ pingv1connect.RegisterPingServiceHandler(server, &pingServer{}) ++ connecthttp.Mount(mux, server, connecthttp.WithReadMaxBytes(0)) + return http.ListenAndServe(":8080", mux) + } + + + +The following issues require manual code changes: + ./service.go:17:13: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + ./service.go:17:89: otelconnect.NewInterceptor stays in the connect.NewServer(...) list unmigrated. v2 uses otelconnect.NewServerInterceptor (connectrpc.com/otelconnect/v2), which returns an error and is assigned before the constructor. + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). + +go.mod is missing the v2 modules. Pull them in and tidy: + + go get -u \ + connectrpc.com/otelconnect/v2 + go mod tidy +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/ecosystem_vanguard.txtar b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_vanguard.txtar new file mode 100644 index 00000000..5baccbef --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/ecosystem_vanguard.txtar @@ -0,0 +1,51 @@ +# connectrpc.com/vanguard import flips to /v2 (golden-only: vanguard v2 unavailable). +exec migrate +cmp stdout out.txt +-- ecosystem_vanguard.go -- +package example + +import ( + "net/http" + + "connectrpc.com/vanguard" +) + +func mount(mux *http.ServeMux, handler http.Handler) error { + services := []*vanguard.Service{ + vanguard.NewService("acme.user.v1.UserService", handler), + } + transcoder, err := vanguard.NewTranscoder(services) + if err != nil { + return err + } + mux.Handle("/", transcoder) + return nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./ecosystem_vanguard.go: import_ecosystem_v2=1 +--- ./ecosystem_vanguard.go ++++ ./ecosystem_vanguard.go +@@ -3,7 +3,7 @@ + import ( + "net/http" + +- "connectrpc.com/vanguard" ++ "connectrpc.com/vanguard/v2" + ) + + func mount(mux *http.ServeMux, handler http.Handler) error { + + +The following issues require manual code changes: + ./ecosystem_vanguard.go:11:3: vanguard.NewService -> vanguard.Mount(mux, server) mounts REST routes for registered methods with google.api.http annotations. See docs/v2-migration.md + ./ecosystem_vanguard.go:13:21: vanguard.NewTranscoder -> vanguard.Mount(mux, server) mounts REST routes for registered methods with google.api.http annotations. See docs/v2-migration.md + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). + +go.mod is missing the v2 modules. Pull them in and tidy: + + go get -u \ + connectrpc.com/vanguard/v2 + go mod tidy +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/error_detail.txtar b/cmd/connect-go-v2-migrate/testdata/script/error_detail.txtar new file mode 100644 index 00000000..16f9a25a --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/error_detail.txtar @@ -0,0 +1,52 @@ +# NewErrorDetail + AddDetail folds into the v2 WithDetail builder. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package gerrors + +import ( + "connectrpc.com/connect" + "google.golang.org/protobuf/proto" +) + +func toConnectError(code uint32, err error, info proto.Message) error { + cErr := connect.NewError(connect.Code(code), err) + if detail, detailErr := connect.NewErrorDetail(info); detailErr == nil { + cErr.AddDetail(detail) + } + return cErr +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=1 convert_new_error=1 flip_residual_connect=1 import_add_connectv2=1 import_drop_v1=1 retarget_error_detail=1 +--- ./service.go ++++ ./service.go +@@ -1,14 +1,15 @@ + package gerrors + + import ( +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connectproto" + "google.golang.org/protobuf/proto" + ) + + func toConnectError(code uint32, err error, info proto.Message) error { +- cErr := connect.NewError(connect.Code(code), err) +- if detail, detailErr := connect.NewErrorDetail(info); detailErr == nil { +- cErr.AddDetail(detail) ++ cErr := connect.NewError(connect.Code(code), err.Error()) ++ if detail, detailErr := connectproto.NewErrorDetail(info); detailErr == nil { ++ cErr = cErr.WithDetail(detail) + } + return cErr + } + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/error_detail_closure.txtar b/cmd/connect-go-v2-migrate/testdata/script/error_detail_closure.txtar new file mode 100644 index 00000000..b941b8f4 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/error_detail_closure.txtar @@ -0,0 +1,62 @@ +# Regression: collapsing a NewErrorDetail guard must preserve a sibling closure's layout. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "connectrpc.com/connect" + "google.golang.org/protobuf/proto" +) + +func run(fn func()) { + fn() +} + +func toConnectError(code uint32, err error, info proto.Message) error { + cErr := connect.NewError(connect.Code(code), err) + if detail, detailErr := connect.NewErrorDetail(info); detailErr == nil { + cErr.AddDetail(detail) + } + run(func() { + _ = info + }) + return cErr +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=1 convert_new_error=1 flip_residual_connect=1 import_add_connectv2=1 import_drop_v1=1 retarget_error_detail=1 +--- ./service.go ++++ ./service.go +@@ -1,7 +1,8 @@ + package app + + import ( +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connectproto" + "google.golang.org/protobuf/proto" + ) + +@@ -10,9 +11,9 @@ + } + + func toConnectError(code uint32, err error, info proto.Message) error { +- cErr := connect.NewError(connect.Code(code), err) +- if detail, detailErr := connect.NewErrorDetail(info); detailErr == nil { +- cErr.AddDetail(detail) ++ cErr := connect.NewError(connect.Code(code), err.Error()) ++ if detail, detailErr := connectproto.NewErrorDetail(info); detailErr == nil { ++ cErr = cErr.WithDetail(detail) + } + run(func() { + _ = info + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/error_detail_yoda.txtar b/cmd/connect-go-v2-migrate/testdata/script/error_detail_yoda.txtar new file mode 100644 index 00000000..8c0b9f1e --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/error_detail_yoda.txtar @@ -0,0 +1,52 @@ +# Regression: a NewErrorDetail guard with a Yoda `nil == err` check still collapses. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "connectrpc.com/connect" + "google.golang.org/protobuf/proto" +) + +func toConnectError(code uint32, err error, info proto.Message) error { + cErr := connect.NewError(connect.Code(code), err) + if detail, detailErr := connect.NewErrorDetail(info); nil == detailErr { + cErr.AddDetail(detail) + } + return cErr +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=1 convert_new_error=1 flip_residual_connect=1 import_add_connectv2=1 import_drop_v1=1 retarget_error_detail=1 +--- ./service.go ++++ ./service.go +@@ -1,14 +1,15 @@ + package app + + import ( +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connectproto" + "google.golang.org/protobuf/proto" + ) + + func toConnectError(code uint32, err error, info proto.Message) error { +- cErr := connect.NewError(connect.Code(code), err) +- if detail, detailErr := connect.NewErrorDetail(info); nil == detailErr { +- cErr.AddDetail(detail) ++ cErr := connect.NewError(connect.Code(code), err.Error()) ++ if detail, detailErr := connectproto.NewErrorDetail(info); nil == detailErr { ++ cErr = cErr.WithDetail(detail) + } + return cErr + } + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/error_writer.txtar b/cmd/connect-go-v2-migrate/testdata/script/error_writer.txtar new file mode 100644 index 00000000..64aef656 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/error_writer.txtar @@ -0,0 +1,56 @@ +# connect.NewErrorWriter, the connect.ErrorWriter type, and +# connect.IsNotModifiedError relocate verbatim to connecthttp, so they rewrite +# mechanically instead of warning. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- errorwriter.go -- +package p + +import ( + "net/http" + + "connectrpc.com/connect" +) + +var writer *connect.ErrorWriter = connect.NewErrorWriter(connect.WithRequireConnectProtocolHeader()) + +func handle(w http.ResponseWriter, r *http.Request, err error) { + if connect.IsNotModifiedError(err) { + w.WriteHeader(http.StatusNotModified) + return + } + if writer.IsSupported(r) { + _ = writer.Write(w, r, err) + } +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./errorwriter.go: import_add_connecthttp=1 import_drop_v1=1 option_to_connecthttp=4 +--- ./errorwriter.go ++++ ./errorwriter.go +@@ -3,13 +3,13 @@ + import ( + "net/http" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2/connecthttp" + ) + +-var writer *connect.ErrorWriter = connect.NewErrorWriter(connect.WithRequireConnectProtocolHeader()) ++var writer *connecthttp.ErrorWriter = connecthttp.NewErrorWriter(connecthttp.WithRequireConnectProtocolHeader()) + + func handle(w http.ResponseWriter, r *http.Request, err error) { +- if connect.IsNotModifiedError(err) { ++ if connecthttp.IsNotModifiedError(err) { + w.WriteHeader(http.StatusNotModified) + return + } + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/gomod_missing_v2.txtar b/cmd/connect-go-v2-migrate/testdata/script/gomod_missing_v2.txtar new file mode 100644 index 00000000..8b89b3f0 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/gomod_missing_v2.txtar @@ -0,0 +1,81 @@ +# A handwritten v1 client with no generated stubs: the second-pass report must +# prompt for the v2 modules that go.mod is missing. +exec migrate +cmp stdout out.txt +stdout 'go.mod is missing the v2 modules' +stdout 'connectrpc.com/connect/v2' +! stdout 'First, move the dependencies' +-- go.mod -- +module example.com/app + +go 1.25 + +require connectrpc.com/connect v1.18.1 + +replace connectrpc.com/connect => ./connectv1 +-- connectv1/go.mod -- +module connectrpc.com/connect + +go 1.25 +-- connectv1/connect.go -- +// Package connect is a minimal stand-in for connectrpc.com/connect v1. +package connect + +// Code is a stand-in error code. +type Code uint32 + +// CodeInternal is a stand-in code constant. +const CodeInternal Code = 13 + +// Error is a stand-in error type. +type Error struct { + code Code + err error +} + +// NewError is the v1 constructor. +func NewError(code Code, err error) *Error { return &Error{code: code, err: err} } + +func (e *Error) Error() string { return e.err.Error() } +-- client.go -- +package app + +import ( + "errors" + + "connectrpc.com/connect" +) + +func fail() error { + return connect.NewError(connect.CodeInternal, errors.New("boom")) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./client.go: convert_code_const=1 convert_new_error=1 flip_residual_connect=1 import_add_connectv2=1 import_drop_v1=1 +--- ./client.go ++++ ./client.go +@@ -1,12 +1,10 @@ + package app + + import ( +- "errors" +- +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + ) + + func fail() error { +- return connect.NewError(connect.CodeInternal, errors.New("boom")) ++ return connect.NewError(connect.CodeInternal, "boom") + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). + +go.mod is missing the v2 modules. Pull them in and tidy: + + go get -u \ + connectrpc.com/connect/v2 + go mod tidy +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/handler_closure_unwrap.txtar b/cmd/connect-go-v2-migrate/testdata/script/handler_closure_unwrap.txtar new file mode 100644 index 00000000..8041e967 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/handler_closure_unwrap.txtar @@ -0,0 +1,76 @@ +# A req.Msg access captured inside a closure unwraps after the param flips. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func runInTx(ctx context.Context, fn func(ctx context.Context) error) error { + return fn(ctx) +} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + var text string + if err := runInTx(ctx, func(ctx context.Context) error { + // req captured from outer scope. + text = req.Msg.Text + return nil + }); err != nil { + return nil, err + } + resp := &pingv1.PingResponse{Text: text} + return connect.NewResponse(resp), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: body_drop_msg=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,6 @@ + import ( + "context" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -16,16 +15,16 @@ + return fn(ctx) + } + +-func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { ++func (s *pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { + var text string + if err := runInTx(ctx, func(ctx context.Context) error { + // req captured from outer scope. +- text = req.Msg.Text ++ text = req.Text + return nil + }); err != nil { + return nil, err + } + resp := &pingv1.PingResponse{Text: text} +- return connect.NewResponse(resp), nil ++ return resp, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/handler_construction.txtar b/cmd/connect-go-v2-migrate/testdata/script/handler_construction.txtar new file mode 100644 index 00000000..51fcbc43 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/handler_construction.txtar @@ -0,0 +1,82 @@ +# Handler construction migrates; the validate interceptor module has no v2 build yet (golden-only). +stubs v2 +exec migrate +cmp stdout out.txt +-- handler_construction.go -- +package example + +import ( + "net/http" + + "connectrpc.com/connect" + "example.com/app/gen/connect/ping/v1/pingv1connect" + "connectrpc.com/validate" +) + +type PingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func run() error { + mux := http.NewServeMux() + mux.Handle( + pingv1connect.NewPingServiceHandler( + &PingServer{}, + // Validation via Protovalidate is almost always recommended. + connect.WithInterceptors(validate.NewInterceptor()), + ), + ) + return http.ListenAndServe(":8080", mux) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./handler_construction.go: import_add_connecthttp=1 import_add_connectv2=1 import_drop_v1=1 import_ecosystem_v2=1 interceptor_validate_v2=1 read_limit_pinned=1 server_construction=1 +--- ./handler_construction.go ++++ ./handler_construction.go +@@ -3,9 +3,10 @@ + import ( + "net/http" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" ++ "connectrpc.com/validate/v2" + "example.com/app/gen/connect/ping/v1/pingv1connect" +- "connectrpc.com/validate" + ) + + type PingServer struct { +@@ -14,13 +15,13 @@ + + func run() error { + mux := http.NewServeMux() +- mux.Handle( +- pingv1connect.NewPingServiceHandler( +- &PingServer{}, +- // Validation via Protovalidate is almost always recommended. +- connect.WithInterceptors(validate.NewInterceptor()), +- ), +- ) ++ server := connect.NewServer( ++ ++ // Validation via Protovalidate is almost always recommended. ++ validate.NewServerInterceptor()) ++ pingv1connect.RegisterPingServiceHandler(server, &PingServer{}) ++ connecthttp.Mount(mux, server, connecthttp.WithReadMaxBytes(0)) ++ + return http.ListenAndServe(":8080", mux) + } + + + +The following issues require manual code changes: + ./handler_construction.go:18:3: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). + +go.mod is missing the v2 modules. Pull them in and tidy: + + go get -u \ + connectrpc.com/validate/v2 + go mod tidy +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/handler_construction_group.txtar b/cmd/connect-go-v2-migrate/testdata/script/handler_construction_group.txtar new file mode 100644 index 00000000..a76a401a --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/handler_construction_group.txtar @@ -0,0 +1,69 @@ +# Grouped handler construction (golden-only: validate v2 module unavailable). +stubs v2 +exec migrate +cmp stdout out.txt +-- handler_construction_group.go -- +package example + +import ( + "net/http" + + "example.com/app/gen/connect/ping/v1/pingv1connect" + "connectrpc.com/grpchealth" +) + +type PingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func run() error { + checker := grpchealth.NewStaticChecker(pingv1connect.PingServiceName) + mux := http.NewServeMux() + mux.Handle(pingv1connect.NewPingServiceHandler(&PingServer{})) + mux.Handle(grpchealth.NewHandler(checker)) + return http.ListenAndServe(":8080", mux) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./handler_construction_group.go: grpchealth_register=1 import_add_connecthttp=1 import_add_connectv2=1 import_ecosystem_v2=1 read_limit_pinned=1 server_construction=1 +--- ./handler_construction_group.go ++++ ./handler_construction_group.go +@@ -3,8 +3,10 @@ + import ( + "net/http" + ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" ++ "connectrpc.com/grpchealth/v2" + "example.com/app/gen/connect/ping/v1/pingv1connect" +- "connectrpc.com/grpchealth" + ) + + type PingServer struct { +@@ -14,8 +16,11 @@ + func run() error { + checker := grpchealth.NewStaticChecker(pingv1connect.PingServiceName) + mux := http.NewServeMux() +- mux.Handle(pingv1connect.NewPingServiceHandler(&PingServer{})) +- mux.Handle(grpchealth.NewHandler(checker)) ++ server := connect.NewServer() ++ pingv1connect.RegisterPingServiceHandler(server, &PingServer{}) ++ grpchealth.Register(server, checker) ++ connecthttp.Mount(mux, server, connecthttp.WithReadMaxBytes(0)) ++ + return http.ListenAndServe(":8080", mux) + } + + + +The following issues require manual code changes: + ./handler_construction_group.go:17:13: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). + +go.mod is missing the v2 modules. Pull them in and tidy: + + go get -u \ + connectrpc.com/grpchealth/v2 + go mod tidy +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/handler_construction_group_split.txtar b/cmd/connect-go-v2-migrate/testdata/script/handler_construction_group_split.txtar new file mode 100644 index 00000000..dca7ed4f --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/handler_construction_group_split.txtar @@ -0,0 +1,85 @@ +# Split grouped handler construction (golden-only: validate v2 module unavailable). +stubs v2 +exec migrate +cmp stdout out.txt +-- handler_construction_group_split.go -- +package example + +import ( + "net/http" + + "connectrpc.com/connect" + "example.com/app/gen/connect/ping/v1/pingv1connect" + "connectrpc.com/grpchealth" + "connectrpc.com/validate" +) + +type PingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func run() error { + checker := grpchealth.NewStaticChecker(pingv1connect.PingServiceName) + mux := http.NewServeMux() + mux.Handle(pingv1connect.NewPingServiceHandler( + &PingServer{}, + connect.WithInterceptors(validate.NewInterceptor()), + )) + mux.Handle(grpchealth.NewHandler(checker)) + return http.ListenAndServe(":8080", mux) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./handler_construction_group_split.go: grpchealth_register=1 import_add_connecthttp=1 import_add_connectv2=1 import_drop_v1=1 import_ecosystem_v2=2 interceptor_validate_v2=1 read_limit_pinned=2 server_construction=1 +--- ./handler_construction_group_split.go ++++ ./handler_construction_group_split.go +@@ -3,10 +3,11 @@ + import ( + "net/http" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" ++ "connectrpc.com/grpchealth/v2" ++ "connectrpc.com/validate/v2" + "example.com/app/gen/connect/ping/v1/pingv1connect" +- "connectrpc.com/grpchealth" +- "connectrpc.com/validate" + ) + + type PingServer struct { +@@ -16,11 +17,13 @@ + func run() error { + checker := grpchealth.NewStaticChecker(pingv1connect.PingServiceName) + mux := http.NewServeMux() +- mux.Handle(pingv1connect.NewPingServiceHandler( +- &PingServer{}, +- connect.WithInterceptors(validate.NewInterceptor()), +- )) +- mux.Handle(grpchealth.NewHandler(checker)) ++ server := connect.NewServer(validate.NewServerInterceptor()) ++ pingv1connect.RegisterPingServiceHandler(server, &PingServer{}) ++ connecthttp.Mount(mux, server, connecthttp.WithReadMaxBytes(0)) ++ srv := connect.NewServer() ++ ++ grpchealth.Register(srv, checker) ++ connecthttp.Mount(mux, srv, connecthttp.WithReadMaxBytes(0)) + return http.ListenAndServe(":8080", mux) + } + + + +The following issues require manual code changes: + ./handler_construction_group_split.go:19:13: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + ./handler_construction_group_split.go:23:2: handlers on mux have differing options, so each group gets its own *connect.Server. v2 interceptors apply per server. + ./handler_construction_group_split.go:23:13: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). + +go.mod is missing the v2 modules. Pull them in and tidy: + + go get -u \ + connectrpc.com/grpchealth/v2 \ + connectrpc.com/validate/v2 + go mod tidy +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/handler_construction_interceptor_var.txtar b/cmd/connect-go-v2-migrate/testdata/script/handler_construction_interceptor_var.txtar new file mode 100644 index 00000000..2fc70edf --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/handler_construction_interceptor_var.txtar @@ -0,0 +1,67 @@ +# Handler construction with an interceptor bound to a var. The interceptor +# type has no v2 equivalent, so the migration is intentionally incomplete +# (manual follow-up) and the result is asserted by golden output only. +stubs v2 +exec migrate +cmp stdout out.txt +-- handler_construction_interceptor_var.go -- +package example + +import ( + "net/http" + + "connectrpc.com/connect" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type PingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +// run binds the interceptor option to a variable before constructing the +// handler. The option is still an interceptor, so it moves to connect.NewServer +// rather than connecthttp.Mount. The interceptor type itself is left for a +// manual update. +func run() error { + opt := connect.WithInterceptors(newErrorInterceptor()) + mux := http.NewServeMux() + mux.Handle(pingv1connect.NewPingServiceHandler(&PingServer{}, opt)) + return http.ListenAndServe(":8080", mux) +} + +func newErrorInterceptor() connect.Interceptor { return nil } +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./handler_construction_interceptor_var.go: import_add_connecthttp=1 import_add_connectv2=1 import_drop_v1=1 read_limit_pinned=1 server_construction=1 +--- ./handler_construction_interceptor_var.go ++++ ./handler_construction_interceptor_var.go +@@ -3,7 +3,8 @@ + import ( + "net/http" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + +@@ -18,7 +19,9 @@ + func run() error { + opt := connect.WithInterceptors(newErrorInterceptor()) + mux := http.NewServeMux() +- mux.Handle(pingv1connect.NewPingServiceHandler(&PingServer{}, opt)) ++ server := connect.NewServer(opt) ++ pingv1connect.RegisterPingServiceHandler(server, &PingServer{}) ++ connecthttp.Mount(mux, server, connecthttp.WithReadMaxBytes(0)) + return http.ListenAndServe(":8080", mux) + } + + + +The following issues require manual code changes: + ./handler_construction_interceptor_var.go:19:9: connect.WithInterceptors -> interceptors pass to connect.NewServer(...) or connect.NewClient(...). The v2 type is connect.ServerInterceptor or connect.ClientInterceptor. + ./handler_construction_interceptor_var.go:21:13: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + ./handler_construction_interceptor_var.go:21:64: interceptor opt stays in the connect.NewServer(...) list unmigrated. Its v2 type is connect.ServerInterceptor. + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/handler_unwrap.txtar b/cmd/connect-go-v2-migrate/testdata/script/handler_unwrap.txtar new file mode 100644 index 00000000..696a64bd --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/handler_unwrap.txtar @@ -0,0 +1,58 @@ +# Handler body: req.Msg field access unwraps and the response wrapper is stripped. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + text := req.Msg.Text + resp := &pingv1.PingResponse{Text: text} + return connect.NewResponse(resp), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: body_drop_msg=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,6 @@ + import ( + "context" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,9 +11,9 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { +- text := req.Msg.Text ++func (s *pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { ++ text := req.Text + resp := &pingv1.PingResponse{Text: text} +- return connect.NewResponse(resp), nil ++ return resp, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/handler_v1.txtar b/cmd/connect-go-v2-migrate/testdata/script/handler_v1.txtar new file mode 100644 index 00000000..bfcd1a2b --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/handler_v1.txtar @@ -0,0 +1,93 @@ +# v1 stubs present: the tool defers Go edits and prints regenerate-first advice. +stubs v1generic +exec migrate +cmp stdout out.txt +exec go build ./... +-- service.go -- +package app + +import ( + "context" + "errors" + "io" + "net/http" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct{} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + return connect.NewResponse(&pingv1.PingResponse{Number: req.Msg.Number, Text: req.Msg.Text}), nil +} + +func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("fail")) +} + +func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { + var sum int64 + for stream.Receive() { + sum += stream.Msg().Number + } + if err := stream.Err(); err != nil { + return nil, err + } + return connect.NewResponse(&pingv1.SumResponse{Sum: sum}), nil +} + +func (s *pingServer) CountUp(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { + for i := int64(1); i <= req.Msg.Number; i++ { + if err := stream.Send(&pingv1.CountUpResponse{Number: i}); err != nil { + return err + } + } + return nil +} + +func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { + var sum int64 + for { + req, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + sum += req.Number + if err := stream.Send(&pingv1.CumSumResponse{Sum: sum}); err != nil { + return err + } + } +} + +func setup() http.Handler { + mux := http.NewServeMux() + mux.Handle(pingv1connect.NewPingServiceHandler(&pingServer{})) + return mux +} +-- out.txt -- +Scanned 1 Go file and 0 Buf templates. The generated Connect code still +targets v1, so no Go source changes are proposed yet. + +Generated v1 Connect code: + ./gen/connect/ping/v1/pingv1connect + +First, move the dependencies and generated code to v2: + + 1. go get -u \ + connectrpc.com/connect/v2 + (pulls the v2 core, generated SDKs, and ecosystem modules into go.mod) + 2. go install connectrpc.com/connect/v2/cmd/protoc-gen-connect-go@latest + (see docs/v2-migration.md if you generate with a remote plugin or a + go.mod tool) + 3. buf generate + +Then re-run connect-go-v2-migrate to work through the Go source changes: it +rewrites the call sites against the v2 stubs and reports anything that needs +a manual update. + +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/handler_v2.txtar b/cmd/connect-go-v2-migrate/testdata/script/handler_v2.txtar new file mode 100644 index 00000000..c620c2df --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/handler_v2.txtar @@ -0,0 +1,163 @@ +# v2 stubs present: the tool rewrites the call sites against them. +stubs v2 +exec migrate +cmp stdout out.txt +# The v1-style source only compiles once rewritten to v2. +exec migrate -w +exec go build ./... +# Idempotent: a second run proposes nothing further. +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "errors" + "io" + "net/http" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct{} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + return connect.NewResponse(&pingv1.PingResponse{Number: req.Msg.Number, Text: req.Msg.Text}), nil +} + +func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("fail")) +} + +func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { + var sum int64 + for stream.Receive() { + sum += stream.Msg().Number + } + if err := stream.Err(); err != nil { + return nil, err + } + return connect.NewResponse(&pingv1.SumResponse{Sum: sum}), nil +} + +func (s *pingServer) CountUp(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { + for i := int64(1); i <= req.Msg.Number; i++ { + if err := stream.Send(&pingv1.CountUpResponse{Number: i}); err != nil { + return err + } + } + return nil +} + +func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { + var sum int64 + for { + req, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + sum += req.Number + if err := stream.Send(&pingv1.CumSumResponse{Sum: sum}); err != nil { + return err + } + } +} + +func setup() http.Handler { + mux := http.NewServeMux() + mux.Handle(pingv1connect.NewPingServiceHandler(&pingServer{})) + return mux +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: body_drop_msg=3 convert_code_const=1 convert_new_error=1 flip_residual_connect=1 import_add_connecthttp=1 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=3 read_limit_pinned=1 result_unwrap_response=3 server_construction=1 stream_handler_param=3 stream_recv_loop=1 strip_new_response=2 +--- ./service.go ++++ ./service.go +@@ -6,34 +6,40 @@ + "io" + "net/http" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + + type pingServer struct{} + +-func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { +- return connect.NewResponse(&pingv1.PingResponse{Number: req.Msg.Number, Text: req.Msg.Text}), nil ++func (s *pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { ++ return &pingv1.PingResponse{Number: req.Number, Text: req.Text}, nil + } + +-func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { +- return nil, connect.NewError(connect.CodeUnimplemented, errors.New("fail")) ++func (s *pingServer) Fail(ctx context.Context, req *pingv1.FailRequest) (*pingv1.FailResponse, error) { ++ return nil, connect.NewError(connect.CodeUnimplemented, "fail") + } + +-func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { ++func (s *pingServer) Sum(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { + var sum int64 +- for stream.Receive() { +- sum += stream.Msg().Number +- } +- if err := stream.Err(); err != nil { +- return nil, err ++ for { ++ msg, err := stream.Receive() ++ if err != nil { ++ if errors.Is(err, io.EOF) { ++ break ++ } ++ return nil, err ++ } ++ sum += msg.Number + } +- return connect.NewResponse(&pingv1.SumResponse{Sum: sum}), nil ++ ++ return &pingv1.SumResponse{Sum: sum}, nil + } + +-func (s *pingServer) CountUp(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { +- for i := int64(1); i <= req.Msg.Number; i++ { ++func (s *pingServer) CountUp(ctx context.Context, req *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { ++ for i := int64(1); i <= req.Number; i++ { + if err := stream.Send(&pingv1.CountUpResponse{Number: i}); err != nil { + return err + } +@@ -41,7 +47,7 @@ + return nil + } + +-func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { ++func (s *pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { + var sum int64 + for { + req, err := stream.Receive() +@@ -60,7 +66,9 @@ + + func setup() http.Handler { + mux := http.NewServeMux() +- mux.Handle(pingv1connect.NewPingServiceHandler(&pingServer{})) ++ server := connect.NewServer() ++ pingv1connect.RegisterPingServiceHandler(server, &pingServer{}) ++ connecthttp.Mount(mux, server, connecthttp.WithReadMaxBytes(0)) + return mux + } + + + +The following issues require manual code changes: + ./service.go:63:13: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/message_wrapper_types.txtar b/cmd/connect-go-v2-migrate/testdata/script/message_wrapper_types.txtar new file mode 100644 index 00000000..53c34e2c --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/message_wrapper_types.txtar @@ -0,0 +1,71 @@ +# Wrapper types in struct fields and function-type signatures flip to bare messages. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "testing" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" +) + +type testCase struct { + assertions func(*testing.T, *connect.Response[pingv1.PingResponse], error) + build func() *connect.Request[pingv1.PingRequest] +} + +func run(t *testing.T, check func(*testing.T, *connect.Response[pingv1.PingResponse], error)) { + var collected []*connect.Response[pingv1.PingResponse] + check(t, &connect.Response[pingv1.PingResponse]{}, nil) + _ = collected +} + +func approve(_ *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + return &connect.Response[pingv1.PingResponse]{}, nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: flip_message_wrapper=4 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 strip_response_literal=2 +--- ./service.go ++++ ./service.go +@@ -3,22 +3,21 @@ + import ( + "testing" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + ) + + type testCase struct { +- assertions func(*testing.T, *connect.Response[pingv1.PingResponse], error) +- build func() *connect.Request[pingv1.PingRequest] ++ assertions func(*testing.T, *pingv1.PingResponse, error) ++ build func() *pingv1.PingRequest + } + +-func run(t *testing.T, check func(*testing.T, *connect.Response[pingv1.PingResponse], error)) { +- var collected []*connect.Response[pingv1.PingResponse] +- check(t, &connect.Response[pingv1.PingResponse]{}, nil) ++func run(t *testing.T, check func(*testing.T, *pingv1.PingResponse, error)) { ++ var collected []*pingv1.PingResponse ++ check(t, &pingv1.PingResponse{}, nil) + _ = collected + } + +-func approve(_ *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { +- return &connect.Response[pingv1.PingResponse]{}, nil ++func approve(_ *pingv1.PingRequest) (*pingv1.PingResponse, error) { ++ return &pingv1.PingResponse{}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/mock_v1import.txtar b/cmd/connect-go-v2-migrate/testdata/script/mock_v1import.txtar new file mode 100644 index 00000000..e4a75c8f --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/mock_v1import.txtar @@ -0,0 +1,77 @@ +# A generated mock importing connect v1 is reported as a follow-up, not treated +# as a v1 stub: the consumer migrates and there is no regenerate-first deferral. +stubs v2 +exec migrate +cmp stdout out.txt +stdout 'Generated mocks still import connect v1' +! stdout 'First, move the dependencies' +exec migrate -w +exec go build ./... +-- consumer.go -- +package app + +import ( + "context" + "net/http" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +func ping(ctx context.Context) error { + client := pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080") + _, err := client.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Number: 1})) + return err +} +-- mocks/mockping/mock.go -- +// Code generated by mockery. DO NOT EDIT. + +package mockping + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" +) + +type MockPusher struct{} + +func (m *MockPusher) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + return nil, nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./consumer.go: client_construction=1 import_add_connecthttp=1 import_add_connectv2=1 import_drop_v1=1 read_limit_pinned=1 strip_new_request=1 +--- ./consumer.go ++++ ./consumer.go +@@ -4,14 +4,15 @@ + "context" + "net/http" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + + func ping(ctx context.Context) error { +- client := pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080") +- _, err := client.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Number: 1})) ++ client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080", connecthttp.WithReadMaxBytes(0)))) ++ _, err := client.Ping(ctx, &pingv1.PingRequest{Number: 1}) + return err + } + + + +The following issues require manual code changes: + ./consumer.go:13:12: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + +Generated mocks still import connect v1. Regenerate them after migrating: + example.com/app/mocks/mockping + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/nested_module.txtar b/cmd/connect-go-v2-migrate/testdata/script/nested_module.txtar new file mode 100644 index 00000000..ce06153e --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/nested_module.txtar @@ -0,0 +1,68 @@ +# No go.mod at the root with a module under child/: ./... does not cross +# module boundaries, so the tool loads no packages and points at the nested +# module instead of reporting an opaque scan. +exec migrate +cmp stdout out.txt +stdout 'No Go packages were loaded' +stdout 'does not cross module boundaries' +stdout 'cd ./child' +# Pointed at the nested module by path, it migrates as if run inside it. +exec migrate ./child +stdout 'client_construction' +! stdout 'does not cross module boundaries' +-- child/client/main.go -- +package main + +import ( + "net/http" + + "example.com/app/gen/pingv1connect" +) + +func main() { + _ = pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080/") +} +-- child/connectv2/connect.go -- +// Package connect is a minimal stand-in for connectrpc.com/connect/v2. +package connect + +type Client struct{} + +type ( + Transport interface{ transport() } + ClientInterceptor interface{ clientInterceptor() } +) + +func NewClient(Transport, ...ClientInterceptor) *Client { return &Client{} } +-- child/connectv2/go.mod -- +module connectrpc.com/connect/v2 + +go 1.25 +-- child/gen/pingv1connect/ping.connect.go -- +// Code generated by protoc-gen-connect-go. DO NOT EDIT. + +package pingv1connect + +import "connectrpc.com/connect/v2" + +type PingServiceClient struct{ client *connect.Client } + +// Deliberately the v1-shaped constructor so the fixture's hand-written call +// site is the v1 form the tool must reshape. +func NewPingServiceClient(httpClient any, baseURL string) PingServiceClient { + return PingServiceClient{} +} +-- child/go.mod -- +module example.com/app + +go 1.25 + +require connectrpc.com/connect/v2 v2.0.0 + +replace connectrpc.com/connect/v2 => ./connectv2 +-- out.txt -- +Scanned 0 Go files and 0 Buf templates. No automatic rewrites were applied. +No Go packages were loaded. "./..." does not cross module boundaries, and +each directory below has its own go.mod, so run the tool from inside them: + cd ./child && connect-go-v2-migrate + cd ./child/connectv2 && connect-go-v2-migrate diff --git a/cmd/connect-go-v2-migrate/testdata/script/new_error_already_string.txtar b/cmd/connect-go-v2-migrate/testdata/script/new_error_already_string.txtar new file mode 100644 index 00000000..b6aeb871 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/new_error_already_string.txtar @@ -0,0 +1,57 @@ +# An argument that is already a string (err.Error()) must not be double-wrapped. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { + err := ctx.Err() + if connect.CodeOf(err) == connect.CodeCanceled { + return nil, err + } + return nil, connect.NewError(connect.CodeDeadlineExceeded, err.Error()) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=3 convert_code_of=1 flip_residual_connect=3 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,7 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,7 +12,7 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { ++func (s *pingServer) Fail(ctx context.Context, req *pingv1.FailRequest) (*pingv1.FailResponse, error) { + err := ctx.Err() + if connect.CodeOf(err) == connect.CodeCanceled { + return nil, err + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/new_error_errors_new.txtar b/cmd/connect-go-v2-migrate/testdata/script/new_error_errors_new.txtar new file mode 100644 index 00000000..9155d173 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/new_error_errors_new.txtar @@ -0,0 +1,57 @@ +# connect.NewError(code, errors.New("...")) -> connect.NewError(code, "..."). +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "errors" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("forbidden")) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=1 convert_new_error=1 flip_residual_connect=1 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 +--- ./service.go ++++ ./service.go +@@ -2,9 +2,8 @@ + + import ( + "context" +- "errors" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -13,7 +12,7 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { +- return nil, connect.NewError(connect.CodePermissionDenied, errors.New("forbidden")) ++func (s *pingServer) Fail(ctx context.Context, req *pingv1.FailRequest) (*pingv1.FailResponse, error) { ++ return nil, connect.NewError(connect.CodePermissionDenied, "forbidden") + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/new_error_fmt_errorf.txtar b/cmd/connect-go-v2-migrate/testdata/script/new_error_fmt_errorf.txtar new file mode 100644 index 00000000..712dacb4 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/new_error_fmt_errorf.txtar @@ -0,0 +1,65 @@ +# Error wrapping inside a real handler: connect.NewError(code, fmt.Errorf(...)) +# becomes connect.Errorf(code, format, args...). +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { + if err := ctx.Err(); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("wrap: %s", err)) + } + return connect.NewResponse(&pingv1.FailResponse{}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=1 convert_new_error=1 flip_residual_connect=2 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -2,9 +2,8 @@ + + import ( + "context" +- "fmt" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -13,10 +12,10 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { ++func (s *pingServer) Fail(ctx context.Context, req *pingv1.FailRequest) (*pingv1.FailResponse, error) { + if err := ctx.Err(); err != nil { +- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("wrap: %s", err)) ++ return nil, connect.Errorf(connect.CodeInternal, "wrap: %s", err) + } +- return connect.NewResponse(&pingv1.FailResponse{}), nil ++ return &pingv1.FailResponse{}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/new_error_fmt_errorf_with_w.txtar b/cmd/connect-go-v2-migrate/testdata/script/new_error_fmt_errorf_with_w.txtar new file mode 100644 index 00000000..bc95d4fd --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/new_error_fmt_errorf_with_w.txtar @@ -0,0 +1,62 @@ +# fmt.Errorf with %w cannot collapse to Errorf; the message is taken via .Error(). +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { + if err := ctx.Err(); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("wrap: %w", err)) + } + return connect.NewResponse(&pingv1.FailResponse{}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=1 convert_new_error=1 flip_residual_connect=1 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -4,7 +4,7 @@ + "context" + "fmt" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -13,10 +13,10 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { ++func (s *pingServer) Fail(ctx context.Context, req *pingv1.FailRequest) (*pingv1.FailResponse, error) { + if err := ctx.Err(); err != nil { +- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("wrap: %w", err)) ++ return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("wrap: %w", err).Error()) + } +- return connect.NewResponse(&pingv1.FailResponse{}), nil ++ return &pingv1.FailResponse{}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/new_error_fmt_errorf_wrap_mid.txtar b/cmd/connect-go-v2-migrate/testdata/script/new_error_fmt_errorf_wrap_mid.txtar new file mode 100644 index 00000000..ebacbbfc --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/new_error_fmt_errorf_wrap_mid.txtar @@ -0,0 +1,64 @@ +# fmt.Errorf with a non-leading %w keeps fmt.Errorf and appends .Error(). +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { + name := "ping" + if err := ctx.Err(); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("%w occurred for %s", err, name)) + } + return connect.NewResponse(&pingv1.FailResponse{}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=1 convert_new_error=1 flip_residual_connect=1 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -4,7 +4,7 @@ + "context" + "fmt" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -13,11 +13,11 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { ++func (s *pingServer) Fail(ctx context.Context, req *pingv1.FailRequest) (*pingv1.FailResponse, error) { + name := "ping" + if err := ctx.Err(); err != nil { +- return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("%w occurred for %s", err, name)) ++ return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("%w occurred for %s", err, name).Error()) + } +- return connect.NewResponse(&pingv1.FailResponse{}), nil ++ return &pingv1.FailResponse{}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/new_error_generic.txtar b/cmd/connect-go-v2-migrate/testdata/script/new_error_generic.txtar new file mode 100644 index 00000000..27c92d23 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/new_error_generic.txtar @@ -0,0 +1,61 @@ +# A bare error argument to NewError gains .Error(). +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { + if err := ctx.Err(); err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return connect.NewResponse(&pingv1.FailResponse{}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=1 convert_new_error=1 flip_residual_connect=1 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,7 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,10 +12,10 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { ++func (s *pingServer) Fail(ctx context.Context, req *pingv1.FailRequest) (*pingv1.FailResponse, error) { + if err := ctx.Err(); err != nil { +- return nil, connect.NewError(connect.CodeInvalidArgument, err) ++ return nil, connect.NewError(connect.CodeInvalidArgument, err.Error()) + } +- return connect.NewResponse(&pingv1.FailResponse{}), nil ++ return &pingv1.FailResponse{}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/new_error_nil.txtar b/cmd/connect-go-v2-migrate/testdata/script/new_error_nil.txtar new file mode 100644 index 00000000..ba7d44ff --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/new_error_nil.txtar @@ -0,0 +1,54 @@ +# A nil error argument to NewError becomes an empty message string. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { + return nil, connect.NewError(connect.CodeInternal, nil) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=1 convert_new_error=1 flip_residual_connect=1 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,7 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,7 +12,7 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { +- return nil, connect.NewError(connect.CodeInternal, nil) ++func (s *pingServer) Fail(ctx context.Context, req *pingv1.FailRequest) (*pingv1.FailResponse, error) { ++ return nil, connect.NewError(connect.CodeInternal, "") + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/new_error_string_literal.txtar b/cmd/connect-go-v2-migrate/testdata/script/new_error_string_literal.txtar new file mode 100644 index 00000000..2d42d70f --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/new_error_string_literal.txtar @@ -0,0 +1,53 @@ +# A string-literal message must be left as-is (not wrapped in .Error()). +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { + return nil, connect.NewError(connect.CodeInternal, "handler error") +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: convert_code_const=1 flip_residual_connect=1 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,7 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,7 +12,7 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Fail(ctx context.Context, req *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { ++func (s *pingServer) Fail(ctx context.Context, req *pingv1.FailRequest) (*pingv1.FailResponse, error) { + return nil, connect.NewError(connect.CodeInternal, "handler error") + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/no_op.txtar b/cmd/connect-go-v2-migrate/testdata/script/no_op.txtar new file mode 100644 index 00000000..6bf826c9 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/no_op.txtar @@ -0,0 +1,19 @@ +# A file with no connect usage is left untouched. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package example + +import "fmt" + +func unrelated() string { + return fmt.Sprintf("no connect here") +} +-- out.txt -- +Scanned 1 Go file and 0 Buf templates. No automatic rewrites were applied. +None of the scanned Go files import connectrpc.com/connect, so there is nothing to migrate. diff --git a/cmd/connect-go-v2-migrate/testdata/script/options_all.txtar b/cmd/connect-go-v2-migrate/testdata/script/options_all.txtar new file mode 100644 index 00000000..15dd4f4c --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/options_all.txtar @@ -0,0 +1,88 @@ +# Full option coverage: every v1 client/handler option. The tool flips or +# renames the options whose v2 form is compatible (to connecthttp) and warns +# on the reshape cases whose signature changed (WithCompression, +# WithAcceptCompression, WithConditionalHandlerOptions) plus the option type +# itself. Golden-only: the warned options stay as connect.* until hand-fixed, +# so the result does not compile by design. +exec migrate +cmp stdout out.txt +-- opts.go -- +package p + +import "connectrpc.com/connect" + +func clientOpts() []connect.ClientOption { + return []connect.ClientOption{ + connect.WithGRPC(), + connect.WithGRPCWeb(), + connect.WithProtoJSON(), + connect.WithCodec(nil), + connect.WithCompression("gzip", nil, nil), + connect.WithAcceptCompression("gzip", nil, nil), + connect.WithSendGzip(), + connect.WithSendCompression("gzip"), + connect.WithReadMaxBytes(1), + connect.WithSendMaxBytes(1), + connect.WithCompressMinBytes(1), + connect.WithHTTPGet(), + connect.WithHTTPGetMaxURLSize(1, true), + connect.WithRequireConnectProtocolHeader(), + connect.WithConditionalHandlerOptions(nil), + } +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./opts.go: import_add_connecthttp=1 option_protocol=2 option_to_connecthttp=10 +--- ./opts.go ++++ ./opts.go +@@ -1,23 +1,26 @@ + package p + +-import "connectrpc.com/connect" ++import ( ++ "connectrpc.com/connect" ++ "connectrpc.com/connect/v2/connecthttp" ++) + + func clientOpts() []connect.ClientOption { + return []connect.ClientOption{ +- connect.WithGRPC(), +- connect.WithGRPCWeb(), +- connect.WithProtoJSON(), +- connect.WithCodec(nil), ++ connecthttp.WithGRPC(), ++ connecthttp.WithGRPCWeb(), ++ connecthttp.WithProtoJSON(), ++ connecthttp.WithCodec(nil), + connect.WithCompression("gzip", nil, nil), + connect.WithAcceptCompression("gzip", nil, nil), +- connect.WithSendGzip(), +- connect.WithSendCompression("gzip"), +- connect.WithReadMaxBytes(1), +- connect.WithSendMaxBytes(1), +- connect.WithCompressMinBytes(1), +- connect.WithHTTPGet(), +- connect.WithHTTPGetMaxURLSize(1, true), +- connect.WithRequireConnectProtocolHeader(), ++ connecthttp.WithSendGzip(), ++ connecthttp.WithSendCompression("gzip"), ++ connecthttp.WithReadMaxBytes(1), ++ connecthttp.WithSendMaxBytes(1), ++ connecthttp.WithCompressMinBytes(1), ++ connecthttp.WithHTTPGet(), ++ connecthttp.WithHTTPGetMaxURLSize(1, true), ++ connecthttp.WithRequireConnectProtocolHeader(), + connect.WithConditionalHandlerOptions(nil), + } + } + + +The following issues require manual code changes: + ./opts.go:3:8: connectrpc.com/connect (v1) import retained because it still uses connect.ClientOption, connect.WithAcceptCompression, connect.WithCompression, connect.WithConditionalHandlerOptions. + ./opts.go:5:21: connect.ClientOption -> connecthttp.Option (interceptors go to connect.NewClient, HTTP options to connecthttp.NewTransport) + ./opts.go:11:3: connect.WithCompression -> connecthttp.WithCompressor(connect.Compressor) (see connectgzip); the (name, decompressor, compressor) signature changed + ./opts.go:12:3: connect.WithAcceptCompression -> connecthttp.WithCompressor to register a connect.Compressor (see connectgzip), then connecthttp.WithAcceptCompression(name) to advertise it + ./opts.go:21:3: connect.WithConditionalHandlerOptions -> connecthttp.WithConditionalOptions(func(connect.Spec) []connecthttp.Option); the callback signature changed + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/options_http_only.txtar b/cmd/connect-go-v2-migrate/testdata/script/options_http_only.txtar new file mode 100644 index 00000000..9453bd02 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/options_http_only.txtar @@ -0,0 +1,39 @@ +# Client options that are all transport options move to the connecthttp package. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package p + +import "connectrpc.com/connect" + +func opts() []connect.ClientOption { + return []connect.ClientOption{connect.WithCodec(nil), connect.WithReadMaxBytes(1)} +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_add_connecthttp=1 import_drop_v1=1 option_to_connecthttp=2 option_type_to_connecthttp=1 +--- ./service.go ++++ ./service.go +@@ -1,8 +1,10 @@ + package p + +-import "connectrpc.com/connect" ++import ( ++ "connectrpc.com/connect/v2/connecthttp" ++) + +-func opts() []connect.ClientOption { +- return []connect.ClientOption{connect.WithCodec(nil), connect.WithReadMaxBytes(1)} ++func opts() []connecthttp.Option { ++ return []connecthttp.Option{connecthttp.WithCodec(nil), connecthttp.WithReadMaxBytes(1)} + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/package_split.txtar b/cmd/connect-go-v2-migrate/testdata/script/package_split.txtar new file mode 100644 index 00000000..70c1d402 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/package_split.txtar @@ -0,0 +1,61 @@ +# A whole option slice of transport options moves to []connecthttp.Option. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package p + +import ( + "connectrpc.com/connect" +) + +func options() []connect.HandlerOption { + return []connect.HandlerOption{ + connect.WithReadMaxBytes(1024), + connect.WithSendMaxBytes(2048), + connect.WithCompressMinBytes(512), + connect.WithRequireConnectProtocolHeader(), + connect.WithGRPC(), + connect.WithCodec(nil), + } +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_add_connecthttp=1 import_drop_v1=1 option_protocol=1 option_to_connecthttp=5 option_type_to_connecthttp=1 +--- ./service.go ++++ ./service.go +@@ -1,17 +1,17 @@ + package p + + import ( +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2/connecthttp" + ) + +-func options() []connect.HandlerOption { +- return []connect.HandlerOption{ +- connect.WithReadMaxBytes(1024), +- connect.WithSendMaxBytes(2048), +- connect.WithCompressMinBytes(512), +- connect.WithRequireConnectProtocolHeader(), +- connect.WithGRPC(), +- connect.WithCodec(nil), ++func options() []connecthttp.Option { ++ return []connecthttp.Option{ ++ connecthttp.WithReadMaxBytes(1024), ++ connecthttp.WithSendMaxBytes(2048), ++ connecthttp.WithCompressMinBytes(512), ++ connecthttp.WithRequireConnectProtocolHeader(), ++ connecthttp.WithGRPC(), ++ connecthttp.WithCodec(nil), + } + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/partial_stub.txtar b/cmd/connect-go-v2-migrate/testdata/script/partial_stub.txtar new file mode 100644 index 00000000..28f6ff5c --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/partial_stub.txtar @@ -0,0 +1,188 @@ +# Partial migration: consumer_a's stubs are v2 (it migrates now), consumer_b's +# are still v1 (deferred until regenerated). A dry-run phase-decision check: +# not a regenerate-first run, and consumer_b is left for later. +exec migrate +cmp stdout out.txt +stdout 'Deferred until their connect stubs are regenerated' +stdout 'strip_new_request' +! stdout 'First, move the dependencies' +-- connectv1/connect.go -- +// Package connect is a minimal stand-in for connectrpc.com/connect v1. +package connect + +import "net/http" + +type HTTPClient interface{ Do(*http.Request) (*http.Response, error) } +type ClientOption interface{ clientOption() } + +type Request[T any] struct{ Msg *T } + +func NewRequest[T any](msg *T) *Request[T] { return &Request[T]{Msg: msg} } + +type Response[T any] struct{ Msg *T } +-- connectv1/go.mod -- +module connectrpc.com/connect + +go 1.25 +-- connectv2/connect.go -- +// Package connect is a minimal stand-in for connectrpc.com/connect/v2. +package connect + +import "net/http" + +type HTTPClient interface{ Do(*http.Request) (*http.Response, error) } +type ClientOption interface{ clientOption() } +type Client struct{} + +func NewClient(HTTPClient, string, ...ClientOption) *Client { return &Client{} } +-- connectv2/go.mod -- +module connectrpc.com/connect/v2 + +go 1.25 +-- consumer_a.go -- +package app + +import ( + "context" + "net/http" + + "connectrpc.com/connect" + + "example.com/app/gen/av1" + "example.com/app/gen/av1/av1connect" +) + +// echo binds to service A, whose stub is already v2, so it migrates now. +func echo(ctx context.Context) error { + client := av1connect.NewAServiceClient(http.DefaultClient, "http://localhost") + _, err := client.Echo(ctx, connect.NewRequest(&av1.EchoRequest{Text: "hi"})) + return err +} +-- consumer_b.go -- +package app + +import ( + "context" + "net/http" + + "connectrpc.com/connect" + + "example.com/app/gen/bv1" + "example.com/app/gen/bv1/bv1connect" +) + +// ping binds to service B, whose stub is still v1, so it is deferred. +func ping(ctx context.Context) error { + client := bv1connect.NewBServiceClient(http.DefaultClient, "http://localhost") + _, err := client.Ping(ctx, connect.NewRequest(&bv1.PingRequest{N: 1})) + return err +} +-- gen/av1/av1connect/echo.connect.go -- +// Code generated by protoc-gen-connect-go. DO NOT EDIT. + +package av1connect + +import ( + "context" + + "connectrpc.com/connect/v2" + + "example.com/app/gen/av1" +) + +type AServiceClient struct{ client *connect.Client } + +func NewAServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) *AServiceClient { + return &AServiceClient{client: connect.NewClient(httpClient, baseURL, opts...)} +} + +func (c *AServiceClient) Echo(ctx context.Context, req *av1.EchoRequest) (*av1.EchoResponse, error) { + return nil, nil +} +-- gen/av1/echo.pb.go -- +// Code generated by protoc-gen-go. DO NOT EDIT. + +package av1 + +type EchoRequest struct{ Text string } +type EchoResponse struct{ Text string } +-- gen/bv1/bv1connect/ping.connect.go -- +// Code generated by protoc-gen-connect-go. DO NOT EDIT. + +package bv1connect + +import ( + "context" + + "connectrpc.com/connect" + + "example.com/app/gen/bv1" +) + +type BServiceClient struct{} + +func NewBServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) *BServiceClient { + return &BServiceClient{} +} + +func (c *BServiceClient) Ping(ctx context.Context, req *connect.Request[bv1.PingRequest]) (*connect.Response[bv1.PingResponse], error) { + return nil, nil +} +-- gen/bv1/ping.pb.go -- +// Code generated by protoc-gen-go. DO NOT EDIT. + +package bv1 + +type PingRequest struct{ N int64 } +type PingResponse struct{ N int64 } +-- go.mod -- +module example.com/app + +go 1.25 + +require ( + connectrpc.com/connect v1.18.1 + connectrpc.com/connect/v2 v2.0.0 +) + +replace connectrpc.com/connect => ./connectv1 + +replace connectrpc.com/connect/v2 => ./connectv2 +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./consumer_a.go: client_construction=1 import_add_connecthttp=1 import_add_connectv2=1 import_drop_v1=1 read_limit_pinned=1 strip_new_request=1 +--- ./consumer_a.go ++++ ./consumer_a.go +@@ -4,7 +4,8 @@ + "context" + "net/http" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" + + "example.com/app/gen/av1" + "example.com/app/gen/av1/av1connect" +@@ -12,8 +13,8 @@ + + // echo binds to service A, whose stub is already v2, so it migrates now. + func echo(ctx context.Context) error { +- client := av1connect.NewAServiceClient(http.DefaultClient, "http://localhost") +- _, err := client.Echo(ctx, connect.NewRequest(&av1.EchoRequest{Text: "hi"})) ++ client := av1connect.NewAServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, "http://localhost", connecthttp.WithReadMaxBytes(0)))) ++ _, err := client.Echo(ctx, &av1.EchoRequest{Text: "hi"}) + return err + } + + + +The following issues require manual code changes: + ./consumer_a.go:15:12: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + ./consumer_b.go:7:2: connectrpc.com/connect (v1) import retained because it still uses connect.NewRequest. + +Deferred until their connect stubs are regenerated to v2: + ./consumer_b.go:15:12: stub-dependent rewrite (handler/client signatures, .Msg, NewRequest/NewResponse, streams, construction) +Regenerate those stubs (buf generate) and re-run connect-go-v2-migrate. + +Scanned 2 Go files and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/response_header.txtar b/cmd/connect-go-v2-migrate/testdata/script/response_header.txtar new file mode 100644 index 00000000..2ca42ac0 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/response_header.txtar @@ -0,0 +1,60 @@ +# Client response-header read: seeds connect.NewClientContext and reads via +# info.ResponseHeader(). +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +func call(ctx context.Context, client pingv1connect.PingServiceClient) (string, string, error) { + res, err := client.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{})) + if err != nil { + return "", "", err + } + trace := res.Header().Get("X-Trace") + return res.Msg.Text, trace, nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: body_drop_msg=1 client_context_insert=1 client_response_metadata_rewrite=1 import_add_connectv2=1 import_drop_v1=1 strip_new_request=1 +--- ./service.go ++++ ./service.go +@@ -3,17 +3,18 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + + func call(ctx context.Context, client pingv1connect.PingServiceClient) (string, string, error) { +- res, err := client.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{})) ++ ctx, info := connect.NewClientContext(ctx) ++ res, err := client.Ping(ctx, &pingv1.PingRequest{}) + if err != nil { + return "", "", err + } +- trace := res.Header().Get("X-Trace") +- return res.Msg.Text, trace, nil ++ trace := info.ResponseHeader().Get("X-Trace") ++ return res.Text, trace, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/send_msg_channel.txtar b/cmd/connect-go-v2-migrate/testdata/script/send_msg_channel.txtar new file mode 100644 index 00000000..5c89e9b5 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/send_msg_channel.txtar @@ -0,0 +1,58 @@ +# A dangling req.Msg in a channel send is dropped to req after the param unwraps. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + out := make(chan *pingv1.PingRequest, 1) + out <- req.Msg + return connect.NewResponse(&pingv1.PingResponse{}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: body_drop_msg=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,6 @@ + import ( + "context" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,9 +11,9 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { ++func (s *pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { + out := make(chan *pingv1.PingRequest, 1) +- out <- req.Msg +- return connect.NewResponse(&pingv1.PingResponse{}), nil ++ out <- req ++ return &pingv1.PingResponse{}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/server_header.txtar b/cmd/connect-go-v2-migrate/testdata/script/server_header.txtar new file mode 100644 index 00000000..e8e4258c --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/server_header.txtar @@ -0,0 +1,58 @@ +# Handler request-header access after the request param unwraps to a bare message. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + token := req.Header().Get("Authorization") + return connect.NewResponse(&pingv1.PingResponse{Text: token}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 server_header_rewrite=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,7 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,8 +12,9 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { +- token := req.Header().Get("Authorization") +- return connect.NewResponse(&pingv1.PingResponse{Text: token}), nil ++func (s *pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { ++ info, _ := connect.CallInfoForServerContext(ctx) ++ token := info.RequestHeader().Get("Authorization") ++ return &pingv1.PingResponse{Text: token}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/server_header_blank_ctx.txtar b/cmd/connect-go-v2-migrate/testdata/script/server_header_blank_ctx.txtar new file mode 100644 index 00000000..c298c0bc --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/server_header_blank_ctx.txtar @@ -0,0 +1,56 @@ +# With a blank (_) context the request-header access cannot be rewritten to +# connect.CallInfoForServerContext(ctx); the migration is intentionally incomplete +# (golden-only). +stubs v2 +exec migrate +cmp stdout out.txt +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Ping(_ context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + token := req.Header().Get("Authorization") + return connect.NewResponse(&pingv1.PingResponse{Text: token}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,6 @@ + import ( + "context" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,8 +11,8 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Ping(_ context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { ++func (s *pingServer) Ping(_ context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { + token := req.Header().Get("Authorization") +- return connect.NewResponse(&pingv1.PingResponse{Text: token}), nil ++ return &pingv1.PingResponse{Text: token}, nil + } + + + +The following issues require manual code changes: + ./service.go:16:11: req.Header() reads request headers via the connect.CallInfoForServerContext(ctx) info's RequestHeader() in v2 + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/server_response_header.txtar b/cmd/connect-go-v2-migrate/testdata/script/server_response_header.txtar new file mode 100644 index 00000000..6ac7f019 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/server_response_header.txtar @@ -0,0 +1,63 @@ +# Handler response metadata: res.Header()/res.Trailer() move to the server CallInfo. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + res := connect.NewResponse(&pingv1.PingResponse{Text: req.Msg.Text}) + res.Header().Set("X-Server-Name", "ping-server") + res.Trailer().Set("X-Trace", "abc") + return res, nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: body_drop_msg=1 import_add_connectv2=1 import_drop_v1=1 param_unwrap_request=1 result_unwrap_response=1 server_response_metadata_rewrite=2 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,7 @@ + import ( + "context" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,10 +12,11 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { +- res := connect.NewResponse(&pingv1.PingResponse{Text: req.Msg.Text}) +- res.Header().Set("X-Server-Name", "ping-server") +- res.Trailer().Set("X-Trace", "abc") ++func (s *pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { ++ info, _ := connect.CallInfoForServerContext(ctx) ++ res := &pingv1.PingResponse{Text: req.Text} ++ info.ResponseHeader().Set("X-Server-Name", "ping-server") ++ info.ResponseTrailer().Set("X-Trace", "abc") + return res, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/server_stream_bidi.txtar b/cmd/connect-go-v2-migrate/testdata/script/server_stream_bidi.txtar new file mode 100644 index 00000000..0d64c9fa --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/server_stream_bidi.txtar @@ -0,0 +1,67 @@ +# Bidi handler (CumSum): stream param resolves to the v2 server-stream type. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "errors" + "io" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { + var sum int64 + for { + req, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + sum += req.Number + if err := stream.Send(&pingv1.CumSumResponse{Sum: sum}); err != nil { + return err + } + } +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 stream_handler_param=1 +--- ./service.go ++++ ./service.go +@@ -5,7 +5,6 @@ + "errors" + "io" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -14,7 +13,7 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { ++func (s *pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { + var sum int64 + for { + req, err := stream.Receive() + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/server_stream_client.txtar b/cmd/connect-go-v2-migrate/testdata/script/server_stream_client.txtar new file mode 100644 index 00000000..878b8581 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/server_stream_client.txtar @@ -0,0 +1,80 @@ +# Client-streaming handler (Sum): stream param resolves to v2 type; bool loop reshapes. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { + var sum int64 + for stream.Receive() { + sum += stream.Msg().Number + } + if err := stream.Err(); err != nil { + return nil, err + } + return connect.NewResponse(&pingv1.SumResponse{Sum: sum}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 result_unwrap_response=1 stream_handler_param=1 stream_recv_loop=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -2,8 +2,9 @@ + + import ( + "context" ++ "errors" ++ "io" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,14 +13,19 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { ++func (s *pingServer) Sum(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { + var sum int64 +- for stream.Receive() { +- sum += stream.Msg().Number +- } +- if err := stream.Err(); err != nil { +- return nil, err ++ for { ++ msg, err := stream.Receive() ++ if err != nil { ++ if errors.Is(err, io.EOF) { ++ break ++ } ++ return nil, err ++ } ++ sum += msg.Number + } +- return connect.NewResponse(&pingv1.SumResponse{Sum: sum}), nil ++ ++ return &pingv1.SumResponse{Sum: sum}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/server_stream_header.txtar b/cmd/connect-go-v2-migrate/testdata/script/server_stream_header.txtar new file mode 100644 index 00000000..383dda1f --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/server_stream_header.txtar @@ -0,0 +1,77 @@ +# Streaming handler metadata: stream.RequestHeader()/ResponseHeader()/ +# ResponseTrailer() move to connect.CallInfoForServerContext(ctx). +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "errors" + "io" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { + _ = stream.RequestHeader().Get("X-Tenant") + stream.ResponseHeader().Set("X-Server", "ping") + stream.ResponseTrailer().Set("X-Trace", "abc") + for { + req, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + if err := stream.Send(&pingv1.CumSumResponse{Sum: req.Number}); err != nil { + return err + } + } +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_add_connectv2=1 import_drop_v1=1 stream_handler_param=1 stream_metadata_rewrite=3 +--- ./service.go ++++ ./service.go +@@ -5,7 +5,7 @@ + "errors" + "io" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -14,10 +14,11 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { +- _ = stream.RequestHeader().Get("X-Tenant") +- stream.ResponseHeader().Set("X-Server", "ping") +- stream.ResponseTrailer().Set("X-Trace", "abc") ++func (s *pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { ++ info, _ := connect.CallInfoForServerContext(ctx) ++ _ = info.RequestHeader().Get("X-Tenant") ++ info.ResponseHeader().Set("X-Server", "ping") ++ info.ResponseTrailer().Set("X-Trace", "abc") + for { + req, err := stream.Receive() + if err != nil { + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/server_stream_server.txtar b/cmd/connect-go-v2-migrate/testdata/script/server_stream_server.txtar new file mode 100644 index 00000000..5acd4672 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/server_stream_server.txtar @@ -0,0 +1,59 @@ +# Server-streaming handler: req unwraps and the stream param resolves to the v2 type. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) CountUp(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { + for i := int64(1); i <= req.Msg.Number; i++ { + if err := stream.Send(&pingv1.CountUpResponse{Number: i}); err != nil { + return err + } + } + return nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: body_drop_msg=1 import_drop_v1=1 param_unwrap_request=1 stream_handler_param=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,6 @@ + import ( + "context" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,8 +11,8 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) CountUp(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { +- for i := int64(1); i <= req.Msg.Number; i++ { ++func (s *pingServer) CountUp(ctx context.Context, req *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { ++ for i := int64(1); i <= req.Number; i++ { + if err := stream.Send(&pingv1.CountUpResponse{Number: i}); err != nil { + return err + } + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/stream_client_comment.txtar b/cmd/connect-go-v2-migrate/testdata/script/stream_client_comment.txtar new file mode 100644 index 00000000..138b7c54 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/stream_client_comment.txtar @@ -0,0 +1,94 @@ +# A comment inside the receive loop is preserved when the loop is reshaped. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) CountUp(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { + return nil +} + +func runCountUp(ctx context.Context, client pingv1connect.PingServiceClient) error { + stream, err := client.CountUp(ctx, connect.NewRequest(&pingv1.CountUpRequest{Number: 3})) + if err != nil { + return err + } + for stream.Receive() { + // Print every event as it arrives. + fmt.Println(stream.Msg().Number) + } + return stream.Err() +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 param_unwrap_request=1 stream_err_after_loop=1 stream_handler_param=1 stream_recv_loop=1 strip_new_request=1 +--- ./service.go ++++ ./service.go +@@ -2,9 +2,10 @@ + + import ( + "context" ++ "errors" + "fmt" ++ "io" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -13,19 +14,28 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) CountUp(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { ++func (s *pingServer) CountUp(ctx context.Context, req *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { + return nil + } + + func runCountUp(ctx context.Context, client pingv1connect.PingServiceClient) error { +- stream, err := client.CountUp(ctx, connect.NewRequest(&pingv1.CountUpRequest{Number: 3})) ++ stream, err := client.CountUp(ctx, &pingv1.CountUpRequest{Number: 3}) + if err != nil { + return err + } +- for stream.Receive() { ++ var streamErr error ++ for { ++ msg, err := stream.Receive() ++ if err != nil { ++ streamErr = err ++ break ++ } + // Print every event as it arrives. +- fmt.Println(stream.Msg().Number) ++ fmt.Println(msg.Number) + } +- return stream.Err() ++ if errors.Is(streamErr, io.EOF) { ++ streamErr = nil ++ } ++ return streamErr + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/stream_client_comment_defer.txtar b/cmd/connect-go-v2-migrate/testdata/script/stream_client_comment_defer.txtar new file mode 100644 index 00000000..cb252a5e --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/stream_client_comment_defer.txtar @@ -0,0 +1,151 @@ +# A _test.go streaming consumer: client construction migrates, the receive loop +# reshapes, and `defer cancel()` plus the in-loop comment survive intact. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) CountUp(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { + return nil +} +-- service_test.go -- +package app + +import ( + "context" + "net/http" + "testing" + "time" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +func TestCountUp(t *testing.T) { + t.Run("counts", func(t *testing.T) { + cli := pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080") + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + + // Start the stream before collecting so the server is already running + // when the receive loop begins. + stream, err := cli.CountUp(ctx, connect.NewRequest(&pingv1.CountUpRequest{Number: 3})) + if err != nil { + t.Fatal(err) + } + var nums []int64 + for stream.Receive() { + // Collect every number the server streams back. + nums = append(nums, stream.Msg().Number) + } + if err := stream.Err(); err != nil { + t.Fatal(err) + } + if len(nums) == 0 { + t.Fatal("no numbers received") + } + }) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 param_unwrap_request=1 stream_handler_param=1 +--- ./service.go ++++ ./service.go +@@ -3,7 +3,6 @@ + import ( + "context" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,7 +11,7 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) CountUp(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { ++func (s *pingServer) CountUp(ctx context.Context, req *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { + return nil + } + + + ./service_test.go: client_construction=1 import_add_connecthttp=1 import_add_connectv2=1 import_drop_v1=1 read_limit_pinned=1 stream_recv_loop=1 strip_new_request=1 +--- ./service_test.go ++++ ./service_test.go +@@ -2,35 +2,43 @@ + + import ( + "context" ++ "errors" ++ "io" + "net/http" + "testing" + "time" + +- "connectrpc.com/connect" ++ "connectrpc.com/connect/v2" ++ "connectrpc.com/connect/v2/connecthttp" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + + func TestCountUp(t *testing.T) { + t.Run("counts", func(t *testing.T) { +- cli := pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080") ++ cli := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080", connecthttp.WithReadMaxBytes(0)))) + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + + // Start the stream before collecting so the server is already running + // when the receive loop begins. +- stream, err := cli.CountUp(ctx, connect.NewRequest(&pingv1.CountUpRequest{Number: 3})) ++ stream, err := cli.CountUp(ctx, &pingv1.CountUpRequest{Number: 3}) + if err != nil { + t.Fatal(err) + } + var nums []int64 +- for stream.Receive() { ++ for { ++ msg, err := stream.Receive() ++ if err != nil { ++ if errors.Is(err, io.EOF) { ++ break ++ } ++ t.Fatal(err) ++ } + // Collect every number the server streams back. +- nums = append(nums, stream.Msg().Number) +- } +- if err := stream.Err(); err != nil { +- t.Fatal(err) ++ nums = append(nums, msg.Number) + } ++ + if len(nums) == 0 { + t.Fatal("no numbers received") + } + + +The following issues require manual code changes: + ./service_test.go:16:10: v2 limits reads to 4 MiB per message where v1 allowed any size, so this call is pinned to WithReadMaxBytes(0) to keep v1 behaviour. Drop it to take the v2 default. + +Scanned 2 Go files and 0 Buf templates. 2 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/stream_client_var_decl.txtar b/cmd/connect-go-v2-migrate/testdata/script/stream_client_var_decl.txtar new file mode 100644 index 00000000..842b4ff1 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/stream_client_var_decl.txtar @@ -0,0 +1,97 @@ +# Regression: a var-declared stream constructor gains the v2 (stream, err) result. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "errors" + "io" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { + return nil +} + +func runCumSum(ctx context.Context, client pingv1connect.PingServiceClient) error { + var stream = client.CumSum(ctx) + if err := stream.Send(&pingv1.CumSumRequest{Number: 1}); err != nil { + return err + } + if err := stream.CloseRequest(); err != nil { + return err + } + for { + res, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + _ = res + } + return stream.CloseResponse() +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 stream_client_ctor=1 stream_close_rename=2 stream_handler_param=1 +--- ./service.go ++++ ./service.go +@@ -5,7 +5,6 @@ + "errors" + "io" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -14,16 +13,19 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { ++func (s *pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { + return nil + } + + func runCumSum(ctx context.Context, client pingv1connect.PingServiceClient) error { +- var stream = client.CumSum(ctx) ++ var stream, err = client.CumSum(ctx) ++ if err != nil { ++ return err ++ } + if err := stream.Send(&pingv1.CumSumRequest{Number: 1}); err != nil { + return err + } +- if err := stream.CloseRequest(); err != nil { ++ if err := stream.CloseSend(); err != nil { + return err + } + for { +@@ -36,6 +38,6 @@ + } + _ = res + } +- return stream.CloseResponse() ++ return stream.Close() + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/stream_recv_err_collision.txtar b/cmd/connect-go-v2-migrate/testdata/script/stream_recv_err_collision.txtar new file mode 100644 index 00000000..57d5627c --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/stream_recv_err_collision.txtar @@ -0,0 +1,92 @@ +# The synthesized receive must not redeclare an err the loop body already binds. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func parse(n int64) (int64, error) { + return n, nil +} + +func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { + var sum int64 + for stream.Receive() { + n, err := parse(stream.Msg().Number) + if err != nil { + return nil, err + } + sum += n + } + if err := stream.Err(); err != nil { + return nil, err + } + return connect.NewResponse(&pingv1.SumResponse{Sum: sum}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 result_unwrap_response=1 stream_handler_param=1 stream_recv_loop=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -2,8 +2,9 @@ + + import ( + "context" ++ "errors" ++ "io" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -16,18 +17,23 @@ + return n, nil + } + +-func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { ++func (s *pingServer) Sum(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { + var sum int64 +- for stream.Receive() { +- n, err := parse(stream.Msg().Number) ++ for { ++ msg, err2 := stream.Receive() ++ if err2 != nil { ++ if errors.Is(err2, io.EOF) { ++ break ++ } ++ return nil, err2 ++ } ++ n, err := parse(msg.Number) + if err != nil { + return nil, err + } + sum += n + } +- if err := stream.Err(); err != nil { +- return nil, err +- } +- return connect.NewResponse(&pingv1.SumResponse{Sum: sum}), nil ++ ++ return &pingv1.SumResponse{Sum: sum}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/stream_recv_err_nil_check.txtar b/cmd/connect-go-v2-migrate/testdata/script/stream_recv_err_nil_check.txtar new file mode 100644 index 00000000..4095b67c --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/stream_recv_err_nil_check.txtar @@ -0,0 +1,82 @@ +# A trailing stream.Err() == nil success check must not become the loop error handler. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { + var sum int64 + for stream.Receive() { + sum += stream.Msg().Number + } + if stream.Err() == nil { + sum *= 2 + } + return connect.NewResponse(&pingv1.SumResponse{Sum: sum}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 result_unwrap_response=1 stream_err_after_loop=1 stream_handler_param=1 stream_recv_loop=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -2,8 +2,9 @@ + + import ( + "context" ++ "errors" ++ "io" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -12,14 +13,23 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { ++func (s *pingServer) Sum(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { + var sum int64 +- for stream.Receive() { +- sum += stream.Msg().Number ++ var streamErr error ++ for { ++ msg, err := stream.Receive() ++ if err != nil { ++ streamErr = err ++ break ++ } ++ sum += msg.Number + } +- if stream.Err() == nil { ++ if errors.Is(streamErr, io.EOF) { ++ streamErr = nil ++ } ++ if streamErr == nil { + sum *= 2 + } +- return connect.NewResponse(&pingv1.SumResponse{Sum: sum}), nil ++ return &pingv1.SumResponse{Sum: sum}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/stream_recv_err_use.txtar b/cmd/connect-go-v2-migrate/testdata/script/stream_recv_err_use.txtar new file mode 100644 index 00000000..edec8b20 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/stream_recv_err_use.txtar @@ -0,0 +1,85 @@ +# Regression: stream.Err() referenced outside a return (a log call) must be +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + "log" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { + var sum int64 + for stream.Receive() { + sum += stream.Msg().Number + } + if stream.Err() != nil { + log.Println("sum failed:", stream.Err()) + return nil, stream.Err() + } + return connect.NewResponse(&pingv1.SumResponse{Sum: sum}), nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 result_unwrap_response=1 stream_handler_param=1 stream_recv_loop=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -2,9 +2,10 @@ + + import ( + "context" ++ "errors" ++ "io" + "log" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) +@@ -13,15 +14,20 @@ + pingv1connect.UnimplementedPingServiceHandler + } + +-func (s *pingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { ++func (s *pingServer) Sum(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { + var sum int64 +- for stream.Receive() { +- sum += stream.Msg().Number +- } +- if stream.Err() != nil { +- log.Println("sum failed:", stream.Err()) +- return nil, stream.Err() ++ for { ++ msg, err := stream.Receive() ++ if err != nil { ++ if errors.Is(err, io.EOF) { ++ break ++ } ++ log.Println("sum failed:", err) ++ return nil, err ++ } ++ sum += msg.Number + } +- return connect.NewResponse(&pingv1.SumResponse{Sum: sum}), nil ++ ++ return &pingv1.SumResponse{Sum: sum}, nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/stream_send_ctx.txtar b/cmd/connect-go-v2-migrate/testdata/script/stream_send_ctx.txtar new file mode 100644 index 00000000..eaef097f --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/stream_send_ctx.txtar @@ -0,0 +1,26 @@ +# Negative test: a non-service stream param and its Send must be left untouched. +exec migrate +cmp stdout out.txt +exec go build ./... +! stdout 'Proposed rewrites' +-- service.go -- +package p + +import ( + "context" + + "connectrpc.com/connect" +) + +type out struct{} + +func h(ctx context.Context, s *connect.ServerStream[out]) error { + return s.Send(&out{}) +} +-- out.txt -- +The following issues require manual code changes: + ./service.go:6:2: connectrpc.com/connect (v1) import retained because it still uses connect.ServerStream. + ./service.go:11:29: stream parameter "s" has v1 type connect.ServerStream. Its v2 type is the generated handler stream type for this RPC. + +Scanned 1 Go file and 0 Buf templates. No automatic rewrites were applied. +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/strip_newrequest.txtar b/cmd/connect-go-v2-migrate/testdata/script/strip_newrequest.txtar new file mode 100644 index 00000000..91ba3765 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/strip_newrequest.txtar @@ -0,0 +1,56 @@ +# Client call: connect.NewRequest(msg) -> msg, resp.Msg.Text -> resp.Text. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +func call(ctx context.Context, c pingv1connect.PingServiceClient) error { + resp, err := c.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Text: "hi"})) + if err != nil { + return err + } + _ = resp.Msg.Text + return nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: body_drop_msg=1 import_drop_v1=1 strip_new_request=1 +--- ./service.go ++++ ./service.go +@@ -3,17 +3,16 @@ + import ( + "context" + +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" + ) + + func call(ctx context.Context, c pingv1connect.PingServiceClient) error { +- resp, err := c.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Text: "hi"})) ++ resp, err := c.Ping(ctx, &pingv1.PingRequest{Text: "hi"}) + if err != nil { + return err + } +- _ = resp.Msg.Text ++ _ = resp.Text + return nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/strip_newresponse.txtar b/cmd/connect-go-v2-migrate/testdata/script/strip_newresponse.txtar new file mode 100644 index 00000000..0cf313ed --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/strip_newresponse.txtar @@ -0,0 +1,42 @@ +# Response helper: *connect.Response[T] -> *T, connect.NewResponse(m) -> m. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" +) + +func reply(text string) *connect.Response[pingv1.PingResponse] { + return connect.NewResponse(&pingv1.PingResponse{Text: text}) +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 result_unwrap_response=1 strip_new_response=1 +--- ./service.go ++++ ./service.go +@@ -1,11 +1,10 @@ + package app + + import ( +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + ) + +-func reply(text string) *connect.Response[pingv1.PingResponse] { +- return connect.NewResponse(&pingv1.PingResponse{Text: text}) ++func reply(text string) *pingv1.PingResponse { ++ return &pingv1.PingResponse{Text: text} + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/strip_response_literal.txtar b/cmd/connect-go-v2-migrate/testdata/script/strip_response_literal.txtar new file mode 100644 index 00000000..535bc9db --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/strip_response_literal.txtar @@ -0,0 +1,53 @@ +# A struct-literal &connect.Response[T]{Msg: m} unwraps to the bare message. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package app + +import ( + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" +) + +func reply(text string) (*connect.Response[pingv1.PingResponse], error) { + return &connect.Response[pingv1.PingResponse]{ + Msg: &pingv1.PingResponse{ + // TODO: return the correct value. + Text: text, + }, + }, nil +} +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_drop_v1=1 result_unwrap_response=1 strip_response_literal=1 +--- ./service.go ++++ ./service.go +@@ -1,16 +1,14 @@ + package app + + import ( +- "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + ) + +-func reply(text string) (*connect.Response[pingv1.PingResponse], error) { +- return &connect.Response[pingv1.PingResponse]{ +- Msg: &pingv1.PingResponse{ ++func reply(text string) (*pingv1.PingResponse, error) { ++ return &pingv1.PingResponse{ + // TODO: return the correct value. + Text: text, + }, +- }, nil ++ nil + } + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/testdata/script/v1gen.txtar b/cmd/connect-go-v2-migrate/testdata/script/v1gen.txtar new file mode 100644 index 00000000..693da559 --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/v1gen.txtar @@ -0,0 +1,103 @@ +# v1 local generation: v1 stubs plus a v1 buf.gen.yaml. The tool is +# regenerate-first (no Go edits), names the template update, and --json emits +# the structured report with next steps and a documentation URL. +stubs v1generic +exec migrate +cmp stdout out.txt +stdout 'Proposed Buf template updates' +stdout 'no Go source changes are proposed yet' +exec migrate --json +cmp stdout report.json +stdout 'documentation_url' +exec go build ./... +-- service.go -- +package app + +import ( + "context" + + "connectrpc.com/connect" + pingv1 "example.com/app/gen/connect/ping/v1" + "example.com/app/gen/connect/ping/v1/pingv1connect" +) + +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (s *pingServer) Ping(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + return connect.NewResponse(&pingv1.PingResponse{Number: req.Msg.Number, Text: req.Msg.Text}), nil +} +-- buf.gen.yaml -- +version: v1 +managed: + enabled: true + go_package_prefix: + default: example.com/app/gen +plugins: + - name: go + out: gen + opt: paths=source_relative + - name: connect-go + out: gen + opt: paths=source_relative,simple=true +-- out.txt -- +Scanned 1 Go file and 1 Buf template. The generated Connect code still targets +v1, so no Go source changes are proposed yet. + +Generated v1 Connect code: + ./gen/connect/ping/v1/pingv1connect + +Proposed Buf template updates (rerun with -w to apply): + ./buf.gen.yaml: bufgen_remove_simple=1 +--- ./buf.gen.yaml ++++ ./buf.gen.yaml +@@ -9,5 +9,5 @@ + opt: paths=source_relative + - name: connect-go + out: gen +- opt: paths=source_relative,simple=true ++ opt: paths=source_relative + + + +First, move the dependencies and generated code to v2: + + 1. connect-go-v2-migrate -w (applies the Buf template update above) + 2. go get -u \ + connectrpc.com/connect/v2 + (pulls the v2 core, generated SDKs, and ecosystem modules into go.mod) + 3. go install connectrpc.com/connect/v2/cmd/protoc-gen-connect-go@latest + (./buf.gen.yaml runs the local protoc-gen-connect-go binary. v1 and v2 + share the binary name, so reinstalling from the /v2 module switches + generation to v2) + 4. buf generate + +Then re-run connect-go-v2-migrate to work through the Go source changes: it +rewrites the call sites against the v2 stubs and reports anything that needs +a manual update. + +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md +-- report.json -- +{ + "summary": { + "files_scanned": 2, + "rewrites_applied": 1, + "files_needing_follow_up": 0 + }, + "diagnostics": [], + "buf_templates": [ + "./buf.gen.yaml" + ], + "ignored_paths": [ + "./gen/connect/ping/v1/pingv1connect" + ], + "next_steps": [ + "connect-go-v2-migrate -w (applies the Buf template update above)", + "go get -u \\\n connectrpc.com/connect/v2", + "go install connectrpc.com/connect/v2/cmd/protoc-gen-connect-go@latest", + "buf generate", + "re-run connect-go-v2-migrate" + ], + "documentation_url": "https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md" +} diff --git a/cmd/connect-go-v2-migrate/testdata/script/with_codec.txtar b/cmd/connect-go-v2-migrate/testdata/script/with_codec.txtar new file mode 100644 index 00000000..69c0dd2a --- /dev/null +++ b/cmd/connect-go-v2-migrate/testdata/script/with_codec.txtar @@ -0,0 +1,34 @@ +# connect.WithCodec -> connecthttp.WithCodecs; the v1 import drops for connecthttp. +stubs v2 +exec migrate +cmp stdout out.txt +exec migrate -w +exec go build ./... +exec migrate +! stdout 'Proposed rewrites' +-- service.go -- +package p + +import "connectrpc.com/connect" + +var _ = connect.WithCodec(nil) +-- out.txt -- +Proposed rewrites (rerun with -w to apply): + ./service.go: import_add_connecthttp=1 import_drop_v1=1 option_to_connecthttp=1 +--- ./service.go ++++ ./service.go +@@ -1,6 +1,8 @@ + package p + +-import "connectrpc.com/connect" ++import ( ++ "connectrpc.com/connect/v2/connecthttp" ++) + +-var _ = connect.WithCodec(nil) ++var _ = connecthttp.WithCodec(nil) + + + +Scanned 1 Go file and 0 Buf templates. 1 rewrite(s) ready (rerun with -w to apply). +Full migration guide: https://github.com/connectrpc/connect-go/blob/main/docs/v2-migration.md diff --git a/cmd/connect-go-v2-migrate/walk.go b/cmd/connect-go-v2-migrate/walk.go new file mode 100644 index 00000000..350f9e9e --- /dev/null +++ b/cmd/connect-go-v2-migrate/walk.go @@ -0,0 +1,135 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "io/fs" + "os" + "path/filepath" + "strings" +) + +// walkProject calls visit for every file that survives pruning of hidden +// trees, vendor/node_modules/testdata, and .gitignore'd directories. Per-entry +// read errors are skipped; an error reading root itself is returned. +func walkProject(root string, visit func(path string) error) error { + var rules []ignoreRule + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + if path == root { + return err + } + return nil // skip unreadable sub-entries + } + if !entry.IsDir() { + return visit(path) + } + if path != root && skipWalkDir(entry.Name()) { + return filepath.SkipDir + } + abs, absErr := filepath.Abs(path) + if absErr != nil { + return nil //nolint:nilerr // can't resolve the path; walk past it rather than abort + } + if isIgnoredDir(abs, rules) { + return filepath.SkipDir + } + // WalkDir is pre-order, so a directory's .gitignore rules are in place + // before its children are visited. + rules = appendGitignore(rules, abs) + return nil + }) +} + +// skipWalkDir reports whether a directory should be pruned by name: hidden +// directories and the well-known dependency/fixture trees. +func skipWalkDir(name string) bool { + if strings.HasPrefix(name, ".") { + return true + } + switch name { + case "vendor", "node_modules", "testdata": + return true + } + return false +} + +// ignoreRule is one directory-pruning pattern from a .gitignore, scoped to the +// directory the file lived in. +type ignoreRule struct { + base string // absolute directory the .gitignore was read from + pattern string // cleaned pattern, slash-separated, no leading/trailing slash + anchored bool // pattern is relative to base (had a leading or internal slash) +} + +// appendGitignore reads dir/.gitignore and appends its directory-pruning rules. +// Comments, blanks, negations, and globs are skipped: the walk only needs to +// avoid descending into ignored trees, never to match files. +func appendGitignore(rules []ignoreRule, dir string) []ignoreRule { + content, err := os.ReadFile(filepath.Join(dir, ".gitignore")) + if err != nil { + return rules + } + for line := range strings.SplitSeq(string(content), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "!") { + continue + } + pattern := strings.TrimSuffix(line, "/") + if strings.ContainsAny(pattern, "*?[") { + continue // globs match files more often than dirs, so skip for safety + } + // A leading or internal slash anchors the pattern to base. A bare name + // (optionally with a trailing slash) matches at any depth. + anchored := strings.Contains(pattern, "/") + pattern = strings.Trim(pattern, "/") + if pattern == "" { + continue + } + rules = append(rules, ignoreRule{base: dir, pattern: filepath.ToSlash(pattern), anchored: anchored}) + } + return rules +} + +// isIgnoredDir reports whether dir matches any gitignore rule in scope. +func isIgnoredDir(dir string, rules []ignoreRule) bool { + for _, rule := range rules { + rel, err := filepath.Rel(rule.base, dir) + if err != nil { + continue + } + rel = filepath.ToSlash(rel) + if rel == "." || strings.HasPrefix(rel, "../") { + continue // dir is not within the rule's scope + } + if rule.anchored { + if rel == rule.pattern { + return true + } + continue + } + if lastSegment(rel) == rule.pattern { + return true + } + } + return false +} + +func lastSegment(path string) string { + if index := strings.LastIndex(path, "/"); index >= 0 { + return path[index+1:] + } + return path +} diff --git a/cmd/connect-go-v2-migrate/walk_test.go b/cmd/connect-go-v2-migrate/walk_test.go new file mode 100644 index 00000000..792eab90 --- /dev/null +++ b/cmd/connect-go-v2-migrate/walk_test.go @@ -0,0 +1,106 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "path/filepath" + "slices" + "sort" + "testing" +) + +// TestSkipWalkDir covers the by-name directory pruning: hidden ".dir" trees and +// the well-known dependency and fixture directories are skipped, real source +// directories are not. +func TestSkipWalkDir(t *testing.T) { + t.Parallel() + skip := []string{".git", ".github", ".idea", ".vscode", "vendor", "node_modules", "testdata"} + keep := []string{"pkg", "internal", "cmd", "gen", "proto"} + for _, name := range skip { + if !skipWalkDir(name) { + t.Errorf("skipWalkDir(%q) = false, want true", name) + } + } + for _, name := range keep { + if skipWalkDir(name) { + t.Errorf("skipWalkDir(%q) = true, want false", name) + } + } +} + +// TestWalkProject verifies that the walk visits files in real source +// directories and prunes hidden, dependency, fixture, and gitignored trees. +func TestWalkProject(t *testing.T) { + t.Parallel() + root := t.TempDir() + // .gitignore prunes a bare name (any depth) and an anchored path. + writeFile(t, filepath.Join(root, ".gitignore"), "build\n/dist\n*.log\n") + for _, rel := range []string{ + "buf.gen.yaml", // visited + "src/buf.gen.yaml", // visited + "src/build/buf.gen.yaml", // pruned: gitignore "build" at any depth + "dist/buf.gen.yaml", // pruned: gitignore "/dist" anchored at root + ".github/workflows/ci.yaml", // pruned: hidden + "node_modules/p/buf.gen.yaml", // pruned: dependency tree + "testdata/buf.gen.yaml", // pruned: fixture tree + } { + writeFile(t, filepath.Join(root, rel), "x\n") + } + + var visited []string + if err := walkProject(root, func(path string) error { + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + visited = append(visited, filepath.ToSlash(rel)) + return nil + }); err != nil { + t.Fatal(err) + } + sort.Strings(visited) + + want := []string{".gitignore", "buf.gen.yaml", "src/buf.gen.yaml"} + if !slices.Equal(visited, want) { + t.Errorf("walkProject visited %v, want %v", visited, want) + } +} + +// TestIsIgnoredDir covers anchored vs floating gitignore directory patterns and +// scoping to the directory the .gitignore lived in. +func TestIsIgnoredDir(t *testing.T) { + t.Parallel() + base := filepath.FromSlash("/repo") + rules := []ignoreRule{ + {base: base, pattern: "build", anchored: false}, // any depth + {base: base, pattern: "dist", anchored: true}, // only /repo/dist + } + tests := []struct { + dir string + want bool + }{ + {dir: "/repo/build", want: true}, + {dir: "/repo/src/build", want: true}, // floating matches at depth + {dir: "/repo/dist", want: true}, // anchored matches at root + {dir: "/repo/src/dist", want: false}, // anchored does not match at depth + {dir: "/repo/src", want: false}, + {dir: "/other/build", want: false}, // outside the rule's scope + } + for _, test := range tests { + if got := isIgnoredDir(filepath.FromSlash(test.dir), rules); got != test.want { + t.Errorf("isIgnoredDir(%q) = %v, want %v", test.dir, got, test.want) + } + } +} diff --git a/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/buf.gen.yaml b/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/buf.gen.yaml index 940d473b..40740d65 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/buf.gen.yaml +++ b/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/buf.gen.yaml @@ -3,7 +3,7 @@ managed: enabled: true override: - file_option: go_package_prefix - value: connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen + value: connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen plugins: - local: protoc-gen-go out: gen diff --git a/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/defaultpackage.pb.go b/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/defaultpackage.pb.go index 957a2637..9fadd194 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/defaultpackage.pb.go +++ b/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/defaultpackage.pb.go @@ -116,8 +116,8 @@ const file_defaultpackage_proto_rawDesc = "" + "\n" + "\bResponse2h\n" + "\vTestService\x12Y\n" + - "\x06Method\x12%.connect.test.default_package.Request\x1a&.connect.test.default_package.Response\"\x00B\x9c\x02\n" + - " com.connect.test.default_packageB\x13DefaultpackageProtoP\x01ZUconnectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen\xa2\x02\x03CTD\xaa\x02\x1bConnect.Test.DefaultPackage\xca\x02\x1bConnect\\Test\\DefaultPackage\xe2\x02'Connect\\Test\\DefaultPackage\\GPBMetadata\xea\x02\x1dConnect::Test::DefaultPackageb\x06proto3" + "\x06Method\x12%.connect.test.default_package.Request\x1a&.connect.test.default_package.Response\"\x00B\x9f\x02\n" + + " com.connect.test.default_packageB\x13DefaultpackageProtoP\x01ZXconnectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen\xa2\x02\x03CTD\xaa\x02\x1bConnect.Test.DefaultPackage\xca\x02\x1bConnect\\Test\\DefaultPackage\xe2\x02'Connect\\Test\\DefaultPackage\\GPBMetadata\xea\x02\x1dConnect::Test::DefaultPackageb\x06proto3" var ( file_defaultpackage_proto_rawDescOnce sync.Once diff --git a/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/genconnect/defaultpackage.connect.go b/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/genconnect/defaultpackage.connect.go index f18cd825..22ed602d 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/genconnect/defaultpackage.connect.go +++ b/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/genconnect/defaultpackage.connect.go @@ -19,104 +19,93 @@ package genconnect import ( - connect "connectrpc.com/connect" - gen "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen" + connect "connectrpc.com/connect/v2" + gen "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen" context "context" - errors "errors" - http "net/http" - strings "strings" + sync "sync" ) -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - const ( // TestServiceName is the fully-qualified name of the TestService service. TestServiceName = "connect.test.default_package.TestService" ) -// These constants are the fully-qualified names of the RPCs defined in this package. They're -// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// These constants are the procedure names of the RPCs defined in this package. They're exposed at +// runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( - // TestServiceMethodProcedure is the fully-qualified name of the TestService's Method RPC. + // TestServiceMethodProcedure is the procedure name of the TestService's Method RPC. TestServiceMethodProcedure = "/connect.test.default_package.TestService/Method" ) +var ( + testServiceMethodSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: gen.File_defaultpackage_proto.Services().ByName("TestService").Methods().ByName("Method"), + Procedure: TestServiceMethodProcedure, + } + }) +) + // TestServiceClient is a client for the connect.test.default_package.TestService service. type TestServiceClient interface { - Method(context.Context, *connect.Request[gen.Request]) (*connect.Response[gen.Response], error) + Method(context.Context, *gen.Request) (*gen.Response, error) } // NewTestServiceClient constructs a client for the connect.test.default_package.TestService -// service. By default, it uses the Connect protocol with the binary Protobuf Codec, asks for -// gzipped responses, and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply -// the connect.WithGRPC() or connect.WithGRPCWeb() options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewTestServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) TestServiceClient { - baseURL = strings.TrimRight(baseURL, "/") - testServiceMethods := gen.File_defaultpackage_proto.Services().ByName("TestService").Methods() - return &testServiceClient{ - method: connect.NewClient[gen.Request, gen.Response]( - httpClient, - baseURL+TestServiceMethodProcedure, - connect.WithSchema(testServiceMethods.ByName("Method")), - connect.WithClientOptions(opts...), - ), - } -} - -// testServiceClient implements TestServiceClient. -type testServiceClient struct { - method *connect.Client[gen.Request, gen.Response] -} - -// Method calls connect.test.default_package.TestService.Method. -func (c *testServiceClient) Method(ctx context.Context, req *connect.Request[gen.Request]) (*connect.Response[gen.Response], error) { - return c.method.CallUnary(ctx, req) +// service. Multiple service clients may share a single connect.Client. +func NewTestServiceClient(client *connect.Client) TestServiceClient { + return &testServiceClient{client: client} } // TestServiceHandler is an implementation of the connect.test.default_package.TestService service. type TestServiceHandler interface { - Method(context.Context, *connect.Request[gen.Request]) (*connect.Response[gen.Response], error) + Method(context.Context, *gen.Request) (*gen.Response, error) } -// NewTestServiceHandler builds an HTTP handler from the service implementation. It returns the path -// on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewTestServiceHandler(svc TestServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - testServiceMethods := gen.File_defaultpackage_proto.Services().ByName("TestService").Methods() - testServiceMethodHandler := connect.NewUnaryHandler( - TestServiceMethodProcedure, - svc.Method, - connect.WithSchema(testServiceMethods.ByName("Method")), - connect.WithHandlerOptions(opts...), +// RegisterTestServiceHandler registers svc as the connect.test.default_package.TestService +// implementation on server. +func RegisterTestServiceHandler(server *connect.Server, svc TestServiceHandler) { + adapter := testServiceHandler{svc: svc} + server.Register( + connect.Method{Spec: testServiceMethodSpec(), Handler: adapter.method}, ) - return "/connect.test.default_package.TestService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case TestServiceMethodProcedure: - testServiceMethodHandler.ServeHTTP(w, r) - default: - http.NotFound(w, r) - } - }) } // UnimplementedTestServiceHandler returns CodeUnimplemented from all methods. type UnimplementedTestServiceHandler struct{} -func (UnimplementedTestServiceHandler) Method(context.Context, *connect.Request[gen.Request]) (*connect.Response[gen.Response], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("connect.test.default_package.TestService.Method is not implemented")) +func (UnimplementedTestServiceHandler) Method(context.Context, *gen.Request) (*gen.Response, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connect.test.default_package.TestService.Method is not implemented") +} + +type testServiceClient struct { + client *connect.Client +} + +func (c *testServiceClient) Method(ctx context.Context, req *gen.Request) (*gen.Response, error) { + var res gen.Response + if err := c.client.CallUnary(ctx, testServiceMethodSpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +type testServiceHandler struct{ svc TestServiceHandler } + +func (h testServiceHandler) method(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req gen.Request + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.Method(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) } diff --git a/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/buf.gen.yaml b/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/buf.gen.yaml index 5452c8c1..16bfc5a9 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/buf.gen.yaml +++ b/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/buf.gen.yaml @@ -3,7 +3,7 @@ managed: enabled: true override: - file_option: go_package_prefix - value: connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen + value: connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen plugins: - local: protoc-gen-go out: gen diff --git a/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/diffpackage.pb.go b/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/diffpackage.pb.go index ab76a3dc..84b14bbe 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/diffpackage.pb.go +++ b/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/diffpackage.pb.go @@ -116,8 +116,8 @@ const file_diffpackage_proto_rawDesc = "" + "\n" + "\bResponse2l\n" + "\vTestService\x12]\n" + - "\x06Method\x12'.connect.test.different_package.Request\x1a(.connect.test.different_package.Response\"\x00B\xa0\x02\n" + - "\"com.connect.test.different_packageB\x10DiffpackageProtoP\x01ZRconnectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen\xa2\x02\x03CTD\xaa\x02\x1dConnect.Test.DifferentPackage\xca\x02\x1dConnect\\Test\\DifferentPackage\xe2\x02)Connect\\Test\\DifferentPackage\\GPBMetadata\xea\x02\x1fConnect::Test::DifferentPackageb\x06proto3" + "\x06Method\x12'.connect.test.different_package.Request\x1a(.connect.test.different_package.Response\"\x00B\xa3\x02\n" + + "\"com.connect.test.different_packageB\x10DiffpackageProtoP\x01ZUconnectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen\xa2\x02\x03CTD\xaa\x02\x1dConnect.Test.DifferentPackage\xca\x02\x1dConnect\\Test\\DifferentPackage\xe2\x02)Connect\\Test\\DifferentPackage\\GPBMetadata\xea\x02\x1fConnect::Test::DifferentPackageb\x06proto3" var ( file_diffpackage_proto_rawDescOnce sync.Once diff --git a/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/gendiff/diffpackage.connect.go b/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/gendiff/diffpackage.connect.go index b3389307..431fdf01 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/gendiff/diffpackage.connect.go +++ b/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/gendiff/diffpackage.connect.go @@ -19,105 +19,94 @@ package gendiff import ( - connect "connectrpc.com/connect" - gen "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen" + connect "connectrpc.com/connect/v2" + gen "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen" context "context" - errors "errors" - http "net/http" - strings "strings" + sync "sync" ) -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - const ( // TestServiceName is the fully-qualified name of the TestService service. TestServiceName = "connect.test.different_package.TestService" ) -// These constants are the fully-qualified names of the RPCs defined in this package. They're -// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// These constants are the procedure names of the RPCs defined in this package. They're exposed at +// runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( - // TestServiceMethodProcedure is the fully-qualified name of the TestService's Method RPC. + // TestServiceMethodProcedure is the procedure name of the TestService's Method RPC. TestServiceMethodProcedure = "/connect.test.different_package.TestService/Method" ) +var ( + testServiceMethodSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: gen.File_diffpackage_proto.Services().ByName("TestService").Methods().ByName("Method"), + Procedure: TestServiceMethodProcedure, + } + }) +) + // TestServiceClient is a client for the connect.test.different_package.TestService service. type TestServiceClient interface { - Method(context.Context, *connect.Request[gen.Request]) (*connect.Response[gen.Response], error) + Method(context.Context, *gen.Request) (*gen.Response, error) } // NewTestServiceClient constructs a client for the connect.test.different_package.TestService -// service. By default, it uses the Connect protocol with the binary Protobuf Codec, asks for -// gzipped responses, and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply -// the connect.WithGRPC() or connect.WithGRPCWeb() options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewTestServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) TestServiceClient { - baseURL = strings.TrimRight(baseURL, "/") - testServiceMethods := gen.File_diffpackage_proto.Services().ByName("TestService").Methods() - return &testServiceClient{ - method: connect.NewClient[gen.Request, gen.Response]( - httpClient, - baseURL+TestServiceMethodProcedure, - connect.WithSchema(testServiceMethods.ByName("Method")), - connect.WithClientOptions(opts...), - ), - } -} - -// testServiceClient implements TestServiceClient. -type testServiceClient struct { - method *connect.Client[gen.Request, gen.Response] -} - -// Method calls connect.test.different_package.TestService.Method. -func (c *testServiceClient) Method(ctx context.Context, req *connect.Request[gen.Request]) (*connect.Response[gen.Response], error) { - return c.method.CallUnary(ctx, req) +// service. Multiple service clients may share a single connect.Client. +func NewTestServiceClient(client *connect.Client) TestServiceClient { + return &testServiceClient{client: client} } // TestServiceHandler is an implementation of the connect.test.different_package.TestService // service. type TestServiceHandler interface { - Method(context.Context, *connect.Request[gen.Request]) (*connect.Response[gen.Response], error) + Method(context.Context, *gen.Request) (*gen.Response, error) } -// NewTestServiceHandler builds an HTTP handler from the service implementation. It returns the path -// on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewTestServiceHandler(svc TestServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - testServiceMethods := gen.File_diffpackage_proto.Services().ByName("TestService").Methods() - testServiceMethodHandler := connect.NewUnaryHandler( - TestServiceMethodProcedure, - svc.Method, - connect.WithSchema(testServiceMethods.ByName("Method")), - connect.WithHandlerOptions(opts...), +// RegisterTestServiceHandler registers svc as the connect.test.different_package.TestService +// implementation on server. +func RegisterTestServiceHandler(server *connect.Server, svc TestServiceHandler) { + adapter := testServiceHandler{svc: svc} + server.Register( + connect.Method{Spec: testServiceMethodSpec(), Handler: adapter.method}, ) - return "/connect.test.different_package.TestService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case TestServiceMethodProcedure: - testServiceMethodHandler.ServeHTTP(w, r) - default: - http.NotFound(w, r) - } - }) } // UnimplementedTestServiceHandler returns CodeUnimplemented from all methods. type UnimplementedTestServiceHandler struct{} -func (UnimplementedTestServiceHandler) Method(context.Context, *connect.Request[gen.Request]) (*connect.Response[gen.Response], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("connect.test.different_package.TestService.Method is not implemented")) +func (UnimplementedTestServiceHandler) Method(context.Context, *gen.Request) (*gen.Response, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connect.test.different_package.TestService.Method is not implemented") +} + +type testServiceClient struct { + client *connect.Client +} + +func (c *testServiceClient) Method(ctx context.Context, req *gen.Request) (*gen.Response, error) { + var res gen.Response + if err := c.client.CallUnary(ctx, testServiceMethodSpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +type testServiceHandler struct{ svc TestServiceHandler } + +func (h testServiceHandler) method(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req gen.Request + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.Method(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) } diff --git a/cmd/protoc-gen-connect-go/internal/testdata/noservice/buf.gen.yaml b/cmd/protoc-gen-connect-go/internal/testdata/noservice/buf.gen.yaml index 7f1f4d6e..9f692cdc 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/noservice/buf.gen.yaml +++ b/cmd/protoc-gen-connect-go/internal/testdata/noservice/buf.gen.yaml @@ -3,7 +3,7 @@ managed: enabled: true override: - file_option: go_package_prefix - value: connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/noservice/gen + value: connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/noservice/gen plugins: - local: protoc-gen-go out: gen diff --git a/cmd/protoc-gen-connect-go/internal/testdata/noservice/gen/noservice.pb.go b/cmd/protoc-gen-connect-go/internal/testdata/noservice/gen/noservice.pb.go index d6b9e7d9..be4866f6 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/noservice/gen/noservice.pb.go +++ b/cmd/protoc-gen-connect-go/internal/testdata/noservice/gen/noservice.pb.go @@ -114,8 +114,8 @@ const file_noservice_proto_rawDesc = "" + "\x0fnoservice.proto\x12\x17connect.test.no_service\"\t\n" + "\aRequest\"\n" + "\n" + - "\bResponseB\xf9\x01\n" + - "\x1bcom.connect.test.no_serviceB\x0eNoserviceProtoP\x01ZPconnectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/noservice/gen\xa2\x02\x03CTN\xaa\x02\x16Connect.Test.NoService\xca\x02\x16Connect\\Test\\NoService\xe2\x02\"Connect\\Test\\NoService\\GPBMetadata\xea\x02\x18Connect::Test::NoServiceb\x06proto3" + "\bResponseB\xfc\x01\n" + + "\x1bcom.connect.test.no_serviceB\x0eNoserviceProtoP\x01ZSconnectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/noservice/gen\xa2\x02\x03CTN\xaa\x02\x16Connect.Test.NoService\xca\x02\x16Connect\\Test\\NoService\xe2\x02\"Connect\\Test\\NoService\\GPBMetadata\xea\x02\x18Connect::Test::NoServiceb\x06proto3" var ( file_noservice_proto_rawDescOnce sync.Once diff --git a/cmd/protoc-gen-connect-go/internal/testdata/samepackage/buf.gen.yaml b/cmd/protoc-gen-connect-go/internal/testdata/samepackage/buf.gen.yaml index 7dc35769..c672143d 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/samepackage/buf.gen.yaml +++ b/cmd/protoc-gen-connect-go/internal/testdata/samepackage/buf.gen.yaml @@ -3,7 +3,7 @@ managed: enabled: true override: - file_option: go_package_prefix - value: connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen + value: connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen plugins: - local: protoc-gen-go out: gen diff --git a/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen/samepackage.connect.go b/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen/samepackage.connect.go index 7a16d192..fa9d3754 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen/samepackage.connect.go +++ b/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen/samepackage.connect.go @@ -19,103 +19,92 @@ package gen import ( - connect "connectrpc.com/connect" + connect "connectrpc.com/connect/v2" context "context" - errors "errors" - http "net/http" - strings "strings" + sync "sync" ) -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - const ( // TestServiceName is the fully-qualified name of the TestService service. TestServiceName = "connect.test.same_package.TestService" ) -// These constants are the fully-qualified names of the RPCs defined in this package. They're -// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// These constants are the procedure names of the RPCs defined in this package. They're exposed at +// runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( - // TestServiceMethodProcedure is the fully-qualified name of the TestService's Method RPC. + // TestServiceMethodProcedure is the procedure name of the TestService's Method RPC. TestServiceMethodProcedure = "/connect.test.same_package.TestService/Method" ) +var ( + testServiceMethodSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: File_samepackage_proto.Services().ByName("TestService").Methods().ByName("Method"), + Procedure: TestServiceMethodProcedure, + } + }) +) + // TestServiceClient is a client for the connect.test.same_package.TestService service. type TestServiceClient interface { - Method(context.Context, *connect.Request[Request]) (*connect.Response[Response], error) + Method(context.Context, *Request) (*Response, error) } // NewTestServiceClient constructs a client for the connect.test.same_package.TestService service. -// By default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped -// responses, and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the -// connect.WithGRPC() or connect.WithGRPCWeb() options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewTestServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) TestServiceClient { - baseURL = strings.TrimRight(baseURL, "/") - testServiceMethods := File_samepackage_proto.Services().ByName("TestService").Methods() - return &testServiceClient{ - method: connect.NewClient[Request, Response]( - httpClient, - baseURL+TestServiceMethodProcedure, - connect.WithSchema(testServiceMethods.ByName("Method")), - connect.WithClientOptions(opts...), - ), - } -} - -// testServiceClient implements TestServiceClient. -type testServiceClient struct { - method *connect.Client[Request, Response] -} - -// Method calls connect.test.same_package.TestService.Method. -func (c *testServiceClient) Method(ctx context.Context, req *connect.Request[Request]) (*connect.Response[Response], error) { - return c.method.CallUnary(ctx, req) +// Multiple service clients may share a single connect.Client. +func NewTestServiceClient(client *connect.Client) TestServiceClient { + return &testServiceClient{client: client} } // TestServiceHandler is an implementation of the connect.test.same_package.TestService service. type TestServiceHandler interface { - Method(context.Context, *connect.Request[Request]) (*connect.Response[Response], error) + Method(context.Context, *Request) (*Response, error) } -// NewTestServiceHandler builds an HTTP handler from the service implementation. It returns the path -// on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewTestServiceHandler(svc TestServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - testServiceMethods := File_samepackage_proto.Services().ByName("TestService").Methods() - testServiceMethodHandler := connect.NewUnaryHandler( - TestServiceMethodProcedure, - svc.Method, - connect.WithSchema(testServiceMethods.ByName("Method")), - connect.WithHandlerOptions(opts...), +// RegisterTestServiceHandler registers svc as the connect.test.same_package.TestService +// implementation on server. +func RegisterTestServiceHandler(server *connect.Server, svc TestServiceHandler) { + adapter := testServiceHandler{svc: svc} + server.Register( + connect.Method{Spec: testServiceMethodSpec(), Handler: adapter.method}, ) - return "/connect.test.same_package.TestService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case TestServiceMethodProcedure: - testServiceMethodHandler.ServeHTTP(w, r) - default: - http.NotFound(w, r) - } - }) } // UnimplementedTestServiceHandler returns CodeUnimplemented from all methods. type UnimplementedTestServiceHandler struct{} -func (UnimplementedTestServiceHandler) Method(context.Context, *connect.Request[Request]) (*connect.Response[Response], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("connect.test.same_package.TestService.Method is not implemented")) +func (UnimplementedTestServiceHandler) Method(context.Context, *Request) (*Response, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connect.test.same_package.TestService.Method is not implemented") +} + +type testServiceClient struct { + client *connect.Client +} + +func (c *testServiceClient) Method(ctx context.Context, req *Request) (*Response, error) { + var res Response + if err := c.client.CallUnary(ctx, testServiceMethodSpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +type testServiceHandler struct{ svc TestServiceHandler } + +func (h testServiceHandler) method(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req Request + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.Method(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) } diff --git a/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen/samepackage.pb.go b/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen/samepackage.pb.go index 0d931553..9c390411 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen/samepackage.pb.go +++ b/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen/samepackage.pb.go @@ -116,8 +116,8 @@ const file_samepackage_proto_rawDesc = "" + "\n" + "\bResponse2b\n" + "\vTestService\x12S\n" + - "\x06Method\x12\".connect.test.same_package.Request\x1a#.connect.test.same_package.Response\"\x00B\x87\x02\n" + - "\x1dcom.connect.test.same_packageB\x10SamepackageProtoP\x01ZRconnectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen\xa2\x02\x03CTS\xaa\x02\x18Connect.Test.SamePackage\xca\x02\x18Connect\\Test\\SamePackage\xe2\x02$Connect\\Test\\SamePackage\\GPBMetadata\xea\x02\x1aConnect::Test::SamePackageb\x06proto3" + "\x06Method\x12\".connect.test.same_package.Request\x1a#.connect.test.same_package.Response\"\x00B\x8a\x02\n" + + "\x1dcom.connect.test.same_packageB\x10SamepackageProtoP\x01ZUconnectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen\xa2\x02\x03CTS\xaa\x02\x18Connect.Test.SamePackage\xca\x02\x18Connect\\Test\\SamePackage\xe2\x02$Connect\\Test\\SamePackage\\GPBMetadata\xea\x02\x1aConnect::Test::SamePackageb\x06proto3" var ( file_samepackage_proto_rawDescOnce sync.Once diff --git a/cmd/protoc-gen-connect-go/internal/testdata/simple/gen/genconnect/simple.connect.go b/cmd/protoc-gen-connect-go/internal/testdata/simple/gen/genconnect/simple.connect.go deleted file mode 100644 index 87bc7925..00000000 --- a/cmd/protoc-gen-connect-go/internal/testdata/simple/gen/genconnect/simple.connect.go +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Code generated by protoc-gen-connect-go. DO NOT EDIT. -// -// Source: simple.proto - -package genconnect - -import ( - connect "connectrpc.com/connect" - gen "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/simple/gen" - context "context" - errors "errors" - http "net/http" - strings "strings" -) - -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - -const ( - // TestServiceName is the fully-qualified name of the TestService service. - TestServiceName = "connect.test.simple.TestService" -) - -// These constants are the fully-qualified names of the RPCs defined in this package. They're -// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. -// -// Note that these are different from the fully-qualified method names used by -// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to -// reflection-formatted method names, remove the leading slash and convert the remaining slash to a -// period. -const ( - // TestServiceMethodProcedure is the fully-qualified name of the TestService's Method RPC. - TestServiceMethodProcedure = "/connect.test.simple.TestService/Method" - // TestServiceMethodClientStreamProcedure is the fully-qualified name of the TestService's - // MethodClientStream RPC. - TestServiceMethodClientStreamProcedure = "/connect.test.simple.TestService/MethodClientStream" - // TestServiceMethodServerStreamProcedure is the fully-qualified name of the TestService's - // MethodServerStream RPC. - TestServiceMethodServerStreamProcedure = "/connect.test.simple.TestService/MethodServerStream" - // TestServiceMethodBidiStreamProcedure is the fully-qualified name of the TestService's - // MethodBidiStream RPC. - TestServiceMethodBidiStreamProcedure = "/connect.test.simple.TestService/MethodBidiStream" -) - -// TestServiceClient is a client for the connect.test.simple.TestService service. -type TestServiceClient interface { - Method(context.Context, *gen.Request) (*gen.Response, error) - MethodClientStream(context.Context) (*connect.ClientStreamForClientSimple[gen.Request, gen.Response], error) - MethodServerStream(context.Context, *gen.Request) (*connect.ServerStreamForClient[gen.Response], error) - MethodBidiStream(context.Context, *gen.Request) (*connect.ServerStreamForClient[gen.Response], error) -} - -// NewTestServiceClient constructs a client for the connect.test.simple.TestService service. By -// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, -// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the -// connect.WithGRPC() or connect.WithGRPCWeb() options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewTestServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) TestServiceClient { - baseURL = strings.TrimRight(baseURL, "/") - testServiceMethods := gen.File_simple_proto.Services().ByName("TestService").Methods() - return &testServiceClient{ - method: connect.NewClient[gen.Request, gen.Response]( - httpClient, - baseURL+TestServiceMethodProcedure, - connect.WithSchema(testServiceMethods.ByName("Method")), - connect.WithClientOptions(opts...), - ), - methodClientStream: connect.NewClient[gen.Request, gen.Response]( - httpClient, - baseURL+TestServiceMethodClientStreamProcedure, - connect.WithSchema(testServiceMethods.ByName("MethodClientStream")), - connect.WithClientOptions(opts...), - ), - methodServerStream: connect.NewClient[gen.Request, gen.Response]( - httpClient, - baseURL+TestServiceMethodServerStreamProcedure, - connect.WithSchema(testServiceMethods.ByName("MethodServerStream")), - connect.WithClientOptions(opts...), - ), - methodBidiStream: connect.NewClient[gen.Request, gen.Response]( - httpClient, - baseURL+TestServiceMethodBidiStreamProcedure, - connect.WithSchema(testServiceMethods.ByName("MethodBidiStream")), - connect.WithClientOptions(opts...), - ), - } -} - -// testServiceClient implements TestServiceClient. -type testServiceClient struct { - method *connect.Client[gen.Request, gen.Response] - methodClientStream *connect.Client[gen.Request, gen.Response] - methodServerStream *connect.Client[gen.Request, gen.Response] - methodBidiStream *connect.Client[gen.Request, gen.Response] -} - -// Method calls connect.test.simple.TestService.Method. -func (c *testServiceClient) Method(ctx context.Context, req *gen.Request) (*gen.Response, error) { - response, err := c.method.CallUnary(ctx, connect.NewRequest(req)) - if response != nil { - return response.Msg, err - } - return nil, err -} - -// MethodClientStream calls connect.test.simple.TestService.MethodClientStream. -func (c *testServiceClient) MethodClientStream(ctx context.Context) (*connect.ClientStreamForClientSimple[gen.Request, gen.Response], error) { - return c.methodClientStream.CallClientStreamSimple(ctx) -} - -// MethodServerStream calls connect.test.simple.TestService.MethodServerStream. -func (c *testServiceClient) MethodServerStream(ctx context.Context, req *gen.Request) (*connect.ServerStreamForClient[gen.Response], error) { - return c.methodServerStream.CallServerStream(ctx, connect.NewRequest(req)) -} - -// MethodBidiStream calls connect.test.simple.TestService.MethodBidiStream. -func (c *testServiceClient) MethodBidiStream(ctx context.Context, req *gen.Request) (*connect.ServerStreamForClient[gen.Response], error) { - return c.methodBidiStream.CallServerStream(ctx, connect.NewRequest(req)) -} - -// TestServiceHandler is an implementation of the connect.test.simple.TestService service. -type TestServiceHandler interface { - Method(context.Context, *gen.Request) (*gen.Response, error) - MethodClientStream(context.Context, *connect.ClientStream[gen.Request]) (*gen.Response, error) - MethodServerStream(context.Context, *gen.Request, *connect.ServerStream[gen.Response]) error - MethodBidiStream(context.Context, *gen.Request, *connect.ServerStream[gen.Response]) error -} - -// NewTestServiceHandler builds an HTTP handler from the service implementation. It returns the path -// on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewTestServiceHandler(svc TestServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - testServiceMethods := gen.File_simple_proto.Services().ByName("TestService").Methods() - testServiceMethodHandler := connect.NewUnaryHandlerSimple( - TestServiceMethodProcedure, - svc.Method, - connect.WithSchema(testServiceMethods.ByName("Method")), - connect.WithHandlerOptions(opts...), - ) - testServiceMethodClientStreamHandler := connect.NewClientStreamHandlerSimple( - TestServiceMethodClientStreamProcedure, - svc.MethodClientStream, - connect.WithSchema(testServiceMethods.ByName("MethodClientStream")), - connect.WithHandlerOptions(opts...), - ) - testServiceMethodServerStreamHandler := connect.NewServerStreamHandlerSimple( - TestServiceMethodServerStreamProcedure, - svc.MethodServerStream, - connect.WithSchema(testServiceMethods.ByName("MethodServerStream")), - connect.WithHandlerOptions(opts...), - ) - testServiceMethodBidiStreamHandler := connect.NewServerStreamHandlerSimple( - TestServiceMethodBidiStreamProcedure, - svc.MethodBidiStream, - connect.WithSchema(testServiceMethods.ByName("MethodBidiStream")), - connect.WithHandlerOptions(opts...), - ) - return "/connect.test.simple.TestService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case TestServiceMethodProcedure: - testServiceMethodHandler.ServeHTTP(w, r) - case TestServiceMethodClientStreamProcedure: - testServiceMethodClientStreamHandler.ServeHTTP(w, r) - case TestServiceMethodServerStreamProcedure: - testServiceMethodServerStreamHandler.ServeHTTP(w, r) - case TestServiceMethodBidiStreamProcedure: - testServiceMethodBidiStreamHandler.ServeHTTP(w, r) - default: - http.NotFound(w, r) - } - }) -} - -// UnimplementedTestServiceHandler returns CodeUnimplemented from all methods. -type UnimplementedTestServiceHandler struct{} - -func (UnimplementedTestServiceHandler) Method(context.Context, *gen.Request) (*gen.Response, error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("connect.test.simple.TestService.Method is not implemented")) -} - -func (UnimplementedTestServiceHandler) MethodClientStream(context.Context, *connect.ClientStream[gen.Request]) (*gen.Response, error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("connect.test.simple.TestService.MethodClientStream is not implemented")) -} - -func (UnimplementedTestServiceHandler) MethodServerStream(context.Context, *gen.Request, *connect.ServerStream[gen.Response]) error { - return connect.NewError(connect.CodeUnimplemented, errors.New("connect.test.simple.TestService.MethodServerStream is not implemented")) -} - -func (UnimplementedTestServiceHandler) MethodBidiStream(context.Context, *gen.Request, *connect.ServerStream[gen.Response]) error { - return connect.NewError(connect.CodeUnimplemented, errors.New("connect.test.simple.TestService.MethodBidiStream is not implemented")) -} diff --git a/cmd/protoc-gen-connect-go/internal/testdata/simple/gen/simple.pb.go b/cmd/protoc-gen-connect-go/internal/testdata/simple/gen/simple.pb.go deleted file mode 100644 index 9da2293f..00000000 --- a/cmd/protoc-gen-connect-go/internal/testdata/simple/gen/simple.pb.go +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: simple.proto - -package gen - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type Request struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Request) Reset() { - *x = Request{} - mi := &file_simple_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Request) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Request) ProtoMessage() {} - -func (x *Request) ProtoReflect() protoreflect.Message { - mi := &file_simple_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Request.ProtoReflect.Descriptor instead. -func (*Request) Descriptor() ([]byte, []int) { - return file_simple_proto_rawDescGZIP(), []int{0} -} - -type Response struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Response) Reset() { - *x = Response{} - mi := &file_simple_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Response) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Response) ProtoMessage() {} - -func (x *Response) ProtoReflect() protoreflect.Message { - mi := &file_simple_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Response.ProtoReflect.Descriptor instead. -func (*Response) Descriptor() ([]byte, []int) { - return file_simple_proto_rawDescGZIP(), []int{1} -} - -var File_simple_proto protoreflect.FileDescriptor - -const file_simple_proto_rawDesc = "" + - "\n" + - "\fsimple.proto\x12\x13connect.test.simple\"\t\n" + - "\aRequest\"\n" + - "\n" + - "\bResponse2\xd9\x02\n" + - "\vTestService\x12G\n" + - "\x06Method\x12\x1c.connect.test.simple.Request\x1a\x1d.connect.test.simple.Response\"\x00\x12U\n" + - "\x12MethodClientStream\x12\x1c.connect.test.simple.Request\x1a\x1d.connect.test.simple.Response\"\x00(\x01\x12U\n" + - "\x12MethodServerStream\x12\x1c.connect.test.simple.Request\x1a\x1d.connect.test.simple.Response\"\x000\x01\x12S\n" + - "\x10MethodBidiStream\x12\x1c.connect.test.simple.Request\x1a\x1d.connect.test.simple.Response\"\x000\x01B\xe3\x01\n" + - "\x17com.connect.test.simpleB\vSimpleProtoP\x01ZMconnectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/simple/gen\xa2\x02\x03CTS\xaa\x02\x13Connect.Test.Simple\xca\x02\x13Connect\\Test\\Simple\xe2\x02\x1fConnect\\Test\\Simple\\GPBMetadata\xea\x02\x15Connect::Test::Simpleb\x06proto3" - -var ( - file_simple_proto_rawDescOnce sync.Once - file_simple_proto_rawDescData []byte -) - -func file_simple_proto_rawDescGZIP() []byte { - file_simple_proto_rawDescOnce.Do(func() { - file_simple_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_simple_proto_rawDesc), len(file_simple_proto_rawDesc))) - }) - return file_simple_proto_rawDescData -} - -var file_simple_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_simple_proto_goTypes = []any{ - (*Request)(nil), // 0: connect.test.simple.Request - (*Response)(nil), // 1: connect.test.simple.Response -} -var file_simple_proto_depIdxs = []int32{ - 0, // 0: connect.test.simple.TestService.Method:input_type -> connect.test.simple.Request - 0, // 1: connect.test.simple.TestService.MethodClientStream:input_type -> connect.test.simple.Request - 0, // 2: connect.test.simple.TestService.MethodServerStream:input_type -> connect.test.simple.Request - 0, // 3: connect.test.simple.TestService.MethodBidiStream:input_type -> connect.test.simple.Request - 1, // 4: connect.test.simple.TestService.Method:output_type -> connect.test.simple.Response - 1, // 5: connect.test.simple.TestService.MethodClientStream:output_type -> connect.test.simple.Response - 1, // 6: connect.test.simple.TestService.MethodServerStream:output_type -> connect.test.simple.Response - 1, // 7: connect.test.simple.TestService.MethodBidiStream:output_type -> connect.test.simple.Response - 4, // [4:8] is the sub-list for method output_type - 0, // [0:4] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_simple_proto_init() } -func file_simple_proto_init() { - if File_simple_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_simple_proto_rawDesc), len(file_simple_proto_rawDesc)), - NumEnums: 0, - NumMessages: 2, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_simple_proto_goTypes, - DependencyIndexes: file_simple_proto_depIdxs, - MessageInfos: file_simple_proto_msgTypes, - }.Build() - File_simple_proto = out.File - file_simple_proto_goTypes = nil - file_simple_proto_depIdxs = nil -} diff --git a/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/buf.gen.yaml b/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/buf.gen.yaml index 208c03c2..bda896e1 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/buf.gen.yaml +++ b/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/buf.gen.yaml @@ -3,7 +3,7 @@ managed: enabled: true override: - file_option: go_package_prefix - value: connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen + value: connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen plugins: - local: protoc-gen-go out: gen diff --git a/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen/v1beta1service.connect.go b/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen/v1beta1service.connect.go index 37142516..5907bf25 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen/v1beta1service.connect.go +++ b/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen/v1beta1service.connect.go @@ -21,103 +21,91 @@ package gen import ( - connect "connectrpc.com/connect" + connect "connectrpc.com/connect/v2" context "context" - errors "errors" - http "net/http" - strings "strings" + sync "sync" ) -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - const ( - // ExampleV1betaName is the fully-qualified name of the ExampleV1beta service. - ExampleV1betaName = "example.ExampleV1beta" + // ExampleV1BetaName is the fully-qualified name of the ExampleV1beta service. + ExampleV1BetaName = "example.ExampleV1beta" ) -// These constants are the fully-qualified names of the RPCs defined in this package. They're -// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// These constants are the procedure names of the RPCs defined in this package. They're exposed at +// runtime as Spec.Procedure and as the final two segments of the HTTP route. // // Note that these are different from the fully-qualified method names used by // google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( - // ExampleV1BetaMethodProcedure is the fully-qualified name of the ExampleV1beta's Method RPC. + // ExampleV1BetaMethodProcedure is the procedure name of the ExampleV1beta's Method RPC. ExampleV1BetaMethodProcedure = "/example.ExampleV1beta/Method" ) +var ( + exampleV1BetaMethodSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: File_v1beta1service_proto.Services().ByName("ExampleV1beta").Methods().ByName("Method"), + Procedure: ExampleV1BetaMethodProcedure, + } + }) +) + // ExampleV1BetaClient is a client for the example.ExampleV1beta service. type ExampleV1BetaClient interface { - Method(context.Context, *connect.Request[GetExample_Request]) (*connect.Response[Get1ExampleResponse], error) + Method(context.Context, *GetExample_Request) (*Get1ExampleResponse, error) } -// NewExampleV1BetaClient constructs a client for the example.ExampleV1beta service. By default, it -// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends -// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or -// connect.WithGRPCWeb() options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewExampleV1BetaClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ExampleV1BetaClient { - baseURL = strings.TrimRight(baseURL, "/") - exampleV1BetaMethods := File_v1beta1service_proto.Services().ByName("ExampleV1beta").Methods() - return &exampleV1BetaClient{ - method: connect.NewClient[GetExample_Request, Get1ExampleResponse]( - httpClient, - baseURL+ExampleV1BetaMethodProcedure, - connect.WithSchema(exampleV1BetaMethods.ByName("Method")), - connect.WithClientOptions(opts...), - ), - } -} - -// exampleV1BetaClient implements ExampleV1BetaClient. -type exampleV1BetaClient struct { - method *connect.Client[GetExample_Request, Get1ExampleResponse] -} - -// Method calls example.ExampleV1beta.Method. -func (c *exampleV1BetaClient) Method(ctx context.Context, req *connect.Request[GetExample_Request]) (*connect.Response[Get1ExampleResponse], error) { - return c.method.CallUnary(ctx, req) +// NewExampleV1BetaClient constructs a client for the example.ExampleV1beta service. Multiple +// service clients may share a single connect.Client. +func NewExampleV1BetaClient(client *connect.Client) ExampleV1BetaClient { + return &exampleV1BetaClient{client: client} } // ExampleV1BetaHandler is an implementation of the example.ExampleV1beta service. type ExampleV1BetaHandler interface { - Method(context.Context, *connect.Request[GetExample_Request]) (*connect.Response[Get1ExampleResponse], error) + Method(context.Context, *GetExample_Request) (*Get1ExampleResponse, error) } -// NewExampleV1BetaHandler builds an HTTP handler from the service implementation. It returns the -// path on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewExampleV1BetaHandler(svc ExampleV1BetaHandler, opts ...connect.HandlerOption) (string, http.Handler) { - exampleV1BetaMethods := File_v1beta1service_proto.Services().ByName("ExampleV1beta").Methods() - exampleV1BetaMethodHandler := connect.NewUnaryHandler( - ExampleV1BetaMethodProcedure, - svc.Method, - connect.WithSchema(exampleV1BetaMethods.ByName("Method")), - connect.WithHandlerOptions(opts...), +// RegisterExampleV1BetaHandler registers svc as the example.ExampleV1beta implementation on server. +func RegisterExampleV1BetaHandler(server *connect.Server, svc ExampleV1BetaHandler) { + adapter := exampleV1BetaHandler{svc: svc} + server.Register( + connect.Method{Spec: exampleV1BetaMethodSpec(), Handler: adapter.method}, ) - return "/example.ExampleV1beta/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case ExampleV1BetaMethodProcedure: - exampleV1BetaMethodHandler.ServeHTTP(w, r) - default: - http.NotFound(w, r) - } - }) } // UnimplementedExampleV1BetaHandler returns CodeUnimplemented from all methods. type UnimplementedExampleV1BetaHandler struct{} -func (UnimplementedExampleV1BetaHandler) Method(context.Context, *connect.Request[GetExample_Request]) (*connect.Response[Get1ExampleResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("example.ExampleV1beta.Method is not implemented")) +func (UnimplementedExampleV1BetaHandler) Method(context.Context, *GetExample_Request) (*Get1ExampleResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "example.ExampleV1beta.Method is not implemented") +} + +type exampleV1BetaClient struct { + client *connect.Client +} + +func (c *exampleV1BetaClient) Method(ctx context.Context, req *GetExample_Request) (*Get1ExampleResponse, error) { + var res Get1ExampleResponse + if err := c.client.CallUnary(ctx, exampleV1BetaMethodSpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +type exampleV1BetaHandler struct{ svc ExampleV1BetaHandler } + +func (h exampleV1BetaHandler) method(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req GetExample_Request + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.Method(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) } diff --git a/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen/v1beta1service.pb.go b/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen/v1beta1service.pb.go index 6d1f7a5e..c7ffbb4b 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen/v1beta1service.pb.go +++ b/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen/v1beta1service.pb.go @@ -117,8 +117,8 @@ const file_v1beta1service_proto_rawDesc = "" + "\x12GetExample_Request\"\x16\n" + "\x14Get1example_response2W\n" + "\rExampleV1beta\x12F\n" + - "\x06Method\x12\x1b.example.GetExample_Request\x1a\x1d.example.Get1example_response\"\x00B\xb5\x01\n" + - "\vcom.exampleB\x13V1beta1serviceProtoP\x01ZUconnectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen\xa2\x02\x03EXX\xaa\x02\aExample\xca\x02\aExample\xe2\x02\x13Example\\GPBMetadata\xea\x02\aExampleb\x06proto3" + "\x06Method\x12\x1b.example.GetExample_Request\x1a\x1d.example.Get1example_response\"\x00B\xb8\x01\n" + + "\vcom.exampleB\x13V1beta1serviceProtoP\x01ZXconnectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen\xa2\x02\x03EXX\xaa\x02\aExample\xca\x02\aExample\xe2\x02\x13Example\\GPBMetadata\xea\x02\aExampleb\x06proto3" var ( file_v1beta1service_proto_rawDescOnce sync.Once diff --git a/cmd/protoc-gen-connect-go/main.go b/cmd/protoc-gen-connect-go/main.go index 804c4cca..acb92f42 100644 --- a/cmd/protoc-gen-connect-go/main.go +++ b/cmd/protoc-gen-connect-go/main.go @@ -73,7 +73,7 @@ import ( "strings" "unicode/utf8" - connect "connectrpc.com/connect" + "connectrpc.com/connect/v2" "google.golang.org/protobuf/compiler/protogen" "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/descriptorpb" @@ -81,16 +81,14 @@ import ( ) const ( - contextPackage = protogen.GoImportPath("context") - errorsPackage = protogen.GoImportPath("errors") - httpPackage = protogen.GoImportPath("net/http") - stringsPackage = protogen.GoImportPath("strings") - connectPackage = protogen.GoImportPath("connectrpc.com/connect") + contextPackage = protogen.GoImportPath("context") + syncPackage = protogen.GoImportPath("sync") + connectPackage = protogen.GoImportPath("connectrpc.com/connect") + connectV2Package = protogen.GoImportPath("connectrpc.com/connect/v2") generatedFilenameExtension = ".connect.go" defaultPackageSuffix = "connect" packageSuffixFlagName = "package_suffix" - simpleFlagName = "simple" usage = "See https://connectrpc.com/docs/go/getting-started to learn how to use this plugin.\n\nFlags:\n -h, --help\tPrint this help and exit.\n --version\tPrint the version and exit." @@ -121,27 +119,17 @@ func main() { defaultPackageSuffix, "Generate files into a sub-package of the package containing the base .pb.go files using the given suffix. An empty suffix denotes to generate into the same package as the base pb.go files.", ) - // "simple" is a bool, but we want to support just setting "simple" without needing to set "simple=true" - // We do this via making the flag a string, and then parsing manually in getSimpleBool. - simpleString := flagSet.String( - simpleFlagName, - "false", - "Generate client and handler interfaces with simple function signatures. This eliminates the wrapper connect.Request and connect.Response types, instead having functions directly use generated RPC request and responses. Clients and handlers will instead use context.Contexts to propagate information such as headers. Most users will be more familiar with these interfaces than the default.", - ) protogen.Options{ - ParamFunc: flagSet.Set, + ParamFunc: flagSet.Set, + ImportRewriteFunc: importRewriteFunc, }.Run( func(plugin *protogen.Plugin) error { plugin.SupportedFeatures = uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) | uint64(pluginpb.CodeGeneratorResponse_FEATURE_SUPPORTS_EDITIONS) - simple, err := getSimpleBool(*simpleString) - if err != nil { - return err - } plugin.SupportedEditionsMinimum = descriptorpb.Edition_EDITION_PROTO2 plugin.SupportedEditionsMaximum = descriptorpb.Edition_EDITION_2024 for _, file := range plugin.Files { if file.Generate { - generate(plugin, file, *packageSuffix, simple) + generate(plugin, file, *packageSuffix) } } return nil @@ -149,7 +137,16 @@ func main() { ) } -func generate(plugin *protogen.Plugin, file *protogen.File, packageSuffix string, simple bool) { +// importRewriteFunc is a workaround for import alias naming. +// See https://github.com/golang/protobuf/issues/1205 +func importRewriteFunc(path protogen.GoImportPath) protogen.GoImportPath { + if path == connectPackage { + return connectV2Package + } + return path +} + +func generate(plugin *protogen.Plugin, file *protogen.File, packageSuffix string) { if len(file.Services) == 0 { return } @@ -182,7 +179,7 @@ func generate(plugin *protogen.Plugin, file *protogen.File, packageSuffix string generatePreamble(generatedFile, file) generateServiceNameConstants(generatedFile, file.Services) for _, service := range file.Services { - generateService(generatedFile, file, service, simple) + generateService(generatedFile, file, service) } } @@ -221,20 +218,13 @@ func generatePreamble(g *protogen.GeneratedFile, file *protogen.File) { g.P("package ", file.GoPackageName) g.P() - wrapComments(g, "This is a compile-time assertion to ensure that this generated file ", - "and the connect package are compatible. If you get a compiler error that this constant ", - "is not defined, this code was generated with a version of connect newer than the one ", - "compiled into your binary. You can fix the problem by either regenerating this code ", - "with an older version of connect or updating the connect version compiled into your binary.") - g.P("const _ = ", connectPackage.Ident("IsAtLeastVersion1_13_0")) - g.P() } func generateServiceNameConstants(g *protogen.GeneratedFile, services []*protogen.Service) { var numMethods int g.P("const (") for _, service := range services { - constName := fmt.Sprintf("%sName", service.Desc.Name()) + constName := serviceNameConst(service) wrapComments(g, constName, " is the fully-qualified name of the ", service.Desc.Name(), " service.") g.P(constName, ` = "`, service.Desc.FullName(), `"`) @@ -246,7 +236,7 @@ func generateServiceNameConstants(g *protogen.GeneratedFile, services []*protoge if numMethods == 0 { return } - wrapComments(g, "These constants are the fully-qualified names of the RPCs defined in this package. ", + wrapComments(g, "These constants are the procedure names of the RPCs defined in this package. ", "They're exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.") g.P("//") wrapComments(g, "Note that these are different from the fully-qualified method names used by ", @@ -258,7 +248,7 @@ func generateServiceNameConstants(g *protogen.GeneratedFile, services []*protoge for _, method := range service.Methods { // The runtime exposes this value as Spec.Procedure, so we should use the // same term here. - wrapComments(g, procedureConstName(method), " is the fully-qualified name of the ", + wrapComments(g, procedureConstName(method), " is the procedure name of the ", service.Desc.Name(), "'s ", method.Desc.Name(), " RPC.") g.P(procedureConstName(method), ` = "`, fmt.Sprintf("/%s/%s", service.Desc.FullName(), method.Desc.Name()), `"`) } @@ -267,389 +257,396 @@ func generateServiceNameConstants(g *protogen.GeneratedFile, services []*protoge g.P() } -func generateServiceMethodsVar(g *protogen.GeneratedFile, file *protogen.File, service *protogen.Service) { +// generateSpecVars emits one lazily-resolved Spec per method. The Spec's +// Schema reads the file descriptor from the protoc-gen-go package. +func generateSpecVars(g *protogen.GeneratedFile, file *protogen.File, service *protogen.Service) { if len(service.Methods) == 0 { return } - serviceMethodsName := serviceVarMethodsName(service) - g.P(serviceMethodsName, ` := `, - g.QualifiedGoIdent(file.GoDescriptorIdent), - `.Services().ByName("`, service.Desc.Name(), `").Methods()`) + specType := g.QualifiedGoIdent(connectPackage.Ident("Spec")) + onceValue := g.QualifiedGoIdent(syncPackage.Ident("OnceValue")) + descIdent := g.QualifiedGoIdent(file.GoDescriptorIdent) + g.P("var (") + for _, method := range service.Methods { + g.P(specVar(service, method), " = ", onceValue, "(func() ", specType, " {") + g.P("return ", specType, "{") + g.P("StreamType: ", methodStreamName(g, method), ",") + g.P("Schema: ", descIdent, `.Services().ByName("`, service.Desc.Name(), `").Methods().ByName("`, method.Desc.Name(), `"),`) + g.P("Procedure: ", procedureConstName(method), ",") + if idem := methodIdempotencyName(g, method); idem != "" { + g.P("IdempotencyLevel: ", idem, ",") + } + g.P("}") + g.P("})") + } + g.P(")") + g.P() } -func generateService(g *protogen.GeneratedFile, file *protogen.File, service *protogen.Service, simple bool) { - names := newNames(service) - generateClientInterface(g, service, names, simple) - generateClientImplementation(g, file, service, names, simple) - generateServerInterface(g, service, names, simple) - generateServerConstructor(g, file, service, names, simple) - generateUnimplementedServerImplementation(g, service, names, simple) +func generateService(g *protogen.GeneratedFile, file *protogen.File, service *protogen.Service) { + generateSpecVars(g, file, service) + generateClientInterface(g, service) + generateClientConstructor(g, service) + generatePerRPCClientStreams(g, service) + generateServerInterface(g, service) + generateRegisterHandler(g, service) + generatePerRPCServerStreams(g, service) + generateUnimplementedServerImplementation(g, service) + // Unexported implementation types at the bottom of the file. + generateClientImplementation(g, service) + generateHandlerAdapter(g, service) } -func generateClientInterface(g *protogen.GeneratedFile, service *protogen.Service, names names, simple bool) { - wrapComments(g, names.Client, " is a client for the ", service.Desc.FullName(), " service.") +func generateClientInterface(g *protogen.GeneratedFile, service *protogen.Service) { + wrapComments(g, service.GoName, "Client is a client for the ", service.Desc.FullName(), " service.") if isDeprecatedService(service) { g.P("//") deprecated(g) } - g.AnnotateSymbol(names.Client, protogen.Annotation{Location: service.Location}) - g.P("type ", names.Client, " interface {") + g.AnnotateSymbol(service.GoName+"Client", protogen.Annotation{Location: service.Location}) + g.P("type ", service.GoName, "Client interface {") for _, method := range service.Methods { - g.AnnotateSymbol(names.Client+"."+method.GoName, protogen.Annotation{Location: method.Location}) - leadingComments( - g, - method.Comments.Leading, - isDeprecatedMethod(method), - ) - g.P(clientSignature(g, method, false /* named */, simple)) + ctx := g.QualifiedGoIdent(contextPackage.Ident("Context")) + input := g.QualifiedGoIdent(method.Input.GoIdent) + out := g.QualifiedGoIdent(method.Output.GoIdent) + name := method.GoName + streamT := clientStreamType(service, method) + g.AnnotateSymbol(service.GoName+"Client"+"."+method.GoName, protogen.Annotation{Location: method.Location}) + leadingComments(g, method.Comments.Leading, isDeprecatedMethod(method)) + switch methodCardinality(method) { + case connect.StreamTypeUnary: + g.P(name, "(", ctx, ", *", input, ") (*", out, ", error)") + case connect.StreamTypeClient, connect.StreamTypeBidi: + g.P(name, "(", ctx, ") (", streamT, ", error)") + case connect.StreamTypeServer: + g.P(name, "(", ctx, ", *", input, ") (", streamT, ", error)") + } } g.P("}") g.P() } -func generateClientImplementation(g *protogen.GeneratedFile, file *protogen.File, service *protogen.Service, names names, simple bool) { - clientOption := connectPackage.Ident("ClientOption") +func generateClientConstructor(g *protogen.GeneratedFile, service *protogen.Service) { + clientType := g.QualifiedGoIdent(connectPackage.Ident("Client")) + // Take a *connect.Client rather than a Transport so several service clients + // can share one Client (and its transport plus interceptor chain), mirroring + // how the generated Register*Handler shares one *connect.Server. + wrapComments(g, "New", service.GoName, "Client constructs a client for the ", service.Desc.FullName(), " service. ", + "Multiple service clients may share a single ", clientType, ".", + ) + g.P("func New", service.GoName, "Client(client *", clientType, ") ", service.GoName, "Client {") + g.P("return &", clientStruct(service), "{client: client}") + g.P("}") + g.P() +} - // Client constructor. - wrapComments(g, names.ClientConstructor, " constructs a client for the ", service.Desc.FullName(), - " service. By default, it uses the Connect protocol with the binary Protobuf Codec, ", - "asks for gzipped responses, and sends uncompressed requests. ", - "To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or ", - "connect.WithGRPCWeb() options.") - g.P("//") - wrapComments(g, "The URL supplied here should be the base URL for the Connect or gRPC server ", - "(for example, http://api.acme.com or https://acme.com/grpc).") +func generateServerInterface(g *protogen.GeneratedFile, service *protogen.Service) { + wrapComments(g, service.GoName, "Handler is an implementation of the ", service.Desc.FullName(), " service.") if isDeprecatedService(service) { g.P("//") deprecated(g) } - g.P("func ", names.ClientConstructor, " (httpClient ", connectPackage.Ident("HTTPClient"), - ", baseURL string, opts ...", clientOption, ") ", names.Client, " {") - if len(service.Methods) > 0 { - g.P("baseURL = ", stringsPackage.Ident("TrimRight"), `(baseURL, "/")`) - } - generateServiceMethodsVar(g, file, service) - g.P("return &", names.ClientImpl, "{") + g.AnnotateSymbol(service.GoName+"Handler", protogen.Annotation{Location: service.Location}) + g.P("type ", service.GoName, "Handler interface {") for _, method := range service.Methods { - g.P(unexport(method.GoName), ": ", - connectPackage.Ident("NewClient"), - "[", method.Input.GoIdent, ", ", method.Output.GoIdent, "]", - "(", - ) - g.P("httpClient,") - g.P(`baseURL + `, procedureConstName(method), `,`) - g.P(connectPackage.Ident("WithSchema"), "(", procedureVarMethodDescriptor(method), "),") - idempotency := methodIdempotency(method) - switch idempotency { - case connect.IdempotencyNoSideEffects: - g.P(connectPackage.Ident("WithIdempotency"), "(", connectPackage.Ident("IdempotencyNoSideEffects"), "),") - case connect.IdempotencyIdempotent: - g.P(connectPackage.Ident("WithIdempotency"), "(", connectPackage.Ident("IdempotencyIdempotent"), "),") - case connect.IdempotencyUnknown: + ctx := g.QualifiedGoIdent(contextPackage.Ident("Context")) + input := g.QualifiedGoIdent(method.Input.GoIdent) + out := g.QualifiedGoIdent(method.Output.GoIdent) + name := method.GoName + leadingComments(g, method.Comments.Leading, isDeprecatedMethod(method)) + g.AnnotateSymbol(service.GoName+"Handler"+"."+method.GoName, protogen.Annotation{Location: method.Location}) + switch methodCardinality(method) { + case connect.StreamTypeUnary: + g.P(name, "(", ctx, ", *", input, ") (*", out, ", error)") + case connect.StreamTypeClient: + g.P(name, "(", ctx, ", ", serverStreamType(service, method), ") (*", out, ", error)") + case connect.StreamTypeServer: + g.P(name, "(", ctx, ", *", input, ", ", serverStreamType(service, method), ") error") + case connect.StreamTypeBidi: + g.P(name, "(", ctx, ", ", serverStreamType(service, method), ") error") } - g.P(connectPackage.Ident("WithClientOptions"), "(opts...),") - g.P("),") } g.P("}") - g.P("}") g.P() +} - // Client struct. - wrapComments(g, names.ClientImpl, " implements ", names.Client, ".") - g.P("type ", names.ClientImpl, " struct {") +func generateRegisterHandler(g *protogen.GeneratedFile, service *protogen.Service) { + serverType := g.QualifiedGoIdent(connectPackage.Ident("Server")) + methodType := g.QualifiedGoIdent(connectPackage.Ident("Method")) + wrapComments(g, "Register", service.GoName, "Handler registers svc as the ", service.Desc.FullName(), " implementation on server.") + g.P("func Register", service.GoName, "Handler(server *", serverType, ", svc ", service.GoName, "Handler) {") + if len(service.Methods) > 0 { + g.P("adapter := ", adapterStruct(service), "{svc: svc}") + } + g.P("server.Register(") for _, method := range service.Methods { - g.P(unexport(method.GoName), " *", connectPackage.Ident("Client"), - "[", method.Input.GoIdent, ", ", method.Output.GoIdent, "]") + g.P(methodType, "{Spec: ", specVar(service, method), "(), Handler: adapter.", adapterMethod(method), "},") } + g.P(")") g.P("}") g.P() - for _, method := range service.Methods { - generateClientMethod(g, method, names, simple) - } } - -func generateClientMethod(g *protogen.GeneratedFile, method *protogen.Method, names names, simple bool) { - receiver := names.ClientImpl - isStreamingClient := method.Desc.IsStreamingClient() - isStreamingServer := method.Desc.IsStreamingServer() - wrapComments(g, method.GoName, " calls ", method.Desc.FullName(), ".") - if isDeprecatedMethod(method) { - g.P("//") - deprecated(g) - } - g.P("func (c *", receiver, ") ", clientSignature(g, method, true /* named */, simple), " {") - - switch { - case isStreamingClient && !isStreamingServer: - if simple { - g.P("return c.", unexport(method.GoName), ".CallClientStreamSimple(ctx)") - } else { - g.P("return c.", unexport(method.GoName), ".CallClientStream(ctx)") +func generatePerRPCClientStreams(g *protogen.GeneratedFile, service *protogen.Service) { + streamIface := g.QualifiedGoIdent(connectPackage.Ident("ClientStream")) + for _, method := range service.Methods { + card := methodCardinality(method) + if card == connect.StreamTypeUnary { + continue } - case !isStreamingClient && isStreamingServer: - if simple { - g.P("return c.", unexport(method.GoName), ".CallServerStream(ctx, ", connectPackage.Ident("NewRequest"), "(req))") - } else { - g.P("return c.", unexport(method.GoName), ".CallServerStream(ctx, req)") + typeName := clientStreamType(service, method) + input := g.QualifiedGoIdent(method.Input.GoIdent) + out := g.QualifiedGoIdent(method.Output.GoIdent) + wrapComments(g, typeName, " is the client stream for the ", service.Desc.Name(), "'s ", method.Desc.Name(), " RPC.") + g.P("type ", typeName, " struct {") + g.P("stream ", streamIface) + g.P("}") + g.P() + if card == connect.StreamTypeClient || card == connect.StreamTypeBidi { + wrapComments(g, "SendHeaders opens the stream and flushes the request headers without a message. The first Send or Receive does this implicitly.") + g.P("func (s ", typeName, ") SendHeaders() error {") + g.P("return s.stream.SendHeaders()") + g.P("}") + g.P() + wrapComments(g, "Send sends a request message to the server.") + g.P("func (s ", typeName, ") Send(req *", input, ") error {") + g.P("return s.stream.Send(req)") + g.P("}") + g.P() } - case isStreamingClient && isStreamingServer: - if simple { - g.P("return c.", unexport(method.GoName), ".CallBidiStreamSimple(ctx)") - } else { - g.P("return c.", unexport(method.GoName), ".CallBidiStream(ctx)") + if card == connect.StreamTypeBidi { + wrapComments(g, "CloseSend closes the request side of the stream.") + g.P("func (s ", typeName, ") CloseSend() error {") + g.P("return s.stream.CloseSend()") + g.P("}") + g.P() } - default: - if simple { - g.P("response, err := c.", unexport(method.GoName), ".CallUnary(ctx, ", connectPackage.Ident("NewRequest"), "(req))") - g.P("if response != nil {") - g.P("return response.Msg, err") + if card == connect.StreamTypeClient { + wrapComments(g, "CloseAndReceive closes the request side of the stream and returns the single response message. It reads the stream to completion to release its resources.") + g.P("func (s ", typeName, ") CloseAndReceive() (*", out, ", error) {") + g.P("if err := s.stream.CloseSend(); err != nil {") + g.P("return nil, err") g.P("}") + g.P("var res ", out) + g.P("if err := s.stream.Receive(&res); err != nil {") g.P("return nil, err") + g.P("}") + g.P("return &res, nil") + g.P("}") + g.P() } else { - g.P("return c.", unexport(method.GoName), ".CallUnary(ctx, req)") + wrapComments(g, "Receive returns the next response message from the server.") + g.P("func (s ", typeName, ") Receive() (*", out, ", error) {") + g.P("var res ", out) + g.P("if err := s.stream.Receive(&res); err != nil {") + g.P("return nil, err") + g.P("}") + g.P("return &res, nil") + g.P("}") + g.P() + wrapComments(g, "Close releases the stream's resources. It is idempotent and is typically deferred to clean up a stream abandoned before io.EOF.") + g.P("func (s ", typeName, ") Close() error {") + g.P("return s.stream.Close()") + g.P("}") + g.P() } } - g.P("}") - g.P() } -func clientSignature(g *protogen.GeneratedFile, method *protogen.Method, named bool, simple bool) string { - reqName := "req" - ctxName := "ctx" - if !named { - reqName, ctxName = "", "" - } - if method.Desc.IsStreamingClient() && method.Desc.IsStreamingServer() { - // bidi streaming - if simple { - return method.GoName + "(" + ctxName + " " + g.QualifiedGoIdent(contextPackage.Ident("Context")) + ") " + - "(*" + g.QualifiedGoIdent(connectPackage.Ident("BidiStreamForClientSimple")) + - "[" + g.QualifiedGoIdent(method.Input.GoIdent) + ", " + g.QualifiedGoIdent(method.Output.GoIdent) + "]" + - ", error)" +func generatePerRPCServerStreams(g *protogen.GeneratedFile, service *protogen.Service) { + streamIface := g.QualifiedGoIdent(connectPackage.Ident("ServerStream")) + for _, method := range service.Methods { + card := methodCardinality(method) + if card == connect.StreamTypeUnary { + continue } - return method.GoName + "(" + ctxName + " " + g.QualifiedGoIdent(contextPackage.Ident("Context")) + ") " + - "*" + g.QualifiedGoIdent(connectPackage.Ident("BidiStreamForClient")) + - "[" + g.QualifiedGoIdent(method.Input.GoIdent) + ", " + g.QualifiedGoIdent(method.Output.GoIdent) + "]" - } - if method.Desc.IsStreamingClient() { - // client streaming - if simple { - return method.GoName + "(" + ctxName + " " + g.QualifiedGoIdent(contextPackage.Ident("Context")) + ") " + - "(*" + g.QualifiedGoIdent(connectPackage.Ident("ClientStreamForClientSimple")) + - "[" + g.QualifiedGoIdent(method.Input.GoIdent) + ", " + g.QualifiedGoIdent(method.Output.GoIdent) + "]" + - ", error)" + typeName := serverStreamType(service, method) + input := g.QualifiedGoIdent(method.Input.GoIdent) + out := g.QualifiedGoIdent(method.Output.GoIdent) + wrapComments(g, typeName, " is the server stream for the ", service.Desc.Name(), "'s ", method.Desc.Name(), " RPC.") + g.P("type ", typeName, " struct {") + g.P("stream ", streamIface) + g.P("}") + g.P() + if card == connect.StreamTypeClient || card == connect.StreamTypeBidi { + wrapComments(g, "Receive returns the next request message from the client.") + g.P("func (s ", typeName, ") Receive() (*", input, ", error) {") + g.P("var req ", input) + g.P("if err := s.stream.Receive(&req); err != nil {") + g.P("return nil, err") + g.P("}") + g.P("return &req, nil") + g.P("}") + g.P() } - return method.GoName + "(" + ctxName + " " + g.QualifiedGoIdent(contextPackage.Ident("Context")) + ") " + - "*" + g.QualifiedGoIdent(connectPackage.Ident("ClientStreamForClient")) + - "[" + g.QualifiedGoIdent(method.Input.GoIdent) + ", " + g.QualifiedGoIdent(method.Output.GoIdent) + "]" - } - if method.Desc.IsStreamingServer() { - if simple { - return method.GoName + "(" + ctxName + " " + g.QualifiedGoIdent(contextPackage.Ident("Context")) + - ", " + reqName + " *" + - g.QualifiedGoIdent(method.Input.GoIdent) + ") " + - "(*" + g.QualifiedGoIdent(connectPackage.Ident("ServerStreamForClient")) + - "[" + g.QualifiedGoIdent(method.Output.GoIdent) + "]" + - ", error)" + if card == connect.StreamTypeServer || card == connect.StreamTypeBidi { + wrapComments(g, "SendHeaders flushes the response headers without a message. The first Send does this implicitly.") + g.P("func (s ", typeName, ") SendHeaders() error {") + g.P("return s.stream.SendHeaders()") + g.P("}") + g.P() + wrapComments(g, "Send sends a response message to the client.") + g.P("func (s ", typeName, ") Send(res *", out, ") error {") + g.P("return s.stream.Send(res)") + g.P("}") + g.P() } - return method.GoName + "(" + ctxName + " " + g.QualifiedGoIdent(contextPackage.Ident("Context")) + - ", " + reqName + " *" + g.QualifiedGoIdent(connectPackage.Ident("Request")) + "[" + - g.QualifiedGoIdent(method.Input.GoIdent) + "]) " + - "(*" + g.QualifiedGoIdent(connectPackage.Ident("ServerStreamForClient")) + - "[" + g.QualifiedGoIdent(method.Output.GoIdent) + "]" + - ", error)" } - // unary; symmetric so we can re-use server templating - return method.GoName + serverSignatureParams(g, method, named, simple) } -func generateServerInterface(g *protogen.GeneratedFile, service *protogen.Service, names names, simple bool) { - wrapComments(g, names.Server, " is an implementation of the ", service.Desc.FullName(), " service.") - if isDeprecatedService(service) { - g.P("//") - deprecated(g) - } - g.AnnotateSymbol(names.Server, protogen.Annotation{Location: service.Location}) - g.P("type ", names.Server, " interface {") - for _, method := range service.Methods { - leadingComments( - g, - method.Comments.Leading, - isDeprecatedMethod(method), - ) - g.AnnotateSymbol(names.Server+"."+method.GoName, protogen.Annotation{Location: method.Location}) - g.P(serverSignature(g, method, simple)) - } +func generateClientImplementation(g *protogen.GeneratedFile, service *protogen.Service) { + clientType := g.QualifiedGoIdent(connectPackage.Ident("Client")) + g.P("type ", clientStruct(service), " struct {") + g.P("client *", clientType) g.P("}") g.P() -} - -func generateServerConstructor(g *protogen.GeneratedFile, file *protogen.File, service *protogen.Service, names names, simple bool) { - wrapComments(g, names.ServerConstructor, " builds an HTTP handler from the service implementation.", - " It returns the path on which to mount the handler and the handler itself.") - g.P("//") - wrapComments(g, "By default, handlers support the Connect, gRPC, and gRPC-Web protocols with ", - "the binary Protobuf and JSON codecs. They also support gzip compression.") - if isDeprecatedService(service) { - g.P("//") - deprecated(g) - } - handlerOption := connectPackage.Ident("HandlerOption") - g.P("func ", names.ServerConstructor, "(svc ", names.Server, ", opts ...", handlerOption, - ") (string, ", httpPackage.Ident("Handler"), ") {") - generateServiceMethodsVar(g, file, service) + recv := fmt.Sprintf("c *%s", clientStruct(service)) for _, method := range service.Methods { - isStreamingServer := method.Desc.IsStreamingServer() - isStreamingClient := method.Desc.IsStreamingClient() - idempotency := methodIdempotency(method) - switch { - case isStreamingClient && !isStreamingServer: - if simple { - g.P(procedureHandlerName(method), ` := `, connectPackage.Ident("NewClientStreamHandlerSimple"), "(") - } else { - g.P(procedureHandlerName(method), ` := `, connectPackage.Ident("NewClientStreamHandler"), "(") - } - case !isStreamingClient && isStreamingServer: - if simple { - g.P(procedureHandlerName(method), ` := `, connectPackage.Ident("NewServerStreamHandlerSimple"), "(") - } else { - g.P(procedureHandlerName(method), ` := `, connectPackage.Ident("NewServerStreamHandler"), "(") - } - case isStreamingClient && isStreamingServer: - g.P(procedureHandlerName(method), ` := `, connectPackage.Ident("NewBidiStreamHandler"), "(") - default: - if simple { - g.P(procedureHandlerName(method), ` := `, connectPackage.Ident("NewUnaryHandlerSimple"), "(") - } else { - g.P(procedureHandlerName(method), ` := `, connectPackage.Ident("NewUnaryHandler"), "(") - } - } - g.P(procedureConstName(method), `,`) - g.P("svc.", method.GoName, ",") - g.P(connectPackage.Ident("WithSchema"), "(", procedureVarMethodDescriptor(method), "),") - switch idempotency { - case connect.IdempotencyNoSideEffects: - g.P(connectPackage.Ident("WithIdempotency"), "(", connectPackage.Ident("IdempotencyNoSideEffects"), "),") - case connect.IdempotencyIdempotent: - g.P(connectPackage.Ident("WithIdempotency"), "(", connectPackage.Ident("IdempotencyIdempotent"), "),") - case connect.IdempotencyUnknown: + ctx := g.QualifiedGoIdent(contextPackage.Ident("Context")) + input := g.QualifiedGoIdent(method.Input.GoIdent) + out := g.QualifiedGoIdent(method.Output.GoIdent) + name := method.GoName + streamT := clientStreamType(service, method) + spec := specVar(service, method) + "()" + switch methodCardinality(method) { + case connect.StreamTypeUnary: + g.P("func (", recv, ") ", name, "(ctx ", ctx, ", req *", input, ") (*", out, ", error) {") + g.P("var res ", out) + g.P("if err := c.client.CallUnary(ctx, ", spec, ", req, &res); err != nil {") + g.P("return nil, err") + g.P("}") + g.P("return &res, nil") + g.P("}") + case connect.StreamTypeClient, connect.StreamTypeBidi: + g.P("func (", recv, ") ", name, "(ctx ", ctx, ") (", streamT, ", error) {") + g.P("stream, err := c.client.CallClientStream(ctx, ", spec, ")") + g.P("if err != nil {") + g.P("return ", streamT, "{}, err") + g.P("}") + g.P("return ", streamT, "{stream: stream}, nil") + g.P("}") + case connect.StreamTypeServer: + g.P("func (", recv, ") ", name, "(ctx ", ctx, ", req *", input, ") (", streamT, ", error) {") + g.P("stream, err := c.client.CallServerStream(ctx, ", spec, ", req)") + g.P("if err != nil {") + g.P("return ", streamT, "{}, err") + g.P("}") + g.P("return ", streamT, "{stream: stream}, nil") + g.P("}") } - g.P(connectPackage.Ident("WithHandlerOptions"), "(opts...),") - g.P(")") + g.P() } - g.P(`return "/`, service.Desc.FullName(), `/", `, httpPackage.Ident("HandlerFunc"), `(func(w `, httpPackage.Ident("ResponseWriter"), `, r *`, httpPackage.Ident("Request"), `){`) - g.P("switch r.URL.Path {") +} + +func generateHandlerAdapter(g *protogen.GeneratedFile, service *protogen.Service) { + recv := "h " + adapterStruct(service) + g.P("type ", adapterStruct(service), " struct{ svc ", service.GoName, "Handler }") + g.P() for _, method := range service.Methods { - g.P("case ", procedureConstName(method), ":") - g.P(procedureHandlerName(method), ".ServeHTTP(w, r)") + ctx := g.QualifiedGoIdent(contextPackage.Ident("Context")) + specType := g.QualifiedGoIdent(connectPackage.Ident("Spec")) + handlerStream := g.QualifiedGoIdent(connectPackage.Ident("ServerStream")) + name := adapterMethod(method) + input := g.QualifiedGoIdent(method.Input.GoIdent) + streamT := serverStreamType(service, method) + switch methodCardinality(method) { + case connect.StreamTypeUnary: + g.P("func (", recv, ") ", name, "(ctx ", ctx, ", _ ", specType, ", stream ", handlerStream, ") error {") + g.P("var req ", input) + g.P("if err := stream.Receive(&req); err != nil {") + g.P("return err") + g.P("}") + g.P("res, err := h.svc.", method.GoName, "(ctx, &req)") + g.P("if err != nil {") + g.P("return err") + g.P("}") + g.P("return stream.Send(res)") + g.P("}") + case connect.StreamTypeClient: + g.P("func (", recv, ") ", name, "(ctx ", ctx, ", _ ", specType, ", stream ", handlerStream, ") error {") + g.P("res, err := h.svc.", method.GoName, "(ctx, ", streamT, "{stream: stream})") + g.P("if err != nil {") + g.P("return err") + g.P("}") + g.P("return stream.Send(res)") + g.P("}") + case connect.StreamTypeServer: + g.P("func (", recv, ") ", name, "(ctx ", ctx, ", _ ", specType, ", stream ", handlerStream, ") error {") + g.P("var req ", input) + g.P("if err := stream.Receive(&req); err != nil {") + g.P("return err") + g.P("}") + g.P("return h.svc.", method.GoName, "(ctx, &req, ", streamT, "{stream: stream})") + g.P("}") + case connect.StreamTypeBidi: + g.P("func (", recv, ") ", name, "(ctx ", ctx, ", _ ", specType, ", stream ", handlerStream, ") error {") + g.P("return h.svc.", method.GoName, "(ctx, ", streamT, "{stream: stream})") + g.P("}") + } + g.P() } - g.P("default:") - g.P(httpPackage.Ident("NotFound"), "(w, r)") - g.P("}") - g.P("})") - g.P("}") - g.P() } -func generateUnimplementedServerImplementation(g *protogen.GeneratedFile, service *protogen.Service, names names, simple bool) { - wrapComments(g, names.UnimplementedServer, " returns CodeUnimplemented from all methods.") - g.P("type ", names.UnimplementedServer, " struct {}") +func generateUnimplementedServerImplementation(g *protogen.GeneratedFile, service *protogen.Service) { + newError := g.QualifiedGoIdent(connectPackage.Ident("NewError")) + codeUnimplemented := g.QualifiedGoIdent(connectPackage.Ident("CodeUnimplemented")) + typeName := "Unimplemented" + service.GoName + "Handler" + wrapComments(g, typeName, " returns CodeUnimplemented from all methods.") + g.P("type ", typeName, " struct{}") g.P() for _, method := range service.Methods { - g.P("func (", names.UnimplementedServer, ") ", serverSignature(g, method, simple), "{") - if method.Desc.IsStreamingServer() { - g.P("return ", connectPackage.Ident("NewError"), "(", - connectPackage.Ident("CodeUnimplemented"), ", ", errorsPackage.Ident("New"), - `("`, method.Desc.FullName(), ` is not implemented"))`) - } else { - g.P("return nil, ", connectPackage.Ident("NewError"), "(", - connectPackage.Ident("CodeUnimplemented"), ", ", errorsPackage.Ident("New"), - `("`, method.Desc.FullName(), ` is not implemented"))`) + ctx := g.QualifiedGoIdent(contextPackage.Ident("Context")) + input := g.QualifiedGoIdent(method.Input.GoIdent) + out := g.QualifiedGoIdent(method.Output.GoIdent) + msg := fmt.Sprintf("%q", fmt.Sprintf("%s is not implemented", method.Desc.FullName())) + switch methodCardinality(method) { + case connect.StreamTypeUnary: + g.P("func (", typeName, ") ", method.GoName, "(", ctx, ", *", input, ") (*", out, ", error) {") + g.P("return nil, ", newError, "(", codeUnimplemented, ", ", msg, ")") + case connect.StreamTypeClient: + g.P("func (", typeName, ") ", method.GoName, "(", ctx, ", ", serverStreamType(service, method), ") (*", out, ", error) {") + g.P("return nil, ", newError, "(", codeUnimplemented, ", ", msg, ")") + case connect.StreamTypeServer: + g.P("func (", typeName, ") ", method.GoName, "(", ctx, ", *", input, ", ", serverStreamType(service, method), ") error {") + g.P("return ", newError, "(", codeUnimplemented, ", ", msg, ")") + case connect.StreamTypeBidi: + g.P("func (", typeName, ") ", method.GoName, "(", ctx, ", ", serverStreamType(service, method), ") error {") + g.P("return ", newError, "(", codeUnimplemented, ", ", msg, ")") } g.P("}") g.P() } - g.P() } -func serverSignature(g *protogen.GeneratedFile, method *protogen.Method, simple bool) string { - return method.GoName + serverSignatureParams(g, method, false /* named */, simple) -} - -func serverSignatureParams(g *protogen.GeneratedFile, method *protogen.Method, named bool, simple bool) string { - ctxName := "ctx " - reqName := "req " - streamName := "stream " - if !named { - ctxName, reqName, streamName = "", "", "" - } - if method.Desc.IsStreamingClient() && method.Desc.IsStreamingServer() { - // bidi streaming - return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + ", " + - streamName + "*" + g.QualifiedGoIdent(connectPackage.Ident("BidiStream")) + - "[" + g.QualifiedGoIdent(method.Input.GoIdent) + ", " + g.QualifiedGoIdent(method.Output.GoIdent) + "]" + - ") error" - } - if method.Desc.IsStreamingClient() { - // client streaming - if simple { - return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + ", " + - streamName + "*" + g.QualifiedGoIdent(connectPackage.Ident("ClientStream")) + - "[" + g.QualifiedGoIdent(method.Input.GoIdent) + "]" + - ") (*" + g.QualifiedGoIdent(method.Output.GoIdent) + " ,error)" - } - return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + ", " + - streamName + "*" + g.QualifiedGoIdent(connectPackage.Ident("ClientStream")) + - "[" + g.QualifiedGoIdent(method.Input.GoIdent) + "]" + - ") (*" + g.QualifiedGoIdent(connectPackage.Ident("Response")) + "[" + g.QualifiedGoIdent(method.Output.GoIdent) + "] ,error)" - } - if method.Desc.IsStreamingServer() { - // server streaming - if simple { - return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + - ", " + reqName + " *" + - g.QualifiedGoIdent(method.Input.GoIdent) + ", " + - streamName + "*" + g.QualifiedGoIdent(connectPackage.Ident("ServerStream")) + - "[" + g.QualifiedGoIdent(method.Output.GoIdent) + "]" + - ") error" - } - return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + - ", " + reqName + "*" + g.QualifiedGoIdent(connectPackage.Ident("Request")) + "[" + - g.QualifiedGoIdent(method.Input.GoIdent) + "], " + - streamName + "*" + g.QualifiedGoIdent(connectPackage.Ident("ServerStream")) + - "[" + g.QualifiedGoIdent(method.Output.GoIdent) + "]" + - ") error" - } - // unary - if simple { - return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + - ", " + reqName + " *" + - g.QualifiedGoIdent(method.Input.GoIdent) + ") " + - "(*" + - g.QualifiedGoIdent(method.Output.GoIdent) + ", error)" - } - return "(" + ctxName + g.QualifiedGoIdent(contextPackage.Ident("Context")) + - ", " + reqName + " *" + g.QualifiedGoIdent(connectPackage.Ident("Request")) + "[" + - g.QualifiedGoIdent(method.Input.GoIdent) + "]) " + - "(*" + g.QualifiedGoIdent(connectPackage.Ident("Response")) + "[" + - g.QualifiedGoIdent(method.Output.GoIdent) + "], error)" +func serviceNameConst(service *protogen.Service) string { + return service.GoName + "Name" } func procedureConstName(m *protogen.Method) string { return fmt.Sprintf("%s%sProcedure", m.Parent.GoName, m.GoName) } -func procedureHandlerName(m *protogen.Method) string { - return fmt.Sprintf("%s%sHandler", unexport(m.Parent.GoName), m.GoName) +func specVar(service *protogen.Service, method *protogen.Method) string { + return unexport(service.GoName) + method.GoName + "Spec" +} + +func clientStruct(service *protogen.Service) string { + return unexport(service.GoName) + "Client" +} + +func adapterStruct(service *protogen.Service) string { + return unexport(service.GoName) + "Handler" } -func serviceVarMethodsName(m *protogen.Service) string { - return unexport(fmt.Sprintf("%sMethods", m.GoName)) +func adapterMethod(method *protogen.Method) string { + return unexport(method.GoName) } -func procedureVarMethodDescriptor(m *protogen.Method) string { - serviceMethodsName := serviceVarMethodsName(m.Parent) - return serviceMethodsName + `.ByName("` + string(m.Desc.Name()) + `")` +func clientStreamType(service *protogen.Service, method *protogen.Method) string { + return service.GoName + method.GoName + "ClientStream" +} + +func serverStreamType(service *protogen.Service, method *protogen.Method) string { + return service.GoName + method.GoName + "ServerStream" } func isDeprecatedService(service *protogen.Service) bool { @@ -662,20 +659,46 @@ func isDeprecatedMethod(method *protogen.Method) bool { return ok && methodOptions.GetDeprecated() } -func methodIdempotency(method *protogen.Method) connect.IdempotencyLevel { - methodOptions, ok := method.Desc.Options().(*descriptorpb.MethodOptions) - if !ok { - return connect.IdempotencyUnknown +func methodIdempotencyName(g *protogen.GeneratedFile, method *protogen.Method) string { + opts, ok := method.Desc.Options().(*descriptorpb.MethodOptions) + if !ok || opts == nil { + return "" } - switch methodOptions.GetIdempotencyLevel() { + switch opts.GetIdempotencyLevel() { case descriptorpb.MethodOptions_NO_SIDE_EFFECTS: - return connect.IdempotencyNoSideEffects + return g.QualifiedGoIdent(connectPackage.Ident("IdempotencyNoSideEffects")) case descriptorpb.MethodOptions_IDEMPOTENT: - return connect.IdempotencyIdempotent + return g.QualifiedGoIdent(connectPackage.Ident("IdempotencyIdempotent")) case descriptorpb.MethodOptions_IDEMPOTENCY_UNKNOWN: - return connect.IdempotencyUnknown + return "" } - return connect.IdempotencyUnknown + return "" +} + +func methodCardinality(method *protogen.Method) connect.StreamType { + switch { + case method.Desc.IsStreamingClient() && method.Desc.IsStreamingServer(): + return connect.StreamTypeBidi + case method.Desc.IsStreamingClient(): + return connect.StreamTypeClient + case method.Desc.IsStreamingServer(): + return connect.StreamTypeServer + } + return connect.StreamTypeUnary +} + +func methodStreamName(g *protogen.GeneratedFile, method *protogen.Method) string { + switch methodCardinality(method) { + case connect.StreamTypeClient: + return g.QualifiedGoIdent(connectPackage.Ident("StreamTypeClient")) + case connect.StreamTypeServer: + return g.QualifiedGoIdent(connectPackage.Ident("StreamTypeServer")) + case connect.StreamTypeBidi: + return g.QualifiedGoIdent(connectPackage.Ident("StreamTypeBidi")) + case connect.StreamTypeUnary: + return g.QualifiedGoIdent(connectPackage.Ident("StreamTypeUnary")) + } + return g.QualifiedGoIdent(connectPackage.Ident("StreamTypeUnary")) } // Raggedy comments in the generated code are driving me insane. This @@ -742,40 +765,3 @@ func unexport(s string) string { return lowercased } } - -type names struct { - Base string - Client string - ClientConstructor string - ClientImpl string - ClientExposeMethod string - Server string - ServerConstructor string - UnimplementedServer string -} - -func newNames(service *protogen.Service) names { - base := service.GoName - return names{ - Base: base, - Client: fmt.Sprintf("%sClient", base), - ClientConstructor: fmt.Sprintf("New%sClient", base), - ClientImpl: fmt.Sprintf("%sClient", unexport(base)), - Server: fmt.Sprintf("%sHandler", base), - ServerConstructor: fmt.Sprintf("New%sHandler", base), - UnimplementedServer: fmt.Sprintf("Unimplemented%sHandler", base), - } -} - -// "simple" is a bool, but we want to support just setting "simple" without needing to set "simple=true" -// We do this via making the flag a string, and then parsing manually here. -func getSimpleBool(simpleString string) (bool, error) { - switch simpleString { - case "", "true": - return true, nil - case "false": - return false, nil - default: - return false, fmt.Errorf(`unknown value for option "simple" (must be one of "", "true", "false"): %q`, simpleString) - } -} diff --git a/cmd/protoc-gen-connect-go/main_test.go b/cmd/protoc-gen-connect-go/main_test.go index af323f8d..805df8c9 100644 --- a/cmd/protoc-gen-connect-go/main_test.go +++ b/cmd/protoc-gen-connect-go/main_test.go @@ -19,25 +19,23 @@ import ( "context" "embed" "io" - "net/http" - "net/http/httptest" "os" "os/exec" "runtime" "strings" "testing" - "connectrpc.com/connect" - defaultpackage "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen" - defaultpackageconnect "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/genconnect" - diffpackage "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen" - diffpackagediff "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/gendiff" - noservice "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/noservice/gen" - samepackage "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen" - simple "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/simple/gen" - _ "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen" - "connectrpc.com/connect/internal/assert" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2" + defaultpackage "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen" + defaultpackageconnect "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/genconnect" + diffpackage "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen" + diffpackagediff "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/gendiff" + noservice "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/noservice/gen" + samepackage "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen" + _ "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/v1beta1service/gen" + "connectrpc.com/connect/v2/connectinprocess" + "connectrpc.com/connect/v2/internal/assert" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protodesc" @@ -83,7 +81,7 @@ func TestGenerate(t *testing.T) { assert.Equal(t, len(rsp.File), 1) file := rsp.File[0] - assert.Equal(t, file.GetName(), "connectrpc.com/connect/internal/gen/connect/ping/v1/pingv1connect/ping.connect.go") + assert.Equal(t, file.GetName(), "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect/ping.connect.go") assert.NotZero(t, file.GetContent()) }) t.Run("defaultpackage.proto", func(t *testing.T) { @@ -105,11 +103,11 @@ func TestGenerate(t *testing.T) { assert.Equal(t, len(rsp.File), 1) file := rsp.File[0] - assert.Equal(t, file.GetName(), "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/genconnect/defaultpackage.connect.go") + assert.Equal(t, file.GetName(), "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/defaultpackage/gen/genconnect/defaultpackage.connect.go") assert.NotZero(t, file.GetContent()) testCmpToTestdata(t, file.GetContent(), "internal/testdata/defaultpackage/gen/genconnect/defaultpackage.connect.go") }) - // Check generated code into a the same package. + // Check generated code into the same package. t.Run("samepackage.proto", func(t *testing.T) { t.Parallel() samePackageFileDesc := protodesc.ToFileDescriptorProto(samepackage.File_samepackage_proto) @@ -125,7 +123,7 @@ func TestGenerate(t *testing.T) { assert.Equal(t, len(rsp.File), 1) file := rsp.File[0] - assert.Equal(t, file.GetName(), "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen/samepackage.connect.go") + assert.Equal(t, file.GetName(), "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/samepackage/gen/samepackage.connect.go") assert.NotZero(t, file.GetContent()) testCmpToTestdata(t, file.GetContent(), "internal/testdata/samepackage/gen/samepackage.connect.go") }) @@ -145,7 +143,7 @@ func TestGenerate(t *testing.T) { assert.Equal(t, len(rsp.File), 1) file := rsp.File[0] - assert.Equal(t, file.GetName(), "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/gendiff/diffpackage.connect.go") + assert.Equal(t, file.GetName(), "connectrpc.com/connect/v2/cmd/protoc-gen-connect-go/internal/testdata/diffpackage/gen/gendiff/diffpackage.connect.go") assert.NotZero(t, file.GetContent()) testCmpToTestdata(t, file.GetContent(), "internal/testdata/diffpackage/gen/gendiff/diffpackage.connect.go") }) @@ -177,27 +175,6 @@ func TestGenerate(t *testing.T) { assert.Nil(t, rsp.Error) assert.Equal(t, len(rsp.File), 0) }) - t.Run("simple.proto", func(t *testing.T) { - t.Parallel() - simpleFileDesc := protodesc.ToFileDescriptorProto(simple.File_simple_proto) - for _, parameter := range []string{"simple", "simple=true"} { - req := &pluginpb.CodeGeneratorRequest{ - FileToGenerate: []string{"simple.proto"}, - Parameter: ptr(parameter), - ProtoFile: []*descriptorpb.FileDescriptorProto{simpleFileDesc}, - SourceFileDescriptors: []*descriptorpb.FileDescriptorProto{simpleFileDesc}, - CompilerVersion: compilerVersion, - } - rsp := testGenerate(t, req) - assert.Nil(t, rsp.Error) - - assert.Equal(t, len(rsp.File), 1) - file := rsp.File[0] - assert.Equal(t, file.GetName(), "connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/simple/gen/genconnect/simple.connect.go") - assert.NotZero(t, file.GetContent()) - testCmpToTestdata(t, file.GetContent(), "internal/testdata/simple/gen/genconnect/simple.connect.go") - } - }) } func TestClientHandler(t *testing.T) { @@ -205,34 +182,28 @@ func TestClientHandler(t *testing.T) { ctx := t.Context() t.Run("defaultpackage.proto", func(t *testing.T) { t.Parallel() - svc := testDefaultPackageService{} - mux := http.NewServeMux() - mux.Handle(defaultpackageconnect.NewTestServiceHandler(svc)) - server := httptest.NewServer(mux) - client := defaultpackageconnect.NewTestServiceClient(server.Client(), server.URL) - rsp, err := client.Method(ctx, connect.NewRequest(&defaultpackage.Request{})) + server := connect.NewServer() + defaultpackageconnect.RegisterTestServiceHandler(server, testDefaultPackageService{}) + client := defaultpackageconnect.NewTestServiceClient(connect.NewClient(connectinprocess.New(server))) + rsp, err := client.Method(ctx, &defaultpackage.Request{}) assert.Nil(t, err) assert.NotNil(t, rsp) }) t.Run("diffpackage.proto", func(t *testing.T) { t.Parallel() - svc := testDiffPackageService{} - mux := http.NewServeMux() - mux.Handle(diffpackagediff.NewTestServiceHandler(svc)) - server := httptest.NewServer(mux) - client := diffpackagediff.NewTestServiceClient(server.Client(), server.URL) - rsp, err := client.Method(ctx, connect.NewRequest(&diffpackage.Request{})) + server := connect.NewServer() + diffpackagediff.RegisterTestServiceHandler(server, testDiffPackageService{}) + client := diffpackagediff.NewTestServiceClient(connect.NewClient(connectinprocess.New(server))) + rsp, err := client.Method(ctx, &diffpackage.Request{}) assert.Nil(t, err) assert.NotNil(t, rsp) }) t.Run("samepackage.proto", func(t *testing.T) { t.Parallel() - svc := testSamePackageService{} - mux := http.NewServeMux() - mux.Handle(samepackage.NewTestServiceHandler(svc)) - server := httptest.NewServer(mux) - client := samepackage.NewTestServiceClient(server.Client(), server.URL) - rsp, err := client.Method(ctx, connect.NewRequest(&samepackage.Request{})) + server := connect.NewServer() + samepackage.RegisterTestServiceHandler(server, testSamePackageService{}) + client := samepackage.NewTestServiceClient(connect.NewClient(connectinprocess.New(server))) + rsp, err := client.Method(ctx, &samepackage.Request{}) assert.Nil(t, err) assert.NotNil(t, rsp) }) @@ -295,22 +266,22 @@ type testDefaultPackageService struct { defaultpackageconnect.UnimplementedTestServiceHandler } -func (testDefaultPackageService) Method(context.Context, *connect.Request[defaultpackage.Request]) (*connect.Response[defaultpackage.Response], error) { - return connect.NewResponse(&defaultpackage.Response{}), nil +func (testDefaultPackageService) Method(context.Context, *defaultpackage.Request) (*defaultpackage.Response, error) { + return &defaultpackage.Response{}, nil } type testDiffPackageService struct { diffpackagediff.UnimplementedTestServiceHandler } -func (testDiffPackageService) Method(context.Context, *connect.Request[diffpackage.Request]) (*connect.Response[diffpackage.Response], error) { - return connect.NewResponse(&diffpackage.Response{}), nil +func (testDiffPackageService) Method(context.Context, *diffpackage.Request) (*diffpackage.Response, error) { + return &diffpackage.Response{}, nil } type testSamePackageService struct { samepackage.UnimplementedTestServiceHandler } -func (testSamePackageService) Method(context.Context, *connect.Request[samepackage.Request]) (*connect.Response[samepackage.Response], error) { - return connect.NewResponse(&samepackage.Response{}), nil +func (testSamePackageService) Method(context.Context, *samepackage.Request) (*samepackage.Response, error) { + return &samepackage.Response{}, nil } diff --git a/code.go b/code.go deleted file mode 100644 index 6677ae45..00000000 --- a/code.go +++ /dev/null @@ -1,226 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "fmt" - "strconv" - "strings" -) - -// A Code is one of the Connect protocol's error codes. There are no user-defined -// codes, so only the codes enumerated below are valid. In both name and -// semantics, these codes match the gRPC status codes. -// -// The descriptions below are optimized for brevity rather than completeness. -// See the [Connect protocol specification] for detailed descriptions of each -// code and example usage. -// -// [Connect protocol specification]: https://connectrpc.com/docs/protocol -type Code uint32 - -const ( - // The zero code in gRPC is OK, which indicates that the operation was a - // success. We don't define a constant for it because it overlaps awkwardly - // with Go's error semantics: what does it mean to have a non-nil error with - // an OK status? (Also, the Connect protocol doesn't use a code for - // successes.) - - // CodeCanceled indicates that the operation was canceled, typically by the - // caller. - CodeCanceled Code = 1 - - // CodeUnknown indicates that the operation failed for an unknown reason. - CodeUnknown Code = 2 - - // CodeInvalidArgument indicates that client supplied an invalid argument. - CodeInvalidArgument Code = 3 - - // CodeDeadlineExceeded indicates that deadline expired before the operation - // could complete. - CodeDeadlineExceeded Code = 4 - - // CodeNotFound indicates that some requested entity (for example, a file or - // directory) was not found. - CodeNotFound Code = 5 - - // CodeAlreadyExists indicates that client attempted to create an entity (for - // example, a file or directory) that already exists. - CodeAlreadyExists Code = 6 - - // CodePermissionDenied indicates that the caller doesn't have permission to - // execute the specified operation. - CodePermissionDenied Code = 7 - - // CodeResourceExhausted indicates that some resource has been exhausted. For - // example, a per-user quota may be exhausted or the entire file system may - // be full. - CodeResourceExhausted Code = 8 - - // CodeFailedPrecondition indicates that the system is not in a state - // required for the operation's execution. - CodeFailedPrecondition Code = 9 - - // CodeAborted indicates that operation was aborted by the system, usually - // because of a concurrency issue such as a sequencer check failure or - // transaction abort. - CodeAborted Code = 10 - - // CodeOutOfRange indicates that the operation was attempted past the valid - // range (for example, seeking past end-of-file). - CodeOutOfRange Code = 11 - - // CodeUnimplemented indicates that the operation isn't implemented, - // supported, or enabled in this service. - CodeUnimplemented Code = 12 - - // CodeInternal indicates that some invariants expected by the underlying - // system have been broken. This code is reserved for serious errors. - CodeInternal Code = 13 - - // CodeUnavailable indicates that the service is currently unavailable. This - // is usually temporary, so clients can back off and retry idempotent - // operations. - CodeUnavailable Code = 14 - - // CodeDataLoss indicates that the operation has resulted in unrecoverable - // data loss or corruption. - CodeDataLoss Code = 15 - - // CodeUnauthenticated indicates that the request does not have valid - // authentication credentials for the operation. - CodeUnauthenticated Code = 16 - - minCode = CodeCanceled - maxCode = CodeUnauthenticated -) - -func (c Code) String() string { - switch c { - case CodeCanceled: - return "canceled" - case CodeUnknown: - return "unknown" - case CodeInvalidArgument: - return "invalid_argument" - case CodeDeadlineExceeded: - return "deadline_exceeded" - case CodeNotFound: - return "not_found" - case CodeAlreadyExists: - return "already_exists" - case CodePermissionDenied: - return "permission_denied" - case CodeResourceExhausted: - return "resource_exhausted" - case CodeFailedPrecondition: - return "failed_precondition" - case CodeAborted: - return "aborted" - case CodeOutOfRange: - return "out_of_range" - case CodeUnimplemented: - return "unimplemented" - case CodeInternal: - return "internal" - case CodeUnavailable: - return "unavailable" - case CodeDataLoss: - return "data_loss" - case CodeUnauthenticated: - return "unauthenticated" - } - return fmt.Sprintf("code_%d", c) -} - -// MarshalText implements [encoding.TextMarshaler]. -func (c Code) MarshalText() ([]byte, error) { - return []byte(c.String()), nil -} - -// UnmarshalText implements [encoding.TextUnmarshaler]. -func (c *Code) UnmarshalText(data []byte) error { - dataStr := string(data) - switch dataStr { - case "canceled": - *c = CodeCanceled - return nil - case "unknown": - *c = CodeUnknown - return nil - case "invalid_argument": - *c = CodeInvalidArgument - return nil - case "deadline_exceeded": - *c = CodeDeadlineExceeded - return nil - case "not_found": - *c = CodeNotFound - return nil - case "already_exists": - *c = CodeAlreadyExists - return nil - case "permission_denied": - *c = CodePermissionDenied - return nil - case "resource_exhausted": - *c = CodeResourceExhausted - return nil - case "failed_precondition": - *c = CodeFailedPrecondition - return nil - case "aborted": - *c = CodeAborted - return nil - case "out_of_range": - *c = CodeOutOfRange - return nil - case "unimplemented": - *c = CodeUnimplemented - return nil - case "internal": - *c = CodeInternal - return nil - case "unavailable": - *c = CodeUnavailable - return nil - case "data_loss": - *c = CodeDataLoss - return nil - case "unauthenticated": - *c = CodeUnauthenticated - return nil - } - // Ensure that non-canonical codes round-trip through MarshalText and - // UnmarshalText. - if after, ok := strings.CutPrefix(dataStr, "code_"); ok { - dataStr = after - code, err := strconv.ParseUint(dataStr, 10 /* base */, 32 /* bitsize */) - if err == nil && (code < uint64(minCode) || code > uint64(maxCode)) { - *c = Code(code) - return nil - } - } - return fmt.Errorf("invalid code %q", dataStr) -} - -// CodeOf returns the error's status code if it is or wraps an [*Error] and -// [CodeUnknown] otherwise. -func CodeOf(err error) Code { - if connectErr, ok := asError(err); ok { - return connectErr.Code() - } - return CodeUnknown -} diff --git a/code_test.go b/code_test.go deleted file mode 100644 index e457b835..00000000 --- a/code_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "strconv" - "strings" - "testing" - - "connectrpc.com/connect/internal/assert" -) - -func TestCode(t *testing.T) { - t.Parallel() - var valid []Code - for code := minCode; code <= maxCode; code++ { - valid = append(valid, code) - } - // Ensures that we don't forget to update the mapping in the Stringer - // implementation. - for _, code := range valid { - assert.False( - t, - strings.HasPrefix(code.String(), "code_"), - assert.Sprintf("update Code.String() method for new code %v", code), - ) - assertCodeRoundTrips(t, code) - } - assertCodeRoundTrips(t, Code(999)) -} - -func assertCodeRoundTrips(tb testing.TB, code Code) { - tb.Helper() - encoded, err := code.MarshalText() - assert.Nil(tb, err) - var decoded Code - assert.Nil(tb, decoded.UnmarshalText(encoded)) - assert.Equal(tb, decoded, code) - if code >= minCode && code <= maxCode { - var invalid Code - // For the known codes, we only accept the canonical string representation: "canceled", not "code_1". - assert.NotNil(tb, invalid.UnmarshalText([]byte("code_"+strconv.Itoa(int(code))))) - } -} diff --git a/codec.go b/codec.go deleted file mode 100644 index 58af6b8d..00000000 --- a/codec.go +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/runtime/protoiface" -) - -const ( - codecNameProto = "proto" - codecNameJSON = "json" - codecNameJSONCharsetUTF8 = codecNameJSON + "; charset=utf-8" -) - -// Codec marshals structs (typically generated from a schema) to and from bytes. -type Codec interface { - // Name returns the name of the Codec. - // - // This may be used as part of the Content-Type within HTTP. For example, - // with gRPC this is the content subtype, so "application/grpc+proto" will - // map to the Codec with name "proto". - // - // Names must not be empty. - Name() string - // Marshal marshals the given message. - // - // Marshal may expect a specific type of message, and will error if this type - // is not given. - Marshal(any) ([]byte, error) - // Unmarshal unmarshals the given message. - // - // Unmarshal may expect a specific type of message, and will error if this - // type is not given. - Unmarshal([]byte, any) error -} - -// marshalAppender is an extension to Codec for appending to a byte slice. -type marshalAppender interface { - Codec - - // MarshalAppend marshals the given message and appends it to the given - // byte slice. - // - // MarshalAppend may expect a specific type of message, and will error if - // this type is not given. - MarshalAppend([]byte, any) ([]byte, error) -} - -// stableCodec is an extension to Codec for serializing with stable output. -type stableCodec interface { - Codec - - // MarshalStable marshals the given message with stable field ordering. - // - // MarshalStable should return the same output for a given input. Although - // it is not guaranteed to be canonicalized, the marshalling routine for - // MarshalStable will opt for the most normalized output available for a - // given serialization. - // - // For practical reasons, it is possible for MarshalStable to return two - // different results for two inputs considered to be "equal" in their own - // domain, and it may change in the future with codec updates, but for - // any given concrete value and any given version, it should return the - // same output. - MarshalStable(any) ([]byte, error) - - // IsBinary returns true if the marshalled data is binary for this codec. - // - // If this function returns false, the data returned from Marshal and - // MarshalStable are considered valid text and may be used in contexts - // where text is expected. - IsBinary() bool -} - -type protoBinaryCodec struct{} - -var _ Codec = (*protoBinaryCodec)(nil) - -func (c *protoBinaryCodec) Name() string { return codecNameProto } - -func (c *protoBinaryCodec) Marshal(message any) ([]byte, error) { - protoMessage, ok := message.(proto.Message) - if !ok { - return nil, errNotProto(message) - } - return proto.Marshal(protoMessage) -} - -func (c *protoBinaryCodec) MarshalAppend(dst []byte, message any) ([]byte, error) { - protoMessage, ok := message.(proto.Message) - if !ok { - return nil, errNotProto(message) - } - return proto.MarshalOptions{}.MarshalAppend(dst, protoMessage) -} - -func (c *protoBinaryCodec) Unmarshal(data []byte, message any) error { - protoMessage, ok := message.(proto.Message) - if !ok { - return errNotProto(message) - } - err := proto.Unmarshal(data, protoMessage) - if err != nil { - return fmt.Errorf("unmarshal into %T: %w", message, err) - } - return nil -} - -func (c *protoBinaryCodec) MarshalStable(message any) ([]byte, error) { - protoMessage, ok := message.(proto.Message) - if !ok { - return nil, errNotProto(message) - } - // protobuf does not offer a canonical output today, so this format is not - // guaranteed to match deterministic output from other protobuf libraries. - // In addition, unknown fields may cause inconsistent output for otherwise - // equal messages. - // https://github.com/golang/protobuf/issues/1121 - options := proto.MarshalOptions{Deterministic: true} - return options.Marshal(protoMessage) -} - -func (c *protoBinaryCodec) IsBinary() bool { - return true -} - -type protoJSONCodec struct { - name string -} - -var _ Codec = (*protoJSONCodec)(nil) - -func (c *protoJSONCodec) Name() string { return c.name } - -func (c *protoJSONCodec) Marshal(message any) ([]byte, error) { - protoMessage, ok := message.(proto.Message) - if !ok { - return nil, errNotProto(message) - } - return protojson.MarshalOptions{}.Marshal(protoMessage) -} - -func (c *protoJSONCodec) MarshalAppend(dst []byte, message any) ([]byte, error) { - protoMessage, ok := message.(proto.Message) - if !ok { - return nil, errNotProto(message) - } - return protojson.MarshalOptions{}.MarshalAppend(dst, protoMessage) -} - -func (c *protoJSONCodec) Unmarshal(binary []byte, message any) error { - protoMessage, ok := message.(proto.Message) - if !ok { - return errNotProto(message) - } - if len(binary) == 0 { - return errors.New("zero-length payload is not a valid JSON object") - } - // Discard unknown fields so clients and servers aren't forced to always use - // exactly the same version of the schema. - options := protojson.UnmarshalOptions{DiscardUnknown: true} - err := options.Unmarshal(binary, protoMessage) - if err != nil { - return fmt.Errorf("unmarshal into %T: %w", message, err) - } - return nil -} - -func (c *protoJSONCodec) MarshalStable(message any) ([]byte, error) { - // protojson does not offer a "deterministic" field ordering, but fields - // are still ordered consistently by their index. However, protojson can - // output inconsistent whitespace for some reason, therefore it is - // suggested to use a formatter to ensure consistent formatting. - // https://github.com/golang/protobuf/issues/1373 - messageJSON, err := c.Marshal(message) - if err != nil { - return nil, err - } - compactedJSON := bytes.NewBuffer(messageJSON[:0]) - if err = json.Compact(compactedJSON, messageJSON); err != nil { - return nil, err - } - return compactedJSON.Bytes(), nil -} - -func (c *protoJSONCodec) IsBinary() bool { - return false -} - -// readOnlyCodecs is a read-only interface to a map of named codecs. -type readOnlyCodecs interface { - // Get gets the Codec with the given name. - Get(string) Codec - // Protobuf gets the user-supplied protobuf codec, falling back to the default - // implementation if necessary. - // - // This is helpful in the gRPC protocol, where the wire protocol requires - // marshaling protobuf structs to binary even if the RPC procedures were - // generated from a different IDL. - Protobuf() Codec - // Names returns a copy of the registered codec names. The returned slice is - // safe for the caller to mutate. - Names() []string -} - -func newReadOnlyCodecs(nameToCodec map[string]Codec) readOnlyCodecs { - return &codecMap{ - nameToCodec: nameToCodec, - } -} - -type codecMap struct { - nameToCodec map[string]Codec -} - -func (m *codecMap) Get(name string) Codec { - return m.nameToCodec[name] -} - -func (m *codecMap) Protobuf() Codec { - if pb, ok := m.nameToCodec[codecNameProto]; ok { - return pb - } - return &protoBinaryCodec{} -} - -func (m *codecMap) Names() []string { - names := make([]string, 0, len(m.nameToCodec)) - for name := range m.nameToCodec { - names = append(names, name) - } - return names -} - -func errNotProto(message any) error { - if _, ok := message.(protoiface.MessageV1); ok { - return fmt.Errorf("%T uses github.com/golang/protobuf, but connect-go only supports google.golang.org/protobuf: see https://go.dev/blog/protobuf-apiv2", message) - } - return fmt.Errorf("%T doesn't implement proto.Message", message) -} diff --git a/compression.go b/compression.go deleted file mode 100644 index 24251a23..00000000 --- a/compression.go +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "bytes" - "errors" - "io" - "math" - "net/http" - "slices" - "strings" - "sync" -) - -const ( - compressionGzip = "gzip" - compressionIdentity = "identity" -) - -// A Decompressor is a reusable wrapper that decompresses an underlying data -// source. The standard library's [*gzip.Reader] implements Decompressor. -type Decompressor interface { - io.Reader - - // Close closes the Decompressor, but not the underlying data source. It may - // return an error if the Decompressor wasn't read to EOF. - Close() error - - // Reset discards the Decompressor's internal state, if any, and prepares it - // to read from a new source of compressed data. - Reset(io.Reader) error -} - -// A Compressor is a reusable wrapper that compresses data written to an -// underlying sink. The standard library's [*gzip.Writer] implements Compressor. -type Compressor interface { - io.Writer - - // Close flushes any buffered data to the underlying sink, then closes the - // Compressor. It must not close the underlying sink. - Close() error - - // Reset discards the Compressor's internal state, if any, and prepares it to - // write compressed data to a new sink. - Reset(io.Writer) -} - -type compressionPool struct { - decompressors sync.Pool - compressors sync.Pool -} - -func newCompressionPool( - newDecompressor func() Decompressor, - newCompressor func() Compressor, -) *compressionPool { - if newDecompressor == nil && newCompressor == nil { - return nil - } - return &compressionPool{ - decompressors: sync.Pool{ - New: func() any { return newDecompressor() }, - }, - compressors: sync.Pool{ - New: func() any { return newCompressor() }, - }, - } -} - -func (c *compressionPool) Decompress(dst *bytes.Buffer, src *bytes.Buffer, readMaxBytes int64) *Error { - decompressor, err := c.getDecompressor(src) - if err != nil { - return errorf(CodeInvalidArgument, "get decompressor: %w", err) - } - reader := io.Reader(decompressor) - if readMaxBytes > 0 && readMaxBytes < math.MaxInt64 { - reader = io.LimitReader(decompressor, readMaxBytes+1) - } - bytesRead, err := dst.ReadFrom(reader) - if err != nil { - _ = c.putDecompressor(decompressor) - err = wrapIfContextError(err) - if connectErr, ok := asError(err); ok { - return connectErr - } - return errorf(CodeInvalidArgument, "decompress: %w", err) - } - if readMaxBytes > 0 && bytesRead > readMaxBytes { - discardedBytes, err := io.Copy(io.Discard, decompressor) - _ = c.putDecompressor(decompressor) - if err != nil { - return errorf(CodeResourceExhausted, "message is larger than configured max %d - unable to determine message size: %w", readMaxBytes, err) - } - return errorf(CodeResourceExhausted, "message size %d is larger than configured max %d", bytesRead+discardedBytes, readMaxBytes) - } - if err := c.putDecompressor(decompressor); err != nil { - return errorf(CodeUnknown, "recycle decompressor: %w", err) - } - return nil -} - -func (c *compressionPool) Compress(dst *bytes.Buffer, src *bytes.Buffer) *Error { - compressor, err := c.getCompressor(dst) - if err != nil { - return errorf(CodeUnknown, "get compressor: %w", err) - } - if _, err := src.WriteTo(compressor); err != nil { - _ = c.putCompressor(compressor) - err = wrapIfContextError(err) - if connectErr, ok := asError(err); ok { - return connectErr - } - return errorf(CodeInternal, "compress: %w", err) - } - if err := c.putCompressor(compressor); err != nil { - return errorf(CodeInternal, "recycle compressor: %w", err) - } - return nil -} - -func (c *compressionPool) getDecompressor(reader io.Reader) (Decompressor, error) { - decompressor, ok := c.decompressors.Get().(Decompressor) - if !ok { - return nil, errors.New("expected Decompressor, got incorrect type from pool") - } - return decompressor, decompressor.Reset(reader) -} - -func (c *compressionPool) putDecompressor(decompressor Decompressor) error { - if err := decompressor.Close(); err != nil { - return err - } - // While it's in the pool, we don't want the decompressor to retain a - // reference to the underlying reader. However, most decompressors attempt to - // read some header data from the new data source when Reset; since we don't - // know the compression format, we can't provide a valid header. Since we - // also reset the decompressor when it's pulled out of the pool, we can - // ignore errors here. - _ = decompressor.Reset(http.NoBody) - c.decompressors.Put(decompressor) - return nil -} - -func (c *compressionPool) getCompressor(writer io.Writer) (Compressor, error) { - compressor, ok := c.compressors.Get().(Compressor) - if !ok { - return nil, errors.New("expected Compressor, got incorrect type from pool") - } - compressor.Reset(writer) - return compressor, nil -} - -func (c *compressionPool) putCompressor(compressor Compressor) error { - if err := compressor.Close(); err != nil { - return err - } - compressor.Reset(io.Discard) // don't keep references - c.compressors.Put(compressor) - return nil -} - -// readOnlyCompressionPools is a read-only interface to a map of named -// compressionPools. -type readOnlyCompressionPools interface { - Get(string) *compressionPool - Contains(string) bool - // Wordy, but clarifies how this is different from readOnlyCodecs.Names(). - CommaSeparatedNames() string -} - -func newReadOnlyCompressionPools( - nameToPool map[string]*compressionPool, - reversedNames []string, -) readOnlyCompressionPools { - // Client and handler configs keep compression names in registration order, - // but we want the last registered to be the most preferred. - names := make([]string, 0, len(reversedNames)) - seen := make(map[string]struct{}, len(reversedNames)) - for _, name := range slices.Backward(reversedNames) { - if _, ok := seen[name]; ok { - continue - } - seen[name] = struct{}{} - names = append(names, name) - } - return &namedCompressionPools{ - nameToPool: nameToPool, - commaSeparatedNames: strings.Join(names, ","), - } -} - -type namedCompressionPools struct { - nameToPool map[string]*compressionPool - commaSeparatedNames string -} - -func (m *namedCompressionPools) Get(name string) *compressionPool { - if name == "" || name == compressionIdentity { - return nil - } - return m.nameToPool[name] -} - -func (m *namedCompressionPools) Contains(name string) bool { - _, ok := m.nameToPool[name] - return ok -} - -func (m *namedCompressionPools) CommaSeparatedNames() string { - return m.commaSeparatedNames -} diff --git a/compression_test.go b/compression_test.go deleted file mode 100644 index 85fe6393..00000000 --- a/compression_test.go +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "net/http" - "testing" - - "connectrpc.com/connect/internal/assert" - "connectrpc.com/connect/internal/memhttp/memhttptest" - "google.golang.org/protobuf/types/known/emptypb" -) - -func TestAcceptEncodingOrdering(t *testing.T) { - t.Parallel() - const ( - compressionBrotli = "br" - expect = compressionGzip + "," + compressionBrotli - ) - - withFakeBrotli, ok := withGzip().(*compressionOption) - assert.True(t, ok) - withFakeBrotli.Name = compressionBrotli - - var called bool - verify := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got := r.Header.Get(connectUnaryHeaderAcceptCompression) - assert.Equal(t, got, expect) - w.WriteHeader(http.StatusOK) - called = true - }) - server := memhttptest.NewServer(t, verify) - client := NewClient[emptypb.Empty, emptypb.Empty]( - server.Client(), - server.URL(), - withFakeBrotli, - withGzip(), - ) - _, _ = client.CallUnary(t.Context(), NewRequest(&emptypb.Empty{})) - assert.True(t, called) -} - -func TestClientCompressionOptionTest(t *testing.T) { - t.Parallel() - const testURL = "http://foo.bar.com/service/method" - - checkPools := func(t *testing.T, config *clientConfig) { - t.Helper() - assert.Equal(t, len(config.CompressionNames), len(config.CompressionPools)) - for _, name := range config.CompressionNames { - pool := config.CompressionPools[name] - assert.NotNil(t, pool) - } - } - dummyDecompressCtor := func() Decompressor { return nil } - dummyCompressCtor := func() Compressor { return nil } - - t.Run("defaults", func(t *testing.T) { - t.Parallel() - config, err := newClientConfig(testURL, nil) - assert.Nil(t, err) - assert.Equal(t, config.CompressionNames, []string{compressionGzip}) - checkPools(t, config) - }) - t.Run("WithAcceptCompression", func(t *testing.T) { - t.Parallel() - opts := []ClientOption{WithAcceptCompression("foo", dummyDecompressCtor, dummyCompressCtor)} - config, err := newClientConfig(testURL, opts) - assert.Nil(t, err) - assert.Equal(t, config.CompressionNames, []string{compressionGzip, "foo"}) - checkPools(t, config) - }) - t.Run("WithAcceptCompression-empty-name-noop", func(t *testing.T) { - t.Parallel() - opts := []ClientOption{WithAcceptCompression("", dummyDecompressCtor, dummyCompressCtor)} - config, err := newClientConfig(testURL, opts) - assert.Nil(t, err) - assert.Equal(t, config.CompressionNames, []string{compressionGzip}) - checkPools(t, config) - }) - t.Run("WithAcceptCompression-nil-ctors-noop", func(t *testing.T) { - t.Parallel() - opts := []ClientOption{WithAcceptCompression("foo", nil, nil)} - config, err := newClientConfig(testURL, opts) - assert.Nil(t, err) - assert.Equal(t, config.CompressionNames, []string{compressionGzip}) - checkPools(t, config) - }) - t.Run("WithAcceptCompression-nil-ctors-unregisters", func(t *testing.T) { - t.Parallel() - opts := []ClientOption{WithAcceptCompression("gzip", nil, nil)} - config, err := newClientConfig(testURL, opts) - assert.Nil(t, err) - assert.Equal(t, config.CompressionNames, nil) - checkPools(t, config) - }) -} - -func TestHandlerCompressionOptionTest(t *testing.T) { - t.Parallel() - const testProc = "/service/method" - - checkPools := func(t *testing.T, config *handlerConfig) { - t.Helper() - assert.Equal(t, len(config.CompressionNames), len(config.CompressionPools)) - for _, name := range config.CompressionNames { - pool := config.CompressionPools[name] - assert.NotNil(t, pool) - } - } - dummyDecompressCtor := func() Decompressor { return nil } - dummyCompressCtor := func() Compressor { return nil } - - t.Run("defaults", func(t *testing.T) { - t.Parallel() - config := newHandlerConfig(testProc, StreamTypeUnary, nil) - assert.Equal(t, config.CompressionNames, []string{compressionGzip}) - checkPools(t, config) - }) - t.Run("WithCompression", func(t *testing.T) { - t.Parallel() - opts := []HandlerOption{WithCompression("foo", dummyDecompressCtor, dummyCompressCtor)} - config := newHandlerConfig(testProc, StreamTypeUnary, opts) - assert.Equal(t, config.CompressionNames, []string{compressionGzip, "foo"}) - checkPools(t, config) - }) - t.Run("WithCompression-empty-name-noop", func(t *testing.T) { - t.Parallel() - opts := []HandlerOption{WithCompression("", dummyDecompressCtor, dummyCompressCtor)} - config := newHandlerConfig(testProc, StreamTypeUnary, opts) - assert.Equal(t, config.CompressionNames, []string{compressionGzip}) - checkPools(t, config) - }) - t.Run("WithCompression-nil-ctors-noop", func(t *testing.T) { - t.Parallel() - opts := []HandlerOption{WithCompression("foo", nil, nil)} - config := newHandlerConfig(testProc, StreamTypeUnary, opts) - assert.Equal(t, config.CompressionNames, []string{compressionGzip}) - checkPools(t, config) - }) - t.Run("WithCompression-nil-ctors-unregisters", func(t *testing.T) { - t.Parallel() - opts := []HandlerOption{WithCompression("gzip", nil, nil)} - config := newHandlerConfig(testProc, StreamTypeUnary, opts) - assert.Equal(t, config.CompressionNames, nil) - checkPools(t, config) - }) -} diff --git a/connect.go b/connect.go index f3f6e9ec..1638abf7 100644 --- a/connect.go +++ b/connect.go @@ -25,36 +25,514 @@ package connect import ( + "context" + "encoding/base64" "errors" "fmt" "io" - "net/http" - "net/url" + "iter" + "net/textproto" + "slices" + "strconv" + "strings" ) // Version is the semantic version of the connect module. -const Version = "1.21.0-dev" +const Version = "2.0.0-dev" -// These constants are used in compile-time handshakes with connect's generated -// code. +// Well-known codec names. const ( - IsAtLeastVersion0_0_1 = true - IsAtLeastVersion0_1_0 = true - IsAtLeastVersion1_7_0 = true - IsAtLeastVersion1_13_0 = true + // CodecNameProto is the protocol token for protobuf binary encoding. + CodecNameProto = "proto" + // CodecNameJSON is the protocol token for protobuf JSON encoding. + CodecNameJSON = "json" ) -// StreamType describes whether the client, server, neither, or both is -// streaming. -type StreamType uint8 +// Well-known compression names. +const ( + // CompressionNameIdentity is the token for uncompressed messages. It is + // named so transport metadata can report "identity" consistently even + // though no compressor is needed. + CompressionNameIdentity = "identity" + // CompressionNameGzip is the token for gzip compression. + CompressionNameGzip = "gzip" + // CompressionNameBr is the token for Brotli compression. The core package + // defines the name so transports and compressor packages can agree on the + // token. It does not provide a Brotli compressor. + CompressionNameBr = "br" + // CompressionNameZstd is the token for Zstandard compression. The core + // package defines the name so transports and compressor packages can agree + // on the token. It does not provide a Zstandard compressor. + CompressionNameZstd = "zstd" +) + +// Well-known protocol names reported by [CallInfo.Protocol]. +const ( + // ProtocolNameConnect is the token for the Connect protocol. + ProtocolNameConnect = "connect" + // ProtocolNameGRPC is the token for the gRPC protocol. + ProtocolNameGRPC = "grpc" + // ProtocolNameGRPCWeb is the token for the gRPC-Web protocol. + ProtocolNameGRPCWeb = "grpcweb" +) +// StreamType values describe the directionality of an RPC. const ( - StreamTypeUnary StreamType = 0b00 + // StreamTypeUnary identifies an RPC with one request and one response. + StreamTypeUnary StreamType = 0b00 + // StreamTypeClient identifies a client-streaming RPC. StreamTypeClient StreamType = 0b01 + // StreamTypeServer identifies a server-streaming RPC. StreamTypeServer StreamType = 0b10 - StreamTypeBidi = StreamTypeClient | StreamTypeServer + // StreamTypeBidi identifies a bidirectional-streaming RPC. + StreamTypeBidi = StreamTypeClient | StreamTypeServer ) +// IdempotencyLevel values mirror protobuf MethodOptions.idempotency_level. +const ( + // IdempotencyUnknown means the schema does not declare an idempotency + // level. + IdempotencyUnknown IdempotencyLevel = 0 + // IdempotencyNoSideEffects means the RPC has no side effects. Transports + // may use this to enable protocol features such as Connect GET. + IdempotencyNoSideEffects IdempotencyLevel = 1 + // IdempotencyIdempotent means repeated identical requests have the same + // effect as one request. + IdempotencyIdempotent IdempotencyLevel = 2 +) + +// Standard Connect RPC codes. [Code.String] returns the lowercase protocol +// token for each code. +const ( + // The zero code in gRPC is OK, which indicates that the operation was a + // success. We don't define a constant for it because it overlaps awkwardly + // with Go's error semantics: what does it mean to have a non-nil error with + // an OK status? (Also, the Connect protocol doesn't use a code for + // successes.) + + // CodeCanceled indicates that the operation was canceled, typically by the + // caller. + CodeCanceled Code = 1 + + // CodeUnknown indicates that the operation failed for an unknown reason. + CodeUnknown Code = 2 + + // CodeInvalidArgument indicates that client supplied an invalid argument. + CodeInvalidArgument Code = 3 + + // CodeDeadlineExceeded indicates that deadline expired before the operation + // could complete. + CodeDeadlineExceeded Code = 4 + + // CodeNotFound indicates that some requested entity (for example, a file or + // directory) was not found. + CodeNotFound Code = 5 + + // CodeAlreadyExists indicates that client attempted to create an entity (for + // example, a file or directory) that already exists. + CodeAlreadyExists Code = 6 + + // CodePermissionDenied indicates that the caller doesn't have permission to + // execute the specified operation. + CodePermissionDenied Code = 7 + + // CodeResourceExhausted indicates that some resource has been exhausted. For + // example, a per-user quota may be exhausted or the entire file system may + // be full. + CodeResourceExhausted Code = 8 + + // CodeFailedPrecondition indicates that the system is not in a state + // required for the operation's execution. + CodeFailedPrecondition Code = 9 + + // CodeAborted indicates that operation was aborted by the system, usually + // because of a concurrency issue such as a sequencer check failure or + // transaction abort. + CodeAborted Code = 10 + + // CodeOutOfRange indicates that the operation was attempted past the valid + // range (for example, seeking past end-of-file). + CodeOutOfRange Code = 11 + + // CodeUnimplemented indicates that the operation isn't implemented, + // supported, or enabled in this service. + CodeUnimplemented Code = 12 + + // CodeInternal indicates that some invariants expected by the underlying + // system have been broken. This code is reserved for serious errors. + CodeInternal Code = 13 + + // CodeUnavailable indicates that the service is currently unavailable. This + // is usually temporary, so clients can back off and retry idempotent + // operations. + CodeUnavailable Code = 14 + + // CodeDataLoss indicates that the operation has resulted in unrecoverable + // data loss or corruption. + CodeDataLoss Code = 15 + + // CodeUnauthenticated indicates that the request does not have valid + // authentication credentials for the operation. + CodeUnauthenticated Code = 16 + + minCode = CodeCanceled + maxCode = CodeUnauthenticated +) + +// Transport is the boundary between [Client] and an RPC execution environment. +// A Client passes the RPC [Spec] to a Transport, then drives the returned +// [ClientStream] by sending request messages, closing the send side, receiving +// response messages, and closing the stream. Generated clients hold a [*Client] +// rather than a Transport directly. +// +// [connectrpc.com/connect/v2/connecthttp.NewTransport] returns a Transport that +// sends RPCs over Connect, gRPC, or gRPC-Web on net/http. Other implementations +// can dispatch directly to a [Server], use a test double, or adapt another wire +// protocol without regenerating clients. +type Transport interface { + // NewClientStream opens a client stream for spec. + NewClientStream(ctx context.Context, spec Spec) (ClientStream, error) +} + +// Client bundles a [Transport] with the client-side interceptor chain. +// Generated service clients hold a [*Client] and dispatch every RPC through +// one of the Call methods. +// +// The interceptor chain is applied once in [NewClient], producing one +// prebuilt [ClientFunc]. +type Client struct { + transport Transport + clientFunc ClientFunc +} + +// NewClient returns a [Client] that dispatches RPCs over transport. The +// interceptors fire in argument order. The first wraps the outermost call. +// Interceptors that need per-message behavior wrap the [ClientStream] that +// next returns. +// +// The interceptor chain is applied here, once, not on every RPC. Per-RPC +// state belongs in the [ClientFunc] an interceptor returns, or in a +// [ClientStream] wrapper, not in the interceptor function itself. +func NewClient(transport Transport, interceptors ...ClientInterceptor) *Client { + return &Client{ + transport: transport, + clientFunc: chainClientInterceptors(interceptors, transport.NewClientStream), + } +} + +// CallUnary opens a stream for spec, sends req, closes the send side, +// and reads a single response into res. +// +// The initialization passes through the interceptor chain. The full +// [ClientStream.Send], [ClientStream.CloseSend], and [ClientStream.Receive] +// sequence is then executed on the resulting stream. Receive reads the +// response to [io.EOF], which releases the stream's resources. +func (c *Client) CallUnary(ctx context.Context, spec Spec, req, res any) (err error) { + ctx = ensureClientContext(ctx) + stream, err := c.clientFunc(ctx, spec) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, stream.Close()) + }() + if err := stream.Send(req); err != nil { + return err + } + if err := stream.CloseSend(); err != nil { + return err + } + return stream.Receive(res) +} + +// CallClientStream opens a stream for spec for client-streaming or bidi RPCs. +// The returned [ClientStream] is wrapped by interceptors when any are +// configured. +// +// The stream's underlying resources are released automatically when +// [ClientStream.Receive] returns [io.EOF] or the RPC context is canceled. To +// abandon a stream before reading to completion, cancel the context or call +// [ClientStream.Close]. +func (c *Client) CallClientStream(ctx context.Context, spec Spec) (ClientStream, error) { + ctx = ensureClientContext(ctx) + return c.clientFunc(ctx, spec) +} + +// CallServerStream opens a stream for spec, sends the single request, closes +// the send side, and returns the stream for the caller to read responses from. +// The returned [ClientStream] is wrapped by interceptors when any are +// configured. +// +// Its resources are released automatically when [ClientStream.Receive] +// returns [io.EOF] or the RPC context is canceled. To abandon the stream +// early, cancel the context or call [ClientStream.Close]. +func (c *Client) CallServerStream(ctx context.Context, spec Spec, req any) (ClientStream, error) { + ctx = ensureClientContext(ctx) + stream, err := c.clientFunc(ctx, spec) + if err != nil { + return nil, err + } + if err := stream.Send(req); err != nil { + return nil, err + } + if err := stream.CloseSend(); err != nil { + return nil, err + } + return stream, nil +} + +// Server is the procedure-to-method dispatcher. It owns the method +// registry and the server-side interceptor chain. +type Server struct { + chain ServerInterceptor + methods map[string]Method // procedure -> registered method + specs []Spec + unknown ServerFunc // fallback for unregistered procedures, nil means CodeUnimplemented +} + +// NewServer returns a dispatcher with no registered methods. The interceptors +// fire in argument order. The first wraps the outermost call. Interceptors +// that need per-message behavior should wrap the [ServerStream] before +// calling next. +func NewServer(interceptors ...ServerInterceptor) *Server { + return &Server{ + chain: chainServerInterceptors(interceptors), + methods: map[string]Method{}, + } +} + +// Register stores each method on the dispatcher. Each [Method.Handler] is +// wrapped with the server-side interceptor chain so [Server.Call] is just a map +// lookup plus one call. +// +// Register panics if two methods have the same [Spec.Procedure]. Register +// is intended for setup and must not run concurrently with [Server.Register], +// [Server.Specs], or [Server.Call]. +func (s *Server) Register(methods ...Method) { + for _, method := range methods { + if _, dup := s.methods[method.Spec.Procedure]; dup { + panic(fmt.Sprintf("connect: duplicate procedure %q", method.Spec.Procedure)) //nolint:forbidigo // setup-time misuse: panic surfaces the bug at startup + } + if s.chain != nil { + method.Handler = s.chain(method.Handler) + } + s.methods[method.Spec.Procedure] = method + s.specs = append(s.specs, method.Spec) + } +} + +// Specs yields the [Spec] values of registered methods. Transports use this +// to install per-procedure routes. Specs are yielded in registration order. +// Do not call Specs concurrently with [Server.Register]. +func (s *Server) Specs() iter.Seq[Spec] { + return func(yield func(Spec) bool) { + for _, spec := range s.specs { + if !yield(spec) { + return + } + } + } +} + +// Call dispatches an RPC to the method registered for procedure. Transports +// build the [ServerStream] from their wire input, pass the per-RPC [CallInfo] +// (or nil to let Call allocate one), and Call attaches it to ctx so user +// handlers can read it via [CallInfoForServerContext]. Call returns the method +// error for the transport to encode in its protocol. It does not finalize +// protocol state. The transport does that once Call returns. Returns +// [CodeUnimplemented] when no method is registered. +func (s *Server) Call(ctx context.Context, procedure string, info *CallInfo, stream ServerStream) error { + if info == nil { + info = &CallInfo{} + } + ctx = withServerContext(ctx, info) + ctx = clearClientContext(ctx) + method, ok := s.methods[procedure] + if !ok { + if s.unknown != nil { + return s.unknown(ctx, Spec{Procedure: procedure, StreamType: StreamTypeBidi}, stream) + } + return Errorf(CodeUnimplemented, "procedure %q not registered", procedure) + } + return method.Handler(ctx, method.Spec, stream) +} + +// SetUnknownHandler sets the fallback [ServerFunc] that [Server.Call] invokes when +// no method is registered for a procedure. The handler receives a [Spec] whose +// Procedure is the requested procedure and whose StreamType is [StreamTypeBidi], +// the most permissive shape, since the cardinality of an unregistered +// procedure is unknown. Its Schema is nil. The handler is wrapped by the +// server interceptor chain like a registered method. A nil fn restores the +// default, which fails the call with [CodeUnimplemented]. +// +// SetUnknownHandler is intended for setup and must not run concurrently with +// [Server.Call] or [Server.Register]. +func (s *Server) SetUnknownHandler(fn ServerFunc) { + if fn != nil && s.chain != nil { + fn = s.chain(fn) + } + s.unknown = fn +} + +// Spec describes a single RPC procedure. Generated code typically provides +// one Spec per protobuf method. +type Spec struct { + // StreamType describes which side, if any, sends multiple messages. + // It controls which stream operations generated wrappers expose. + StreamType StreamType + // IdempotencyLevel mirrors protobuf MethodOptions.idempotency_level. + // Transports may use it for protocol features such as Connect GET. + IdempotencyLevel IdempotencyLevel + // Schema is opaque method schema information. Protobuf generated code + // stores a protoreflect.MethodDescriptor. Other schema systems may use + // their own descriptor types. + Schema any + // Procedure is the leading-slash RPC path, such as + // "/package.Service/Method". Server uses it as the registry key, and + // HTTP transports use it as the route path. + Procedure string +} + +// ClientStream is the client-side view of a single in-flight RPC. +// +// A caller drives the stream by sending request messages, closing the send side +// with CloseSend, and receiving response messages until Receive reports the end +// of the response stream with an error matching io.EOF under [errors.Is]. +// Reading to io.EOF releases the stream's resources and makes the response +// trailers available on the call's [CallInfo]. To abandon a stream before +// io.EOF, cancel the context passed to the [Client] call: that releases +// resources and unblocks any pending operation. +// [Client.CallUnary] drives this full sequence internally. +// +// The first Send or Receive flushes the request headers from +// [CallInfo.RequestHeader] and opens the stream. Call +// [ClientStream.SendHeaders] to flush them eagerly without sending a message. +// After the headers are flushed, later mutations to the request headers may +// not affect the request. +// +// Send transmits request messages, and Receive reads response messages. The +// msg passed to Send and Receive must match the message type described by the +// stream's [Spec]. Generated wrappers expose typed methods for the legal +// operations, but raw stream users must follow Spec.StreamType themselves. +// +// A stream supports one active send-side operation and one active receive-side +// operation at a time, and the two sides may run concurrently. Do not call +// Send concurrently with Send or CloseSend, and do not call Receive +// concurrently with Receive. +// +// RPC status and protocol errors returned by streams can be inspected with +// [errors.As] into [*Error] or classified with [CodeOf]. Clean receive-side +// completion is reported by an error that matches io.EOF under [errors.Is]. +// Context cancellation may be reported directly as [context.Canceled] or +// [context.DeadlineExceeded], or as an [*Error] whose cause matches one of +// those errors. +// +// The stream does not expose [Spec] or [CallInfo]. The Spec is passed +// alongside the stream into [ClientFunc], and the CallInfo is reached through +// ctx via [CallInfoForClientContext]. +type ClientStream interface { + // SendHeaders flushes the request headers and opens the stream without + // sending a request message. The first Send or Receive does this + // implicitly; call SendHeaders only to flush eagerly, for example to let + // the server begin work, surface connection errors before the first + // message, or record timing. It is idempotent. + SendHeaders() error + // Send sends msg as the next request message. + Send(msg any) error + // CloseSend closes the request side of the stream. It is idempotent. + CloseSend() error + // Receive reads the next response message into msg. It reports clean + // receive-side completion with an error that matches io.EOF under errors.Is. + // Reading to io.EOF releases the stream's resources; to abandon a stream + // earlier, cancel the call's context or call Close. + Receive(msg any) error + // Close releases the stream's resources, unblocking a pending Receive and + // tearing the stream down. It may be called concurrently with Receive, + // typically as defer stream.Close(). It is idempotent. After Close, the + // stream must not be used again. + Close() error +} + +// ServerStream is the server-side view of a single in-flight RPC. +// The [Spec] is passed alongside the stream into [ServerFunc], and the +// [CallInfo] is reached through ctx via [CallInfoForServerContext]. +// +// The first Send flushes the response headers from [CallInfo.ResponseHeader]. +// Call [ServerStream.SendHeaders] to flush them eagerly without sending a +// response message. After the headers are flushed, later mutations to the +// response headers may not affect the response. +// +// Receive reads request messages, and Send transmits response messages. The +// msg passed to Receive and Send must match the message type described by the +// stream's [Spec]. Generated wrappers expose typed methods for the legal +// operations, but raw stream users must follow Spec.StreamType themselves. +// +// A stream supports one active send-side operation and one active receive-side +// operation at a time, and the two sides may run concurrently. Do not call Send +// concurrently with Send, and do not call Receive concurrently with Receive. +// +// The server stream has no Close method: the transport finalizes the RPC when +// the handler returns, encoding the handler's error and any trailers. Handler +// code ends the RPC by returning, not by closing the stream. +// +// RPC status and protocol errors returned by streams can be inspected with +// [errors.As] into [*Error] or classified with [CodeOf]. Clean receive-side +// completion is reported by an error that matches io.EOF under [errors.Is]. +// Context cancellation may be reported directly as [context.Canceled] or +// [context.DeadlineExceeded], or as an [*Error] whose cause matches one of +// those errors. +type ServerStream interface { + // Receive reads the next request message into msg. It reports clean + // receive-side completion with an error that matches io.EOF under errors.Is. + Receive(msg any) error + // SendHeaders flushes the response headers and opens the stream without + // sending a response message. The first Send does this implicitly; call + // SendHeaders only to flush eagerly, for example to let the client begin + // work or surface headers before the first Send. It is idempotent. + SendHeaders() error + // Send sends msg as the next response message. + Send(msg any) error +} + +// ClientFunc opens a [ClientStream] for an RPC. Interceptors wrap one +// ClientFunc to produce another. The innermost function invokes the +// [Transport] to establish the connection and initialize the stream. +type ClientFunc func(ctx context.Context, spec Spec) (ClientStream, error) + +// ServerFunc serves an RPC on an open [ServerStream]. Interceptors wrap one +// ServerFunc to produce another. The innermost function is the registered +// [Method.Handler]. +type ServerFunc func(ctx context.Context, spec Spec, stream ServerStream) error + +// ClientInterceptor wraps a [ClientFunc]. It may return an error without +// calling next to short-circuit the RPC. +// +// Unlike server interceptors, client interceptors wrap the initialization +// of the stream rather than the full execution of the RPC. This model is +// identical across unary and streaming calls. Because next opens the stream, +// deriving a new context and passing it to next propagates that context to the +// [Transport] and the stream. To observe or modify messages, interceptors call +// next and wrap the returned [ClientStream]. To observe the end of the RPC, +// wrap both Close and Receive: streaming callers may finish at [io.EOF] +// without calling Close. +// +// [NewClient] applies the chain when the client is constructed, so the +// interceptor function runs once, not once per RPC. Keep per-RPC state in the +// returned ClientFunc or stream wrapper. +type ClientInterceptor func(next ClientFunc) ClientFunc + +// ServerInterceptor wraps a [ServerFunc]. It may return an error without +// calling next to short-circuit the RPC. +// +// Server interceptors surround the full server invocation. To observe or +// modify individual messages, interceptors can pass a wrapped [ServerStream] +// to next. +type ServerInterceptor func(next ServerFunc) ServerFunc + +// StreamType describes the directionality of an RPC. +type StreamType uint8 + +// String returns a human-readable stream type name. func (s StreamType) String() string { switch s { case StreamTypeUnary: @@ -66,434 +544,637 @@ func (s StreamType) String() string { case StreamTypeBidi: return "bidi" } - return fmt.Sprintf("stream_%d", s) -} - -// StreamingHandlerConn is the server's view of a bidirectional message -// exchange. Interceptors for streaming RPCs may wrap StreamingHandlerConns. -// -// Like the standard library's [http.ResponseWriter], StreamingHandlerConns write -// response headers to the network with the first call to Send. Any subsequent -// mutations are effectively no-ops. Handlers may mutate response trailers at -// any time before returning. When the client has finished sending data, -// Receive returns an error wrapping [io.EOF]. Handlers should check for this -// using the standard library's [errors.Is]. -// -// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for -// use by the gRPC and Connect protocols: applications may read them but -// shouldn't write them. -// -// StreamingHandlerConn implementations provided by this module guarantee that -// all returned errors can be cast to [*Error] using the standard library's -// [errors.As]. -// -// StreamingHandlerConn implementations provided by this module support limited -// concurrent use: the read side (Receive, RequestHeader) may be called -// concurrently with the write side (Send, ResponseHeader, ResponseTrailer), but -// the read side must not be called concurrently with itself, and the write side -// must not be called concurrently with itself. -type StreamingHandlerConn interface { - Spec() Spec - Peer() Peer - - // Receive and RequestHeader form the read side of the stream. They are not - // safe to call concurrently with each other, but may be called concurrently - // with Send, ResponseHeader, and ResponseTrailer. - Receive(any) error - RequestHeader() http.Header - - // Send, ResponseHeader, and ResponseTrailer form the write side of the - // stream. They are not safe to call concurrently with each other, but may - // be called concurrently with Receive and RequestHeader. - Send(any) error - ResponseHeader() http.Header - ResponseTrailer() http.Header -} - -// StreamingClientConn is the client's view of a bidirectional message exchange. -// Interceptors for streaming RPCs may wrap StreamingClientConns. -// -// StreamingClientConns write request headers to the network with the first -// call to Send. Any subsequent mutations are effectively no-ops. When the -// server is done sending data, the StreamingClientConn's Receive method -// returns an error wrapping [io.EOF]. Clients should check for this using the -// standard library's [errors.Is]. If the server encounters an error during -// processing, subsequent calls to the StreamingClientConn's Send method will -// return an error wrapping [io.EOF]; clients may then call Receive to unmarshal -// the error. -// -// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for -// use by the gRPC and Connect protocols: applications may read them but -// shouldn't write them. -// -// StreamingClientConn implementations provided by this module guarantee that -// all returned errors can be cast to [*Error] using the standard library's -// [errors.As]. -// -// StreamingClientConn implementations provided by this module support limited -// concurrent use: the read side (Receive, ResponseHeader, ResponseTrailer, -// CloseResponse) may be called concurrently with the write side (Send, -// RequestHeader, CloseRequest), but the read side must not be called -// concurrently with itself, and the write side must not be called concurrently -// with itself. -type StreamingClientConn interface { - // Spec and Peer are safe to call concurrently with all other methods. - Spec() Spec - Peer() Peer - - // Send, RequestHeader, and CloseRequest form the write side of the stream. - // They are not safe to call concurrently with each other, but may be called - // concurrently with Receive, ResponseHeader, ResponseTrailer, and - // CloseResponse. - Send(any) error - RequestHeader() http.Header - CloseRequest() error - - // Receive, ResponseHeader, ResponseTrailer, and CloseResponse form the read - // side of the stream. They are not safe to call concurrently with each - // other, but may be called concurrently with Send, RequestHeader, and - // CloseRequest. - Receive(any) error - ResponseHeader() http.Header - ResponseTrailer() http.Header - CloseResponse() error -} - -// Request is a wrapper around a generated request message. It provides -// access to metadata like headers and the RPC specification, as well as -// strongly-typed access to the message itself. -type Request[T any] struct { - Msg *T - - spec Spec - peer Peer - header http.Header - method string -} - -// NewRequest wraps a generated request message. -func NewRequest[T any](message *T) *Request[T] { - return &Request[T]{ - Msg: message, - // Initialized lazily so we don't allocate unnecessarily. - header: nil, - } -} - -// Any returns the concrete request message as an empty interface, so that -// *Request implements the [AnyRequest] interface. -func (r *Request[_]) Any() any { - return r.Msg + return fmt.Sprintf("stream_%d", uint8(s)) } -// Spec returns a description of this RPC. -func (r *Request[_]) Spec() Spec { - return r.spec -} +// IdempotencyLevel mirrors the protobuf MethodOptions idempotency_level. +type IdempotencyLevel int32 -// Peer describes the other party for this RPC. -func (r *Request[_]) Peer() Peer { - return r.peer +// String returns a human-readable idempotency-level name. +func (i IdempotencyLevel) String() string { + switch i { + case IdempotencyUnknown: + return "idempotency_unknown" + case IdempotencyNoSideEffects: + return "no_side_effects" + case IdempotencyIdempotent: + return "idempotent" + } + return fmt.Sprintf("idempotency_%d", int(i)) } -// Header returns the HTTP headers for this request. Headers beginning with -// "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC -// protocols: applications may read them but shouldn't write them. -func (r *Request[_]) Header() http.Header { - if r.header == nil { - r.header = make(http.Header) - } - return r.header +// Method binds a [Spec] to the [ServerFunc] that serves it. +type Method struct { + // Spec describes the RPC procedure being registered. + Spec Spec + // Handler serves the RPC described by Spec. + Handler ServerFunc } -// HTTPMethod returns the HTTP method for this request. This is nearly always -// POST, but side-effect-free unary RPCs could be made via a GET. +// Header is a mutable, case-insensitive, multi-valued string map, mirroring +// [net/http.Header]. The zero value is ready to use. Header is not safe for +// concurrent use. // -// On a newly created request, via NewRequest, this will return the empty -// string until the actual request is actually sent and the HTTP method -// determined. This means that client interceptor functions will see the -// empty string until *after* they delegate to the handler they wrapped. It -// is even possible for this to return the empty string after such delegation, -// if the request was never actually sent to the server (and thus no -// determination ever made about the HTTP method). -func (r *Request[_]) HTTPMethod() string { - return r.method +// Keys are canonicalized with textproto.CanonicalMIMEHeaderKey. Transports +// ignore or overwrite protocol-reserved keys when sending an RPC. +type Header struct { + store map[string][]string } -// internalOnly implements AnyRequest. -func (r *Request[_]) internalOnly() {} +// Get returns the first value associated with key, or "" if the key has no +// values. +func (m *Header) Get(key string) string { + if m == nil { + return "" + } + vs := m.store[textproto.CanonicalMIMEHeaderKey(key)] + if len(vs) == 0 { + return "" + } + return vs[0] +} -// setRequestMethod sets the request method to the given value. -func (r *Request[_]) setRequestMethod(method string) { - r.method = method +func (m *Header) Has(key string) bool { + if m == nil { + return false + } + _, ok := m.store[textproto.CanonicalMIMEHeaderKey(key)] + return ok } -// AnyRequest is the common method set of every [Request], regardless of type -// parameter. It's used in unary interceptors. -// -// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for -// use by the gRPC and Connect protocols: applications may read them but -// shouldn't write them. -// -// To preserve our ability to add methods to this interface without breaking -// backward compatibility, only types defined in this package can implement -// AnyRequest. -type AnyRequest interface { - Any() any - Spec() Spec - Peer() Peer - Header() http.Header - HTTPMethod() string +// Values returns all values associated with key. The returned slice aliases +// the header. +func (m *Header) Values(key string) []string { + if m == nil { + return nil + } + return m.store[textproto.CanonicalMIMEHeaderKey(key)] +} - internalOnly() - setRequestMethod(string) +// Set replaces key's values with value. +func (m *Header) Set(key, value string) { + m.ensure()[textproto.CanonicalMIMEHeaderKey(key)] = []string{value} } -// Response is a wrapper around a generated response message. It provides -// access to metadata like headers and trailers, as well as strongly-typed -// access to the message itself. -type Response[T any] struct { - Msg *T +// SetValues replaces key's values with values. It does not copy values. +func (m *Header) SetValues(key string, values []string) { + canonical := textproto.CanonicalMIMEHeaderKey(key) + m.ensure()[canonical] = values +} - header http.Header - trailer http.Header +// Add appends value to key's values. +func (m *Header) Add(key, value string) { + k := textproto.CanonicalMIMEHeaderKey(key) + m.ensure()[k] = append(m.store[k], value) //nolint:gocritic // ensure() returns m.store, so the LHS and RHS refer to the same slot } -// NewResponse wraps a generated response message. -func NewResponse[T any](message *T) *Response[T] { - return &Response[T]{ - Msg: message, - // Initialized lazily so we don't allocate unnecessarily. - header: nil, - trailer: nil, +// Delete removes all values for key. +func (m *Header) Delete(key string) { + if m == nil { + return } + delete(m.store, textproto.CanonicalMIMEHeaderKey(key)) } -// Any returns the concrete response message as an empty interface, so that -// *Response implements the [AnyResponse] interface. -func (r *Response[_]) Any() any { - return r.Msg +// All yields each key and its values in unspecified order. The yielded slices +// alias the header. +func (m *Header) All() iter.Seq2[string, []string] { + return func(yield func(string, []string) bool) { + if m == nil { + return + } + for k, v := range m.store { + if !yield(k, v) { + return + } + } + } } -// Header returns the HTTP headers for this response. Headers beginning with -// "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC -// protocols: applications may read them but shouldn't write them. -func (r *Response[_]) Header() http.Header { - if r.header == nil { - r.header = make(http.Header) +// Len returns the number of keys. +func (m *Header) Len() int { + if m == nil { + return 0 } - return r.header + return len(m.store) } -// Trailer returns the trailers for this response. Depending on the underlying -// RPC protocol, trailers may be sent as HTTP trailers or a protocol-specific -// block of in-body metadata. -// -// Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the -// Connect and gRPC protocols: applications may read them but shouldn't write -// them. -func (r *Response[_]) Trailer() http.Header { - if r.trailer == nil { - r.trailer = make(http.Header) +func (m *Header) ensure() map[string][]string { + if m.store == nil { + m.store = make(map[string][]string) } - return r.trailer + return m.store } -// internalOnly implements AnyResponse. -func (r *Response[_]) internalOnly() {} +// EncodeBinaryHeader base64-encodes the data. It always emits unpadded values. +// +// In the Connect, gRPC, and gRPC-Web protocols, binary headers must have keys +// ending in "-Bin". +func EncodeBinaryHeader(data []byte) string { + // gRPC specification says that implementations should emit unpadded values. + return base64.RawStdEncoding.EncodeToString(data) +} -// AnyResponse is the common method set of every [Response], regardless of type -// parameter. It's used in unary interceptors. +// DecodeBinaryHeader base64-decodes the data. It can decode padded or unpadded +// values. Following usual HTTP semantics, multiple base64-encoded values may +// be joined with a comma. When receiving such comma-separated values, split +// them with [strings.Split] before calling DecodeBinaryHeader. // -// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for -// use by the gRPC and Connect protocols: applications may read them but -// shouldn't write them. +// Binary headers sent using the Connect, gRPC, and gRPC-Web protocols have +// keys ending in "-Bin". +func DecodeBinaryHeader(data string) ([]byte, error) { + if len(data)%4 != 0 { + // Data definitely isn't padded. + return base64.RawStdEncoding.DecodeString(data) + } + // Either the data was padded, or padding wasn't necessary. In both cases, + // the padding-aware decoder works. + return base64.StdEncoding.DecodeString(data) +} + +// CallInfo describes an RPC as it runs. Clients reach it with +// [NewClientContext], servers with [CallInfoForServerContext]. The two sides +// use separate context keys, so a handler's outbound calls don't share its +// inbound metadata. // -// To preserve our ability to add methods to this interface without breaking -// backward compatibility, only types defined in this package can implement -// AnyResponse. -type AnyResponse interface { - Any() any - Header() http.Header - Trailer() http.Header +// The transport populates the fields: request-side fields before dispatch, +// response-side fields and stats as messages flow. On a streaming RPC, +// reading a field before the matching Send or Receive returns races the +// transport. +type CallInfo struct { + // Spec describes the RPC procedure. + Spec Spec + // PeerAddr is the remote peer's address, or empty when the transport has + // no network peer. + PeerAddr string + // Protocol is the wire protocol, such as "connect", "grpc", or "grpcweb". + Protocol string + // Codec is the codec name, such as "proto" or "json". + Codec string + // RequestEncoding is the request compression, "identity" when + // uncompressed. + RequestEncoding string + // ResponseEncoding is the response compression, "identity" when + // uncompressed. + ResponseEncoding string + // SendStats holds byte counts for the most recent Send. + SendStats MessageStats + // ReceiveStats holds byte counts for the most recent Receive. + ReceiveStats MessageStats + // TransportInfo is optional transport-specific metadata, such as + // [connectrpc.com/connect/v2/connecthttp.ClientInfo] or + // [connectrpc.com/connect/v2/connecthttp.ServerInfo]. + TransportInfo any - internalOnly() + request, response, trailer Header } -// HTTPClient is the interface connect expects HTTP clients to implement. The -// standard library's *http.Client implements HTTPClient. -type HTTPClient interface { - Do(*http.Request) (*http.Response, error) +// RequestHeader returns the request headers. The client sets them before the +// first Send, and the server reads them. +func (c *CallInfo) RequestHeader() *Header { return &c.request } + +// ResponseHeader returns the response headers. The server sets them before +// its first Send, and the client reads them once the response arrives. +func (c *CallInfo) ResponseHeader() *Header { return &c.response } + +// ResponseTrailer returns the response trailers. The server sets them before +// the stream ends, and the client reads them after receiving to [io.EOF]. +func (c *CallInfo) ResponseTrailer() *Header { return &c.trailer } + +// MessageStats holds byte counts for one RPC message. The counts exclude +// envelope framing, HTTP headers, and trailers. +type MessageStats struct { + // Size is the uncompressed payload size in bytes. + Size int + // CompressedSize is the compressed payload size in bytes. It is zero when + // the message is uncompressed. + CompressedSize int } -// Spec is a description of a client call or a handler invocation. +// Codec marshals and unmarshals RPC messages. Implementations must be safe +// for concurrent use. Method contexts carry per-call values. Codecs are not +// required to observe cancellation. // -// If you're using Protobuf, protoc-gen-connect-go generates a constant for the -// fully-qualified Procedure corresponding to each RPC in your schema. -type Spec struct { - StreamType StreamType - Schema any // for protobuf RPCs, a protoreflect.MethodDescriptor - Procedure string // for example, "/acme.foo.v1.FooService/Bar" - IsClient bool // otherwise we're in a handler - IdempotencyLevel IdempotencyLevel +// Transports pass [io.Writer] and [io.Reader] so codecs may stream when their +// encoding supports it. Codecs that need contiguous input or output should +// buffer internally. Writers from this module also expose the AvailableBuffer +// and Grow methods of [bytes.Buffer] for append-style encoders. +// +// dst and src are valid only for the duration of the call. A codec must +// not retain or use either after it returns. +type Codec interface { + // Name returns the protocol codec token, such as "proto" or "json". + Name() string + // MarshalWrite encodes msg to dst. The encoding must be complete when + // MarshalWrite returns. Any write error from dst must be returned + // directly or wrapped with %w. + MarshalWrite(ctx context.Context, dst io.Writer, msg any) error + // UnmarshalRead decodes one message from src into msg. src is bounded to + // that message and reports [io.EOF] once the payload is consumed. Any read + // error from src must be returned directly or wrapped with %w. The transport + // drains any unread bytes once it returns. + UnmarshalRead(ctx context.Context, src io.Reader, msg any) error } -// Peer describes the other party to an RPC. -// -// When accessed client-side, Addr contains the host or host:port from the -// server's URL. When accessed server-side, Addr contains the client's address -// in IP:port format. +// StableCodec is a [Codec] with a deterministic encoding. Transports use +// stable encodings for features such as Connect GET request URLs. +type StableCodec interface { + Codec + // MarshalWriteStable encodes msg deterministically to dst. Messages + // equivalent under the codec's schema rules must produce identical bytes. + // The dst writer follows the rules of [Codec.MarshalWrite]. + MarshalWriteStable(ctx context.Context, dst io.Writer, msg any) error + // IsBinary reports whether MarshalWriteStable writes binary data. Binary + // encodings are text-encoded before use in URLs. + IsBinary() bool +} + +// Compressor compresses and decompresses RPC payloads. Implementations must +// be safe for concurrent use. // -// On both the client and the server, Protocol is the RPC protocol in use. -// Currently, it's either [ProtocolConnect], [ProtocolGRPC], or -// [ProtocolGRPCWeb], but additional protocols may be added in the future. +// The writers and readers returned by Compressor methods own per-payload state. +// They are used by one goroutine at a time and must be closed exactly once. +// Implementations may pool state and recycle it on Close. // -// Query contains the query parameters for the request. For the server, this -// will reflect the actual query parameters sent. For the client, it is unset. -type Peer struct { - Addr string - Protocol string - Query url.Values // server-only +// The returned writer may retain dst until Close, and the returned reader may +// retain src until Close. They must not write to dst or read from src after +// Close. Transports enforce decompressed-size limits by wrapping the reader +// returned from Decompress. +type Compressor interface { + // Name returns the content-encoding token, such as "gzip". + Name() string + // Compress returns a writer that writes compressed bytes to dst. Closing + // the writer flushes buffered data and releases resources. Callers must + // close the writer exactly once and must not use it after Close. + Compress(dst io.Writer) (io.WriteCloser, error) + // Decompress returns a reader for the decompressed form of src. Closing + // the reader releases resources without closing src. Callers must close + // the reader exactly once, even if it was not fully consumed, and must not + // use it after Close. + Decompress(src io.Reader) (io.ReadCloser, error) } -func newPeerForURL(url *url.URL, protocol string) Peer { - return Peer{ - Addr: url.Host, - Protocol: protocol, +// Code is a Connect error code. +type Code uint32 + +// String returns the lowercase protocol token for c. +func (c Code) String() string { + switch c { + case CodeCanceled: + return "canceled" + case CodeUnknown: + return "unknown" + case CodeInvalidArgument: + return "invalid_argument" + case CodeDeadlineExceeded: + return "deadline_exceeded" + case CodeNotFound: + return "not_found" + case CodeAlreadyExists: + return "already_exists" + case CodePermissionDenied: + return "permission_denied" + case CodeResourceExhausted: + return "resource_exhausted" + case CodeFailedPrecondition: + return "failed_precondition" + case CodeAborted: + return "aborted" + case CodeOutOfRange: + return "out_of_range" + case CodeUnimplemented: + return "unimplemented" + case CodeInternal: + return "internal" + case CodeUnavailable: + return "unavailable" + case CodeDataLoss: + return "data_loss" + case CodeUnauthenticated: + return "unauthenticated" } + return fmt.Sprintf("code_%d", c) } -// handlerConnCloser extends StreamingHandlerConn with a method for handlers to -// terminate the message exchange (and optionally send an error to the client). -type handlerConnCloser interface { - StreamingHandlerConn +// MarshalText implements [encoding.TextMarshaler]. +func (c Code) MarshalText() ([]byte, error) { + return []byte(c.String()), nil +} - Close(error) error +// UnmarshalText implements [encoding.TextUnmarshaler]. +func (c *Code) UnmarshalText(data []byte) error { + dataStr := string(data) + switch dataStr { + case "canceled": + *c = CodeCanceled + return nil + case "unknown": + *c = CodeUnknown + return nil + case "invalid_argument": + *c = CodeInvalidArgument + return nil + case "deadline_exceeded": + *c = CodeDeadlineExceeded + return nil + case "not_found": + *c = CodeNotFound + return nil + case "already_exists": + *c = CodeAlreadyExists + return nil + case "permission_denied": + *c = CodePermissionDenied + return nil + case "resource_exhausted": + *c = CodeResourceExhausted + return nil + case "failed_precondition": + *c = CodeFailedPrecondition + return nil + case "aborted": + *c = CodeAborted + return nil + case "out_of_range": + *c = CodeOutOfRange + return nil + case "unimplemented": + *c = CodeUnimplemented + return nil + case "internal": + *c = CodeInternal + return nil + case "unavailable": + *c = CodeUnavailable + return nil + case "data_loss": + *c = CodeDataLoss + return nil + case "unauthenticated": + *c = CodeUnauthenticated + return nil + } + // Ensure that non-canonical codes round-trip through MarshalText and + // UnmarshalText. + if after, ok := strings.CutPrefix(dataStr, "code_"); ok { + dataStr = after + code, err := strconv.ParseUint(dataStr, 10 /* base */, 32 /* bitsize */) + if err == nil && (code < uint64(minCode) || code > uint64(maxCode)) { + *c = Code(code) + return nil + } + } + return fmt.Errorf("invalid code %q", dataStr) } -// receiveConn represents the shared methods of both StreamingClientConn and StreamingHandlerConn -// that the below helper functions use for implementing the rules around a "unary" stream, that -// is expected to have exactly one message (or zero messages followed by a non-EOF error). -type receiveConn interface { - Spec() Spec - Receive(any) error +// Error is the Connect error type. Code, message, and supported detail +// values may be serialized by transports. Cause and remote are local process +// metadata and are never serialized. +// +// Handlers fail an RPC by returning an error, but only a locally authored +// *Error carries its code, message, and details to the wire. Transports +// must not serialize other errors: clients see only a code, typically +// [CodeUnknown], with no message. Text sent to callers is therefore always +// constructed intentionally with [NewError] or [Errorf]. +type Error struct { + code Code + message string + details []*ErrorDetail + cause error + remote bool } -// hasHTTPMethod is implemented by streaming connections that support HTTP methods other than -// POST. -type hasHTTPMethod interface { - getHTTPMethod() string +// ErrorDetail is a self-describing message attached to an [*Error]. On the +// Connect, gRPC, and gRPC-Web protocols, details are Protobuf messages: +// construct them with +// [connectrpc.com/connect/v2/connectproto.NewErrorDetail] and decode them +// with [connectrpc.com/connect/v2/connectproto.UnmarshalErrorDetail]. +type ErrorDetail struct { + // Type is the fully-qualified message type name, such as + // "google.rpc.RetryInfo". + Type string + // Value is the serialized message. + Value []byte + // Debug is an optional human-readable representation of Value, carried + // by the Connect protocol as the detail's "debug" JSON. It is best + // effort: transports may regenerate or omit it. + Debug []byte } -// errStreamingClientConn is a sentinel error implementation of StreamingClientConn. -type errStreamingClientConn struct { - err error +// NewError returns a new [*Error] with code and a public message. +// The message is serialized to the wire. Do not include sensitive details. +func NewError(code Code, message string) *Error { + return &Error{code: code, message: message} } -func (c *errStreamingClientConn) Receive(msg any) error { - return c.err +// Errorf returns a new [*Error] with the given code and a formatted public +// message. The message is serialized to the wire. Do not include sensitive +// details. +// +// Errorf uses fmt.Sprintf and does not attach wrapped errors. Use [Error.WithCause] +// for local-only causes. +func Errorf(code Code, format string, args ...any) *Error { + return NewError(code, fmt.Sprintf(format, args...)) } -func (c *errStreamingClientConn) Spec() Spec { - return Spec{} +// Code returns the Connect error code. If the stored code is zero, Code returns +// [CodeUnknown]. +func (e *Error) Code() Code { + if e == nil || e.code == 0 { + return CodeUnknown + } + return e.code } -func (c *errStreamingClientConn) Peer() Peer { - return Peer{} +// Message returns the optional human-readable error message serialized on the +// wire. +func (e *Error) Message() string { + if e == nil { + return "" + } + return e.message } -func (c *errStreamingClientConn) Send(msg any) error { - return c.err +// Details returns a copy of the error's detail list. Decode each detail with +// a codec package, such as +// [connectrpc.com/connect/v2/connectproto.UnmarshalErrorDetail]. +func (e *Error) Details() []*ErrorDetail { + return slices.Clone(e.details) } -func (c *errStreamingClientConn) CloseRequest() error { - return c.err +// IsRemote reports whether this error is a peer's RPC verdict rather than +// the local handler's own. Server transports must not forward a remote Error +// as the handler's own RPC verdict. +func (e *Error) IsRemote() bool { + return e != nil && e.remote } -func (c *errStreamingClientConn) CloseResponse() error { - return c.err +// Unwrap returns the local cause for errors.Is and errors.As. The cause is not +// serialized. +func (e *Error) Unwrap() error { + if e == nil { + return nil + } + return e.cause } -func (c *errStreamingClientConn) RequestHeader() http.Header { - return make(http.Header) +// Error returns "code" or "code: message". +func (e *Error) Error() string { + code := e.Code() + if e.Message() == "" { + return code.String() + } + return code.String() + ": " + e.Message() } -func (c *errStreamingClientConn) ResponseHeader() http.Header { - return make(http.Header) +// WithDetail returns a cloned error with detail appended as a public detail +// value. Construct details with a codec package, such as +// [connectrpc.com/connect/v2/connectproto.NewErrorDetail]. A nil detail is +// ignored. +func (e *Error) WithDetail(detail *ErrorDetail) *Error { + if detail == nil { + return e + } + clone := *e + clone.details = make([]*ErrorDetail, len(e.details), len(e.details)+1) + copy(clone.details, e.details) + clone.details = append(clone.details, detail) + return &clone } -func (c *errStreamingClientConn) ResponseTrailer() http.Header { - return make(http.Header) +// WithCause returns a cloned error with err attached as a local cause. Causes +// are available to errors.Is and errors.As but are not serialized. A nil cause +// is ignored. +func (e *Error) WithCause(err error) *Error { + if err == nil { + return e + } + clone := *e + if clone.cause == nil { + clone.cause = err + } else { + clone.cause = errors.Join(clone.cause, err) + } + return &clone } -// receiveUnaryResponse unmarshals a message from a StreamingClientConn, then -// envelopes the message and attaches headers and trailers. It attempts to -// consume the response stream and isn't appropriate when receiving multiple -// messages. -func receiveUnaryResponse[T any](conn StreamingClientConn, initializer maybeInitializer) (*Response[T], error) { - msg, err := receiveUnaryMessage[T](conn, initializer, "response") - if err != nil { - return nil, err +// WithRemote returns a cloned error marked as a peer's RPC verdict. +func (e *Error) WithRemote() *Error { + if e == nil { + return nil } - return &Response[T]{ - Msg: msg, - header: conn.ResponseHeader(), - trailer: conn.ResponseTrailer(), - }, nil + clone := *e + clone.remote = true + return &clone } -// receiveUnaryRequest unmarshals a message from a StreamingClientConn, then -// envelopes the message and attaches headers and other request properties. It -// attempts to consume the request stream and isn't appropriate when receiving -// multiple messages. -func receiveUnaryRequest[T any](conn StreamingHandlerConn, initializer maybeInitializer) (*Request[T], error) { - msg, err := receiveUnaryMessage[T](conn, initializer, "request") - if err != nil { - return nil, err +// NewClientContext attaches a fresh client-side [CallInfo] to ctx and returns +// both. Use it to set request metadata before issuing an RPC, or to read +// response metadata after the call. A reused handle reports the most recent +// call. For concurrent calls, derive a separate context per call. +func NewClientContext(ctx context.Context) (context.Context, *CallInfo) { + info := &CallInfo{} + return context.WithValue(ctx, clientInfoKey{}, info), info +} + +// CallInfoForClientContext returns the client-side [CallInfo] attached to +// ctx, if there is one. [Client] call methods attach a fresh CallInfo before +// invoking the interceptor chain when ctx does not already carry one, so +// client interceptors and the [Transport] can rely on it being present. +// Outside a Client call, it reports false unless the caller used +// [NewClientContext]. +func CallInfoForClientContext(ctx context.Context) (*CallInfo, bool) { + info, _ := ctx.Value(clientInfoKey{}).(*CallInfo) + return info, info != nil +} + +// CallInfoForServerContext returns the server-side [CallInfo] attached by +// [Server.Call] before invoking interceptors and generated handlers, if there +// is one. It reports false if the call did not come through [Server.Call] +// (e.g., a raw [ServerFunc] invocation in a test). +func CallInfoForServerContext(ctx context.Context) (*CallInfo, bool) { + info, _ := ctx.Value(serverInfoKey{}).(*CallInfo) + return info, info != nil +} + +// CodeOf returns the Connect code carried by err. If err does not wrap an +// [*Error], CodeOf returns [CodeUnknown]. Passing nil is not meaningful and +// also returns CodeUnknown. +func CodeOf(err error) Code { + var e *Error + if errors.As(err, &e) { + return e.Code() } - method := http.MethodPost - if hasRequestMethod, ok := conn.(hasHTTPMethod); ok { - method = hasRequestMethod.getHTTPMethod() + return CodeUnknown +} + +type ( + clientInfoKey struct{} + serverInfoKey struct{} +) + +func chainClientInterceptors(ics []ClientInterceptor, next ClientFunc) ClientFunc { + for _, interceptor := range slices.Backward(ics) { + next = interceptor(next) } - return &Request[T]{ - Msg: msg, - spec: conn.Spec(), - peer: conn.Peer(), - header: conn.RequestHeader(), - method: method, - }, nil + return next } -func receiveUnaryMessage[T any](conn receiveConn, initializer maybeInitializer, what string) (*T, error) { - var msg T - if err := initializer.maybe(conn.Spec(), &msg); err != nil { - return nil, err +func chainServerInterceptors(ics []ServerInterceptor) ServerInterceptor { + switch len(ics) { + case 0: + return nil + case 1: + return ics[0] } - // Possibly counter-intuitive, but the gRPC specs about error codes state that both clients - // and servers should return "unimplemented" when they encounter a cardinality violation: where - // the number of messages in the stream is wrong. Search for "cardinality violation" in the - // following docs: - // https://grpc.github.io/grpc/core/md_doc_statuscodes.html - if err := conn.Receive(&msg); err != nil { - if errors.Is(err, io.EOF) { - err = NewError(CodeUnimplemented, fmt.Errorf("unary %s has zero messages", what)) + chain := make([]ServerInterceptor, len(ics)) + copy(chain, ics) + return func(next ServerFunc) ServerFunc { + for _, interceptor := range slices.Backward(chain) { + next = interceptor(next) } - return nil, err + return next } - // In a well-formed stream, the one message must be the only content in the body. - // To verify that it is well-formed, try to read another message from the stream. - // TODO: optimize this second receive: ideally do it w/out allocation, w/out - // fully reading next message (if one is present), and w/out trying to - // actually unmarshal the bytes) - var msg2 T - if err := initializer.maybe(conn.Spec(), &msg2); err != nil { - return nil, err +} + +func withServerContext(ctx context.Context, info *CallInfo) context.Context { + return context.WithValue(ctx, serverInfoKey{}, info) +} + +// ensureClientContext returns a ctx carrying a client-side CallInfo. A reused +// CallInfo has transport fields reset. +func ensureClientContext(ctx context.Context) context.Context { + if info, ok := CallInfoForClientContext(ctx); ok { + info.Spec = Spec{} + info.PeerAddr = "" + info.Protocol = "" + info.Codec = "" + info.RequestEncoding = "" + info.ResponseEncoding = "" + info.TransportInfo = nil + info.SendStats = MessageStats{} + info.ReceiveStats = MessageStats{} + clear(info.response.store) + clear(info.trailer.store) + return ctx } - if err := conn.Receive(&msg2); !errors.Is(err, io.EOF) { - if err == nil { - err = NewError(CodeUnimplemented, fmt.Errorf("unary %s has multiple messages", what)) - } - return nil, err + ctx, _ = NewClientContext(ctx) + return ctx +} + +// clearClientContext shadows any inbound client-side CallInfo with a nil +// value so a server handler's context never exposes the caller's client +// CallInfo. A handler is server-side: it reads its own CallInfo through +// [CallInfoForServerContext], and a nested outbound RPC must start with a fresh +// client CallInfo. Without this, a transport that dispatches the handler on +// the caller's context (such as the in-process transport) would let that +// nested RPC reuse the caller's request headers and overwrite the caller's +// response metadata. HTTP transports build the handler context from the +// request, which never carries a client CallInfo, so this is a no-op for +// them. +func clearClientContext(ctx context.Context) context.Context { + if _, ok := CallInfoForClientContext(ctx); !ok { + return ctx } - return &msg, nil + return context.WithValue(ctx, clientInfoKey{}, (*CallInfo)(nil)) } diff --git a/connectgzip/connectgzip.go b/connectgzip/connectgzip.go new file mode 100644 index 00000000..b3880617 --- /dev/null +++ b/connectgzip/connectgzip.go @@ -0,0 +1,171 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package connectgzip provides a gzip [connect.Compressor] for the "gzip" +// Content-Encoding. +package connectgzip + +import ( + "compress/gzip" + "errors" + "fmt" + "io" + "sync" + + "connectrpc.com/connect/v2" +) + +var _ connect.Compressor = (*Compressor)(nil) + +// errClosed is returned when a recycled writer or reader is used after +// Close. +var errClosed = errors.New("connectgzip: use after Close") + +// Option configures a [Compressor]. +type Option interface { + apply(*Compressor) +} + +// WithLevel sets the gzip compression level. Accepts any value valid +// for [compress/gzip.NewWriterLevel] (-2 through 9). Defaults to +// [compress/gzip.DefaultCompression]. +func WithLevel(level int) Option { + return optionFunc(func(c *Compressor) { c.level = level }) +} + +// Compressor implements [connect.Compressor] using compress/gzip. +type Compressor struct { + level int + writers sync.Pool + readers sync.Pool +} + +// New returns a [Compressor] configured with the given options. +func New(opts ...Option) *Compressor { + c := &Compressor{level: gzip.DefaultCompression} + for _, o := range opts { + o.apply(c) + } + return c +} + +// Name returns "gzip". +func (c *Compressor) Name() string { return connect.CompressionNameGzip } + +// Compress returns a pooled writer that gzip-encodes everything written +// to it onto dst. Close flushes the gzip stream and recycles the writer. +func (c *Compressor) Compress(dst io.Writer) (io.WriteCloser, error) { + if pooled, ok := c.writers.Get().(*compressWriter); ok { + pooled.writer.Reset(dst) + pooled.closed = false + return pooled, nil + } + gzWriter, err := gzip.NewWriterLevel(dst, c.level) + if err != nil { + return nil, fmt.Errorf("connectgzip: create writer: %w", err) + } + return &compressWriter{compressor: c, writer: gzWriter}, nil +} + +// Decompress returns a pooled reader yielding the gzip-decoded form of +// src. The gzip header is read from src before Decompress returns. Close +// recycles the reader without closing src. It is safe to close a reader +// that was not fully consumed. +func (c *Compressor) Decompress(src io.Reader) (io.ReadCloser, error) { + if pooled, ok := c.readers.Get().(*decompressReader); ok { + if err := pooled.reader.Reset(src); err != nil { + // Pool poisoning risk on Reset failure: drop state, do not return. + return nil, fmt.Errorf("connectgzip: open reader: %w", err) + } + pooled.closed = false + return pooled, nil + } + gzReader, err := gzip.NewReader(src) + if err != nil { + return nil, fmt.Errorf("connectgzip: open reader: %w", err) + } + return &decompressReader{compressor: c, reader: gzReader}, nil +} + +type optionFunc func(*Compressor) + +func (f optionFunc) apply(c *Compressor) { f(c) } + +// compressWriter is a pooled gzip writer. Close returns it to the owning +// [Compressor]'s pool. The gzip writer is not reset on Close: Compress +// resets it onto the next destination, so resetting here would +// reinitialize the deflate state twice per payload. +type compressWriter struct { + compressor *Compressor + writer *gzip.Writer + closed bool +} + +func (w *compressWriter) Write(p []byte) (int, error) { + if w.closed { + return 0, errClosed + } + n, err := w.writer.Write(p) + if err != nil { + return n, fmt.Errorf("connectgzip: compress: %w", err) + } + return n, nil +} + +func (w *compressWriter) Close() error { + if w.closed { + return nil + } + w.closed = true + if err := w.writer.Close(); err != nil { + // Pool poisoning risk: drop state, do not return to pool. + return fmt.Errorf("connectgzip: close writer: %w", err) + } + w.compressor.writers.Put(w) + return nil +} + +// decompressReader is a pooled gzip reader. Close returns it to the +// owning [Compressor]'s pool. Reset on the next Decompress fully +// reinitializes the inflate state, so a half-consumed reader is safe to +// recycle. +type decompressReader struct { + compressor *Compressor + reader *gzip.Reader + closed bool +} + +func (r *decompressReader) Read(p []byte) (int, error) { + if r.closed { + return 0, errClosed + } + n, err := r.reader.Read(p) + if err != nil && !errors.Is(err, io.EOF) { + return n, fmt.Errorf("connectgzip: decompress: %w", err) + } + return n, err +} + +func (r *decompressReader) Close() error { + if r.closed { + return nil + } + r.closed = true + if err := r.reader.Close(); err != nil { + // Pool poisoning risk: drop state, do not return to pool. + return fmt.Errorf("connectgzip: close reader: %w", err) + } + r.compressor.readers.Put(r) + return nil +} diff --git a/connectgzip/connectgzip_test.go b/connectgzip/connectgzip_test.go new file mode 100644 index 00000000..8451a773 --- /dev/null +++ b/connectgzip/connectgzip_test.go @@ -0,0 +1,185 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connectgzip_test + +import ( + "bytes" + "io" + "testing" + + "connectrpc.com/connect/v2/connectgzip" +) + +func TestRoundTrip(t *testing.T) { + t.Parallel() + comp := connectgzip.New() + src := bytes.Repeat([]byte("connectrpc"), 4096) + + got := decompress(t, comp, compress(t, comp, src)) + if !bytes.Equal(got, src) { + t.Fatalf("round trip mismatch: got %d bytes, want %d", len(got), len(src)) + } +} + +// TestPooledReuse exercises the recycle path: closed writers and readers +// return to the pool and must reset cleanly for the next payload. +func TestPooledReuse(t *testing.T) { + t.Parallel() + comp := connectgzip.New() + for i := range 3 { + src := bytes.Repeat([]byte{byte('a' + i)}, 1024*(i+1)) + got := decompress(t, comp, compress(t, comp, src)) + if !bytes.Equal(got, src) { + t.Fatalf("round trip %d mismatch: got %d bytes, want %d", i, len(got), len(src)) + } + } +} + +// TestCloseHalfConsumed verifies a reader closed before EOF recycles +// safely: the next Decompress must reset the pooled state completely. +func TestCloseHalfConsumed(t *testing.T) { + t.Parallel() + comp := connectgzip.New() + src := bytes.Repeat([]byte("connectrpc"), 4096) + compressed := compress(t, comp, src) + + reader, err := comp.Decompress(bytes.NewReader(compressed)) + if err != nil { + t.Fatal(err) + } + if _, err := reader.Read(make([]byte, 10)); err != nil { + t.Fatal(err) + } + if err := reader.Close(); err != nil { + t.Fatal(err) + } + + got := decompress(t, comp, compressed) + if !bytes.Equal(got, src) { + t.Fatal("round trip after half-consumed close mismatch") + } +} + +// TestUseAfterClose verifies closed writers and readers reject further +// use instead of corrupting pooled state. +func TestUseAfterClose(t *testing.T) { + t.Parallel() + comp := connectgzip.New() + + var buf bytes.Buffer + writer, err := comp.Compress(&buf) + if err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte("x")); err == nil { + t.Fatal("Write after Close succeeded") + } + if err := writer.Close(); err != nil { + t.Fatalf("second Close returned %v", err) + } + + reader, err := comp.Decompress(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatal(err) + } + if err := reader.Close(); err != nil { + t.Fatal(err) + } + if _, err := reader.Read(make([]byte, 1)); err == nil { + t.Fatal("Read after Close succeeded") + } +} + +// TestStreamingDecompressStopsEarly verifies that a bounded consumer can +// abandon a huge payload without materializing it: only the bytes read +// are decompressed. +func TestStreamingDecompressStopsEarly(t *testing.T) { + t.Parallel() + comp := connectgzip.New() + // 16 MiB of zeros compresses to a few KiB but expands enormously. + const decompressedSize = 16 << 20 + bomb := compress(t, comp, make([]byte, decompressedSize)) + + reader, err := comp.Decompress(bytes.NewReader(bomb)) + if err != nil { + t.Fatal(err) + } + const maxBytes = 1 << 10 + n, err := io.Copy(io.Discard, io.LimitReader(reader, maxBytes)) + if err != nil { + t.Fatal(err) + } + if n != maxBytes { + t.Fatalf("read %d bytes, want %d", n, maxBytes) + } + if err := reader.Close(); err != nil { + t.Fatal(err) + } +} + +func BenchmarkDecompress(b *testing.B) { + comp := connectgzip.New() + src := bytes.Repeat([]byte("connectrpc the rpc framework "), 8192) // ~232 KiB + compressed := compress(b, comp, src) + b.ReportAllocs() + b.SetBytes(int64(len(src))) + for b.Loop() { + reader, err := comp.Decompress(bytes.NewReader(compressed)) + if err != nil { + b.Fatal(err) + } + if _, err := io.Copy(io.Discard, reader); err != nil { + b.Fatal(err) + } + if err := reader.Close(); err != nil { + b.Fatal(err) + } + } +} + +func compress(tb testing.TB, comp *connectgzip.Compressor, src []byte) []byte { + tb.Helper() + var buf bytes.Buffer + writer, err := comp.Compress(&buf) + if err != nil { + tb.Fatal(err) + } + if _, err := writer.Write(src); err != nil { + tb.Fatal(err) + } + if err := writer.Close(); err != nil { + tb.Fatal(err) + } + return buf.Bytes() +} + +func decompress(tb testing.TB, comp *connectgzip.Compressor, src []byte) []byte { + tb.Helper() + reader, err := comp.Decompress(bytes.NewReader(src)) + if err != nil { + tb.Fatal(err) + } + got, err := io.ReadAll(reader) + if err != nil { + tb.Fatal(err) + } + if err := reader.Close(); err != nil { + tb.Fatal(err) + } + return got +} diff --git a/bench_test.go b/connecthttp/bench_test.go similarity index 85% rename from bench_test.go rename to connecthttp/bench_test.go index 2a8b19fb..804cb7d0 100644 --- a/bench_test.go +++ b/connecthttp/bench_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect_test +package connecthttp_test import ( "bytes" @@ -25,19 +25,20 @@ import ( "strings" "testing" - connect "connectrpc.com/connect" - "connectrpc.com/connect/internal/assert" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + "connectrpc.com/connect/v2/internal/assert" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" ) func BenchmarkConnect(b *testing.B) { mux := http.NewServeMux() - mux.Handle( - pingv1connect.NewPingServiceHandler( - &ExamplePingServer{}, - ), - ) + srv := connect.NewServer() + + pingv1connect.RegisterPingServiceHandler(srv, &ExamplePingServer{}) + connecthttp.Mount(mux, srv) + server := httptest.NewUnstartedServer(mux) p := new(http.Protocols) p.SetHTTP1(true) @@ -56,31 +57,30 @@ func BenchmarkConnect(b *testing.B) { clients := []struct { name string - opts []connect.ClientOption + opts []connecthttp.Option }{{ name: "connect", - opts: []connect.ClientOption{}, + opts: []connecthttp.Option{}, }, { name: "grpc", - opts: []connect.ClientOption{ - connect.WithGRPC(), + opts: []connecthttp.Option{ + connecthttp.WithGRPC(), }, }, { name: "grpcweb", - opts: []connect.ClientOption{ - connect.WithGRPCWeb(), + opts: []connecthttp.Option{ + connecthttp.WithGRPCWeb(), }, }} twoMiB := strings.Repeat("a", 2*1024*1024) for _, client := range clients { b.Run(client.name, func(b *testing.B) { - client := pingv1connect.NewPingServiceClient( - httpClient, + opts := append(client.opts, connecthttp.WithSendCompression("gzip")) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(httpClient, server.URL, - connect.WithSendGzip(), - connect.WithClientOptions(client.opts...), - ) + opts..., + ))) ctx := b.Context() b.Run("unary_big", func(b *testing.B) { @@ -149,14 +149,22 @@ func BenchmarkConnect(b *testing.B) { b.Error(err) return } - number := int64(1) - for ; stream.Receive(); number++ { - if got := stream.Msg().GetNumber(); got != number { + number := int64(0) + for { + msg, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + b.Fatal(err) + } + number++ + if got := msg.GetNumber(); got != number { b.Errorf("expected %d, got %d", number, got) } } - if number != upTo+1 { - b.Errorf("expected %d, got %d", upTo+1, number) + if number != upTo { + b.Errorf("expected %d, got %d", upTo, number) } } }) @@ -186,10 +194,10 @@ func BenchmarkConnect(b *testing.B) { b.Errorf("expected %d, got %d", expected, got) } } - if err := stream.CloseRequest(); err != nil { + if err := stream.CloseSend(); err != nil { b.Error(err) } - if err := stream.CloseResponse(); err != nil { + if err := stream.Close(); err != nil { b.Error(err) } } diff --git a/client_example_test.go b/connecthttp/client_example_test.go similarity index 64% rename from client_example_test.go rename to connecthttp/client_example_test.go index 1a5433fa..94bba206 100644 --- a/client_example_test.go +++ b/connecthttp/client_example_test.go @@ -12,31 +12,27 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect_test +package connecthttp_test import ( "context" "log" - "net/http" "os" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" ) func Example_client() { logger := log.New(os.Stdout, "" /* prefix */, 0 /* flags */) - // To keep this example runnable, we'll use an HTTP server and client - // that communicate over in-memory pipes. The client is still a plain - // *http.Client! - var httpClient *http.Client = examplePingServer.Client() - - // By default, clients use the Connect protocol. Add connect.WithGRPC() or + // To keep this example runnable, we'll use an in-memory server and client. + // By default, transports use the Connect protocol. Add connect.WithGRPC() or // connect.WithGRPCWeb() to switch protocols. - client := pingv1connect.NewPingServiceClient( - httpClient, - examplePingServer.URL(), - ) + connectClient := connect.NewClient(connecthttp.NewTransport(examplePingServer.Client(), examplePingServer.URL())) + + client := pingv1connect.NewPingServiceClient(connectClient) response, err := client.Ping( context.Background(), &pingv1.PingRequest{Number: 42}, diff --git a/connecthttp/client_ext_test.go b/connecthttp/client_ext_test.go new file mode 100644 index 00000000..9d22c55a --- /dev/null +++ b/connecthttp/client_ext_test.go @@ -0,0 +1,853 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp_test + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + "connectrpc.com/connect/v2/internal/assert" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2/internal/memhttp/memhttptest" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/dynamicpb" +) + +func TestNewClient_InitFailure(t *testing.T) { + t.Parallel() + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, + "http://127.0.0.1:8080", + // This triggers an error during initialization, so each call will short circuit returning an error. + connecthttp.WithSendCompression("invalid"))), + ) + validateExpectedError := func(t *testing.T, err error) { + t.Helper() + assert.NotNil(t, err) + var connectErr *connect.Error + assert.True(t, errors.As(err, &connectErr)) + assert.Equal(t, connectErr.Message(), `unknown compression "invalid"`) + } + + t.Run("unary", func(t *testing.T) { + t.Parallel() + _, err := client.Ping(t.Context(), &pingv1.PingRequest{}) + validateExpectedError(t, err) + }) + t.Run("bidi", func(t *testing.T) { + t.Parallel() + _, err := client.CumSum(t.Context()) + validateExpectedError(t, err) + }) + t.Run("client_stream", func(t *testing.T) { + t.Parallel() + _, err := client.Sum(t.Context()) + validateExpectedError(t, err) + }) + t.Run("server_stream", func(t *testing.T) { + t.Parallel() + _, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 3}) + validateExpectedError(t, err) + }) +} + +func TestNewClient_UnknownSendCodec(t *testing.T) { + t.Parallel() + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, + "http://127.0.0.1:8080", + // The codec is never registered with WithCodec, so each call fails. + connecthttp.WithSendCodec("invalid"))), + ) + _, err := client.Ping(t.Context(), &pingv1.PingRequest{}) + assert.NotNil(t, err) + var connectErr *connect.Error + assert.True(t, errors.As(err, &connectErr)) + assert.Equal(t, connectErr.Code(), connect.CodeUnknown) + assert.Equal(t, connectErr.Message(), `unknown codec "invalid"`) +} + +func TestClientPeer(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + + run := func(t *testing.T, unaryHTTPMethod string, opts ...connecthttp.Option) { + t.Helper() + client := pingv1connect.NewPingServiceClient(connect.NewClient( + connecthttp.NewTransport(server.Client(), server.URL(), opts...), + assertPeerInterceptor(t), + )) + t.Run("unary", func(t *testing.T) { + ctx, _ := connect.NewClientContext(t.Context()) + _, err := client.Ping(ctx, &pingv1.PingRequest{}) + assert.Nil(t, err) + clientInfo, _ := connecthttp.ClientInfoForContext(ctx) + assert.Equal(t, unaryHTTPMethod, clientInfo.HTTPMethod()) + text := strings.Repeat(".", 256) + r, err := client.Ping(t.Context(), &pingv1.PingRequest{Text: text}) + assert.Nil(t, err) + assert.Equal(t, r.GetText(), text) + }) + t.Run("client_stream", func(t *testing.T) { + ctx := context.Background() + clientStream, err := client.Sum(ctx) + assert.Nil(t, err) + t.Cleanup(func() { + _, closeErr := clientStream.CloseAndReceive() + assert.Nil(t, closeErr) + }) + assert.Nil(t, clientStream.Send(&pingv1.SumRequest{})) + }) + t.Run("server_stream", func(t *testing.T) { + ctx := context.Background() + serverStream, err := client.CountUp(ctx, &pingv1.CountUpRequest{Number: 1}) + assert.Nil(t, err) + t.Cleanup(func() { + assert.Nil(t, serverStream.Close()) + }) + }) + t.Run("bidi_stream", func(t *testing.T) { + ctx := context.Background() + bidiStream, err := client.CumSum(ctx) + assert.Nil(t, err) + t.Cleanup(func() { + assert.Nil(t, bidiStream.CloseSend()) + assert.Nil(t, bidiStream.Close()) + }) + assert.Nil(t, bidiStream.Send(&pingv1.CumSumRequest{})) + }) + } + + t.Run("connect", func(t *testing.T) { + t.Parallel() + run(t, http.MethodPost) + }) + t.Run("connect+get", func(t *testing.T) { + t.Parallel() + run(t, http.MethodGet, + connecthttp.WithHTTPGet(), + connecthttp.WithSendCompression("gzip"), + ) + }) + t.Run("grpc", func(t *testing.T) { + t.Parallel() + run(t, http.MethodPost, connecthttp.WithGRPC()) + }) + t.Run("grpcweb", func(t *testing.T) { + t.Parallel() + run(t, http.MethodPost, connecthttp.WithGRPCWeb()) + }) +} + +func TestGetNotModified(t *testing.T) { + t.Parallel() + + const etag = "some-etag" + // Handlers should automatically set Vary to include request headers that are + // part of the RPC protocol. + expectVary := []string{"Accept-Encoding"} + + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, ¬ModifiedPingServer{etag: etag}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL(), + connecthttp.WithHTTPGet())), + ) + // unconditional request + ctx, info := connect.NewClientContext(t.Context()) + _, err := client.Ping(ctx, &pingv1.PingRequest{}) + assert.Nil(t, err) + etagValue := info.ResponseHeader().Get("Etag") + assert.Equal(t, etagValue, etag) + assert.Equal(t, info.ResponseHeader().Values("Vary"), expectVary) + httpInfo, _ := info.TransportInfo.(*connecthttp.ClientInfo) + assert.Equal(t, http.MethodGet, httpInfo.HTTPMethod()) + + condCtx, condInfo := connect.NewClientContext(t.Context()) + condInfo.RequestHeader().Set("If-None-Match", etag) + _, err = client.Ping(condCtx, &pingv1.PingRequest{}) + assert.NotNil(t, err) + assert.Equal(t, connect.CodeOf(err), connect.CodeUnknown) + assert.True(t, connecthttp.IsNotModifiedError(err)) + var connectErr *connect.Error + assert.True(t, errors.As(err, &connectErr)) + condHTTPInfo, _ := condInfo.TransportInfo.(*connecthttp.ClientInfo) + assert.Equal(t, http.MethodGet, condHTTPInfo.HTTPMethod()) +} + +func TestNotModifiedOnlyForGet(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, &alwaysNotModifiedPingServer{}) + connecthttp.Mount(mux, srv) + // Clients never put a query string on a POST, so add one here: the handler + // must key the 304 off the HTTP method, not off the query. + server := memhttptest.NewServer(t, http.HandlerFunc(func(respWriter http.ResponseWriter, req *http.Request) { + req.URL.RawQuery = "cache-buster=1" + mux.ServeHTTP(respWriter, req) + })) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport( + server.Client(), + server.URL(), + ))) + + _, err := client.Ping(t.Context(), &pingv1.PingRequest{}) + assert.NotNil(t, err) + assert.Equal(t, connect.CodeOf(err), connect.CodeUnknown) + var connectErr *connect.Error + assert.True(t, errors.As(err, &connectErr)) + assert.Equal(t, connectErr.Message(), "not modified") +} + +func TestGetNoContentHeaders(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, &pingServer{}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, http.HandlerFunc(func(respWriter http.ResponseWriter, req *http.Request) { + if len(req.Header.Values("content-type")) > 0 || + len(req.Header.Values("content-encoding")) > 0 || + len(req.Header.Values("content-length")) > 0 { + http.Error(respWriter, "GET request should not include content headers", http.StatusBadRequest) + } + mux.ServeHTTP(respWriter, req) + })) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL(), + connecthttp.WithHTTPGet())), + ) + ctx, _ := connect.NewClientContext(t.Context()) + _, err := client.Ping(ctx, &pingv1.PingRequest{}) + assert.Nil(t, err) + clientInfo, _ := connecthttp.ClientInfoForContext(ctx) + assert.Equal(t, http.MethodGet, clientInfo.HTTPMethod()) +} + +func TestGetURLSizeBoundary(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, &pingServer{}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + call := func(options ...connecthttp.Option) (*connecthttp.ClientInfo, error) { + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport( + server.Client(), + server.URL(), + append([]connecthttp.Option{connecthttp.WithHTTPGet()}, options...)..., + ))) + ctx, _ := connect.NewClientContext(t.Context()) + _, err := client.Ping(ctx, &pingv1.PingRequest{Text: "boundary"}) + clientInfo, _ := connecthttp.ClientInfoForContext(ctx) + return clientInfo, err + } + + unlimited, err := call() + assert.Nil(t, err) + assert.Equal(t, unlimited.HTTPMethod(), http.MethodGet) + unlimitedURL := unlimited.RequestURL() + urlSize := len(unlimitedURL.String()) + + atLimit, err := call(connecthttp.WithHTTPGetMaxURLSize(urlSize, false)) + assert.Nil(t, err) + assert.Equal(t, atLimit.HTTPMethod(), http.MethodGet) + + atLimitWithFallback, err := call(connecthttp.WithHTTPGetMaxURLSize(urlSize, true)) + assert.Nil(t, err) + assert.Equal(t, atLimitWithFallback.HTTPMethod(), http.MethodGet) + + overLimit, err := call(connecthttp.WithHTTPGetMaxURLSize(urlSize-1, true)) + assert.Nil(t, err) + assert.Equal(t, overLimit.HTTPMethod(), http.MethodPost) +} + +func TestConnectionDropped(t *testing.T) { + t.Parallel() + ctx := t.Context() + for _, protocol := range []string{connect.ProtocolNameConnect, connect.ProtocolNameGRPC, connect.ProtocolNameGRPCWeb} { + var opts []connecthttp.Option + switch protocol { + case connect.ProtocolNameGRPC: + opts = []connecthttp.Option{connecthttp.WithGRPC()} + case connect.ProtocolNameGRPCWeb: + opts = []connecthttp.Option{connecthttp.WithGRPCWeb()} + } + t.Run(protocol, func(t *testing.T) { + t.Parallel() + httpClient := httpClientFunc(func(_ *http.Request) (*http.Response, error) { + return nil, io.EOF + }) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(httpClient, + "http://1.2.3.4", + opts...), + )) + t.Run("unary", func(t *testing.T) { + t.Parallel() + _, err := client.Ping(ctx, &pingv1.PingRequest{}) + assert.NotNil(t, err) + if !assert.Equal(t, connect.CodeOf(err), connect.CodeUnavailable) { + t.Logf("err = %v\n%#v", err, err) + } + }) + t.Run("stream", func(t *testing.T) { + t.Parallel() + svrStream, err := client.CountUp(ctx, &pingv1.CountUpRequest{}) + if err == nil { + t.Cleanup(func() { + assert.Nil(t, svrStream.Close()) + }) + _, err = svrStream.Receive() + } + assert.NotNil(t, err) + if !assert.Equal(t, connect.CodeOf(err), connect.CodeUnavailable) { + t.Logf("err = %v\n%#v", err, err) + } + }) + }) + } +} + +func TestSpecSchema(t *testing.T) { + t.Parallel() + asserter := &assertSchemaInterceptor{t} + mux := http.NewServeMux() + srv := connect.NewServer(asserter.ServerInterceptor) + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + + server := memhttptest.NewServer(t, mux) + ctx := t.Context() + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL()), asserter.ClientInterceptor), + ) + t.Run("unary", func(t *testing.T) { + t.Parallel() + _, err := client.Ping(ctx, &pingv1.PingRequest{}) + assert.Nil(t, err) + text := strings.Repeat(".", 256) + r, err := client.Ping(ctx, &pingv1.PingRequest{Text: text}) + assert.Nil(t, err) + assert.Equal(t, r.GetText(), text) + }) + t.Run("bidi_stream", func(t *testing.T) { + t.Parallel() + bidiStream, err := client.CumSum(ctx) + assert.Nil(t, err) + t.Cleanup(func() { + assert.Nil(t, bidiStream.CloseSend()) + assert.Nil(t, bidiStream.Close()) + }) + assert.Nil(t, bidiStream.Send(&pingv1.CumSumRequest{})) + }) +} + +func TestDynamicClient(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + client := connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL())) + + t.Run("unary", func(t *testing.T) { + t.Parallel() + desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.Ping") + assert.Nil(t, err) + methodDesc, ok := desc.(protoreflect.MethodDescriptor) + assert.True(t, ok) + + req := dynamicpb.NewMessage(methodDesc.Input()) + req.Set( + methodDesc.Input().Fields().ByName("number"), + protoreflect.ValueOfInt64(42), + ) + rsp := dynamicpb.NewMessage(methodDesc.Output()) + + err = client.CallUnary(t.Context(), connect.Spec{ + StreamType: connect.StreamTypeUnary, + IdempotencyLevel: connect.IdempotencyNoSideEffects, + Schema: methodDesc, + Procedure: "/connect.ping.v1.PingService/Ping", + }, req, rsp) + assert.Nil(t, err) + got := rsp.Get(methodDesc.Output().Fields().ByName("number")).Int() + assert.Equal(t, got, 42) + }) + t.Run("clientStream", func(t *testing.T) { + t.Parallel() + desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.Sum") + assert.Nil(t, err) + methodDesc, ok := desc.(protoreflect.MethodDescriptor) + assert.True(t, ok) + + req := dynamicpb.NewMessage(methodDesc.Input()) + req.Set( + methodDesc.Input().Fields().ByName("number"), + protoreflect.ValueOfInt64(42), + ) + rsp := dynamicpb.NewMessage(methodDesc.Output()) + + stream, err := client.CallClientStream(t.Context(), connect.Spec{ + StreamType: connect.StreamTypeClient, + Schema: methodDesc, + Procedure: "/connect.ping.v1.PingService/Sum", + }) + assert.Nil(t, err) + + assert.Nil(t, stream.Send(req)) + assert.Nil(t, stream.Send(req)) + assert.Nil(t, stream.CloseSend()) + assert.Nil(t, stream.Receive(rsp)) + + got := rsp.Get(methodDesc.Output().Fields().ByName("sum")).Int() + assert.Equal(t, got, 42*2) + }) + t.Run("serverStream", func(t *testing.T) { + t.Parallel() + desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.CountUp") + assert.Nil(t, err) + methodDesc, ok := desc.(protoreflect.MethodDescriptor) + assert.True(t, ok) + + req := dynamicpb.NewMessage(methodDesc.Input()) + req.Set( + methodDesc.Input().Fields().ByName("number"), + protoreflect.ValueOfInt64(2), + ) + rsp := dynamicpb.NewMessage(methodDesc.Output()) + + stream, err := client.CallServerStream(t.Context(), connect.Spec{ + StreamType: connect.StreamTypeServer, + Schema: methodDesc, + Procedure: "/connect.ping.v1.PingService/CountUp", + }, req) + if !assert.Nil(t, err) { + return + } + + for i := 1; true; i++ { + if err := stream.Receive(rsp); err != nil { + if errors.Is(err, io.EOF) { + break + } + t.Error(err) + break + } + got := rsp.Get(methodDesc.Output().Fields().ByName("number")).Int() + assert.Equal(t, got, int64(i)) + } + assert.Nil(t, stream.Close()) + }) + t.Run("bidi", func(t *testing.T) { + t.Parallel() + desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.CumSum") + assert.Nil(t, err) + methodDesc, ok := desc.(protoreflect.MethodDescriptor) + assert.True(t, ok) + + stream, err := client.CallClientStream(t.Context(), connect.Spec{ + StreamType: connect.StreamTypeBidi, + Schema: methodDesc, + Procedure: "/connect.ping.v1.PingService/CumSum", + }) + if !assert.Nil(t, err) { + return + } + + req := dynamicpb.NewMessage(methodDesc.Input()) + req.Set( + methodDesc.Input().Fields().ByName("number"), + protoreflect.ValueOfInt64(42), + ) + rsp := dynamicpb.NewMessage(methodDesc.Output()) + + assert.Nil(t, stream.Send(req)) + assert.Nil(t, stream.CloseSend()) + assert.Nil(t, stream.Receive(rsp)) + assert.Nil(t, stream.Close()) + got := rsp.Get(methodDesc.Output().Fields().ByName("sum")).Int() + assert.Equal(t, got, 42) + }) +} + +func TestClientDeadlineHandling(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping slow test") + } + + // Note that these tests are not able to reproduce issues with the race + // detector enabled. That's partly why the makefile only runs "slow" + // tests with the race detector disabled. + + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + svr := httptest.NewUnstartedServer(http.HandlerFunc(func(respWriter http.ResponseWriter, req *http.Request) { + if req.Context().Err() != nil { + return + } + mux.ServeHTTP(respWriter, req) + })) + svr.Config.ErrorLog = log.New(io.Discard, "", 0) //nolint:forbidigo + p := new(http.Protocols) + p.SetHTTP1(true) + p.SetUnencryptedHTTP2(true) + svr.Config.Protocols = p + svr.Start() + t.Cleanup(svr.Close) + + clientProtos := new(http.Protocols) + clientProtos.SetUnencryptedHTTP2(true) + client := svr.Client() + transport, ok := client.Transport.(*http.Transport) + assert.True(t, ok) + transport.Protocols = clientProtos + + // This case creates a new connection for each RPC to verify that timeouts during dialing + // won't cause issues. This is historically easier to reproduce, so it uses a smaller + // duration, no concurrency, and fewer iterations. This is important because if we used + // a new connection for each RPC in the bigger test scenario below, we'd encounter other + // issues related to overwhelming the loopback interface and exhausting ephemeral ports. + t.Run("dial", func(t *testing.T) { + t.Parallel() + transport, ok := client.Transport.(*http.Transport) + if !assert.True(t, ok) { + t.FailNow() + } + testClientDeadlineBruteForceLoop(t, + 5*time.Second, 5, 1, + func(ctx context.Context) (string, rpcErrors) { + httpClient := &http.Client{ + Transport: transport.Clone(), + } + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(httpClient, svr.URL))) + _, err := client.Ping(ctx, &pingv1.PingRequest{Text: "foo"}) + // Close all connections and make sure to give a little time for the OS to + // release socket resources to prevent resource exhaustion (such as running + // out of ephemeral ports). + httpClient.CloseIdleConnections() + time.Sleep(time.Millisecond / 2) + return pingv1connect.PingServicePingProcedure, rpcErrors{recvErr: err} + }, + ) + }) + + // This case creates significantly more load than the above one, but uses a normal + // client so pools and re-uses connections. It also uses all stream types to send + // messages, to make sure that all stream implementations handle deadlines correctly. + // The I/O errors related to deadlines are historically harder to reproduce, so it + // throws a lot more effort into reproducing, particularly a longer duration for + // which it will run. It also uses larger messages (by packing requests with + // unrecognized fields) and compression, to make it more likely to encounter the + // deadline in the middle of read and write operations. + t.Run("read-write", func(t *testing.T) { + t.Parallel() + + var extraField []byte + extraField = protowire.AppendTag(extraField, 999, protowire.BytesType) + extraData := make([]byte, 16*1024) + // use good random data so it's not very compressible + if _, err := rand.Read(extraData); err != nil { + t.Fatalf("failed to generate extra payload: %v", err) + return + } + extraField = protowire.AppendBytes(extraField, extraData) + + clientConnect := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(client, svr.URL, connecthttp.WithSendCompression("gzip")))) + clientGRPC := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(client, svr.URL, connecthttp.WithSendCompression("gzip"), connecthttp.WithGRPCWeb()))) + var count atomic.Int32 + testClientDeadlineBruteForceLoop(t, + 20*time.Second, 200, runtime.GOMAXPROCS(0), + func(ctx context.Context) (string, rpcErrors) { + var procedure string + var errs rpcErrors + rpcNum := count.Add(1) + var client pingv1connect.PingServiceClient + if rpcNum&4 == 0 { + client = clientConnect + } else { + client = clientGRPC + } + switch rpcNum & 3 { + case 0: + procedure = pingv1connect.PingServicePingProcedure + _, errs.recvErr = client.Ping(ctx, addUnrecognizedBytes(&pingv1.PingRequest{Text: "foo"}, extraField)) + case 1: + procedure = pingv1connect.PingServiceSumProcedure + stream, err := client.Sum(ctx) + if err != nil { + errs.recvErr = err + break + } + for range 3 { + errs.sendErr = stream.Send(addUnrecognizedBytes(&pingv1.SumRequest{Number: 1}, extraField)) + if errs.sendErr != nil { + break + } + } + _, errs.recvErr = stream.CloseAndReceive() + case 2: + procedure = pingv1connect.PingServiceCountUpProcedure + stream, err := client.CountUp(ctx, addUnrecognizedBytes(&pingv1.CountUpRequest{Number: 3}, extraField)) + errs.recvErr = err + if err == nil { + for { + if _, err := stream.Receive(); err != nil { + if !errors.Is(err, io.EOF) { + errs.recvErr = err + } + break + } + } + errs.closeRecvErr = stream.Close() + } + case 3: + procedure = pingv1connect.PingServiceCumSumProcedure + stream, err := client.CumSum(ctx) + if err != nil { + errs.recvErr = err + break + } + for range 3 { + errs.sendErr = stream.Send(addUnrecognizedBytes(&pingv1.CumSumRequest{Number: 1}, extraField)) + if _, errs.recvErr = stream.Receive(); errs.recvErr != nil { + break + } + } + errs.closeSendErr = stream.CloseSend() + errs.closeRecvErr = stream.Close() + } + return procedure, errs + }, + ) + }) +} + +func testClientDeadlineBruteForceLoop( + t *testing.T, + duration time.Duration, + iterationsPerDeadline int, + parallelism int, + loopBody func(ctx context.Context) (string, rpcErrors), +) { + t.Helper() + testContext, testCancel := context.WithTimeout(t.Context(), duration) + defer testCancel() + var rpcCount atomic.Int64 + + var wg sync.WaitGroup + for goroutine := range parallelism { + wg.Go(func() { + // We try a range of timeouts since the timing issue is sensitive + // to execution environment (e.g. CPU, memory, and network speeds). + // So the lower timeout values may be more likely to trigger an issue + // in faster environments; higher timeouts for slower environments. + const minTimeout = 10 * time.Microsecond + const maxTimeout = 2 * time.Millisecond + for { + for timeout := minTimeout; timeout <= maxTimeout; timeout += 10 * time.Microsecond { + for range iterationsPerDeadline { + if testContext.Err() != nil { + return + } + ctx, cancel := context.WithTimeout(t.Context(), timeout) + // We are intentionally not inheriting from testContext, which signals when the + // test loop should stop and return but need not influence the RPC deadline. + proc, errs := loopBody(ctx) //nolint:contextcheck + rpcCount.Add(1) + cancel() + type errCase struct { + err error + name string + allowEOF bool + } + errCases := []errCase{ + { + err: errs.sendErr, + name: "send error", + allowEOF: true, + }, + { + err: errs.recvErr, + name: "receive error", + }, + { + err: errs.closeSendErr, + name: "close-send error", + }, + { + err: errs.closeRecvErr, + name: "close-receive error", + }, + } + for _, errCase := range errCases { + err := errCase.err + if err == nil { + // operation completed before timeout, try again + continue + } + if errCase.allowEOF && errors.Is(err, io.EOF) { + continue + } + + if !assert.Equal(t, connect.CodeOf(err), connect.CodeDeadlineExceeded) { + var buf bytes.Buffer + _, _ = fmt.Fprintf(&buf, "actual %v from %s: %v\n%#v", errCase.name, proc, err, err) + for { + err = errors.Unwrap(err) + if err == nil { + break + } + _, _ = fmt.Fprintf(&buf, "\n caused by: %#v", err) + } + t.Log(buf.String()) + testCancel() + } + } + } + } + t.Logf("goroutine %d: repeating duration loop", goroutine) + } + }) + } + wg.Wait() + t.Logf("Issued %d RPCs.", rpcCount.Load()) +} + +type rpcErrors struct { + sendErr error + recvErr error + closeSendErr error + closeRecvErr error +} + +func addUnrecognizedBytes[M protoreflect.ProtoMessage](msg M, data []byte) M { + msg.ProtoReflect().SetUnknown(data) + return msg +} + +type notModifiedPingServer struct { + pingv1connect.UnimplementedPingServiceHandler + + etag string +} + +func (s *notModifiedPingServer) Ping( + ctx context.Context, + _ *pingv1.PingRequest, +) (*pingv1.PingResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + ifNoneMatch := info.RequestHeader().Get("If-None-Match") + serverInfo, _ := connecthttp.ServerInfoForContext(ctx) + if serverInfo.HTTPMethod() == http.MethodGet && ifNoneMatch == s.etag { + info.ResponseHeader().Set("Etag", s.etag) + return nil, connecthttp.NewNotModifiedError() + } + info.ResponseHeader().Set("Etag", s.etag) + return &pingv1.PingResponse{}, nil +} + +type alwaysNotModifiedPingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (*alwaysNotModifiedPingServer) Ping( + _ context.Context, + _ *pingv1.PingRequest, +) (*pingv1.PingResponse, error) { + return nil, connecthttp.NewNotModifiedError() +} + +func assertPeerInterceptor(tb testing.TB) connect.ClientInterceptor { + tb.Helper() + return func(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + // The transport populates peer info on the CallInfo when it opens + // the stream inside next, so observe it once next returns. + stream, err := next(ctx, spec) + info, _ := connect.CallInfoForClientContext(ctx) + assert.NotZero(tb, info.PeerAddr) + assert.NotZero(tb, info.Protocol) + assert.NotZero(tb, spec) + return stream, err + } + } +} + +type assertSchemaInterceptor struct { + tb testing.TB +} + +func (a *assertSchemaInterceptor) ClientInterceptor(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + a.assertSchema(spec) + return next(ctx, spec) + } +} + +func (a *assertSchemaInterceptor) ServerInterceptor(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + a.assertSchema(spec) + return next(ctx, spec, stream) + } +} + +func (a *assertSchemaInterceptor) assertSchema(spec connect.Spec) { + if !assert.NotNil(a.tb, spec.Schema) { + return + } + methodDesc, ok := spec.Schema.(protoreflect.MethodDescriptor) + if assert.True(a.tb, ok) { + procedure := fmt.Sprintf("/%s/%s", methodDesc.Parent().FullName(), methodDesc.Name()) + assert.Equal(a.tb, procedure, spec.Procedure) + } +} + +type httpClientFunc func(*http.Request) (*http.Response, error) + +func (fn httpClientFunc) Do(req *http.Request) (*http.Response, error) { + return fn(req) +} diff --git a/connecthttp/client_get_fallback_test.go b/connecthttp/client_get_fallback_test.go new file mode 100644 index 00000000..a3d50a58 --- /dev/null +++ b/connecthttp/client_get_fallback_test.go @@ -0,0 +1,53 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp_test + +import ( + "net/http" + "strings" + "testing" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + "connectrpc.com/connect/v2/internal/assert" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2/internal/memhttp/memhttptest" +) + +func TestClientUnaryGetFallback(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL(), + connecthttp.WithHTTPGet(), + connecthttp.WithHTTPGetMaxURLSize(1, true), + connecthttp.WithSendCompression("gzip"), + ))) + ctx := t.Context() + + _, err := client.Ping(ctx, &pingv1.PingRequest{}) + assert.Nil(t, err) + + text := strings.Repeat(".", 256) + r, err := client.Ping(ctx, &pingv1.PingRequest{Text: text}) + assert.Nil(t, err) + assert.Equal(t, r.GetText(), text) +} diff --git a/connecthttp/client_stream.go b/connecthttp/client_stream.go new file mode 100644 index 00000000..9d95254c --- /dev/null +++ b/connecthttp/client_stream.go @@ -0,0 +1,216 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp + +import ( + "errors" + "io" + + "connectrpc.com/connect/v2" +) + +// selectClientProtocol resolves a protocol name to the in-package protocol +// implementation used to build a client. +func selectClientProtocol(name string) (protocol, error) { + switch name { + case "", connect.ProtocolNameConnect: + return &protocolConnect{}, nil + case connect.ProtocolNameGRPC: + return &protocolGRPC{web: false}, nil + case connect.ProtocolNameGRPCWeb: + return &protocolGRPC{web: true}, nil + default: + return nil, connect.Errorf(connect.CodeInternal, "unknown protocol %q", name) + } +} + +// connectUnaryClientStream adapts a unary [streamingClientConn] to [connect.ClientStream]. +type connectUnaryClientStream struct { + conn streamingClientConn + info *connect.CallInfo + protoClient protocolClient + streamType connect.StreamType + + headerFlushed bool + sentOnce bool + sendClosed bool + rxEnd bool +} + +// flushHeader merges request metadata, then writes protocol headers. +func (s *connectUnaryClientStream) flushHeader() { + if s.headerFlushed { + return + } + s.headerFlushed = true + header := s.conn.RequestHeader() + if s.info != nil { + toHTTPHeader(header, s.info.RequestHeader()) + } + s.protoClient.WriteRequestHeader(s.streamType, header) +} + +func (s *connectUnaryClientStream) SendHeaders() error { + s.flushHeader() + return nil +} + +func (s *connectUnaryClientStream) Send(msg any) error { + if s.sendClosed { + return io.EOF + } + if s.sentOnce { + return errors.New("connecthttp: unary stream sent more than once") + } + s.sentOnce = true + s.flushHeader() + return s.conn.Send(msg) +} + +func (s *connectUnaryClientStream) CloseSend() error { + s.sendClosed = true + s.flushHeader() + return s.conn.CloseRequest() +} + +func (s *connectUnaryClientStream) Receive(dst any) error { + if s.rxEnd { + return io.EOF + } + s.rxEnd = true + return receiveUnaryResponse(s.conn, dst, s.info) +} + +// receiveUnaryResponse reads the single response message, mapping a cardinality +// violation to CodeUnimplemented, then syncs trailers and closes the response. +func receiveUnaryResponse(conn streamingClientConn, dst any, info *connect.CallInfo) error { + err := conn.Receive(dst) + if err == nil { + if drainErr := conn.Receive(dst); drainErr == nil { + err = connect.Errorf(connect.CodeUnimplemented, "unary stream has multiple messages") + } else if !errors.Is(drainErr, io.EOF) { + err = drainErr + } + } else if errors.Is(err, io.EOF) { + err = connect.Errorf(connect.CodeUnimplemented, "unary stream has zero messages") + } + if info != nil { + fromHTTPHeader(info.ResponseHeader(), conn.ResponseHeader()) + fromHTTPHeader(info.ResponseTrailer(), conn.ResponseTrailer()) + } + _ = conn.CloseResponse() + return err +} + +func (s *connectUnaryClientStream) Close() error { + _ = s.conn.CloseRequest() + return s.conn.CloseResponse() +} + +// connectStreamingClientStream adapts a streaming [streamingClientConn] to [connect.ClientStream]. +type connectStreamingClientStream struct { + conn streamingClientConn + info *connect.CallInfo + protoClient protocolClient + streamType connect.StreamType + + headerFlushed bool + syncedHeader bool + rxEnd bool +} + +// flushHeader merges request metadata, then writes protocol headers. +func (s *connectStreamingClientStream) flushHeader() { + if s.headerFlushed { + return + } + s.headerFlushed = true + header := s.conn.RequestHeader() + if s.info != nil { + toHTTPHeader(header, s.info.RequestHeader()) + } + s.protoClient.WriteRequestHeader(s.streamType, header) +} + +// SendHeaders opens the stream without sending a message. +func (s *connectStreamingClientStream) SendHeaders() error { + s.flushHeader() + return s.conn.Send(nil) +} + +func (s *connectStreamingClientStream) Send(msg any) error { + s.flushHeader() + return s.conn.Send(msg) +} + +func (s *connectStreamingClientStream) CloseSend() error { + s.flushHeader() + return s.conn.CloseRequest() +} + +func (s *connectStreamingClientStream) Receive(dst any) error { + if s.streamType == connect.StreamTypeClient { + if s.rxEnd { + return io.EOF + } + s.rxEnd = true + return receiveUnaryResponse(s.conn, dst, s.info) + } + err := s.conn.Receive(dst) + if !s.syncedHeader && s.info != nil { + fromHTTPHeader(s.info.ResponseHeader(), s.conn.ResponseHeader()) + s.syncedHeader = true + } + if err != nil { + // Stream ended or failed: trailers are now available. + if s.info != nil { + fromHTTPHeader(s.info.ResponseTrailer(), s.conn.ResponseTrailer()) + } + _ = s.conn.CloseResponse() + } + return err +} + +func (s *connectStreamingClientStream) Close() error { + _ = s.conn.CloseRequest() + return s.conn.CloseResponse() +} + +// newProtocolClient builds a v1 protocol client for spec from the transport's +// resolved options, adapting the v2 codec/compressors to the in-package types. +func (t *transport) newProtocolClient(spec connect.Spec, opts *options) (protocolClient, error) { + proto, err := selectClientProtocol(opts.protocol) + if err != nil { + return nil, err + } + pools := make(map[string]*compressionPool, len(opts.compressors)) + for name, compressor := range opts.compressors { + pools[name] = newCompressionPool(compressor) + } + return proto.NewClient(&protocolClientParams{ + CompressionName: opts.sendCompressor, + CompressionPools: newReadOnlyCompressionPools(pools, opts.compressorNames), + Codec: opts.codecs[opts.sendCodecName], + Protobuf: newReadOnlyCodecs(opts.codecs).Protobuf(), + CompressMinBytes: opts.compressMinBytes, + HTTPClient: t.httpClient, + URL: t.urlForProcedure(spec.Procedure), + ReadMaxBytes: opts.readMaxBytes, + SendMaxBytes: opts.sendMaxBytes, + EnableGet: opts.getEnabled, + GetURLMaxBytes: opts.getMaxURLBytes, + GetUseFallback: opts.getUseFallback, + }) +} diff --git a/connecthttp/codec.go b/connecthttp/codec.go new file mode 100644 index 00000000..47b34c5d --- /dev/null +++ b/connecthttp/codec.go @@ -0,0 +1,69 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp + +import ( + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectproto" +) + +const ( + codecNameJSONCharsetUTF8 = connect.CodecNameJSON + "; charset=utf-8" +) + +// readOnlyCodecs is a read-only interface to a map of named codecs. +type readOnlyCodecs interface { + // Get gets the Codec with the given name. + Get(string) connect.Codec + // Protobuf gets the user-supplied protobuf codec, falling back to the default + // implementation if necessary. + // + // This is helpful in the gRPC protocol, where the wire protocol requires + // marshaling protobuf structs to binary even if the RPC procedures were + // generated from a different IDL. + Protobuf() connect.Codec + // Names returns a copy of the registered codec names. The returned slice is + // safe for the caller to mutate. + Names() []string +} + +func newReadOnlyCodecs(nameToCodec map[string]connect.Codec) readOnlyCodecs { + return &codecMap{ + nameToCodec: nameToCodec, + } +} + +type codecMap struct { + nameToCodec map[string]connect.Codec +} + +func (m *codecMap) Get(name string) connect.Codec { + return m.nameToCodec[name] +} + +func (m *codecMap) Protobuf() connect.Codec { + if pb, ok := m.nameToCodec[connect.CodecNameProto]; ok { + return pb + } + return &connectproto.BinaryCodec{} +} + +func (m *codecMap) Names() []string { + names := make([]string, 0, len(m.nameToCodec)) + for name := range m.nameToCodec { + names = append(names, name) + } + return names +} diff --git a/codec_test.go b/connecthttp/codec_test.go similarity index 53% rename from codec_test.go rename to connecthttp/codec_test.go index 46e29399..d12b5a11 100644 --- a/codec_test.go +++ b/connecthttp/codec_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "bytes" @@ -20,8 +20,10 @@ import ( "testing" "testing/quick" - "connectrpc.com/connect/internal/assert" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectproto" + "connectrpc.com/connect/v2/internal/assert" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/structpb" @@ -37,90 +39,61 @@ func convertMapToInterface(stringMap map[string]string) map[string]any { func TestCodecRoundTrips(t *testing.T) { t.Parallel() - makeRoundtrip := func(codec Codec) func(string, int64) bool { + makeRoundtrip := func(codec connect.Codec) func(string, int64) bool { return func(text string, number int64) bool { - got := pingv1.PingRequest{} want := pingv1.PingRequest{Text: text, Number: number} - data, err := codec.Marshal(&want) - if err != nil { - t.Fatal(err) - } - err = codec.Unmarshal(data, &got) - if err != nil { + var buffer bytes.Buffer + if err := codec.MarshalWrite(t.Context(), &buffer, &want); err != nil { t.Fatal(err) } - return proto.Equal(&got, &want) - } - } - if err := quick.Check(makeRoundtrip(&protoBinaryCodec{}), nil /* config */); err != nil { - t.Error(err) - } - if err := quick.Check(makeRoundtrip(&protoJSONCodec{}), nil /* config */); err != nil { - t.Error(err) - } -} - -func TestAppendCodec(t *testing.T) { - t.Parallel() - makeRoundtrip := func(codec marshalAppender) func(string, int64) bool { - var data []byte - return func(text string, number int64) bool { got := pingv1.PingRequest{} - want := pingv1.PingRequest{Text: text, Number: number} - data = data[:0] - var err error - data, err = codec.MarshalAppend(data, &want) - if err != nil { - t.Fatal(err) - } - err = codec.Unmarshal(data, &got) - if err != nil { + if err := codec.UnmarshalRead(t.Context(), &buffer, &got); err != nil { t.Fatal(err) } return proto.Equal(&got, &want) } } - if err := quick.Check(makeRoundtrip(&protoBinaryCodec{}), nil /* config */); err != nil { + if err := quick.Check(makeRoundtrip(&connectproto.BinaryCodec{}), nil /* config */); err != nil { t.Error(err) } - if err := quick.Check(makeRoundtrip(&protoJSONCodec{}), nil /* config */); err != nil { + if err := quick.Check(makeRoundtrip(&connectproto.JSONCodec{}), nil /* config */); err != nil { t.Error(err) } } func TestStableCodec(t *testing.T) { t.Parallel() - makeRoundtrip := func(codec stableCodec) func(map[string]string) bool { + makeRoundtrip := func(codec connect.StableCodec) func(map[string]string) bool { return func(input map[string]string) bool { initialProto, err := structpb.NewStruct(convertMapToInterface(input)) if err != nil { t.Fatal(err) } - want, err := codec.MarshalStable(initialProto) - if err != nil { + var buffer bytes.Buffer + if err := codec.MarshalWriteStable(t.Context(), &buffer, initialProto); err != nil { t.Fatal(err) } + want := buffer.Bytes() for range 10 { roundtripProto := &structpb.Struct{} - err = codec.Unmarshal(want, roundtripProto) - if err != nil { + if err := codec.UnmarshalRead(t.Context(), bytes.NewReader(want), roundtripProto); err != nil { t.Fatal(err) } - got, err := codec.MarshalStable(roundtripProto) - if err != nil { + var got bytes.Buffer + if err := codec.MarshalWriteStable(t.Context(), &got, roundtripProto); err != nil { t.Fatal(err) } - if !bytes.Equal(got, want) { + if !bytes.Equal(got.Bytes(), want) { return false } } return true } } - if err := quick.Check(makeRoundtrip(&protoBinaryCodec{}), nil /* config */); err != nil { + if err := quick.Check(makeRoundtrip(&connectproto.BinaryCodec{}), nil /* config */); err != nil { t.Error(err) } - if err := quick.Check(makeRoundtrip(&protoJSONCodec{}), nil /* config */); err != nil { + if err := quick.Check(makeRoundtrip(&connectproto.JSONCodec{}), nil /* config */); err != nil { t.Error(err) } } @@ -128,23 +101,23 @@ func TestStableCodec(t *testing.T) { func TestJSONCodec(t *testing.T) { t.Parallel() - codec := &protoJSONCodec{name: codecNameJSON} + codec := connectproto.NewJSONCodec() t.Run("success", func(t *testing.T) { t.Parallel() - err := codec.Unmarshal([]byte("{}"), &emptypb.Empty{}) + err := codec.UnmarshalRead(t.Context(), strings.NewReader("{}"), &emptypb.Empty{}) assert.Nil(t, err) }) t.Run("unknown fields", func(t *testing.T) { t.Parallel() - err := codec.Unmarshal([]byte(`{"foo": "bar"}`), &emptypb.Empty{}) + err := codec.UnmarshalRead(t.Context(), strings.NewReader(`{"foo": "bar"}`), &emptypb.Empty{}) assert.Nil(t, err) }) t.Run("empty string", func(t *testing.T) { t.Parallel() - err := codec.Unmarshal([]byte{}, &emptypb.Empty{}) + err := codec.UnmarshalRead(t.Context(), strings.NewReader(""), &emptypb.Empty{}) assert.NotNil(t, err) assert.True( t, diff --git a/connecthttp/compression.go b/connecthttp/compression.go new file mode 100644 index 00000000..abf31a06 --- /dev/null +++ b/connecthttp/compression.go @@ -0,0 +1,133 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp + +import ( + "bytes" + "io" + "math" + "slices" + "strings" + + "connectrpc.com/connect/v2" +) + +type compressionPool struct { + compressor connect.Compressor +} + +func newCompressionPool(compressor connect.Compressor) *compressionPool { + if compressor == nil { + return nil + } + return &compressionPool{ + compressor: compressor, + } +} + +func (c *compressionPool) Decompress(dst *bytes.Buffer, src *bytes.Buffer, readMaxBytes int64) *connect.Error { + decompressor, err := c.compressor.Decompress(src) + if err != nil { + return connect.Errorf(connect.CodeInvalidArgument, "get decompressor: %s", err).WithCause(err) + } + defer decompressor.Close() + reader := io.Reader(decompressor) + if readMaxBytes > 0 && readMaxBytes < math.MaxInt64 { + reader = io.LimitReader(decompressor, readMaxBytes+1) + } + bytesRead, err := dst.ReadFrom(reader) + if err != nil { + err = wrapIfContextError(err) + if connectErr, ok := asError(err); ok { + return connectErr + } + return connect.Errorf(connect.CodeInvalidArgument, "decompress: %s", err).WithCause(err) + } + if readMaxBytes > 0 && bytesRead > readMaxBytes { + discardedBytes, err := io.Copy(io.Discard, decompressor) + if err != nil { + return connect.Errorf(connect.CodeResourceExhausted, "message is larger than configured max %d - unable to determine message size: %s", readMaxBytes, err).WithCause(err) + } + return connect.Errorf(connect.CodeResourceExhausted, "message size %d is larger than configured max %d", bytesRead+discardedBytes, readMaxBytes) + } + return nil +} + +func (c *compressionPool) Compress(dst *bytes.Buffer, src *bytes.Buffer) *connect.Error { + compressor, err := c.compressor.Compress(dst) + if err != nil { + return connect.Errorf(connect.CodeUnknown, "get compressor: %s", err).WithCause(err) + } + defer compressor.Close() + if _, err := src.WriteTo(compressor); err != nil { + err = wrapIfContextError(err) + if connectErr, ok := asError(err); ok { + return connectErr + } + return connect.Errorf(connect.CodeInternal, "compress: %s", err).WithCause(err) + } + return nil +} + +// readOnlyCompressionPools is a read-only interface to a map of named +// compressionPools. +type readOnlyCompressionPools interface { + Get(string) *compressionPool + Contains(string) bool + // Wordy, but clarifies how this is different from readOnlyCodecs.Names(). + CommaSeparatedNames() string +} + +func newReadOnlyCompressionPools( + nameToPool map[string]*compressionPool, + reversedNames []string, +) readOnlyCompressionPools { + // Client and handler configs keep compression names in registration order, + // but we want the last registered to be the most preferred. + names := make([]string, 0, len(reversedNames)) + seen := make(map[string]struct{}, len(reversedNames)) + for _, name := range slices.Backward(reversedNames) { + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + names = append(names, name) + } + return &namedCompressionPools{ + nameToPool: nameToPool, + commaSeparatedNames: strings.Join(names, ","), + } +} + +type namedCompressionPools struct { + nameToPool map[string]*compressionPool + commaSeparatedNames string +} + +func (m *namedCompressionPools) Get(name string) *compressionPool { + if name == "" || name == connect.CompressionNameIdentity { + return nil + } + return m.nameToPool[name] +} + +func (m *namedCompressionPools) Contains(name string) bool { + _, ok := m.nameToPool[name] + return ok +} + +func (m *namedCompressionPools) CommaSeparatedNames() string { + return m.commaSeparatedNames +} diff --git a/connecthttp/compression_test.go b/connecthttp/compression_test.go new file mode 100644 index 00000000..27e5b865 --- /dev/null +++ b/connecthttp/compression_test.go @@ -0,0 +1,95 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp + +import ( + "io" + "testing" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/internal/assert" +) + +func TestCompressionOption(t *testing.T) { + t.Parallel() + + apply := func(opts ...Option) *options { + o := defaultOptions() + for _, opt := range opts { + opt.apply(&o) + } + return &o + } + checkPools := func(t *testing.T, opts *options) { + t.Helper() + assert.Equal(t, len(opts.compressorNames), len(opts.compressors)) + for _, name := range opts.compressorNames { + assert.NotNil(t, opts.compressors[name]) + } + } + + t.Run("defaults", func(t *testing.T) { + t.Parallel() + opts := apply() + assert.Equal(t, opts.compressorNames, []string{connect.CompressionNameGzip}) + checkPools(t, opts) + }) + t.Run("withCompressors registers and accepts", func(t *testing.T) { + t.Parallel() + opts := apply(WithCompressor(identityCompressor{})) + assert.Equal(t, opts.compressorNames, []string{connect.CompressionNameGzip, connect.CompressionNameIdentity}) + checkPools(t, opts) + }) + t.Run("withNoCompression-disables", func(t *testing.T) { + t.Parallel() + opts := apply(WithNoCompression()) + assert.Equal(t, opts.compressorNames, nil) + assert.Equal(t, len(opts.compressors), 0) + }) + t.Run("withAcceptCompression-selects-name", func(t *testing.T) { + t.Parallel() + opts := apply(WithAcceptCompression("br")) + assert.Equal(t, opts.compressorNames, []string{connect.CompressionNameGzip, "br"}) + }) + t.Run("withAcceptCompression-empty-name-noop", func(t *testing.T) { + t.Parallel() + opts := apply(WithAcceptCompression("")) + assert.Equal(t, opts.compressorNames, []string{connect.CompressionNameGzip}) + }) + t.Run("withAcceptCompression-deduplicates", func(t *testing.T) { + t.Parallel() + opts := apply(WithAcceptCompression(connect.CompressionNameGzip)) + assert.Equal(t, opts.compressorNames, []string{connect.CompressionNameGzip}) + }) +} + +// identityCompressor is a test [connect.Compressor] that performs no +// compression: Compress and Decompress pass bytes through unchanged. +type identityCompressor struct{} + +func (identityCompressor) Name() string { return connect.CompressionNameIdentity } + +func (identityCompressor) Compress(dst io.Writer) (io.WriteCloser, error) { + return identityWriteCloser{dst}, nil +} + +func (identityCompressor) Decompress(src io.Reader) (io.ReadCloser, error) { + return io.NopCloser(src), nil +} + +// identityWriteCloser adapts an io.Writer to io.WriteCloser with a no-op Close. +type identityWriteCloser struct{ io.Writer } + +func (identityWriteCloser) Close() error { return nil } diff --git a/connecthttp/connect.go b/connecthttp/connect.go new file mode 100644 index 00000000..dd62e4cc --- /dev/null +++ b/connecthttp/connect.go @@ -0,0 +1,96 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp + +import ( + "net/http" + "net/url" + + "connectrpc.com/connect/v2" +) + +// streamingHandlerConn is the server's view of a bidirectional message +// exchange. Interceptors for streaming RPCs may wrap StreamingHandlerConns. +// +// Like the standard library's [http.ResponseWriter], StreamingHandlerConns write +// response headers to the network with the first call to Send. Any subsequent +// mutations are effectively no-ops. Handlers may mutate response trailers at +// any time before returning. When the client has finished sending data, +// Receive returns an error wrapping [io.EOF]. Handlers should check for this +// using the standard library's [errors.Is]. +// +// Headers and trailers beginning with "Connect-" and "Grpc-" are reserved for +// use by the gRPC and Connect protocols: applications may read them but +// shouldn't write them. +// +// streamingHandlerConn implementations provided by this module guarantee that +// all returned errors can be cast to [*connect.Error] using the standard library's +// [errors.As]. +// +// streamingHandlerConn implementations provided by this module support limited +// concurrent use: the read side (Receive, RequestHeader) may be called +// concurrently with the write side (Send, ResponseHeader, ResponseTrailer), but +// the read side must not be called concurrently with itself, and the write side +// must not be called concurrently with itself. +type streamingHandlerConn interface { + Spec() connect.Spec + Peer() peer + + // Receive and RequestHeader form the read side of the stream. They are not + // safe to call concurrently with each other, but may be called concurrently + // with Send, ResponseHeader, and ResponseTrailer. + Receive(any) error + RequestHeader() http.Header + + // Send, ResponseHeader, and ResponseTrailer form the write side of the + // stream. They are not safe to call concurrently with each other, but may + // be called concurrently with Receive and RequestHeader. + Send(any) error + ResponseHeader() http.Header + ResponseTrailer() http.Header +} + +// peer describes the other party to an RPC. +// +// When accessed client-side, Addr contains the host or host:port from the +// server's URL. When accessed server-side, Addr contains the client's address +// in IP:port format. +// +// On both the client and the server, Protocol is the RPC protocol in use. +// Currently, it's either [connect.ProtocolNameConnect], [connect.ProtocolNameGRPC], +// or [connect.ProtocolNameGRPCWeb], but additional protocols may be added in the future. +// +// Query contains the query parameters for the request. For the server, this +// will reflect the actual query parameters sent. For the client, it is unset. +type peer struct { + Addr string + Protocol string + Query url.Values // server-only +} + +func newPeerForURL(url *url.URL, protocol string) peer { + return peer{ + Addr: url.Host, + Protocol: protocol, + } +} + +// handlerConnCloser extends streamingHandlerConn with a method for handlers to +// terminate the message exchange (and optionally send an error to the client). +type handlerConnCloser interface { + streamingHandlerConn + + Close(error) error +} diff --git a/connect_ext_test.go b/connecthttp/connect_ext_test.go similarity index 51% rename from connect_ext_test.go rename to connecthttp/connect_ext_test.go index fc6bb581..eb18b2fc 100644 --- a/connect_ext_test.go +++ b/connecthttp/connect_ext_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect_test +package connecthttp_test import ( "bytes" @@ -20,6 +20,7 @@ import ( "compress/gzip" "context" "encoding/binary" + "encoding/json" "errors" "fmt" "io" @@ -36,14 +37,15 @@ import ( "testing" "time" - connect "connectrpc.com/connect" - "connectrpc.com/connect/internal/assert" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/generics/connect/import/v1/importv1connect" - "connectrpc.com/connect/internal/gen/generics/connect/ping/v1/pingv1connect" - pingv1connectsimple "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" - "connectrpc.com/connect/internal/memhttp" - "connectrpc.com/connect/internal/memhttp/memhttptest" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + "connectrpc.com/connect/v2/connectproto" + "connectrpc.com/connect/v2/internal/assert" + "connectrpc.com/connect/v2/internal/gen/connect/import/v1/importv1connect" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2/internal/memhttp" + "connectrpc.com/connect/v2/internal/memhttp/memhttptest" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoregistry" "google.golang.org/protobuf/types/known/wrapperspb" @@ -70,14 +72,15 @@ func TestCallInfo(t *testing.T) { t.Run("simple_api", func(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connectsimple.NewPingServiceHandler( - pingServerSimple{}, - )) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) - client := pingv1connectsimple.NewPingServiceClient(server.Client(), server.URL()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) t.Run("unary", func(t *testing.T) { t.Parallel() - testUnarySimple(t, client) + testUnary(t, client) }) t.Run("unary_no_callinfo", func(t *testing.T) { t.Parallel() @@ -90,16 +93,17 @@ func TestCallInfo(t *testing.T) { t.Run("unary_generics_server", func(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{}, - )) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) - simpleClient := pingv1connectsimple.NewPingServiceClient(server.Client(), server.URL()) - testUnarySimple(t, simpleClient) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + testUnary(t, client) }) t.Run("server_stream", func(t *testing.T) { t.Parallel() - testServerStreamSimple(t, client) + testServerStream(t, client) }) t.Run("server_stream_no_callinfo", func(t *testing.T) { t.Parallel() @@ -111,291 +115,125 @@ func TestCallInfo(t *testing.T) { // Receive expected messages for idx := range val { expected := int64(idx + 1) - assert.True(t, stream.Receive()) - assert.Nil(t, stream.Err()) - msg := stream.Msg() + msg, err := stream.Receive() + assert.Nil(t, err) assert.NotNil(t, msg) assert.Equal(t, msg.GetNumber(), expected) } - - // Stream should be done. Expect false on receive and close stream - assert.False(t, stream.Receive()) - assert.Nil(t, stream.Err()) + _, err = stream.Receive() + assert.True(t, errors.Is(err, io.EOF)) assert.Nil(t, stream.Close()) }) t.Run("server_stream_generics_server", func(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{}, - )) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) - simpleClient := pingv1connectsimple.NewPingServiceClient(server.Client(), server.URL()) - testServerStreamSimple(t, simpleClient) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + testServerStream(t, client) }) t.Run("client_stream", func(t *testing.T) { t.Parallel() - testClientStreamSimple(t, client) + testClientStream(t, client) }) t.Run("client_stream_generics_server", func(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{}, - )) - server := memhttptest.NewServer(t, mux) - simpleClient := pingv1connectsimple.NewPingServiceClient(server.Client(), server.URL()) - testClientStreamSimple(t, simpleClient) - }) - t.Run("client_stream_no_callinfo", func(t *testing.T) { - t.Parallel() - const ( - upTo = 10 - expect = 55 // 1+10 + 2+9 + ... + 5+6 = 55 - ) - stream, err := client.Sum(t.Context()) - assert.Nil(t, err) - - // Send messages - for i := range upTo { - err := stream.Send(&pingv1.SumRequest{Number: int64(i + 1)}) - assert.Nil(t, err, assert.Sprintf("send %d", i)) - } + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) - response, err := stream.CloseAndReceive() - assert.Nil(t, err) - assert.Equal(t, response.GetSum(), expect) + server := memhttptest.NewServer(t, mux) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + testClientStream(t, client) }) t.Run("bidi_stream", func(t *testing.T) { t.Parallel() - testBidiStreamSimple(t, client) + testBidiStream(t, client, true) }) t.Run("bidi_stream_generics_server", func(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{}, - )) - server := memhttptest.NewServer(t, mux) - simpleClient := pingv1connectsimple.NewPingServiceClient(server.Client(), server.URL()) - testBidiStreamSimple(t, simpleClient) - }) - t.Run("bidi_stream_no_callinfo", func(t *testing.T) { - t.Parallel() - send := []int64{3, 5, 1} - expect := []int64{3, 8, 9} - var got []int64 - stream, err := client.CumSum(t.Context()) - assert.Nil(t, err) - assert.NotNil(t, stream) - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - for i, n := range send { - err := stream.Send(&pingv1.CumSumRequest{Number: n}) - assert.Nil(t, err, assert.Sprintf("send error #%d", i)) - } - assert.Nil(t, stream.CloseRequest()) - }() - go func() { - defer wg.Done() - for { - msg, err := stream.Receive() - if errors.Is(err, io.EOF) { - break - } - assert.Nil(t, err) - got = append(got, msg.GetSum()) - } - assert.Nil(t, stream.CloseResponse()) - }() - wg.Wait() - assert.Equal(t, got, expect) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) - // Assert values on stream - assert.Equal(t, stream.Spec().StreamType, connect.StreamTypeBidi) - assert.Equal(t, stream.Spec().Procedure, pingv1connect.PingServiceCumSumProcedure) - assert.True(t, stream.Spec().IsClient) - assert.Equal(t, stream.Peer().Addr, httptest.DefaultRemoteAddr) + server := memhttptest.NewServer(t, mux) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + testBidiStream(t, client, true) }) }) t.Run("generics_api", func(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{}, - )) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) t.Run("unary", func(t *testing.T) { t.Parallel() - testUnaryGenerics(t, client) + testUnary(t, client) }) t.Run("unary_simple_server", func(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connectsimple.NewPingServiceHandler( - pingServerSimple{}, - )) - server := memhttptest.NewServer(t, mux) - genericsClient := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - testUnaryGenerics(t, genericsClient) - }) - t.Run("unary_no_callinfo", func(t *testing.T) { - t.Parallel() - num := int64(42) - request := connect.NewRequest(&pingv1.PingRequest{Number: num}) - request.Header().Add(clientHeader, "foo") - request.Header().Add(clientHeader, "bar") - expect := &pingv1.PingResponse{Number: num} + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) - response, err := client.Ping(t.Context(), request) - assert.Nil(t, err) - assert.Equal(t, response.Msg, expect) - assert.Equal(t, request.Spec().StreamType, connect.StreamTypeUnary) - assert.Equal(t, request.Spec().Procedure, pingv1connect.PingServicePingProcedure) - assert.True(t, request.Spec().IsClient) - assert.Equal(t, request.Peer().Addr, httptest.DefaultRemoteAddr) - // Wrap the response object so that it can implement the responseInfo interface and we can verify its response - // headers and trailers using the same function callInfo does - wrapper := &responseWrapper[pingv1.PingResponse]{response: response} - assertResponseHeadersAndTrailers(t, wrapper) + server := memhttptest.NewServer(t, mux) + genericsClient := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + testUnary(t, genericsClient) }) t.Run("server_stream", func(t *testing.T) { t.Parallel() - testServerStreamGenerics(t, client) + testServerStream(t, client) }) t.Run("server_stream_simple_server", func(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connectsimple.NewPingServiceHandler( - pingServerSimple{}, - )) - server := memhttptest.NewServer(t, mux) - genericsClient := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - testServerStreamGenerics(t, genericsClient) - }) - t.Run("server_stream_no_callinfo", func(t *testing.T) { - t.Parallel() - val := 3 - req := connect.NewRequest(&pingv1.CountUpRequest{ - Number: int64(val), - }) - req.Header().Set(clientHeader, "foo") - req.Header().Add(clientHeader, "bar") - - stream, err := client.CountUp(t.Context(), req) - assert.Nil(t, err) - // Receive expected messages - for idx := range val { - expected := int64(idx + 1) - assert.True(t, stream.Receive()) - assert.Nil(t, stream.Err()) - msg := stream.Msg() - assert.NotNil(t, msg) - assert.Equal(t, msg.GetNumber(), expected) - } + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) - // Stream should be done. Expect false on receive and close stream - assert.False(t, stream.Receive()) - assert.Nil(t, stream.Err()) - assert.Nil(t, stream.Close()) - // Assert values on request - assert.Equal(t, req.Spec().StreamType, connect.StreamTypeServer) - assert.Equal(t, req.Spec().Procedure, pingv1connect.PingServiceCountUpProcedure) - assert.True(t, req.Spec().IsClient) - assert.Equal(t, req.Peer().Addr, httptest.DefaultRemoteAddr) - assertResponseHeadersAndTrailers(t, stream) + server := memhttptest.NewServer(t, mux) + genericsClient := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + testServerStream(t, genericsClient) }) t.Run("client_stream", func(t *testing.T) { t.Parallel() - testClientStreamGenerics(t, client) + testClientStream(t, client) }) t.Run("client_stream_simple_server", func(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connectsimple.NewPingServiceHandler( - pingServerSimple{}, - )) - server := memhttptest.NewServer(t, mux) - genericsClient := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - testClientStreamGenerics(t, genericsClient) - }) - t.Run("client_stream_no_callinfo", func(t *testing.T) { - t.Parallel() - const ( - upTo = 10 - expect = 55 // 1+10 + 2+9 + ... + 5+6 = 55 - ) - stream := client.Sum(t.Context()) - stream.RequestHeader().Add(clientHeader, "foo") - stream.RequestHeader().Add(clientHeader, "bar") - - // Send messages - for i := range upTo { - err := stream.Send(&pingv1.SumRequest{Number: int64(i + 1)}) - assert.Nil(t, err, assert.Sprintf("send %d", i)) - } + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) - response, err := stream.CloseAndReceive() - assert.Nil(t, err) - assert.Equal(t, response.Msg.GetSum(), expect) - // Wrap the response object so that it can implement the responseInfo interface and we can verify its response - // headers and trailers using the same function callInfo does - wrapper := &responseWrapper[pingv1.SumResponse]{response: response} - assertResponseHeadersAndTrailers(t, wrapper) + server := memhttptest.NewServer(t, mux) + genericsClient := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + testClientStream(t, genericsClient) }) t.Run("bidi_stream", func(t *testing.T) { t.Parallel() - testBidiStreamGenerics(t, client, true) + testBidiStream(t, client, true) }) t.Run("bidi_stream_simple_server", func(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connectsimple.NewPingServiceHandler( - pingServerSimple{}, - )) - server := memhttptest.NewServer(t, mux) - genericsClient := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - testBidiStreamGenerics(t, genericsClient, true) - }) - t.Run("bidi_stream_no_callinfo", func(t *testing.T) { - t.Parallel() - send := []int64{3, 5, 1} - expect := []int64{3, 8, 9} - var got []int64 - stream := client.CumSum(t.Context()) - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - for i, n := range send { - err := stream.Send(&pingv1.CumSumRequest{Number: n}) - assert.Nil(t, err, assert.Sprintf("send error #%d", i)) - } - assert.Nil(t, stream.CloseRequest()) - }() - go func() { - defer wg.Done() - for { - msg, err := stream.Receive() - if errors.Is(err, io.EOF) { - break - } - assert.Nil(t, err) - got = append(got, msg.GetSum()) - } - assert.Nil(t, stream.CloseResponse()) - }() - wg.Wait() - assert.Equal(t, got, expect) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) - // Assert values on stream - assert.Equal(t, stream.Spec().StreamType, connect.StreamTypeBidi) - assert.Equal(t, stream.Spec().Procedure, pingv1connect.PingServiceCumSumProcedure) - assert.True(t, stream.Spec().IsClient) - assert.Equal(t, stream.Peer().Addr, httptest.DefaultRemoteAddr) + server := memhttptest.NewServer(t, mux) + genericsClient := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + testBidiStream(t, genericsClient, true) }) }) } @@ -405,22 +243,20 @@ func TestServer(t *testing.T) { testPing := func(t *testing.T, client pingv1connect.PingServiceClient) { //nolint:thelper t.Run("ping", func(t *testing.T) { t.Parallel() - testUnaryGenerics(t, client) + testUnary(t, client) }) t.Run("zero_ping", func(t *testing.T) { t.Parallel() - request := connect.NewRequest(&pingv1.PingRequest{}) + ctx, callInfo := connect.NewClientContext(t.Context()) + request := &pingv1.PingRequest{} for _, el := range expectedHeaderValues { - request.Header().Add(clientHeader, el) + callInfo.RequestHeader().Add(clientHeader, el) } - response, err := client.Ping(t.Context(), request) + response, err := client.Ping(ctx, request) assert.Nil(t, err) var expect pingv1.PingResponse - assert.Equal(t, response.Msg, &expect) - // Wrap the response object so that it can implement the responseInfo interface and we can verify its response - // headers and trailers using the same function callInfo does - wrapper := &responseWrapper[pingv1.PingResponse]{response: response} - assertResponseHeadersAndTrailers(t, wrapper) + assert.Equal(t, response, &expect) + assertResponseHeadersAndTrailers(t, callInfo) }) t.Run("large_ping", func(t *testing.T) { t.Parallel() @@ -430,24 +266,22 @@ func TestServer(t *testing.T) { if testing.Short() { t.Skipf("skipping %s test in short mode", t.Name()) } - hellos := strings.Repeat("hello", 1024*1024) // ~5mb - request := connect.NewRequest(&pingv1.PingRequest{Text: hellos}) + ctx, callInfo := connect.NewClientContext(t.Context()) + hellos := strings.Repeat("hello", 512*1024) // ~2.5mb + request := &pingv1.PingRequest{Text: hellos} for _, el := range expectedHeaderValues { - request.Header().Add(clientHeader, el) + callInfo.RequestHeader().Add(clientHeader, el) } - response, err := client.Ping(t.Context(), request) + response, err := client.Ping(ctx, request) assert.Nil(t, err) - assert.Equal(t, response.Msg.GetText(), hellos) - // Wrap the response object so that it can implement the responseInfo interface and we can verify its response - // headers and trailers using the same function callInfo does - wrapper := &responseWrapper[pingv1.PingResponse]{response: response} - assertResponseHeadersAndTrailers(t, wrapper) + assert.Equal(t, response.GetText(), hellos) + assertResponseHeadersAndTrailers(t, callInfo) }) t.Run("ping_error", func(t *testing.T) { t.Parallel() _, err := client.Ping( t.Context(), - connect.NewRequest(&pingv1.PingRequest{}), + &pingv1.PingRequest{}, ) assert.Equal(t, connect.CodeOf(err), connect.CodeInvalidArgument) }) @@ -455,8 +289,9 @@ func TestServer(t *testing.T) { t.Parallel() ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) defer cancel() - request := connect.NewRequest(&pingv1.PingRequest{}) - request.Header().Set(clientHeader, "foo") + ctx, callInfo := connect.NewClientContext(ctx) + request := &pingv1.PingRequest{} + callInfo.RequestHeader().Set(clientHeader, "foo") _, err := client.Ping(ctx, request) assert.Equal(t, connect.CodeOf(err), connect.CodeDeadlineExceeded) }) @@ -464,37 +299,41 @@ func TestServer(t *testing.T) { testSum := func(t *testing.T, client pingv1connect.PingServiceClient) { //nolint:thelper t.Run("sum", func(t *testing.T) { t.Parallel() - testClientStreamGenerics(t, client) + testClientStream(t, client) }) t.Run("sum_error", func(t *testing.T) { t.Parallel() - stream := client.Sum(t.Context()) + stream, err := client.Sum(t.Context()) + if err != nil { + t.Fatal(err) + } if err := stream.Send(&pingv1.SumRequest{Number: 1}); err != nil { assert.ErrorIs(t, err, io.EOF) assert.Equal(t, connect.CodeOf(err), connect.CodeUnknown) } - _, err := stream.CloseAndReceive() + _, err = stream.CloseAndReceive() assert.Equal(t, connect.CodeOf(err), connect.CodeInvalidArgument) }) t.Run("sum_close_and_receive_without_send", func(t *testing.T) { t.Parallel() - stream := client.Sum(t.Context()) + ctx, callInfo := connect.NewClientContext(t.Context()) + stream, err := client.Sum(ctx) + if err != nil { + t.Fatal(err) + } for _, el := range expectedHeaderValues { - stream.RequestHeader().Add(clientHeader, el) + callInfo.RequestHeader().Add(clientHeader, el) } got, err := stream.CloseAndReceive() assert.Nil(t, err) - assert.Equal(t, got.Msg, &pingv1.SumResponse{}) // receive header only stream - // Wrap the response object so that it can implement the responseInfo interface and we can verify its response - // headers and trailers using the same function callInfo does - wrapper := &responseWrapper[pingv1.SumResponse]{response: got} - assertResponseHeadersAndTrailers(t, wrapper) + assert.Equal(t, got, &pingv1.SumResponse{}) // receive header only stream + assertResponseHeadersAndTrailers(t, callInfo) }) } testCountUp := func(t *testing.T, client pingv1connect.PingServiceClient) { //nolint:thelper t.Run("count_up", func(t *testing.T) { t.Parallel() - testServerStreamGenerics(t, client) + testServerStream(t, client) }) t.Run("count_up_error", func(t *testing.T) { t.Parallel() @@ -502,15 +341,19 @@ func TestServer(t *testing.T) { t.Cleanup(cancel) stream, err := client.CountUp( ctx, - connect.NewRequest(&pingv1.CountUpRequest{Number: 1}), + &pingv1.CountUpRequest{Number: 1}, ) assert.Nil(t, err) - for stream.Receive() { + for { + _, err = stream.Receive() + if err != nil { + break + } t.Fatalf("expected error, shouldn't receive any messages") } assert.Equal( t, - connect.CodeOf(stream.Err()), + connect.CodeOf(err), connect.CodeInvalidArgument, ) assert.Nil(t, stream.Close()) @@ -519,34 +362,39 @@ func TestServer(t *testing.T) { t.Parallel() ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) t.Cleanup(cancel) - _, err := client.CountUp(ctx, connect.NewRequest(&pingv1.CountUpRequest{Number: 1})) + _, err := client.CountUp(ctx, &pingv1.CountUpRequest{Number: 1}) assert.NotNil(t, err) assert.Equal(t, connect.CodeOf(err), connect.CodeDeadlineExceeded) }) t.Run("count_up_cancel_after_first_response", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(t.Context()) - request := connect.NewRequest(&pingv1.CountUpRequest{Number: 5}) - request.Header().Add(clientHeader, "foo") - request.Header().Add(clientHeader, "bar") + ctx, callInfo := connect.NewClientContext(ctx) + request := &pingv1.CountUpRequest{Number: 5} + callInfo.RequestHeader().Add(clientHeader, "foo") + callInfo.RequestHeader().Add(clientHeader, "bar") stream, err := client.CountUp(ctx, request) assert.Nil(t, err) - assert.True(t, stream.Receive()) + _, err = stream.Receive() + assert.Nil(t, err) cancel() - assert.False(t, stream.Receive()) - assert.NotNil(t, stream.Err()) - assert.Equal(t, connect.CodeOf(stream.Err()), connect.CodeCanceled) + _, err = stream.Receive() + assert.NotNil(t, err) + assert.Equal(t, connect.CodeOf(err), connect.CodeCanceled) assert.Nil(t, stream.Close()) }) } testCumSum := func(t *testing.T, client pingv1connect.PingServiceClient, expectSuccess bool) { //nolint:thelper t.Run("cumsum", func(t *testing.T) { t.Parallel() - testBidiStreamGenerics(t, client, expectSuccess) + testBidiStream(t, client, expectSuccess) }) t.Run("cumsum_error", func(t *testing.T) { t.Parallel() - stream := client.CumSum(t.Context()) + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatal(err) + } if !expectSuccess { // server doesn't support HTTP/2 failNoHTTP2(t, stream) return @@ -557,15 +405,21 @@ func TestServer(t *testing.T) { } // We didn't send the headers the server expects, so we should now get an // error. - _, err := stream.Receive() + _, err = stream.Receive() assert.Equal(t, connect.CodeOf(err), connect.CodeInvalidArgument) - assert.True(t, connect.IsWireError(err)) + var cerr *connect.Error + assert.True(t, errors.As(err, &cerr)) + assert.True(t, cerr.IsRemote()) }) t.Run("cumsum_empty_stream", func(t *testing.T) { t.Parallel() - stream := client.CumSum(t.Context()) + ctx, callInfo := connect.NewClientContext(t.Context()) + stream, err := client.CumSum(ctx) + if err != nil { + t.Fatal(err) + } for _, el := range expectedHeaderValues { - stream.RequestHeader().Add(clientHeader, el) + callInfo.RequestHeader().Add(clientHeader, el) } if !expectSuccess { // server doesn't support HTTP/2 failNoHTTP2(t, stream) @@ -573,19 +427,25 @@ func TestServer(t *testing.T) { } // Deliberately closing with calling Send to test the behavior of Receive. // This test case is based on the grpc interop tests. - assert.Nil(t, stream.CloseRequest()) + assert.Nil(t, stream.CloseSend()) response, err := stream.Receive() assert.Nil(t, response) assert.True(t, errors.Is(err, io.EOF)) - assert.False(t, connect.IsWireError(err)) - assert.Nil(t, stream.CloseResponse()) // clean-up the stream + var cerr *connect.Error + assert.True(t, errors.As(err, &cerr)) + assert.False(t, cerr.IsRemote()) + assert.Nil(t, stream.Close()) // clean-up the stream }) t.Run("cumsum_cancel_after_first_response", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(t.Context()) - stream := client.CumSum(ctx) + ctx, callInfo := connect.NewClientContext(ctx) + stream, err := client.CumSum(ctx) + if err != nil { + t.Fatal(err) + } for _, el := range expectedHeaderValues { - stream.RequestHeader().Add(clientHeader, el) + callInfo.RequestHeader().Add(clientHeader, el) } if !expectSuccess { // server doesn't support HTTP/2 failNoHTTP2(t, stream) @@ -605,95 +465,108 @@ func TestServer(t *testing.T) { _, err = stream.Receive() assert.Equal(t, connect.CodeOf(err), connect.CodeCanceled) assert.Equal(t, got, expect) - assert.False(t, connect.IsWireError(err)) - assert.Nil(t, stream.CloseResponse()) + var cerr *connect.Error + assert.True(t, errors.As(err, &cerr)) + assert.False(t, cerr.IsRemote()) + assert.Nil(t, stream.Close()) }) t.Run("cumsum_cancel_before_send", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(t.Context()) - stream := client.CumSum(ctx) + ctx, callInfo := connect.NewClientContext(ctx) + stream, err := client.CumSum(ctx) + if err != nil { + t.Fatal(err) + } if !expectSuccess { // server doesn't support HTTP/2 failNoHTTP2(t, stream) cancel() return } for _, el := range expectedHeaderValues { - stream.RequestHeader().Add(clientHeader, el) + callInfo.RequestHeader().Add(clientHeader, el) } assert.Nil(t, stream.Send(&pingv1.CumSumRequest{Number: 8})) cancel() // On a subsequent send, ensure that we are still catching context // cancellations. - err := stream.Send(&pingv1.CumSumRequest{Number: 19}) + err = stream.Send(&pingv1.CumSumRequest{Number: 19}) assert.Equal(t, connect.CodeOf(err), connect.CodeCanceled, assert.Sprintf("%v", err)) - assert.False(t, connect.IsWireError(err)) - assert.Nil(t, stream.CloseRequest()) - assert.Nil(t, stream.CloseResponse()) + var cerr *connect.Error + assert.True(t, errors.As(err, &cerr)) + assert.False(t, cerr.IsRemote()) + assert.Nil(t, stream.CloseSend()) + assert.Nil(t, stream.Close()) }) } testErrors := func(t *testing.T, client pingv1connect.PingServiceClient) { //nolint:thelper - assertIsHTTPMiddlewareError := func(tb testing.TB, err error) { + assertIsHTTPMiddlewareError := func(tb testing.TB, ctx context.Context, err error) { tb.Helper() assert.NotNil(tb, err) var connectErr *connect.Error assert.True(tb, errors.As(err, &connectErr)) - expect := newHTTPMiddlewareError() + expect := connect.NewError(connect.CodeResourceExhausted, "error from HTTP middleware") assert.Equal(tb, connectErr.Code(), expect.Code()) assert.Equal(tb, connectErr.Message(), expect.Message()) - for k, v := range expect.Meta() { - assert.Equal(tb, connectErr.Meta().Values(k), v) - } + callInfo, _ := connect.CallInfoForClientContext(ctx) + assert.NotNil(t, callInfo) + got := callInfo.ResponseHeader().Get("Middleware-Foo") + assert.Equal(t, got, "bar") assert.Equal(tb, len(connectErr.Details()), len(expect.Details())) } t.Run("errors", func(t *testing.T) { t.Parallel() - request := connect.NewRequest(&pingv1.FailRequest{ + ctx, callInfo := connect.NewClientContext(t.Context()) + request := &pingv1.FailRequest{ Code: int32(connect.CodeResourceExhausted), - }) + } for _, el := range expectedHeaderValues { - request.Header().Add(clientHeader, el) + callInfo.RequestHeader().Add(clientHeader, el) } - response, err := client.Fail(t.Context(), request) + response, err := client.Fail(ctx, request) assert.Nil(t, response) assert.NotNil(t, err) var connectErr *connect.Error ok := errors.As(err, &connectErr) assert.True(t, ok, assert.Sprintf("conversion to *connect.Error")) - assert.True(t, connect.IsWireError(err)) + var cerr *connect.Error + assert.True(t, errors.As(err, &cerr)) + assert.True(t, cerr.IsRemote()) assert.Equal(t, connectErr.Code(), connect.CodeResourceExhausted) assert.Equal(t, connectErr.Error(), "resource_exhausted: "+errorMessage) assert.Zero(t, connectErr.Details()) - // Wrap the connect error so that it can implement the responseInfo interface and we can verify its response - // headers and trailers using a single function - wrapper := &errorWrapper{err: connectErr} - assertResponseHeadersAndTrailers(t, wrapper) + assertErrorResponseMetadata(t, callInfo) }) t.Run("middleware_errors_unary", func(t *testing.T) { t.Parallel() - request := connect.NewRequest(&pingv1.PingRequest{}) + ctx, callInfo := connect.NewClientContext(t.Context()) + request := &pingv1.PingRequest{} for _, el := range expectedHeaderValues { - request.Header().Set(clientMiddlewareErrorHeader, el) + callInfo.RequestHeader().Set(clientMiddlewareErrorHeader, el) } - _, err := client.Ping(t.Context(), request) - assertIsHTTPMiddlewareError(t, err) + _, err := client.Ping(ctx, request) + assertIsHTTPMiddlewareError(t, ctx, err) }) t.Run("middleware_errors_streaming", func(t *testing.T) { t.Parallel() - request := connect.NewRequest(&pingv1.CountUpRequest{Number: 10}) + ctx, callInfo := connect.NewClientContext(t.Context()) + request := &pingv1.CountUpRequest{Number: 10} for _, el := range expectedHeaderValues { - request.Header().Set(clientMiddlewareErrorHeader, el) + callInfo.RequestHeader().Set(clientMiddlewareErrorHeader, el) } - stream, err := client.CountUp(t.Context(), request) + stream, err := client.CountUp(ctx, request) assert.Nil(t, err) - assert.False(t, stream.Receive()) - assertIsHTTPMiddlewareError(t, stream.Err()) + _, err = stream.Receive() + assert.NotNil(t, err) + assertIsHTTPMiddlewareError(t, ctx, err) }) + _ = assertIsHTTPMiddlewareError } testMatrix := func(t *testing.T, client *http.Client, url string, bidi bool) { //nolint:thelper - run := func(t *testing.T, opts ...connect.ClientOption) { + run := func(t *testing.T, opts ...connecthttp.Option) { t.Helper() - client := pingv1connect.NewPingServiceClient(client, url, opts...) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(client, url, opts...))) testPing(t, client) testSum(t, client) testCountUp(t, client) @@ -708,23 +581,23 @@ func TestServer(t *testing.T) { }) t.Run("proto_gzip", func(t *testing.T) { t.Parallel() - run(t, connect.WithSendGzip()) + run(t, connecthttp.WithSendCompression("gzip")) }) t.Run("json_gzip", func(t *testing.T) { t.Parallel() run( t, - connect.WithProtoJSON(), - connect.WithSendGzip(), + connecthttp.WithSendCodec(connect.CodecNameJSON), + connecthttp.WithSendCompression("gzip"), ) }) t.Run("json_get", func(t *testing.T) { t.Parallel() run( t, - connect.WithProtoJSON(), - connect.WithHTTPGet(), - connect.WithHTTPGetMaxURLSize(1024, true), + connecthttp.WithSendCodec(connect.CodecNameJSON), + connecthttp.WithHTTPGet(), + connecthttp.WithHTTPGetMaxURLSize(1024, true), ) }) }) @@ -732,19 +605,19 @@ func TestServer(t *testing.T) { t.Parallel() t.Run("proto", func(t *testing.T) { t.Parallel() - run(t, connect.WithGRPC()) + run(t, connecthttp.WithGRPC()) }) t.Run("proto_gzip", func(t *testing.T) { t.Parallel() - run(t, connect.WithGRPC(), connect.WithSendGzip()) + run(t, connecthttp.WithGRPC(), connecthttp.WithSendCompression("gzip")) }) t.Run("json_gzip", func(t *testing.T) { t.Parallel() run( t, - connect.WithGRPC(), - connect.WithProtoJSON(), - connect.WithSendGzip(), + connecthttp.WithGRPC(), + connecthttp.WithSendCodec(connect.CodecNameJSON), + connecthttp.WithSendCompression("gzip"), ) }) }) @@ -752,31 +625,34 @@ func TestServer(t *testing.T) { t.Parallel() t.Run("proto", func(t *testing.T) { t.Parallel() - run(t, connect.WithGRPCWeb()) + run(t, connecthttp.WithGRPCWeb()) }) t.Run("proto_gzip", func(t *testing.T) { t.Parallel() - run(t, connect.WithGRPCWeb(), connect.WithSendGzip()) + run(t, connecthttp.WithGRPCWeb(), connecthttp.WithSendCompression("gzip")) }) t.Run("json_gzip", func(t *testing.T) { t.Parallel() run( t, - connect.WithGRPCWeb(), - connect.WithProtoJSON(), - connect.WithSendGzip(), + connecthttp.WithGRPCWeb(), + connecthttp.WithSendCodec(connect.CodecNameJSON), + connecthttp.WithSendCompression("gzip"), ) }) }) } mux := http.NewServeMux() - pingRoute, pingHandler := pingv1connect.NewPingServiceHandler( - pingServer{checkMetadata: true}, - ) - errorWriter := connect.NewErrorWriter() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{checkMetadata: true}) + innerMux := http.NewServeMux() + connecthttp.Mount(innerMux, srv) + errorWriter := connecthttp.NewErrorWriter() + httpMiddlewareErr := connect.NewError(connect.CodeResourceExhausted, "error from HTTP middleware") + // Add net/http middleware to the ping service to evaluate HTTP state. - mux.Handle(pingRoute, http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + mux.Handle("/", http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { // Exercise ErrorWriter for HTTP middleware errors. if request.Header.Get(clientMiddlewareErrorHeader) != "" { defer request.Body.Close() @@ -786,7 +662,8 @@ func TestServer(t *testing.T) { if !errorWriter.IsSupported(request) { t.Errorf("ErrorWriter doesn't support Content-Type %q", request.Header.Get("Content-Type")) } - if err := errorWriter.Write(response, request, newHTTPMiddlewareError()); err != nil { + response.Header().Set("Middleware-Foo", "bar") + if err := errorWriter.Write(response, request, httpMiddlewareErr); err != nil { t.Errorf("send RPC error from HTTP middleware: %v", err) } return @@ -809,7 +686,7 @@ func TestServer(t *testing.T) { default: t.Errorf("unexpected path %q", request.URL.Path) } - pingHandler.ServeHTTP(response, request) + innerMux.ServeHTTP(response, request) })) t.Run("http1", func(t *testing.T) { @@ -825,22 +702,27 @@ func TestServer(t *testing.T) { testMatrix(t, client, server.URL(), true /* bidi */) }) } - func TestConcurrentStreams(t *testing.T) { if testing.Short() { t.Skipf("skipping %s test in short mode", t.Name()) } t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{})) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) var done, start sync.WaitGroup start.Add(1) for range runtime.GOMAXPROCS(0) * 8 { done.Go(func() { - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) var total int64 - sum := client.CumSum(t.Context()) + sum, err := client.CumSum(t.Context()) + if err != nil { + t.Errorf("failed to open stream: %v", err) + return + } start.Wait() for range 100 { num := rand.Int64N(1000) @@ -859,10 +741,10 @@ func TestConcurrentStreams(t *testing.T) { break } } - if err := sum.CloseRequest(); err != nil { + if err := sum.CloseSend(); err != nil { t.Errorf("failed to close request: %v", err) } - if err := sum.CloseResponse(); err != nil { + if err := sum.Close(); err != nil { t.Errorf("failed to close response: %v", err) } }) @@ -873,39 +755,59 @@ func TestConcurrentStreams(t *testing.T) { func TestErrorHeaderPropagation(t *testing.T) { t.Parallel() - newError := func(testname string, isWire bool) *connect.Error { - err := connect.NewError(connect.CodeInvalidArgument, errors.New(testname)) + + newError := func(ctx context.Context, testname string, isWire bool) *connect.Error { + err := connect.NewError(connect.CodeInvalidArgument, testname) if isWire { - err = connect.NewWireError(connect.CodeInvalidArgument, errors.New(testname)) + err = err.WithRemote() } - msgDetail := &wrapperspb.StringValue{Value: "server details"} - errDetail, derr := connect.NewErrorDetail(msgDetail) - if assert.Nil(t, derr) { - err.AddDetail(errDetail) + msgDetail, detailErr := connectproto.NewErrorDetail(&wrapperspb.StringValue{Value: "server details"}) + if detailErr != nil { + return connect.NewError(connect.CodeInternal, detailErr.Error()) } - err.Meta().Set("Content-Length", "1337") - err.Meta().Set("Content-Type", "application/xml") - err.Meta().Set("Accept-Encoding", "bogus") - err.Meta().Set("Date", "Thu, 01 Jan 1970 00:00:00 GMT") - err.Meta().Set("Grpc-Status", "0") + err = err.WithDetail(msgDetail) + callInfo, _ := connect.CallInfoForServerContext(ctx) + callInfo.ResponseHeader().Set("Content-Length", "1337") + callInfo.ResponseHeader().Set("Content-Type", "application/xml") + callInfo.ResponseHeader().Set("Accept-Encoding", "bogus") + callInfo.ResponseHeader().Set("Date", "Thu, 01 Jan 1970 00:00:00 GMT") + callInfo.ResponseHeader().Set("Grpc-Status", "0") // Set custom headers. - err.Meta().Set("X-Test", testname) - err.Meta()["x-test-case"] = []string{testname} + callInfo.ResponseHeader().Set("X-Test", testname) + callInfo.ResponseHeader().SetValues("x-test-case", []string{testname}) return err } pingServer := &pluggablePingServer{ - ping: func(ctx context.Context, request *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { - return nil, newError(request.Header().Get("X-Test"), request.Header().Get("X-Test-Is-Wire") == "true") + ping: func(ctx context.Context, request *pingv1.PingRequest) (*pingv1.PingResponse, error) { + callInfo, _ := connect.CallInfoForServerContext(ctx) + xTest := callInfo.RequestHeader().Get("X-Test") + xTestWire := callInfo.RequestHeader().Get("X-Test-Is-Wire") + return nil, newError(ctx, xTest, xTestWire == "true") }, - cumSum: func(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { - return newError(stream.RequestHeader().Get("X-Test"), stream.RequestHeader().Get("X-Test-Is-Wire") == "true") + cumSum: func(ctx context.Context, request pingv1connect.PingServiceCumSumServerStream) error { + callInfo, _ := connect.CallInfoForServerContext(ctx) + xTest := callInfo.RequestHeader().Get("X-Test") + xTestWire := callInfo.RequestHeader().Get("X-Test-Is-Wire") + return newError(ctx, xTest, xTestWire == "true") }, } mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer)) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) - assertError := func(t *testing.T, err error, allowCustomHeaders bool) { + assertHeaders := func(t *testing.T, ctx context.Context) { + t.Helper() + callInfo, _ := connect.CallInfoForClientContext(ctx) + assert.NotEqual(t, metaValues(callInfo, "Content-Length"), []string{"1337"}) + assert.NotEqual(t, metaValues(callInfo, "Accept-Encoding"), []string{"bogus"}) + assert.NotEqual(t, metaValues(callInfo, "Content-Type"), []string{"application/xml"}) + assert.NotEqual(t, metaValues(callInfo, "Date"), []string{"Thu, 01 Jan 1970 00:00:00 GMT"}) + assert.Equal(t, metaValues(callInfo, "x-test-case"), []string{t.Name()}) + assert.Equal(t, metaValues(callInfo, "X-Test"), []string{t.Name()}) + } + assertError := func(t *testing.T, ctx context.Context, err error) { t.Helper() var connectErr *connect.Error if !assert.True(t, errors.As(err, &connectErr)) { @@ -915,7 +817,7 @@ func TestErrorHeaderPropagation(t *testing.T) { assert.Equal(t, connectErr.Message(), t.Name()) details := connectErr.Details() if assert.Equal(t, len(details), 1) { - detailMsg, err := details[0].Value() + detailMsg, err := connectproto.UnmarshalErrorDetail(details[0]) if !assert.Nil(t, err) { return } @@ -925,266 +827,697 @@ func TestErrorHeaderPropagation(t *testing.T) { } assert.Equal(t, serverDetails.Value, "server details") } - meta := connectErr.Meta() - assert.NotEqual(t, meta.Values("Content-Length"), []string{"1337"}) - assert.NotEqual(t, meta.Values("Accept-Encoding"), []string{"bogus"}) - assert.NotEqual(t, meta.Values("Content-Type"), []string{"application/xml"}) - assert.NotEqual(t, meta.Values("Content-Length"), []string{"1337"}) - assert.NotEqual(t, meta.Values("Date"), []string{"Thu, 01 Jan 1970 00:00:00 GMT"}) - if allowCustomHeaders { - assert.Equal(t, meta.Values("x-test-case"), []string{t.Name()}) - assert.Equal(t, meta.Values("X-Test"), []string{t.Name()}) - } else { - assert.Equal(t, meta.Values("x-test-case"), []string(nil)) - assert.Equal(t, meta.Values("X-Test"), []string(nil)) + assertHeaders(t, ctx) + } + // A handler-returned remote error is scrubbed to CodeInternal with no + // message or details; headers still propagate. + assertScrubbedError := func(t *testing.T, ctx context.Context, err error) { + t.Helper() + var connectErr *connect.Error + if !assert.True(t, errors.As(err, &connectErr)) { + return } + assert.Equal(t, connectErr.Code(), connect.CodeInternal) + assert.Equal(t, connectErr.Message(), "") + assert.Equal(t, len(connectErr.Details()), 0) + assertHeaders(t, ctx) } testServices := func(t *testing.T, client pingv1connect.PingServiceClient) { t.Helper() t.Run("unary", func(t *testing.T) { t.Parallel() - request := connect.NewRequest(&pingv1.PingRequest{}) - request.Header().Set("X-Test", t.Name()) - _, err := client.Ping(t.Context(), request) + ctx, callInfo := connect.NewClientContext(t.Context()) + request := &pingv1.PingRequest{} + callInfo.RequestHeader().Set("X-Test", t.Name()) + _, err := client.Ping(ctx, request) if !assert.NotNil(t, err) { return } - assertError(t, err, true /* allowCustomHeaders */) + assertError(t, ctx, err) t.Run("wire", func(t *testing.T) { t.Parallel() - request := connect.NewRequest(&pingv1.PingRequest{}) - request.Header().Set("X-Test", t.Name()) - request.Header().Set("X-Test-Is-Wire", "true") - _, err := client.Ping(t.Context(), request) + ctx, callInfo := connect.NewClientContext(t.Context()) + request := &pingv1.PingRequest{} + callInfo.RequestHeader().Set("X-Test", t.Name()) + callInfo.RequestHeader().Set("X-Test-Is-Wire", "true") + _, err := client.Ping(ctx, request) if !assert.NotNil(t, err) { return } - assertError(t, err, false /* allowCustomHeaders */) + assertScrubbedError(t, ctx, err) }) }) t.Run("bidi", func(t *testing.T) { t.Parallel() - stream := client.CumSum(t.Context()) - stream.RequestHeader().Set("X-Test", t.Name()) - if err := stream.Send(nil); err != nil { + ctx, callInfo := connect.NewClientContext(t.Context()) + stream, err := client.CumSum(ctx) + if err != nil { t.Fatal(err) } - _, err := stream.Receive() + callInfo.RequestHeader().Set("X-Test", t.Name()) + if err := stream.SendHeaders(); err != nil { + t.Fatal(err) + } + _, err = stream.Receive() if !assert.NotNil(t, err) { return } - assertError(t, err, true /* allowCustomHeaders */) + assertError(t, ctx, err) t.Run("wire", func(t *testing.T) { + ctx, callInfo := connect.NewClientContext(t.Context()) t.Parallel() - stream := client.CumSum(t.Context()) - stream.RequestHeader().Set("X-Test", t.Name()) - stream.RequestHeader().Set("X-Test-Is-Wire", "true") - if err := stream.Send(nil); err != nil { + stream, err := client.CumSum(ctx) + if err != nil { t.Fatal(err) } - _, err := stream.Receive() + callInfo.RequestHeader().Set("X-Test", t.Name()) + callInfo.RequestHeader().Set("X-Test-Is-Wire", "true") + if err := stream.SendHeaders(); err != nil { + t.Fatal(err) + } + _, err = stream.Receive() if !assert.NotNil(t, err) { return } + assertScrubbedError(t, ctx, err) }) }) } t.Run("connect", func(t *testing.T) { t.Parallel() - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) testServices(t, client) }) t.Run("grpc", func(t *testing.T) { t.Parallel() - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPC()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC()))) testServices(t, client) }) t.Run("grpc-web", func(t *testing.T) { t.Parallel() - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPCWeb()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb()))) testServices(t, client) }) } -func TestHeaderBasic(t *testing.T) { +// TestCallInfoDetails verifies transports populate CallInfo's codec, +// encoding, and message-stats fields on both sides of a call. +func TestCallInfoDetails(t *testing.T) { t.Parallel() - const ( - key = "Test-Key" - cval = "client value" - hval = "client value" - ) - pingServer := &pluggablePingServer{ - ping: func(ctx context.Context, request *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { - assert.Equal(t, request.Header().Get(key), cval) - response := connect.NewResponse(&pingv1.PingResponse{}) - response.Header().Set(key, hval) - return response, nil + ping: func(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + if info.Codec == "" { + return nil, connect.NewError(connect.CodeInternal, "codec not set") + } + if want := info.RequestHeader().Get("Expect-Request-Encoding"); info.RequestEncoding != want { + return nil, connect.Errorf(connect.CodeInternal, "RequestEncoding = %q, want %q", info.RequestEncoding, want) + } + if info.ResponseEncoding == "" { + return nil, connect.NewError(connect.CodeInternal, "response encoding not set") + } + if info.ReceiveStats.Size == 0 { + return nil, connect.NewError(connect.CodeInternal, "ReceiveStats.Size not set") + } + if wantCompressed := info.RequestEncoding != connect.CompressionNameIdentity; wantCompressed != (info.ReceiveStats.CompressedSize > 0) { + return nil, connect.Errorf(connect.CodeInternal, "ReceiveStats.CompressedSize = %d with encoding %q", info.ReceiveStats.CompressedSize, info.RequestEncoding) + } + return &pingv1.PingResponse{Number: req.GetNumber(), Text: req.GetText()}, nil + }, + cumSum: func(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { + var sum int64 + for { + req, err := stream.Receive() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + sum += req.GetNumber() + if err := stream.Send(&pingv1.CumSumResponse{Sum: sum}); err != nil { + return err + } + } }, } mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer)) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - request := connect.NewRequest(&pingv1.PingRequest{}) - request.Header().Set(key, cval) - response, err := client.Ping(t.Context(), request) - assert.Nil(t, err) - assert.Equal(t, response.Header().Get(key), hval) -} - -func TestHeaderHost(t *testing.T) { - t.Parallel() - const ( - key = "Host" - cval = "buf.build" - ) - - pingServer := &pluggablePingServer{ - ping: func(_ context.Context, request *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { - assert.Equal(t, request.Header().Get(key), cval) - response := connect.NewResponse(&pingv1.PingResponse{}) - return response, nil - }, - } - - newHTTP2Server := func(t *testing.T) *memhttp.Server { - t.Helper() - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer)) - server := memhttptest.NewServer(t, mux) - return server - } - - callWithHost := func(t *testing.T, client pingv1connect.PingServiceClient) { + assertUnary := func(t *testing.T, wantCodec, wantRequestEncoding string, options ...connecthttp.Option) { t.Helper() - - request := connect.NewRequest(&pingv1.PingRequest{}) - request.Header().Set(key, cval) - response, err := client.Ping(t.Context(), request) - assert.Nil(t, err) - assert.Equal(t, response.Header().Get(key), "") + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), options...))) + ctx, info := connect.NewClientContext(t.Context()) + info.RequestHeader().Set("Expect-Request-Encoding", wantRequestEncoding) + _, err := client.Ping(ctx, &pingv1.PingRequest{Number: 42, Text: strings.Repeat("connect", 32)}) + if err != nil { + t.Fatalf("Ping: %v", err) + } + assert.Equal(t, info.Codec, wantCodec) + assert.Equal(t, info.RequestEncoding, wantRequestEncoding) + assert.Equal(t, info.ResponseEncoding, connect.CompressionNameGzip) + assert.True(t, info.SendStats.Size > 0) + assert.Equal(t, info.SendStats.CompressedSize > 0, wantRequestEncoding == connect.CompressionNameGzip) + assert.True(t, info.ReceiveStats.Size > 0) + assert.True(t, info.ReceiveStats.CompressedSize > 0) + } + protocols := map[string][]connecthttp.Option{ + "connect": nil, + "grpc": {connecthttp.WithGRPC()}, + "grpc_web": {connecthttp.WithGRPCWeb()}, + } + for name, protocolOpts := range protocols { + t.Run(name, func(t *testing.T) { + t.Parallel() + assertUnary(t, connect.CodecNameProto, connect.CompressionNameIdentity, protocolOpts...) + }) + t.Run(name+"_gzip", func(t *testing.T) { + t.Parallel() + assertUnary(t, connect.CodecNameProto, connect.CompressionNameGzip, append(protocolOpts, connecthttp.WithSendGzip())...) + }) } - - t.Run("connect", func(t *testing.T) { - t.Parallel() - server := newHTTP2Server(t) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - callWithHost(t, client) - }) - - t.Run("grpc", func(t *testing.T) { + t.Run("connect_json", func(t *testing.T) { t.Parallel() - server := newHTTP2Server(t) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPC()) - callWithHost(t, client) + assertUnary(t, connect.CodecNameJSON, connect.CompressionNameIdentity, connecthttp.WithProtoJSON()) }) - - t.Run("grpc-web", func(t *testing.T) { + t.Run("connect_streaming", func(t *testing.T) { t.Parallel() - server := newHTTP2Server(t) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPCWeb()) - callWithHost(t, client) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + ctx, info := connect.NewClientContext(t.Context()) + stream, err := client.CumSum(ctx) + if err != nil { + t.Fatal(err) + } + defer stream.Close() + if err := stream.Send(&pingv1.CumSumRequest{Number: 42}); err != nil { + t.Fatalf("Send: %v", err) + } + if _, err := stream.Receive(); err != nil { + t.Fatalf("Receive: %v", err) + } + assert.Equal(t, info.Codec, connect.CodecNameProto) + assert.Equal(t, info.RequestEncoding, connect.CompressionNameIdentity) + assert.Equal(t, info.ResponseEncoding, connect.CompressionNameGzip) + assert.True(t, info.SendStats.Size > 0) + assert.Equal(t, info.SendStats.CompressedSize, 0) + assert.True(t, info.ReceiveStats.Size > 0) + assert.True(t, info.ReceiveStats.CompressedSize > 0) }) } -func TestTimeoutParsing(t *testing.T) { +// TestMountUnknownMethod verifies RPC requests for unknown methods of a +// registered service fail with CodeUnimplemented instead of falling through +// to sibling mux routes. +func TestMountUnknownMethod(t *testing.T) { t.Parallel() - const timeout = 10 * time.Minute - pingServer := &pluggablePingServer{ - ping: func(ctx context.Context, request *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { - deadline, ok := ctx.Deadline() - assert.True(t, ok) - remaining := time.Until(deadline) - assert.True(t, remaining > 0) - assert.True(t, remaining <= timeout) - return connect.NewResponse(&pingv1.PingResponse{}), nil - }, - } mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer)) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + // A sibling catch-all route: unknown-method requests must not reach it. + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) server := memhttptest.NewServer(t, mux) - ctx, cancel := context.WithTimeout(t.Context(), timeout) - defer cancel() - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - _, err := client.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{})) - assert.Nil(t, err) -} - -func TestFailCodec(t *testing.T) { - t.Parallel() - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) - server := memhttptest.NewServer(t, handler) - client := pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - connect.WithCodec(failCodec{}), - ) - stream := client.CumSum(t.Context()) - err := stream.Send(&pingv1.CumSumRequest{}) - var connectErr *connect.Error - assert.NotNil(t, err) - assert.True(t, errors.As(err, &connectErr)) - assert.Equal(t, connectErr.Code(), connect.CodeInternal) + unknownProcedure := "/connect.ping.v1.PingService/DoesNotExist" + unknownSpec := connect.Spec{ + StreamType: connect.StreamTypeUnary, + Procedure: unknownProcedure, + } + t.Run("raw_http", func(t *testing.T) { + t.Parallel() + request, err := http.NewRequestWithContext( + t.Context(), + http.MethodPost, + server.URL()+unknownProcedure, + strings.NewReader(""), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/proto") + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + assert.Equal(t, response.StatusCode, http.StatusNotImplemented) + var wireErr struct { + Code string `json:"code"` + } + if err := json.NewDecoder(response.Body).Decode(&wireErr); err != nil { + t.Fatal(err) + } + assert.Equal(t, wireErr.Code, "unimplemented") + }) + t.Run("connect_client", func(t *testing.T) { + t.Parallel() + client := connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL())) + err := client.CallUnary(t.Context(), unknownSpec, &pingv1.PingRequest{}, &pingv1.PingResponse{}) + assert.Equal(t, connect.CodeOf(err), connect.CodeUnimplemented) + }) + t.Run("grpc_client", func(t *testing.T) { + t.Parallel() + client := connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC())) + err := client.CallUnary(t.Context(), unknownSpec, &pingv1.PingRequest{}, &pingv1.PingResponse{}) + assert.Equal(t, connect.CodeOf(err), connect.CodeUnimplemented) + }) + t.Run("grpc_web_client", func(t *testing.T) { + t.Parallel() + client := connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb())) + err := client.CallUnary(t.Context(), unknownSpec, &pingv1.PingRequest{}, &pingv1.PingResponse{}) + assert.Equal(t, connect.CodeOf(err), connect.CodeUnimplemented) + }) + t.Run("registered_method", func(t *testing.T) { + t.Parallel() + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + _, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 42}) + assert.Nil(t, err) + }) + t.Run("unknown_service", func(t *testing.T) { + t.Parallel() + request, err := http.NewRequestWithContext( + t.Context(), + http.MethodPost, + server.URL()+"/some.other.v1.Service/Method", + strings.NewReader(""), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/proto") + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + assert.Equal(t, response.StatusCode, http.StatusTeapot) + }) } -func TestContextError(t *testing.T) { +// TestMountUnknownHandler verifies a SetUnknownHandler fallback answers +// unknown-method RPCs dispatched by Mount's catch-all. +func TestMountUnknownHandler(t *testing.T) { + t.Parallel() + const fallbackText = "fallback" + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + // Set after Mount: the fallback is read at call time. + srv.SetUnknownHandler(func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + info, _ := connect.CallInfoForServerContext(ctx) + info.ResponseHeader().Set("Fallback-Procedure", spec.Procedure) + var req pingv1.PingRequest + if err := stream.Receive(&req); err != nil { + return err + } + return stream.Send(&pingv1.PingResponse{Number: req.GetNumber(), Text: fallbackText}) + }) + server := memhttptest.NewServer(t, mux) + + unknownProcedure := "/connect.ping.v1.PingService/DoesNotExist" + unknownSpec := connect.Spec{ + StreamType: connect.StreamTypeUnary, + Procedure: unknownProcedure, + } + testUnaryFallback := func(t *testing.T, options ...connecthttp.Option) { + t.Helper() + client := connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), options...)) + ctx, callInfo := connect.NewClientContext(t.Context()) + var res pingv1.PingResponse + if err := client.CallUnary(ctx, unknownSpec, &pingv1.PingRequest{Number: 42}, &res); err != nil { + t.Fatalf("CallUnary: %v", err) + } + if res.GetText() != fallbackText || res.GetNumber() != 42 { + t.Errorf("response = %d %q, want 42 %q", res.GetNumber(), res.GetText(), fallbackText) + } + if got := callInfo.ResponseHeader().Get("Fallback-Procedure"); got != unknownProcedure { + t.Errorf("Fallback-Procedure = %q, want %q", got, unknownProcedure) + } + } + t.Run("connect_client", func(t *testing.T) { + t.Parallel() + testUnaryFallback(t) + }) + t.Run("grpc_client", func(t *testing.T) { + t.Parallel() + testUnaryFallback(t, connecthttp.WithGRPC()) + }) + t.Run("grpc_web_client", func(t *testing.T) { + t.Parallel() + testUnaryFallback(t, connecthttp.WithGRPCWeb()) + }) + t.Run("connect_streaming_client", func(t *testing.T) { + t.Parallel() + client := connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL())) + stream, err := client.CallClientStream(t.Context(), connect.Spec{ + StreamType: connect.StreamTypeBidi, + Procedure: unknownProcedure, + }) + if err != nil { + t.Fatal(err) + } + defer stream.Close() + if err := stream.Send(&pingv1.PingRequest{Number: 7}); err != nil { + t.Fatalf("Send: %v", err) + } + if err := stream.CloseSend(); err != nil { + t.Fatalf("CloseSend: %v", err) + } + var res pingv1.PingResponse + if err := stream.Receive(&res); err != nil { + t.Fatalf("Receive: %v", err) + } + if res.GetText() != fallbackText || res.GetNumber() != 7 { + t.Errorf("response = %d %q, want 7 %q", res.GetNumber(), res.GetText(), fallbackText) + } + if err := stream.Receive(&res); !errors.Is(err, io.EOF) { + t.Errorf("second Receive = %v, want io.EOF", err) + } + }) + t.Run("registered_method_unaffected", func(t *testing.T) { + t.Parallel() + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + res, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 42}) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if res.GetText() == fallbackText { + t.Error("registered method was served by the fallback") + } + }) + assertRawStatus := func(t *testing.T, method, contentType string, wantStatus int) { + t.Helper() + request, err := http.NewRequestWithContext( + t.Context(), + method, + server.URL()+unknownProcedure, + strings.NewReader(""), + ) + if err != nil { + t.Fatal(err) + } + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + assert.Equal(t, response.StatusCode, wantStatus) + } + t.Run("get_not_dispatched", func(t *testing.T) { + t.Parallel() + assertRawStatus(t, http.MethodGet, "", http.StatusNotFound) + }) + t.Run("non_rpc_post_not_dispatched", func(t *testing.T) { + t.Parallel() + assertRawStatus(t, http.MethodPost, "text/plain", http.StatusNotFound) + }) +} + +// TestHandlerErrorScrub verifies handler errors that are not *connect.Error +// reach the client as a bare code with no message. +func TestHandlerErrorScrub(t *testing.T) { + t.Parallel() + handlerErrs := map[string]error{ + "plain": errors.New("disk full: /var/data"), + "wrapped": fmt.Errorf("query users: %w", errors.New("pq: connection refused")), + "canceled": fmt.Errorf("copy data: %w", context.Canceled), + "deadline": fmt.Errorf("copy data: %w", context.DeadlineExceeded), + } + wantCodes := map[string]connect.Code{ + "plain": connect.CodeUnknown, + "wrapped": connect.CodeUnknown, + "canceled": connect.CodeCanceled, + "deadline": connect.CodeDeadlineExceeded, + } + pingServer := &pluggablePingServer{ + ping: func(ctx context.Context, _ *pingv1.PingRequest) (*pingv1.PingResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + key := info.RequestHeader().Get("X-Error-Case") + return nil, handlerErrs[key] + }, + cumSum: func(ctx context.Context, _ pingv1connect.PingServiceCumSumServerStream) error { + info, _ := connect.CallInfoForServerContext(ctx) + key := info.RequestHeader().Get("X-Error-Case") + return handlerErrs[key] + }, + } + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + + assertScrubbed := func(t *testing.T, err error, wantCode connect.Code) { + t.Helper() + if !assert.NotNil(t, err) { + return + } + var connectErr *connect.Error + if !assert.True(t, errors.As(err, &connectErr)) { + return + } + assert.Equal(t, connectErr.Code(), wantCode) + assert.Equal(t, connectErr.Message(), "") + } + testCases := func(t *testing.T, client pingv1connect.PingServiceClient) { + t.Helper() + for name := range handlerErrs { + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx, callInfo := connect.NewClientContext(t.Context()) + callInfo.RequestHeader().Set("X-Error-Case", name) + _, err := client.Ping(ctx, &pingv1.PingRequest{}) + assertScrubbed(t, err, wantCodes[name]) + }) + t.Run(name+"_stream", func(t *testing.T) { + t.Parallel() + ctx, callInfo := connect.NewClientContext(t.Context()) + callInfo.RequestHeader().Set("X-Error-Case", name) + stream, err := client.CumSum(ctx) + if err != nil { + t.Fatal(err) + } + defer stream.Close() + if err := stream.SendHeaders(); err != nil { + t.Fatal(err) + } + _, err = stream.Receive() + assertScrubbed(t, err, wantCodes[name]) + }) + } + } + t.Run("connect", func(t *testing.T) { + t.Parallel() + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + testCases(t, client) + }) + t.Run("grpc", func(t *testing.T) { + t.Parallel() + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC()))) + testCases(t, client) + }) + t.Run("grpc-web", func(t *testing.T) { + t.Parallel() + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb()))) + testCases(t, client) + }) +} + +func TestHeaderBasic(t *testing.T) { + t.Parallel() + const ( + key = "Test-Key" + cval = "client value" + hval = "client value" + ) + + pingServer := &pluggablePingServer{ + ping: func(ctx context.Context, request *pingv1.PingRequest) (*pingv1.PingResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + reqVal := info.RequestHeader().Get(key) + assert.Equal(t, reqVal, cval) + info.ResponseHeader().Set(key, hval) + return &pingv1.PingResponse{}, nil + }, + } + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + ctx, callInfo := connect.NewClientContext(t.Context()) + callInfo.RequestHeader().Set(key, cval) + _, err := client.Ping(ctx, &pingv1.PingRequest{}) + assert.Nil(t, err) + respVal := callInfo.ResponseHeader().Get(key) + assert.Equal(t, respVal, hval) +} + +func TestHeaderHost(t *testing.T) { + t.Parallel() + const ( + key = "Host" + cval = "buf.build" + ) + + pingServer := &pluggablePingServer{ + ping: func(ctx context.Context, _ *pingv1.PingRequest) (*pingv1.PingResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + reqVal := info.RequestHeader().Get(key) + assert.Equal(t, reqVal, cval) + return &pingv1.PingResponse{}, nil + }, + } + + newHTTP2Server := func(t *testing.T) *memhttp.Server { + t.Helper() + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + return server + } + + callWithHost := func(t *testing.T, client pingv1connect.PingServiceClient) { + t.Helper() + + ctx, callInfo := connect.NewClientContext(t.Context()) + callInfo.RequestHeader().Set(key, cval) + _, err := client.Ping(ctx, &pingv1.PingRequest{}) + assert.Nil(t, err) + assert.Equal(t, callInfo.ResponseHeader().Has(key), false) + } + + t.Run("connect", func(t *testing.T) { + t.Parallel() + server := newHTTP2Server(t) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + callWithHost(t, client) + }) + + t.Run("grpc", func(t *testing.T) { + t.Parallel() + server := newHTTP2Server(t) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC()))) + callWithHost(t, client) + }) + + t.Run("grpc-web", func(t *testing.T) { + t.Parallel() + server := newHTTP2Server(t) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb()))) + callWithHost(t, client) + }) +} + +func TestTimeoutParsing(t *testing.T) { + t.Parallel() + const timeout = 10 * time.Minute + pingServer := &pluggablePingServer{ + ping: func(ctx context.Context, request *pingv1.PingRequest) (*pingv1.PingResponse, error) { + deadline, ok := ctx.Deadline() + assert.True(t, ok) + remaining := time.Until(deadline) + assert.True(t, remaining > 0) + assert.True(t, remaining <= timeout) + return &pingv1.PingResponse{}, nil + }, + } + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + + ctx, cancel := context.WithTimeout(t.Context(), timeout) + defer cancel() + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + _, err := client.Ping(ctx, &pingv1.PingRequest{}) + assert.Nil(t, err) +} + +func TestFailCodec(t *testing.T) { t.Parallel() handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) server := memhttptest.NewServer(t, handler) - client := pingv1connect.NewPingServiceClient( - server.Client(), + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), + connecthttp.WithCodec(failCodec{}))), + ) + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatal(err) + } + err = stream.Send(&pingv1.CumSumRequest{}) + var connectErr *connect.Error + assert.NotNil(t, err) + assert.True(t, errors.As(err, &connectErr)) + assert.Equal(t, connectErr.Code(), connect.CodeInternal) +} + +func TestContextError(t *testing.T) { + t.Parallel() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + server := memhttptest.NewServer(t, handler) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL())), ) ctx, cancel := context.WithCancel(t.Context()) cancel() - stream := client.CumSum(ctx) - err := stream.Send(nil) + stream, err := client.CumSum(ctx) + if err != nil { + t.Fatal(err) + } + err = stream.Send(&pingv1.CumSumRequest{}) var connectErr *connect.Error assert.NotNil(t, err) assert.True(t, errors.As(err, &connectErr)) assert.Equal(t, connectErr.Code(), connect.CodeCanceled) - assert.False(t, connect.IsWireError(err)) + assert.False(t, connectErr.IsRemote()) } -func TestGRPCMarshalStatusError(t *testing.T) { +func TestGRPCMarshalStatus(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{ - // Include error details in the response, so that the Status protobuf will be marshaled. - includeErrorDetails: true, - }, - // We're using a codec that will fail to marshal the Status protobuf, which means the returned error will be ignored - connect.WithCodec(failCodec{}), - )) - server := memhttptest.NewServer(t, mux) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{ + // Include error details in the response, so that the Status protobuf will be marshaled. + includeErrorDetails: true, + }) + connecthttp.Mount(mux, srv) - assertInternalError := func(tb testing.TB, opts ...connect.ClientOption) { + server := memhttptest.NewServer(t, mux) + assertInternalError := func(tb testing.TB, opts ...connecthttp.Option) { tb.Helper() - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), opts...) - request := connect.NewRequest(&pingv1.FailRequest{Code: int32(connect.CodeResourceExhausted)}) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), opts...))) + request := &pingv1.FailRequest{Code: int32(connect.CodeResourceExhausted)} _, err := client.Fail(t.Context(), request) tb.Log(err) assert.NotNil(t, err, assert.Sprintf("expected an error")) var connectErr *connect.Error ok := errors.As(err, &connectErr) assert.True(t, ok, assert.Sprintf("expected the error to be a connect.Error")) - // This should be Internal, not ResourceExhausted, because we're testing when the Status object itself fails to marshal - assert.Equal(t, connectErr.Code(), connect.CodeInternal, assert.Sprintf("expected the error code to be Internal, was %s", connectErr.Code())) - assert.True( - t, - strings.HasSuffix(connectErr.Message(), ": boom"), - ) + assert.Equal(t, connectErr.Code(), connect.CodeResourceExhausted) + assert.True(t, strings.HasSuffix(connectErr.Message(), errorMessage)) } // Only applies to gRPC protocols, where we're marshaling the Status protobuf // message to binary. - assertInternalError(t, connect.WithGRPC()) - assertInternalError(t, connect.WithGRPCWeb()) + assertInternalError(t, connecthttp.WithGRPC()) + assertInternalError(t, connecthttp.WithGRPCWeb()) } func TestGRPCMissingTrailersError(t *testing.T) { @@ -1198,11 +1531,12 @@ func TestGRPCMissingTrailersError(t *testing.T) { } mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{checkMetadata: true}, - )) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{checkMetadata: true}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, trimTrailers(mux)) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPC()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC()))) assertErrorNoTrailers := func(t *testing.T, err error) { t.Helper() @@ -1226,54 +1560,63 @@ func TestGRPCMissingTrailersError(t *testing.T) { t.Run("ping", func(t *testing.T) { t.Parallel() - request := connect.NewRequest(&pingv1.PingRequest{Number: 1, Text: "foobar"}) + request := &pingv1.PingRequest{Number: 1, Text: "foobar"} _, err := client.Ping(t.Context(), request) assertErrorNoTrailers(t, err) }) t.Run("sum", func(t *testing.T) { t.Parallel() - stream := client.Sum(t.Context()) - err := stream.Send(&pingv1.SumRequest{Number: 1}) + stream, err := client.Sum(t.Context()) + if err != nil { + t.Fatal(err) + } + err = stream.Send(&pingv1.SumRequest{Number: 1}) assertNilOrEOF(t, err) _, err = stream.CloseAndReceive() assertErrorNoTrailers(t, err) }) t.Run("count_up", func(t *testing.T) { t.Parallel() - stream, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{Number: 10})) + stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 10}) assert.Nil(t, err) - assert.False(t, stream.Receive()) - assertErrorNoTrailers(t, stream.Err()) + _, err = stream.Receive() + assertErrorNoTrailers(t, err) + assert.Nil(t, stream.Close()) }) t.Run("cumsum", func(t *testing.T) { t.Parallel() - stream := client.CumSum(t.Context()) + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatal(err) + } assertNilOrEOF(t, stream.Send(&pingv1.CumSumRequest{Number: 10})) response, err := stream.Receive() assert.Nil(t, response) assertErrorNoTrailers(t, err) - assert.Nil(t, stream.CloseResponse()) + assert.Nil(t, stream.Close()) }) t.Run("cumsum_empty_stream", func(t *testing.T) { t.Parallel() - stream := client.CumSum(t.Context()) - assert.Nil(t, stream.CloseRequest()) + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatal(err) + } + assert.Nil(t, stream.CloseSend()) response, err := stream.Receive() assert.Nil(t, response) assertErrorNoTrailers(t, err) - assert.Nil(t, stream.CloseResponse()) + assert.Nil(t, stream.Close()) }) } func TestUnavailableIfHostInvalid(t *testing.T) { t.Parallel() - client := pingv1connect.NewPingServiceClient( - http.DefaultClient, - "https://api.invalid/", + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, + "https://api.invalid/")), ) _, err := client.Ping( t.Context(), - connect.NewRequest(&pingv1.PingRequest{}), + &pingv1.PingRequest{}, ) assert.NotNil(t, err) assert.Equal(t, connect.CodeOf(err), connect.CodeUnavailable) @@ -1287,17 +1630,19 @@ func TestBidiRequiresHTTP2(t *testing.T) { assert.Nil(t, err) }) server := memhttptest.NewServer(t, handler) - client := pingv1connect.NewPingServiceClient( - &http.Client{Transport: server.TransportHTTP1()}, - server.URL(), + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(&http.Client{Transport: server.TransportHTTP1()}, + server.URL())), ) - stream := client.CumSum(t.Context()) + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatal(err) + } // Stream creates an async request, can error on Send or Receive. if err := stream.Send(&pingv1.CumSumRequest{}); err != nil { assert.ErrorIs(t, err, io.EOF) } - assert.Nil(t, stream.CloseRequest()) - _, err := stream.Receive() + assert.Nil(t, stream.CloseSend()) + _, err = stream.Receive() assert.NotNil(t, err) var connectErr *connect.Error assert.True(t, errors.As(err, &connectErr)) @@ -1318,12 +1663,11 @@ func TestCompressMinBytesClient(t *testing.T) { assert.Equal(tb, request.Header.Get("Content-Encoding"), expect) })) server := memhttptest.NewServer(t, mux) - _, err := pingv1connect.NewPingServiceClient( - server.Client(), + _, err := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), - connect.WithSendGzip(), - connect.WithCompressMinBytes(8), - ).Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Text: text})) + connecthttp.WithSendCompression("gzip"), + connecthttp.WithCompressMinBytes(8))), + ).Ping(t.Context(), &pingv1.PingRequest{Text: text}) assert.Nil(tb, err) } t.Run("request_uncompressed", func(t *testing.T) { @@ -1348,10 +1692,10 @@ func TestCompressMinBytesClient(t *testing.T) { func TestCompressMinBytes(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{}, - connect.WithCompressMinBytes(8), - )) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv, connecthttp.WithCompressMinBytes(8)) + server := memhttptest.NewServer(t, mux) client := server.Client() @@ -1390,49 +1734,39 @@ func TestCompressMinBytes(t *testing.T) { func TestCustomCompression(t *testing.T) { t.Parallel() mux := http.NewServeMux() - compressionName := "deflate" - decompressor := func() connect.Decompressor { - // Need to instantiate with a reader - before decompressing Reset(io.Reader) is called - return newDeflateReader(strings.NewReader("")) - } - compressor := func() connect.Compressor { - w, err := flate.NewWriter(&strings.Builder{}, flate.DefaultCompression) - if err != nil { - t.Fatalf("failed to create flate writer: %v", err) - } - return w - } - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{}, - connect.WithCompression(compressionName, decompressor, compressor), - )) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv, connecthttp.WithCompressor(deflateCompressor{})) + server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), - connect.WithAcceptCompression(compressionName, decompressor, compressor), - connect.WithSendCompression(compressionName), + connecthttp.WithCompressor(deflateCompressor{}), + connecthttp.WithSendCompression("deflate"))), ) request := &pingv1.PingRequest{Text: "testing 1..2..3.."} - response, err := client.Ping(t.Context(), connect.NewRequest(request)) + response, err := client.Ping(t.Context(), request) assert.Nil(t, err) - assert.Equal(t, response.Msg, &pingv1.PingResponse{Text: request.GetText()}) + assert.Equal(t, response, &pingv1.PingResponse{Text: request.GetText()}) } func TestClientWithoutGzipSupport(t *testing.T) { - // See https://connectrpc.com/connect/pull/349 for why we want to + // See https://github.com/connectrpc/connect-go/pull/349 for why we want to // support this. TL;DR is that Microsoft's dapr sidecar can't handle // asymmetric compression. t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{})) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), - connect.WithAcceptCompression("gzip", nil, nil), - connect.WithSendGzip(), + connecthttp.WithNoCompression(), + connecthttp.WithSendGzip())), ) request := &pingv1.PingRequest{Text: "gzip me!"} - _, err := client.Ping(t.Context(), connect.NewRequest(request)) + _, err := client.Ping(t.Context(), request) assert.NotNil(t, err) assert.Equal(t, connect.CodeOf(err), connect.CodeUnknown) assert.True(t, strings.Contains(err.Error(), "unknown compression")) @@ -1441,7 +1775,9 @@ func TestClientWithoutGzipSupport(t *testing.T) { func TestInvalidHeaderTimeout(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{})) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) getPingResponseWithTimeout := func(t *testing.T, timeout string) *http.Response { t.Helper() @@ -1471,43 +1807,14 @@ func TestInvalidHeaderTimeout(t *testing.T) { }) } -func TestInterceptorReturnsWrongType(t *testing.T) { - t.Parallel() - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{})) - server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { - return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) { - if _, err := next(ctx, request); err != nil { - return nil, err - } - return connect.NewResponse(&pingv1.CumSumResponse{ - Sum: 1, - }), nil - } - }))) - _, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Text: "hello!"})) - assert.NotNil(t, err) - var connectErr *connect.Error - assert.True(t, errors.As(err, &connectErr)) - assert.Equal(t, connectErr.Code(), connect.CodeInternal) - assert.True(t, strings.Contains(connectErr.Message(), "unexpected client response type")) -} - func TestHandlerWithReadMaxBytes(t *testing.T) { t.Parallel() mux := http.NewServeMux() readMaxBytes := 1024 - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{}, - connect.WithConditionalHandlerOptions(func(spec connect.Spec) []connect.HandlerOption { - var options []connect.HandlerOption - if spec.Procedure == pingv1connect.PingServicePingProcedure { - options = append(options, connect.WithReadMaxBytes(readMaxBytes)) - } - return options - }), - )) + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + connecthttp.Mount(mux, server, connecthttp.WithReadMaxBytes(readMaxBytes)) + readMaxBytesMatrix := func(t *testing.T, client pingv1connect.PingServiceClient, compressed bool) { t.Helper() t.Run("equal_read_max", func(t *testing.T) { @@ -1515,7 +1822,7 @@ func TestHandlerWithReadMaxBytes(t *testing.T) { // Serializes to exactly readMaxBytes (1024) - no errors expected pingRequest := &pingv1.PingRequest{Text: strings.Repeat("a", 1021)} assert.Equal(t, proto.Size(pingRequest), readMaxBytes) - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) assert.Nil(t, err) }) t.Run("read_max_plus_one", func(t *testing.T) { @@ -1527,7 +1834,7 @@ func TestHandlerWithReadMaxBytes(t *testing.T) { compressedSize := gzipCompressedSize(t, pingRequest) assert.True(t, compressedSize < readMaxBytes, assert.Sprintf("expected compressed size %d < %d", compressedSize, readMaxBytes)) } - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) assert.NotNil(t, err, assert.Sprintf("expected non-nil error for large message")) assert.Equal(t, connect.CodeOf(err), connect.CodeResourceExhausted) assert.True(t, strings.HasSuffix(err.Error(), fmt.Sprintf("message size %d is larger than configured max %d", proto.Size(pingRequest), readMaxBytes))) @@ -1545,7 +1852,7 @@ func TestHandlerWithReadMaxBytes(t *testing.T) { expectedSize = gzipCompressedSize(t, pingRequest) assert.True(t, expectedSize > readMaxBytes, assert.Sprintf("expected compressed size %d > %d", expectedSize, readMaxBytes)) } - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) assert.NotNil(t, err, assert.Sprintf("expected non-nil error for large message")) assert.Equal(t, connect.CodeOf(err), connect.CodeResourceExhausted) assert.Equal(t, err.Error(), fmt.Sprintf("resource_exhausted: message size %d is larger than configured max %d", expectedSize, readMaxBytes)) @@ -1559,37 +1866,37 @@ func TestHandlerWithReadMaxBytes(t *testing.T) { t.Run("connect", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) readMaxBytesMatrix(t, client, false) }) t.Run("connect_gzip", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithSendGzip()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithSendCompression("gzip")))) readMaxBytesMatrix(t, client, true) }) t.Run("grpc", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPC()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC()))) readMaxBytesMatrix(t, client, false) }) t.Run("grpc_gzip", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPC(), connect.WithSendGzip()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC(), connecthttp.WithSendCompression("gzip")))) readMaxBytesMatrix(t, client, true) }) t.Run("grpcweb", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPCWeb()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb()))) readMaxBytesMatrix(t, client, false) }) t.Run("grpcweb_gzip", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPCWeb(), connect.WithSendGzip()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb(), connecthttp.WithSendCompression("gzip")))) readMaxBytesMatrix(t, client, true) }) } @@ -1599,20 +1906,23 @@ func TestHandlerWithHTTPMaxBytes(t *testing.T) { // whole stream using the stdlib's http.MaxBytesHandler. t.Parallel() const readMaxBytes = 128 + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + innerMux := http.NewServeMux() + connecthttp.Mount(innerMux, srv) mux := http.NewServeMux() - pingRoute, pingHandler := pingv1connect.NewPingServiceHandler(pingServer{}) - mux.Handle(pingRoute, http.MaxBytesHandler(pingHandler, readMaxBytes)) + mux.Handle("/", http.MaxBytesHandler(innerMux, readMaxBytes)) run := func(t *testing.T, client pingv1connect.PingServiceClient, compressed bool) { t.Helper() t.Run("below_read_max", func(t *testing.T) { t.Parallel() - _, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{})) + _, err := client.Ping(t.Context(), &pingv1.PingRequest{}) assert.Nil(t, err) }) t.Run("just_above_max", func(t *testing.T) { t.Parallel() pingRequest := &pingv1.PingRequest{Text: strings.Repeat("a", readMaxBytes*10)} - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) if compressed { compressedSize := gzipCompressedSize(t, pingRequest) assert.True(t, compressedSize < readMaxBytes, assert.Sprintf("expected compressed size %d < %d", compressedSize, readMaxBytes)) @@ -1632,7 +1942,7 @@ func TestHandlerWithHTTPMaxBytes(t *testing.T) { expectedSize := gzipCompressedSize(t, pingRequest) assert.True(t, expectedSize > readMaxBytes, assert.Sprintf("expected compressed size %d > %d", expectedSize, readMaxBytes)) } - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) assert.NotNil(t, err, assert.Sprintf("expected non-nil error for large message")) assert.Equal(t, connect.CodeOf(err), connect.CodeResourceExhausted) }) @@ -1640,37 +1950,37 @@ func TestHandlerWithHTTPMaxBytes(t *testing.T) { t.Run("connect", func(t *testing.T) { t.Parallel() server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) run(t, client, false) }) t.Run("connect_gzip", func(t *testing.T) { t.Parallel() server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithSendGzip()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithSendCompression("gzip")))) run(t, client, true) }) t.Run("grpc", func(t *testing.T) { t.Parallel() server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPC()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC()))) run(t, client, false) }) t.Run("grpc_gzip", func(t *testing.T) { t.Parallel() server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPC(), connect.WithSendGzip()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC(), connecthttp.WithSendCompression("gzip")))) run(t, client, true) }) t.Run("grpcweb", func(t *testing.T) { t.Parallel() server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPCWeb()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb()))) run(t, client, false) }) t.Run("grpcweb_gzip", func(t *testing.T) { t.Parallel() server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPCWeb(), connect.WithSendGzip()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb(), connecthttp.WithSendCompression("gzip")))) run(t, client, true) }) } @@ -1680,13 +1990,17 @@ func TestClientWithReadMaxBytes(t *testing.T) { createServer := func(tb testing.TB, enableCompression bool) *memhttp.Server { tb.Helper() mux := http.NewServeMux() - var compressionOption connect.HandlerOption + var compressionOption connecthttp.Option if enableCompression { - compressionOption = connect.WithCompressMinBytes(1) + compressionOption = connecthttp.WithCompressMinBytes(1) } else { - compressionOption = connect.WithCompressMinBytes(math.MaxInt) + compressionOption = connecthttp.WithCompressMinBytes(math.MaxInt) } - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{}, compressionOption)) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + // Disable the handler's default read limit: this test exercises the + // client's. + connecthttp.Mount(mux, srv, compressionOption, connecthttp.WithReadMaxBytes(0)) server := memhttptest.NewServer(t, mux) return server } @@ -1700,7 +2014,7 @@ func TestClientWithReadMaxBytes(t *testing.T) { // Serializes to exactly readMaxBytes (1024) - no errors expected pingRequest := &pingv1.PingRequest{Text: strings.Repeat("a", 1021)} assert.Equal(t, proto.Size(pingRequest), readMaxBytes) - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) assert.Nil(t, err) }) t.Run("read_max_plus_one", func(t *testing.T) { @@ -1708,7 +2022,7 @@ func TestClientWithReadMaxBytes(t *testing.T) { // Serializes to readMaxBytes+1 (1025) - expect resource exhausted. // This will be over the limit after decompression but under with compression. pingRequest := &pingv1.PingRequest{Text: strings.Repeat("a", 1022)} - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) assert.NotNil(t, err, assert.Sprintf("expected non-nil error for large message")) assert.Equal(t, connect.CodeOf(err), connect.CodeResourceExhausted) assert.True(t, strings.HasSuffix(err.Error(), fmt.Sprintf("message size %d is larger than configured max %d", proto.Size(pingRequest), readMaxBytes))) @@ -1727,7 +2041,7 @@ func TestClientWithReadMaxBytes(t *testing.T) { assert.True(t, expectedSize > readMaxBytes, assert.Sprintf("expected compressed size %d > %d", expectedSize, readMaxBytes)) } assert.True(t, expectedSize > readMaxBytes, assert.Sprintf("expected compressed size %d > %d", expectedSize, readMaxBytes)) - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) assert.NotNil(t, err, assert.Sprintf("expected non-nil error for large message")) assert.Equal(t, connect.CodeOf(err), connect.CodeResourceExhausted) assert.Equal(t, err.Error(), fmt.Sprintf("resource_exhausted: message size %d is larger than configured max %d", expectedSize, readMaxBytes)) @@ -1735,32 +2049,32 @@ func TestClientWithReadMaxBytes(t *testing.T) { } t.Run("connect", func(t *testing.T) { t.Parallel() - client := pingv1connect.NewPingServiceClient(serverUncompressed.Client(), serverUncompressed.URL(), connect.WithReadMaxBytes(readMaxBytes)) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(serverUncompressed.Client(), serverUncompressed.URL(), connecthttp.WithReadMaxBytes(readMaxBytes)))) readMaxBytesMatrix(t, client, false) }) t.Run("connect_gzip", func(t *testing.T) { t.Parallel() - client := pingv1connect.NewPingServiceClient(serverCompressed.Client(), serverCompressed.URL(), connect.WithReadMaxBytes(readMaxBytes)) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(serverCompressed.Client(), serverCompressed.URL(), connecthttp.WithReadMaxBytes(readMaxBytes)))) readMaxBytesMatrix(t, client, true) }) t.Run("grpc", func(t *testing.T) { t.Parallel() - client := pingv1connect.NewPingServiceClient(serverUncompressed.Client(), serverUncompressed.URL(), connect.WithReadMaxBytes(readMaxBytes), connect.WithGRPC()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(serverUncompressed.Client(), serverUncompressed.URL(), connecthttp.WithReadMaxBytes(readMaxBytes), connecthttp.WithGRPC()))) readMaxBytesMatrix(t, client, false) }) t.Run("grpc_gzip", func(t *testing.T) { t.Parallel() - client := pingv1connect.NewPingServiceClient(serverCompressed.Client(), serverCompressed.URL(), connect.WithReadMaxBytes(readMaxBytes), connect.WithGRPC()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(serverCompressed.Client(), serverCompressed.URL(), connecthttp.WithReadMaxBytes(readMaxBytes), connecthttp.WithGRPC()))) readMaxBytesMatrix(t, client, true) }) t.Run("grpcweb", func(t *testing.T) { t.Parallel() - client := pingv1connect.NewPingServiceClient(serverUncompressed.Client(), serverUncompressed.URL(), connect.WithReadMaxBytes(readMaxBytes), connect.WithGRPCWeb()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(serverUncompressed.Client(), serverUncompressed.URL(), connecthttp.WithReadMaxBytes(readMaxBytes), connecthttp.WithGRPCWeb()))) readMaxBytesMatrix(t, client, false) }) t.Run("grpcweb_gzip", func(t *testing.T) { t.Parallel() - client := pingv1connect.NewPingServiceClient(serverCompressed.Client(), serverCompressed.URL(), connect.WithReadMaxBytes(readMaxBytes), connect.WithGRPCWeb()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(serverCompressed.Client(), serverCompressed.URL(), connecthttp.WithReadMaxBytes(readMaxBytes), connecthttp.WithGRPCWeb()))) readMaxBytesMatrix(t, client, true) }) } @@ -1775,7 +2089,7 @@ func TestHandlerWithSendMaxBytes(t *testing.T) { // Serializes to exactly sendMaxBytes (1024) - no errors expected pingRequest := &pingv1.PingRequest{Text: strings.Repeat("a", 1021)} assert.Equal(t, proto.Size(pingRequest), sendMaxBytes) - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) assert.Nil(t, err) }) t.Run("send_max_plus_one", func(t *testing.T) { @@ -1787,7 +2101,7 @@ func TestHandlerWithSendMaxBytes(t *testing.T) { compressedSize := gzipCompressedSize(t, pingRequest) assert.True(t, compressedSize < sendMaxBytes, assert.Sprintf("expected compressed size %d < %d", compressedSize, sendMaxBytes)) } - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) if compressed { assert.Nil(t, err) } else { @@ -1809,7 +2123,7 @@ func TestHandlerWithSendMaxBytes(t *testing.T) { expectedSize = gzipCompressedSize(t, pingRequest) assert.True(t, expectedSize > sendMaxBytes, assert.Sprintf("expected compressed size %d > %d", expectedSize, sendMaxBytes)) } - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) assert.NotNil(t, err, assert.Sprintf("expected non-nil error for large message")) assert.Equal(t, connect.CodeOf(err), connect.CodeResourceExhausted) if compressed { @@ -1822,53 +2136,54 @@ func TestHandlerWithSendMaxBytes(t *testing.T) { newHTTP2Server := func(t *testing.T, compressed bool, sendMaxBytes int) *memhttp.Server { t.Helper() mux := http.NewServeMux() - options := []connect.HandlerOption{connect.WithSendMaxBytes(sendMaxBytes)} + // Disable the default read limit: this test exercises the send limit. + options := []connecthttp.Option{connecthttp.WithSendMaxBytes(sendMaxBytes), connecthttp.WithReadMaxBytes(0)} if compressed { - options = append(options, connect.WithCompressMinBytes(1)) + options = append(options, connecthttp.WithCompressMinBytes(1)) } else { - options = append(options, connect.WithCompressMinBytes(math.MaxInt)) + options = append(options, connecthttp.WithCompressMinBytes(math.MaxInt)) } - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{}, - options..., - )) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv, options...) + server := memhttptest.NewServer(t, mux) return server } t.Run("connect", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t, false, sendMaxBytes) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) sendMaxBytesMatrix(t, client, false) }) t.Run("connect_gzip", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t, true, sendMaxBytes) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) sendMaxBytesMatrix(t, client, true) }) t.Run("grpc", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t, false, sendMaxBytes) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPC()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC()))) sendMaxBytesMatrix(t, client, false) }) t.Run("grpc_gzip", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t, true, sendMaxBytes) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPC()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC()))) sendMaxBytesMatrix(t, client, true) }) t.Run("grpcweb", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t, false, sendMaxBytes) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPCWeb()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb()))) sendMaxBytesMatrix(t, client, false) }) t.Run("grpcweb_gzip", func(t *testing.T) { t.Parallel() server := newHTTP2Server(t, true, sendMaxBytes) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPCWeb()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb()))) sendMaxBytesMatrix(t, client, true) }) } @@ -1876,7 +2191,9 @@ func TestHandlerWithSendMaxBytes(t *testing.T) { func TestClientWithSendMaxBytes(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{})) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) sendMaxBytesMatrix := func(t *testing.T, client pingv1connect.PingServiceClient, sendMaxBytes int, compressed bool) { t.Helper() @@ -1885,7 +2202,7 @@ func TestClientWithSendMaxBytes(t *testing.T) { // Serializes to exactly sendMaxBytes (1024) - no errors expected pingRequest := &pingv1.PingRequest{Text: strings.Repeat("a", 1021)} assert.Equal(t, proto.Size(pingRequest), sendMaxBytes) - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) assert.Nil(t, err) }) t.Run("send_max_plus_one", func(t *testing.T) { @@ -1893,7 +2210,7 @@ func TestClientWithSendMaxBytes(t *testing.T) { // Serializes to sendMaxBytes+1 (1025) - expect resource exhausted. pingRequest := &pingv1.PingRequest{Text: strings.Repeat("a", 1022)} assert.Equal(t, proto.Size(pingRequest), sendMaxBytes+1) - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) if compressed { assert.True(t, gzipCompressedSize(t, pingRequest) < sendMaxBytes) assert.Nil(t, err, assert.Sprintf("expected nil error for compressed message < sendMaxBytes")) @@ -1916,7 +2233,7 @@ func TestClientWithSendMaxBytes(t *testing.T) { expectedSize = gzipCompressedSize(t, pingRequest) } assert.True(t, expectedSize > sendMaxBytes) - _, err := client.Ping(t.Context(), connect.NewRequest(pingRequest)) + _, err := client.Ping(t.Context(), pingRequest) assert.NotNil(t, err, assert.Sprintf("expected non-nil error for large message")) assert.Equal(t, connect.CodeOf(err), connect.CodeResourceExhausted) if compressed { @@ -1929,67 +2246,69 @@ func TestClientWithSendMaxBytes(t *testing.T) { t.Run("connect", func(t *testing.T) { t.Parallel() sendMaxBytes := 1024 - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithSendMaxBytes(sendMaxBytes)) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithSendMaxBytes(sendMaxBytes)))) sendMaxBytesMatrix(t, client, sendMaxBytes, false) }) t.Run("connect_gzip", func(t *testing.T) { t.Parallel() sendMaxBytes := 1024 - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithSendMaxBytes(sendMaxBytes), connect.WithSendGzip()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithSendMaxBytes(sendMaxBytes), connecthttp.WithSendCompression("gzip")))) sendMaxBytesMatrix(t, client, sendMaxBytes, true) }) t.Run("grpc", func(t *testing.T) { t.Parallel() sendMaxBytes := 1024 - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithSendMaxBytes(sendMaxBytes), connect.WithGRPC()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithSendMaxBytes(sendMaxBytes), connecthttp.WithGRPC()))) sendMaxBytesMatrix(t, client, sendMaxBytes, false) }) t.Run("grpc_gzip", func(t *testing.T) { t.Parallel() sendMaxBytes := 1024 - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithSendMaxBytes(sendMaxBytes), connect.WithGRPC(), connect.WithSendGzip()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithSendMaxBytes(sendMaxBytes), connecthttp.WithGRPC(), connecthttp.WithSendCompression("gzip")))) sendMaxBytesMatrix(t, client, sendMaxBytes, true) }) t.Run("grpcweb", func(t *testing.T) { t.Parallel() sendMaxBytes := 1024 - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithSendMaxBytes(sendMaxBytes), connect.WithGRPCWeb()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithSendMaxBytes(sendMaxBytes), connecthttp.WithGRPCWeb()))) sendMaxBytesMatrix(t, client, sendMaxBytes, false) }) t.Run("grpcweb_gzip", func(t *testing.T) { t.Parallel() sendMaxBytes := 1024 - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithSendMaxBytes(sendMaxBytes), connect.WithGRPCWeb(), connect.WithSendGzip()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithSendMaxBytes(sendMaxBytes), connecthttp.WithGRPCWeb(), connecthttp.WithSendCompression("gzip")))) sendMaxBytesMatrix(t, client, sendMaxBytes, true) }) } - func TestBidiStreamServerSendsFirstMessage(t *testing.T) { t.Parallel() - run := func(t *testing.T, opts ...connect.ClientOption) { + run := func(t *testing.T, opts ...connecthttp.Option) { t.Helper() headersSent := make(chan struct{}) pingServer := &pluggablePingServer{ - cumSum: func(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { + cumSum: func(_ context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { close(headersSent) return nil }, } mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer)) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient( - server.Client(), + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), - connect.WithClientOptions(opts...), - connect.WithInterceptors(&assertPeerInterceptor{t}), + opts...), assertPeerInterceptor(t)), ) - stream := client.CumSum(t.Context()) + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatal(err) + } t.Cleanup(func() { - assert.Nil(t, stream.CloseRequest()) - assert.Nil(t, stream.CloseResponse()) + assert.Nil(t, stream.CloseSend()) + assert.Nil(t, stream.Close()) }) - assert.Nil(t, stream.Send(nil)) + assert.Nil(t, stream.SendHeaders()) select { case <-time.After(time.Second): t.Error("timed out to get request headers") @@ -2002,11 +2321,11 @@ func TestBidiStreamServerSendsFirstMessage(t *testing.T) { }) t.Run("grpc", func(t *testing.T) { t.Parallel() - run(t, connect.WithGRPC()) + run(t, connecthttp.WithGRPC()) }) t.Run("grpcweb", func(t *testing.T) { t.Parallel() - run(t, connect.WithGRPCWeb()) + run(t, connecthttp.WithGRPCWeb()) }) } @@ -2015,68 +2334,73 @@ func TestStreamForServer(t *testing.T) { newPingClient := func(t *testing.T, pingServer pingv1connect.PingServiceHandler) pingv1connect.PingServiceClient { t.Helper() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer)) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL())), ) return client } t.Run("not-proto-message", func(t *testing.T) { t.Parallel() - client := newPingClient(t, &pluggablePingServer{ - cumSum: func(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { - return stream.Conn().Send("foobar") + + mux := http.NewServeMux() + srv := connect.NewServer() + srv.Register(connect.Method{ + Spec: connect.Spec{ + Procedure: pingv1connect.PingServiceCumSumProcedure, + StreamType: connect.StreamTypeBidi, }, - }) - stream := client.CumSum(t.Context()) - assert.Nil(t, stream.Send(nil)) - _, err := stream.Receive() - assert.NotNil(t, err) - assert.Equal(t, connect.CodeOf(err), connect.CodeInternal) - assert.Nil(t, stream.CloseRequest()) - }) - t.Run("nil-message", func(t *testing.T) { - t.Parallel() - client := newPingClient(t, &pluggablePingServer{ - cumSum: func(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { - return stream.Send(nil) + Handler: func(_ context.Context, _ connect.Spec, stream connect.ServerStream) error { + return stream.Send("a-string") }, }) - stream := client.CumSum(t.Context()) - assert.Nil(t, stream.Send(nil)) - _, err := stream.Receive() + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + client := pingv1connect.NewPingServiceClient(connect.NewClient( + connecthttp.NewTransport(server.Client(), server.URL()), + )) + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatal(err) + } + assert.Nil(t, stream.Send(&pingv1.CumSumRequest{})) + _, err = stream.Receive() assert.NotNil(t, err) - assert.Equal(t, connect.CodeOf(err), connect.CodeUnknown) - assert.Nil(t, stream.CloseRequest()) + assert.Equal(t, connect.CodeOf(err), connect.CodeInternal) + assert.Nil(t, stream.CloseSend()) }) t.Run("get-spec", func(t *testing.T) { t.Parallel() client := newPingClient(t, &pluggablePingServer{ - cumSum: func(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { - assert.Equal(t, stream.Spec().StreamType, connect.StreamTypeBidi) - assert.Equal(t, stream.Spec().Procedure, pingv1connect.PingServiceCumSumProcedure) - assert.False(t, stream.Spec().IsClient) + cumSum: func(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { + info, _ := connect.CallInfoForServerContext(ctx) + assert.Equal(t, info.Spec.StreamType, connect.StreamTypeBidi) + assert.Equal(t, info.Spec.Procedure, pingv1connect.PingServiceCumSumProcedure) return nil }, }) - stream := client.CumSum(t.Context()) - assert.Nil(t, stream.Send(nil)) - assert.Nil(t, stream.CloseRequest()) + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatal(err) + } + assert.Nil(t, stream.SendHeaders()) + assert.Nil(t, stream.CloseSend()) }) t.Run("server-stream", func(t *testing.T) { t.Parallel() client := newPingClient(t, &pluggablePingServer{ - countUp: func(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { - assert.Equal(t, stream.Conn().Spec().StreamType, connect.StreamTypeServer) - assert.Equal(t, stream.Conn().Spec().Procedure, pingv1connect.PingServiceCountUpProcedure) - assert.False(t, stream.Conn().Spec().IsClient) + countUp: func(ctx context.Context, req *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { + info, _ := connect.CallInfoForServerContext(ctx) + assert.Equal(t, info.Spec.StreamType, connect.StreamTypeServer) + assert.Equal(t, info.Spec.Procedure, pingv1connect.PingServiceCountUpProcedure) assert.Nil(t, stream.Send(&pingv1.CountUpResponse{Number: 1})) return nil }, }) - stream, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{})) + stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{}) assert.Nil(t, err) assert.NotNil(t, stream) assert.Nil(t, stream.Close()) @@ -2084,92 +2408,42 @@ func TestStreamForServer(t *testing.T) { t.Run("server-stream-send", func(t *testing.T) { t.Parallel() client := newPingClient(t, &pluggablePingServer{ - countUp: func(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { + countUp: func(ctx context.Context, req *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { assert.Nil(t, stream.Send(&pingv1.CountUpResponse{Number: 1})) return nil }, }) - stream, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{})) + stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{}) assert.Nil(t, err) - assert.True(t, stream.Receive()) - msg := stream.Msg() - assert.NotNil(t, msg) - assert.Equal(t, msg.GetNumber(), 1) - assert.Nil(t, stream.Close()) - }) - t.Run("server-stream-send-nil", func(t *testing.T) { - t.Parallel() - client := newPingClient(t, &pluggablePingServer{ - countUp: func(ctx context.Context, req *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { - stream.ResponseHeader().Set("foo", "bar") - stream.ResponseTrailer().Set("bas", "blah") - assert.Nil(t, stream.Send(nil)) - return nil - }, - }) - stream, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{})) + msg, err := stream.Receive() assert.Nil(t, err) - assert.False(t, stream.Receive()) - headers := stream.ResponseHeader() - assert.NotNil(t, headers) - assert.Equal(t, headers.Get("foo"), "bar") - trailers := stream.ResponseTrailer() - assert.NotNil(t, trailers) - assert.Equal(t, trailers.Get("bas"), "blah") + assert.NotNil(t, msg) + assert.Equal(t, msg.GetNumber(), int64(1)) assert.Nil(t, stream.Close()) }) t.Run("client-stream", func(t *testing.T) { t.Parallel() client := newPingClient(t, &pluggablePingServer{ - sum: func(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { - assert.Equal(t, stream.Spec().StreamType, connect.StreamTypeClient) - assert.Equal(t, stream.Spec().Procedure, pingv1connect.PingServiceSumProcedure) - assert.False(t, stream.Spec().IsClient) - assert.True(t, stream.Receive()) - msg := stream.Msg() + sum: func(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + assert.Equal(t, info.Spec.StreamType, connect.StreamTypeClient) + assert.Equal(t, info.Spec.Procedure, pingv1connect.PingServiceSumProcedure) + msg, err := stream.Receive() + assert.Nil(t, err) assert.NotNil(t, msg) - assert.Equal(t, msg.GetNumber(), 1) - return connect.NewResponse(&pingv1.SumResponse{Sum: 1}), nil - }, - }) - stream := client.Sum(t.Context()) - assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 1})) - res, err := stream.CloseAndReceive() - assert.Nil(t, err) - assert.NotNil(t, res) - assert.Equal(t, res.Msg.GetSum(), 1) - }) - t.Run("client-stream-conn", func(t *testing.T) { - t.Parallel() - client := newPingClient(t, &pluggablePingServer{ - sum: func(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { - assert.True(t, stream.Receive()) - assert.NotNil(t, stream.Conn().Send("not-proto")) - return connect.NewResponse(&pingv1.SumResponse{}), nil + assert.Equal(t, msg.GetNumber(), int64(1)) + return &pingv1.SumResponse{Sum: 1}, nil }, }) - stream := client.Sum(t.Context()) - assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 1})) - res, err := stream.CloseAndReceive() + cstream, err := client.Sum(t.Context()) + if err != nil { + t.Fatal(err) + } + assert.Nil(t, cstream.Send(&pingv1.SumRequest{Number: 1})) + res, err := cstream.CloseAndReceive() assert.Nil(t, err) assert.NotNil(t, res) - }) - t.Run("client-stream-send-msg", func(t *testing.T) { - t.Parallel() - client := newPingClient(t, &pluggablePingServer{ - sum: func(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { - assert.True(t, stream.Receive()) - // We end up sending two response messages, but only one is expected. - assert.Nil(t, stream.Conn().Send(&pingv1.SumResponse{Sum: 2})) - return connect.NewResponse(&pingv1.SumResponse{}), nil - }, - }) - stream := client.Sum(t.Context()) - assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 1})) - res, err := stream.CloseAndReceive() - assert.NotNil(t, err) - assert.Equal(t, connect.CodeOf(err), connect.CodeUnimplemented) - assert.Nil(t, res) + assert.Equal(t, res.GetSum(), int64(1)) }) } @@ -2179,11 +2453,13 @@ func TestConnectHTTPErrorCodes(t *testing.T) { t.Helper() mux := http.NewServeMux() pluggableServer := &pluggablePingServer{ - ping: func(_ context.Context, _ *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { - return nil, connect.NewError(connectCode, errors.New("error")) + ping: func(_ context.Context, _ *pingv1.PingRequest) (*pingv1.PingResponse, error) { + return nil, connect.NewError(connectCode, "error") }, } - mux.Handle(pingv1connect.NewPingServiceHandler(pluggableServer)) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pluggableServer) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) req, err := http.NewRequestWithContext( t.Context(), @@ -2197,72 +2473,72 @@ func TestConnectHTTPErrorCodes(t *testing.T) { assert.Nil(t, err) defer resp.Body.Close() assert.Equal(t, wantHttpStatus, resp.StatusCode) - connectClient := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - connectResp, err := connectClient.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{})) + connectClient := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + connectResp, err := connectClient.Ping(t.Context(), &pingv1.PingRequest{}) assert.NotNil(t, err) assert.Nil(t, connectResp) } - t.Run("CodeCanceled-499", func(t *testing.T) { + t.Run("connect.CodeCanceled-499", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeCanceled, 499) }) - t.Run("CodeUnknown-500", func(t *testing.T) { + t.Run("connect.CodeUnknown-500", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeUnknown, 500) }) - t.Run("CodeInvalidArgument-400", func(t *testing.T) { + t.Run("connect.CodeInvalidArgument-400", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeInvalidArgument, 400) }) - t.Run("CodeDeadlineExceeded-504", func(t *testing.T) { + t.Run("connect.CodeDeadlineExceeded-504", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeDeadlineExceeded, 504) }) - t.Run("CodeNotFound-404", func(t *testing.T) { + t.Run("connect.CodeNotFound-404", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeNotFound, 404) }) - t.Run("CodeAlreadyExists-409", func(t *testing.T) { + t.Run("connect.CodeAlreadyExists-409", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeAlreadyExists, 409) }) - t.Run("CodePermissionDenied-403", func(t *testing.T) { + t.Run("connect.CodePermissionDenied-403", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodePermissionDenied, 403) }) - t.Run("CodeResourceExhausted-429", func(t *testing.T) { + t.Run("connect.CodeResourceExhausted-429", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeResourceExhausted, 429) }) - t.Run("CodeFailedPrecondition-400", func(t *testing.T) { + t.Run("connect.CodeFailedPrecondition-400", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeFailedPrecondition, 400) }) - t.Run("CodeAborted-409", func(t *testing.T) { + t.Run("connect.CodeAborted-409", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeAborted, 409) }) - t.Run("CodeOutOfRange-400", func(t *testing.T) { + t.Run("connect.CodeOutOfRange-400", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeOutOfRange, 400) }) - t.Run("CodeUnimplemented-501", func(t *testing.T) { + t.Run("connect.CodeUnimplemented-501", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeUnimplemented, 501) }) - t.Run("CodeInternal-500", func(t *testing.T) { + t.Run("connect.CodeInternal-500", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeInternal, 500) }) - t.Run("CodeUnavailable-503", func(t *testing.T) { + t.Run("connect.CodeUnavailable-503", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeUnavailable, 503) }) - t.Run("CodeDataLoss-500", func(t *testing.T) { + t.Run("connect.CodeDataLoss-500", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeDataLoss, 500) }) - t.Run("CodeUnauthenticated-401", func(t *testing.T) { + t.Run("connect.CodeUnauthenticated-401", func(t *testing.T) { t.Parallel() checkHTTPStatus(t, connect.CodeUnauthenticated, 401) }) @@ -2279,28 +2555,17 @@ func TestConnectHTTPErrorCodes(t *testing.T) { func TestFailCompression(t *testing.T) { t.Parallel() mux := http.NewServeMux() - compressorName := "fail" - compressor := func() connect.Compressor { return failCompressor{} } - decompressor := func() connect.Decompressor { return failDecompressor{} } - mux.Handle( - pingv1connect.NewPingServiceHandler( - pingServer{}, - connect.WithCompression(compressorName, decompressor, compressor), - ), - ) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv, connecthttp.WithCompressor(failCompressor{})) server := memhttptest.NewServer(t, mux) - pingclient := pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - connect.WithAcceptCompression(compressorName, decompressor, compressor), - connect.WithSendCompression(compressorName), - ) - _, err := pingclient.Ping( - t.Context(), - connect.NewRequest(&pingv1.PingRequest{ - Text: "ping", - }), - ) + client := pingv1connect.NewPingServiceClient(connect.NewClient( + connecthttp.NewTransport(server.Client(), server.URL(), + connecthttp.WithCompressor(failCompressor{}), + connecthttp.WithSendCompression(failCompressor{}.Name()), + ), + )) + _, err := client.Ping(t.Context(), &pingv1.PingRequest{Text: "ping"}) assert.NotNil(t, err) assert.Equal(t, connect.CodeOf(err), connect.CodeInternal) } @@ -2319,36 +2584,39 @@ func TestUnflushableResponseWriter(t *testing.T) { assert.Sprintf("error doesn't reference http.Flusher: %s", err.Error()), ) } + innerMux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(innerMux, srv) mux := http.NewServeMux() - path, handler := pingv1connect.NewPingServiceHandler(pingServer{}) - wrapped := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handler.ServeHTTP(&unflushableWriter{w}, r) - }) - mux.Handle(path, wrapped) + mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + innerMux.ServeHTTP(&unflushableWriter{w}, r) + })) server := memhttptest.NewServer(t, mux) tests := []struct { name string - options []connect.ClientOption + options []connecthttp.Option }{ {"connect", nil}, - {"grpc", []connect.ClientOption{connect.WithGRPC()}}, - {"grpcweb", []connect.ClientOption{connect.WithGRPCWeb()}}, + {"grpc", []connecthttp.Option{connecthttp.WithGRPC()}}, + {"grpcweb", []connecthttp.Option{connecthttp.WithGRPCWeb()}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - pingclient := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), tt.options...) + pingclient := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), tt.options...))) stream, err := pingclient.CountUp( t.Context(), - connect.NewRequest(&pingv1.CountUpRequest{Number: 5}), + &pingv1.CountUpRequest{Number: 5}, ) if err != nil { assertIsFlusherErr(t, err) return } - if assert.False(t, stream.Receive()) { - assertIsFlusherErr(t, stream.Err()) + _, err = stream.Receive() + if err != nil { + assertIsFlusherErr(t, err) } }) } @@ -2357,7 +2625,9 @@ func TestUnflushableResponseWriter(t *testing.T) { func TestGRPCErrorMetadataIsTrailersOnly(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{})) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) protoBytes, err := proto.Marshal(&pingv1.FailRequest{Code: int32(connect.CodeInternal)}) @@ -2383,42 +2653,48 @@ func TestGRPCErrorMetadataIsTrailersOnly(t *testing.T) { assert.Nil(t, err) assert.Equal(t, res.StatusCode, http.StatusOK) assert.Equal(t, res.Header.Get("Content-Type"), "application/grpc") - // pingServer.Fail adds handlerHeader and handlerTrailer to the error - // metadata. The gRPC protocol should send all error metadata as trailers. - assert.Zero(t, res.Header.Get(handlerHeader)) + // pingServer.Fail sets handlerHeader on the response header and + // handlerTrailer on the response trailer. gRPC sends leading metadata as + // HTTP headers and trailing metadata as HTTP trailers. + assert.NotZero(t, res.Header.Get(handlerHeader)) assert.Zero(t, res.Header.Get(handlerTrailer)) _, err = io.Copy(io.Discard, res.Body) assert.Nil(t, err) assert.Nil(t, res.Body.Close()) - assert.NotZero(t, res.Trailer.Get(handlerHeader)) + assert.Zero(t, res.Trailer.Get(handlerHeader)) assert.NotZero(t, res.Trailer.Get(handlerTrailer)) } func TestConnectProtocolHeaderSentByDefault(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{}, connect.WithRequireConnectProtocolHeader())) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv, connecthttp.WithRequireConnectProtocolHeader()) server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - _, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{})) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + _, err := client.Ping(t.Context(), &pingv1.PingRequest{}) assert.Nil(t, err) - stream := client.CumSum(t.Context()) + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatal(err) + } assert.Nil(t, stream.Send(&pingv1.CumSumRequest{})) _, err = stream.Receive() assert.Nil(t, err) - assert.Nil(t, stream.CloseRequest()) - assert.Nil(t, stream.CloseResponse()) + assert.Nil(t, stream.CloseSend()) + assert.Nil(t, stream.Close()) } func TestConnectProtocolHeaderRequired(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler( - pingServer{}, - connect.WithRequireConnectProtocolHeader(), - )) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv, connecthttp.WithRequireConnectProtocolHeader()) + server := memhttptest.NewServer(t, mux) tests := []struct { @@ -2450,17 +2726,17 @@ func TestUserAgent(t *testing.T) { const customAgent = "custom" protocols := []struct { name string - opts []connect.ClientOption + opts []connecthttp.Option }{ {"connect", nil}, - {"grpc", []connect.ClientOption{connect.WithGRPC()}}, - {"grpcweb", []connect.ClientOption{connect.WithGRPCWeb()}}, + {"grpc", []connecthttp.Option{connecthttp.WithGRPC()}}, + {"grpcweb", []connecthttp.Option{connecthttp.WithGRPCWeb()}}, } headers := []struct { name string // set mutates the outgoing request header. A nil func leaves the // User-Agent unset, so the framework should supply its own default. - set func(http.Header) + set func(*connect.Header) // check verifies what the server received for the User-Agent header. check func(t *testing.T, values []string, ok bool) }{ @@ -2478,7 +2754,7 @@ func TestUserAgent(t *testing.T) { { // A user-provided User-Agent must not be clobbered. name: "custom", - set: func(h http.Header) { h.Set("User-Agent", customAgent) }, + set: func(h *connect.Header) { h.Set("User-Agent", customAgent) }, check: func(t *testing.T, values []string, _ bool) { t.Helper() assert.Equal(t, values, []string{customAgent}) @@ -2488,7 +2764,7 @@ func TestUserAgent(t *testing.T) { // An explicit empty value suppresses the default; the server // should see no User-Agent header at all. name: "empty-string", - set: func(h http.Header) { h["User-Agent"] = []string{""} }, + set: func(h *connect.Header) { h.Set("User-Agent", "") }, check: func(t *testing.T, _ []string, ok bool) { t.Helper() assert.False(t, ok) @@ -2497,7 +2773,7 @@ func TestUserAgent(t *testing.T) { { // An explicit nil slice also suppresses the default. name: "nil-slice", - set: func(h http.Header) { h["User-Agent"] = nil }, + set: func(h *connect.Header) { h.SetValues("User-Agent", nil) }, check: func(t *testing.T, _ []string, ok bool) { t.Helper() assert.False(t, ok) @@ -2509,20 +2785,28 @@ func TestUserAgent(t *testing.T) { t.Run(protocol.name+"/"+header.name, func(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(&pluggablePingServer{ - ping: func(_ context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { - values, ok := req.Header()["User-Agent"] + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, &pluggablePingServer{ + ping: func(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + values := info.RequestHeader().Values("User-Agent") + ok := info.RequestHeader().Has("User-Agent") + t.Log(values, ok) header.check(t, values, ok) - return connect.NewResponse(&pingv1.PingResponse{Number: req.Msg.GetNumber()}), nil + return &pingv1.PingResponse{Number: req.GetNumber()}, nil }, - })) + }) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), protocol.opts...) - req := connect.NewRequest(&pingv1.PingRequest{Number: 42}) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), protocol.opts...))) + ctx := t.Context() if header.set != nil { - header.set(req.Header()) + var callInfo *connect.CallInfo + ctx, callInfo = connect.NewClientContext(ctx) + header.set(callInfo.RequestHeader()) } - _, err := client.Ping(t.Context(), req) + _, err := client.Ping(ctx, &pingv1.PingRequest{Number: 42}) assert.Nil(t, err) }) } @@ -2533,91 +2817,58 @@ func TestWebXUserAgent(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(&pluggablePingServer{ - ping: func(_ context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { - agent := req.Header().Get("User-Agent") + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, &pluggablePingServer{ + ping: func(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + agent := info.RequestHeader().Get("User-Agent") assert.NotZero(t, agent) + xAgent := info.RequestHeader().Get("X-User-Agent") assert.Equal( t, - req.Header().Get("X-User-Agent"), + xAgent, agent, ) - return connect.NewResponse(&pingv1.PingResponse{Number: req.Msg.GetNumber()}), nil + return &pingv1.PingResponse{Number: req.GetNumber()}, nil }, - })) + }) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPCWeb()) - req := connect.NewRequest(&pingv1.PingRequest{Number: 42}) - _, err := client.Ping(t.Context(), req) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb()))) + _, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 42}) assert.Nil(t, err) } func TestBidiOverHTTP1(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{})) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) // Clients expecting a full-duplex connection that end up with a simplex // HTTP/1.1 connection shouldn't hang. Instead, the server should close the // TCP connection. - client := pingv1connect.NewPingServiceClient( - &http.Client{Transport: server.TransportHTTP1()}, - server.URL(), + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(&http.Client{Transport: server.TransportHTTP1()}, + server.URL())), ) - stream := client.CumSum(t.Context()) + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatal(err) + } // Stream creates an async request, can error on Send or Receive. if err := stream.Send(&pingv1.CumSumRequest{Number: 2}); err != nil { assert.ErrorIs(t, err, io.EOF) } - _, err := stream.Receive() + _, err = stream.Receive() assert.NotNil(t, err) assert.Equal(t, connect.CodeOf(err), connect.CodeUnknown) assert.Equal(t, err.Error(), "unknown: HTTP status 505 HTTP Version Not Supported") - assert.Nil(t, stream.CloseRequest()) - assert.Nil(t, stream.CloseResponse()) -} - -func TestHandlerReturnsNilResponse(t *testing.T) { - // When user-written handlers return nil responses _and_ nil errors, ensure - // that the resulting panic includes at least the name of the procedure. - t.Parallel() - - var panics int - recoverPanic := func(_ context.Context, spec connect.Spec, _ http.Header, p any) error { - panics++ - assert.NotNil(t, p) - str := fmt.Sprint(p) - assert.True( - t, - strings.Contains(str, spec.Procedure), - assert.Sprintf("%q does not contain procedure %q", str, spec.Procedure), - ) - return connect.NewError(connect.CodeInternal, errors.New(str)) - } - - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(&pluggablePingServer{ - ping: func(ctx context.Context, req *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { - return nil, nil //nolint: nilnil - }, - sum: func(ctx context.Context, req *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { - return nil, nil //nolint: nilnil - }, - }, connect.WithRecover(recoverPanic))) - server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - - _, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{})) - assert.NotNil(t, err) - assert.Equal(t, connect.CodeOf(err), connect.CodeInternal) - - _, err = client.Sum(t.Context()).CloseAndReceive() - assert.NotNil(t, err) - assert.Equal(t, connect.CodeOf(err), connect.CodeInternal) - - assert.Equal(t, panics, 2) + assert.Nil(t, stream.CloseSend()) + assert.Nil(t, stream.Close()) } func TestStreamUnexpectedEOF(t *testing.T) { @@ -2644,12 +2895,12 @@ func TestStreamUnexpectedEOF(t *testing.T) { testcases := []struct { name string handler http.HandlerFunc - options []connect.ClientOption + options []connecthttp.Option expectCode connect.Code expectMsg string }{{ name: "connect_missing_end", - options: []connect.ClientOption{connect.WithProtoJSON()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON)}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { header := responseWriter.Header() header.Set("Content-Type", "application/connect+json") @@ -2662,7 +2913,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: "internal: protocol error: unexpected EOF", }, { name: "grpc_missing_end", - options: []connect.ClientOption{connect.WithProtoJSON(), connect.WithGRPC()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON), connecthttp.WithGRPC()}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { header := responseWriter.Header() header.Set("Content-Type", "application/grpc+json") @@ -2675,7 +2926,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: "internal: protocol error: no Grpc-Status trailer: unexpected EOF", }, { name: "grpc_missing_status", - options: []connect.ClientOption{connect.WithProtoJSON(), connect.WithGRPC()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON), connecthttp.WithGRPC()}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { header := responseWriter.Header() header.Set("Content-Type", "application/grpc+json") @@ -2690,7 +2941,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: "unknown: protocol error: no Grpc-Status trailer: unexpected EOF", }, { name: "grpc-web_missing_end", - options: []connect.ClientOption{connect.WithProtoJSON(), connect.WithGRPCWeb()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON), connecthttp.WithGRPCWeb()}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { header := responseWriter.Header() header.Set("Content-Type", "application/grpc-web+json") @@ -2703,7 +2954,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: "internal: protocol error: no Grpc-Status trailer: unexpected EOF", }, { name: "grpc-web_missing_status", - options: []connect.ClientOption{connect.WithProtoJSON(), connect.WithGRPCWeb()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON), connecthttp.WithGRPCWeb()}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { header := responseWriter.Header() header.Set("Content-Type", "application/grpc-web+json") @@ -2726,7 +2977,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: "unknown: protocol error: no Grpc-Status trailer: unexpected EOF", }, { name: "connect_partial_payload", - options: []connect.ClientOption{connect.WithProtoJSON()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON)}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { header := responseWriter.Header() header.Set("Content-Type", "application/connect+json") @@ -2739,7 +2990,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: fmt.Sprintf("invalid_argument: protocol error: promised %d bytes in enveloped message, got %d bytes", len(payload), len(payload)-1), }, { name: "grpc_partial_payload", - options: []connect.ClientOption{connect.WithProtoJSON(), connect.WithGRPC()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON), connecthttp.WithGRPC()}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { header := responseWriter.Header() header.Set("Content-Type", "application/grpc+json") @@ -2752,7 +3003,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: fmt.Sprintf("invalid_argument: protocol error: promised %d bytes in enveloped message, got %d bytes", len(payload), len(payload)-1), }, { name: "grpc-web_partial_payload", - options: []connect.ClientOption{connect.WithProtoJSON(), connect.WithGRPCWeb()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON), connecthttp.WithGRPCWeb()}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { header := responseWriter.Header() header.Set("Content-Type", "application/grpc-web+json") @@ -2765,7 +3016,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: fmt.Sprintf("invalid_argument: protocol error: promised %d bytes in enveloped message, got %d bytes", len(payload), len(payload)-1), }, { name: "connect_partial_frame", - options: []connect.ClientOption{connect.WithProtoJSON()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON)}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { header := responseWriter.Header() header.Set("Content-Type", "application/connect+json") @@ -2776,7 +3027,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: "invalid_argument: protocol error: incomplete envelope: unexpected EOF", }, { name: "grpc_partial_frame", - options: []connect.ClientOption{connect.WithProtoJSON(), connect.WithGRPC()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON), connecthttp.WithGRPC()}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { header := responseWriter.Header() header.Set("Content-Type", "application/grpc+json") @@ -2787,7 +3038,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: "invalid_argument: protocol error: incomplete envelope: unexpected EOF", }, { name: "grpc-web_partial_frame", - options: []connect.ClientOption{connect.WithProtoJSON(), connect.WithGRPCWeb()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON), connecthttp.WithGRPCWeb()}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { header := responseWriter.Header() header.Set("Content-Type", "application/grpc-web+json") @@ -2798,7 +3049,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: "invalid_argument: protocol error: incomplete envelope: unexpected EOF", }, { name: "connect_excess_eof", - options: []connect.ClientOption{connect.WithProtoJSON()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON)}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { responseWriter.Header().Set("Content-Type", "application/connect+json") _, err := responseWriter.Write(head[:]) @@ -2820,7 +3071,7 @@ func TestStreamUnexpectedEOF(t *testing.T) { expectMsg: fmt.Sprintf("internal: corrupt response: %d extra bytes after end of stream", len(payload)+len(head)), }, { name: "grpc-web_excess_eof", - options: []connect.ClientOption{connect.WithProtoJSON(), connect.WithGRPCWeb()}, + options: []connecthttp.Option{connecthttp.WithSendCodec(connect.CodecNameJSON), connecthttp.WithGRPCWeb()}, handler: func(responseWriter http.ResponseWriter, _ *http.Request) { responseWriter.Header().Set("Content-Type", "application/grpc-web+json") _, err := responseWriter.Write(head[:]) @@ -2853,27 +3104,33 @@ func TestStreamUnexpectedEOF(t *testing.T) { for _, testcase := range testcases { t.Run(testcase.name, func(t *testing.T) { t.Parallel() - client := pingv1connect.NewPingServiceClient( - server.Client(), + ctx, callInfo := connect.NewClientContext(t.Context()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), - testcase.options..., - ) + testcase.options...))) const upTo = 2 - request := connect.NewRequest(&pingv1.CountUpRequest{Number: upTo}) - request.Header().Set("Test-Case", t.Name()) - stream, err := client.CountUp(t.Context(), request) + callInfo.RequestHeader().Set("Test-Case", t.Name()) + stream, err := client.CountUp(ctx, &pingv1.CountUpRequest{Number: upTo}) assert.Nil(t, err) - for i := 0; stream.Receive() && i < upTo; i++ { - assert.Equal(t, stream.Msg().GetNumber(), 42) + var streamErr error + for range upTo { + _, err := stream.Receive() + if err != nil { + streamErr = err + break + } } - assert.NotNil(t, stream.Err()) - assert.Equal(t, connect.CodeOf(stream.Err()), testcase.expectCode) - assert.Equal(t, stream.Err().Error(), testcase.expectMsg) + if streamErr == nil { + _, streamErr = stream.Receive() + } + assert.NotNil(t, streamErr) + assert.Equal(t, connect.CodeOf(streamErr), testcase.expectCode) + assert.Equal(t, streamErr.Error(), testcase.expectMsg) }) } } -// TestClientDisconnect tests that the handler receives a CodeCanceled error when +// TestClientDisconnect tests that the handler receives a connect.CodeCanceled error when // the client abruptly disconnects. func TestClientDisconnect(t *testing.T) { t.Parallel() @@ -2916,26 +3173,37 @@ func TestClientDisconnect(t *testing.T) { gotResponse = make(chan struct{}) ) pingServer := &pluggablePingServer{ - sum: func(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) { + sum: func(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { close(gotRequest) - for stream.Receive() { + for { + _, err := stream.Receive() + if err != nil { + if !errors.Is(err, io.EOF) { + handlerReceiveErr = err + } + break + } // Do nothing } - handlerReceiveErr = stream.Err() <-ctx.Done() // Context cancel is asynchronous, wait for cancel handlerContextErr = ctx.Err() close(gotResponse) - return connect.NewResponse(&pingv1.SumResponse{}), nil + return &pingv1.SumResponse{}, nil }, } mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer)) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) var clientConn net.Conn transport := captureTransport(server, &clientConn, gotRequest) serverClient := &http.Client{Transport: transport} - client := pingv1connect.NewPingServiceClient(serverClient, server.URL()) - stream := client.Sum(t.Context()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(serverClient, server.URL()))) + stream, err := client.Sum(t.Context()) + if err != nil { + t.Fatal(err) + } // Send header. assert.Nil(t, stream.Send(nil)) <-gotRequest @@ -2944,7 +3212,7 @@ func TestClientDisconnect(t *testing.T) { return } assert.Nil(t, clientConn.Close()) - _, err := stream.CloseAndReceive() + _, err = stream.CloseAndReceive() assert.NotNil(t, err) <-gotResponse assert.NotNil(t, handlerReceiveErr) @@ -2965,7 +3233,7 @@ func TestClientDisconnect(t *testing.T) { gotResponse = make(chan struct{}) ) pingServer := &pluggablePingServer{ - countUp: func(ctx context.Context, _ *connect.Request[pingv1.CountUpRequest], stream *connect.ServerStream[pingv1.CountUpResponse]) error { + countUp: func(ctx context.Context, _ *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { close(gotRequest) var err error for err == nil { @@ -2979,13 +3247,15 @@ func TestClientDisconnect(t *testing.T) { }, } mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer)) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) var clientConn net.Conn transport := captureTransport(server, &clientConn, gotRequest) serverClient := &http.Client{Transport: transport} - client := pingv1connect.NewPingServiceClient(serverClient, server.URL()) - stream, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{})) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(serverClient, server.URL()))) + stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{}) if !assert.Nil(t, err) { return } @@ -2995,10 +3265,18 @@ func TestClientDisconnect(t *testing.T) { return } assert.Nil(t, clientConn.Close()) - for stream.Receive() { + var streamErr error + for { + _, err := stream.Receive() + if err != nil { + if !errors.Is(err, io.EOF) { + streamErr = err + } + break + } // Do nothing } - assert.NotNil(t, stream.Err()) + assert.NotNil(t, streamErr) <-gotResponse assert.NotNil(t, handlerReceiveErr) assert.Equal(t, connect.CodeOf(handlerReceiveErr), connect.CodeCanceled) @@ -3031,18 +3309,18 @@ func TestSetProtocolHeaders(t *testing.T) { t.Parallel() tests := []struct { name string - clientOption connect.ClientOption + clientOption connecthttp.Option expectContentType string }{{ name: "connect", expectContentType: "application/proto", }, { name: "grpc", - clientOption: connect.WithGRPC(), + clientOption: connecthttp.WithGRPC(), expectContentType: "application/grpc", }, { name: "grpcweb", - clientOption: connect.WithGRPCWeb(), + clientOption: connecthttp.WithGRPCWeb(), expectContentType: "application/grpc-web+proto", }} for _, tt := range tests { @@ -3051,40 +3329,45 @@ func TestSetProtocolHeaders(t *testing.T) { t.Parallel() pingServer := &pingServer{} mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer)) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) - clientOpts := []connect.ClientOption{} + clientOpts := []connecthttp.Option{} if testcase.clientOption == nil { // Use a different protocol to test the override. - clientOpts = append(clientOpts, connect.WithGRPC()) + clientOpts = append(clientOpts, connecthttp.WithGRPC()) } - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), clientOpts...) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), clientOpts...))) pingProxyServer := &pluggablePingServer{ - ping: func(ctx context.Context, request *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { + ping: func(ctx context.Context, request *pingv1.PingRequest) (*pingv1.PingResponse, error) { return client.Ping(ctx, request) }, } proxyMux := http.NewServeMux() - proxyMux.Handle(pingv1connect.NewPingServiceHandler(pingProxyServer)) + srv2 := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv2, pingProxyServer) + connecthttp.Mount(proxyMux, srv2) proxyServer := memhttptest.NewServer(t, proxyMux) - proxyClientOpts := []connect.ClientOption{} + proxyClientOpts := []connecthttp.Option{} if testcase.clientOption != nil { proxyClientOpts = append(proxyClientOpts, testcase.clientOption) } - proxyClient := pingv1connect.NewPingServiceClient(proxyServer.Client(), proxyServer.URL(), proxyClientOpts...) + proxyClient := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(proxyServer.Client(), proxyServer.URL(), proxyClientOpts...))) - request := connect.NewRequest(&pingv1.PingRequest{Number: 42}) - request.Header().Set("X-Test", t.Name()) - response, err := proxyClient.Ping(t.Context(), request) + ctx, callInfo := connect.NewClientContext(t.Context()) + callInfo.RequestHeader().Set("X-Test", t.Name()) + _, err := proxyClient.Ping(ctx, &pingv1.PingRequest{Number: 42}) if !assert.Nil(t, err) { return } // Assert the Content-Type is set for the proxy clients protocol and not the client's. - assert.Equal(t, response.Header().Get("Content-Type"), testcase.expectContentType) - assert.Equal(t, len(response.Header().Values("Content-Type")), 1) + contentType := callInfo.ResponseHeader().Get("Content-Type") + assert.Equal(t, contentType, testcase.expectContentType) + assert.Equal(t, len(callInfo.ResponseHeader().Values("Content-Type")), 1) }) } } @@ -3092,84 +3375,60 @@ func TestSetProtocolHeaders(t *testing.T) { func TestCallInfoHeadersOnError(t *testing.T) { t.Parallel() - handler := &pluggablePingServerSimple{ + handler := &pluggablePingServer{ ping: func(ctx context.Context, request *pingv1.PingRequest) (*pingv1.PingResponse, error) { - callInfo, ok := connect.CallInfoForHandlerContext(ctx) - if !ok { - return nil, connect.NewError(connect.CodeInternal, nil) - } + callInfo, _ := connect.CallInfoForServerContext(ctx) if request.GetNumber() < 0 { callInfo.ResponseHeader().Set("x-custom-key", "ping-error") callInfo.ResponseHeader().Set("x-header-only", "should-not-be-in-trailers") callInfo.ResponseTrailer().Set("x-trailer-only", "should-not-be-in-headers") - callInfo.ResponseTrailer().Set("x-both-sources", "from-callinfo-trailer") - err := connect.NewError(connect.CodeInvalidArgument, nil) - err.Meta().Set("x-error-only", "from-error-only") - err.Meta().Set("x-both-sources", "from-error-meta") - return nil, err + return nil, connect.NewError(connect.CodeInvalidArgument, "") } callInfo.ResponseHeader().Set("x-custom-key", "ping-success") callInfo.ResponseHeader().Set("x-success-header", "in-headers") callInfo.ResponseTrailer().Set("x-success-trailer", "in-trailers") return &pingv1.PingResponse{Number: request.GetNumber()}, nil }, - sum: func(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*pingv1.SumResponse, error) { - callInfo, ok := connect.CallInfoForHandlerContext(ctx) - if !ok { - return nil, connect.NewError(connect.CodeInternal, nil) - } + sum: func(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { + callInfo, _ := connect.CallInfoForServerContext(ctx) var sum int64 - for stream.Receive() { - if stream.Msg().GetNumber() < 0 { - // Error case + for { + msg, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, err + } + if msg.GetNumber() < 0 { callInfo.ResponseHeader().Set("x-custom-key", "sum-error") callInfo.ResponseHeader().Set("x-header-only", "should-not-be-in-trailers") callInfo.ResponseTrailer().Set("x-trailer-only", "should-not-be-in-headers") - callInfo.ResponseTrailer().Set("x-both-sources", "from-callinfo-trailer") - err := connect.NewError(connect.CodeInvalidArgument, nil) - err.Meta().Set("x-error-only", "from-error-only") - err.Meta().Set("x-both-sources", "from-error-meta") - return nil, err + return nil, connect.NewError(connect.CodeInvalidArgument, "") } - sum += stream.Msg().GetNumber() - } - if stream.Err() != nil { - return nil, stream.Err() + sum += msg.GetNumber() } callInfo.ResponseHeader().Set("x-custom-key", "sum-success") callInfo.ResponseHeader().Set("x-success-header", "in-headers") callInfo.ResponseTrailer().Set("x-success-trailer", "in-trailers") return &pingv1.SumResponse{Sum: sum}, nil }, - countUp: func(ctx context.Context, request *pingv1.CountUpRequest, stream *connect.ServerStream[pingv1.CountUpResponse]) error { - callInfo, ok := connect.CallInfoForHandlerContext(ctx) - if !ok { - return connect.NewError(connect.CodeInternal, nil) - } + countUp: func(ctx context.Context, request *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { + callInfo, _ := connect.CallInfoForServerContext(ctx) if request.GetNumber() < 0 { - // Error before first response callInfo.ResponseHeader().Set("x-custom-key", "countup-error") callInfo.ResponseHeader().Set("x-header-only", "should-not-be-in-trailers") callInfo.ResponseTrailer().Set("x-trailer-only", "should-not-be-in-headers") - callInfo.ResponseTrailer().Set("x-both-sources", "from-callinfo-trailer") - err := connect.NewError(connect.CodeInvalidArgument, nil) - err.Meta().Set("x-error-only", "from-error-only") - err.Meta().Set("x-both-sources", "from-error-meta") - return err + return connect.NewError(connect.CodeInvalidArgument, "") } callInfo.ResponseHeader().Set("x-custom-key", "countup-success") callInfo.ResponseHeader().Set("x-success-header", "in-headers") callInfo.ResponseTrailer().Set("x-success-trailer", "in-trailers") for number := int64(1); number <= request.GetNumber(); number++ { - // Simulate error after sending 2 responses (for testing trailers) + // Simulate an error after sending some responses (for testing trailers). if number == 3 && request.GetNumber() == 5 { callInfo.ResponseTrailer().Set("x-error-trailer", "error-after-streaming") - callInfo.ResponseTrailer().Set("x-trailer-only-after", "only-in-trailers") - callInfo.ResponseTrailer().Set("x-both-sources", "from-callinfo-trailer") - err := connect.NewError(connect.CodeInternal, nil) - err.Meta().Set("x-error-only", "from-error-only") - err.Meta().Set("x-both-sources", "from-error-meta") - return err + return connect.NewError(connect.CodeInternal, "") } if err := stream.Send(&pingv1.CountUpResponse{Number: number}); err != nil { return err @@ -3177,11 +3436,8 @@ func TestCallInfoHeadersOnError(t *testing.T) { } return nil }, - cumSum: func(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { - callInfo, ok := connect.CallInfoForHandlerContext(ctx) - if !ok { - return connect.NewError(connect.CodeInternal, nil) - } + cumSum: func(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { + callInfo, _ := connect.CallInfoForServerContext(ctx) callInfo.ResponseHeader().Set("x-custom-key", "cumsum-success") callInfo.ResponseHeader().Set("x-success-header", "in-headers") callInfo.ResponseTrailer().Set("x-success-trailer", "in-trailers") @@ -3195,25 +3451,15 @@ func TestCallInfoHeadersOnError(t *testing.T) { return err } if req.GetNumber() == -99 { - // Special case: error after successful exchanges (for testing trailers) - callInfo.ResponseHeader().Set("x-custom-key", "cumsum-error-streaming") - callInfo.ResponseHeader().Set("x-header-only", "should-not-be-in-trailers") - callInfo.ResponseTrailer().Set("x-trailer-only", "should-not-be-in-headers") - callInfo.ResponseTrailer().Set("x-both-sources", "from-callinfo-trailer") - err := connect.NewError(connect.CodeInternal, nil) - err.Meta().Set("x-error-only", "from-error-only") - err.Meta().Set("x-both-sources", "from-error-meta") - return err + // Error after successful exchanges (for testing trailers). + callInfo.ResponseTrailer().Set("x-error-trailer", "error-after-streaming") + return connect.NewError(connect.CodeInternal, "") } if req.GetNumber() < 0 { callInfo.ResponseHeader().Set("x-custom-key", "cumsum-error") callInfo.ResponseHeader().Set("x-header-only", "should-not-be-in-trailers") callInfo.ResponseTrailer().Set("x-trailer-only", "should-not-be-in-headers") - callInfo.ResponseTrailer().Set("x-both-sources", "from-callinfo-trailer") - err := connect.NewError(connect.CodeInvalidArgument, nil) - err.Meta().Set("x-error-only", "from-error-only") - err.Meta().Set("x-both-sources", "from-error-meta") - return err + return connect.NewError(connect.CodeInvalidArgument, "") } sum += req.GetNumber() if err := stream.Send(&pingv1.CumSumResponse{Sum: sum}); err != nil { @@ -3224,25 +3470,13 @@ func TestCallInfoHeadersOnError(t *testing.T) { }, } - testHeadersMatchErrorMetadata := func(t *testing.T, err error, callInfo connect.CallInfo) { - t.Helper() - var connectErr *connect.Error - assert.True(t, errors.As(err, &connectErr)) - expectedMeta := make(http.Header) - for key, vals := range callInfo.ResponseHeader() { - expectedMeta[key] = append(expectedMeta[key], vals...) - } - for key, vals := range callInfo.ResponseTrailer() { - expectedMeta[key] = append(expectedMeta[key], vals...) - } - assert.True(t, compareHeaders(connectErr.Meta(), expectedMeta)) - } - mux := http.NewServeMux() - mux.Handle(pingv1connectsimple.NewPingServiceHandler(handler)) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, handler) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) - testCallInfoHeaders := func(t *testing.T, client pingv1connectsimple.PingServiceClient, protocol string) { //nolint:thelper + testCallInfoHeaders := func(t *testing.T, client pingv1connect.PingServiceClient) { //nolint:thelper t.Run("unary_ping_success", func(t *testing.T) { t.Parallel() ctx, callInfo := connect.NewClientContext(t.Context()) @@ -3263,43 +3497,16 @@ func TestCallInfoHeadersOnError(t *testing.T) { assert.NotNil(t, err) assert.Nil(t, response) - assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-custom-key"), []string{"ping-error"})) - assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-header-only"), []string{"should-not-be-in-trailers"})) + assert.True(t, compareValues(metaValues(callInfo, "x-custom-key"), []string{"ping-error"})) + assert.True(t, compareValues(metaValues(callInfo, "x-header-only"), []string{"should-not-be-in-trailers"})) assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-trailer-only"), []string{"should-not-be-in-headers"})) assert.Equal(t, len(callInfo.ResponseHeader().Values("x-trailer-only")), 0) - - var connectErr *connect.Error - assert.True(t, errors.As(err, &connectErr)) - expectedMeta := make(http.Header) - for key, vals := range callInfo.ResponseHeader() { - expectedMeta[key] = append(expectedMeta[key], vals...) - } - for key, vals := range callInfo.ResponseTrailer() { - expectedMeta[key] = append(expectedMeta[key], vals...) - } - assert.True(t, compareHeaders(connectErr.Meta(), expectedMeta)) - - // Assert the protocol specific handling of error metadata is used. - switch protocol { - case connect.ProtocolConnect: - assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-error-only"), []string{"from-error-only"})) - assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-both-sources"), []string{"from-error-meta"})) - assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-both-sources"), []string{"from-callinfo-trailer"})) - case connect.ProtocolGRPC, connect.ProtocolGRPCWeb: - assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-error-only"), []string{"from-error-only"})) - bothSourcesValues := callInfo.ResponseTrailer().Values("x-both-sources") - assert.Equal(t, len(bothSourcesValues), 2) - assert.True(t, compareValues(bothSourcesValues, []string{"from-callinfo-trailer", "from-error-meta"})) - default: - t.Errorf("unknown protocol: %s", protocol) - } }) t.Run("client_stream_sum_success", func(t *testing.T) { t.Parallel() ctx, callInfo := connect.NewClientContext(t.Context()) stream, err := client.Sum(ctx) assert.Nil(t, err) - assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 1})) assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 2})) assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 3})) @@ -3318,34 +3525,30 @@ func TestCallInfoHeadersOnError(t *testing.T) { ctx, callInfo := connect.NewClientContext(t.Context()) stream, err := client.Sum(ctx) assert.Nil(t, err) - assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: -1})) response, err := stream.CloseAndReceive() assert.NotNil(t, err) assert.Nil(t, response) - assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-custom-key"), []string{"sum-error"})) - assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-header-only"), []string{"should-not-be-in-trailers"})) + assert.True(t, compareValues(metaValues(callInfo, "x-custom-key"), []string{"sum-error"})) + assert.True(t, compareValues(metaValues(callInfo, "x-header-only"), []string{"should-not-be-in-trailers"})) assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-trailer-only"), []string{"should-not-be-in-headers"})) assert.Equal(t, len(callInfo.ResponseHeader().Values("x-trailer-only")), 0) - assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-error-only"), []string{"from-error-only"})) - bothSourcesValues := callInfo.ResponseTrailer().Values("x-both-sources") - assert.Equal(t, len(bothSourcesValues), 2) - assert.True(t, compareValues(bothSourcesValues, []string{"from-callinfo-trailer", "from-error-meta"})) - - testHeadersMatchErrorMetadata(t, err, callInfo) }) t.Run("server_stream_countup_success", func(t *testing.T) { t.Parallel() ctx, callInfo := connect.NewClientContext(t.Context()) stream, err := client.CountUp(ctx, &pingv1.CountUpRequest{Number: 3}) assert.Nil(t, err) - count := 0 - for stream.Receive() { + for { + if _, err := stream.Receive(); err != nil { + assert.True(t, errors.Is(err, io.EOF)) + break + } count++ } - assert.Nil(t, stream.Err()) + assert.Nil(t, stream.Close()) assert.Equal(t, count, 3) assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-custom-key"), []string{"countup-success"})) @@ -3359,40 +3562,32 @@ func TestCallInfoHeadersOnError(t *testing.T) { ctx, callInfo := connect.NewClientContext(t.Context()) stream, err := client.CountUp(ctx, &pingv1.CountUpRequest{Number: -1}) assert.Nil(t, err) + _, err = stream.Receive() + assert.NotNil(t, err) + assert.Nil(t, stream.Close()) - hasData := stream.Receive() - assert.False(t, hasData) - assert.NotNil(t, stream.Err()) - - assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-custom-key"), []string{"countup-error"})) - assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-header-only"), []string{"should-not-be-in-trailers"})) + assert.True(t, compareValues(metaValues(callInfo, "x-custom-key"), []string{"countup-error"})) + assert.True(t, compareValues(metaValues(callInfo, "x-header-only"), []string{"should-not-be-in-trailers"})) assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-trailer-only"), []string{"should-not-be-in-headers"})) assert.Equal(t, len(callInfo.ResponseHeader().Values("x-trailer-only")), 0) - assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-error-only"), []string{"from-error-only"})) - bothSourcesValues := callInfo.ResponseTrailer().Values("x-both-sources") - assert.Equal(t, len(bothSourcesValues), 2) - assert.True(t, compareValues(bothSourcesValues, []string{"from-callinfo-trailer", "from-error-meta"})) - - testHeadersMatchErrorMetadata(t, stream.Err(), callInfo) }) t.Run("bidi_stream_cumsum_success", func(t *testing.T) { t.Parallel() ctx, callInfo := connect.NewClientContext(t.Context()) stream, err := client.CumSum(ctx) assert.Nil(t, err) - assert.Nil(t, stream.Send(&pingv1.CumSumRequest{Number: 1})) msg1, err := stream.Receive() assert.Nil(t, err) - assert.Equal(t, msg1.Sum, int64(1)) + assert.Equal(t, msg1.GetSum(), int64(1)) assert.Nil(t, stream.Send(&pingv1.CumSumRequest{Number: 2})) msg2, err := stream.Receive() assert.Nil(t, err) - assert.Equal(t, msg2.Sum, int64(3)) - assert.Nil(t, stream.CloseRequest()) + assert.Equal(t, msg2.GetSum(), int64(3)) + assert.Nil(t, stream.CloseSend()) _, err = stream.Receive() - assert.NotNil(t, err) - assert.Nil(t, stream.CloseResponse()) + assert.True(t, errors.Is(err, io.EOF)) + assert.Nil(t, stream.Close()) assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-custom-key"), []string{"cumsum-success"})) assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-success-header"), []string{"in-headers"})) @@ -3405,87 +3600,69 @@ func TestCallInfoHeadersOnError(t *testing.T) { ctx, callInfo := connect.NewClientContext(t.Context()) stream, err := client.CumSum(ctx) assert.Nil(t, err) - assert.Nil(t, stream.Send(&pingv1.CumSumRequest{Number: -1})) _, err = stream.Receive() assert.NotNil(t, err) + assert.Nil(t, stream.Close()) - assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-custom-key"), []string{"cumsum-error"})) - assert.True(t, compareValues(callInfo.ResponseHeader().Values("x-header-only"), []string{"should-not-be-in-trailers"})) + assert.True(t, compareValues(metaValues(callInfo, "x-custom-key"), []string{"cumsum-error"})) + assert.True(t, compareValues(metaValues(callInfo, "x-header-only"), []string{"should-not-be-in-trailers"})) assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-trailer-only"), []string{"should-not-be-in-headers"})) assert.Equal(t, len(callInfo.ResponseHeader().Values("x-trailer-only")), 0) - assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-error-only"), []string{"from-error-only"})) - bothSourcesValues := callInfo.ResponseTrailer().Values("x-both-sources") - assert.Equal(t, len(bothSourcesValues), 2) - assert.True(t, compareValues(bothSourcesValues, []string{"from-callinfo-trailer", "from-error-meta"})) - - testHeadersMatchErrorMetadata(t, err, callInfo) }) t.Run("server_stream_countup_error_after_first_response", func(t *testing.T) { t.Parallel() ctx, callInfo := connect.NewClientContext(t.Context()) stream, err := client.CountUp(ctx, &pingv1.CountUpRequest{Number: 5}) assert.Nil(t, err) - - hasData := stream.Receive() - assert.True(t, hasData) - assert.Equal(t, stream.Msg().Number, int64(1)) - hasData = stream.Receive() - assert.True(t, hasData) - assert.Equal(t, stream.Msg().Number, int64(2)) - hasData = stream.Receive() - assert.False(t, hasData) - assert.NotNil(t, stream.Err()) + msg1, err := stream.Receive() + assert.Nil(t, err) + assert.Equal(t, msg1.GetNumber(), int64(1)) + msg2, err := stream.Receive() + assert.Nil(t, err) + assert.Equal(t, msg2.GetNumber(), int64(2)) + _, err = stream.Receive() + assert.NotNil(t, err) + assert.Nil(t, stream.Close()) assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-error-trailer"), []string{"error-after-streaming"})) - assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-error-only"), []string{"from-error-only"})) - bothSourcesValues := callInfo.ResponseTrailer().Values("x-both-sources") - assert.Equal(t, len(bothSourcesValues), 2) - assert.True(t, compareValues(bothSourcesValues, []string{"from-callinfo-trailer", "from-error-meta"})) - - testHeadersMatchErrorMetadata(t, stream.Err(), callInfo) }) t.Run("bidi_stream_cumsum_error_after_first_response", func(t *testing.T) { t.Parallel() ctx, callInfo := connect.NewClientContext(t.Context()) stream, err := client.CumSum(ctx) assert.Nil(t, err) - assert.Nil(t, stream.Send(&pingv1.CumSumRequest{Number: 1})) msg1, err := stream.Receive() assert.Nil(t, err) - assert.Equal(t, msg1.Sum, int64(1)) + assert.Equal(t, msg1.GetSum(), int64(1)) assert.Nil(t, stream.Send(&pingv1.CumSumRequest{Number: 2})) msg2, err := stream.Receive() assert.Nil(t, err) - assert.Equal(t, msg2.Sum, int64(3)) + assert.Equal(t, msg2.GetSum(), int64(3)) assert.Nil(t, stream.Send(&pingv1.CumSumRequest{Number: -99})) _, err = stream.Receive() assert.NotNil(t, err) + assert.Nil(t, stream.Close()) - assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-error-only"), []string{"from-error-only"})) - bothSourcesValues := callInfo.ResponseTrailer().Values("x-both-sources") - assert.Equal(t, len(bothSourcesValues), 2) - assert.True(t, compareValues(bothSourcesValues, []string{"from-callinfo-trailer", "from-error-meta"})) - - testHeadersMatchErrorMetadata(t, err, callInfo) + assert.True(t, compareValues(callInfo.ResponseTrailer().Values("x-error-trailer"), []string{"error-after-streaming"})) }) } t.Run("connect", func(t *testing.T) { t.Parallel() - client := pingv1connectsimple.NewPingServiceClient(server.Client(), server.URL()) - testCallInfoHeaders(t, client, connect.ProtocolConnect) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + testCallInfoHeaders(t, client) }) t.Run("grpc", func(t *testing.T) { t.Parallel() - client := pingv1connectsimple.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPC()) - testCallInfoHeaders(t, client, connect.ProtocolGRPC) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPC()))) + testCallInfoHeaders(t, client) }) t.Run("grpcweb", func(t *testing.T) { t.Parallel() - client := pingv1connectsimple.NewPingServiceClient(server.Client(), server.URL(), connect.WithGRPCWeb()) - testCallInfoHeaders(t, client, connect.ProtocolGRPCWeb) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL(), connecthttp.WithGRPCWeb()))) + testCallInfoHeaders(t, client) }) } @@ -3515,282 +3692,130 @@ func (c failCodec) Name() string { return "proto" } -func (c failCodec) Marshal(message any) ([]byte, error) { - return nil, errors.New("boom") +func (c failCodec) MarshalWrite(_ context.Context, _ io.Writer, _ any) error { + return errors.New("boom") } -func (c failCodec) Unmarshal(data []byte, message any) error { +func (c failCodec) UnmarshalRead(_ context.Context, src io.Reader, message any) error { protoMessage, ok := message.(proto.Message) if !ok { return fmt.Errorf("not protobuf: %T", message) } + data, err := io.ReadAll(src) + if err != nil { + return err + } return proto.Unmarshal(data, protoMessage) } type pluggablePingServer struct { pingv1connect.UnimplementedPingServiceHandler - ping func(context.Context, *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) - sum func(context.Context, *connect.ClientStream[pingv1.SumRequest]) (*connect.Response[pingv1.SumResponse], error) - countUp func(context.Context, *connect.Request[pingv1.CountUpRequest], *connect.ServerStream[pingv1.CountUpResponse]) error - cumSum func(context.Context, *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error -} - -func (p *pluggablePingServer) Ping( - ctx context.Context, - request *connect.Request[pingv1.PingRequest], -) (*connect.Response[pingv1.PingResponse], error) { - return p.ping(ctx, request) -} - -func (p *pluggablePingServer) Sum( - ctx context.Context, - stream *connect.ClientStream[pingv1.SumRequest], -) (*connect.Response[pingv1.SumResponse], error) { - return p.sum(ctx, stream) -} - -func (p *pluggablePingServer) CountUp( - ctx context.Context, - req *connect.Request[pingv1.CountUpRequest], - stream *connect.ServerStream[pingv1.CountUpResponse], -) error { - return p.countUp(ctx, req, stream) -} - -func (p *pluggablePingServer) CumSum( - ctx context.Context, - stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse], -) error { - return p.cumSum(ctx, stream) -} - -type pluggablePingServerSimple struct { - pingv1connectsimple.UnimplementedPingServiceHandler - ping func(context.Context, *pingv1.PingRequest) (*pingv1.PingResponse, error) - sum func(context.Context, *connect.ClientStream[pingv1.SumRequest]) (*pingv1.SumResponse, error) - countUp func(context.Context, *pingv1.CountUpRequest, *connect.ServerStream[pingv1.CountUpResponse]) error - cumSum func(context.Context, *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error + sum func(context.Context, pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) + countUp func(context.Context, *pingv1.CountUpRequest, pingv1connect.PingServiceCountUpServerStream) error + cumSum func(context.Context, pingv1connect.PingServiceCumSumServerStream) error } -func (p *pluggablePingServerSimple) Ping(ctx context.Context, request *pingv1.PingRequest) (*pingv1.PingResponse, error) { +func (p *pluggablePingServer) Ping(ctx context.Context, request *pingv1.PingRequest) (*pingv1.PingResponse, error) { return p.ping(ctx, request) } -func (p *pluggablePingServerSimple) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*pingv1.SumResponse, error) { +func (p *pluggablePingServer) Sum(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { return p.sum(ctx, stream) } -func (p *pluggablePingServerSimple) CountUp(ctx context.Context, req *pingv1.CountUpRequest, stream *connect.ServerStream[pingv1.CountUpResponse]) error { +func (p *pluggablePingServer) CountUp(ctx context.Context, req *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { return p.countUp(ctx, req, stream) } -func (p *pluggablePingServerSimple) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { +func (p *pluggablePingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { return p.cumSum(ctx, stream) } type pingServer struct { pingv1connect.UnimplementedPingServiceHandler - // Whether to verify metadata sent to the server. Can be used to force an error returned from the server - // by intentionally sending no metadata. + // Whether to verify metadata sent to the server. Can be used to force an + // error returned from the server by intentionally sending no metadata. checkMetadata bool includeErrorDetails bool } -func (p pingServer) Ping(ctx context.Context, request *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { - if err := validateRequestInfo(request); err != nil { - return nil, err - } - if err := compareContextAndRequest(ctx, request, request.Header()); err != nil { +func (p pingServer) Ping(ctx context.Context, request *pingv1.PingRequest) (*pingv1.PingResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + if err := validateRequestInfo(info); err != nil { return nil, err } if p.checkMetadata { - if err := expectMetadata(request.Header()); err != nil { + if err := expectMetadata(info.RequestHeader()); err != nil { return nil, err } } - response := connect.NewResponse( - &pingv1.PingResponse{ - Number: request.Msg.GetNumber(), - Text: request.Msg.GetText(), - }, - ) - // Copy the values sent in the client request header to the response headers and trailers - reqHeader := request.Header().Values(clientHeader) - for _, el := range reqHeader { - response.Header().Add(handlerHeader, el) - response.Trailer().Add(handlerTrailer, el) + response := &pingv1.PingResponse{ + Number: request.GetNumber(), + Text: request.GetText(), } - + copyClientHeaderToResponse(info) return response, nil } -func (p pingServer) Fail(ctx context.Context, request *connect.Request[pingv1.FailRequest]) (*connect.Response[pingv1.FailResponse], error) { - if err := validateRequestInfo(request); err != nil { - return nil, err - } - if err := compareContextAndRequest(ctx, request, request.Header()); err != nil { +func (p pingServer) Fail(ctx context.Context, request *pingv1.FailRequest) (*pingv1.FailResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + if err := validateRequestInfo(info); err != nil { return nil, err } - err := connect.NewError( - connect.Code(request.Msg.GetCode()), - errors.New(errorMessage), - ) - // Copy the values sent in the client request header to the error metadata headers and trailers - reqHeader := request.Header().Values(clientHeader) - for _, el := range reqHeader { - err.Meta().Add(handlerHeader, el) - err.Meta().Add(handlerTrailer, el) - } + err := connect.NewError(connect.Code(request.GetCode()), errorMessage) if p.includeErrorDetails { - detail, derr := connect.NewErrorDetail(&pingv1.FailRequest{Code: request.Msg.GetCode()}) - if derr != nil { - return nil, derr + detail, detailErr := connectproto.NewErrorDetail(&pingv1.FailRequest{Code: request.GetCode()}) + if detailErr != nil { + return nil, connect.NewError(connect.CodeInternal, detailErr.Error()) } - err.AddDetail(detail) + err = err.WithDetail(detail) } + copyClientHeaderToResponse(info) return nil, err } -func (p pingServer) Sum( - ctx context.Context, - stream *connect.ClientStream[pingv1.SumRequest], -) (*connect.Response[pingv1.SumResponse], error) { - if err := validateRequestInfo(stream); err != nil { - return nil, err - } - if err := compareContextAndRequest(ctx, stream, stream.RequestHeader()); err != nil { +func (p pingServer) Sum(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + if err := validateRequestInfo(info); err != nil { return nil, err } if p.checkMetadata { - if err := expectMetadata(stream.RequestHeader()); err != nil { + if err := expectMetadata(info.RequestHeader()); err != nil { return nil, err } } var sum int64 - for stream.Receive() { - sum += stream.Msg().GetNumber() - } - if stream.Err() != nil { - return nil, stream.Err() - } - response := connect.NewResponse(&pingv1.SumResponse{Sum: sum}) - // Copy the values sent in the client request header to the response headers and trailers - reqHeader := stream.RequestHeader().Values(clientHeader) - for _, el := range reqHeader { - response.Header().Add(handlerHeader, el) - response.Trailer().Add(handlerTrailer, el) - } - return response, nil -} - -func (p pingServer) CountUp( - ctx context.Context, - request *connect.Request[pingv1.CountUpRequest], - stream *connect.ServerStream[pingv1.CountUpResponse], -) error { - if err := validateRequestInfo(stream.Conn()); err != nil { - return err - } - if err := compareContextAndRequest(ctx, request, request.Header()); err != nil { - return err - } - if p.checkMetadata { - if err := expectMetadata(request.Header()); err != nil { - return err - } - } - if request.Msg.GetNumber() <= 0 { - return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf( - "number must be positive: got %v", - request.Msg.GetNumber(), - )) - } - // Copy the values sent in the client request header to the response headers and trailers - reqHeader := request.Header().Values(clientHeader) - for _, el := range reqHeader { - stream.ResponseHeader().Add(handlerHeader, el) - stream.ResponseTrailer().Add(handlerTrailer, el) - } - for i := range request.Msg.GetNumber() { - if err := stream.Send(&pingv1.CountUpResponse{Number: i + 1}); err != nil { - return err - } - } - return nil -} - -func (p pingServer) CumSum( - ctx context.Context, - stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse], -) error { - return handleCumSum(ctx, stream, p.checkMetadata) -} - -type pingServerSimple struct { - pingv1connectsimple.UnimplementedPingServiceHandler - - checkMetadata bool - includeErrorDetails bool -} - -func (p pingServerSimple) Ping(ctx context.Context, request *pingv1.PingRequest) (*pingv1.PingResponse, error) { - callInfo, ok := connect.CallInfoForHandlerContext(ctx) - if !ok { - return nil, connect.NewError(connect.CodeInternal, errors.New("no call info found in context")) - } - if err := validateRequestInfo(callInfo); err != nil { - return nil, err - } - if p.checkMetadata { - if err := expectMetadata(callInfo.RequestHeader()); err != nil { + for { + msg, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } return nil, err } + sum += msg.GetNumber() } - response := &pingv1.PingResponse{ - Number: request.GetNumber(), - Text: request.GetText(), - } - // Copy the values sent in the client request header to the response headers and trailers - reqHeader := callInfo.RequestHeader().Values(clientHeader) - for _, el := range reqHeader { - callInfo.ResponseHeader().Add(handlerHeader, el) - callInfo.ResponseTrailer().Add(handlerTrailer, el) - } - return response, nil + copyClientHeaderToResponse(info) + return &pingv1.SumResponse{Sum: sum}, nil } - -func (p pingServerSimple) CountUp( - ctx context.Context, - request *pingv1.CountUpRequest, - stream *connect.ServerStream[pingv1.CountUpResponse], -) error { - callInfo, ok := connect.CallInfoForHandlerContext(ctx) - if !ok { - return connect.NewError(connect.CodeInternal, errors.New("no call info found in context")) - } - if err := validateRequestInfo(callInfo); err != nil { + +func (p pingServer) CountUp(ctx context.Context, request *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { + info, _ := connect.CallInfoForServerContext(ctx) + if err := validateRequestInfo(info); err != nil { return err } if p.checkMetadata { - if err := expectMetadata(callInfo.RequestHeader()); err != nil { + if err := expectMetadata(info.RequestHeader()); err != nil { return err } } if request.GetNumber() <= 0 { - return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf( - "number must be positive: got %v", - request.GetNumber(), - )) - } - // Copy the values sent in the client request header to the response headers and trailers - reqHeader := callInfo.RequestHeader().Values(clientHeader) - for _, el := range reqHeader { - callInfo.ResponseHeader().Add(handlerHeader, el) - callInfo.ResponseTrailer().Add(handlerTrailer, el) + return connect.Errorf(connect.CodeInvalidArgument, "number must be positive: got %v", request.GetNumber()) } + copyClientHeaderToResponse(info) for i := range request.GetNumber() { if err := stream.Send(&pingv1.CountUpResponse{Number: i + 1}); err != nil { return err @@ -3799,106 +3824,110 @@ func (p pingServerSimple) CountUp( return nil } -func (p pingServerSimple) Fail(ctx context.Context, request *pingv1.FailRequest) (*pingv1.FailResponse, error) { - callInfo, ok := connect.CallInfoForHandlerContext(ctx) - if !ok { - return nil, connect.NewError(connect.CodeInternal, errors.New("no call info found in context")) - } - if err := validateRequestInfo(callInfo); err != nil { - return nil, err +func (p pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { + return handleCumSum(ctx, stream, p.checkMetadata) +} + +// handleCumSum handles the bidi endpoint CumSum. +func handleCumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream, checkMetadata bool) error { + info, _ := connect.CallInfoForServerContext(ctx) + if err := validateRequestInfo(info); err != nil { + return err } - if p.checkMetadata { - if err := expectMetadata(callInfo.RequestHeader()); err != nil { - return nil, err + if checkMetadata { + if err := expectMetadata(info.RequestHeader()); err != nil { + return err } } - err := connect.NewError( - connect.Code(request.GetCode()), - errors.New(errorMessage), - ) - // Copy the values sent in the client request header to the error metadata headers and trailers - reqHeader := callInfo.RequestHeader().Values(clientHeader) - for _, el := range reqHeader { - err.Meta().Add(handlerHeader, el) - err.Meta().Add(handlerTrailer, el) - } - if p.includeErrorDetails { - detail, derr := connect.NewErrorDetail(&pingv1.FailRequest{Code: request.GetCode()}) - if derr != nil { - return nil, derr + var sum int64 + copyClientHeaderToResponse(info) + for { + msg, err := stream.Receive() + if errors.Is(err, io.EOF) { + return nil + } else if err != nil { + return err + } + sum += msg.GetNumber() + if err := stream.Send(&pingv1.CumSumResponse{Sum: sum}); err != nil { + return err } - err.AddDetail(detail) } - return nil, err } -func (p pingServerSimple) Sum( - ctx context.Context, - stream *connect.ClientStream[pingv1.SumRequest], -) (*pingv1.SumResponse, error) { - callInfo, ok := connect.CallInfoForHandlerContext(ctx) - if !ok { - return nil, connect.NewError(connect.CodeInternal, errors.New("no call info found in context")) - } - if err := validateRequestInfo(callInfo); err != nil { - return nil, err +// copyClientHeaderToResponse copies the client request header values to the +// response headers and trailers so the client can verify propagation. +func copyClientHeaderToResponse(info *connect.CallInfo) { + for _, el := range info.RequestHeader().Values(clientHeader) { + info.ResponseHeader().Add(handlerHeader, el) + info.ResponseTrailer().Add(handlerTrailer, el) } - if err := compareContextAndRequest(ctx, stream, stream.RequestHeader()); err != nil { - return nil, err - } - if p.checkMetadata { - if err := expectMetadata(callInfo.RequestHeader()); err != nil { - return nil, err - } - } - var sum int64 - for stream.Receive() { - sum += stream.Msg().GetNumber() - } - if stream.Err() != nil { - return nil, stream.Err() +} + +func validateRequestInfo(callInfo *connect.CallInfo) error { + if callInfo == nil || callInfo.PeerAddr == "" { + return connect.NewError(connect.CodeInternal, "no peer address") } - response := &pingv1.SumResponse{Sum: sum} - // Copy the values sent in the client request header to the response headers and trailers - reqHeader := stream.RequestHeader().Values(clientHeader) - for _, el := range reqHeader { - callInfo.ResponseHeader().Add(handlerHeader, el) - callInfo.ResponseTrailer().Add(handlerTrailer, el) + if callInfo.Protocol == "" { + return connect.NewError(connect.CodeInternal, "no peer protocol") } - return response, nil + return nil } -func (p pingServerSimple) CumSum( - ctx context.Context, - stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse], -) error { - return handleCumSum(ctx, stream, p.checkMetadata) +// expectMetadata returns an error if meta doesn't contain the expected header +// values. Used with the server's checkMetadata setting to force an error. +func expectMetadata(meta *connect.Header) error { + vals := meta.Values(clientHeader) + if !compareValues(vals, expectedHeaderValues) { + return connect.Errorf(connect.CodeInvalidArgument, + "header %q: got %q, expected %q", + clientHeader, + vals, + expectedHeaderValues, + ) + } + return nil } -type deflateReader struct { - r io.ReadCloser +// compareValues compares two string slices of header values, ignoring order. +func compareValues(hdr1 []string, hdr2 []string) bool { + if len(hdr1) != len(hdr2) { + return false + } + sorted1 := make([]string, len(hdr1)) + copy(sorted1, hdr1) + sorted2 := make([]string, len(hdr2)) + copy(sorted2, hdr2) + sort.Strings(sorted1) + sort.Strings(sorted2) + for i := range sorted1 { + if sorted1[i] != sorted2[i] { + return false + } + } + return true } -func newDeflateReader(r io.Reader) *deflateReader { - return &deflateReader{r: flate.NewReader(r)} +// metaValues returns key's values across response headers and trailers, since +// gRPC errors are trailers-only while Connect keeps the header/trailer split. +func metaValues(callInfo *connect.CallInfo, key string) []string { + out := append([]string{}, callInfo.ResponseHeader().Values(key)...) + return append(out, callInfo.ResponseTrailer().Values(key)...) } -func (d *deflateReader) Read(p []byte) (int, error) { - return d.r.Read(p) -} +type deflateCompressor struct{} -func (d *deflateReader) Close() error { - return d.r.Close() +func (deflateCompressor) Name() string { return "deflate" } + +func (deflateCompressor) Compress(dst io.Writer) (io.WriteCloser, error) { + return flate.NewWriter(dst, flate.DefaultCompression) } -func (d *deflateReader) Reset(reader io.Reader) error { - if resetter, ok := d.r.(flate.Resetter); ok { - return resetter.Reset(reader, nil) - } - return errors.New("flate reader should implement flate.Resetter") +func (deflateCompressor) Decompress(src io.Reader) (io.ReadCloser, error) { + return flate.NewReader(src), nil } -var _ connect.Decompressor = (*deflateReader)(nil) +var _ connect.Compressor = deflateCompressor{} type trimTrailerWriter struct { w http.ResponseWriter @@ -3940,109 +3969,42 @@ func (l *trimTrailerWriter) removeTrailers() { } } -func newHTTPMiddlewareError() *connect.Error { - err := connect.NewError(connect.CodeResourceExhausted, errors.New("error from HTTP middleware")) - err.Meta().Set("Middleware-Foo", "bar") - return err -} - -type failDecompressor struct { - connect.Decompressor -} - type failCompressor struct{} -func (failCompressor) Write([]byte) (int, error) { - return 0, errors.New("failCompressor") -} - -func (failCompressor) Close() error { - return errors.New("failCompressor") -} - -func (failCompressor) Reset(io.Writer) {} +func (failCompressor) Name() string { return "fail" } -type requestInfo interface { - Peer() connect.Peer - Spec() connect.Spec +func (failCompressor) Compress(dst io.Writer) (io.WriteCloser, error) { + return failCompressorWriteCloser{}, nil } - -type responseInfo interface { - ResponseHeader() http.Header - ResponseTrailer() http.Header -} - -// responseWrapper wraps a Response object so that it can implement the responseInfo interface. -type responseWrapper[Res any] struct { - response *connect.Response[Res] +func (failCompressor) Decompress(src io.Reader) (io.ReadCloser, error) { + return failCompressorReadCloser{}, nil } -func (w *responseWrapper[Res]) ResponseHeader() http.Header { - return w.response.Header() -} +type failCompressorWriteCloser struct{} -func (w *responseWrapper[Res]) ResponseTrailer() http.Header { - return w.response.Trailer() +func (failCompressorWriteCloser) Write([]byte) (int, error) { + return 0, errors.New("failCompressor") } - -// errorWrapper wraps a Connect error so that it can implement the responseInfo interface. -type errorWrapper struct { - err *connect.Error +func (failCompressorWriteCloser) Close() error { + return errors.New("failCompressor") } -func (w *errorWrapper) ResponseHeader() http.Header { - return w.err.Meta() -} +type failCompressorReadCloser struct{} -func (w *errorWrapper) ResponseTrailer() http.Header { - return w.err.Meta() +func (failCompressorReadCloser) Read([]byte) (int, error) { + return 0, errors.New("failCompressor") } - -// handleCumSum handles the bidi endpoint CumSum for both pingServer and pingServerSimple. -// The API for bidi-streaming does not change for simple vs. generics API on the server. -func handleCumSum( - ctx context.Context, - stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse], - checkMetadata bool, -) error { - if err := validateRequestInfo(stream); err != nil { - return err - } - if err := compareContextAndRequest(ctx, stream, stream.RequestHeader()); err != nil { - return err - } - if checkMetadata { - if err := expectMetadata(stream.RequestHeader()); err != nil { - return err - } - } - var sum int64 - reqHeader := stream.RequestHeader().Values(clientHeader) - for _, el := range reqHeader { - stream.ResponseHeader().Add(handlerHeader, el) - stream.ResponseTrailer().Add(handlerTrailer, el) - } - for { - msg, err := stream.Receive() - if errors.Is(err, io.EOF) { - return nil - } else if err != nil { - return err - } - sum += msg.GetNumber() - if err := stream.Send(&pingv1.CumSumResponse{Sum: sum}); err != nil { - return err - } - } +func (failCompressorReadCloser) Close() error { + return errors.New("failCompressor") } -func failNoHTTP2(tb testing.TB, stream *connect.BidiStreamForClient[pingv1.CumSumRequest, pingv1.CumSumResponse]) { +func failNoHTTP2(tb testing.TB, stream pingv1connect.PingServiceCumSumClientStream) { tb.Helper() if err := stream.Send(&pingv1.CumSumRequest{}); err != nil { assert.ErrorIs(tb, err, io.EOF) assert.Equal(tb, connect.CodeOf(err), connect.CodeUnknown) } - assert.Nil(tb, stream.CloseRequest()) + assert.Nil(tb, stream.CloseSend()) _, err := stream.Receive() assert.NotNil(tb, err) // should be 505 assert.True( @@ -4050,45 +4012,10 @@ func failNoHTTP2(tb testing.TB, stream *connect.BidiStreamForClient[pingv1.CumSu strings.Contains(err.Error(), "HTTP status 505"), assert.Sprintf("expected 505, got %v", err), ) - assert.Nil(tb, stream.CloseResponse()) -} - -func testUnaryGenerics(t *testing.T, client pingv1connect.PingServiceClient) { //nolint:thelper - num := int64(42) - request := connect.NewRequest(&pingv1.PingRequest{Number: num}) - - ctx, callInfo := connect.NewClientContext(t.Context()) - // With the generics API, a user can use the call info or request wrapper or both to set request headers. - // The resulting headers should be combined and sent in the request. - request.Header().Add(clientHeader, "foo") - callInfo.RequestHeader().Add(clientHeader, "bar") - expect := &pingv1.PingResponse{Number: num} - - response, err := client.Ping(ctx, request) - assert.Nil(t, err) - assert.Equal(t, response.Msg, expect) - // When using the generics API for unary calls, users can access request info such as spec and peer - // either from the call info in context or the request wrapper. This verifies both have the same information. - assert.Equal(t, request.Spec().StreamType, connect.StreamTypeUnary) - assert.Equal(t, request.Spec().Procedure, pingv1connect.PingServicePingProcedure) - assert.True(t, request.Spec().IsClient) - assert.Equal(t, request.Peer().Addr, httptest.DefaultRemoteAddr) - assert.Equal(t, callInfo.Spec().StreamType, connect.StreamTypeUnary) - assert.Equal(t, callInfo.Spec().Procedure, pingv1connect.PingServicePingProcedure) - assert.True(t, callInfo.Spec().IsClient) - assert.Equal(t, callInfo.Peer().Addr, httptest.DefaultRemoteAddr) - - // Wrap the response object so that it can implement the responseInfo interface and we can verify its response - // headers and trailers using the same function callInfo does - wrapper := &responseWrapper[pingv1.PingResponse]{response: response} - - // When using the generics API for unary calls, users can access response headers and trailers - // either from the call info in context or the response wrapper. This verifies both have the same information. - assertResponseHeadersAndTrailers(t, callInfo) - assertResponseHeadersAndTrailers(t, wrapper) + assert.Nil(tb, stream.Close()) } -func testUnarySimple(t *testing.T, client pingv1connectsimple.PingServiceClient) { //nolint:thelper +func testUnary(t *testing.T, client pingv1connect.PingServiceClient) { //nolint:thelper num := int64(42) ctx, callInfo := connect.NewClientContext(t.Context()) for _, el := range expectedHeaderValues { @@ -4098,16 +4025,13 @@ func testUnarySimple(t *testing.T, client pingv1connectsimple.PingServiceClient) response, err := client.Ping(ctx, &pingv1.PingRequest{Number: num}) assert.Equal(t, response, expect) assert.Nil(t, err) - assert.Equal(t, callInfo.Spec().StreamType, connect.StreamTypeUnary) - assert.Equal(t, callInfo.Spec().Procedure, pingv1connect.PingServicePingProcedure) - assert.True(t, callInfo.Spec().IsClient) - assert.Equal(t, callInfo.Peer().Addr, httptest.DefaultRemoteAddr) + assert.Equal(t, callInfo.PeerAddr, httptest.DefaultRemoteAddr) // When using the simple API for unary calls, users can only access response headers and trailers // from the call info in context. assertResponseHeadersAndTrailers(t, callInfo) } -func testServerStreamSimple(t *testing.T, client pingv1connectsimple.PingServiceClient) { //nolint:thelper +func testServerStream(t *testing.T, client pingv1connect.PingServiceClient) { //nolint:thelper ctx, callInfo := connect.NewClientContext(t.Context()) for _, el := range expectedHeaderValues { callInfo.RequestHeader().Add(clientHeader, el) @@ -4120,76 +4044,27 @@ func testServerStreamSimple(t *testing.T, client pingv1connectsimple.PingService // Receive expected messages for idx := range val { expected := int64(idx + 1) - assert.True(t, stream.Receive()) - assert.Nil(t, stream.Err()) - msg := stream.Msg() - assert.NotNil(t, msg) - assert.Equal(t, msg.GetNumber(), expected) - } - - // Stream should be done. Expect false on receive and close stream - assert.False(t, stream.Receive()) - assert.Nil(t, stream.Err()) - assert.Nil(t, stream.Close()) - assert.Equal(t, callInfo.Spec().StreamType, connect.StreamTypeServer) - assert.Equal(t, callInfo.Spec().Procedure, pingv1connect.PingServiceCountUpProcedure) - assert.True(t, callInfo.Spec().IsClient) - assert.Equal(t, callInfo.Peer().Addr, httptest.DefaultRemoteAddr) - - // On server-streaming calls, users can access response headers and trailers - // either from the call info in context or from the stream itself. - // This verifies that the both the stream and the call info have the same information - assertResponseHeadersAndTrailers(t, callInfo) - assertResponseHeadersAndTrailers(t, stream) -} - -func testServerStreamGenerics(t *testing.T, client pingv1connect.PingServiceClient) { //nolint:thelper - val := 3 - req := connect.NewRequest(&pingv1.CountUpRequest{ - Number: int64(val), - }) - ctx, callInfo := connect.NewClientContext(t.Context()) - // With the generics API, A user can use the call info or request wrapper or both to set request headers. - // The resulting headers should be combined and sent in the request. - callInfo.RequestHeader().Set(clientHeader, "foo") - req.Header().Add(clientHeader, "bar") - - stream, err := client.CountUp(ctx, req) - assert.Nil(t, err) - // Receive expected messages - for idx := range val { - expected := int64(idx + 1) - assert.True(t, stream.Receive()) - assert.Nil(t, stream.Err()) - msg := stream.Msg() + msg, err := stream.Receive() + assert.Nil(t, err) assert.NotNil(t, msg) assert.Equal(t, msg.GetNumber(), expected) } - // Stream should be done. Expect false on receive and close stream - assert.False(t, stream.Receive()) - assert.Nil(t, stream.Err()) + // Stream should be done. Expect EOF on receive and close stream + _, err = stream.Receive() + assert.True(t, errors.Is(err, io.EOF)) assert.Nil(t, stream.Close()) - // Assert values on request - assert.Equal(t, req.Spec().StreamType, connect.StreamTypeServer) - assert.Equal(t, req.Spec().Procedure, pingv1connect.PingServiceCountUpProcedure) - assert.True(t, req.Spec().IsClient) - assert.Equal(t, req.Peer().Addr, httptest.DefaultRemoteAddr) - - // Assert the same values are in the call info - assert.Equal(t, callInfo.Spec().StreamType, connect.StreamTypeServer) - assert.Equal(t, callInfo.Spec().Procedure, pingv1connect.PingServiceCountUpProcedure) - assert.True(t, callInfo.Spec().IsClient) - assert.Equal(t, callInfo.Peer().Addr, httptest.DefaultRemoteAddr) + assert.Equal(t, callInfo.Spec.StreamType, connect.StreamTypeServer) + assert.Equal(t, callInfo.Spec.Procedure, pingv1connect.PingServiceCountUpProcedure) + assert.Equal(t, callInfo.PeerAddr, httptest.DefaultRemoteAddr) // On server-streaming calls, users can access response headers and trailers // either from the call info in context or from the stream itself. // This verifies that the both the stream and the call info have the same information assertResponseHeadersAndTrailers(t, callInfo) - assertResponseHeadersAndTrailers(t, stream) } -func testClientStreamSimple(t *testing.T, client pingv1connectsimple.PingServiceClient) { //nolint:thelper +func testClientStream(t *testing.T, client pingv1connect.PingServiceClient) { //nolint:thelper ctx, callInfo := connect.NewClientContext(t.Context()) for _, el := range expectedHeaderValues { callInfo.RequestHeader().Add(clientHeader, el) @@ -4212,61 +4087,14 @@ func testClientStreamSimple(t *testing.T, client pingv1connectsimple.PingService assert.Nil(t, err) assert.Equal(t, response.GetSum(), expect) - // Assert values on stream - assert.Equal(t, stream.Spec().StreamType, connect.StreamTypeClient) - assert.Equal(t, stream.Spec().Procedure, pingv1connect.PingServiceSumProcedure) - assert.True(t, stream.Spec().IsClient) - assert.Equal(t, stream.Peer().Addr, httptest.DefaultRemoteAddr) - - // Assert the same values are in the call info - assert.Equal(t, callInfo.Spec().StreamType, connect.StreamTypeClient) - assert.Equal(t, callInfo.Spec().Procedure, pingv1connect.PingServiceSumProcedure) - assert.True(t, callInfo.Spec().IsClient) - assert.Equal(t, callInfo.Peer().Addr, httptest.DefaultRemoteAddr) - - assertResponseHeadersAndTrailers(t, callInfo) -} - -func testClientStreamGenerics(t *testing.T, client pingv1connect.PingServiceClient) { //nolint:thelper - ctx, callInfo := connect.NewClientContext(t.Context()) - callInfo.RequestHeader().Add(clientHeader, "foo") - const ( - upTo = 10 - expect = 55 // 1+10 + 2+9 + ... + 5+6 = 55 - ) - stream := client.Sum(ctx) - stream.RequestHeader().Add(clientHeader, "bar") - - // Send messages - for i := range upTo { - err := stream.Send(&pingv1.SumRequest{Number: int64(i + 1)}) - assert.Nil(t, err, assert.Sprintf("send %d", i)) - } + assert.Equal(t, callInfo.Spec.StreamType, connect.StreamTypeClient) + assert.Equal(t, callInfo.Spec.Procedure, pingv1connect.PingServiceSumProcedure) + assert.Equal(t, callInfo.PeerAddr, httptest.DefaultRemoteAddr) - response, err := stream.CloseAndReceive() - assert.Nil(t, err) - assert.Equal(t, response.Msg.GetSum(), expect) - - // Assert values on stream - assert.Equal(t, stream.Spec().StreamType, connect.StreamTypeClient) - assert.Equal(t, stream.Spec().Procedure, pingv1connect.PingServiceSumProcedure) - assert.True(t, stream.Spec().IsClient) - assert.Equal(t, stream.Peer().Addr, httptest.DefaultRemoteAddr) - - // Assert the same values are in the call info - assert.Equal(t, callInfo.Spec().StreamType, connect.StreamTypeClient) - assert.Equal(t, callInfo.Spec().Procedure, pingv1connect.PingServiceSumProcedure) - assert.True(t, callInfo.Spec().IsClient) - assert.Equal(t, callInfo.Peer().Addr, httptest.DefaultRemoteAddr) - - // Wrap the response object so that it can implement the responseInfo interface and we can verify its response - // headers and trailers using the same function callInfo does - wrapper := &responseWrapper[pingv1.SumResponse]{response: response} - assertResponseHeadersAndTrailers(t, wrapper) assertResponseHeadersAndTrailers(t, callInfo) } -func testBidiStreamSimple(t *testing.T, client pingv1connectsimple.PingServiceClient) { //nolint:thelper +func testBidiStream(t *testing.T, client pingv1connect.PingServiceClient, expectSuccess bool) { //nolint:thelper send := []int64{3, 5, 1} expect := []int64{3, 8, 9} var got []int64 @@ -4278,62 +4106,11 @@ func testBidiStreamSimple(t *testing.T, client pingv1connectsimple.PingServiceCl assert.Nil(t, err) assert.NotNil(t, stream) - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - for i, n := range send { - err := stream.Send(&pingv1.CumSumRequest{Number: n}) - assert.Nil(t, err, assert.Sprintf("send error #%d", i)) - } - assert.Nil(t, stream.CloseRequest()) - }() - go func() { - defer wg.Done() - for { - msg, err := stream.Receive() - if errors.Is(err, io.EOF) { - break - } - assert.Nil(t, err) - got = append(got, msg.GetSum()) - } - assert.Nil(t, stream.CloseResponse()) - }() - wg.Wait() - assert.Equal(t, got, expect) - - // Assert values on stream - assert.Equal(t, stream.Spec().StreamType, connect.StreamTypeBidi) - assert.Equal(t, stream.Spec().Procedure, pingv1connect.PingServiceCumSumProcedure) - assert.True(t, stream.Spec().IsClient) - assert.Equal(t, stream.Peer().Addr, httptest.DefaultRemoteAddr) - - // Assert the same values are in the call info - assert.Equal(t, callInfo.Spec().StreamType, connect.StreamTypeBidi) - assert.Equal(t, callInfo.Spec().Procedure, pingv1connect.PingServiceCumSumProcedure) - assert.True(t, callInfo.Spec().IsClient) - assert.Equal(t, callInfo.Peer().Addr, httptest.DefaultRemoteAddr) - - assertResponseHeadersAndTrailers(t, callInfo) - assertResponseHeadersAndTrailers(t, stream) -} - -func testBidiStreamGenerics(t *testing.T, client pingv1connect.PingServiceClient, expectSuccess bool) { //nolint:thelper - send := []int64{3, 5, 1} - expect := []int64{3, 8, 9} - var got []int64 - ctx, callInfo := connect.NewClientContext(t.Context()) - // With the generics API, A user can use the call info or request wrapper or both to set request headers. - // The resulting headers should be combined and sent in the request. - callInfo.RequestHeader().Add(clientHeader, "foo") - stream := client.CumSum(ctx) - stream.RequestHeader().Add(clientHeader, "bar") - if !expectSuccess { // server doesn't support HTTP/2 failNoHTTP2(t, stream) return } + var wg sync.WaitGroup wg.Add(2) go func() { @@ -4342,7 +4119,7 @@ func testBidiStreamGenerics(t *testing.T, client pingv1connect.PingServiceClient err := stream.Send(&pingv1.CumSumRequest{Number: n}) assert.Nil(t, err, assert.Sprintf("send error #%d", i)) } - assert.Nil(t, stream.CloseRequest()) + assert.Nil(t, stream.CloseSend()) }() go func() { defer wg.Done() @@ -4354,123 +4131,30 @@ func testBidiStreamGenerics(t *testing.T, client pingv1connect.PingServiceClient assert.Nil(t, err) got = append(got, msg.GetSum()) } - assert.Nil(t, stream.CloseResponse()) + assert.Nil(t, stream.Close()) }() wg.Wait() assert.Equal(t, got, expect) - // Assert values on stream - assert.Equal(t, stream.Spec().StreamType, connect.StreamTypeBidi) - assert.Equal(t, stream.Spec().Procedure, pingv1connect.PingServiceCumSumProcedure) - assert.True(t, stream.Spec().IsClient) - assert.Equal(t, stream.Peer().Addr, httptest.DefaultRemoteAddr) - - // Assert the same values are in the call info - assert.Equal(t, callInfo.Spec().StreamType, connect.StreamTypeBidi) - assert.Equal(t, callInfo.Spec().Procedure, pingv1connect.PingServiceCumSumProcedure) - assert.True(t, callInfo.Spec().IsClient) - assert.Equal(t, callInfo.Peer().Addr, httptest.DefaultRemoteAddr) + assert.Equal(t, callInfo.Spec.StreamType, connect.StreamTypeBidi) + assert.Equal(t, callInfo.Spec.Procedure, pingv1connect.PingServiceCumSumProcedure) + assert.Equal(t, callInfo.PeerAddr, httptest.DefaultRemoteAddr) assertResponseHeadersAndTrailers(t, callInfo) - assertResponseHeadersAndTrailers(t, stream) -} - -// Validates that the peer and spec information is set correctly in a request. -func validateRequestInfo(request requestInfo) error { - if request.Peer().Addr == "" { - return connect.NewError(connect.CodeInternal, errors.New("no peer address")) - } - if request.Peer().Protocol == "" { - return connect.NewError(connect.CodeInternal, errors.New("no peer protocol")) - } - if request.Spec().Procedure == "" { - return connect.NewError(connect.CodeInternal, errors.New("no procedure name")) - } - return nil -} - -// Compares the information in the call info in context with the given request information to verify they match. -func compareContextAndRequest(ctx context.Context, request requestInfo, requestHeaders http.Header) error { - callInfo, ok := connect.CallInfoForHandlerContext(ctx) - if !ok { - return connect.NewError(connect.CodeInternal, errors.New("no call info in handler context")) - } - if request.Peer().Addr != callInfo.Peer().Addr { - return connect.NewError(connect.CodeInternal, fmt.Errorf("mismatched peer address. found %s in request and %s in call info", request.Peer().Addr, callInfo.Peer().Addr)) - } - if request.Peer().Protocol != callInfo.Peer().Protocol { - return connect.NewError(connect.CodeInternal, fmt.Errorf("mismatched peer protocol. found %s in request and %s in call info", request.Peer().Addr, callInfo.Peer().Addr)) - } - if request.Spec().Procedure != callInfo.Spec().Procedure { - return connect.NewError(connect.CodeInternal, fmt.Errorf("mismatched procedure name. found %s in request and %s in call info", request.Spec().Procedure, request.Spec().Procedure)) - } - if valid := compareHeaders(callInfo.RequestHeader(), requestHeaders); !valid { - return connect.NewError(connect.CodeInternal, fmt.Errorf("mismatched request headers. found %+v in request and %+v in call info", callInfo.RequestHeader(), requestHeaders)) - } - return nil -} - -// expectMetadata returns an error if the given http headers don't contain the expected header values. -// Typically, most methods in the pingServer implementations just read the request headers -// and copy those to the response headers and trailers and let the client verify that way. -// However, this method can be used in conjunction with the server's verifyMetadata setting -// to force an error to be returned if metadata isn't set. For example, see -// TestGRPCMissingTrailersError tests. -func expectMetadata(meta http.Header) error { - vals := meta.Values(clientHeader) - if ok := compareValues(vals, expectedHeaderValues); !ok { - return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf( - "header %q: got %q, expected %q", - clientHeader, - vals, - expectedHeaderValues, - )) - } - return nil } // assertResponseHeadersAndTrailers verifies that the given response info contains the expected headers and trailers. -func assertResponseHeadersAndTrailers(t *testing.T, resp responseInfo) { //nolint:thelper - assert.True(t, compareValues(resp.ResponseHeader().Values(handlerHeader), expectedHeaderValues)) - assert.True(t, compareValues(resp.ResponseTrailer().Values(handlerTrailer), expectedHeaderValues)) -} - -// compareHeaders compares two http Header objects to verify they contain the exact same information. -func compareHeaders(hdr1 http.Header, hdr2 http.Header) bool { - if len(hdr1) != len(hdr2) { - return false - } - for key, hdr1Val := range hdr1 { - hdr2Val, ok := hdr2[key] - if !ok || len(hdr1Val) != len(hdr2Val) { - return false - } - - if equal := compareValues(hdr1Val, hdr2Val); !equal { - return false - } - } - return true +func assertResponseHeadersAndTrailers(t *testing.T, callInfo *connect.CallInfo) { //nolint:thelper + assert.True(t, compareValues(callInfo.ResponseHeader().Values(handlerHeader), expectedHeaderValues)) + assert.True(t, compareValues(callInfo.ResponseTrailer().Values(handlerTrailer), expectedHeaderValues)) } -// compareValues compares two string slices of header values to verify they are the same, ignoring order. -func compareValues(hdr1 []string, hdr2 []string) bool { - if len(hdr1) != len(hdr2) { - return false - } - // Copy slices to avoid race conditions with other tests trying to read the headers - sorted1 := make([]string, len(hdr1)) - copy(sorted1, hdr1) - sorted2 := make([]string, len(hdr2)) - copy(sorted2, hdr2) - - sort.Strings(sorted1) - sort.Strings(sorted2) - - for idx, el := range sorted1 { - if el != sorted2[idx] { - return false - } - } - return true +// assertErrorResponseMetadata verifies the handler's error metadata reached the +// client. gRPC sends a trailers-only error response, folding leading metadata +// into trailers, so handlerHeader may arrive as a header or a trailer. +func assertErrorResponseMetadata(t *testing.T, callInfo *connect.CallInfo) { //nolint:thelper + header := append([]string{}, callInfo.ResponseHeader().Values(handlerHeader)...) + header = append(header, callInfo.ResponseTrailer().Values(handlerHeader)...) + assert.True(t, compareValues(header, expectedHeaderValues)) + assert.True(t, compareValues(callInfo.ResponseTrailer().Values(handlerTrailer), expectedHeaderValues)) } diff --git a/connecthttp/connecthttp.go b/connecthttp/connecthttp.go new file mode 100644 index 00000000..d2406d94 --- /dev/null +++ b/connecthttp/connecthttp.go @@ -0,0 +1,430 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package connecthttp adapts [connect] onto [net/http]. It provides +// [NewTransport] for client-side [connect.Client] values and a [Mount] entry +// point for servers. +// +// The package implements the Connect, gRPC, and gRPC-Web wire protocols. +package connecthttp + +import ( + "context" + "crypto/tls" + "maps" + "net/http" + "net/url" + "slices" + "strings" + "sync" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectgzip" + "connectrpc.com/connect/v2/connectproto" +) + +// HTTPClient is the HTTP client surface the transport drives. The +// standard library's [*http.Client] satisfies it. +type HTTPClient interface { + // Do sends request and returns the HTTP response. + Do(*http.Request) (*http.Response, error) +} + +// ClientInfo is the HTTP metadata for a client RPC, carried on +// [connect.CallInfo.TransportInfo]. +// +// Request-side accessors are safe to read once the first Send (or Receive) +// returns, response-side accessors once the first Receive returns. Reading +// them earlier on a streaming RPC races the transport's dispatch goroutine. +type ClientInfo struct { + request *http.Request + response *http.Response +} + +// ClientInfoForContext returns the [*ClientInfo] carried on the client-side +// [connect.CallInfo]'s TransportInfo, if there is one. It reports false when +// the RPC is not dispatched over HTTP. The transport sets it when it opens +// the stream. To read it after a call returns, attach the CallInfo with +// [connect.NewClientContext] first. +func ClientInfoForContext(ctx context.Context) (*ClientInfo, bool) { + callInfo, ok := connect.CallInfoForClientContext(ctx) + if !ok { + return nil, false + } + info, ok := callInfo.TransportInfo.(*ClientInfo) + return info, ok +} + +// HTTPMethod is the HTTP method of the dispatched request, "POST" by +// default, "GET" when [WithHTTPGet] upgrades a side-effect-free unary call. +func (c *ClientInfo) HTTPMethod() string { + if c == nil || c.request == nil { + return "" + } + return c.request.Method +} + +// RequestURL is the URL the transport dispatched to. Returned by +// value, so callers may inspect or modify their copy without +// affecting the live request. +func (c *ClientInfo) RequestURL() url.URL { + if c == nil || c.request == nil || c.request.URL == nil { + return url.URL{} + } + return *c.request.URL +} + +// ResponseStatus is the HTTP status code returned by the server. Zero +// before the response arrives. +func (c *ClientInfo) ResponseStatus() int { + if c == nil || c.response == nil { + return 0 + } + return c.response.StatusCode +} + +// ResponseProto is the HTTP protocol version reported on the +// response, e.g. "HTTP/2.0". Empty before the response arrives. +func (c *ClientInfo) ResponseProto() string { + if c == nil || c.response == nil { + return "" + } + return c.response.Proto +} + +// TLS is the TLS connection state for the response, or nil if the +// transport was plain HTTP or the response has not yet arrived. +func (c *ClientInfo) TLS() *tls.ConnectionState { + if c == nil || c.response == nil { + return nil + } + return c.response.TLS +} + +// ServerInfo is the HTTP metadata for a server RPC, carried on +// [connect.CallInfo.TransportInfo]. +type ServerInfo struct { + request *http.Request +} + +// ServerInfoForContext returns the [*ServerInfo] carried on the server-side +// [connect.CallInfo]'s TransportInfo, if there is one. It reports false when +// the RPC is not served over HTTP. +func ServerInfoForContext(ctx context.Context) (*ServerInfo, bool) { + callInfo, ok := connect.CallInfoForServerContext(ctx) + if !ok { + return nil, false + } + info, ok := callInfo.TransportInfo.(*ServerInfo) + return info, ok +} + +// HTTPMethod is the HTTP method of the incoming request. +func (s *ServerInfo) HTTPMethod() string { + if s == nil || s.request == nil { + return "" + } + return s.request.Method +} + +// RequestURL is the URL of the incoming request as net/http parsed +// it (request-URI form, not absolute). Returned by value. +func (s *ServerInfo) RequestURL() url.URL { + if s == nil || s.request == nil || s.request.URL == nil { + return url.URL{} + } + return *s.request.URL +} + +// RequestProto is the HTTP protocol version reported on the request, +// e.g. "HTTP/1.1" or "HTTP/2.0". +func (s *ServerInfo) RequestProto() string { + if s == nil || s.request == nil { + return "" + } + return s.request.Proto +} + +// TLS is the TLS connection state for the incoming connection, or +// nil for plain HTTP. +func (s *ServerInfo) TLS() *tls.ConnectionState { + if s == nil || s.request == nil { + return nil + } + return s.request.TLS +} + +// HTTPGetQueryParams returns the raw HTTP Get URL parameters. +func (s *ServerInfo) HTTPGetQueryParams() url.Values { + if s == nil || s.request == nil || s.request.Method != http.MethodGet { + return nil + } + return s.request.URL.Query() +} + +// ServeMux is the subset of [http.ServeMux] used by [Mount]. +// Any router that satisfies this interface can host Connect routes. +type ServeMux interface { + // Handle registers handler for pattern. + Handle(pattern string, handler http.Handler) +} + +// encodingOrIdentity normalizes an empty compression name to "identity". +func encodingOrIdentity(name string) string { + if name == "" { + return connect.CompressionNameIdentity + } + return name +} + +// NewTransport returns a [connect.Transport] that dispatches RPCs over +// httpClient against baseURL. Pass the returned transport to +// [connect.NewClient] before constructing generated service clients. +func NewTransport(httpClient HTTPClient, baseURL string, options ...Option) connect.Transport { + parsed, err := url.Parse(baseURL) + if err != nil { + parsed = nil + } + opts := defaultOptions() + for _, opt := range options { + opt.apply(&opts) + } + return &transport{ + httpClient: httpClient, + baseURLPtr: parsed, + baseURLErr: err, + options: opts, + } +} + +// defaultOptions returns the options with the defaults every transport and +// server starts from before user options are applied. +func defaultOptions() options { + return options{ + protocol: connect.ProtocolNameConnect, + codecs: defaultCodecs(), + sendCodecName: connect.CodecNameProto, + compressors: defaultCompressors(), + compressorNames: []string{connect.CompressionNameGzip}, + readMaxBytes: defaultReadMaxBytes, + getUseFallback: true, + } +} + +// defaultCodecs returns the proto and JSON codecs every transport and handler +// registers by default. +func defaultCodecs() map[string]connect.Codec { + return map[string]connect.Codec{ + connect.CodecNameProto: connectproto.NewBinaryCodec(), + connect.CodecNameJSON: connectproto.NewJSONCodec(), + } +} + +// defaultCompressors returns the gzip compressor registered by default. +func defaultCompressors() map[string]connect.Compressor { + return map[string]connect.Compressor{ + connect.CompressionNameGzip: connectgzip.New(), + } +} + +// Mount registers an [http.Handler] for each of server's procedures on mux, +// plus a per-service catch-all that routes RPC requests for unknown methods +// through [connect.Server.Call]. Non-RPC requests get a plain 404, and +// requests for unknown services fall through to mux. +func Mount(mux ServeMux, server *connect.Server, options ...Option) { + opts := defaultOptions() + for _, opt := range options { + opt.apply(&opts) + } + services := make(map[string]struct{}) + for spec := range server.Specs() { + mux.Handle(spec.Procedure, newProcedureHandler(server, spec, &opts)) + // "/package.Service/Method" -> subtree pattern "/package.Service/". + if idx := strings.LastIndexByte(spec.Procedure, '/'); idx > 0 { + services[spec.Procedure[:idx+1]] = struct{}{} + } + } + for service := range services { + mux.Handle(service, newUnknownMethodHandler(server, &opts)) + } +} + +// newUnknownMethodHandler routes RPC-shaped POST requests through +// [connect.Server.Call] and 404s everything else. Connect GET requests are +// not dispatched: an unknown method's idempotency is unknown. +func newUnknownMethodHandler(server *connect.Server, opts *options) http.Handler { + classifier := &ErrorWriter{ + protobuf: newReadOnlyCodecs(opts.codecs).Protobuf(), + requireConnectProtocolHeader: opts.requireConnectProtocolHeader, + } + return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + http.NotFound(responseWriter, request) + return + } + streamType := connect.StreamTypeBidi + switch classifier.classifyRequest(request) { + case unknownProtocol: + http.NotFound(responseWriter, request) + return + case connectUnaryProtocol: + // Connect unary framing differs; other protocols use one framing + // for every stream shape. + streamType = connect.StreamTypeUnary + case connectStreamProtocol, grpcProtocol, grpcWebProtocol: + } + spec := connect.Spec{ + StreamType: streamType, + Procedure: request.URL.Path, + } + // Cold path: build the handler per request to reuse the + // registered-procedure machinery. + newProcedureHandler(server, spec, opts).ServeHTTP(responseWriter, request) + }) +} + +type transport struct { + httpClient HTTPClient + options options + + baseURLPtr *url.URL + baseURLErr error + + procedureURLs sync.Map // procedure string -> *url.URL +} + +// NewClientStream implements [connect.Transport]. +func (t *transport) NewClientStream(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + info, ok := connect.CallInfoForClientContext(ctx) + if !ok { + ctx, info = connect.NewClientContext(ctx) + } + clientInfo := &ClientInfo{} + info.TransportInfo = clientInfo + opts := t.options.forSpec(spec) + if opts.sendCompressor != "" && opts.sendCompressor != connect.CompressionNameIdentity { + if _, ok := opts.compressors[opts.sendCompressor]; !ok { + return nil, connect.Errorf(connect.CodeUnknown, "unknown compression %q", opts.sendCompressor) + } + } + if _, ok := opts.codecs[opts.sendCodecName]; !ok { + return nil, connect.Errorf(connect.CodeUnknown, "unknown codec %q", opts.sendCodecName) + } + if t.baseURLErr != nil { + return nil, connect.Errorf(connect.CodeUnavailable, "invalid base URL: %v", t.baseURLErr) + } + protocolClient, err := t.newProtocolClient(spec, opts) + if err != nil { + return nil, err + } + conn := protocolClient.NewConn(ctx, spec, make(http.Header, 8)) + peer := conn.Peer() + info.Spec = spec + info.PeerAddr = peer.Addr + info.Protocol = peer.Protocol + info.Codec = opts.sendCodecName + info.RequestEncoding = encodingOrIdentity(opts.sendCompressor) + conn.onRequestSend(func(request *http.Request) { + clientInfo.request = request + }) + conn.onResponseReceive(func(response *http.Response) { + clientInfo.response = response + }) + if spec.StreamType == connect.StreamTypeUnary { + return &connectUnaryClientStream{conn: conn, info: info, protoClient: protocolClient, streamType: spec.StreamType}, nil + } + return &connectStreamingClientStream{conn: conn, info: info, protoClient: protocolClient, streamType: spec.StreamType}, nil +} + +// urlForProcedure returns a cached *url.URL for the given procedure path, +// rooted at the transport's baseURL. The returned URL must not be mutated +// because it is shared across concurrent dispatches. +func (t *transport) urlForProcedure(procedure string) *url.URL { + if v, ok := t.procedureURLs.Load(procedure); ok { + return v.(*url.URL) //nolint:errcheck,forcetypeassert // map only stores *url.URL + } + u := *t.baseURLPtr + u.Path = joinURLPath(t.baseURLPtr.Path, procedure) + actual, _ := t.procedureURLs.LoadOrStore(procedure, &u) + return actual.(*url.URL) //nolint:errcheck,forcetypeassert // map only stores *url.URL +} + +// options holds the resolved options for a transport ([NewTransport]) or server +// ([Mount]). A single [Option] list builds it up; each entry point reads the +// fields relevant to it and ignores the rest. +type options struct { + // Shared by clients and servers. + codecs map[string]connect.Codec + compressors map[string]connect.Compressor + compressorNames []string + compressMinBytes int + readMaxBytes int + sendMaxBytes int + + // Client-only ([Mount] ignores these). + protocol string + sendCodecName string + sendCompressor string + getEnabled bool + getMaxURLBytes int + getUseFallback bool + + // Server-only ([NewTransport] ignores these). + requireConnectProtocolHeader bool + + // conditional holds per-procedure option functions registered with + // WithConditionalOptions. They are evaluated against each spec by forSpec. + conditional []func(connect.Spec) []Option +} + +// forSpec returns the options to apply for spec. When no conditional options are +// registered it returns the receiver unchanged; otherwise it clones the options +// and applies the options each conditional returns for spec. +func (o *options) forSpec(spec connect.Spec) *options { + if len(o.conditional) == 0 { + return o + } + clone := o.clone() + for _, conditional := range o.conditional { + for _, opt := range conditional(spec) { + opt.apply(clone) + } + } + return clone +} + +// clone returns a deep-enough copy of c that applying options to the copy does +// not mutate the receiver's maps and slices. The conditional list is dropped to +// avoid re-evaluating it. +func (o *options) clone() *options { + opts := *o + opts.codecs = maps.Clone(o.codecs) + opts.compressors = maps.Clone(o.compressors) + opts.compressorNames = slices.Clone(o.compressorNames) + opts.conditional = nil + return &opts +} + +// joinURLPath joins a base path and a procedure path with exactly one +// '/' between them. Connect procedures always begin with '/'. +func joinURLPath(base, procedure string) string { + switch { + case base == "": + return procedure + case base[len(base)-1] == '/' && len(procedure) > 0 && procedure[0] == '/': + return base + procedure[1:] + default: + return base + procedure + } +} diff --git a/duplex_http_call.go b/connecthttp/duplex_http_call.go similarity index 93% rename from duplex_http_call.go rename to connecthttp/duplex_http_call.go index 6aaada30..6f725eb4 100644 --- a/duplex_http_call.go +++ b/connecthttp/duplex_http_call.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "context" @@ -22,6 +22,8 @@ import ( "net/url" "sync" "sync/atomic" + + "connectrpc.com/connect/v2" ) // duplexHTTPCall is a full-duplex stream between the client and server. The @@ -30,11 +32,12 @@ import ( // // Be warned: we need to use some lesser-known APIs to do this with net/http. type duplexHTTPCall struct { - ctx context.Context - httpClient HTTPClient - streamType StreamType - onRequestSend func(*http.Request) - validateResponse func(*http.Response) *Error + ctx context.Context + httpClient HTTPClient + streamType connect.StreamType + onRequestSend func(*http.Request) + onResponseReceive func(*http.Response) + validateResponse func(*http.Response) *connect.Error // requestBodyWriter streams the request body for client-streaming and bidi // RPCs. Assigned once in newDuplexHTTPCall and never reassigned, so it is @@ -57,7 +60,7 @@ func newDuplexHTTPCall( ctx context.Context, httpClient HTTPClient, url *url.URL, - spec Spec, + streamType connect.StreamType, header http.Header, ) *duplexHTTPCall { // ensure we make a copy of the url before we pass along to the @@ -85,14 +88,14 @@ func newDuplexHTTPCall( duplex := &duplexHTTPCall{ ctx: ctx, httpClient: httpClient, - streamType: spec.StreamType, + streamType: streamType, request: request, responseReady: make(chan struct{}), } // Client-streaming and bidi RPCs stream the request body through an // io.Pipe. Set it up here so requestBodyWriter is assigned once at // construction and safe to read concurrently from Send and CloseWrite. - if spec.StreamType&StreamTypeClient != 0 { + if streamType&connect.StreamTypeClient != 0 { pipeReader, pipeWriter := io.Pipe() duplex.requestBodyWriter = pipeWriter duplex.request.Body = pipeReader @@ -104,7 +107,7 @@ func newDuplexHTTPCall( // Send sends a message to the server. func (d *duplexHTTPCall) Send(payload messagePayload) (int64, error) { - if d.streamType&StreamTypeClient == 0 { + if d.streamType&connect.StreamTypeClient == 0 { return d.sendUnary(payload) } isFirst := d.requestSent.CompareAndSwap(false, true) @@ -284,10 +287,21 @@ func (d *duplexHTTPCall) ResponseTrailer() http.Header { // SetValidateResponse sets the response validation function. The function runs // in a background goroutine. -func (d *duplexHTTPCall) SetValidateResponse(validate func(*http.Response) *Error) { +func (d *duplexHTTPCall) SetValidateResponse(validate func(*http.Response) *connect.Error) { d.validateResponse = validate } +// awaitResponse blocks until the response is ready or cancelled. It reports +// whether the response arrived. If false, the response state may still be +// written by makeRequest and must not be read. +func (d *duplexHTTPCall) awaitResponse() bool { + if !d.requestSent.Load() { + return false + } + response, _ := d.blockUntilResponseReady() + return response != nil +} + // blockUntilResponseReady blocks until the response is ready or the context is // cancelled. It returns a nil response when none was received, either because // the request failed or the context was cancelled before the response arrived. @@ -338,7 +352,7 @@ func (d *duplexHTTPCall) makeRequest() { err = wrapIfLikelyWithGRPCNotUsedError(err) err = wrapIfRSTError(d.ctx, err) if _, ok := asError(err); !ok { - err = NewError(CodeUnavailable, err) + err = connect.Errorf(connect.CodeUnavailable, "%s", err).WithCause(err) } d.responseErr = err _ = d.CloseWrite() @@ -347,16 +361,19 @@ func (d *duplexHTTPCall) makeRequest() { // We've got a response. We can now read from the response body. // Closing the response body is delegated to the caller even on error. d.response = response + if d.onResponseReceive != nil { + d.onResponseReceive(response) + } if err := d.validateResponse(response); err != nil { d.responseErr = err _ = d.CloseWrite() return } - if (d.streamType&StreamTypeBidi) == StreamTypeBidi && response.ProtoMajor < 2 { + if (d.streamType&connect.StreamTypeBidi) == connect.StreamTypeBidi && response.ProtoMajor < 2 { // If we somehow dialed an HTTP/1.x server, fail with an explicit message // rather than returning a more cryptic error later on. - d.responseErr = errorf( - CodeUnimplemented, + d.responseErr = connect.Errorf( + connect.CodeUnimplemented, "response from %v is HTTP/%d.%d: bidi streams require at least HTTP/2", d.request.URL, response.ProtoMajor, diff --git a/duplex_http_call_test.go b/connecthttp/duplex_http_call_test.go similarity index 84% rename from duplex_http_call_test.go rename to connecthttp/duplex_http_call_test.go index 4d1246c1..b973eaa6 100644 --- a/duplex_http_call_test.go +++ b/connecthttp/duplex_http_call_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "bytes" @@ -26,7 +26,9 @@ import ( "testing" "time" - "connectrpc.com/connect/internal/assert" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/internal/assert" + "connectrpc.com/connect/v2/internal/bufferpool" ) // TestHTTPCallGetBody tests that the client is able to retry requests on @@ -56,7 +58,6 @@ func TestHTTPCallGetBody(t *testing.T) { assert.True(t, ok) transport.Protocols = clientProtos - bufferPool := newBufferPool() serverURL, _ := url.Parse(server.URL) errGetBodyCalled := errors.New("getBodyCalled") // sentinel error caller := func(size int) error { @@ -64,7 +65,7 @@ func TestHTTPCallGetBody(t *testing.T) { t.Context(), client, serverURL, - Spec{StreamType: StreamTypeUnary}, + connect.StreamTypeUnary, http.Header{}, ) getBodyCalled := false @@ -78,11 +79,11 @@ func TestHTTPCallGetBody(t *testing.T) { } } // SetValidateResponse must be set. - call.SetValidateResponse(func(*http.Response) *Error { + call.SetValidateResponse(func(*http.Response) *connect.Error { return nil }) - buf := bufferPool.Get() - defer bufferPool.Put(buf) + buf := bufferpool.Get() + defer bufferpool.Put(buf) buf.Write(make([]byte, size)) _, err := call.Send(bytes.NewReader(buf.Bytes())) assert.Nil(t, err) @@ -174,10 +175,10 @@ func TestDuplexHTTPCallSendCloseWriteNoNilDeref(t *testing.T) { t.Context(), server.Client(), serverURL, - Spec{StreamType: StreamTypeClient}, + connect.StreamTypeClient, http.Header{}, ) - call.SetValidateResponse(func(*http.Response) *Error { return nil }) + call.SetValidateResponse(func(*http.Response) *connect.Error { return nil }) // Start a goroutine that issues a Send after a short delay. The delay // lets the main goroutine's CloseWrite win the CAS on requestSent @@ -219,10 +220,10 @@ func TestBlockUntilResponseReadyRespectsContext(t *testing.T) { ctx, &hangingHTTPClient{}, serverURL, - Spec{StreamType: StreamTypeClient}, + connect.StreamTypeClient, http.Header{}, ) - call.SetValidateResponse(func(*http.Response) *Error { return nil }) + call.SetValidateResponse(func(*http.Response) *connect.Error { return nil }) _, err = call.Send(bytes.NewReader([]byte("hello"))) assert.Nil(t, err) @@ -238,12 +239,46 @@ func TestBlockUntilResponseReadyRespectsContext(t *testing.T) { select { case err := <-done: assert.NotNil(t, err) - assert.Equal(t, CodeDeadlineExceeded, CodeOf(err)) + assert.Equal(t, connect.CodeDeadlineExceeded, connect.CodeOf(err)) case <-time.After(200 * time.Millisecond): t.Fatal("BlockUntilResponseReady did not return after context expiry") } } +func TestAwaitResponseRespectsContext(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + serverURL, err := url.Parse("http://localhost:0") + assert.Nil(t, err) + + call := newDuplexHTTPCall( + ctx, + &hangingHTTPClient{}, + serverURL, + connect.StreamTypeClient, + http.Header{}, + ) + call.SetValidateResponse(func(*http.Response) *connect.Error { return nil }) + + _, err = call.Send(bytes.NewReader([]byte("hello"))) + assert.Nil(t, err) + assert.Nil(t, call.CloseWrite()) + + cancel() + + done := make(chan bool, 1) + go func() { + done <- call.awaitResponse() + }() + select { + case ready := <-done: + assert.False(t, ready) + case <-time.After(200 * time.Millisecond): + t.Fatal("awaitResponse did not return after context cancellation") + } +} + // hangingHTTPClient simulates Do() not returning promptly after context // cancellation, as can happen with Go's HTTP/2 transport. type hangingHTTPClient struct{} diff --git a/envelope.go b/connecthttp/envelope.go similarity index 69% rename from envelope.go rename to connecthttp/envelope.go index dd5e5b49..14ae5920 100644 --- a/envelope.go +++ b/connecthttp/envelope.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "bytes" @@ -22,18 +22,20 @@ import ( "fmt" "io" "math" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/internal/bufferpool" ) // flagEnvelopeCompressed indicates that the data is compressed. It has the // same meaning in the gRPC-Web, gRPC-HTTP2, and Connect protocols. const flagEnvelopeCompressed = 0b00000001 -var errSpecialEnvelope = errorf( - CodeUnknown, - "final message has protocol-specific flags: %w", - // User code checks for end of stream with errors.Is(err, io.EOF). +var errSpecialEnvelope = connect.Errorf( + connect.CodeUnknown, + "final message has protocol-specific flags: %s", io.EOF, -) +).WithCause(io.EOF) // User code checks for end of stream with errors.Is(err, io.EOF). // envelope is a block of arbitrary bytes wrapped in gRPC and Connect's framing // protocol. @@ -128,14 +130,22 @@ func (e *envelope) Len() int { type envelopeWriter struct { ctx context.Context //nolint:containedctx sender messageSender - codec Codec + codec connect.Codec compressMinBytes int compressionPool *compressionPool - bufferPool *bufferPool sendMaxBytes int + stats *connect.MessageStats +} + +// recordStats records message byte counts, skipping protocol envelopes. +func (w *envelopeWriter) recordStats(flags uint8, size, compressedSize int) { + if w.stats == nil || flags&^flagEnvelopeCompressed != 0 { + return + } + *w.stats = connect.MessageStats{Size: size, CompressedSize: compressedSize} } -func (w *envelopeWriter) Marshal(message any) *Error { +func (w *envelopeWriter) Marshal(message any) *connect.Error { if message == nil { // Send no-op message to create the request and send headers. payload := nopPayload{} @@ -143,86 +153,67 @@ func (w *envelopeWriter) Marshal(message any) *Error { if connectErr, ok := asError(err); ok { return connectErr } - return NewError(CodeUnknown, err) + return connect.Errorf(connect.CodeUnknown, "%s", err).WithCause(err) } return nil } - if appender, ok := w.codec.(marshalAppender); ok { - return w.marshalAppend(message, appender) + // Codec supports MarshalAppend; try to re-use a []byte from the pool. + buffer := bufferpool.Get() + defer bufferpool.Put(buffer) + if err := w.codec.MarshalWrite(w.ctx, buffer, message); err != nil { + return connect.Errorf(connect.CodeInternal, "marshal message: %s", err).WithCause(err) } - return w.marshal(message) + envelope := &envelope{Data: buffer} + return w.Write(envelope) } // Write writes the enveloped message, compressing as necessary. It doesn't // retain any references to the supplied envelope or its underlying data. -func (w *envelopeWriter) Write(env *envelope) *Error { +func (w *envelopeWriter) Write(env *envelope) *connect.Error { if env.IsSet(flagEnvelopeCompressed) || w.compressionPool == nil || env.Data.Len() < w.compressMinBytes { if w.sendMaxBytes > 0 && env.Data.Len() > w.sendMaxBytes { - return errorf(CodeResourceExhausted, "message size %d exceeds sendMaxBytes %d", env.Data.Len(), w.sendMaxBytes) + return connect.Errorf(connect.CodeResourceExhausted, "message size %d exceeds sendMaxBytes %d", env.Data.Len(), w.sendMaxBytes) } - return w.write(env) + size, compressedSize := env.Data.Len(), 0 + if env.IsSet(flagEnvelopeCompressed) { + // Pre-compressed payload; uncompressed size unknown. + size, compressedSize = 0, env.Data.Len() + } + if err := w.write(env); err != nil { + return err + } + w.recordStats(env.Flags, size, compressedSize) + return nil } - data := w.bufferPool.Get() - defer w.bufferPool.Put(data) + size := env.Data.Len() // before Compress drains the buffer + data := bufferpool.Get() + defer bufferpool.Put(data) if err := w.compressionPool.Compress(data, env.Data); err != nil { return err } if w.sendMaxBytes > 0 && data.Len() > w.sendMaxBytes { - return errorf(CodeResourceExhausted, "compressed message size %d exceeds sendMaxBytes %d", data.Len(), w.sendMaxBytes) + return connect.Errorf(connect.CodeResourceExhausted, "compressed message size %d exceeds sendMaxBytes %d", data.Len(), w.sendMaxBytes) } - return w.write(&envelope{ + compressedSize := data.Len() // before write drains the buffer + if err := w.write(&envelope{ Data: data, Flags: env.Flags | flagEnvelopeCompressed, - }) -} - -func (w *envelopeWriter) marshalAppend(message any, codec marshalAppender) *Error { - // Codec supports MarshalAppend; try to re-use a []byte from the pool. - buffer := w.bufferPool.Get() - defer w.bufferPool.Put(buffer) - raw, err := codec.MarshalAppend(buffer.Bytes(), message) - if err != nil { - return errorf(CodeInternal, "marshal message: %w", err) - } - if cap(raw) > buffer.Cap() { - // The buffer from the pool was too small, so MarshalAppend grew the slice. - // Pessimistically assume that the too-small buffer is insufficient for the - // application workload, so there's no point in keeping it in the pool. - // Instead, replace it with the larger, newly-allocated slice. This - // allocates, but it's a small, constant-size allocation. - *buffer = *bytes.NewBuffer(raw) - } else { - // MarshalAppend didn't allocate, but we need to fix the internal state of - // the buffer. Compared to replacing the buffer (as above), buffer.Write - // copies but avoids allocating. - buffer.Write(raw) - } - envelope := &envelope{Data: buffer} - return w.Write(envelope) -} - -func (w *envelopeWriter) marshal(message any) *Error { - // Codec doesn't support MarshalAppend; let Marshal allocate a []byte. - raw, err := w.codec.Marshal(message) - if err != nil { - return errorf(CodeInternal, "marshal message: %w", err) + }); err != nil { + return err } - buffer := bytes.NewBuffer(raw) - // Put our new []byte into the pool for later reuse. - defer w.bufferPool.Put(buffer) - envelope := &envelope{Data: buffer} - return w.Write(envelope) + w.recordStats(env.Flags, size, compressedSize) + return nil } -func (w *envelopeWriter) write(env *envelope) *Error { +func (w *envelopeWriter) write(env *envelope) *connect.Error { if _, err := w.sender.Send(env); err != nil { err = wrapIfContextDone(w.ctx, err) if connectErr, ok := asError(err); ok { return connectErr } - return errorf(CodeUnknown, "write envelope: %w", err) + return connect.Errorf(connect.CodeUnknown, "write envelope: %s", err).WithCause(err) } return nil } @@ -231,19 +222,19 @@ type envelopeReader struct { ctx context.Context //nolint:containedctx reader io.Reader bytesRead int64 // detect trailers-only gRPC responses - codec Codec + codec connect.Codec last envelope compressionPool *compressionPool - bufferPool *bufferPool readMaxBytes int + stats *connect.MessageStats } -func (r *envelopeReader) Unmarshal(message any) *Error { - buffer := r.bufferPool.Get() +func (r *envelopeReader) Unmarshal(message any) *connect.Error { + buffer := bufferpool.Get() var dontRelease *bytes.Buffer defer func() { if buffer != dontRelease { - r.bufferPool.Put(buffer) + bufferpool.Put(buffer) } }() @@ -251,8 +242,7 @@ func (r *envelopeReader) Unmarshal(message any) *Error { err := r.Read(env) switch { case err == nil && env.IsSet(flagEnvelopeCompressed) && r.compressionPool == nil: - return errorf( - CodeInternal, + return connect.Errorf(connect.CodeInternal, "protocol error: sent compressed message without compression support", ) case err == nil && @@ -260,6 +250,9 @@ func (r *envelopeReader) Unmarshal(message any) *Error { env.Data.Len() == 0: // This is a standard message (because none of the top 7 bits are set) and // there's no data, so the zero value of the message is correct. + if r.stats != nil { + *r.stats = connect.MessageStats{} + } return nil case err != nil && errors.Is(err, io.EOF): // The stream has ended. Propagate the EOF to the caller. @@ -270,11 +263,13 @@ func (r *envelopeReader) Unmarshal(message any) *Error { } data := env.Data + compressedSize := 0 if data.Len() > 0 && env.IsSet(flagEnvelopeCompressed) { - decompressed := r.bufferPool.Get() + compressedSize = data.Len() + decompressed := bufferpool.Get() defer func() { if decompressed != dontRelease { - r.bufferPool.Put(decompressed) + bufferpool.Put(decompressed) } }() if err := r.compressionPool.Decompress(decompressed, data, int64(r.readMaxBytes)); err != nil { @@ -292,9 +287,9 @@ func (r *envelopeReader) Unmarshal(message any) *Error { if connErr, ok := asError(err); ok { return connErr } - return errorf(CodeInternal, "corrupt response: I/O error after end-stream message: %w", err) + return connect.Errorf(connect.CodeInternal, "corrupt response: I/O error after end-stream message: %s", err).WithCause(err) } else if numBytes > 0 { - return errorf(CodeInternal, "corrupt response: %d extra bytes after end of stream", numBytes) + return connect.Errorf(connect.CodeInternal, "corrupt response: %d extra bytes after end of stream", numBytes) } // One of the protocol-specific flags are set, so this is the end of the // stream. Save the message for protocol-specific code to process and @@ -308,13 +303,17 @@ func (r *envelopeReader) Unmarshal(message any) *Error { return errSpecialEnvelope } - if err := r.codec.Unmarshal(data.Bytes(), message); err != nil { - return errorf(CodeInvalidArgument, "unmarshal message: %w", err) + size := data.Len() // before UnmarshalRead drains the buffer + if err := r.codec.UnmarshalRead(r.ctx, data, message); err != nil { + return connect.Errorf(connect.CodeInvalidArgument, "unmarshal message: %s", err).WithCause(err) + } + if r.stats != nil { + *r.stats = connect.MessageStats{Size: size, CompressedSize: compressedSize} } return nil } -func (r *envelopeReader) Read(env *envelope) *Error { +func (r *envelopeReader) Read(env *envelope) *connect.Error { prefixes := [5]byte{} // io.ReadFull reads the number of bytes requested, or returns an error. // io.EOF will only be returned if no bytes were read. @@ -325,7 +324,7 @@ func (r *envelopeReader) Read(env *envelope) *Error { // The stream ended cleanly. That's expected, but we need to propagate an EOF // to the user so that they know that the stream has ended. We shouldn't // add any alarming text about protocol errors, though. - return NewError(CodeUnknown, err) + return connect.Errorf(connect.CodeUnknown, "%s", err).WithCause(err) } err = wrapIfMaxBytesError(err, "read 5 byte message prefix") err = wrapIfContextDone(r.ctx, err) @@ -333,19 +332,19 @@ func (r *envelopeReader) Read(env *envelope) *Error { return connectErr } // Something else has gone wrong - the stream didn't end cleanly. - return errorf( - CodeInvalidArgument, - "protocol error: incomplete envelope: %w", err, - ) + return connect.Errorf( + connect.CodeInvalidArgument, + "protocol error: incomplete envelope: %s", err, + ).WithCause(err) } size := int64(binary.BigEndian.Uint32(prefixes[1:5])) if r.readMaxBytes > 0 && size > int64(r.readMaxBytes) { n, err := io.CopyN(io.Discard, r.reader, size) r.bytesRead += n if err != nil && !errors.Is(err, io.EOF) { - return errorf(CodeResourceExhausted, "message is larger than configured max %d - unable to determine message size: %w", r.readMaxBytes, err) + return connect.Errorf(connect.CodeResourceExhausted, "message is larger than configured max %d - unable to determine message size: %s", r.readMaxBytes, err).WithCause(err) } - return errorf(CodeResourceExhausted, "message size %d is larger than configured max %d", size, r.readMaxBytes) + return connect.Errorf(connect.CodeResourceExhausted, "message size %d is larger than configured max %d", size, r.readMaxBytes) } // We've read the prefix, so we know how many bytes to expect. // CopyN will return an error if it doesn't read the requested @@ -356,8 +355,7 @@ func (r *envelopeReader) Read(env *envelope) *Error { if errors.Is(err, io.EOF) { // We've gotten fewer bytes than we expected, so the stream has ended // unexpectedly. - return errorf( - CodeInvalidArgument, + return connect.Errorf(connect.CodeInvalidArgument, "protocol error: promised %d bytes in enveloped message, got %d bytes", size, readN, @@ -368,7 +366,7 @@ func (r *envelopeReader) Read(env *envelope) *Error { if connectErr, ok := asError(err); ok { return connectErr } - return errorf(CodeUnknown, "read enveloped message: %w", err) + return connect.Errorf(connect.CodeUnknown, "read enveloped message: %s", err).WithCause(err) } env.Flags = prefixes[0] return nil diff --git a/envelope_test.go b/connecthttp/envelope_test.go similarity index 97% rename from envelope_test.go rename to connecthttp/envelope_test.go index 9ab4c2aa..a58cfe7d 100644 --- a/envelope_test.go +++ b/connecthttp/envelope_test.go @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "bytes" "io" "testing" - "connectrpc.com/connect/internal/assert" + "connectrpc.com/connect/v2/internal/assert" ) func TestEnvelope(t *testing.T) { diff --git a/connecthttp/error.go b/connecthttp/error.go new file mode 100644 index 00000000..5d5e62e6 --- /dev/null +++ b/connecthttp/error.go @@ -0,0 +1,287 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + "connectrpc.com/connect/v2" +) + +const commonErrorsURL = "https://connectrpc.com/docs/go/common-errors" + +var ( + // errNotModified signals Connect-protocol responses to GET requests to use the + // 304 Not Modified HTTP error code. + errNotModified = errors.New("not modified") + // errNotModifiedClient wraps ErrNotModified for use client-side. + errNotModifiedClient = fmt.Errorf("HTTP 304: %w", errNotModified) +) + +// NewNotModifiedError indicates that the requested resource hasn't changed. It +// should be used only when handlers wish to respond to conditional HTTP GET +// requests with a 304 Not Modified. In all other circumstances, including all +// RPCs using the gRPC or gRPC-Web protocols, it's equivalent to sending an +// error with [connect.CodeUnknown]. Handlers should set Etag, Cache-Control, +// or any other headers required by [RFC 9110 § 15.4.5] on the response header +// metadata reached via [connect.CallInfoForServerContext]. +// +// Clients should check for this error using [IsNotModifiedError]. +// +// [RFC 9110 § 15.4.5]: https://httpwg.org/specs/rfc9110.html#status.304 +func NewNotModifiedError() *connect.Error { + return connect.Errorf(connect.CodeUnknown, "%s", errNotModified).WithCause(errNotModified) +} + +// IsNotModifiedError checks whether the supplied error indicates that the +// requested resource hasn't changed. It only returns true if the server used +// [NewNotModifiedError] in response to a Connect-protocol RPC made with an +// HTTP GET. +func IsNotModifiedError(err error) bool { + return errors.Is(err, errNotModified) +} + +// asError uses errors.As to unwrap any error and look for a connect *connect.Error. +func asError(err error) (*connect.Error, bool) { + var connectErr *connect.Error + ok := errors.As(err, &connectErr) + return connectErr, ok +} + +// wrapIfUncoded ensures that all errors are wrapped. It leaves already-wrapped +// errors unchanged, uses wrapIfContextError to apply codes to context.Canceled +// and context.DeadlineExceeded, and falls back to wrapping other errors with +// connect.CodeUnknown. +func wrapIfUncoded(err error) error { + if err == nil { + return nil + } + maybeCodedErr := wrapIfContextError(err) + if _, ok := asError(maybeCodedErr); ok { + return maybeCodedErr + } + return connect.NewError(connect.CodeUnknown, maybeCodedErr.Error()).WithCause(maybeCodedErr) +} + +// scrubHandlerError converts a handler's returned error into the wire +// verdict: only a locally authored *connect.Error keeps its code, message, +// and details. Remote errors become [connect.CodeInternal] and other errors +// [connect.CodeUnknown] (context errors keep their codes), all with no +// message. The original error stays attached as a local-only cause. +func scrubHandlerError(err error) error { + if err == nil { + return nil + } + if cerr, ok := asError(err); ok { + if cerr.IsRemote() { + return connect.NewError(connect.CodeInternal, "").WithCause(err) + } + return err + } + code := connect.CodeUnknown + switch { + case errors.Is(err, context.Canceled): + code = connect.CodeCanceled + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, os.ErrDeadlineExceeded): + code = connect.CodeDeadlineExceeded + } + return connect.NewError(code, "").WithCause(err) +} + +// wrapIfContextError applies connect.CodeCanceled or connect.CodeDeadlineExceeded to Go's +// context.Canceled and context.DeadlineExceeded errors, but only if they +// haven't already been wrapped. +func wrapIfContextError(err error) error { + if err == nil { + return nil + } + if _, ok := asError(err); ok { + return err + } + if errors.Is(err, context.Canceled) { + return connect.NewError(connect.CodeCanceled, err.Error()).WithCause(err) + } + if errors.Is(err, context.DeadlineExceeded) { + return connect.NewError(connect.CodeDeadlineExceeded, err.Error()).WithCause(err) + } + // Ick, some dial errors can be returned as os.ErrDeadlineExceeded + // instead of context.DeadlineExceeded :( + // https://github.com/golang/go/issues/64449 + if errors.Is(err, os.ErrDeadlineExceeded) { + return connect.NewError(connect.CodeDeadlineExceeded, err.Error()).WithCause(err) + } + return err +} + +// wrapIfContextDone wraps errors with connect.CodeCanceled or connect.CodeDeadlineExceeded +// if the context is done. It leaves already-wrapped errors unchanged. +func wrapIfContextDone(ctx context.Context, err error) error { + if err == nil { + return nil + } + err = wrapIfContextError(err) + if _, ok := asError(err); ok { + return err + } + ctxErr := ctx.Err() + if errors.Is(ctxErr, context.Canceled) { + return connect.NewError(connect.CodeCanceled, err.Error()).WithCause(err) + } else if errors.Is(ctxErr, context.DeadlineExceeded) { + return connect.NewError(connect.CodeDeadlineExceeded, err.Error()).WithCause(err) + } + return err +} + +// wrapIfLikelyH2CNotConfiguredError adds a wrapping error that has a message +// telling the caller that they likely need to use h2c but are using a raw http.Client{}. +// +// This happens when running a gRPC-only server. +// This is fragile and may break over time, and this should be considered a best-effort. +func wrapIfLikelyH2CNotConfiguredError(request *http.Request, err error) error { + if err == nil { + return nil + } + if _, ok := asError(err); ok { + return err + } + if url := request.URL; url != nil && url.Scheme != "http" { + // If the scheme is not http, we definitely do not have an h2c error, so just return. + return err + } + // net/http code has been investigated and there is no typing of any of these errors + // they are all created with fmt.Errorf + // grpc-go returns the first error 2/3-3/4 of the time, and the second error 1/4-1/3 of the time + if errString := err.Error(); strings.HasPrefix(errString, `Post "`) && + (strings.Contains(errString, `net/http: HTTP/1.x transport connection broken: malformed HTTP response`) || + strings.HasSuffix(errString, `write: broken pipe`)) { + return fmt.Errorf("possible h2c configuration issue when talking to gRPC server, see %s: %w", commonErrorsURL, err) + } + return err +} + +// wrapIfLikelyWithGRPCNotUsedError adds a wrapping error that has a message +// telling the caller that they likely forgot to use connecthttp.WithGRPC(). +// +// This happens when running a gRPC-only server. +// This is fragile and may break over time, and this should be considered a best-effort. +func wrapIfLikelyWithGRPCNotUsedError(err error) error { + if err == nil { + return nil + } + if _, ok := asError(err); ok { + return err + } + // golang.org/x/net code has been investigated and there is no typing of this error + // it is created with fmt.Errorf + // http2/transport.go:573: return nil, fmt.Errorf("http2: Transport: cannot retry err [%v] after Request.Body was written; define Request.GetBody to avoid this error", err) + if errString := err.Error(); strings.HasPrefix(errString, `Post "`) && + strings.Contains(errString, `http2: Transport: cannot retry err`) && + strings.HasSuffix(errString, `after Request.Body was written; define Request.GetBody to avoid this error`) { + return fmt.Errorf("possible missing connecthttp.WithGRPC() client option when talking to gRPC server, see %s: %w", commonErrorsURL, err) + } + return err +} + +// HTTP/2 has its own set of error codes, which it sends in RST_STREAM frames. +// When the server sends one of these errors, we should map it back into our +// RPC error codes following +// https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#http2-transport-mapping. +// +// This would be vastly simpler if we were using x/net/http2 directly, since +// the StreamError type is exported. When x/net/http2 gets vendored into +// net/http, though, all these types become unexported...so we're left with +// string munging. +func wrapIfRSTError(ctx context.Context, err error) error { + const ( + streamErrPrefix = "stream error: " + fromPeerSuffix = "; received from peer" + ) + if err == nil { + return nil + } + if _, ok := asError(err); ok { + return err + } + if urlErr := new(url.Error); errors.As(err, &urlErr) { + // If we get an RST_STREAM error from http.Client.Do, it's wrapped in a + // *url.Error. + err = urlErr.Unwrap() + } + msg := err.Error() + if !strings.HasPrefix(msg, streamErrPrefix) { + return err + } + if !strings.HasSuffix(msg, fromPeerSuffix) { + return err + } + msg = strings.TrimSuffix(msg, fromPeerSuffix) + i := strings.LastIndex(msg, ";") + if i < 0 || i >= len(msg)-1 { + return err + } + msg = msg[i+1:] + msg = strings.TrimSpace(msg) + switch msg { + case "NO_ERROR", "PROTOCOL_ERROR", "INTERNAL_ERROR", "FLOW_CONTROL_ERROR", + "SETTINGS_TIMEOUT", "FRAME_SIZE_ERROR", "COMPRESSION_ERROR", "CONNECT_ERROR": + return connect.NewError(connect.CodeInternal, err.Error()).WithCause(err) + case "REFUSED_STREAM": + return connect.NewError(connect.CodeUnavailable, err.Error()).WithCause(err) + case "CANCEL": + if deadline, ok := ctx.Deadline(); ok && time.Now().After(deadline) { + // Some server implementations will cancel the HTTP/2 stream with + // a RST_STREAM frame when they observe that the client's deadline + // has elapsed. + // We don't inspect ctx.Err() because we could be racing with the + // timer goroutine that is setting it. But there is no race when + // directly inspecting the context's deadline. In fact, if we get + // here, we have likely already examined ctx.Err() in a prior call + // to wrapIfContextError but observed a nil error and then fell + // through to here. + return connect.NewError(connect.CodeDeadlineExceeded, err.Error()).WithCause(err) + } + return connect.NewError(connect.CodeCanceled, err.Error()).WithCause(err) + case "ENHANCE_YOUR_CALM": + return connect.Errorf(connect.CodeResourceExhausted, "bandwidth exhausted: %v", err).WithCause(err) + case "INADEQUATE_SECURITY": + return connect.Errorf(connect.CodePermissionDenied, "transport protocol insecure: %v", err).WithCause(err) + default: + return err + } +} + +// wrapIfMaxBytesError wraps errors returned reading from a http.MaxBytesHandler +// whose limit has been exceeded. +func wrapIfMaxBytesError(err error, tmpl string, args ...any) error { + if err == nil { + return nil + } + if _, ok := asError(err); ok { + return err + } + var maxBytesErr *http.MaxBytesError + if ok := errors.As(err, &maxBytesErr); !ok { + return err + } + prefix := fmt.Sprintf(tmpl, args...) + return connect.Errorf(connect.CodeResourceExhausted, "%s: exceeded %d byte http.MaxBytesReader limit", prefix, maxBytesErr.Limit) +} diff --git a/error_example_test.go b/connecthttp/error_example_test.go similarity index 79% rename from error_example_test.go rename to connecthttp/error_example_test.go index 4f5f48a2..ca669434 100644 --- a/error_example_test.go +++ b/connecthttp/error_example_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect_test +package connecthttp_test import ( "context" @@ -20,15 +20,16 @@ import ( "fmt" "net/http" - connect "connectrpc.com/connect" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" ) func ExampleError_Message() { err := fmt.Errorf( "another: %w", - connect.NewError(connect.CodeUnavailable, errors.New("failed to foo")), + connect.NewError(connect.CodeUnavailable, "failed to foo"), ) if connectErr := (&connect.Error{}); errors.As(err, &connectErr) { fmt.Println("underlying error message:", connectErr.Message()) @@ -41,11 +42,10 @@ func ExampleError_Message() { func ExampleIsNotModifiedError() { // Assume that the server from NewNotModifiedError's example is running on // localhost:8080. - client := pingv1connect.NewPingServiceClient( - http.DefaultClient, + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080", // Enable client-side support for HTTP GETs. - connect.WithHTTPGet(), + connecthttp.WithHTTPGet())), ) req := &pingv1.PingRequest{Number: 42} ctx, callInfo := connect.NewClientContext(context.Background()) @@ -65,7 +65,7 @@ func ExampleIsNotModifiedError() { // response if possible. callInfo.RequestHeader().Set("If-None-Match", etag) _, err = client.Ping(context.Background(), req) - if connect.IsNotModifiedError(err) { + if connecthttp.IsNotModifiedError(err) { fmt.Println("can reuse cached response") } } diff --git a/error_not_modified_example_test.go b/connecthttp/error_not_modified_example_test.go similarity index 71% rename from error_not_modified_example_test.go rename to connecthttp/error_not_modified_example_test.go index 8f570dad..5a46b865 100644 --- a/error_not_modified_example_test.go +++ b/connecthttp/error_not_modified_example_test.go @@ -12,17 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect_test +package connecthttp_test import ( "context" - "errors" "net/http" "strconv" - connect "connectrpc.com/connect" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" ) // ExampleCachingServer is an example of how servers can take advantage the @@ -42,19 +42,16 @@ func (*ExampleCachingPingServer) Ping( resp := &pingv1.PingResponse{ Number: req.GetNumber(), } - callInfo, ok := connect.CallInfoForHandlerContext(ctx) - if !ok { - return nil, errors.New("no call info found in context") - } + callInfo, _ := connect.CallInfoForServerContext(ctx) + serverInfo, _ := connecthttp.ServerInfoForContext(ctx) // Our hashing logic is simple: we use the number in the PingResponse. hash := strconv.FormatInt(resp.GetNumber(), 10) // If the request was an HTTP GET, we'll need to check if the client already // has the response cached. - if callInfo.HTTPMethod() == http.MethodGet && callInfo.RequestHeader().Get("If-None-Match") == hash { - return nil, connect.NewNotModifiedError(http.Header{ - "Etag": []string{hash}, - }) + if match := callInfo.RequestHeader().Get("If-None-Match"); serverInfo.HTTPMethod() == http.MethodGet && match == hash { + callInfo.ResponseHeader().Set("Etag", hash) + return nil, connecthttp.NewNotModifiedError() } callInfo.ResponseHeader().Set("Etag", hash) return resp, nil @@ -62,6 +59,8 @@ func (*ExampleCachingPingServer) Ping( func ExampleNewNotModifiedError() { mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(&ExampleCachingPingServer{})) + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, &ExampleCachingPingServer{}) + connecthttp.Mount(mux, server) _ = http.ListenAndServe("localhost:8080", mux) } diff --git a/error_test.go b/connecthttp/error_test.go similarity index 50% rename from error_test.go rename to connecthttp/error_test.go index 3d4d2a57..f38e7034 100644 --- a/error_test.go +++ b/connecthttp/error_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "errors" @@ -21,7 +21,9 @@ import ( "testing" "time" - "connectrpc.com/connect/internal/assert" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectproto" + "connectrpc.com/connect/v2/internal/assert" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/emptypb" @@ -29,30 +31,28 @@ import ( func TestErrorNilUnderlying(t *testing.T) { t.Parallel() - err := NewError(CodeUnknown, nil) + err := connect.NewError(connect.CodeUnknown, "") assert.NotNil(t, err) - assert.Equal(t, err.Error(), CodeUnknown.String()) - assert.Equal(t, err.Code(), CodeUnknown) + assert.Equal(t, err.Error(), connect.CodeUnknown.String()) + assert.Equal(t, err.Code(), connect.CodeUnknown) assert.Zero(t, err.Details()) - detail, detailErr := NewErrorDetail(&emptypb.Empty{}) + detail, detailErr := connectproto.NewErrorDetail(&emptypb.Empty{}) assert.Nil(t, detailErr) - err.AddDetail(detail) + err = err.WithDetail(detail) assert.Equal(t, len(err.Details()), 1) - assert.Equal(t, err.Details()[0].Type(), "google.protobuf.Empty") - err.Meta().Set("Foo", "bar") - assert.Equal(t, err.Meta().Get("Foo"), "bar") - assert.Equal(t, CodeOf(err), CodeUnknown) + anyDetail := connectproto.ErrorDetailToAny(err.Details()[0]) + assert.Equal(t, anyDetail.GetTypeUrl(), "type.googleapis.com/google.protobuf.Empty") } func TestErrorFormatting(t *testing.T) { t.Parallel() assert.Equal( t, - NewError(CodeUnavailable, errors.New("")).Error(), - CodeUnavailable.String(), + connect.NewError(connect.CodeUnavailable, "").Error(), + connect.CodeUnavailable.String(), ) - got := NewError(CodeUnavailable, errors.New("Foo")).Error() - assert.True(t, strings.Contains(got, CodeUnavailable.String())) + got := connect.NewError(connect.CodeUnavailable, "Foo").Error() + assert.True(t, strings.Contains(got, connect.CodeUnavailable.String())) assert.True(t, strings.Contains(got, "Foo")) } @@ -60,38 +60,39 @@ func TestErrorCode(t *testing.T) { t.Parallel() err := fmt.Errorf( "another: %w", - NewError(CodeUnavailable, errors.New("foo")), + connect.NewError(connect.CodeUnavailable, "foo"), ) connectErr, ok := asError(err) assert.True(t, ok) - assert.Equal(t, connectErr.Code(), CodeUnavailable) + assert.Equal(t, connectErr.Code(), connect.CodeUnavailable) } func TestCodeOf(t *testing.T) { t.Parallel() assert.Equal( t, - CodeOf(NewError(CodeUnavailable, errors.New("foo"))), - CodeUnavailable, + connect.CodeOf(connect.NewError(connect.CodeUnavailable, "foo")), + connect.CodeUnavailable, ) - assert.Equal(t, CodeOf(errors.New("foo")), CodeUnknown) + assert.Equal(t, connect.CodeOf(errors.New("foo")), connect.CodeUnknown) } func TestErrorDetails(t *testing.T) { t.Parallel() second := durationpb.New(time.Second) - detail, err := NewErrorDetail(second) + detail, err := connectproto.NewErrorDetail(second) assert.Nil(t, err) - connectErr := NewError(CodeUnknown, errors.New("error with details")) + connectErr := connect.NewError(connect.CodeUnknown, "error with details") assert.Zero(t, connectErr.Details()) - connectErr.AddDetail(detail) + connectErr = connectErr.WithDetail(detail) assert.Equal(t, len(connectErr.Details()), 1) - unmarshaled, err := connectErr.Details()[0].Value() + unmarshaled, err := connectproto.UnmarshalErrorDetail(connectErr.Details()[0]) assert.Nil(t, err) assert.Equal(t, unmarshaled, proto.Message(second)) + gotAny := connectproto.ErrorDetailToAny(connectErr.Details()[0]) secondBin, err := proto.Marshal(second) assert.Nil(t, err) - assert.Equal(t, detail.Bytes(), secondBin) + assert.Equal(t, gotAny.Value, secondBin) } func TestErrorIs(t *testing.T) { @@ -103,48 +104,7 @@ func TestErrorIs(t *testing.T) { assert.True(t, errors.Is(err, err)) // Our errors should have the same semantics. Note that we'd need to extend // the ErrorDetail interface to support value equality. - connectErr := NewError(CodeUnavailable, err) - assert.False(t, errors.Is(connectErr, NewError(CodeUnavailable, err))) + connectErr := connect.NewError(connect.CodeUnavailable, err.Error()) + assert.False(t, errors.Is(connectErr, connect.NewError(connect.CodeUnavailable, err.Error()))) assert.True(t, errors.Is(connectErr, connectErr)) } - -func TestTypeNameForURL(t *testing.T) { - t.Parallel() - testCases := []struct { - name string - url string - typeName string - }{ - { - name: "no-prefix", - url: "foo.bar.Baz", - typeName: "foo.bar.Baz", - }, - { - name: "standard-prefix", - url: defaultAnyResolverPrefix + "foo.bar.Baz", - typeName: "foo.bar.Baz", - }, - { - name: "different-hostname", - url: "abc.com/foo.bar.Baz", - typeName: "foo.bar.Baz", - }, - { - name: "additional-path-elements", - url: defaultAnyResolverPrefix + "abc/def/foo.bar.Baz", - typeName: "foo.bar.Baz", - }, - { - name: "full-url", - url: "https://abc.com/abc/def/foo.bar.Baz", - typeName: "foo.bar.Baz", - }, - } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, typeNameForURL(testCase.url), testCase.typeName) - }) - } -} diff --git a/error_writer.go b/connecthttp/error_writer.go similarity index 81% rename from error_writer.go rename to connecthttp/error_writer.go index 92aeed66..f76fb4cb 100644 --- a/error_writer.go +++ b/connecthttp/error_writer.go @@ -12,13 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( + "context" "encoding/json" "fmt" "net/http" "strings" + + "connectrpc.com/connect/v2" ) // protocolType is one of the supported RPC protocols. @@ -39,24 +42,24 @@ const ( // // ErrorWriters are safe to use concurrently. type ErrorWriter struct { - bufferPool *bufferPool - protobuf Codec + protobuf connect.Codec requireConnectProtocolHeader bool } -// NewErrorWriter constructs an ErrorWriter. Handler options may be passed to -// configure the error writer behaviour to match the handlers. +// NewErrorWriter constructs an ErrorWriter. Options may be passed to configure +// the error writer behaviour to match the handlers. // [WithRequireConnectProtocolHeader] will assert that Connect protocol // requests include the version header allowing the error writer to correctly // classify the request. -// Options supplied via [WithConditionalHandlerOptions] are ignored. -func NewErrorWriter(opts ...HandlerOption) *ErrorWriter { - config := newHandlerConfig("", StreamTypeUnary, opts) - codecs := newReadOnlyCodecs(config.Codecs) +func NewErrorWriter(options ...Option) *ErrorWriter { + opts := defaultOptions() + for _, opt := range options { + opt.apply(&opts) + } + codecs := newReadOnlyCodecs(opts.codecs) return &ErrorWriter{ - bufferPool: config.BufferPool, protobuf: codecs.Protobuf(), - requireConnectProtocolHeader: config.RequireConnectProtocolHeader, + requireConnectProtocolHeader: opts.requireConnectProtocolHeader, } } @@ -104,6 +107,7 @@ func (w *ErrorWriter) IsSupported(request *http.Request) bool { // // Write does not read or close the request body. func (w *ErrorWriter) Write(response http.ResponseWriter, request *http.Request, err error) error { + ctx := request.Context() ctype := canonicalizeContentType(getHeaderCanonical(request.Header, headerContentType)) switch protocolType := w.classifyRequest(request); protocolType { case connectStreamProtocol: @@ -111,10 +115,10 @@ func (w *ErrorWriter) Write(response http.ResponseWriter, request *http.Request, return w.writeConnectStreaming(response, err) case grpcProtocol: setHeaderCanonical(response.Header(), headerContentType, ctype) - return w.writeGRPC(response, err) + return w.writeGRPC(ctx, response, err) case grpcWebProtocol: setHeaderCanonical(response.Header(), headerContentType, ctype) - return w.writeGRPCWeb(response, err) + return w.writeGRPCWeb(ctx, response, err) case unknownProtocol, connectUnaryProtocol: fallthrough default: @@ -127,10 +131,7 @@ func (w *ErrorWriter) Write(response http.ResponseWriter, request *http.Request, } func (w *ErrorWriter) writeConnectUnary(response http.ResponseWriter, err error) error { - if connectErr, ok := asError(err); ok && !connectErr.wireErr { - mergeNonProtocolHeaders(response.Header(), connectErr.meta) - } - response.WriteHeader(connectCodeToHTTP(CodeOf(err))) + response.WriteHeader(connectCodeToHTTP(connect.CodeOf(err))) data, marshalErr := json.Marshal(newConnectWireError(err)) if marshalErr != nil { return fmt.Errorf("marshal error: %w", marshalErr) @@ -143,20 +144,19 @@ func (w *ErrorWriter) writeConnectStreaming(response http.ResponseWriter, err er response.WriteHeader(http.StatusOK) marshaler := &connectStreamingMarshaler{ envelopeWriter: envelopeWriter{ - sender: writeSender{writer: response}, - bufferPool: w.bufferPool, + sender: writeSender{writer: response}, }, } - // MarshalEndStream returns *Error: check return value to avoid typed nils. + // MarshalEndStream returns *connect.Error: check return value to avoid typed nils. if marshalErr := marshaler.MarshalEndStream(err, make(http.Header)); marshalErr != nil { return marshalErr } return nil } -func (w *ErrorWriter) writeGRPC(response http.ResponseWriter, err error) error { +func (w *ErrorWriter) writeGRPC(ctx context.Context, response http.ResponseWriter, err error) error { trailers := make(http.Header, 2) // need space for at least code & message - grpcErrorToTrailer(trailers, w.protobuf, err) + grpcErrorToTrailer(ctx, trailers, w.protobuf, err) // To make net/http reliably send trailers without a body, we must set the // Trailers header rather than using http.TrailerPrefix. See // https://github.com/golang/go/issues/54723. @@ -170,10 +170,10 @@ func (w *ErrorWriter) writeGRPC(response http.ResponseWriter, err error) error { return nil } -func (w *ErrorWriter) writeGRPCWeb(response http.ResponseWriter, err error) error { +func (w *ErrorWriter) writeGRPCWeb(ctx context.Context, response http.ResponseWriter, err error) error { // This is a trailers-only response. To match the behavior of Envoy and // protocol_grpc.go, put the trailers in the HTTP headers. - grpcErrorToTrailer(response.Header(), w.protobuf, err) + grpcErrorToTrailer(ctx, response.Header(), w.protobuf, err) response.WriteHeader(http.StatusOK) return nil } diff --git a/error_writer_example_test.go b/connecthttp/error_writer_example_test.go similarity index 93% rename from error_writer_example_test.go rename to connecthttp/error_writer_example_test.go index 18416b37..227335ab 100644 --- a/error_writer_example_test.go +++ b/connecthttp/error_writer_example_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect_test +package connecthttp_test import ( "errors" @@ -20,7 +20,8 @@ import ( "log" "net/http" - connect "connectrpc.com/connect" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" ) // NewHelloHandler is an example HTTP handler. In a real application, it might @@ -34,7 +35,7 @@ func NewHelloHandler() http.Handler { // NewAuthenticatedHandler is an example of middleware that works with both RPC // and non-RPC clients. func NewAuthenticatedHandler(handler http.Handler) http.Handler { - errorWriter := connect.NewErrorWriter() + errorWriter := connecthttp.NewErrorWriter() return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { // Dummy authentication logic. if request.Header.Get("Token") == "super-secret" { @@ -46,7 +47,7 @@ func NewAuthenticatedHandler(handler http.Handler) http.Handler { if errorWriter.IsSupported(request) { // Send a protocol-appropriate error to RPC clients, so that they receive // the right code, message, and any metadata or error details. - unauthenticated := connect.NewError(connect.CodeUnauthenticated, errors.New("invalid token")) + unauthenticated := connect.NewError(connect.CodeUnauthenticated, "invalid token") errorWriter.Write(response, request, unauthenticated) } else { // Send an error to non-RPC clients. diff --git a/error_writer_test.go b/connecthttp/error_writer_test.go similarity index 96% rename from error_writer_test.go rename to connecthttp/error_writer_test.go index 89cc2fce..cbd52b5f 100644 --- a/error_writer_test.go +++ b/connecthttp/error_writer_test.go @@ -12,14 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "net/http" "net/http/httptest" "testing" - "connectrpc.com/connect/internal/assert" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/internal/assert" ) func TestErrorWriter(t *testing.T) { @@ -30,7 +31,7 @@ func TestErrorWriter(t *testing.T) { t.Run("Unary", func(t *testing.T) { t.Parallel() req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "http://localhost", nil) - req.Header.Set("Content-Type", connectUnaryContentTypePrefix+codecNameJSON) + req.Header.Set("Content-Type", connectUnaryContentTypePrefix+connect.CodecNameJSON) assert.False(t, writer.IsSupported(req)) req.Header.Set(connectHeaderProtocolVersion, connectProtocolVersion) assert.True(t, writer.IsSupported(req)) @@ -47,7 +48,7 @@ func TestErrorWriter(t *testing.T) { t.Run("Stream", func(t *testing.T) { t.Parallel() req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "http://localhost", nil) - req.Header.Set("Content-Type", connectStreamingContentTypePrefix+codecNameJSON) + req.Header.Set("Content-Type", connectStreamingContentTypePrefix+connect.CodecNameJSON) assert.True(t, writer.IsSupported(req)) // ignores WithRequireConnectProtocolHeader req.Header.Set(connectHeaderProtocolVersion, connectProtocolVersion) assert.True(t, writer.IsSupported(req)) @@ -59,7 +60,7 @@ func TestErrorWriter(t *testing.T) { t.Run("ConnectUnary", func(t *testing.T) { t.Parallel() req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "http://localhost", nil) - req.Header.Set("Content-Type", connectUnaryContentTypePrefix+codecNameJSON) + req.Header.Set("Content-Type", connectUnaryContentTypePrefix+connect.CodecNameJSON) assert.True(t, writer.IsSupported(req)) }) t.Run("ConnectUnaryGET", func(t *testing.T) { @@ -70,7 +71,7 @@ func TestErrorWriter(t *testing.T) { t.Run("ConnectStream", func(t *testing.T) { t.Parallel() req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "http://localhost", nil) - req.Header.Set("Content-Type", connectStreamingContentTypePrefix+codecNameJSON) + req.Header.Set("Content-Type", connectStreamingContentTypePrefix+connect.CodecNameJSON) assert.True(t, writer.IsSupported(req)) }) t.Run("GRPC", func(t *testing.T) { diff --git a/example_init_test.go b/connecthttp/example_init_test.go similarity index 76% rename from example_init_test.go rename to connecthttp/example_init_test.go index 860c372b..5bf9cec8 100644 --- a/example_init_test.go +++ b/connecthttp/example_init_test.go @@ -12,13 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect_test +package connecthttp_test import ( "net/http" - "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" - "connectrpc.com/connect/internal/memhttp" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2/internal/memhttp" ) var examplePingServer *memhttp.Server @@ -32,6 +34,8 @@ func init() { // deadlock, see: // (https://github.com/golang/go/issues/48394) mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pingServerSimple{})) + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + connecthttp.Mount(mux, server) examplePingServer = memhttp.NewServer(mux) } diff --git a/connecthttp/handler.go b/connecthttp/handler.go new file mode 100644 index 00000000..e0ca8f3f --- /dev/null +++ b/connecthttp/handler.go @@ -0,0 +1,174 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp + +import ( + "context" + "net/http" + + "connectrpc.com/connect/v2" +) + +// streamingHandlerFunc is the signature of a streaming RPC from the handler's +// perspective. +type streamingHandlerFunc func(context.Context, streamingHandlerConn, *connect.CallInfo) error + +// A handler is the server-side implementation of a single RPC defined by a +// service schema. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with +// the binary Protobuf and JSON codecs. They support gzip compression using the +// standard library's [compress/gzip]. +type handler struct { + spec connect.Spec + implementation streamingHandlerFunc + protocolHandlers map[string][]protocolHandler // Method to protocol handlers + allowMethod string // Allow header + acceptPost string // Accept-Post header +} + +// ServeHTTP implements [http.Handler]. +func (h *handler) ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) { + // We don't need to defer functions to close the request body or read to + // EOF: the stream we construct later on already does that, and we only + // return early when dealing with misbehaving clients. In those cases, it's + // okay if we can't re-use the connection. + isBidi := (h.spec.StreamType & connect.StreamTypeBidi) == connect.StreamTypeBidi + if isBidi && request.ProtoMajor < 2 { + // Clients coded to expect full-duplex connections may hang if they've + // mistakenly negotiated HTTP/1.1. To unblock them, we must close the + // underlying TCP connection. + responseWriter.Header().Set("Connection", "close") + responseWriter.WriteHeader(http.StatusHTTPVersionNotSupported) + return + } + + protocolHandlers := h.protocolHandlers[request.Method] + if len(protocolHandlers) == 0 { + responseWriter.Header().Set("Allow", h.allowMethod) + responseWriter.WriteHeader(http.StatusMethodNotAllowed) + return + } + + contentType := canonicalizeContentType(getHeaderCanonical(request.Header, headerContentType)) + + // Find our implementation of the RPC protocol in use. + var protocolHandler protocolHandler + for _, handler := range protocolHandlers { + if handler.CanHandlePayload(request, contentType) { + protocolHandler = handler + break + } + } + if protocolHandler == nil { + responseWriter.Header().Set("Accept-Post", h.acceptPost) + responseWriter.WriteHeader(http.StatusUnsupportedMediaType) + return + } + + if request.Method == http.MethodGet { + // A body must not be present. + hasBody := request.ContentLength > 0 + if request.ContentLength < 0 { + // No content-length header. + // Test if body is empty by trying to read a single byte. + var b [1]byte + n, _ := request.Body.Read(b[:]) + hasBody = n > 0 + } + if hasBody { + responseWriter.WriteHeader(http.StatusUnsupportedMediaType) + return + } + _ = request.Body.Close() + } + + // Establish a stream and serve the RPC. + setHeaderCanonical(request.Header, headerContentType, contentType) + setHeaderCanonical(request.Header, headerHost, request.Host) + ctx, cancel, timeoutErr := protocolHandler.SetTimeout(request) //nolint: contextcheck + if timeoutErr != nil { + ctx = request.Context() + } + if cancel != nil { + defer cancel() + } + info := &connect.CallInfo{TransportInfo: &ServerInfo{request: request}} + connCloser, ok := protocolHandler.NewConn( + responseWriter, + request.WithContext(ctx), + info, + ) + if !ok { + // Failed to create stream, usually because client used an unknown + // compression algorithm. Nothing further to do. + return + } + if timeoutErr != nil { + _ = connCloser.Close(timeoutErr) + return + } + _ = connCloser.Close(h.implementation(ctx, connCloser, info)) +} + +type handlerConfig struct { + CompressionPools map[string]*compressionPool + CompressionNames []string + Codecs map[string]connect.Codec + CompressMinBytes int + Procedure string + Schema any + RequireConnectProtocolHeader bool + IdempotencyLevel connect.IdempotencyLevel + ReadMaxBytes int + SendMaxBytes int + StreamType connect.StreamType +} + +func (c *handlerConfig) newSpec() connect.Spec { + return connect.Spec{ + StreamType: c.StreamType, + IdempotencyLevel: c.IdempotencyLevel, + Schema: c.Schema, + Procedure: c.Procedure, + } +} + +func (c *handlerConfig) newProtocolHandlers() []protocolHandler { + protocols := []protocol{ + &protocolConnect{}, + &protocolGRPC{web: false}, + &protocolGRPC{web: true}, + } + handlers := make([]protocolHandler, 0, len(protocols)) + codecs := newReadOnlyCodecs(c.Codecs) + compressors := newReadOnlyCompressionPools( + c.CompressionPools, + c.CompressionNames, + ) + for _, protocol := range protocols { + handlers = append(handlers, protocol.NewHandler(&protocolHandlerParams{ + spec: c.newSpec(), + Codecs: codecs, + CompressionPools: compressors, + CompressMinBytes: c.CompressMinBytes, + ReadMaxBytes: c.ReadMaxBytes, + SendMaxBytes: c.SendMaxBytes, + RequireConnectProtocolHeader: c.RequireConnectProtocolHeader, + IdempotencyLevel: c.IdempotencyLevel, + })) + } + return handlers +} diff --git a/handler_example_test.go b/connecthttp/handler_example_test.go similarity index 74% rename from handler_example_test.go rename to connecthttp/handler_example_test.go index 1ac6e71e..914aedff 100644 --- a/handler_example_test.go +++ b/connecthttp/handler_example_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect_test +package connecthttp_test import ( "context" @@ -20,9 +20,10 @@ import ( "io" "net/http" - connect "connectrpc.com/connect" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" ) // ExamplePingServer implements some trivial business logic. The Protobuf @@ -43,19 +44,24 @@ func (*ExamplePingServer) Ping( } // Sum implements pingv1connect.PingServiceHandler. -func (p *ExamplePingServer) Sum(ctx context.Context, stream *connect.ClientStream[pingv1.SumRequest]) (*pingv1.SumResponse, error) { +func (p *ExamplePingServer) Sum(_ context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { var sum int64 - for stream.Receive() { - sum += stream.Msg().GetNumber() - } - if stream.Err() != nil { - return nil, stream.Err() + for { + msg, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, err + } + sum += msg.GetNumber() } + return &pingv1.SumResponse{Sum: sum}, nil } // CountUp implements pingv1connect.PingServiceHandler. -func (p *ExamplePingServer) CountUp(ctx context.Context, request *pingv1.CountUpRequest, stream *connect.ServerStream[pingv1.CountUpResponse]) error { +func (p *ExamplePingServer) CountUp(_ context.Context, request *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { for number := int64(1); number <= request.GetNumber(); number++ { if err := stream.Send(&pingv1.CountUpResponse{Number: number}); err != nil { return err @@ -65,7 +71,7 @@ func (p *ExamplePingServer) CountUp(ctx context.Context, request *pingv1.CountUp } // CumSum implements pingv1connect.PingServiceHandler. -func (p *ExamplePingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error { +func (p *ExamplePingServer) CumSum(_ context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { var sum int64 for { msg, err := stream.Receive() @@ -87,11 +93,12 @@ func Example_handler() { // (for example, net/http's StripPrefix). Each handler automatically supports // the Connect, gRPC, and gRPC-Web protocols. mux := http.NewServeMux() - mux.Handle( - pingv1connect.NewPingServiceHandler( - &ExamplePingServer{}, // our business logic - ), - ) + server := connect.NewServer() + + pingv1connect.RegisterPingServiceHandler(server, &ExamplePingServer{}) + connecthttp. // our business logic + Mount(mux, server) + // You can serve gRPC's health and server reflection APIs using // connectrpc.com/grpchealth and connectrpc.com/grpcreflect. _ = http.ListenAndServeTLS( diff --git a/handler_ext_test.go b/connecthttp/handler_ext_test.go similarity index 56% rename from handler_ext_test.go rename to connecthttp/handler_ext_test.go index 62fa929a..eb2c65c2 100644 --- a/handler_ext_test.go +++ b/connecthttp/handler_ext_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect_test +package connecthttp_test import ( "bytes" @@ -20,18 +20,18 @@ import ( "encoding/binary" "encoding/json" "errors" - "fmt" "io" "net/http" "strings" "sync" "testing" - connect "connectrpc.com/connect" - "connectrpc.com/connect/internal/assert" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/generics/connect/ping/v1/pingv1connect" - "connectrpc.com/connect/internal/memhttp/memhttptest" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + "connectrpc.com/connect/v2/internal/assert" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2/internal/memhttp/memhttptest" "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/reflect/protoregistry" "google.golang.org/protobuf/types/dynamicpb" @@ -39,11 +39,12 @@ import ( func TestHandler_ServeHTTP(t *testing.T) { t.Parallel() - path, handler := pingv1connect.NewPingServiceHandler(successPingServer{}) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, successPingServer{}) prefixed := http.NewServeMux() - prefixed.Handle(path, handler) + connecthttp.Mount(prefixed, srv) mux := http.NewServeMux() - mux.Handle(path, handler) + connecthttp.Mount(mux, srv) mux.Handle("/prefixed/", http.StripPrefix("/prefixed", prefixed)) const pingProcedure = pingv1connect.PingServicePingProcedure const sumProcedure = pingv1connect.PingServiceSumProcedure @@ -173,14 +174,11 @@ func TestHandler_ServeHTTP(t *testing.T) { assert.Equal(t, resp.Header.Get("Accept-Post"), strings.Join([]string{ "application/grpc", "application/grpc+json", - "application/grpc+json; charset=utf-8", "application/grpc+proto", "application/grpc-web", "application/grpc-web+json", - "application/grpc-web+json; charset=utf-8", "application/grpc-web+proto", "application/json", - "application/json; charset=utf-8", "application/proto", }, ", ")) }) @@ -248,7 +246,9 @@ func TestHandler_ServeHTTP(t *testing.T) { func TestHandlerMaliciousPrefix(t *testing.T) { t.Parallel() mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(successPingServer{})) + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, successPingServer{}) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) const ( @@ -287,56 +287,45 @@ func TestHandlerMaliciousPrefix(t *testing.T) { func TestDynamicHandler(t *testing.T) { t.Parallel() - initializer := func(spec connect.Spec, msg any) error { - dynamic, ok := msg.(*dynamicpb.Message) - if !ok { - return nil - } - desc, ok := spec.Schema.(protoreflect.MethodDescriptor) - if !ok { - return fmt.Errorf("invalid schema type %T for %T message", spec.Schema, dynamic) - } - if spec.IsClient { - *dynamic = *dynamicpb.NewMessage(desc.Output()) - } else { - *dynamic = *dynamicpb.NewMessage(desc.Input()) - } - return nil - } t.Run("unary", func(t *testing.T) { t.Parallel() desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.Ping") assert.Nil(t, err) methodDesc, ok := desc.(protoreflect.MethodDescriptor) assert.True(t, ok) - dynamicPing := func(_ context.Context, req *connect.Request[dynamicpb.Message]) (*connect.Response[dynamicpb.Message], error) { - got := req.Msg.Get(methodDesc.Input().Fields().ByName("number")).Int() - msg := dynamicpb.NewMessage(methodDesc.Output()) - msg.Set( - methodDesc.Output().Fields().ByName("number"), - protoreflect.ValueOfInt64(got), - ) - return connect.NewResponse(msg), nil - } mux := http.NewServeMux() - mux.Handle("/connect.ping.v1.PingService/Ping", - connect.NewUnaryHandler( - "/connect.ping.v1.PingService/Ping", - dynamicPing, - connect.WithSchema(methodDesc), - connect.WithIdempotency(connect.IdempotencyNoSideEffects), - connect.WithRequestInitializer(initializer), - ), - ) + srv := connect.NewServer() + srv.Register(connect.Method{ + Spec: connect.Spec{ + Procedure: "/connect.ping.v1.PingService/Ping", + Schema: methodDesc, + StreamType: connect.StreamTypeUnary, + IdempotencyLevel: connect.IdempotencyNoSideEffects, + }, + Handler: func(_ context.Context, _ connect.Spec, stream connect.ServerStream) error { + req := dynamicpb.NewMessage(methodDesc.Input()) + if err := stream.Receive(req); err != nil { + return err + } + got := req.Get(methodDesc.Input().Fields().ByName("number")).Int() + msg := dynamicpb.NewMessage(methodDesc.Output()) + msg.Set( + methodDesc.Output().Fields().ByName("number"), + protoreflect.ValueOfInt64(got), + ) + return stream.Send(msg) + }, + }) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - rsp, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{ + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + rsp, err := client.Ping(t.Context(), &pingv1.PingRequest{ Number: 42, - })) + }) if !assert.Nil(t, err) { return } - got := rsp.Msg.Number + got := rsp.Number assert.Equal(t, got, 42) }) t.Run("clientStream", func(t *testing.T) { @@ -345,81 +334,48 @@ func TestDynamicHandler(t *testing.T) { assert.Nil(t, err) methodDesc, ok := desc.(protoreflect.MethodDescriptor) assert.True(t, ok) - dynamicSum := func(_ context.Context, stream *connect.ClientStream[dynamicpb.Message]) (*connect.Response[dynamicpb.Message], error) { - var sum int64 - for stream.Receive() { - got := stream.Msg().Get( - methodDesc.Input().Fields().ByName("number"), - ).Int() - sum += got - } - msg := dynamicpb.NewMessage(methodDesc.Output()) - msg.Set( - methodDesc.Output().Fields().ByName("sum"), - protoreflect.ValueOfInt64(sum), - ) - return connect.NewResponse(msg), nil - } mux := http.NewServeMux() - mux.Handle("/connect.ping.v1.PingService/Sum", - connect.NewClientStreamHandler( - "/connect.ping.v1.PingService/Sum", - dynamicSum, - connect.WithSchema(methodDesc), - connect.WithRequestInitializer(initializer), - ), - ) + srv := connect.NewServer() + srv.Register(connect.Method{ + Spec: connect.Spec{ + Procedure: "/connect.ping.v1.PingService/Sum", + Schema: methodDesc, + StreamType: connect.StreamTypeClient, + }, + Handler: func(_ context.Context, _ connect.Spec, stream connect.ServerStream) error { + var sum int64 + for { + msg := dynamicpb.NewMessage(methodDesc.Input()) + if err := stream.Receive(msg); err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + sum += msg.Get(methodDesc.Input().Fields().ByName("number")).Int() + } + out := dynamicpb.NewMessage(methodDesc.Output()) + out.Set( + methodDesc.Output().Fields().ByName("sum"), + protoreflect.ValueOfInt64(sum), + ) + return stream.Send(out) + }, + }) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - stream := client.Sum(t.Context()) - assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 42})) - assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 42})) - rsp, err := stream.CloseAndReceive() - if !assert.Nil(t, err) { - return + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + stream, err := client.Sum(t.Context()) + if err != nil { + t.Fatal(err) } - assert.Equal(t, rsp.Msg.Sum, 42*2) - }) - t.Run("clientStreamSimple", func(t *testing.T) { - t.Parallel() - desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.Sum") - assert.Nil(t, err) - methodDesc, ok := desc.(protoreflect.MethodDescriptor) - assert.True(t, ok) - dynamicSum := func(_ context.Context, stream *connect.ClientStream[dynamicpb.Message]) (*dynamicpb.Message, error) { - var sum int64 - for stream.Receive() { - got := stream.Msg().Get( - methodDesc.Input().Fields().ByName("number"), - ).Int() - sum += got - } - msg := dynamicpb.NewMessage(methodDesc.Output()) - msg.Set( - methodDesc.Output().Fields().ByName("sum"), - protoreflect.ValueOfInt64(sum), - ) - return msg, nil - } - mux := http.NewServeMux() - mux.Handle("/connect.ping.v1.PingService/Sum", - connect.NewClientStreamHandlerSimple( - "/connect.ping.v1.PingService/Sum", - dynamicSum, - connect.WithSchema(methodDesc), - connect.WithRequestInitializer(initializer), - ), - ) - server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - stream := client.Sum(t.Context()) assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 42})) assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 42})) rsp, err := stream.CloseAndReceive() if !assert.Nil(t, err) { return } - assert.Equal(t, rsp.Msg.Sum, 42*2) + assert.Equal(t, rsp.Sum, 42*2) }) t.Run("serverStream", func(t *testing.T) { t.Parallel() @@ -427,42 +383,54 @@ func TestDynamicHandler(t *testing.T) { assert.Nil(t, err) methodDesc, ok := desc.(protoreflect.MethodDescriptor) assert.True(t, ok) - dynamicCountUp := func(_ context.Context, req *connect.Request[dynamicpb.Message], stream *connect.ServerStream[dynamicpb.Message]) error { - number := req.Msg.Get(methodDesc.Input().Fields().ByName("number")).Int() - for i := int64(1); i <= number; i++ { - msg := dynamicpb.NewMessage(methodDesc.Output()) - msg.Set( - methodDesc.Output().Fields().ByName("number"), - protoreflect.ValueOfInt64(i), - ) - if err := stream.Send(msg); err != nil { + mux := http.NewServeMux() + srv := connect.NewServer() + srv.Register(connect.Method{ + Spec: connect.Spec{ + Procedure: "/connect.ping.v1.PingService/CountUp", + Schema: methodDesc, + StreamType: connect.StreamTypeServer, + }, + Handler: func(_ context.Context, _ connect.Spec, stream connect.ServerStream) error { + req := dynamicpb.NewMessage(methodDesc.Input()) + if err := stream.Receive(req); err != nil { return err } - } - return nil - } - mux := http.NewServeMux() - mux.Handle("/connect.ping.v1.PingService/CountUp", - connect.NewServerStreamHandler( - "/connect.ping.v1.PingService/CountUp", - dynamicCountUp, - connect.WithSchema(methodDesc), - connect.WithRequestInitializer(initializer), - ), - ) + number := req.Get(methodDesc.Input().Fields().ByName("number")).Int() + for i := int64(1); i <= number; i++ { + msg := dynamicpb.NewMessage(methodDesc.Output()) + msg.Set( + methodDesc.Output().Fields().ByName("number"), + protoreflect.ValueOfInt64(i), + ) + if err := stream.Send(msg); err != nil { + return err + } + } + return nil + }, + }) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - stream, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{ + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{ Number: 2, - })) + }) if !assert.Nil(t, err) { return } var sum int64 - for stream.Receive() { - sum += stream.Msg().Number + for { + msg, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + t.Fatal(err) + } + sum += msg.Number } - assert.Nil(t, stream.Err()) + assert.Nil(t, stream.Close()) assert.Equal(t, sum, 3) // 1 + 2 }) t.Run("bidi", func(t *testing.T) { @@ -471,100 +439,51 @@ func TestDynamicHandler(t *testing.T) { assert.Nil(t, err) methodDesc, ok := desc.(protoreflect.MethodDescriptor) assert.True(t, ok) - dynamicCumSum := func( - _ context.Context, - stream *connect.BidiStream[dynamicpb.Message, dynamicpb.Message], - ) error { - var sum int64 - for { - msg, err := stream.Receive() - if errors.Is(err, io.EOF) { - return nil - } else if err != nil { - return err - } - got := msg.Get(methodDesc.Input().Fields().ByName("number")).Int() - sum += got - out := dynamicpb.NewMessage(methodDesc.Output()) - out.Set( - methodDesc.Output().Fields().ByName("sum"), - protoreflect.ValueOfInt64(sum), - ) - if err := stream.Send(out); err != nil { - return err - } - } - } mux := http.NewServeMux() - mux.Handle("/connect.ping.v1.PingService/CumSum", - connect.NewBidiStreamHandler( - "/connect.ping.v1.PingService/CumSum", - dynamicCumSum, - connect.WithSchema(methodDesc), - connect.WithRequestInitializer(initializer), - ), - ) + srv := connect.NewServer() + srv.Register(connect.Method{ + Spec: connect.Spec{ + Procedure: "/connect.ping.v1.PingService/CumSum", + Schema: methodDesc, + StreamType: connect.StreamTypeBidi, + }, + Handler: func(_ context.Context, _ connect.Spec, stream connect.ServerStream) error { + var sum int64 + for { + msg := dynamicpb.NewMessage(methodDesc.Input()) + if err := stream.Receive(msg); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + sum += msg.Get(methodDesc.Input().Fields().ByName("number")).Int() + out := dynamicpb.NewMessage(methodDesc.Output()) + out.Set( + methodDesc.Output().Fields().ByName("sum"), + protoreflect.ValueOfInt64(sum), + ) + if err := stream.Send(out); err != nil { + return err + } + } + }, + }) + connecthttp.Mount(mux, srv) server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - stream := client.CumSum(t.Context()) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()))) + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatal(err) + } assert.Nil(t, stream.Send(&pingv1.CumSumRequest{Number: 1})) msg, err := stream.Receive() if !assert.Nil(t, err) { return } assert.Equal(t, msg.Sum, int64(1)) - assert.Nil(t, stream.CloseRequest()) - assert.Nil(t, stream.CloseResponse()) - }) - t.Run("option", func(t *testing.T) { - t.Parallel() - desc, err := protoregistry.GlobalFiles.FindDescriptorByName("connect.ping.v1.PingService.Ping") - assert.Nil(t, err) - methodDesc, ok := desc.(protoreflect.MethodDescriptor) - assert.True(t, ok) - dynamicPing := func(_ context.Context, req *connect.Request[dynamicpb.Message]) (*connect.Response[dynamicpb.Message], error) { - got := req.Msg.Get(methodDesc.Input().Fields().ByName("number")).Int() - msg := dynamicpb.NewMessage(methodDesc.Output()) - msg.Set( - methodDesc.Output().Fields().ByName("number"), - protoreflect.ValueOfInt64(got), - ) - return connect.NewResponse(msg), nil - } - optionCalled := false - mux := http.NewServeMux() - mux.Handle("/connect.ping.v1.PingService/Ping", - connect.NewUnaryHandler( - "/connect.ping.v1.PingService/Ping", - dynamicPing, - connect.WithSchema(methodDesc), - connect.WithIdempotency(connect.IdempotencyNoSideEffects), - connect.WithRequestInitializer( - func(spec connect.Spec, msg any) error { - assert.NotNil(t, spec) - assert.NotNil(t, msg) - dynamic, ok := msg.(*dynamicpb.Message) - if !assert.True(t, ok) { - return fmt.Errorf("unexpected message type: %T", msg) - } - *dynamic = *dynamicpb.NewMessage(methodDesc.Input()) - optionCalled = true - return nil - }, - ), - ), - ) - server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - rsp, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{ - Number: 42, - })) - if !assert.Nil(t, err) { - return - } - got := rsp.Msg.Number - assert.Equal(t, got, 42) - assert.True(t, optionCalled) + assert.Nil(t, stream.CloseSend()) + assert.Nil(t, stream.Close()) }) } @@ -572,6 +491,6 @@ type successPingServer struct { pingv1connect.UnimplementedPingServiceHandler } -func (successPingServer) Ping(context.Context, *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { - return &connect.Response[pingv1.PingResponse]{}, nil +func (successPingServer) Ping(context.Context, *pingv1.PingRequest) (*pingv1.PingResponse, error) { + return &pingv1.PingResponse{}, nil } diff --git a/header.go b/connecthttp/header.go similarity index 65% rename from header.go rename to connecthttp/header.go index 35dbcebc..d89384cd 100644 --- a/header.go +++ b/connecthttp/header.go @@ -12,11 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( - "encoding/base64" "net/http" + + "connectrpc.com/connect/v2" ) //nolint:gochecknoglobals @@ -25,8 +26,6 @@ var protocolHeaders = map[string]struct{}{ headerContentType: {}, headerContentLength: {}, headerContentEncoding: {}, - headerHost: {}, - headerUserAgent: {}, headerTrailer: {}, headerDate: {}, // Connect headers. @@ -45,32 +44,6 @@ var protocolHeaders = map[string]struct{}{ grpcHeaderDetails: {}, } -// EncodeBinaryHeader base64-encodes the data. It always emits unpadded values. -// -// In the Connect, gRPC, and gRPC-Web protocols, binary headers must have keys -// ending in "-Bin". -func EncodeBinaryHeader(data []byte) string { - // gRPC specification says that implementations should emit unpadded values. - return base64.RawStdEncoding.EncodeToString(data) -} - -// DecodeBinaryHeader base64-decodes the data. It can decode padded or unpadded -// values. Following usual HTTP semantics, multiple base64-encoded values may -// be joined with a comma. When receiving such comma-separated values, split -// them with [strings.Split] before calling DecodeBinaryHeader. -// -// Binary headers sent using the Connect, gRPC, and gRPC-Web protocols have -// keys ending in "-Bin". -func DecodeBinaryHeader(data string) ([]byte, error) { - if len(data)%4 != 0 { - // Data definitely isn't padded. - return base64.RawStdEncoding.DecodeString(data) - } - // Either the data was padded, or padding wasn't necessary. In both cases, - // the padding-aware decoder works. - return base64.StdEncoding.DecodeString(data) -} - func mergeHeaders(into, from http.Header) { for key, vals := range from { if len(vals) == 0 { @@ -83,22 +56,6 @@ func mergeHeaders(into, from http.Header) { } } -// mergeNonProtocolHeaders merges headers excluding protocol headers defined in -// protocolHeaders. -func mergeNonProtocolHeaders(into, from http.Header) { - for key, vals := range from { - if len(vals) == 0 { - // For response trailers, net/http will pre-populate entries - // with nil values based on the "Trailer" header. But if there - // are no actual values for those keys, we skip them. - continue - } - if _, isProtocolHeader := protocolHeaders[key]; !isProtocolHeader { - into[key] = append(into[key], vals...) - } - } -} - // getHeaderCanonical is a shortcut for Header.Get() which // bypasses the CanonicalMIMEHeaderKey operation when we // know the key is already in canonical form. @@ -139,3 +96,21 @@ func setHeaderCanonical(h http.Header, key, value string) { func delHeaderCanonical(h http.Header, key string) { delete(h, key) } + +// toHTTPHeader merges header into the HTTP header h, excluding protocol +// headers so users can't override them. +func toHTTPHeader(h http.Header, header *connect.Header) { + for key, vals := range header.All() { + if _, isProtocolHeader := protocolHeaders[key]; isProtocolHeader { + continue + } + h[key] = append(h[key], vals...) + } +} + +// fromHTTPHeader copies the HTTP header h into header. +func fromHTTPHeader(header *connect.Header, h http.Header) { + for key, vals := range h { + header.SetValues(key, vals) + } +} diff --git a/header_test.go b/connecthttp/header_test.go similarity index 87% rename from header_test.go rename to connecthttp/header_test.go index 6199ce6e..9fc5b4d4 100644 --- a/header_test.go +++ b/connecthttp/header_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "bytes" @@ -20,14 +20,15 @@ import ( "testing" "testing/quick" - "connectrpc.com/connect/internal/assert" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/internal/assert" ) func TestBinaryEncodingQuick(t *testing.T) { t.Parallel() roundtrip := func(binary []byte) bool { - encoded := EncodeBinaryHeader(binary) - decoded, err := DecodeBinaryHeader(encoded) + encoded := connect.EncodeBinaryHeader(binary) + decoded, err := connect.DecodeBinaryHeader(encoded) if err != nil { // We want to abort immediately. Don't use our assert package. t.Fatalf("decode error: %v", err) diff --git a/connecthttp/interceptor_example_test.go b/connecthttp/interceptor_example_test.go new file mode 100644 index 00000000..d2213be8 --- /dev/null +++ b/connecthttp/interceptor_example_test.go @@ -0,0 +1,88 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp_test + +import ( + "context" + "log" + "os" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" +) + +func Example_clientInterceptor() { + logger := log.New(os.Stdout, "" /* prefix */, 0 /* flags */) + loggingInterceptor := func(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + logger.Println("calling:", spec.Procedure) + return next(ctx, spec) + } + } + client := pingv1connect.NewPingServiceClient( + connect.NewClient(connecthttp.NewTransport(examplePingServer.Client(), examplePingServer.URL()), loggingInterceptor), + ) + if _, err := client.Ping(context.Background(), &pingv1.PingRequest{Number: 42}); err != nil { + logger.Println("error:", err) + return + } + + // Output: + // calling: /connect.ping.v1.PingService/Ping +} + +func Example_interceptors() { + logger := log.New(os.Stdout, "" /* prefix */, 0 /* flags */) + logInterceptor := func(name string) connect.ClientInterceptor { + return func(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + logger.Printf("%s interceptor: before call", name) + stream, err := next(ctx, spec) + if err != nil { + return nil, err + } + return &loggingClientStream{ClientStream: stream, name: name, logger: logger}, nil + } + } + } + client := pingv1connect.NewPingServiceClient( + connect.NewClient(connecthttp.NewTransport(examplePingServer.Client(), examplePingServer.URL()), logInterceptor("outer"), logInterceptor("inner")), + ) + if _, err := client.Ping(context.Background(), &pingv1.PingRequest{}); err != nil { + logger.Println("error:", err) + return + } + + // Output: + // outer interceptor: before call + // inner interceptor: before call + // inner interceptor: after call + // outer interceptor: after call +} + +type loggingClientStream struct { + connect.ClientStream + + name string + logger *log.Logger +} + +func (s *loggingClientStream) Close() error { + err := s.ClientStream.Close() + s.logger.Printf("%s interceptor: after call", s.name) + return err +} diff --git a/connecthttp/interceptor_ext_test.go b/connecthttp/interceptor_ext_test.go new file mode 100644 index 00000000..6b6a0f6e --- /dev/null +++ b/connecthttp/interceptor_ext_test.go @@ -0,0 +1,733 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp_test + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "sync/atomic" + "testing" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + "connectrpc.com/connect/v2/internal/assert" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2/internal/memhttp" + "connectrpc.com/connect/v2/internal/memhttp/memhttptest" +) + +func TestNewClientContextInInterceptor(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + srv := connect.NewServer() + + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + + server := memhttptest.NewServer(t, mux) + t.Run("first_interceptor", func(t *testing.T) { + t.Parallel() + createClient := func(counter1 *atomic.Int32, counter2 *atomic.Int32) pingv1connect.PingServiceClient { + return pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL()), + (&contextInterceptor{count: counter1, createNewContext: true}).ClientInterceptor, + (&contextInterceptor{count: counter2}).ClientInterceptor, + )) + } + t.Run("unary", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + resp, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 10}) + assert.Nil(t, err) + assert.NotNil(t, resp) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + }) + t.Run("server_stream", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 10}) + assert.Nil(t, err) + assert.NotNil(t, stream) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + assert.Nil(t, stream.Close()) + }) + t.Run("client_stream", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + stream, err := client.Sum(t.Context()) + assert.Nil(t, err) + assert.NotNil(t, stream) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 1})) + resp, err := stream.CloseAndReceive() + assert.Nil(t, err) + assert.NotNil(t, resp) + }) + t.Run("bidi_stream", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + stream, err := client.CumSum(t.Context()) + assert.Nil(t, err) + assert.NotNil(t, stream) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + assert.Nil(t, stream.CloseSend()) + assert.Nil(t, stream.Close()) + }) + }) + t.Run("subsequent_interceptor", func(t *testing.T) { + t.Parallel() + createClient := func(counter1 *atomic.Int32, counter2 *atomic.Int32) pingv1connect.PingServiceClient { + return pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL()), + (&contextInterceptor{count: counter1}).ClientInterceptor, + (&contextInterceptor{count: counter2, createNewContext: true}).ClientInterceptor, + )) + } + t.Run("unary", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + resp, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 10}) + assert.Nil(t, err) + assert.NotNil(t, resp) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + }) + t.Run("server_stream", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 10}) + assert.Nil(t, err) + assert.NotNil(t, stream) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + assert.Nil(t, stream.Close()) + }) + t.Run("client_stream", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + stream, err := client.Sum(t.Context()) + assert.Nil(t, err) + assert.NotNil(t, stream) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + assert.Nil(t, stream.Send(&pingv1.SumRequest{Number: 1})) + resp, err := stream.CloseAndReceive() + assert.Nil(t, err) + assert.NotNil(t, resp) + }) + t.Run("bidi_stream", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + stream, err := client.CumSum(t.Context()) + assert.Nil(t, err) + assert.NotNil(t, stream) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + assert.Nil(t, stream.CloseSend()) + assert.Nil(t, stream.Close()) + }) + }) + t.Run("sidequest_succeeds", func(t *testing.T) { + t.Parallel() + // These tests create a new context but it is used to issue a separate/new request and not reused in the + // interceptor chain. So, all interceptors should fire and no errors should be returned. + createClient := func(counter1 *atomic.Int32, counter2 *atomic.Int32) pingv1connect.PingServiceClient { + opts := []connect.ClientInterceptor{ + newSideQuestInterceptor(t, counter1, server).ClientInterceptor, + newSideQuestInterceptor(t, counter2, server).ClientInterceptor, + } + return pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL()), opts...), + ) + } + t.Run("unary", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + + resp, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 10}) + assert.NotNil(t, resp) + assert.Nil(t, err) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + }) + t.Run("server_stream", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + + stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 10}) + assert.NotNil(t, stream) + assert.Nil(t, err) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + assert.Nil(t, stream.Close()) + }) + t.Run("client_stream", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + + stream, err := client.Sum(t.Context()) + assert.NotNil(t, stream) + assert.Nil(t, err) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + resp, err := stream.CloseAndReceive() + assert.Nil(t, err) + assert.NotNil(t, resp) + }) + t.Run("bidi_stream", func(t *testing.T) { + t.Parallel() + var clientCounter1, clientCounter2 atomic.Int32 + client := createClient(&clientCounter1, &clientCounter2) + + stream, err := client.CumSum(t.Context()) + assert.Nil(t, err) + assert.NotNil(t, stream) + + assert.Nil(t, stream.CloseSend()) + assert.Nil(t, stream.Close()) + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + }) + }) +} + +func TestOnionOrderingEndToEnd(t *testing.T) { + t.Parallel() + // Helper function: returns a function that asserts that there's some value + // set for header "expect", and adds a value for header "add". + newInspector := func(expect, add string) func(connect.Spec, *connect.Header) { + return func(spec connect.Spec, header *connect.Header) { + if expect != "" { + assert.True( + t, + header.Has(expect), + assert.Sprintf("%s: header %q missing: %v", spec.Procedure, expect, header), + ) + } + header.Set(add, "v") + } + } + // Helper function: asserts that there's a value present for header keys + // "one", "two", "three", and "four". + assertAllPresent := func(spec connect.Spec, header *connect.Header) { + for _, key := range []string{"one", "two", "three", "four"} { + assert.True( + t, + header.Has(key), + assert.Sprintf("%s: checking all headers, %q missing: %v", spec.Procedure, key, header), + ) + } + } + + var clientCounter1, clientCounter2, clientCounter3, handlerCounter1, handlerCounter2, handlerCounter3 atomic.Int32 + + // The client and handler interceptor onions are the meat of the test. The + // order of interceptor execution must be the same for unary and streaming + // procedures. + // + // Requests should fall through the client onion from top to bottom, traverse + // the network, and then fall through the handler onion from top to bottom. + // Responses should climb up the handler onion, traverse the network, and + // then climb up the client onion. + // + // The request and response sides of this onion are numbered to make the + // intended order clear. + clientOnion1 := newHeaderInterceptor( + &clientCounter1, + nil, // 1 (start). request: no-op + assertAllPresent, // 12 (end). response: check "one"-"four" + ) + clientOnion2 := newHeaderInterceptor( + &clientCounter2, + newInspector("", "one"), // 2. request: add header "one" + newInspector("three", "four"), // 11. response: check "three", add "four" + ) + clientOnion3 := newHeaderInterceptor( + &clientCounter3, + newInspector("one", "two"), // 3. request: check "one", add "two" + newInspector("two", "three"), // 10. response: check "two", add "three" + ) + handlerOnion1 := newHeaderInterceptor( + &handlerCounter1, + newInspector("two", "three"), // 4. request: check "two", add "three" + newInspector("one", "two"), // 9. response: check "one", add "two" + ) + handlerOnion2 := newHeaderInterceptor( + &handlerCounter2, + newInspector("three", "four"), // 5. request: check "three", add "four" + newInspector("", "one"), // 8. response: add "one" + ) + handlerOnion3 := newHeaderInterceptor( + &handlerCounter3, + assertAllPresent, // 6. request: check "one"-"four" + nil, // 7. response: no-op + ) + + mux := http.NewServeMux() + srv := connect.NewServer( + handlerOnion1.ServerInterceptor, + handlerOnion2.ServerInterceptor, + handlerOnion3.ServerInterceptor, + ) + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + + server := memhttptest.NewServer(t, mux) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL()), + clientOnion1.ClientInterceptor, + clientOnion2.ClientInterceptor, + clientOnion3.ClientInterceptor, + )) + + _, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 10}) + assert.Nil(t, err) + + // make sure the interceptors were actually invoked + assert.Equal(t, int32(1), clientCounter1.Load()) + assert.Equal(t, int32(1), clientCounter2.Load()) + assert.Equal(t, int32(1), clientCounter3.Load()) + assert.Equal(t, int32(1), handlerCounter1.Load()) + assert.Equal(t, int32(1), handlerCounter2.Load()) + assert.Equal(t, int32(1), handlerCounter3.Load()) + + responses, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 10}) + assert.Nil(t, err) + var sum int64 + for { + msg, err := responses.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + t.Fatal(err) + } + sum += msg.GetNumber() + } + assert.Equal(t, sum, 55) + assert.Nil(t, responses.Close()) + + // make sure the interceptors were invoked again + assert.Equal(t, int32(2), clientCounter1.Load()) + assert.Equal(t, int32(2), clientCounter2.Load()) + assert.Equal(t, int32(2), clientCounter3.Load()) + assert.Equal(t, int32(2), handlerCounter1.Load()) + assert.Equal(t, int32(2), handlerCounter2.Load()) + assert.Equal(t, int32(2), handlerCounter3.Load()) +} + +func TestEmptyUnaryInterceptorFunc(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + clientInterceptor := func(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + return next(ctx, spec) + } + } + serverInterceptor := func(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + return next(ctx, spec, stream) + } + } + srv := connect.NewServer(serverInterceptor) + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + connectClient := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), server.URL()), clientInterceptor)) + _, err := connectClient.Ping(t.Context(), &pingv1.PingRequest{}) + assert.Nil(t, err) + sumStream, err := connectClient.Sum(t.Context()) + if err != nil { + t.Fatal(err) + } + assert.Nil(t, sumStream.Send(&pingv1.SumRequest{Number: 1})) + resp, err := sumStream.CloseAndReceive() + assert.Nil(t, err) + assert.NotNil(t, resp) + countUpStream, err := connectClient.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 1}) + assert.Nil(t, err) + for { + msg, err := countUpStream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + t.Fatal(err) + } + assert.NotNil(t, msg) + } + assert.Nil(t, countUpStream.Close()) +} + +func TestReusedCallInfoResetsTransportFields(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + + assertFresh := func(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + info, _ := connect.CallInfoForClientContext(ctx) + assert.Nil(t, info.TransportInfo) + assert.Equal(t, info.Codec, "") + assert.Equal(t, info.PeerAddr, "") + assert.Equal(t, info.ResponseHeader().Len(), 0) + assert.Equal(t, info.ResponseTrailer().Len(), 0) + return next(ctx, spec) + } + } + client := pingv1connect.NewPingServiceClient(connect.NewClient( + connecthttp.NewTransport(server.Client(), server.URL()), + assertFresh, + )) + + // Both calls share one ctx, so the second reuses the first call's + // CallInfo, and the interceptor asserts it was reset at dispatch. + ctx, info := connect.NewClientContext(t.Context()) + for range 2 { + _, err := client.Ping(ctx, &pingv1.PingRequest{Number: 1}) + assert.Nil(t, err) + assert.NotNil(t, info.TransportInfo) + assert.NotEqual(t, info.Codec, "") + } +} + +func TestInterceptorFuncAccessingHTTPMethod(t *testing.T) { + t.Parallel() + clientChecker := &httpMethodChecker{} + handlerChecker := &httpMethodChecker{} + + mux := http.NewServeMux() + srv := connect.NewServer(handlerChecker.ServerInterceptor) + pingv1connect.RegisterPingServiceHandler(srv, pingServer{}) + connecthttp.Mount(mux, srv) + + server := memhttptest.NewServer(t, mux) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL()), clientChecker.ClientInterceptor), + ) + + _, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 10}) + assert.Nil(t, err) + + // make sure interceptor was invoked + assert.Equal(t, int32(1), clientChecker.count.Load()) + assert.Equal(t, int32(1), handlerChecker.count.Load()) + + responses, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 10}) + assert.Nil(t, err) + var sum int64 + for { + msg, err := responses.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + t.Fatal(err) + } + sum += msg.GetNumber() + } + assert.Equal(t, sum, 55) + assert.Nil(t, responses.Close()) + + // make sure interceptor was invoked again + assert.Equal(t, int32(2), clientChecker.count.Load()) + assert.Equal(t, int32(2), handlerChecker.count.Load()) +} + +func TestHandlerErrorResponseNilInInterceptor(t *testing.T) { + t.Parallel() + handlerErr := connect.NewError(connect.CodeInternal, "handler error") + var sawNilResponse atomic.Bool + interceptor := func(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + stream, err := next(ctx, spec) + if err != nil { + return nil, err + } + return &nilResponseInspector{ClientStream: stream, sawNil: &sawNilResponse}, nil + } + } + mux := http.NewServeMux() + srv := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(srv, &pluggablePingServer{ + ping: func(context.Context, *pingv1.PingRequest) (*pingv1.PingResponse, error) { + return nil, handlerErr + }, + }) + connecthttp.Mount(mux, srv) + server := memhttptest.NewServer(t, mux) + client := pingv1connect.NewPingServiceClient(connect.NewClient( + connecthttp.NewTransport(server.Client(), server.URL()), interceptor, + )) + _, err := client.Ping(t.Context(), &pingv1.PingRequest{}) + assert.NotNil(t, err) + assert.True(t, sawNilResponse.Load()) +} + +// nilResponseInspector records that no response message was received, which +// happens when the handler returns an error. +type nilResponseInspector struct { + connect.ClientStream + + sawNil *atomic.Bool +} + +func (s *nilResponseInspector) Receive(msg any) error { + err := s.ClientStream.Receive(msg) + if err != nil { + s.sawNil.Store(true) + } + return err +} + +// headerInterceptor makes it easier to write interceptors that inspect or +// mutate HTTP headers. It applies the same logic to unary and streaming +// procedures, wrapping the send or receive side of the stream as appropriate. +// +// It's useful as a testing harness to make sure that we're chaining +// interceptors in the correct order. +type headerInterceptor struct { + counter *atomic.Int32 + inspectRequestHeader func(connect.Spec, *connect.Header) + inspectResponseHeader func(connect.Spec, *connect.Header) +} + +// newHeaderInterceptor constructs a headerInterceptor. Nil function pointers +// are treated as no-ops. +func newHeaderInterceptor( + counter *atomic.Int32, + inspectRequestHeader func(connect.Spec, *connect.Header), + inspectResponseHeader func(connect.Spec, *connect.Header), +) *headerInterceptor { + interceptor := headerInterceptor{ + counter: counter, + inspectRequestHeader: inspectRequestHeader, + inspectResponseHeader: inspectResponseHeader, + } + if interceptor.inspectRequestHeader == nil { + interceptor.inspectRequestHeader = func(_ connect.Spec, _ *connect.Header) {} + } + if interceptor.inspectResponseHeader == nil { + interceptor.inspectResponseHeader = func(_ connect.Spec, _ *connect.Header) {} + } + return &interceptor +} + +func (h *headerInterceptor) ClientInterceptor(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + h.counter.Add(1) + info, _ := connect.CallInfoForClientContext(ctx) + h.inspectRequestHeader(spec, info.RequestHeader()) + stream, err := next(ctx, spec) + if err != nil { + return nil, err + } + return &clientStreamInspector{ClientStream: stream, inspect: func() error { + h.inspectResponseHeader(spec, info.ResponseHeader()) + return nil + }}, nil + } +} + +type clientStreamInspector struct { + connect.ClientStream + + inspect func() error +} + +func (s *clientStreamInspector) Close() error { + err := s.ClientStream.Close() + if inspectErr := s.inspect(); inspectErr != nil { + return inspectErr + } + return err +} + +func (h *headerInterceptor) ServerInterceptor(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + h.counter.Add(1) + info, _ := connect.CallInfoForServerContext(ctx) + h.inspectRequestHeader(spec, info.RequestHeader()) + return next(ctx, spec, &headerInspectingHandlerConn{ + ServerStream: stream, + ctx: ctx, + spec: spec, + inspectResponseHeader: h.inspectResponseHeader, + }) + } +} + +type headerInspectingHandlerConn struct { + connect.ServerStream + + ctx context.Context + spec connect.Spec + inspectedResponse bool + inspectResponseHeader func(connect.Spec, *connect.Header) +} + +func (hc *headerInspectingHandlerConn) Send(msg any) error { + if !hc.inspectedResponse { + info, _ := connect.CallInfoForServerContext(hc.ctx) + hc.inspectResponseHeader(hc.spec, info.ResponseHeader()) + hc.inspectedResponse = true + } + return hc.ServerStream.Send(msg) +} + +type httpMethodChecker struct { + count atomic.Int32 +} + +func (h *httpMethodChecker) ClientInterceptor(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + h.count.Add(1) + // method not exposed to streaming interceptor, but that's okay because it's always POST for streams + if spec.StreamType != connect.StreamTypeUnary { + return next(ctx, spec) + } + callInfo, _ := connect.CallInfoForClientContext(ctx) + // TransportInfo is not set until the transport opens the stream. + if httpInfo, ok := callInfo.TransportInfo.(*connecthttp.ClientInfo); ok && httpInfo.HTTPMethod() != "" { + return nil, fmt.Errorf("expected blank HTTP method but instead got %q", httpInfo.HTTPMethod()) + } + stream, err := next(ctx, spec) + if err != nil { + return nil, err + } + return &clientStreamInspector{ClientStream: stream, inspect: func() error { + // NB: In theory, the method could also be GET, not just POST. But for the + // configuration under test, it will always be POST. + httpInfo, _ := callInfo.TransportInfo.(*connecthttp.ClientInfo) + if httpInfo.HTTPMethod() != http.MethodPost { + return fmt.Errorf("expected HTTP method %s but instead got %q", http.MethodPost, httpInfo.HTTPMethod()) + } + return nil + }}, nil + } +} + +func (h *httpMethodChecker) ServerInterceptor(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + h.count.Add(1) + // method not exposed to streaming interceptor, but that's okay because it's always POST for streams + if spec.StreamType == connect.StreamTypeUnary { + // server interceptors see method from the start + // NB: In theory, the method could also be GET, not just POST. But for the + // configuration under test, it will always be POST. + httpInfo, _ := connecthttp.ServerInfoForContext(ctx) + if httpInfo.HTTPMethod() != http.MethodPost { + return fmt.Errorf("expected HTTP method %s but instead got %q", http.MethodPost, httpInfo.HTTPMethod()) + } + } + return next(ctx, spec, stream) + } +} + +type contextInterceptor struct { + count *atomic.Int32 + // Whether the interceptor should derive a new context, which propagates to + // the transport and the rest of the chain. + createNewContext bool +} + +func (h *contextInterceptor) ClientInterceptor(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + h.count.Add(1) + if h.createNewContext { + // This will cause next to return an error + ctx, _ = connect.NewClientContext(ctx) + } + return next(ctx, spec) + } +} + +func (h *contextInterceptor) ServerInterceptor(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + h.count.Add(1) + return next(ctx, spec, stream) + } +} + +type sideQuestInterceptor struct { + count *atomic.Int32 + client pingv1connect.PingServiceClient + t *testing.T +} + +func newSideQuestInterceptor( //nolint:thelper + t *testing.T, + counter *atomic.Int32, + server *memhttp.Server, +) *sideQuestInterceptor { + client := pingv1connect.NewPingServiceClient(connect.NewClient(connecthttp.NewTransport(server.Client(), + server.URL())), + ) + return &sideQuestInterceptor{t: t, client: client, count: counter} +} + +func (s *sideQuestInterceptor) ClientInterceptor(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + s.count.Add(1) + // Create a new client context for the side quest. This should succeed because we aren't + // sending this on through the interceptor chain and reusing this context + newCtx, _ := connect.NewClientContext(ctx) + if spec.StreamType == connect.StreamTypeUnary { + num := int64(42) + resp, err := s.client.Ping(newCtx, &pingv1.PingRequest{Number: num}) + assert.Nil(s.t, err) + assert.Equal(s.t, resp.Number, num) + } else { + responses, err := s.client.CountUp(newCtx, &pingv1.CountUpRequest{Number: 3}) + assert.Nil(s.t, err) + var sum int64 + for { + msg, err := responses.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return next(ctx, spec) + } + sum += msg.GetNumber() + } + assert.Equal(s.t, sum, 6) + assert.Nil(s.t, responses.Close()) + } + return next(ctx, spec) + } +} diff --git a/connecthttp/option.go b/connecthttp/option.go new file mode 100644 index 00000000..f61e14e8 --- /dev/null +++ b/connecthttp/option.go @@ -0,0 +1,323 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp + +import ( + "slices" + + "connectrpc.com/connect/v2" +) + +// Option configures [NewTransport], [Mount], and [NewErrorWriter]. A single +// Option list builds up one [options]; each entry point reads the settings that +// apply to it and ignores the rest. Where an option only affects clients or +// only affects servers, the documentation says so. +type Option interface { + apply(*options) +} + +// WithGRPC configures clients to use the HTTP/2 gRPC protocol. The default is +// the Connect protocol. [Mount] ignores this option; handlers accept all three +// protocols by content-type negotiation. +func WithGRPC() Option { + return protocolOption{name: connect.ProtocolNameGRPC} +} + +// WithGRPCWeb configures clients to use the gRPC-Web protocol. The default is +// the Connect protocol. [Mount] ignores this option; handlers accept all three +// protocols by content-type negotiation. +func WithGRPCWeb() Option { + return protocolOption{name: connect.ProtocolNameGRPCWeb} +} + +// WithProtoJSON configures a client to send JSON-encoded data instead of +// binary Protobuf. It uses the standard Protobuf JSON mapping as implemented by +// [google.golang.org/protobuf/encoding/protojson]: fields are named using +// lowerCamelCase, zero values are omitted, missing required fields are errors, +// enums are emitted as strings, etc. +func WithProtoJSON() Option { + return WithSendCodec(connect.CodecNameJSON) +} + +// WithSendCodec selects the codec (by name) used for outgoing client requests. +// The codec must also be registered via [WithCodec]. Defaults to +// [connect.CodecNameProto]. [Mount] ignores this option. +func WithSendCodec(name string) Option { + return sendCodecOption(name) +} + +// WithCodec registers a [connect.Codec]. Clients may use it for outgoing +// requests. Servers accept it on inbound requests. Multiple WithCodec options +// merge. Defaults to +// [connectrpc.com/connect/v2/connectproto.BinaryCodec] and +// [connectrpc.com/connect/v2/connectproto.JSONCodec]. +func WithCodec(codec connect.Codec) Option { + return codecsOption([]connect.Codec{codec}) +} + +// WithCompressor registers a [connect.Compressor]. Clients advertise it in +// Accept-Encoding and may use it for sending; servers accept it on inbound +// requests and use it for responses. Compressors add to the default set, and +// the most recently registered is the most preferred. Multiple WithCompressor +// options accumulate. +// +// The default is gzip. Use [WithNoCompression] to disable compression. +func WithCompressor(compressor connect.Compressor) Option { + return compressorsOption([]connect.Compressor{compressor}) +} + +// WithNoCompression disables compression on both clients and servers: it +// removes every registered compressor (including the default gzip) so nothing +// is advertised, accepted, or sent compressed. A later [WithCompressor] +// re-registers compressors after it. +func WithNoCompression() Option { + return noCompressionOption{} +} + +// WithAcceptCompression makes a compression algorithm available to a client or +// handler by name, advertising it in Accept-Encoding. The named compressor must +// be registered via [WithCompressor]; the name simply selects from that pool. +// The first registered algorithm is treated as the least preferred, and the +// last registered algorithm is the most preferred. +// +// It's safe to use this option liberally: servers will ignore any compression +// algorithms they don't support. To compress requests, pair this option with +// [WithSendCompression]. +// +// Clients and servers support gzip by default. +// +// Calling WithAcceptCompression with an empty name is a no-op. +func WithAcceptCompression(name string) Option { + return acceptCompressionOption(name) +} + +// WithSendCompression selects the compressor (by name) to apply to outgoing +// client payloads. The compressor must be registered via [WithCompressor] (or +// be the default "gzip" compressor). Requests are sent uncompressed by default, +// to support servers that don't support compression. [Mount] ignores this +// option. +func WithSendCompression(name string) Option { + return sendCompressorOption(name) +} + +// WithSendGzip configures the client to gzip requests. Since clients have +// access to a gzip compressor by default, WithSendGzip doesn't require +// [WithCompressor]. +// +// Some servers don't support gzip, so clients default to sending uncompressed +// requests. +func WithSendGzip() Option { + return WithSendCompression(connect.CompressionNameGzip) +} + +// WithCompressMinBytes sets a minimum size threshold for compression: +// regardless of compressor configuration, messages smaller than the configured +// minimum are sent uncompressed. +// +// The default minimum is zero. Setting a minimum compression threshold may +// improve overall performance, because the CPU cost of compressing very small +// messages usually isn't worth the small reduction in network I/O. +func WithCompressMinBytes(n int) Option { + return compressMinBytesOption(n) +} + +// WithReadMaxBytes limits the performance impact of pathologically large +// messages sent by the other party. For handlers, WithReadMaxBytes limits the +// size of a message that the client can send. For clients, WithReadMaxBytes +// limits the size of a message that the server can respond with. Limits apply +// to each Protobuf message, not to the stream as a whole. +// +// Both clients and handlers default to a limit of 4 MiB. Setting +// WithReadMaxBytes to zero allows any message size. +// +// Handlers may also use [net/http.MaxBytesHandler] to limit the total size of +// the HTTP request stream (rather than the per-message size). Connect handles +// [net/http.MaxBytesError] specially, so clients still receive errors with the +// appropriate error code and informative messages. +func WithReadMaxBytes(n int) Option { + return readMaxBytesOption(n) +} + +// WithSendMaxBytes prevents sending messages too large for the client/handler +// to handle without significant performance overhead. For handlers, +// WithSendMaxBytes limits the size of a message that the handler can respond +// with. For clients, WithSendMaxBytes limits the size of a message that the +// client can send. Limits apply to each message, not to the stream as a whole. +// +// Setting WithSendMaxBytes to zero allows any message size. Both clients and +// handlers default to allowing any message size. +func WithSendMaxBytes(n int) Option { + return sendMaxBytesOption(n) +} + +// WithRequireConnectProtocolHeader configures the handler to require requests +// using the Connect RPC protocol to include the Connect-Protocol-Version +// header. This ensures that HTTP proxies and net/http middleware can easily +// identify valid Connect requests, even if they use a common Content-Type like +// application/json. However, it makes ad-hoc requests with tools like cURL more +// laborious. Streaming requests are not affected by this option. +// +// This option has no effect if the client uses the gRPC or gRPC-Web protocols. +// [NewTransport] ignores this option. +func WithRequireConnectProtocolHeader() Option { + return requireConnectProtocolHeaderOption{} +} + +// WithHTTPGet allows Connect-protocol clients to use HTTP GET requests for +// side-effect free unary RPC calls. Typically, the service schema indicates +// which procedures are idempotent. The gRPC and gRPC-Web protocols are +// POST-only, so this option has no effect when combined with [WithGRPC] or +// [WithGRPCWeb]. +// +// Using HTTP GET requests makes it easier to take advantage of CDNs, caching +// reverse proxies, and browsers' built-in caching. Note, however, that servers +// don't automatically set any cache headers; you can set cache headers using +// interceptors or by adding headers in individual procedure implementations. +// +// By default, all requests are made as HTTP POSTs. [Mount] ignores this option. +func WithHTTPGet() Option { + return enableGetOption{} +} + +// WithHTTPGetMaxURLSize sets the maximum allowable URL length for GET requests +// made using the Connect protocol. It has no effect on gRPC or gRPC-Web +// clients, since those protocols are POST-only. +// +// Limiting the URL size is useful as most user agents, proxies, and servers +// have limits on the allowable length of a URL. For example, Apache and Nginx +// limit the size of a request line to around 8 KiB, meaning that maximum length +// of a URL is a bit smaller than this. If you run into URL size limitations +// imposed by your network infrastructure and don't know the maximum allowable +// size, or if you'd prefer to be cautious from the start, a 4096 byte (4 KiB) +// limit works with most common proxies and CDNs. +// +// If fallback is set to true and the URL would be longer than the configured +// maximum value, the request will be sent as an HTTP POST instead. If fallback +// is set to false, the request will fail with [connect.CodeResourceExhausted]. +// +// By default, Connect-protocol clients with GET requests enabled may send a URL +// of any size. [Mount] ignores this option. +func WithHTTPGetMaxURLSize(bytes int, fallback bool) Option { + return getURLMaxBytesOption{max: bytes, fallback: fallback} +} + +// WithConditionalOptions allows procedures to have different configurations. +// For example, one procedure may need a much larger [WithReadMaxBytes] setting +// than the others. +// +// WithConditionalOptions takes a function which may inspect each procedure's +// [connect.Spec] before deciding which options to apply. Both clients and +// servers evaluate it per procedure. Returning a nil slice is safe. +func WithConditionalOptions(conditional func(spec connect.Spec) []Option) Option { + return conditionalOption{conditional: conditional} +} + +type protocolOption struct{ name string } + +// apply sets the client's outgoing protocol. A client speaks exactly one +// protocol, so the last option wins. +func (o protocolOption) apply(opts *options) { opts.protocol = o.name } + +type sendCodecOption string + +func (o sendCodecOption) apply(opts *options) { opts.sendCodecName = string(o) } + +type codecsOption []connect.Codec + +func (o codecsOption) apply(opts *options) { + if opts.codecs == nil { + opts.codecs = make(map[string]connect.Codec, len(o)) + } + for _, c := range o { + opts.codecs[c.Name()] = c + } +} + +type compressorsOption []connect.Compressor + +func (o compressorsOption) apply(opts *options) { + for _, c := range o { + if _, dup := opts.compressors[c.Name()]; !dup { + opts.compressorNames = append(opts.compressorNames, c.Name()) + } + opts.compressors[c.Name()] = c + } +} + +type noCompressionOption struct{} + +func (noCompressionOption) apply(opts *options) { + opts.compressors = map[string]connect.Compressor{} + opts.compressorNames = nil +} + +type acceptCompressionOption string + +func (o acceptCompressionOption) apply(opts *options) { + name := string(o) + if name == "" { + return + } + if !slices.Contains(opts.compressorNames, name) { + opts.compressorNames = append(opts.compressorNames, name) + } +} + +type sendCompressorOption string + +func (o sendCompressorOption) apply(opts *options) { opts.sendCompressor = string(o) } + +type compressMinBytesOption int + +func (o compressMinBytesOption) apply(opts *options) { opts.compressMinBytes = int(o) } + +type readMaxBytesOption int + +func (o readMaxBytesOption) apply(opts *options) { opts.readMaxBytes = int(o) } + +type sendMaxBytesOption int + +func (o sendMaxBytesOption) apply(opts *options) { opts.sendMaxBytes = int(o) } + +type requireConnectProtocolHeaderOption struct{} + +func (requireConnectProtocolHeaderOption) apply(opts *options) { + opts.requireConnectProtocolHeader = true +} + +type enableGetOption struct{} + +func (enableGetOption) apply(opts *options) { opts.getEnabled = true } + +type getURLMaxBytesOption struct { + max int + fallback bool +} + +func (o getURLMaxBytesOption) apply(opts *options) { + opts.getMaxURLBytes = o.max + opts.getUseFallback = o.fallback +} + +type conditionalOption struct { + conditional func(connect.Spec) []Option +} + +func (o conditionalOption) apply(opts *options) { + if o.conditional == nil { + return + } + opts.conditional = append(opts.conditional, o.conditional) +} diff --git a/protobuf_util.go b/connecthttp/protobuf_util.go similarity index 93% rename from protobuf_util.go rename to connecthttp/protobuf_util.go index d2563b6d..f4a13a86 100644 --- a/protobuf_util.go +++ b/connecthttp/protobuf_util.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "strings" @@ -20,7 +20,7 @@ import ( // extractProtoPath returns the trailing portion of the URL's path, // corresponding to the Protobuf package, service, and method. It always starts -// with a slash. Within connect, we use this as (1) Spec.Procedure and (2) the +// with a slash. Within connect, we use this as (1) spec.Procedure and (2) the // path when mounting handlers on muxes. func extractProtoPath(path string) string { segments := strings.Split(path, "/") diff --git a/protobuf_util_test.go b/connecthttp/protobuf_util_test.go similarity index 95% rename from protobuf_util_test.go rename to connecthttp/protobuf_util_test.go index 020d254f..30f219e6 100644 --- a/protobuf_util_test.go +++ b/connecthttp/protobuf_util_test.go @@ -12,12 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "testing" - "connectrpc.com/connect/internal/assert" + "connectrpc.com/connect/v2/internal/assert" ) func TestParseProtobufURL(t *testing.T) { diff --git a/protocol.go b/connecthttp/protocol.go similarity index 80% rename from protocol.go rename to connecthttp/protocol.go index 4e98b2b1..cabbcbb1 100644 --- a/protocol.go +++ b/connecthttp/protocol.go @@ -12,26 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "context" "errors" - "fmt" "io" "mime" "net/http" "net/url" "sort" "strings" -) -// The names of the Connect, gRPC, and gRPC-Web protocols (as exposed by -// [Peer].Protocol). Additional protocols may be added in the future. -const ( - ProtocolConnect = "connect" - ProtocolGRPC = "grpc" - ProtocolGRPCWeb = "grpcweb" + "connectrpc.com/connect/v2" ) const ( @@ -44,6 +37,10 @@ const ( headerDate = "Date" discardLimit = 1024 * 1024 * 4 // 4MiB + + // defaultReadMaxBytes is the default per-message read limit for clients and + // handlers, matching gRPC's default. WithReadMaxBytes overrides it. + defaultReadMaxBytes = 1024 * 1024 * 4 // 4MiB ) var errNoTimeout = errors.New("no timeout") @@ -71,18 +68,17 @@ type protocol interface { // HandlerParams are the arguments provided to a Protocol's NewHandler // method, bundled into a struct to allow backward-compatible argument // additions. Protocol implementations should take care to use the supplied -// Spec rather than constructing their own, since new fields may have been +// spec rather than constructing their own, since new fields may have been // added. type protocolHandlerParams struct { - Spec Spec + spec connect.Spec Codecs readOnlyCodecs CompressionPools readOnlyCompressionPools CompressMinBytes int - BufferPool *bufferPool ReadMaxBytes int SendMaxBytes int RequireConnectProtocolHeader bool - IdempotencyLevel IdempotencyLevel + IdempotencyLevel connect.IdempotencyLevel } // Handler is the server side of a protocol. HTTP handlers typically support @@ -108,22 +104,22 @@ type protocolHandler interface { // be concerned with the content type/payload specifically. CanHandlePayload(*http.Request, string) bool - // NewConn constructs a HandlerConn for the message exchange. - NewConn(http.ResponseWriter, *http.Request) (handlerConnCloser, bool) + // NewConn constructs a HandlerConn for the message exchange. It populates + // the [connect.CallInfo]'s codec, encoding, and stats fields. + NewConn(http.ResponseWriter, *http.Request, *connect.CallInfo) (handlerConnCloser, bool) } // ClientParams are the arguments provided to a Protocol's NewClient method, // bundled into a struct to allow backward-compatible argument additions. -// Protocol implementations should take care to use the supplied Spec rather +// Protocol implementations should take care to use the supplied spec rather // than constructing their own, since new fields may have been added. type protocolClientParams struct { CompressionName string CompressionPools readOnlyCompressionPools - Codec Codec + Codec connect.Codec CompressMinBytes int HTTPClient HTTPClient URL *url.URL - BufferPool *bufferPool ReadMaxBytes int SendMaxBytes int EnableGet bool @@ -131,17 +127,17 @@ type protocolClientParams struct { GetUseFallback bool // The gRPC family of protocols always needs access to a Protobuf codec to // marshal and unmarshal errors. - Protobuf Codec + Protobuf connect.Codec } // Client is the client side of a protocol. HTTP clients typically use a single // protocol, codec, and compressor to send requests. type protocolClient interface { - // Peer describes the server for the RPC. - Peer() Peer + // peer describes the server for the RPC. + Peer() peer // WriteRequestHeader writes any protocol-specific request headers. - WriteRequestHeader(StreamType, http.Header) + WriteRequestHeader(connect.StreamType, http.Header) // NewConn constructs a StreamingClientConn for the message exchange. // @@ -149,15 +145,24 @@ type protocolClient interface { // been populated by WriteRequestHeader. When constructing a stream for a // unary call, implementations may assume that the Sender's Send and Close // methods return before the Receiver's Receive or Close methods are called. - NewConn(context.Context, Spec, http.Header) streamingClientConn + NewConn(context.Context, connect.Spec, http.Header) streamingClientConn } -// streamingClientConn extends StreamingClientConn with a method for registering -// a hook when the HTTP request is actually sent. +// streamingClientConn is the client's view of a bidirectional message exchange, +// plus a method for registering a hook when the HTTP request is actually sent. type streamingClientConn interface { - StreamingClientConn + Spec() connect.Spec + Peer() peer + Send(any) error + RequestHeader() http.Header + CloseRequest() error + Receive(any) error + ResponseHeader() http.Header + ResponseTrailer() http.Header + CloseResponse() error onRequestSend(fn func(*http.Request)) + onResponseReceive(fn func(*http.Response)) } // errorTranslatingHandlerConnCloser wraps a handlerConnCloser to ensure that @@ -222,13 +227,17 @@ func (cc *errorTranslatingClientConn) onRequestSend(fn func(*http.Request)) { cc.streamingClientConn.onRequestSend(fn) } -// wrapHandlerConnWithCodedErrors ensures that we (1) automatically code -// context-related errors correctly when writing them to the network, and (2) -// return *Errors from all exported APIs. +func (cc *errorTranslatingClientConn) onResponseReceive(fn func(*http.Response)) { + cc.streamingClientConn.onResponseReceive(fn) +} + +// wrapHandlerConnWithCodedErrors ensures that we (1) scrub handler errors +// before writing them to the network, and (2) return *Errors from all +// exported APIs. func wrapHandlerConnWithCodedErrors(conn handlerConnCloser) handlerConnCloser { return &errorTranslatingHandlerConnCloser{ handlerConnCloser: conn, - toWire: wrapIfContextError, + toWire: scrubHandlerError, fromWire: wrapIfUncoded, } } @@ -302,9 +311,9 @@ func discard(reader io.Reader) (int64, error) { func negotiateCompression( //nolint:nonamedreturns availableCompressors readOnlyCompressionPools, sent, accept string, -) (requestCompression, responseCompression string, clientVisibleErr *Error) { - requestCompression = compressionIdentity - if sent != "" && sent != compressionIdentity { +) (requestCompression, responseCompression string, clientVisibleErr *connect.Error) { + requestCompression = connect.CompressionNameIdentity + if sent != "" && sent != connect.CompressionNameIdentity { // We default to identity, so we only care if the client sends something // other than the empty string or compressIdentity. if availableCompressors.Contains(sent) { @@ -312,11 +321,11 @@ func negotiateCompression( //nolint:nonamedreturns } else { // To comply with // https://github.com/grpc/grpc/blob/master/doc/compression.md and the - // Connect protocol, we should return CodeUnimplemented and specify + // Connect protocol, we should return connect.CodeUnimplemented and specify // acceptable compression(s) (in addition to setting the a // protocol-specific accept-encoding header). - return "", "", errorf( - CodeUnimplemented, + return "", "", connect.Errorf( + connect.CodeUnimplemented, "unknown compression %q: supported encodings are %v", sent, availableCompressors.CommaSeparatedNames(), ) @@ -328,7 +337,7 @@ func negotiateCompression( //nolint:nonamedreturns responseCompression = requestCompression // If we're not already planning to compress the response, check whether the // client requested a compression algorithm we support. - if responseCompression == compressionIdentity && accept != "" { + if responseCompression == connect.CompressionNameIdentity && accept != "" { for _, name := range strings.FieldsFunc(accept, isCommaOrSpace) { if availableCompressors.Contains(name) { // We found a mutually supported compression algorithm. Unlike standard @@ -344,10 +353,10 @@ func negotiateCompression( //nolint:nonamedreturns // checkServerStreamsCanFlush ensures that bidi and server streaming handlers // have received an http.ResponseWriter that implements http.Flusher, since // they must flush data after sending each message. -func checkServerStreamsCanFlush(spec Spec, responseWriter http.ResponseWriter) *Error { - requiresFlusher := (spec.StreamType & StreamTypeServer) == StreamTypeServer +func checkServerStreamsCanFlush(spec connect.Spec, responseWriter http.ResponseWriter) *connect.Error { + requiresFlusher := (spec.StreamType & connect.StreamTypeServer) == connect.StreamTypeServer if _, flushable := responseWriter.(http.Flusher); requiresFlusher && !flushable { - return NewError(CodeInternal, fmt.Errorf("%T does not implement http.Flusher", responseWriter)) + return connect.Errorf(connect.CodeInternal, "%T does not implement http.Flusher", responseWriter) } return nil } @@ -388,17 +397,20 @@ func canonicalizeContentTypeSlow(contentType string) string { return contentType } // According to RFC 9110 Section 8.3.2, the charset parameter value should be treated as case-insensitive. - // mime.FormatMediaType canonicalizes parameter names, but not parameter values, - // because the case sensitivity of a parameter value depends on its semantics. - // Therefore, the charset parameter value should be canonicalized here. + // Connect payloads are always UTF-8, so a utf-8 charset is dropped to canonicalize to the bare + // content type; any other charset is retained (and rejected during codec negotiation). // ref.) https://httpwg.org/specs/rfc9110.html#rfc.section.8.3.2 if charset, ok := params["charset"]; ok { - params["charset"] = strings.ToLower(charset) + if strings.ToLower(charset) == "utf-8" { + delete(params, "charset") + } else { + params["charset"] = strings.ToLower(charset) + } } return mime.FormatMediaType(base, params) } -func httpToCode(httpCode int) Code { +func httpToCode(httpCode int) connect.Code { // https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md // Note that this is NOT the inverse of the gRPC-to-HTTP or Connect-to-HTTP // mappings. @@ -407,18 +419,18 @@ func httpToCode(httpCode int) Code { // constants). switch httpCode { case 400: - return CodeInternal + return connect.CodeInternal case 401: - return CodeUnauthenticated + return connect.CodeUnauthenticated case 403: - return CodePermissionDenied + return connect.CodePermissionDenied case 404: - return CodeUnimplemented + return connect.CodeUnimplemented case 429: - return CodeUnavailable + return connect.CodeUnavailable case 502, 503, 504: - return CodeUnavailable + return connect.CodeUnavailable default: - return CodeUnknown + return connect.CodeUnknown } } diff --git a/protocol_connect.go b/connecthttp/protocol_connect.go similarity index 70% rename from protocol_connect.go rename to connecthttp/protocol_connect.go index eb2f680c..c00e08c1 100644 --- a/protocol_connect.go +++ b/connecthttp/protocol_connect.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "bytes" @@ -30,8 +30,9 @@ import ( "strings" "time" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/anypb" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectproto" + "connectrpc.com/connect/v2/internal/bufferpool" ) const ( @@ -48,7 +49,7 @@ const ( connectFlagEnvelopeEndStream = 0b00000010 connectUnaryContentTypePrefix = "application/" - connectUnaryContentTypeJSON = connectUnaryContentTypePrefix + codecNameJSON + connectUnaryContentTypeJSON = connectUnaryContentTypePrefix + connect.CodecNameJSON connectStreamingContentTypePrefix = "application/connect+" connectUnaryEncodingQueryParameter = "encoding" @@ -62,7 +63,7 @@ const ( // defaultConnectUserAgent returns a User-Agent string similar to those used in gRPC. // //nolint:gochecknoglobals -var defaultConnectUserAgent = fmt.Sprintf("connect-go/%s (%s)", Version, runtime.Version()) +var defaultConnectUserAgent = fmt.Sprintf("connect-go/%s (%s)", connect.Version, runtime.Version()) type protocolConnect struct{} @@ -71,13 +72,13 @@ func (*protocolConnect) NewHandler(params *protocolHandlerParams) protocolHandle methods := make(map[string]struct{}) methods[http.MethodPost] = struct{}{} - if params.Spec.StreamType == StreamTypeUnary && params.IdempotencyLevel == IdempotencyNoSideEffects { + if params.spec.StreamType == connect.StreamTypeUnary && params.IdempotencyLevel == connect.IdempotencyNoSideEffects { methods[http.MethodGet] = struct{}{} } contentTypes := make(map[string]struct{}) for _, name := range params.Codecs.Names() { - if params.Spec.StreamType == StreamTypeUnary { + if params.spec.StreamType == connect.StreamTypeUnary { contentTypes[canonicalizeContentType(connectUnaryContentTypePrefix+name)] = struct{}{} continue } @@ -95,7 +96,7 @@ func (*protocolConnect) NewHandler(params *protocolHandlerParams) protocolHandle func (*protocolConnect) NewClient(params *protocolClientParams) (protocolClient, error) { return &connectClient{ protocolClientParams: *params, - peer: newPeerForURL(params.URL, ProtocolConnect), + peer: newPeerForURL(params.URL, connect.ProtocolNameConnect), }, nil } @@ -120,11 +121,11 @@ func (*connectHandler) SetTimeout(request *http.Request) (context.Context, conte return request.Context(), nil, nil } if len(timeout) > 10 { - return nil, nil, errorf(CodeInvalidArgument, "parse timeout: %q has >10 digits", timeout) + return nil, nil, connect.Errorf(connect.CodeInvalidArgument, "parse timeout: %q has >10 digits", timeout) } millis, err := strconv.ParseInt(timeout, 10 /* base */, 64 /* bitsize */) if err != nil { - return nil, nil, errorf(CodeInvalidArgument, "parse timeout: %w", err) + return nil, nil, connect.Errorf(connect.CodeInvalidArgument, "parse timeout: %s", err).WithCause(err) } ctx, cancel := context.WithTimeout( request.Context(), @@ -138,7 +139,7 @@ func (h *connectHandler) CanHandlePayload(request *http.Request, contentType str query := request.URL.Query() codecName := query.Get(connectUnaryEncodingQueryParameter) contentType = connectContentTypeForCodecName( - h.Spec.StreamType, + h.spec.StreamType, codecName, ) } @@ -149,13 +150,14 @@ func (h *connectHandler) CanHandlePayload(request *http.Request, contentType str func (h *connectHandler) NewConn( responseWriter http.ResponseWriter, request *http.Request, + info *connect.CallInfo, ) (handlerConnCloser, bool) { ctx := request.Context() query := request.URL.Query() // We need to parse metadata before entering the interceptor stack; we'll // send the error to the client later on. var contentEncoding, acceptEncoding string - if h.Spec.StreamType == StreamTypeUnary { + if h.spec.StreamType == connect.StreamTypeUnary { if request.Method == http.MethodGet { contentEncoding = query.Get(connectUnaryCompressionQueryParameter) } else { @@ -172,10 +174,10 @@ func (h *connectHandler) NewConn( acceptEncoding, ) if failed == nil { - failed = checkServerStreamsCanFlush(h.Spec, responseWriter) + failed = checkServerStreamsCanFlush(h.spec, responseWriter) } if failed == nil { - required := h.RequireConnectProtocolHeader && (h.Spec.StreamType == StreamTypeUnary) + required := h.RequireConnectProtocolHeader && (h.spec.StreamType == connect.StreamTypeUnary) failed = connectCheckProtocolVersion(request, required) } @@ -183,23 +185,23 @@ func (h *connectHandler) NewConn( var contentType, codecName string if request.Method == http.MethodGet { if failed == nil && !query.Has(connectUnaryEncodingQueryParameter) { - failed = errorf(CodeInvalidArgument, "missing %s parameter", connectUnaryEncodingQueryParameter) + failed = connect.Errorf(connect.CodeInvalidArgument, "missing %s parameter", connectUnaryEncodingQueryParameter) } else if failed == nil && !query.Has(connectUnaryMessageQueryParameter) { - failed = errorf(CodeInvalidArgument, "missing %s parameter", connectUnaryMessageQueryParameter) + failed = connect.Errorf(connect.CodeInvalidArgument, "missing %s parameter", connectUnaryMessageQueryParameter) } msg := query.Get(connectUnaryMessageQueryParameter) msgReader := queryValueReader(msg, query.Get(connectUnaryBase64QueryParameter) == "1") requestBody = io.NopCloser(msgReader) codecName = query.Get(connectUnaryEncodingQueryParameter) contentType = connectContentTypeForCodecName( - h.Spec.StreamType, + h.spec.StreamType, codecName, ) } else { requestBody = request.Body contentType = getHeaderCanonical(request.Header, headerContentType) codecName = connectCodecForContentType( - h.Spec.StreamType, + h.spec.StreamType, contentType, ) } @@ -208,7 +210,7 @@ func (h *connectHandler) NewConn( // The codec can be nil in the GET request case; that's okay: when failed // is non-nil, codec is never used. if failed == nil && codec == nil { - failed = errorf(CodeInvalidArgument, "invalid message encoding: %q", codecName) + failed = connect.Errorf(connect.CodeInvalidArgument, "invalid message encoding: %q", codecName) } // Write any remaining headers here: @@ -221,27 +223,34 @@ func (h *connectHandler) NewConn( header := responseWriter.Header() header[headerContentType] = []string{contentType} acceptCompressionHeader := connectUnaryHeaderAcceptCompression - if h.Spec.StreamType != StreamTypeUnary { + if h.spec.StreamType != connect.StreamTypeUnary { acceptCompressionHeader = connectStreamingHeaderAcceptCompression // We only write the request encoding header here for streaming calls, // since the streaming envelope lets us choose whether to compress each // message individually. For unary, we won't know whether we're compressing // the request until we see how large the payload is. - if responseCompression != compressionIdentity { + if responseCompression != connect.CompressionNameIdentity { header[connectStreamingHeaderCompression] = []string{responseCompression} } } header[acceptCompressionHeader] = []string{h.CompressionPools.CommaSeparatedNames()} + var sendStats, receiveStats *connect.MessageStats + if info != nil { + info.Codec = codecName + info.RequestEncoding = requestCompression + info.ResponseEncoding = responseCompression + sendStats, receiveStats = &info.SendStats, &info.ReceiveStats + } var conn handlerConnCloser - peer := Peer{ + peer := peer{ Addr: request.RemoteAddr, - Protocol: ProtocolConnect, + Protocol: connect.ProtocolNameConnect, Query: query, } - if h.Spec.StreamType == StreamTypeUnary { + if h.spec.StreamType == connect.StreamTypeUnary { conn = &connectUnaryHandlerConn{ - spec: h.Spec, + spec: h.spec, peer: peer, request: request, responseWriter: responseWriter, @@ -252,23 +261,23 @@ func (h *connectHandler) NewConn( compressMinBytes: h.CompressMinBytes, compressionName: responseCompression, compressionPool: h.CompressionPools.Get(responseCompression), - bufferPool: h.BufferPool, header: responseWriter.Header(), sendMaxBytes: h.SendMaxBytes, + stats: sendStats, }, unmarshaler: connectUnaryUnmarshaler{ ctx: ctx, reader: requestBody, codec: codec, compressionPool: h.CompressionPools.Get(requestCompression), - bufferPool: h.BufferPool, readMaxBytes: h.ReadMaxBytes, + stats: receiveStats, }, responseTrailer: make(http.Header), } } else { conn = &connectStreamingHandlerConn{ - spec: h.Spec, + spec: h.spec, peer: peer, request: request, responseWriter: responseWriter, @@ -279,8 +288,8 @@ func (h *connectHandler) NewConn( codec: codec, compressMinBytes: h.CompressMinBytes, compressionPool: h.CompressionPools.Get(responseCompression), - bufferPool: h.BufferPool, sendMaxBytes: h.SendMaxBytes, + stats: sendStats, }, }, unmarshaler: connectStreamingUnmarshaler{ @@ -289,8 +298,8 @@ func (h *connectHandler) NewConn( reader: requestBody, codec: codec, compressionPool: h.CompressionPools.Get(requestCompression), - bufferPool: h.BufferPool, readMaxBytes: h.ReadMaxBytes, + stats: receiveStats, }, }, responseTrailer: make(http.Header), @@ -309,14 +318,14 @@ func (h *connectHandler) NewConn( type connectClient struct { protocolClientParams - peer Peer + peer peer } -func (c *connectClient) Peer() Peer { +func (c *connectClient) Peer() peer { return c.peer } -func (c *connectClient) WriteRequestHeader(streamType StreamType, header http.Header) { +func (c *connectClient) WriteRequestHeader(streamType connect.StreamType, header http.Header) { setUserAgentIfAbsent(header, defaultConnectUserAgent) // We know these header keys are in canonical form, so we can bypass all the // checks in Header.Set. @@ -325,17 +334,17 @@ func (c *connectClient) WriteRequestHeader(streamType StreamType, header http.He connectContentTypeForCodecName(streamType, c.Codec.Name()), } acceptCompressionHeader := connectUnaryHeaderAcceptCompression - if streamType != StreamTypeUnary { + if streamType != connect.StreamTypeUnary { // If we don't set Accept-Encoding, by default http.Client will ask the // server to compress the whole stream. Since we're already compressing // each message, this is a waste. - header[connectUnaryHeaderAcceptCompression] = []string{compressionIdentity} + header[connectUnaryHeaderAcceptCompression] = []string{connect.CompressionNameIdentity} acceptCompressionHeader = connectStreamingHeaderAcceptCompression // We only write the request encoding header here for streaming calls, // since the streaming envelope lets us choose whether to compress each // message individually. For unary, we won't know whether we're compressing // the request until we see how large the payload is. - if c.CompressionName != "" && c.CompressionName != compressionIdentity { + if c.CompressionName != "" && c.CompressionName != connect.CompressionNameIdentity { header[connectStreamingHeaderCompression] = []string{c.CompressionName} } } @@ -346,7 +355,7 @@ func (c *connectClient) WriteRequestHeader(streamType StreamType, header http.He func (c *connectClient) NewConn( ctx context.Context, - spec Spec, + spec connect.Spec, header http.Header, ) streamingClientConn { if deadline, ok := ctx.Deadline(); ok { @@ -358,15 +367,20 @@ func (c *connectClient) NewConn( } // else effectively unbounded } } - duplexCall := newDuplexHTTPCall(ctx, c.HTTPClient, c.URL, spec, header) + duplexCall := newDuplexHTTPCall(ctx, c.HTTPClient, c.URL, spec.StreamType, header) + info, ok := connect.CallInfoForClientContext(ctx) + var sendStats, receiveStats *connect.MessageStats + if ok { + sendStats, receiveStats = &info.SendStats, &info.ReceiveStats + } var conn streamingClientConn - if spec.StreamType == StreamTypeUnary { + if spec.StreamType == connect.StreamTypeUnary { unaryConn := &connectUnaryClientConn{ spec: spec, peer: c.Peer(), + info: info, duplexCall: duplexCall, compressionPools: c.CompressionPools, - bufferPool: c.BufferPool, marshaler: connectUnaryRequestMarshaler{ connectUnaryMarshaler: connectUnaryMarshaler{ ctx: ctx, @@ -375,27 +389,27 @@ func (c *connectClient) NewConn( compressMinBytes: c.CompressMinBytes, compressionName: c.CompressionName, compressionPool: c.CompressionPools.Get(c.CompressionName), - bufferPool: c.BufferPool, header: duplexCall.Header(), sendMaxBytes: c.SendMaxBytes, + stats: sendStats, }, }, unmarshaler: connectUnaryUnmarshaler{ ctx: ctx, reader: duplexCall, codec: c.Codec, - bufferPool: c.BufferPool, readMaxBytes: c.ReadMaxBytes, + stats: receiveStats, }, responseHeader: make(http.Header), responseTrailer: make(http.Header), } - if spec.IdempotencyLevel == IdempotencyNoSideEffects { + if spec.IdempotencyLevel == connect.IdempotencyNoSideEffects { unaryConn.marshaler.enableGet = c.EnableGet unaryConn.marshaler.getURLMaxBytes = c.GetURLMaxBytes unaryConn.marshaler.getUseFallback = c.GetUseFallback unaryConn.marshaler.duplexCall = duplexCall - if stableCodec, ok := c.Codec.(stableCodec); ok { + if stableCodec, ok := c.Codec.(connect.StableCodec); ok { unaryConn.marshaler.stableCodec = stableCodec } } @@ -405,9 +419,9 @@ func (c *connectClient) NewConn( streamingConn := &connectStreamingClientConn{ spec: spec, peer: c.Peer(), + info: info, duplexCall: duplexCall, compressionPools: c.CompressionPools, - bufferPool: c.BufferPool, codec: c.Codec, marshaler: connectStreamingMarshaler{ envelopeWriter: envelopeWriter{ @@ -416,8 +430,8 @@ func (c *connectClient) NewConn( codec: c.Codec, compressMinBytes: c.CompressMinBytes, compressionPool: c.CompressionPools.Get(c.CompressionName), - bufferPool: c.BufferPool, sendMaxBytes: c.SendMaxBytes, + stats: sendStats, }, }, unmarshaler: connectStreamingUnmarshaler{ @@ -425,8 +439,8 @@ func (c *connectClient) NewConn( ctx: ctx, reader: duplexCall, codec: c.Codec, - bufferPool: c.BufferPool, readMaxBytes: c.ReadMaxBytes, + stats: receiveStats, }, }, responseHeader: make(http.Header), @@ -439,22 +453,22 @@ func (c *connectClient) NewConn( } type connectUnaryClientConn struct { - spec Spec - peer Peer + spec connect.Spec + peer peer + info *connect.CallInfo duplexCall *duplexHTTPCall compressionPools readOnlyCompressionPools - bufferPool *bufferPool marshaler connectUnaryRequestMarshaler unmarshaler connectUnaryUnmarshaler responseHeader http.Header responseTrailer http.Header } -func (cc *connectUnaryClientConn) Spec() Spec { +func (cc *connectUnaryClientConn) Spec() connect.Spec { return cc.spec } -func (cc *connectUnaryClientConn) Peer() Peer { +func (cc *connectUnaryClientConn) Peer() peer { return cc.peer } @@ -462,7 +476,7 @@ func (cc *connectUnaryClientConn) Send(msg any) error { if err := cc.marshaler.Marshal(msg); err != nil { return err } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } func (cc *connectUnaryClientConn) RequestHeader() http.Header { @@ -480,17 +494,21 @@ func (cc *connectUnaryClientConn) Receive(msg any) error { if err := cc.unmarshaler.Unmarshal(msg); err != nil { return err } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } func (cc *connectUnaryClientConn) ResponseHeader() http.Header { - _, _ = cc.duplexCall.blockUntilResponseReady() - return cc.responseHeader + if cc.duplexCall.awaitResponse() { + return cc.responseHeader + } + return make(http.Header) } func (cc *connectUnaryClientConn) ResponseTrailer() http.Header { - _, _ = cc.duplexCall.blockUntilResponseReady() - return cc.responseTrailer + if cc.duplexCall.awaitResponse() { + return cc.responseTrailer + } + return make(http.Header) } func (cc *connectUnaryClientConn) CloseResponse() error { @@ -501,7 +519,11 @@ func (cc *connectUnaryClientConn) onRequestSend(fn func(*http.Request)) { cc.duplexCall.onRequestSend = fn } -func (cc *connectUnaryClientConn) validateResponse(response *http.Response) *Error { +func (cc *connectUnaryClientConn) onResponseReceive(fn func(*http.Response)) { + cc.duplexCall.onResponseReceive = fn +} + +func (cc *connectUnaryClientConn) validateResponse(response *http.Response) *connect.Error { for k, v := range response.Header { if !strings.HasPrefix(k, connectUnaryTrailerPrefix) { cc.responseHeader[k] = v @@ -516,37 +538,37 @@ func (cc *connectUnaryClientConn) validateResponse(response *http.Response) *Err response.Status, getHeaderCanonical(response.Header, headerContentType), ); err != nil { - if IsNotModifiedError(err) { - // Allow access to response headers for this kind of error. - // RFC 9110 doesn't allow trailers on 304s, so we only need to include headers. - err.meta = cc.responseHeader.Clone() - } return err } compression := getHeaderCanonical(response.Header, connectUnaryHeaderCompression) if compression != "" && - compression != compressionIdentity && + compression != connect.CompressionNameIdentity && !cc.compressionPools.Contains(compression) { - return errorf( - CodeInternal, + return connect.Errorf( + connect.CodeInternal, "unknown encoding %q: accepted encodings are %v", compression, cc.compressionPools.CommaSeparatedNames(), ) } cc.unmarshaler.compressionPool = cc.compressionPools.Get(compression) + if cc.info != nil { + cc.info.ResponseEncoding = encodingOrIdentity(compression) + } if response.StatusCode != http.StatusOK { unmarshaler := connectUnaryUnmarshaler{ ctx: cc.unmarshaler.ctx, reader: response.Body, compressionPool: cc.unmarshaler.compressionPool, - bufferPool: cc.bufferPool, } var wireErr connectWireError - if err := unmarshaler.UnmarshalFunc(&wireErr, json.Unmarshal); err != nil { - return NewError( + jsonUnmarshaller := func(_ context.Context, src io.Reader, msg any) error { + return json.NewDecoder(src).Decode(msg) + } + if err := unmarshaler.UnmarshalFunc(&wireErr, jsonUnmarshaller); err != nil { + return connect.NewError( httpToCode(response.StatusCode), - errors.New(response.Status), + response.Status, ) } if wireErr.Code == 0 { @@ -557,31 +579,29 @@ func (cc *connectUnaryClientConn) validateResponse(response *http.Response) *Err if serverErr == nil { return nil } - serverErr.meta = cc.responseHeader.Clone() - mergeHeaders(serverErr.meta, cc.responseTrailer) return serverErr } return nil } type connectStreamingClientConn struct { - spec Spec - peer Peer + spec connect.Spec + peer peer + info *connect.CallInfo duplexCall *duplexHTTPCall compressionPools readOnlyCompressionPools - bufferPool *bufferPool - codec Codec + codec connect.Codec marshaler connectStreamingMarshaler unmarshaler connectStreamingUnmarshaler responseHeader http.Header responseTrailer http.Header } -func (cc *connectStreamingClientConn) Spec() Spec { +func (cc *connectStreamingClientConn) Spec() connect.Spec { return cc.spec } -func (cc *connectStreamingClientConn) Peer() Peer { +func (cc *connectStreamingClientConn) Peer() peer { return cc.peer } @@ -589,7 +609,7 @@ func (cc *connectStreamingClientConn) Send(msg any) error { if err := cc.marshaler.Marshal(msg); err != nil { return err } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } func (cc *connectStreamingClientConn) RequestHeader() http.Header { @@ -615,15 +635,13 @@ func (cc *connectStreamingClientConn) Receive(msg any) error { // end-of-stream message means that we're _not_ getting a regular message. // For users to realize that the stream has ended, Receive must return an // error. - serverErr.meta = cc.responseHeader.Clone() - mergeHeaders(serverErr.meta, cc.responseTrailer) _ = cc.duplexCall.CloseWrite() return serverErr } // If the error is EOF but not from a last message, we want to return // io.ErrUnexpectedEOF instead. if errors.Is(err, io.EOF) && !errors.Is(err, errSpecialEnvelope) { - err = errorf(CodeInternal, "protocol error: %w", io.ErrUnexpectedEOF) + err = connect.Errorf(connect.CodeInternal, "protocol error: %s", io.ErrUnexpectedEOF).WithCause(io.ErrUnexpectedEOF) } // There's no error in the trailers, so this was probably an error // converting the bytes to a message, an error reading from the network, or @@ -634,13 +652,17 @@ func (cc *connectStreamingClientConn) Receive(msg any) error { } func (cc *connectStreamingClientConn) ResponseHeader() http.Header { - _, _ = cc.duplexCall.blockUntilResponseReady() - return cc.responseHeader + if cc.duplexCall.awaitResponse() { + return cc.responseHeader + } + return make(http.Header) } func (cc *connectStreamingClientConn) ResponseTrailer() http.Header { - _, _ = cc.duplexCall.blockUntilResponseReady() - return cc.responseTrailer + if cc.duplexCall.awaitResponse() { + return cc.responseTrailer + } + return make(http.Header) } func (cc *connectStreamingClientConn) CloseResponse() error { @@ -651,9 +673,13 @@ func (cc *connectStreamingClientConn) onRequestSend(fn func(*http.Request)) { cc.duplexCall.onRequestSend = fn } -func (cc *connectStreamingClientConn) validateResponse(response *http.Response) *Error { +func (cc *connectStreamingClientConn) onResponseReceive(fn func(*http.Response)) { + cc.duplexCall.onResponseReceive = fn +} + +func (cc *connectStreamingClientConn) validateResponse(response *http.Response) *connect.Error { if response.StatusCode != http.StatusOK { - return errorf(httpToCode(response.StatusCode), "HTTP status %v", response.Status) + return connect.Errorf(httpToCode(response.StatusCode), "HTTP status %v", response.Status) } if err := connectValidateStreamResponseContentType( cc.codec.Name(), @@ -664,23 +690,26 @@ func (cc *connectStreamingClientConn) validateResponse(response *http.Response) } compression := getHeaderCanonical(response.Header, connectStreamingHeaderCompression) if compression != "" && - compression != compressionIdentity && + compression != connect.CompressionNameIdentity && !cc.compressionPools.Contains(compression) { - return errorf( - CodeInternal, + return connect.Errorf( + connect.CodeInternal, "unknown encoding %q: accepted encodings are %v", compression, cc.compressionPools.CommaSeparatedNames(), ) } cc.unmarshaler.compressionPool = cc.compressionPools.Get(compression) + if cc.info != nil { + cc.info.ResponseEncoding = encodingOrIdentity(compression) + } mergeHeaders(cc.responseHeader, response.Header) return nil } type connectUnaryHandlerConn struct { - spec Spec - peer Peer + spec connect.Spec + peer peer request *http.Request responseWriter http.ResponseWriter marshaler connectUnaryMarshaler @@ -688,11 +717,11 @@ type connectUnaryHandlerConn struct { responseTrailer http.Header } -func (hc *connectUnaryHandlerConn) Spec() Spec { +func (hc *connectUnaryHandlerConn) Spec() connect.Spec { return hc.spec } -func (hc *connectUnaryHandlerConn) Peer() Peer { +func (hc *connectUnaryHandlerConn) Peer() peer { return hc.peer } @@ -700,7 +729,7 @@ func (hc *connectUnaryHandlerConn) Receive(msg any) error { if err := hc.unmarshaler.Unmarshal(msg); err != nil { return err } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } func (hc *connectUnaryHandlerConn) RequestHeader() http.Header { @@ -708,11 +737,11 @@ func (hc *connectUnaryHandlerConn) RequestHeader() http.Header { } func (hc *connectUnaryHandlerConn) Send(msg any) error { - hc.mergeResponseHeader(nil /* error */) + hc.mergeResponseHeader() if err := hc.marshaler.Marshal(msg); err != nil { return err } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } func (hc *connectUnaryHandlerConn) ResponseHeader() http.Header { @@ -725,7 +754,7 @@ func (hc *connectUnaryHandlerConn) ResponseTrailer() http.Header { func (hc *connectUnaryHandlerConn) Close(err error) error { if !hc.marshaler.wroteHeader { - hc.mergeResponseHeader(err) + hc.mergeResponseHeader() // If the handler received a GET request and the resource hasn't changed, // return a 304. if hc.request.Method == http.MethodGet && IsNotModifiedError(err) { @@ -738,11 +767,11 @@ func (hc *connectUnaryHandlerConn) Close(err error) error { } // In unary Connect, errors always use application/json. setHeaderCanonical(hc.responseWriter.Header(), headerContentType, connectUnaryContentTypeJSON) - hc.responseWriter.WriteHeader(connectCodeToHTTP(CodeOf(err))) + hc.responseWriter.WriteHeader(connectCodeToHTTP(connect.CodeOf(err))) data, marshalErr := json.Marshal(newConnectWireError(err)) if marshalErr != nil { _ = hc.request.Body.Close() - return errorf(CodeInternal, "marshal error: %w", err) + return connect.Errorf(connect.CodeInternal, "marshal error: %s", err).WithCause(err) } if _, writeErr := hc.responseWriter.Write(data); writeErr != nil { _ = hc.request.Body.Close() @@ -755,7 +784,7 @@ func (hc *connectUnaryHandlerConn) getHTTPMethod() string { return hc.request.Method } -func (hc *connectUnaryHandlerConn) mergeResponseHeader(err error) { +func (hc *connectUnaryHandlerConn) mergeResponseHeader() { header := hc.responseWriter.Header() if hc.request.Method == http.MethodGet { // The response content varies depending on the compression that the client @@ -763,19 +792,14 @@ func (hc *connectUnaryHandlerConn) mergeResponseHeader(err error) { // that the Vary header includes at least Accept-Encoding (and not overwrite any values already set). header[headerVary] = append(header[headerVary], connectUnaryHeaderAcceptCompression) } - if err != nil { - if connectErr, ok := asError(err); ok && !connectErr.wireErr { - mergeNonProtocolHeaders(header, connectErr.meta) - } - } for k, v := range hc.responseTrailer { header[connectUnaryTrailerPrefix+k] = v } } type connectStreamingHandlerConn struct { - spec Spec - peer Peer + spec connect.Spec + peer peer request *http.Request responseWriter http.ResponseWriter marshaler connectStreamingMarshaler @@ -783,11 +807,11 @@ type connectStreamingHandlerConn struct { responseTrailer http.Header } -func (hc *connectStreamingHandlerConn) Spec() Spec { +func (hc *connectStreamingHandlerConn) Spec() connect.Spec { return hc.spec } -func (hc *connectStreamingHandlerConn) Peer() Peer { +func (hc *connectStreamingHandlerConn) Peer() peer { return hc.peer } @@ -797,7 +821,7 @@ func (hc *connectStreamingHandlerConn) Receive(msg any) error { // errSpecialEnvelope. return err } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } func (hc *connectStreamingHandlerConn) RequestHeader() http.Header { @@ -809,7 +833,7 @@ func (hc *connectStreamingHandlerConn) Send(msg any) error { if err := hc.marshaler.Marshal(msg); err != nil { return err } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } func (hc *connectStreamingHandlerConn) ResponseHeader() http.Header { @@ -836,29 +860,26 @@ func (hc *connectStreamingHandlerConn) Close(err error) error { if connectErr, ok := asError(err); ok { return connectErr } - return NewError(CodeUnknown, err) + return connect.Errorf(connect.CodeUnknown, "%s", err).WithCause(err) } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } type connectStreamingMarshaler struct { envelopeWriter } -func (m *connectStreamingMarshaler) MarshalEndStream(err error, trailer http.Header) *Error { +func (m *connectStreamingMarshaler) MarshalEndStream(err error, trailer http.Header) *connect.Error { end := &connectEndStreamMessage{Trailer: trailer} if err != nil { end.Error = newConnectWireError(err) - if connectErr, ok := asError(err); ok && !connectErr.wireErr { - mergeNonProtocolHeaders(end.Trailer, connectErr.meta) - } } data, marshalErr := json.Marshal(end) if marshalErr != nil { - return errorf(CodeInternal, "marshal end stream: %w", marshalErr) + return connect.Errorf(connect.CodeInternal, "marshal end stream: %s", marshalErr).WithCause(marshalErr) } raw := bytes.NewBuffer(data) - defer m.bufferPool.Put(raw) + defer bufferpool.Put(raw) return m.Write(&envelope{ Data: raw, Flags: connectFlagEnvelopeEndStream, @@ -868,11 +889,11 @@ func (m *connectStreamingMarshaler) MarshalEndStream(err error, trailer http.Hea type connectStreamingUnmarshaler struct { envelopeReader - endStreamErr *Error + endStreamErr *connect.Error trailer http.Header } -func (u *connectStreamingUnmarshaler) Unmarshal(message any) *Error { +func (u *connectStreamingUnmarshaler) Unmarshal(message any) *connect.Error { err := u.envelopeReader.Unmarshal(message) if err == nil { return nil @@ -883,13 +904,13 @@ func (u *connectStreamingUnmarshaler) Unmarshal(message any) *Error { env := u.last data := env.Data u.last.Data = nil // don't keep a reference to it - defer u.bufferPool.Put(data) + defer bufferpool.Put(data) if !env.IsSet(connectFlagEnvelopeEndStream) { - return errorf(CodeInternal, "protocol error: invalid envelope flags %d", env.Flags) + return connect.Errorf(connect.CodeInternal, "protocol error: invalid envelope flags %d", env.Flags) } var end connectEndStreamMessage if err := json.Unmarshal(data.Bytes(), &end); err != nil { - return errorf(CodeInternal, "unmarshal end stream message: %w", err) + return connect.Errorf(connect.CodeInternal, "unmarshal end stream message: %s", err).WithCause(err) } for name, value := range end.Trailer { canonical := http.CanonicalHeaderKey(name) @@ -907,59 +928,67 @@ func (u *connectStreamingUnmarshaler) Trailer() http.Header { return u.trailer } -func (u *connectStreamingUnmarshaler) EndStreamError() *Error { +func (u *connectStreamingUnmarshaler) EndStreamError() *connect.Error { return u.endStreamErr } type connectUnaryMarshaler struct { ctx context.Context //nolint:containedctx sender messageSender - codec Codec + codec connect.Codec compressMinBytes int compressionName string compressionPool *compressionPool - bufferPool *bufferPool header http.Header sendMaxBytes int wroteHeader bool + stats *connect.MessageStats } -func (m *connectUnaryMarshaler) Marshal(message any) *Error { +func (m *connectUnaryMarshaler) recordStats(size, compressedSize int) { + if m.stats == nil { + return + } + *m.stats = connect.MessageStats{Size: size, CompressedSize: compressedSize} +} + +func (m *connectUnaryMarshaler) Marshal(message any) *connect.Error { if message == nil { return m.write(nil) } - var data []byte - var err error - if appender, ok := m.codec.(marshalAppender); ok { - data, err = appender.MarshalAppend(m.bufferPool.Get().Bytes(), message) - } else { - // Can't avoid allocating the slice, but we'll reuse it. - data, err = m.codec.Marshal(message) - } - if err != nil { - return errorf(CodeInternal, "marshal message: %w", err) + uncompressed := bufferpool.Get() + if err := m.codec.MarshalWrite(m.ctx, uncompressed, message); err != nil { + return connect.Errorf(connect.CodeInternal, "marshal message: %s", err).WithCause(err) } - uncompressed := bytes.NewBuffer(data) - defer m.bufferPool.Put(uncompressed) - if len(data) < m.compressMinBytes || m.compressionPool == nil { - if m.sendMaxBytes > 0 && len(data) > m.sendMaxBytes { - return NewError(CodeResourceExhausted, fmt.Errorf("message size %d exceeds sendMaxBytes %d", len(data), m.sendMaxBytes)) + defer bufferpool.Put(uncompressed) + if uncompressed.Len() < m.compressMinBytes || m.compressionPool == nil { + if m.sendMaxBytes > 0 && uncompressed.Len() > m.sendMaxBytes { + return connect.Errorf(connect.CodeResourceExhausted, "message size %d exceeds sendMaxBytes %d", uncompressed.Len(), m.sendMaxBytes) } - return m.write(data) + if err := m.write(uncompressed.Bytes()); err != nil { + return err + } + m.recordStats(uncompressed.Len(), 0) + return nil } - compressed := m.bufferPool.Get() - defer m.bufferPool.Put(compressed) + size := uncompressed.Len() // before Compress drains the buffer + compressed := bufferpool.Get() + defer bufferpool.Put(compressed) if err := m.compressionPool.Compress(compressed, uncompressed); err != nil { return err } if m.sendMaxBytes > 0 && compressed.Len() > m.sendMaxBytes { - return NewError(CodeResourceExhausted, fmt.Errorf("compressed message size %d exceeds sendMaxBytes %d", compressed.Len(), m.sendMaxBytes)) + return connect.Errorf(connect.CodeResourceExhausted, "compressed message size %d exceeds sendMaxBytes %d", compressed.Len(), m.sendMaxBytes) } setHeaderCanonical(m.header, connectUnaryHeaderCompression, m.compressionName) - return m.write(compressed.Bytes()) + if err := m.write(compressed.Bytes()); err != nil { + return err + } + m.recordStats(size, compressed.Len()) + return nil } -func (m *connectUnaryMarshaler) write(data []byte) *Error { +func (m *connectUnaryMarshaler) write(data []byte) *connect.Error { m.wroteHeader = true payload := bytes.NewReader(data) if _, err := m.sender.Send(payload); err != nil { @@ -967,25 +996,33 @@ func (m *connectUnaryMarshaler) write(data []byte) *Error { if connectErr, ok := asError(err); ok { return connectErr } - return errorf(CodeUnknown, "write message: %w", err) + return connect.Errorf(connect.CodeUnknown, "write message: %s", err).WithCause(err) } return nil } +func (m *connectUnaryMarshaler) writeAndRecord(data []byte, size, compressedSize int) *connect.Error { + if err := m.write(data); err != nil { + return err + } + m.recordStats(size, compressedSize) + return nil +} + type connectUnaryRequestMarshaler struct { connectUnaryMarshaler enableGet bool getURLMaxBytes int getUseFallback bool - stableCodec stableCodec + stableCodec connect.StableCodec duplexCall *duplexHTTPCall } -func (m *connectUnaryRequestMarshaler) Marshal(message any) *Error { +func (m *connectUnaryRequestMarshaler) Marshal(message any) *connect.Error { if m.enableGet { if m.stableCodec == nil && !m.getUseFallback { - return errorf(CodeInternal, "codec %s doesn't support stable marshal; can't use get", m.codec.Name()) + return connect.Errorf(connect.CodeInternal, "codec %s doesn't support stable marshal; can't use get", m.codec.Name()) } if m.stableCodec != nil { return m.marshalWithGet(message) @@ -994,63 +1031,63 @@ func (m *connectUnaryRequestMarshaler) Marshal(message any) *Error { return m.connectUnaryMarshaler.Marshal(message) } -func (m *connectUnaryRequestMarshaler) marshalWithGet(message any) *Error { - // TODO(jchadwick-buf): This function is mostly a superset of - // connectUnaryMarshaler.Marshal. This should be reconciled at some point. - var data []byte +func (m *connectUnaryRequestMarshaler) marshalWithGet(message any) *connect.Error { + var buffer bytes.Buffer var err error if message != nil { - data, err = m.stableCodec.MarshalStable(message) - if err != nil { - return errorf(CodeInternal, "marshal message stable: %w", err) + if err = m.stableCodec.MarshalWriteStable(context.Background(), &buffer, message); err != nil { + return connect.Errorf(connect.CodeInternal, "marshal message stable: %s", err).WithCause(err) } } - isTooBig := m.sendMaxBytes > 0 && len(data) > m.sendMaxBytes + isTooBig := m.sendMaxBytes > 0 && buffer.Len() > m.sendMaxBytes if isTooBig && m.compressionPool == nil { - return NewError(CodeResourceExhausted, fmt.Errorf( + return connect.Errorf(connect.CodeResourceExhausted, "message size %d exceeds sendMaxBytes %d: enabling request compression may help", - len(data), + buffer.Len(), m.sendMaxBytes, - )) + ) } + data := buffer.Bytes() if !isTooBig { url := m.buildGetURL(data, false /* compressed */) if m.getURLMaxBytes <= 0 || len(url.String()) <= m.getURLMaxBytes { m.writeWithGet(url) + m.recordStats(len(data), 0) return nil } if m.compressionPool == nil { if m.getUseFallback { - return m.write(data) + return m.writeAndRecord(data, len(data), 0) } - return NewError(CodeResourceExhausted, fmt.Errorf( + return connect.Errorf(connect.CodeResourceExhausted, "url size %d exceeds getURLMaxBytes %d: enabling request compression may help", len(url.String()), m.getURLMaxBytes, - )) + ) } } // Compress message to try to make it fit in the URL. uncompressed := bytes.NewBuffer(data) - defer m.bufferPool.Put(uncompressed) - compressed := m.bufferPool.Get() - defer m.bufferPool.Put(compressed) + defer bufferpool.Put(uncompressed) + compressed := bufferpool.Get() + defer bufferpool.Put(compressed) if err := m.compressionPool.Compress(compressed, uncompressed); err != nil { return err } if m.sendMaxBytes > 0 && compressed.Len() > m.sendMaxBytes { - return NewError(CodeResourceExhausted, fmt.Errorf("compressed message size %d exceeds sendMaxBytes %d", compressed.Len(), m.sendMaxBytes)) + return connect.Errorf(connect.CodeResourceExhausted, "compressed message size %d exceeds sendMaxBytes %d", compressed.Len(), m.sendMaxBytes) } url := m.buildGetURL(compressed.Bytes(), true /* compressed */) if m.getURLMaxBytes <= 0 || len(url.String()) <= m.getURLMaxBytes { m.writeWithGet(url) + m.recordStats(len(data), compressed.Len()) return nil } if m.getUseFallback { setHeaderCanonical(m.header, connectUnaryHeaderCompression, m.compressionName) - return m.write(compressed.Bytes()) + return m.writeAndRecord(compressed.Bytes(), len(data), compressed.Len()) } - return NewError(CodeResourceExhausted, fmt.Errorf("compressed url size %d exceeds getURLMaxBytes %d", len(url.String()), m.getURLMaxBytes)) + return connect.Errorf(connect.CodeResourceExhausted, "compressed url size %d exceeds getURLMaxBytes %d", len(url.String()), m.getURLMaxBytes) } func (m *connectUnaryRequestMarshaler) buildGetURL(data []byte, compressed bool) *url.URL { @@ -1095,24 +1132,24 @@ func (m *connectUnaryRequestMarshaler) writeWithGet(url *url.URL) { type connectUnaryUnmarshaler struct { ctx context.Context //nolint:containedctx reader io.Reader - codec Codec + codec connect.Codec compressionPool *compressionPool - bufferPool *bufferPool alreadyRead bool readMaxBytes int + stats *connect.MessageStats } -func (u *connectUnaryUnmarshaler) Unmarshal(message any) *Error { - return u.UnmarshalFunc(message, u.codec.Unmarshal) +func (u *connectUnaryUnmarshaler) Unmarshal(message any) *connect.Error { + return u.UnmarshalFunc(message, u.codec.UnmarshalRead) } -func (u *connectUnaryUnmarshaler) UnmarshalFunc(message any, unmarshal func([]byte, any) error) *Error { +func (u *connectUnaryUnmarshaler) UnmarshalFunc(message any, unmarshal func(context.Context, io.Reader, any) error) *connect.Error { if u.alreadyRead { - return NewError(CodeInternal, io.EOF) + return connect.Errorf(connect.CodeInternal, "%s", io.EOF).WithCause(io.EOF) } u.alreadyRead = true - data := u.bufferPool.Get() - defer u.bufferPool.Put(data) + data := bufferpool.Get() + defer bufferpool.Put(data) reader := u.reader if u.readMaxBytes > 0 && int64(u.readMaxBytes) < math.MaxInt64 { reader = io.LimitReader(u.reader, int64(u.readMaxBytes)+1) @@ -1125,54 +1162,56 @@ func (u *connectUnaryUnmarshaler) UnmarshalFunc(message any, unmarshal func([]by if connectErr, ok := asError(err); ok { return connectErr } - return errorf(CodeUnknown, "read message: %w", err) + return connect.Errorf(connect.CodeUnknown, "read message: %s", err).WithCause(err) } if u.readMaxBytes > 0 && bytesRead > int64(u.readMaxBytes) { // Attempt to read to end in order to allow connection re-use discardedBytes, err := io.Copy(io.Discard, u.reader) if err != nil { - return errorf(CodeResourceExhausted, "message is larger than configured max %d - unable to determine message size: %w", u.readMaxBytes, err) + return connect.Errorf(connect.CodeResourceExhausted, "message is larger than configured max %d - unable to determine message size: %s", u.readMaxBytes, err).WithCause(err) } - return errorf(CodeResourceExhausted, "message size %d is larger than configured max %d", bytesRead+discardedBytes, u.readMaxBytes) + return connect.Errorf(connect.CodeResourceExhausted, "message size %d is larger than configured max %d", bytesRead+discardedBytes, u.readMaxBytes) } + compressedSize := 0 if data.Len() > 0 && u.compressionPool != nil { - decompressed := u.bufferPool.Get() - defer u.bufferPool.Put(decompressed) + compressedSize = data.Len() + decompressed := bufferpool.Get() + defer bufferpool.Put(decompressed) if err := u.compressionPool.Decompress(decompressed, data, int64(u.readMaxBytes)); err != nil { return err } data = decompressed } - if err := unmarshal(data.Bytes(), message); err != nil { - return errorf(CodeInvalidArgument, "unmarshal message: %w", err) + size := data.Len() // before unmarshal drains the buffer + if err := unmarshal(u.ctx, data, message); err != nil { + return connect.Errorf(connect.CodeInvalidArgument, "unmarshal message: %s", err).WithCause(err) + } + if u.stats != nil { + *u.stats = connect.MessageStats{Size: size, CompressedSize: compressedSize} } return nil } -type connectWireDetail ErrorDetail +// connectWireDetail adapts a [connect.ErrorDetail] to the Connect protocol's +// error detail object. +type connectWireDetail connect.ErrorDetail func (d *connectWireDetail) MarshalJSON() ([]byte, error) { - if d.wireJSON != "" { - // If we unmarshaled this detail from JSON, return the original data. This - // lets proxies w/o protobuf descriptors preserve human-readable details. - return []byte(d.wireJSON), nil - } wire := struct { Type string `json:"type"` Value string `json:"value"` Debug json.RawMessage `json:"debug,omitempty"` }{ - Type: typeNameForURL(d.pbAny.GetTypeUrl()), - Value: base64.RawStdEncoding.EncodeToString(d.pbAny.GetValue()), - } - // Try to produce debug info, but expect failure when we don't have - // descriptors. - msg, err := d.getInner() - if err == nil { - var codec protoJSONCodec - debug, err := codec.Marshal(msg) - if err == nil { - wire.Debug = debug + Type: d.Type, + Value: base64.RawStdEncoding.EncodeToString(d.Value), + } + if json.Valid(d.Debug) { + wire.Debug = json.RawMessage(d.Debug) + } else if msg, err := connectproto.ErrorDetailToAny((*connect.ErrorDetail)(d)).UnmarshalNew(); err == nil { + var buffer bytes.Buffer + var codec connectproto.JSONCodec + if err := codec.MarshalWrite(context.Background(), &buffer, msg); err == nil { + wire.Debug = buffer.Bytes() } } return json.Marshal(wire) @@ -1180,53 +1219,42 @@ func (d *connectWireDetail) MarshalJSON() ([]byte, error) { func (d *connectWireDetail) UnmarshalJSON(data []byte) error { var wire struct { - Type string `json:"type"` - Value string `json:"value"` + Type string `json:"type"` + Value string `json:"value"` + Debug json.RawMessage `json:"debug,omitempty"` } if err := json.Unmarshal(data, &wire); err != nil { return err } - if !strings.Contains(wire.Type, "/") { - wire.Type = defaultAnyResolverPrefix + wire.Type - } - decoded, err := DecodeBinaryHeader(wire.Value) + value, err := connect.DecodeBinaryHeader(wire.Value) if err != nil { return fmt.Errorf("decode base64: %w", err) } *d = connectWireDetail{ - pbAny: &anypb.Any{ - TypeUrl: wire.Type, - Value: decoded, - }, - wireJSON: string(data), + Type: wire.Type, + Value: value, + Debug: wire.Debug, } return nil } -func (d *connectWireDetail) getInner() (proto.Message, error) { - if d.pbInner != nil { - return d.pbInner, nil - } - return d.pbAny.UnmarshalNew() -} - type connectWireError struct { - Code Code `json:"code"` + Code connect.Code `json:"code"` Message string `json:"message,omitempty"` Details []*connectWireDetail `json:"details,omitempty"` } func newConnectWireError(err error) *connectWireError { wire := &connectWireError{ - Code: CodeUnknown, + Code: connect.CodeUnknown, Message: err.Error(), } if connectErr, ok := asError(err); ok { wire.Code = connectErr.Code() wire.Message = connectErr.Message() - if len(connectErr.details) > 0 { - wire.Details = make([]*connectWireDetail, len(connectErr.details)) - for i, detail := range connectErr.details { + if details := connectErr.Details(); len(details) > 0 { + wire.Details = make([]*connectWireDetail, len(details)) + for i, detail := range details { wire.Details[i] = (*connectWireDetail)(detail) } } @@ -1234,18 +1262,17 @@ func newConnectWireError(err error) *connectWireError { return wire } -func (e *connectWireError) asError() *Error { +func (e *connectWireError) asError() *connect.Error { if e == nil { return nil } - if e.Code < minCode || e.Code > maxCode { - e.Code = CodeUnknown + if e.Code < connect.CodeCanceled || e.Code > connect.CodeUnauthenticated { + e.Code = connect.CodeUnknown } - err := NewWireError(e.Code, errors.New(e.Message)) + err := connect.NewError(e.Code, e.Message).WithRemote() if len(e.Details) > 0 { - err.details = make([]*ErrorDetail, len(e.Details)) - for i, detail := range e.Details { - err.details[i] = (*ErrorDetail)(detail) + for _, detail := range e.Details { + err = err.WithDetail((*connect.ErrorDetail)(detail)) } } return err @@ -1276,56 +1303,56 @@ type connectEndStreamMessage struct { Trailer http.Header `json:"metadata,omitempty"` } -func connectCodeToHTTP(code Code) int { +func connectCodeToHTTP(code connect.Code) int { // Return literals rather than named constants from the HTTP package to make // it easier to compare this function to the Connect specification. switch code { - case CodeCanceled: + case connect.CodeCanceled: return 499 - case CodeUnknown: + case connect.CodeUnknown: return 500 - case CodeInvalidArgument: + case connect.CodeInvalidArgument: return 400 - case CodeDeadlineExceeded: + case connect.CodeDeadlineExceeded: return 504 - case CodeNotFound: + case connect.CodeNotFound: return 404 - case CodeAlreadyExists: + case connect.CodeAlreadyExists: return 409 - case CodePermissionDenied: + case connect.CodePermissionDenied: return 403 - case CodeResourceExhausted: + case connect.CodeResourceExhausted: return 429 - case CodeFailedPrecondition: + case connect.CodeFailedPrecondition: return 400 - case CodeAborted: + case connect.CodeAborted: return 409 - case CodeOutOfRange: + case connect.CodeOutOfRange: return 400 - case CodeUnimplemented: + case connect.CodeUnimplemented: return 501 - case CodeInternal: + case connect.CodeInternal: return 500 - case CodeUnavailable: + case connect.CodeUnavailable: return 503 - case CodeDataLoss: + case connect.CodeDataLoss: return 500 - case CodeUnauthenticated: + case connect.CodeUnauthenticated: return 401 default: - return 500 // same as CodeUnknown + return 500 // same as connect.CodeUnknown } } -func connectCodecForContentType(streamType StreamType, contentType string) string { - if streamType == StreamTypeUnary { +func connectCodecForContentType(streamType connect.StreamType, contentType string) string { + if streamType == connect.StreamTypeUnary { return strings.TrimPrefix(contentType, connectUnaryContentTypePrefix) } return strings.TrimPrefix(contentType, connectStreamingContentTypePrefix) } -func connectContentTypeForCodecName(streamType StreamType, name string) string { - if streamType == StreamTypeUnary { +func connectContentTypeForCodecName(streamType connect.StreamType, name string) string { + if streamType == connect.StreamTypeUnary { return connectUnaryContentTypePrefix + name } return connectStreamingContentTypePrefix + name @@ -1363,33 +1390,32 @@ func connectValidateUnaryResponseContentType( statusCode int, statusMsg string, responseContentType string, -) *Error { +) *connect.Error { if statusCode != http.StatusOK { if statusCode == http.StatusNotModified && httpMethod == http.MethodGet { - return NewWireError(CodeUnknown, errNotModifiedClient) + return connect.Errorf(connect.CodeUnknown, "%s", errNotModifiedClient).WithCause(errNotModifiedClient) } - // Error responses must be JSON-encoded. - if responseContentType == connectUnaryContentTypePrefix+codecNameJSON || + // connect.Error responses must be JSON-encoded. + if responseContentType == connectUnaryContentTypePrefix+connect.CodecNameJSON || responseContentType == connectUnaryContentTypePrefix+codecNameJSONCharsetUTF8 { return nil } - return NewError( + return connect.NewError( httpToCode(statusCode), - errors.New(statusMsg), + statusMsg, ) } // Normal responses must have valid content-type that indicates same codec as the request. if !strings.HasPrefix(responseContentType, connectUnaryContentTypePrefix) { // Doesn't even look like a Connect response? Use code "unknown". - return errorf( - CodeUnknown, + return connect.Errorf(connect.CodeUnknown, "invalid content-type: %q; expecting %q", responseContentType, connectUnaryContentTypePrefix+requestCodecName, ) } responseCodecName := connectCodecForContentType( - StreamTypeUnary, + connect.StreamTypeUnary, responseContentType, ) if responseCodecName == requestCodecName { @@ -1397,25 +1423,24 @@ func connectValidateUnaryResponseContentType( } // HACK: We likely want a better way to handle the optional "charset" parameter // for application/json, instead of hard-coding. But this suffices for now. - if (responseCodecName == codecNameJSON && requestCodecName == codecNameJSONCharsetUTF8) || - (responseCodecName == codecNameJSONCharsetUTF8 && requestCodecName == codecNameJSON) { + if (responseCodecName == connect.CodecNameJSON && requestCodecName == codecNameJSONCharsetUTF8) || + (responseCodecName == codecNameJSONCharsetUTF8 && requestCodecName == connect.CodecNameJSON) { // Both are JSON return nil } - return errorf( - CodeInternal, + return connect.Errorf(connect.CodeInternal, "invalid content-type: %q; expecting %q", responseContentType, connectUnaryContentTypePrefix+requestCodecName, ) } -func connectValidateStreamResponseContentType(requestCodecName string, streamType StreamType, responseContentType string) *Error { +func connectValidateStreamResponseContentType(requestCodecName string, streamType connect.StreamType, responseContentType string) *connect.Error { // Responses must have valid content-type that indicates same codec as the request. if !strings.HasPrefix(responseContentType, connectStreamingContentTypePrefix) { // Doesn't even look like a Connect response? Use code "unknown". - return errorf( - CodeUnknown, + return connect.Errorf( + connect.CodeUnknown, "invalid content-type: %q; expecting %q", responseContentType, connectStreamingContentTypePrefix+requestCodecName, @@ -1426,8 +1451,8 @@ func connectValidateStreamResponseContentType(requestCodecName string, streamTyp responseContentType, ) if responseCodecName != requestCodecName { - return errorf( - CodeInternal, + return connect.Errorf( + connect.CodeInternal, "invalid content-type: %q; expecting %q", responseContentType, connectStreamingContentTypePrefix+requestCodecName, @@ -1436,24 +1461,24 @@ func connectValidateStreamResponseContentType(requestCodecName string, streamTyp return nil } -func connectCheckProtocolVersion(request *http.Request, required bool) *Error { +func connectCheckProtocolVersion(request *http.Request, required bool) *connect.Error { switch request.Method { case http.MethodGet: version := request.URL.Query().Get(connectUnaryConnectQueryParameter) if version == "" && required { - return errorf(CodeInvalidArgument, "missing required query parameter: set %s to %q", connectUnaryConnectQueryParameter, connectUnaryConnectQueryValue) + return connect.Errorf(connect.CodeInvalidArgument, "missing required query parameter: set %s to %q", connectUnaryConnectQueryParameter, connectUnaryConnectQueryValue) } else if version != "" && version != connectUnaryConnectQueryValue { - return errorf(CodeInvalidArgument, "%s must be %q: got %q", connectUnaryConnectQueryParameter, connectUnaryConnectQueryValue, version) + return connect.Errorf(connect.CodeInvalidArgument, "%s must be %q: got %q", connectUnaryConnectQueryParameter, connectUnaryConnectQueryValue, version) } case http.MethodPost: version := getHeaderCanonical(request.Header, connectHeaderProtocolVersion) if version == "" && required { - return errorf(CodeInvalidArgument, "missing required header: set %s to %q", connectHeaderProtocolVersion, connectProtocolVersion) + return connect.Errorf(connect.CodeInvalidArgument, "missing required header: set %s to %q", connectHeaderProtocolVersion, connectProtocolVersion) } else if version != "" && version != connectProtocolVersion { - return errorf(CodeInvalidArgument, "%s must be %q: got %q", connectHeaderProtocolVersion, connectProtocolVersion, version) + return connect.Errorf(connect.CodeInvalidArgument, "%s must be %q: got %q", connectHeaderProtocolVersion, connectProtocolVersion, version) } default: - return errorf(CodeInvalidArgument, "unsupported method: %q", request.Method) + return connect.Errorf(connect.CodeInvalidArgument, "unsupported method: %q", request.Method) } return nil } diff --git a/protocol_connect_test.go b/connecthttp/protocol_connect_test.go similarity index 71% rename from protocol_connect_test.go rename to connecthttp/protocol_connect_test.go index 7c2a44d7..fdfa7a22 100644 --- a/protocol_connect_test.go +++ b/connecthttp/protocol_connect_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "bytes" @@ -23,7 +23,10 @@ import ( "testing" "time" - "connectrpc.com/connect/internal/assert" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectproto" + "connectrpc.com/connect/v2/internal/assert" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/descriptorpb" "google.golang.org/protobuf/types/known/durationpb" @@ -57,16 +60,18 @@ func TestConnectErrorDetailMarshaling(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { t.Parallel() - detail, err := NewErrorDetail(testCase.errorDetail) + errorDetail, err := connectproto.NewErrorDetail(testCase.errorDetail) assert.Nil(t, err) - data, err := json.Marshal((*connectWireDetail)(detail)) + detail := (*connectWireDetail)(errorDetail) + data, err := json.Marshal(detail) assert.Nil(t, err) t.Logf("marshaled error detail: %s", string(data)) var unmarshaled connectWireDetail assert.Nil(t, json.Unmarshal(data, &unmarshaled)) - assert.Equal(t, unmarshaled.wireJSON, string(data)) - assert.Equal(t, unmarshaled.pbAny, detail.pbAny) + assert.Equal(t, unmarshaled.Type, errorDetail.Type) + assert.Equal(t, unmarshaled.Value, errorDetail.Value) + assert.True(t, len(unmarshaled.Debug) > 0) var extractDetails struct { Debug any `json:"debug"` @@ -79,16 +84,20 @@ func TestConnectErrorDetailMarshaling(t *testing.T) { func TestConnectErrorDetailMarshalingNoDescriptor(t *testing.T) { t.Parallel() - raw := `{"type":"acme.user.v1.User","value":"DEADBF",` + + raw := `{"type":"acme.user.v1.User","value":"DEADBEEF",` + `"debug":{"email":"someone@connectrpc.com"}}` var detail connectWireDetail assert.Nil(t, json.Unmarshal([]byte(raw), &detail)) - assert.Equal(t, detail.pbAny.GetTypeUrl(), defaultAnyResolverPrefix+"acme.user.v1.User") + assert.Equal(t, detail.Type, "acme.user.v1.User") + anyDetail := connectproto.ErrorDetailToAny((*connect.ErrorDetail)(&detail)) + assert.Equal(t, anyDetail.GetTypeUrl(), "type.googleapis.com/acme.user.v1.User") - _, err := (*ErrorDetail)(&detail).Value() + _, err := connectproto.UnmarshalErrorDetail((*connect.ErrorDetail)(&detail)) assert.NotNil(t, err) assert.True(t, strings.HasSuffix(err.Error(), "not found")) + // Re-serializing a decoded detail preserves the debug field without + // descriptors. encoded, err := json.Marshal(&detail) assert.Nil(t, err) assert.Equal(t, string(encoded), raw) @@ -98,7 +107,6 @@ func TestConnectEndOfResponseCanonicalTrailers(t *testing.T) { t.Parallel() buffer := bytes.Buffer{} - bufferPool := newBufferPool() endStreamMessage := connectEndStreamMessage{Trailer: make(http.Header)} endStreamMessage.Trailer["not-canonical-header"] = []string{"a"} @@ -109,8 +117,7 @@ func TestConnectEndOfResponseCanonicalTrailers(t *testing.T) { assert.Nil(t, err) writer := envelopeWriter{ - sender: writeSender{writer: &buffer}, - bufferPool: bufferPool, + sender: writeSender{writer: &buffer}, } err = writer.Write(&envelope{ Flags: connectFlagEnvelopeEndStream, @@ -120,9 +127,8 @@ func TestConnectEndOfResponseCanonicalTrailers(t *testing.T) { unmarshaler := connectStreamingUnmarshaler{ envelopeReader: envelopeReader{ - ctx: t.Context(), - reader: &buffer, - bufferPool: bufferPool, + ctx: t.Context(), + reader: &buffer, }, } err = unmarshaler.Unmarshal(nil) // parameter won't be used @@ -139,23 +145,23 @@ func TestConnectValidateUnaryResponseContentType(t *testing.T) { get bool statusCode int responseContentType string - expectCode Code + expectCode connect.Code expectBadContentType bool expectNotModified bool }{ // Allowed content-types for OK responses. { - codecName: codecNameProto, + codecName: connect.CodecNameProto, statusCode: http.StatusOK, responseContentType: "application/proto", }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, statusCode: http.StatusOK, responseContentType: "application/json", }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, statusCode: http.StatusOK, responseContentType: "application/json; charset=utf-8", }, @@ -171,86 +177,86 @@ func TestConnectValidateUnaryResponseContentType(t *testing.T) { }, // Allowed content-types for error responses. { - codecName: codecNameProto, + codecName: connect.CodecNameProto, statusCode: http.StatusNotFound, responseContentType: "application/json", }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, statusCode: http.StatusBadRequest, responseContentType: "application/json; charset=utf-8", }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, statusCode: http.StatusInternalServerError, responseContentType: "application/json", }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, statusCode: http.StatusPreconditionFailed, responseContentType: "application/json; charset=utf-8", }, // 304 Not Modified for GET request gets a special error, regardless of content-type { - codecName: codecNameProto, + codecName: connect.CodecNameProto, get: true, statusCode: http.StatusNotModified, responseContentType: "application/json", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, expectNotModified: true, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, get: true, statusCode: http.StatusNotModified, responseContentType: "application/json", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, expectNotModified: true, }, // OK status, invalid content-type { - codecName: codecNameProto, + codecName: connect.CodecNameProto, statusCode: http.StatusOK, responseContentType: "application/proto; charset=utf-8", - expectCode: CodeInternal, + expectCode: connect.CodeInternal, expectBadContentType: true, }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, statusCode: http.StatusOK, responseContentType: "application/json", - expectCode: CodeInternal, + expectCode: connect.CodeInternal, expectBadContentType: true, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, statusCode: http.StatusOK, responseContentType: "application/proto", - expectCode: CodeInternal, + expectCode: connect.CodeInternal, expectBadContentType: true, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, statusCode: http.StatusOK, responseContentType: "some/garbage", - expectCode: CodeUnknown, // doesn't even look like it could be connect protocol + expectCode: connect.CodeUnknown, // doesn't even look like it could be connect protocol expectBadContentType: true, }, - // Error status, invalid content-type, returns code based on HTTP status code + // connect.Error status, invalid content-type, returns code based on HTTP status code { - codecName: codecNameProto, + codecName: connect.CodecNameProto, statusCode: http.StatusNotFound, responseContentType: "application/proto", expectCode: httpToCode(http.StatusNotFound), }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, statusCode: http.StatusBadRequest, responseContentType: "some/garbage", expectCode: httpToCode(http.StatusBadRequest), }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, statusCode: http.StatusTooManyRequests, responseContentType: "some/garbage", expectCode: httpToCode(http.StatusTooManyRequests), @@ -274,7 +280,7 @@ func TestConnectValidateUnaryResponseContentType(t *testing.T) { if testCase.expectCode == 0 { assert.Nil(t, err) } else if assert.NotNil(t, err) { - assert.Equal(t, CodeOf(err), testCase.expectCode) + assert.Equal(t, connect.CodeOf(err), testCase.expectCode) switch { case testCase.expectNotModified: assert.ErrorIs(t, err, errNotModified) @@ -293,53 +299,53 @@ func TestConnectValidateStreamResponseContentType(t *testing.T) { testCases := []struct { codecName string responseContentType string - expectCode Code + expectCode connect.Code }{ // Allowed content-types { - codecName: codecNameProto, + codecName: connect.CodecNameProto, responseContentType: "application/connect+proto", }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, responseContentType: "application/connect+json", }, // Mismatched response codec { - codecName: codecNameProto, + codecName: connect.CodecNameProto, responseContentType: "application/connect+json", - expectCode: CodeInternal, + expectCode: connect.CodeInternal, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, responseContentType: "application/connect+proto", - expectCode: CodeInternal, + expectCode: connect.CodeInternal, }, // Disallowed content-types { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, responseContentType: "application/connect+json; charset=utf-8", - expectCode: CodeInternal, // *almost* looks right + expectCode: connect.CodeInternal, // *almost* looks right }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, responseContentType: "application/proto", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, responseContentType: "application/json", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, responseContentType: "application/json; charset=utf-8", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, responseContentType: "some/garbage", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, } for _, testCase := range testCases { @@ -348,13 +354,13 @@ func TestConnectValidateStreamResponseContentType(t *testing.T) { t.Parallel() err := connectValidateStreamResponseContentType( testCase.codecName, - StreamTypeServer, + connect.StreamTypeServer, testCase.responseContentType, ) if testCase.expectCode == 0 { assert.Nil(t, err) } else if assert.NotNil(t, err) { - assert.Equal(t, CodeOf(err), testCase.expectCode) + assert.Equal(t, connect.CodeOf(err), testCase.expectCode) assert.True(t, strings.Contains(err.Message(), fmt.Sprintf("invalid content-type: %q; expecting", testCase.responseContentType))) } }) @@ -364,7 +370,7 @@ func TestConnectValidateStreamResponseContentType(t *testing.T) { func TestConnectUnaryGetURLQueryOrder(t *testing.T) { t.Parallel() const baseURL = "http://example.com/connect.ping.v1.PingService/Ping" - newMarshaler := func(t *testing.T, codec stableCodec, compressionName string) *connectUnaryRequestMarshaler { + newMarshaler := func(t *testing.T, codec connect.StableCodec, compressionName string) *connectUnaryRequestMarshaler { t.Helper() req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, baseURL, http.NoBody) assert.Nil(t, err) @@ -377,11 +383,11 @@ func TestConnectUnaryGetURLQueryOrder(t *testing.T) { duplexCall: &duplexHTTPCall{request: req}, } } - jsonCodec := &protoJSONCodec{name: "json"} - protoCodec := &protoBinaryCodec{} + jsonCodec := &connectproto.JSONCodec{} + protoCodec := &connectproto.BinaryCodec{} testCases := []struct { name string - codec stableCodec + codec connect.StableCodec data []byte compressed bool compressionName string @@ -425,3 +431,23 @@ func TestConnectUnaryGetURLQueryOrder(t *testing.T) { }) } } + +func TestConnectWireErrorDetailBestEffortDebug(t *testing.T) { + t.Parallel() + // Packing failures surface at construction, not serialization. + badMsg := &pingv1.PingResponse{Text: "\xc3\x28"} // invalid UTF-8 + _, err := connectproto.NewErrorDetail(badMsg) + assert.NotNil(t, err) + + // Debug is best effort: an invalid Debug never fails serialization; it is + // regenerated (when the type is registered) or omitted. + detail, err := connectproto.NewErrorDetail(&pingv1.PingResponse{Number: 42}) + assert.Nil(t, err) + detail.Debug = []byte("{invalid") + data, err := json.Marshal((*connectWireDetail)(detail)) + assert.Nil(t, err) + var reparsed connectWireDetail + assert.Nil(t, json.Unmarshal(data, &reparsed)) + assert.Equal(t, reparsed.Type, detail.Type) + assert.Equal(t, reparsed.Value, detail.Value) +} diff --git a/protocol_grpc.go b/connecthttp/protocol_grpc.go similarity index 73% rename from protocol_grpc.go rename to connecthttp/protocol_grpc.go index af054354..f338611a 100644 --- a/protocol_grpc.go +++ b/connecthttp/protocol_grpc.go @@ -12,10 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "bufio" + "bytes" "context" "errors" "fmt" @@ -28,7 +29,11 @@ import ( "strings" "time" - statusv1 "connectrpc.com/connect/internal/gen/connectext/grpc/status/v1" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectproto" + "connectrpc.com/connect/v2/internal/bufferpool" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/types/known/anypb" ) const ( @@ -49,6 +54,13 @@ const ( headerXUserAgent = "X-User-Agent" upperhex = "0123456789ABCDEF" + + // Field numbers of the google.rpc.Status message carried in + // Grpc-Status-Details-Bin. See + // https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto. + grpcStatusFieldCode = 1 // int32 code + grpcStatusFieldMessage = 2 // string message + grpcStatusFieldDetails = 3 // repeated google.protobuf.Any details ) var ( @@ -65,7 +77,7 @@ var ( // User-Agent → "grpc-" Language ?("-" Variant) "/" Version ?( " (" *(AdditionalProperty ";") ")" ) // //nolint:gochecknoglobals - defaultGrpcUserAgent = fmt.Sprintf("grpc-go-connect/%s (%s)", Version, runtime.Version()) + defaultGrpcUserAgent = fmt.Sprintf("grpc-go-connect/%s (%s)", connect.Version, runtime.Version()) //nolint:gochecknoglobals grpcAllowedMethods = map[string]struct{}{ http.MethodPost: {}, @@ -86,7 +98,7 @@ func (g *protocolGRPC) NewHandler(params *protocolHandlerParams) protocolHandler for _, name := range params.Codecs.Names() { contentTypes[canonicalizeContentType(prefix+name)] = struct{}{} } - if params.Codecs.Get(codecNameProto) != nil { + if params.Codecs.Get(connect.CodecNameProto) != nil { contentTypes[bare] = struct{}{} } return &grpcHandler{ @@ -98,9 +110,9 @@ func (g *protocolGRPC) NewHandler(params *protocolHandlerParams) protocolHandler // NewClient implements protocol, so it must return an interface. func (g *protocolGRPC) NewClient(params *protocolClientParams) (protocolClient, error) { - peer := newPeerForURL(params.URL, ProtocolGRPC) + peer := newPeerForURL(params.URL, connect.ProtocolNameGRPC) if g.web { - peer = newPeerForURL(params.URL, ProtocolGRPCWeb) + peer = newPeerForURL(params.URL, connect.ProtocolNameGRPCWeb) } return &grpcClient{ protocolClientParams: *params, @@ -129,7 +141,7 @@ func (*grpcHandler) SetTimeout(request *http.Request) (context.Context, context. if err != nil && !errors.Is(err, errNoTimeout) { // Errors here indicate that the client sent an invalid timeout header, so // the error text is safe to send back. - return nil, nil, NewError(CodeInvalidArgument, err) + return nil, nil, connect.NewError(connect.CodeInvalidArgument, err.Error()).WithCause(err) } else if err != nil { // err wraps errNoTimeout, nothing to do. return request.Context(), nil, nil //nolint:nilerr @@ -146,6 +158,7 @@ func (g *grpcHandler) CanHandlePayload(_ *http.Request, contentType string) bool func (g *grpcHandler) NewConn( responseWriter http.ResponseWriter, request *http.Request, + info *connect.CallInfo, ) (handlerConnCloser, bool) { ctx := request.Context() // We need to parse metadata before entering the interceptor stack; we'll @@ -156,7 +169,7 @@ func (g *grpcHandler) NewConn( getHeaderCanonical(request.Header, grpcHeaderAcceptCompression), ) if failed == nil { - failed = checkServerStreamsCanFlush(g.Spec, responseWriter) + failed = checkServerStreamsCanFlush(g.spec, responseWriter) } // Write any remaining headers here: @@ -169,25 +182,31 @@ func (g *grpcHandler) NewConn( header := responseWriter.Header() header[headerContentType] = []string{getHeaderCanonical(request.Header, headerContentType)} header[grpcHeaderAcceptCompression] = []string{g.CompressionPools.CommaSeparatedNames()} - if responseCompression != compressionIdentity { + if responseCompression != connect.CompressionNameIdentity { header[grpcHeaderCompression] = []string{responseCompression} } codecName := grpcCodecForContentType(g.web, getHeaderCanonical(request.Header, headerContentType)) codec := g.Codecs.Get(codecName) // handler.go guarantees this is not nil - protocolName := ProtocolGRPC + protocolName := connect.ProtocolNameGRPC if g.web { - protocolName = ProtocolGRPCWeb + protocolName = connect.ProtocolNameGRPCWeb + } + var sendStats, receiveStats *connect.MessageStats + if info != nil { + info.Codec = codecName + info.RequestEncoding = requestCompression + info.ResponseEncoding = responseCompression + sendStats, receiveStats = &info.SendStats, &info.ReceiveStats } conn := wrapHandlerConnWithCodedErrors(&grpcHandlerConn{ - spec: g.Spec, - peer: Peer{ + spec: g.spec, + peer: peer{ Addr: request.RemoteAddr, Protocol: protocolName, }, - web: g.web, - bufferPool: g.BufferPool, - protobuf: g.Codecs.Protobuf(), // for errors + web: g.web, + protobuf: g.Codecs.Protobuf(), // for errors marshaler: grpcMarshaler{ envelopeWriter: envelopeWriter{ ctx: ctx, @@ -195,8 +214,8 @@ func (g *grpcHandler) NewConn( compressionPool: g.CompressionPools.Get(responseCompression), codec: codec, compressMinBytes: g.CompressMinBytes, - bufferPool: g.BufferPool, sendMaxBytes: g.SendMaxBytes, + stats: sendStats, }, }, responseWriter: responseWriter, @@ -209,8 +228,8 @@ func (g *grpcHandler) NewConn( reader: request.Body, codec: codec, compressionPool: g.CompressionPools.Get(requestCompression), - bufferPool: g.BufferPool, readMaxBytes: g.ReadMaxBytes, + stats: receiveStats, }, web: g.web, }, @@ -227,14 +246,14 @@ type grpcClient struct { protocolClientParams web bool - peer Peer + peer peer } -func (g *grpcClient) Peer() Peer { +func (g *grpcClient) Peer() peer { return g.peer } -func (g *grpcClient) WriteRequestHeader(_ StreamType, header http.Header) { +func (g *grpcClient) WriteRequestHeader(_ connect.StreamType, header http.Header) { setUserAgentIfAbsent(header, defaultGrpcUserAgent) // We know these header keys are in canonical form, so we can bypass all the // checks in Header.Set. @@ -249,8 +268,8 @@ func (g *grpcClient) WriteRequestHeader(_ StreamType, header http.Header) { // gRPC handles compression on a per-message basis, so we don't want to // compress the whole stream. By default, http.Client will ask the server // to gzip the stream if we don't set Accept-Encoding. - header["Accept-Encoding"] = []string{compressionIdentity} - if g.CompressionName != "" && g.CompressionName != compressionIdentity { + header["Accept-Encoding"] = []string{connect.CompressionNameIdentity} + if g.CompressionName != "" && g.CompressionName != connect.CompressionNameIdentity { header[grpcHeaderCompression] = []string{g.CompressionName} } if acceptCompression := g.CompressionPools.CommaSeparatedNames(); acceptCompression != "" { @@ -265,7 +284,7 @@ func (g *grpcClient) WriteRequestHeader(_ StreamType, header http.Header) { func (g *grpcClient) NewConn( ctx context.Context, - spec Spec, + spec connect.Spec, header http.Header, ) streamingClientConn { if deadline, ok := ctx.Deadline(); ok { @@ -276,15 +295,20 @@ func (g *grpcClient) NewConn( ctx, g.HTTPClient, g.URL, - spec, + spec.StreamType, header, ) + info, ok := connect.CallInfoForClientContext(ctx) + var sendStats, receiveStats *connect.MessageStats + if ok { + sendStats, receiveStats = &info.SendStats, &info.ReceiveStats + } conn := &grpcClientConn{ spec: spec, peer: g.Peer(), + info: info, duplexCall: duplexCall, compressionPools: g.CompressionPools, - bufferPool: g.BufferPool, protobuf: g.Protobuf, marshaler: grpcMarshaler{ envelopeWriter: envelopeWriter{ @@ -293,8 +317,8 @@ func (g *grpcClient) NewConn( compressionPool: g.CompressionPools.Get(g.CompressionName), codec: g.Codec, compressMinBytes: g.CompressMinBytes, - bufferPool: g.BufferPool, sendMaxBytes: g.SendMaxBytes, + stats: sendStats, }, }, unmarshaler: grpcUnmarshaler{ @@ -302,8 +326,8 @@ func (g *grpcClient) NewConn( ctx: ctx, reader: duplexCall, codec: g.Codec, - bufferPool: g.BufferPool, readMaxBytes: g.ReadMaxBytes, + stats: receiveStats, }, }, responseHeader: make(http.Header), @@ -327,12 +351,12 @@ func (g *grpcClient) NewConn( // grpcClientConn works for both gRPC and gRPC-Web. type grpcClientConn struct { - spec Spec - peer Peer + spec connect.Spec + peer peer + info *connect.CallInfo duplexCall *duplexHTTPCall compressionPools readOnlyCompressionPools - bufferPool *bufferPool - protobuf Codec // for errors + protobuf connect.Codec // for errors marshaler grpcMarshaler unmarshaler grpcUnmarshaler responseHeader http.Header @@ -340,11 +364,11 @@ type grpcClientConn struct { readTrailers func(*grpcUnmarshaler, *duplexHTTPCall) http.Header } -func (cc *grpcClientConn) Spec() Spec { +func (cc *grpcClientConn) Spec() connect.Spec { return cc.spec } -func (cc *grpcClientConn) Peer() Peer { +func (cc *grpcClientConn) Peer() peer { return cc.peer } @@ -352,7 +376,7 @@ func (cc *grpcClientConn) Send(msg any) error { if err := cc.marshaler.Marshal(msg); err != nil { return err } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } func (cc *grpcClientConn) RequestHeader() http.Header { @@ -386,17 +410,16 @@ func (cc *grpcClientConn) Receive(msg any) error { delHeaderCanonical(cc.responseTrailer, headerContentType) // Try to read the status out of the headers. - serverErr := grpcErrorForTrailer(cc.protobuf, cc.responseHeader) + serverErr := grpcErrorForTrailer(cc.unmarshaler.ctx, cc.protobuf, cc.responseHeader) if serverErr == nil { // Status says "OK". So return original error (io.EOF). return err } - serverErr.meta = cc.responseHeader.Clone() return serverErr } // See if the server sent an explicit error in the HTTP or gRPC-Web trailers. - serverErr := grpcErrorForTrailer(cc.protobuf, cc.responseTrailer) + serverErr := grpcErrorForTrailer(cc.unmarshaler.ctx, cc.protobuf, cc.responseTrailer) if serverErr != nil && (errors.Is(err, io.EOF) || !errors.Is(serverErr, errTrailersWithoutGRPCStatus)) { // We've either: // - Cleanly read until the end of the response body and *not* received @@ -406,8 +429,6 @@ func (cc *grpcClientConn) Receive(msg any) error { // This is expected from a protocol perspective, but receiving trailers // means that we're _not_ getting a message. For users to realize that // the stream has ended, Receive must return an error. - serverErr.meta = cc.responseHeader.Clone() - mergeHeaders(serverErr.meta, cc.responseTrailer) _ = cc.duplexCall.CloseWrite() return serverErr } @@ -419,13 +440,17 @@ func (cc *grpcClientConn) Receive(msg any) error { } func (cc *grpcClientConn) ResponseHeader() http.Header { - _, _ = cc.duplexCall.blockUntilResponseReady() - return cc.responseHeader + if cc.duplexCall.awaitResponse() { + return cc.responseHeader + } + return make(http.Header) } func (cc *grpcClientConn) ResponseTrailer() http.Header { - _, _ = cc.duplexCall.blockUntilResponseReady() - return cc.responseTrailer + if cc.duplexCall.awaitResponse() { + return cc.responseTrailer + } + return make(http.Header) } func (cc *grpcClientConn) CloseResponse() error { @@ -436,7 +461,11 @@ func (cc *grpcClientConn) onRequestSend(fn func(*http.Request)) { cc.duplexCall.onRequestSend = fn } -func (cc *grpcClientConn) validateResponse(response *http.Response) *Error { +func (cc *grpcClientConn) onResponseReceive(fn func(*http.Response)) { + cc.duplexCall.onResponseReceive = fn +} + +func (cc *grpcClientConn) validateResponse(response *http.Response) *connect.Error { if err := grpcValidateResponse( response, cc.responseHeader, @@ -448,15 +477,17 @@ func (cc *grpcClientConn) validateResponse(response *http.Response) *Error { } compression := getHeaderCanonical(response.Header, grpcHeaderCompression) cc.unmarshaler.compressionPool = cc.compressionPools.Get(compression) + if cc.info != nil { + cc.info.ResponseEncoding = encodingOrIdentity(compression) + } return nil } type grpcHandlerConn struct { - spec Spec - peer Peer + spec connect.Spec + peer peer web bool - bufferPool *bufferPool - protobuf Codec // for errors + protobuf connect.Codec // for errors marshaler grpcMarshaler responseWriter http.ResponseWriter responseHeader http.Header @@ -466,11 +497,11 @@ type grpcHandlerConn struct { unmarshaler grpcUnmarshaler } -func (hc *grpcHandlerConn) Spec() Spec { +func (hc *grpcHandlerConn) Spec() connect.Spec { return hc.spec } -func (hc *grpcHandlerConn) Peer() Peer { +func (hc *grpcHandlerConn) Peer() peer { return hc.peer } @@ -478,7 +509,7 @@ func (hc *grpcHandlerConn) Receive(msg any) error { if err := hc.unmarshaler.Unmarshal(msg); err != nil { return err // already coded } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } func (hc *grpcHandlerConn) RequestHeader() http.Header { @@ -494,7 +525,7 @@ func (hc *grpcHandlerConn) Send(msg any) error { if err := hc.marshaler.Marshal(msg); err != nil { return err } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } func (hc *grpcHandlerConn) ResponseHeader() http.Header { @@ -531,7 +562,7 @@ func (hc *grpcHandlerConn) Close(err error) (retErr error) { len(hc.responseTrailer)+2, // always make space for status & message ) mergeHeaders(mergedTrailers, hc.responseTrailer) - grpcErrorToTrailer(mergedTrailers, hc.protobuf, err) + grpcErrorToTrailer(hc.marshaler.ctx, mergedTrailers, hc.protobuf, err) if hc.web && !hc.wroteToBody && len(hc.responseHeader) == 0 { // We're using gRPC-Web, we haven't yet written to the body, and there are no // custom headers. That means we can send a "trailers-only" response and send @@ -545,7 +576,7 @@ func (hc *grpcHandlerConn) Close(err error) (retErr error) { if err := hc.marshaler.MarshalWebTrailers(mergedTrailers); err != nil { return err } - return nil // must be a literal nil: nil *Error is a non-nil error + return nil // must be a literal nil: nil *connect.Error is a non-nil error } // We're using standard gRPC. Even if we haven't written to the body and // we're sending a "trailers-only" response, we must send trailing metadata @@ -574,9 +605,9 @@ type grpcMarshaler struct { envelopeWriter } -func (m *grpcMarshaler) MarshalWebTrailers(trailer http.Header) *Error { - raw := m.bufferPool.Get() - defer m.bufferPool.Put(raw) +func (m *grpcMarshaler) MarshalWebTrailers(trailer http.Header) *connect.Error { + raw := bufferpool.Get() + defer bufferpool.Put(raw) for key, values := range trailer { // Per the Go specification, keys inserted during iteration may be produced // later in the iteration or may be skipped. For safety, avoid mutating the @@ -589,7 +620,7 @@ func (m *grpcMarshaler) MarshalWebTrailers(trailer http.Header) *Error { trailer[lower] = values } if err := trailer.Write(raw); err != nil { - return errorf(CodeInternal, "format trailers: %w", err) + return connect.Errorf(connect.CodeInternal, "format trailers: %s", err).WithCause(err) } return m.Write(&envelope{ Data: raw, @@ -604,7 +635,7 @@ type grpcUnmarshaler struct { webTrailer http.Header } -func (u *grpcUnmarshaler) Unmarshal(message any) *Error { +func (u *grpcUnmarshaler) Unmarshal(message any) *connect.Error { err := u.envelopeReader.Unmarshal(message) if err == nil { return nil @@ -615,26 +646,26 @@ func (u *grpcUnmarshaler) Unmarshal(message any) *Error { env := u.last data := env.Data u.last.Data = nil // don't keep a reference to it - defer u.bufferPool.Put(data) + defer bufferpool.Put(data) if !u.web || !env.IsSet(grpcFlagEnvelopeTrailer) { - return errorf(CodeInternal, "protocol error: invalid envelope flags %d", env.Flags) + return connect.Errorf(connect.CodeInternal, "protocol error: invalid envelope flags %d", env.Flags) } // Per the gRPC-Web specification, trailers should be encoded as an HTTP/1 // headers block _without_ the terminating newline. To make the headers // parseable by net/textproto, we need to add the newline. if err := data.WriteByte('\n'); err != nil { - return errorf(CodeInternal, "unmarshal web trailers: %w", err) + return connect.Errorf(connect.CodeInternal, "unmarshal web trailers: %s", err).WithCause(err) } bufferedReader := bufio.NewReader(data) mimeReader := textproto.NewReader(bufferedReader) mimeHeader, mimeErr := mimeReader.ReadMIMEHeader() if mimeErr != nil { - return errorf( - CodeInternal, - "gRPC-Web protocol error: trailers invalid: %w", + return connect.Errorf( + connect.CodeInternal, + "gRPC-Web protocol error: trailers invalid: %s", mimeErr, - ) + ).WithCause(mimeErr) } u.webTrailer = http.Header(mimeHeader) return errSpecialEnvelope @@ -650,9 +681,9 @@ func grpcValidateResponse( availableCompressors readOnlyCompressionPools, web bool, codecName string, -) *Error { +) *connect.Error { if response.StatusCode != http.StatusOK { - return errorf(httpToCode(response.StatusCode), "HTTP status %v", response.Status) + return connect.Errorf(httpToCode(response.StatusCode), "HTTP status %v", response.Status) } if err := grpcValidateResponseContentType( web, @@ -662,13 +693,13 @@ func grpcValidateResponse( return err } if compression := getHeaderCanonical(response.Header, grpcHeaderCompression); compression != "" && - compression != compressionIdentity && + compression != connect.CompressionNameIdentity && !availableCompressors.Contains(compression) { // Per https://github.com/grpc/grpc/blob/master/doc/compression.md, we - // should return CodeInternal and specify acceptable compression(s) (in + // should return connect.CodeInternal and specify acceptable compression(s) (in // addition to setting the Grpc-Accept-Encoding header). - return errorf( - CodeInternal, + return connect.Errorf( + connect.CodeInternal, "unknown encoding %q: accepted encodings are %v", compression, availableCompressors.CommaSeparatedNames(), @@ -686,18 +717,18 @@ func grpcValidateResponse( // // A nil error is only returned when a grpc-status key IS present, but it // indicates a code of zero (no error). If no grpc-status key is present, this -// returns a non-nil *Error that wraps errTrailersWithoutGRPCStatus. -func grpcErrorForTrailer(protobuf Codec, trailer http.Header) *Error { +// returns a non-nil *connect.Error that wraps errTrailersWithoutGRPCStatus. +func grpcErrorForTrailer(ctx context.Context, protobuf connect.Codec, trailer http.Header) *connect.Error { codeHeader := getHeaderCanonical(trailer, grpcHeaderStatus) if codeHeader == "" { // If there are no trailers at all, that's an internal error. // But if it's an error determining the status code from the // trailers, it's unknown. - code := CodeUnknown + code := connect.CodeUnknown if len(trailer) == 0 { - code = CodeInternal + code = connect.CodeInternal } - return NewError(code, errTrailersWithoutGRPCStatus) + return connect.Errorf(code, "%s", errTrailersWithoutGRPCStatus).WithCause(errTrailersWithoutGRPCStatus) } if codeHeader == "0" { return nil @@ -705,35 +736,113 @@ func grpcErrorForTrailer(protobuf Codec, trailer http.Header) *Error { code, err := strconv.ParseUint(codeHeader, 10 /* base */, 32 /* bitsize */) if err != nil { - return errorf(CodeUnknown, "protocol error: invalid error code %q", codeHeader) + return connect.Errorf(connect.CodeUnknown, "protocol error: invalid error code %q", codeHeader) } message, err := grpcPercentDecode(getHeaderCanonical(trailer, grpcHeaderMessage)) if err != nil { - return errorf(CodeInternal, "protocol error: invalid error message %q", message) + return connect.Errorf(connect.CodeInternal, "protocol error: invalid error message %q", message) } - retErr := NewWireError(Code(code), errors.New(message)) + retErr := connect.NewError(connect.Code(code), message).WithRemote() detailsBinaryEncoded := getHeaderCanonical(trailer, grpcHeaderDetails) if len(detailsBinaryEncoded) > 0 { - detailsBinary, err := DecodeBinaryHeader(detailsBinaryEncoded) + detailsBinary, err := connect.DecodeBinaryHeader(detailsBinaryEncoded) if err != nil { - return errorf(CodeInternal, "server returned invalid grpc-status-details-bin trailer: %w", err) - } - var status statusv1.Status - if err := protobuf.Unmarshal(detailsBinary, &status); err != nil { - return errorf(CodeInternal, "server returned invalid protobuf for error details: %w", err) + return connect.Errorf(connect.CodeInternal, "server returned invalid grpc-status-details-bin trailer: %s", err).WithCause(err) } - for _, d := range status.GetDetails() { - retErr.details = append(retErr.details, &ErrorDetail{pbAny: d}) + statusCode, statusMessage, details, err := grpcUnmarshalStatus(ctx, protobuf, detailsBinary) + if err != nil { + return connect.Errorf(connect.CodeInternal, "server returned invalid protobuf for error details: %s", err).WithCause(err) } // Prefer the Protobuf-encoded data to the headers (grpc-go does this too). - retErr.code = Code(status.GetCode()) //nolint:gosec // No information loss - retErr.err = errors.New(status.GetMessage()) + retErr = connect.NewError(connect.Code(statusCode), statusMessage).WithRemote() + for _, d := range details { + detail, _ := connectproto.NewErrorDetail(d) // an *anypb.Any never fails + retErr = retErr.WithDetail(detail) + } } return retErr } +// grpcMarshalStatus encodes a google.rpc.Status message. The message has only +// three fields, so we encode it directly with protowire rather than depend on +// a generated type that would register itself in the global proto registry. +func grpcMarshalStatus(ctx context.Context, protobuf connect.Codec, code uint32, message string, details []*anypb.Any) ([]byte, error) { + var buf []byte + if code != 0 { + buf = protowire.AppendTag(buf, grpcStatusFieldCode, protowire.VarintType) + buf = protowire.AppendVarint(buf, uint64(code)) + } + if message != "" { + buf = protowire.AppendTag(buf, grpcStatusFieldMessage, protowire.BytesType) + buf = protowire.AppendString(buf, message) + } + buffer := bufferpool.Get() + defer bufferpool.Put(buffer) + for _, detail := range details { + if err := protobuf.MarshalWrite(ctx, buffer, detail); err != nil { + return nil, err + } + buf = protowire.AppendTag(buf, grpcStatusFieldDetails, protowire.BytesType) + buf = protowire.AppendBytes(buf, buffer.Bytes()) + buffer.Reset() + } + return buf, nil +} + +// grpcUnmarshalStatus decodes a google.rpc.Status message, returning its code, +// message, and details. Unknown fields are skipped so the decoder stays +// forward-compatible with senders that add fields. +func grpcUnmarshalStatus(ctx context.Context, protobuf connect.Codec, data []byte) (uint32, string, []*anypb.Any, error) { + var ( + code uint32 + message string + details []*anypb.Any + ) + for len(data) > 0 { + num, typ, size := protowire.ConsumeTag(data) + if size < 0 { + return 0, "", nil, protowire.ParseError(size) + } + data = data[size:] + switch { + case num == grpcStatusFieldCode && typ == protowire.VarintType: + value, size := protowire.ConsumeVarint(data) + if size < 0 { + return 0, "", nil, protowire.ParseError(size) + } + code = uint32(value) //nolint:gosec // status code is wire-bounded + data = data[size:] + case num == grpcStatusFieldMessage && typ == protowire.BytesType: + value, size := protowire.ConsumeString(data) + if size < 0 { + return 0, "", nil, protowire.ParseError(size) + } + message = value + data = data[size:] + case num == grpcStatusFieldDetails && typ == protowire.BytesType: + value, size := protowire.ConsumeBytes(data) + if size < 0 { + return 0, "", nil, protowire.ParseError(size) + } + detail := &anypb.Any{} + if err := protobuf.UnmarshalRead(ctx, bytes.NewReader(value), detail); err != nil { + return 0, "", nil, err + } + details = append(details, detail) + data = data[size:] + default: + size := protowire.ConsumeFieldValue(num, typ, data) + if size < 0 { + return 0, "", nil, protowire.ParseError(size) + } + data = data[size:] + } + } + return code, message, details, nil +} + func grpcParseTimeout(timeout string) (time.Duration, error) { if timeout == "" { return 0, errNoTimeout @@ -813,7 +922,7 @@ func grpcTimeoutUnitLookup(unit byte) (time.Duration, error) { func grpcCodecForContentType(web bool, contentType string) string { if (!web && contentType == grpcContentTypeDefault) || (web && contentType == grpcWebContentTypeDefault) { // implicitly protobuf - return codecNameProto + return connect.CodecNameProto } prefix := grpcContentTypePrefix if web { @@ -826,7 +935,7 @@ func grpcContentTypeForCodecName(web bool, name string) string { if web { return grpcWebContentTypePrefix + name } - if name == codecNameProto { + if name == connect.CodecNameProto { // For compatibility with Google Cloud Platform's frontends, prefer an // implicit default codec. See // https://github.com/connectrpc/connect-go/pull/655#issuecomment-1915754523 @@ -836,46 +945,38 @@ func grpcContentTypeForCodecName(web bool, name string) string { return grpcContentTypePrefix + name } -func grpcErrorToTrailer(trailer http.Header, protobuf Codec, err error) { +func grpcErrorToTrailer(ctx context.Context, trailer http.Header, protobuf connect.Codec, err error) { if err == nil { setHeaderCanonical(trailer, grpcHeaderStatus, "0") // zero is the gRPC OK status return } - if connectErr, ok := asError(err); ok && !connectErr.wireErr { - mergeNonProtocolHeaders(trailer, connectErr.meta) - } var ( - status = grpcStatusForError(err) - code = status.GetCode() - message = status.GetMessage() + code = uint32(connect.CodeUnknown) + message = err.Error() bin []byte ) - if len(status.Details) > 0 { - var binErr error - bin, binErr = protobuf.Marshal(status) - if binErr != nil { - code = int32(CodeInternal) - message = fmt.Sprintf("marshal protobuf status: %v", binErr) + if connectErr, ok := asError(err); ok { + code = uint32(connectErr.Code()) + message = connectErr.Message() + if rawDetails := connectErr.Details(); len(rawDetails) > 0 { + details := make([]*anypb.Any, 0, len(rawDetails)) + for _, detail := range rawDetails { + details = append(details, connectproto.ErrorDetailToAny(detail)) + } + var binErr error + bin, binErr = grpcMarshalStatus(ctx, protobuf, code, message, details) + if binErr != nil { + code = uint32(connect.CodeInternal) + message = fmt.Sprintf("marshal protobuf status: %v", binErr) + bin = nil + } } } setHeaderCanonical(trailer, grpcHeaderStatus, strconv.Itoa(int(code))) setHeaderCanonical(trailer, grpcHeaderMessage, grpcPercentEncode(message)) if len(bin) > 0 { - setHeaderCanonical(trailer, grpcHeaderDetails, EncodeBinaryHeader(bin)) - } -} - -func grpcStatusForError(err error) *statusv1.Status { - status := &statusv1.Status{ - Code: int32(CodeUnknown), - Message: err.Error(), - } - if connectErr, ok := asError(err); ok { - status.Code = int32(connectErr.Code()) //nolint:gosec // No information loss - status.Message = connectErr.Message() - status.Details = connectErr.detailsAsAny() + setHeaderCanonical(trailer, grpcHeaderDetails, connect.EncodeBinaryHeader(bin)) } - return status } // grpcPercentEncode follows RFC 3986 Section 2.1 and the gRPC HTTP/2 spec. @@ -980,26 +1081,26 @@ func validateHex(input string) error { return nil } -func grpcValidateResponseContentType(web bool, requestCodecName string, responseContentType string) *Error { +func grpcValidateResponseContentType(web bool, requestCodecName string, responseContentType string) *connect.Error { // Responses must have valid content-type that indicates same codec as the request. bare, prefix := grpcContentTypeDefault, grpcContentTypePrefix if web { bare, prefix = grpcWebContentTypeDefault, grpcWebContentTypePrefix } if responseContentType == prefix+requestCodecName || - (requestCodecName == codecNameProto && responseContentType == bare) { + (requestCodecName == connect.CodecNameProto && responseContentType == bare) { return nil } expectedContentType := bare - if requestCodecName != codecNameProto { + if requestCodecName != connect.CodecNameProto { expectedContentType = prefix + requestCodecName } - code := CodeInternal + code := connect.CodeInternal if responseContentType != bare && !strings.HasPrefix(responseContentType, prefix) { // Doesn't even look like a gRPC response? Use code "unknown". - code = CodeUnknown + code = connect.CodeUnknown } - return errorf( + return connect.Errorf( code, "invalid content-type: %q; expecting %q", responseContentType, diff --git a/protocol_grpc_test.go b/connecthttp/protocol_grpc_test.go similarity index 71% rename from protocol_grpc_test.go rename to connecthttp/protocol_grpc_test.go index 19ba7596..05189cd9 100644 --- a/protocol_grpc_test.go +++ b/connecthttp/protocol_grpc_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "errors" @@ -20,13 +20,17 @@ import ( "math" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "testing/quick" "time" "unicode/utf8" - "connectrpc.com/connect/internal/assert" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectproto" + "connectrpc.com/connect/v2/internal/assert" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" "github.com/google/go-cmp/cmp" ) @@ -34,8 +38,7 @@ func TestGRPCHandlerSender(t *testing.T) { t.Parallel() newConn := func(web bool) *grpcHandlerConn { responseWriter := httptest.NewRecorder() - protobufCodec := &protoBinaryCodec{} - bufferPool := newBufferPool() + protobufCodec := &connectproto.BinaryCodec{} request := httptest.NewRequestWithContext( t.Context(), http.MethodPost, @@ -43,15 +46,13 @@ func TestGRPCHandlerSender(t *testing.T) { strings.NewReader(""), ) return &grpcHandlerConn{ - spec: Spec{}, - web: web, - bufferPool: bufferPool, - protobuf: protobufCodec, + spec: connect.Spec{}, + web: web, + protobuf: protobufCodec, marshaler: grpcMarshaler{ envelopeWriter: envelopeWriter{ - sender: writeSender{writer: responseWriter}, - codec: protobufCodec, - bufferPool: bufferPool, + sender: writeSender{writer: responseWriter}, + codec: protobufCodec, }, }, responseWriter: responseWriter, @@ -60,9 +61,8 @@ func TestGRPCHandlerSender(t *testing.T) { request: request, unmarshaler: grpcUnmarshaler{ envelopeReader: envelopeReader{ - reader: request.Body, - codec: protobufCodec, - bufferPool: bufferPool, + reader: request.Body, + codec: protobufCodec, }, }, } @@ -83,7 +83,7 @@ func testGRPCHandlerConnMetadata(t *testing.T, conn handlerConnCloser) { t.Helper() expectHeaders := conn.ResponseHeader().Clone() expectTrailers := conn.ResponseTrailer().Clone() - conn.Close(NewError(CodeUnavailable, errors.New("oh no"))) + conn.Close(connect.NewError(connect.CodeUnavailable, "oh no")) if diff := cmp.Diff(expectHeaders, conn.ResponseHeader()); diff != "" { t.Errorf("headers changed:\n%s", diff) } @@ -182,8 +182,7 @@ func TestGRPCWebTrailerMarshalling(t *testing.T) { responseWriter := httptest.NewRecorder() marshaler := grpcMarshaler{ envelopeWriter: envelopeWriter{ - sender: writeSender{writer: responseWriter}, - bufferPool: newBufferPool(), + sender: writeSender{writer: responseWriter}, }, } trailer := http.Header{} @@ -239,136 +238,136 @@ func TestGRPCValidateResponseContentType(t *testing.T) { web bool codecName string responseContentType string - expectCode Code + expectCode connect.Code }{ // Allowed content-types { - codecName: codecNameProto, + codecName: connect.CodecNameProto, responseContentType: "application/grpc", }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, responseContentType: "application/grpc+proto", }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, responseContentType: "application/grpc+json", }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, web: true, responseContentType: "application/grpc-web", }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, web: true, responseContentType: "application/grpc-web+proto", }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, web: true, responseContentType: "application/grpc-web+json", }, // Mismatched response codec { - codecName: codecNameProto, + codecName: connect.CodecNameProto, responseContentType: "application/grpc+json", - expectCode: CodeInternal, + expectCode: connect.CodeInternal, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, responseContentType: "application/grpc", - expectCode: CodeInternal, + expectCode: connect.CodeInternal, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, responseContentType: "application/grpc+proto", - expectCode: CodeInternal, + expectCode: connect.CodeInternal, }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, web: true, responseContentType: "application/grpc-web+json", - expectCode: CodeInternal, + expectCode: connect.CodeInternal, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, web: true, responseContentType: "application/grpc-web", - expectCode: CodeInternal, + expectCode: connect.CodeInternal, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, web: true, responseContentType: "application/grpc-web+proto", - expectCode: CodeInternal, + expectCode: connect.CodeInternal, }, // Disallowed content-types { - codecName: codecNameProto, + codecName: connect.CodecNameProto, responseContentType: "application/proto", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, responseContentType: "application/grpc-web", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, responseContentType: "application/grpc-web+proto", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, responseContentType: "application/json", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, responseContentType: "application/grpc-web+json", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, web: true, responseContentType: "application/proto", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, web: true, responseContentType: "application/grpc", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, web: true, responseContentType: "application/grpc+proto", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, web: true, responseContentType: "application/json", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameJSON, + codecName: connect.CodecNameJSON, web: true, responseContentType: "application/grpc+json", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, { - codecName: codecNameProto, + codecName: connect.CodecNameProto, responseContentType: "some/garbage", - expectCode: CodeUnknown, + expectCode: connect.CodeUnknown, }, } for _, testCase := range testCases { - protocol := ProtocolGRPC + protocol := connect.ProtocolNameGRPC if testCase.web { - protocol = ProtocolGRPCWeb + protocol = connect.ProtocolNameGRPCWeb } testCaseName := fmt.Sprintf("%s_%s->%s", protocol, testCase.codecName, testCase.responseContentType) t.Run(testCaseName, func(t *testing.T) { @@ -381,9 +380,32 @@ func TestGRPCValidateResponseContentType(t *testing.T) { if testCase.expectCode == 0 { assert.Nil(t, err) } else if assert.NotNil(t, err) { - assert.Equal(t, CodeOf(err), testCase.expectCode) + assert.Equal(t, connect.CodeOf(err), testCase.expectCode) assert.True(t, strings.Contains(err.Message(), fmt.Sprintf("invalid content-type: %q; expecting", testCase.responseContentType))) } }) } } + +func TestGRPCErrorToTrailerDetailRoundTrip(t *testing.T) { + t.Parallel() + protobufCodec := &connectproto.BinaryCodec{} + detail, err := connectproto.NewErrorDetail(&pingv1.PingResponse{Number: 42}) + assert.Nil(t, err) + rpcErr := connect.NewError(connect.CodeInvalidArgument, "validation failed"). + WithDetail(detail) + + trailer := make(http.Header) + grpcErrorToTrailer(t.Context(), trailer, protobufCodec, rpcErr) + assert.Equal(t, trailer.Get(grpcHeaderStatus), strconv.Itoa(int(connect.CodeInvalidArgument))) + assert.NotEqual(t, trailer.Get(grpcHeaderDetails), "") + + gotErr := grpcErrorForTrailer(t.Context(), protobufCodec, trailer) + assert.NotNil(t, gotErr) + assert.Equal(t, gotErr.Code(), connect.CodeInvalidArgument) + details := gotErr.Details() + if assert.Equal(t, len(details), 1) { + assert.Equal(t, details[0].Type, detail.Type) + assert.Equal(t, details[0].Value, detail.Value) + } +} diff --git a/protocol_test.go b/connecthttp/protocol_test.go similarity index 84% rename from protocol_test.go rename to connecthttp/protocol_test.go index 5a97f291..126e3b9d 100644 --- a/protocol_test.go +++ b/connecthttp/protocol_test.go @@ -12,12 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -package connect +package connecthttp import ( "testing" - "connectrpc.com/connect/internal/assert" + "connectrpc.com/connect/v2/internal/assert" ) func TestCanonicalizeContentType(t *testing.T) { @@ -28,7 +28,8 @@ func TestCanonicalizeContentType(t *testing.T) { want string }{ {name: "uppercase should be normalized", arg: "APPLICATION/json", want: "application/json"}, - {name: "charset param should be treated as lowercase", arg: "application/json; charset=UTF-8", want: "application/json; charset=utf-8"}, + {name: "utf-8 charset param should be stripped", arg: "application/json; charset=UTF-8", want: "application/json"}, + {name: "non-utf-8 charset param should be lowercased", arg: "application/json; charset=Shift-JIS", want: "application/json; charset=shift-jis"}, {name: "non charset param should not be changed", arg: "multipart/form-data; boundary=fooBar", want: "multipart/form-data; boundary=fooBar"}, {name: "no parameters should be normalized", arg: "APPLICATION/json; ", want: "application/json"}, } diff --git a/connecthttp/server_stream.go b/connecthttp/server_stream.go new file mode 100644 index 00000000..f9e30624 --- /dev/null +++ b/connecthttp/server_stream.go @@ -0,0 +1,140 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connecthttp + +import ( + "context" + "errors" + "io" + "maps" + "net/http" + + "connectrpc.com/connect/v2" +) + +// handlerStream adapts a [streamingHandlerConn] to [connect.ServerStream]. It +// flushes the response metadata from [connect.CallInfo] onto the wire conn +// before the first Send (the conn writes headers on first write) and owns the +// trailing io.EOF for unary receives. +type handlerStream struct { + conn streamingHandlerConn + info *connect.CallInfo + unary bool + singleRequest bool + + headerFlushed bool + trailerFlushed bool + rxEnd bool +} + +func (s *handlerStream) flushHeader() { + if s.headerFlushed { + return + } + s.headerFlushed = true + toHTTPHeader(s.conn.ResponseHeader(), s.info.ResponseHeader()) +} + +func (s *handlerStream) flushTrailer() { + if s.trailerFlushed { + return + } + s.trailerFlushed = true + toHTTPHeader(s.conn.ResponseTrailer(), s.info.ResponseTrailer()) +} + +func (s *handlerStream) Receive(dst any) error { + if !s.singleRequest { + return s.conn.Receive(dst) + } + if s.rxEnd { + return io.EOF + } + s.rxEnd = true + if err := s.conn.Receive(dst); err != nil { + if errors.Is(err, io.EOF) { + return connect.Errorf(connect.CodeUnimplemented, "unary request has zero messages") + } + return err + } + if err := s.conn.Receive(dst); err == nil { + return connect.Errorf(connect.CodeUnimplemented, "unary request has multiple messages") + } else if !errors.Is(err, io.EOF) { + return err + } + return nil +} + +func (s *handlerStream) SendHeaders() error { return nil } + +func (s *handlerStream) Send(msg any) error { + s.flushHeader() + if s.unary { + s.flushTrailer() + } + return s.conn.Send(msg) +} + +// newServerHandlerConfig builds a v1 handlerConfig for spec from the resolved +// server options, adapting the v2 codec/compressors to the in-package types. +func newServerHandlerConfig(spec connect.Spec, opts *options) *handlerConfig { + codecs := make(map[string]connect.Codec, len(opts.codecs)) + maps.Copy(codecs, opts.codecs) + pools := make(map[string]*compressionPool, len(opts.compressors)) + for name, compressor := range opts.compressors { + pools[name] = newCompressionPool(compressor) + } + return &handlerConfig{ + CompressionPools: pools, + CompressionNames: opts.compressorNames, + Codecs: codecs, + CompressMinBytes: opts.compressMinBytes, + Procedure: spec.Procedure, + Schema: spec.Schema, + RequireConnectProtocolHeader: opts.requireConnectProtocolHeader, + IdempotencyLevel: spec.IdempotencyLevel, + ReadMaxBytes: opts.readMaxBytes, + SendMaxBytes: opts.sendMaxBytes, + StreamType: spec.StreamType, + } +} + +// newProcedureHandler returns an [http.Handler] for a single procedure that +// negotiates the wire protocol (reusing the v1 [Handler.ServeHTTP] skeleton) +// and dispatches through [connect.Server.Call]. +func newProcedureHandler(server *connect.Server, spec connect.Spec, opts *options) http.Handler { + handlerCfg := newServerHandlerConfig(spec, opts.forSpec(spec)) + protocolHandlers := handlerCfg.newProtocolHandlers() + unary := spec.StreamType == connect.StreamTypeUnary + singleRequest := unary || spec.StreamType == connect.StreamTypeServer + implementation := func(ctx context.Context, conn streamingHandlerConn, info *connect.CallInfo) error { + info.Spec = spec + info.PeerAddr = conn.Peer().Addr + info.Protocol = conn.Peer().Protocol + fromHTTPHeader(info.RequestHeader(), conn.RequestHeader()) + stream := &handlerStream{conn: conn, info: info, unary: unary, singleRequest: singleRequest} + err := server.Call(ctx, spec.Procedure, info, stream) + stream.flushHeader() + stream.flushTrailer() + return err + } + return &handler{ + spec: handlerCfg.newSpec(), + implementation: implementation, + protocolHandlers: mappedMethodHandlers(protocolHandlers), + allowMethod: sortedAllowMethodValue(protocolHandlers), + acceptPost: sortedAcceptPostValue(protocolHandlers), + } +} diff --git a/connectinprocess/bench_test.go b/connectinprocess/bench_test.go new file mode 100644 index 00000000..f8113eff --- /dev/null +++ b/connectinprocess/bench_test.go @@ -0,0 +1,69 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connectinprocess_test + +import ( + "errors" + "io" + "testing" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectinprocess" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + pingv1connect "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" +) + +func BenchmarkInProcessUnary(b *testing.B) { + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, pingServer{}) + transport := connectinprocess.New(handler) + client := pingv1connect.NewPingServiceClient(connect.NewClient(transport)) + req := &pingv1.PingRequest{Number: 42, Text: "hello"} + ctx := b.Context() + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err := client.Ping(ctx, req); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkInProcessServerStreaming(b *testing.B) { + const messages = 8 + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, pingServer{}) + transport := connectinprocess.New(handler) + client := pingv1connect.NewPingServiceClient(connect.NewClient(transport)) + ctx := b.Context() + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + stream, err := client.CountUp(ctx, &pingv1.CountUpRequest{Number: messages}) + if err != nil { + b.Fatal(err) + } + for { + if _, err := stream.Receive(); err != nil { + if errors.Is(err, io.EOF) { + break + } + b.Fatal(err) + } + } + } +} diff --git a/connectinprocess/connectinprocess.go b/connectinprocess/connectinprocess.go new file mode 100644 index 00000000..e14aa7b1 --- /dev/null +++ b/connectinprocess/connectinprocess.go @@ -0,0 +1,101 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package connectinprocess provides an in-process [connect.Transport]. The +// transport dispatches every RPC directly to a [connect.Server] inside the +// same process, completely bypassing the network. It is ideal for testing and +// specific production workloads. +package connectinprocess + +import ( + "context" + "errors" + "fmt" + + "connectrpc.com/connect/v2" + "google.golang.org/protobuf/proto" +) + +// CopyFunc copies a message from src into dst. The transport uses it to +// transfer each request and response between the client and server halves +// of an in-process RPC. +// +// The default is [ProtoCopy], a safe deep copy for Protobuf message. Override +// it with [WithCopyFunc]. +// +// A CopyFunc must be safe to call from independent goroutines. +type CopyFunc func(dst, src any) error + +// Option configures a Transport built by [New]. +type Option interface{ apply(*options) } + +// New returns a [connect.Transport] that dispatches every RPC in-process +// to the given server. Pass the returned transport into +// [connect.NewClient] when constructing your generated service clients. +func New(server *connect.Server, opts ...Option) connect.Transport { + resolved := options{copy: ProtoCopy} + for _, opt := range opts { + opt.apply(&resolved) + } + return &transport{ + server: server, + copy: resolved.copy, + } +} + +// WithCopyFunc replaces the default [ProtoCopy] strategy. +func WithCopyFunc(fn CopyFunc) Option { + return optionFunc(func(o *options) { o.copy = fn }) +} + +// ProtoCopy is the default [CopyFunc]. It uses [proto.Reset] followed by +// [proto.Merge] to perform a safe deep copy, so the client and server never +// share message state. It also bridges dynamic and generated messages of the +// same logical type. +func ProtoCopy(dst, src any) error { + dstMsg, ok := dst.(proto.Message) + if !ok { + return fmt.Errorf("connectinprocess: ProtoCopy dst is %T, not a proto.Message", dst) + } + srcMsg, ok := src.(proto.Message) + if !ok { + return fmt.Errorf("connectinprocess: ProtoCopy src is %T, not a proto.Message", src) + } + proto.Reset(dstMsg) + proto.Merge(dstMsg, srcMsg) + return nil +} + +type transport struct { + server *connect.Server + copy CopyFunc +} + +func (t *transport) NewClientStream(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + if t.server == nil { + return nil, errors.New("connectinprocess: nil server") + } + if spec.StreamType == connect.StreamTypeUnary { + return newUnaryClientStream(ctx, t, spec), nil + } + return &clientStream{p: newStreamPair(ctx, t, spec)}, nil +} + +type options struct { + copy CopyFunc +} + +type optionFunc func(*options) + +func (f optionFunc) apply(o *options) { f(o) } diff --git a/connectinprocess/connectinprocess_test.go b/connectinprocess/connectinprocess_test.go new file mode 100644 index 00000000..d08b9270 --- /dev/null +++ b/connectinprocess/connectinprocess_test.go @@ -0,0 +1,739 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connectinprocess_test + +import ( + "context" + "errors" + "io" + "runtime" + "strings" + "testing" + "time" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectinprocess" + pingv1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + pingv1connect "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/dynamicpb" +) + +func TestInProcessUnary(t *testing.T) { + t.Parallel() + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, pingServer{}) + + transport := connectinprocess.New(handler) + client := pingv1connect.NewPingServiceClient(connect.NewClient(transport)) + + resp, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 42, Text: "hello"}) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if got, want := resp.GetNumber(), int64(42); got != want { + t.Errorf("Number = %d, want %d", got, want) + } + if got, want := resp.GetText(), "hello"; got != want { + t.Errorf("Text = %q, want %q", got, want) + } +} + +// TestHandlerNestedCallDropsCallerHeaders verifies a nested outbound call +// from a handler does not inherit the caller's request headers. +func TestHandlerNestedCallDropsCallerHeaders(t *testing.T) { + t.Parallel() + probeServer := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(probeServer, probePingServer{}) + probeClient := pingv1connect.NewPingServiceClient(connect.NewClient(connectinprocess.New(probeServer))) + + frontServer := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(frontServer, frontPingServer{probe: probeClient}) + frontClient := pingv1connect.NewPingServiceClient(connect.NewClient(connectinprocess.New(frontServer))) + + ctx, callInfo := connect.NewClientContext(t.Context()) + callInfo.RequestHeader().Set("Authorization", "secret") + + resp, err := frontClient.Ping(ctx, &pingv1.PingRequest{}) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if resp.GetText() != "" { + t.Errorf("nested call carried the caller's Authorization %q; the handler context leaked the client CallInfo", resp.GetText()) + } +} + +// TestWithCopyFuncOverride verifies WithCopyFunc replaces the default copier. +func TestWithCopyFuncOverride(t *testing.T) { + t.Parallel() + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, pingServer{}) + + noop := func(dst, src any) error { return nil } + transport := connectinprocess.New(handler, connectinprocess.WithCopyFunc(noop)) + client := pingv1connect.NewPingServiceClient(connect.NewClient(transport)) + + // The no-op copier drops both payloads, so the response is zero-valued. + resp, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 7, Text: "x"}) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if resp.GetNumber() != 0 || resp.GetText() != "" { + t.Errorf("expected zero-value response with no-op copier, got Number=%d Text=%q", resp.GetNumber(), resp.GetText()) + } +} + +// TestTrailerCapturedBeforeDispatchResolvesAfter verifies metadata handles +// captured before the call observe values set during it. +func TestTrailerCapturedBeforeDispatchResolvesAfter(t *testing.T) { + t.Parallel() + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, pingServer{ + respHeaders: map[string]string{"X-Custom-Header": "header-value"}, + respTrailers: map[string]string{"X-Audit-Id": "audit-123"}, + }) + + transport := connectinprocess.New(handler) + client := pingv1connect.NewPingServiceClient(connect.NewClient(transport)) + + ctx, info := connect.NewClientContext(t.Context()) + header := info.ResponseHeader() + trailer := info.ResponseTrailer() + + if _, err := client.Ping(ctx, &pingv1.PingRequest{}); err != nil { + t.Fatalf("Ping: %v", err) + } + + if got := header.Get("X-Custom-Header"); got != "header-value" { + t.Errorf("ResponseHeader captured early: Get = %q, want %q", got, "header-value") + } + if got := trailer.Get("X-Audit-Id"); got != "audit-123" { + t.Errorf("ResponseTrailer captured early: Get = %q, want %q", got, "audit-123") + } +} + +func TestStreamContextCancelAborts(t *testing.T) { + t.Parallel() + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, pingServer{}) + + transport := connectinprocess.New(handler) + client := pingv1connect.NewPingServiceClient(connect.NewClient(transport)) + + ctx, cancel := context.WithCancel(t.Context()) + stream, err := client.CumSum(ctx) + if err != nil { + t.Fatalf("CumSum: %v", err) + } + cancel() + if _, err := stream.Receive(); !errors.Is(err, context.Canceled) { + t.Errorf("Receive after cancel = %v, want context.Canceled", err) + } +} + +// TestStreamCloseSendEOFsSubsequentSend verifies Send after CloseSend returns +// io.EOF while Receive still works. +func TestStreamCloseSendEOFsSubsequentSend(t *testing.T) { + t.Parallel() + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, pingServer{}) + + transport := connectinprocess.New(handler) + ctx, _ := connect.NewClientContext(t.Context()) + stream, err := transport.NewClientStream(ctx, connect.Spec{ + StreamType: connect.StreamTypeUnary, + Procedure: pingv1connect.PingServicePingProcedure, + }) + if err != nil { + t.Fatalf("NewClientStream: %v", err) + } + if err := stream.Send(&pingv1.PingRequest{Number: 1}); err != nil { + t.Fatalf("Send: %v", err) + } + if err := stream.CloseSend(); err != nil { + t.Fatalf("CloseSend: %v", err) + } + if err := stream.Send(&pingv1.PingRequest{Number: 2}); !errors.Is(err, io.EOF) { + t.Errorf("Send after CloseSend = %v, want io.EOF", err) + } + var resp pingv1.PingResponse + if err := stream.Receive(&resp); err != nil { + t.Fatalf("Receive after CloseSend: %v", err) + } + if resp.GetNumber() != 1 { + t.Errorf("Number = %d, want 1", resp.GetNumber()) + } +} + +func TestInProcessServerStreaming(t *testing.T) { + t.Parallel() + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, pingServer{}) + + transport := connectinprocess.New(handler) + client := pingv1connect.NewPingServiceClient(connect.NewClient(transport)) + + stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 5}) + if err != nil { + t.Fatalf("CountUp: %v", err) + } + var got []int64 + for { + msg, err := stream.Receive() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("Receive: %v", err) + } + got = append(got, msg.GetNumber()) + } + want := []int64{1, 2, 3, 4, 5} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("got[%d] = %d, want %d", i, got[i], want[i]) + } + } +} + +// TestInProcessServerStreamCloseReleasesHandler verifies abandoning a stream +// with Close, rather than reading to io.EOF, unblocks the handler. +func TestInProcessServerStreamCloseReleasesHandler(t *testing.T) { + t.Parallel() + done := make(chan struct{}) + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, &closeSignalPingServer{done: done}) + + transport := connectinprocess.New(handler) + client := pingv1connect.NewPingServiceClient(connect.NewClient(transport)) + + stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 1_000}) + if err != nil { + t.Fatalf("CountUp: %v", err) + } + if _, err := stream.Receive(); err != nil { + t.Fatalf("Receive: %v", err) + } + if err := stream.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + // Close is idempotent. + if err := stream.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("handler goroutine did not return after Close") + } +} +func TestInProcessClientStreaming(t *testing.T) { + t.Parallel() + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, pingServer{}) + + transport := connectinprocess.New(handler) + client := pingv1connect.NewPingServiceClient(connect.NewClient(transport)) + + stream, err := client.Sum(t.Context()) + if err != nil { + t.Fatalf("Sum: %v", err) + } + for _, n := range []int64{1, 2, 3, 4, 5} { + if err := stream.Send(&pingv1.SumRequest{Number: n}); err != nil { + t.Fatalf("Send: %v", err) + } + } + res, err := stream.CloseAndReceive() + if err != nil { + t.Fatalf("CloseAndReceive: %v", err) + } + if got, want := res.GetSum(), int64(15); got != want { + t.Errorf("Sum = %d, want %d", got, want) + } +} + +func TestInProcessBidiStreaming(t *testing.T) { + t.Parallel() + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, pingServer{}) + + transport := connectinprocess.New(handler) + client := pingv1connect.NewPingServiceClient(connect.NewClient(transport)) + + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatalf("CumSum: %v", err) + } + + inputs := []int64{10, 20, 30} + wantSums := []int64{10, 30, 60} + for i, n := range inputs { + if err := stream.Send(&pingv1.CumSumRequest{Number: n}); err != nil { + t.Fatalf("Send[%d]: %v", i, err) + } + res, err := stream.Receive() + if err != nil { + t.Fatalf("Receive[%d]: %v", i, err) + } + if got, want := res.GetSum(), wantSums[i]; got != want { + t.Errorf("Receive[%d].Sum = %d, want %d", i, got, want) + } + } + if err := stream.CloseSend(); err != nil { + t.Fatalf("CloseSend: %v", err) + } + if _, err := stream.Receive(); !errors.Is(err, io.EOF) { + t.Errorf("final Receive = %v, want io.EOF", err) + } +} + +func TestProtoCopyTypedToTyped(t *testing.T) { + t.Parallel() + src := &pingv1.PingRequest{Number: 42, Text: "hello"} + dst := &pingv1.PingRequest{} + if err := connectinprocess.ProtoCopy(dst, src); err != nil { + t.Fatalf("ProtoCopy: %v", err) + } + if got, want := dst.GetNumber(), int64(42); got != want { + t.Errorf("Number = %d, want %d", got, want) + } + if got, want := dst.GetText(), "hello"; got != want { + t.Errorf("Text = %q, want %q", got, want) + } +} + +func TestProtoCopyDynamicToTyped(t *testing.T) { + t.Parallel() + desc := (&pingv1.PingRequest{}).ProtoReflect().Descriptor() + src := dynamicpb.NewMessage(desc) + src.Set(desc.Fields().ByName("number"), protoreflect.ValueOfInt64(7)) + src.Set(desc.Fields().ByName("text"), protoreflect.ValueOfString("from-dynamic")) + + dst := &pingv1.PingRequest{} + if err := connectinprocess.ProtoCopy(dst, src); err != nil { + t.Fatalf("ProtoCopy: %v", err) + } + if got, want := dst.GetNumber(), int64(7); got != want { + t.Errorf("Number = %d, want %d", got, want) + } + if got, want := dst.GetText(), "from-dynamic"; got != want { + t.Errorf("Text = %q, want %q", got, want) + } +} + +func TestProtoCopyTypedToDynamic(t *testing.T) { + t.Parallel() + desc := (&pingv1.PingRequest{}).ProtoReflect().Descriptor() + src := &pingv1.PingRequest{Number: 99, Text: "to-dynamic"} + dst := dynamicpb.NewMessage(desc) + + if err := connectinprocess.ProtoCopy(dst, src); err != nil { + t.Fatalf("ProtoCopy: %v", err) + } + gotNum := dst.Get(desc.Fields().ByName("number")).Int() + if gotNum != 99 { + t.Errorf("Number = %d, want 99", gotNum) + } + gotText := dst.Get(desc.Fields().ByName("text")).String() + if gotText != "to-dynamic" { + t.Errorf("Text = %q, want %q", gotText, "to-dynamic") + } +} + +func TestProtoCopyRejectsNonProto(t *testing.T) { + t.Parallel() + t.Run("dst", func(t *testing.T) { + t.Parallel() + notProto := &struct{ X int }{X: 1} + err := connectinprocess.ProtoCopy(notProto, &pingv1.PingRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "ProtoCopy dst") { + t.Errorf("error = %q, want mention of dst", err.Error()) + } + }) + t.Run("src", func(t *testing.T) { + t.Parallel() + notProto := &struct{ X int }{X: 1} + err := connectinprocess.ProtoCopy(&pingv1.PingRequest{}, notProto) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "ProtoCopy src") { + t.Errorf("error = %q, want mention of src", err.Error()) + } + }) +} + +// TestUnaryHandlerPanicRecovered verifies a handler panic surfaces as +// CodeInternal rather than crashing the caller's goroutine. +func TestUnaryHandlerPanicRecovered(t *testing.T) { + t.Parallel() + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, panicPingServer{}) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connectinprocess.New(handler))) + + _, err := client.Ping(t.Context(), &pingv1.PingRequest{}) + if err == nil { + t.Fatal("expected an error from a panicking handler") + } + if got := connect.CodeOf(err); got != connect.CodeInternal { + t.Fatalf("code = %s, want CodeInternal", got) + } +} + +// TestUnaryHandlerRemoteErrorScrubbed verifies a forwarded remote error is +// scrubbed to CodeInternal with no message. +func TestUnaryHandlerRemoteErrorScrubbed(t *testing.T) { + t.Parallel() + handler := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(handler, remoteErrPingServer{}) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connectinprocess.New(handler))) + + _, err := client.Ping(t.Context(), &pingv1.PingRequest{}) + if got := connect.CodeOf(err); got != connect.CodeInternal { + t.Fatalf("code = %s, want CodeInternal (scrubbed)", got) + } + var ce *connect.Error + if errors.As(err, &ce) && ce.Message() != "" { + t.Errorf("message = %q, want empty (downstream detail must not leak)", ce.Message()) + } +} + +// TestClientStreamCloseAbortsServer verifies Close cancels the stream +// context, so a handler streaming indefinitely returns. +func TestClientStreamCloseAbortsServer(t *testing.T) { + t.Parallel() + handlerDone := make(chan struct{}) + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, &infiniteCountUpServer{done: handlerDone}) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connectinprocess.New(server))) + + stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 1}) + if err != nil { + t.Fatalf("CountUp: %v", err) + } + if _, err := stream.Receive(); err != nil { + t.Fatalf("Receive: %v", err) + } + if err := stream.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + select { + case <-handlerDone: + case <-time.After(5 * time.Second): + t.Fatal("server was not aborted by Close") + } +} + +// TestClientStreamCloseWithoutUse verifies closing a stream that was never +// sent on or received from does not leak goroutines. +// +//nolint:paralleltest // counts goroutines, which parallel siblings would skew +func TestClientStreamCloseWithoutUse(t *testing.T) { + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connectinprocess.New(server))) + + before := runtime.NumGoroutine() + for range 100 { + stream, err := client.CumSum(t.Context()) + if err != nil { + t.Fatalf("CumSum: %v", err) + } + if err := stream.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + } + deadline := time.Now().Add(5 * time.Second) + for runtime.NumGoroutine() > before+10 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if got := runtime.NumGoroutine(); got > before+10 { + t.Fatalf("goroutines leaked: %d before, %d after", before, got) + } +} + +// TestSetUnknownHandler exercises the Server.Call fallback for procedures +// with no registered method. +func TestSetUnknownHandler(t *testing.T) { + t.Parallel() + const unknownProcedure = "/connect.ping.v1.PingService/DoesNotExist" + unknownSpec := connect.Spec{ + StreamType: connect.StreamTypeUnary, + Procedure: unknownProcedure, + } + t.Run("default_unimplemented", func(t *testing.T) { + t.Parallel() + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + client := connect.NewClient(connectinprocess.New(server)) + err := client.CallUnary(t.Context(), unknownSpec, &pingv1.PingRequest{}, &pingv1.PingResponse{}) + if got, want := connect.CodeOf(err), connect.CodeUnimplemented; got != want { + t.Errorf("CodeOf = %v, want %v", got, want) + } + }) + t.Run("fallback_answers", func(t *testing.T) { + t.Parallel() + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + var gotSpec connect.Spec + server.SetUnknownHandler(func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + gotSpec = spec + var req pingv1.PingRequest + if err := stream.Receive(&req); err != nil { + return err + } + return stream.Send(&pingv1.PingResponse{Number: req.GetNumber(), Text: "fallback"}) + }) + client := connect.NewClient(connectinprocess.New(server)) + var res pingv1.PingResponse + if err := client.CallUnary(t.Context(), unknownSpec, &pingv1.PingRequest{Number: 42}, &res); err != nil { + t.Fatalf("CallUnary: %v", err) + } + if res.GetText() != "fallback" || res.GetNumber() != 42 { + t.Errorf("response = %d %q, want 42 %q", res.GetNumber(), res.GetText(), "fallback") + } + if gotSpec.Procedure != unknownProcedure { + t.Errorf("Spec.Procedure = %q, want %q", gotSpec.Procedure, unknownProcedure) + } + if gotSpec.StreamType != connect.StreamTypeBidi { + t.Errorf("Spec.StreamType = %v, want %v", gotSpec.StreamType, connect.StreamTypeBidi) + } + if gotSpec.Schema != nil { + t.Errorf("Spec.Schema = %v, want nil", gotSpec.Schema) + } + }) + t.Run("wrapped_by_interceptors", func(t *testing.T) { + t.Parallel() + var intercepted []string + interceptor := func(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + intercepted = append(intercepted, spec.Procedure) + return next(ctx, spec, stream) + } + } + server := connect.NewServer(interceptor) + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + server.SetUnknownHandler(func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + var req pingv1.PingRequest + if err := stream.Receive(&req); err != nil { + return err + } + return stream.Send(&pingv1.PingResponse{}) + }) + client := connect.NewClient(connectinprocess.New(server)) + if err := client.CallUnary(t.Context(), unknownSpec, &pingv1.PingRequest{}, &pingv1.PingResponse{}); err != nil { + t.Fatalf("CallUnary: %v", err) + } + if len(intercepted) != 1 || intercepted[0] != unknownProcedure { + t.Errorf("intercepted = %v, want [%q]", intercepted, unknownProcedure) + } + }) + t.Run("nil_restores_default", func(t *testing.T) { + t.Parallel() + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + server.SetUnknownHandler(func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + return stream.Send(&pingv1.PingResponse{}) + }) + server.SetUnknownHandler(nil) + client := connect.NewClient(connectinprocess.New(server)) + err := client.CallUnary(t.Context(), unknownSpec, &pingv1.PingRequest{}, &pingv1.PingResponse{}) + if got, want := connect.CodeOf(err), connect.CodeUnimplemented; got != want { + t.Errorf("CodeOf = %v, want %v", got, want) + } + }) + t.Run("registered_method_unaffected", func(t *testing.T) { + t.Parallel() + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + server.SetUnknownHandler(func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + return connect.NewError(connect.CodeInternal, "unknown handler must not serve registered methods") + }) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connectinprocess.New(server))) + res, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 42}) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if res.GetNumber() != 42 { + t.Errorf("Number = %d, want 42", res.GetNumber()) + } + }) +} + +// frontPingServer forwards Ping to a nested service on the handler context. +type frontPingServer struct { + pingServer + + probe pingv1connect.PingServiceClient +} + +func (s frontPingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { + return s.probe.Ping(ctx, req) +} + +// probePingServer echoes the received Authorization header in the response +// text. +type probePingServer struct { + pingServer +} + +func (probePingServer) Ping(ctx context.Context, _ *pingv1.PingRequest) (*pingv1.PingResponse, error) { + auth := "" + if info, ok := connect.CallInfoForServerContext(ctx); ok { + auth = info.RequestHeader().Get("Authorization") + } + return &pingv1.PingResponse{Text: auth}, nil +} + +type closeSignalPingServer struct { + pingv1connect.UnimplementedPingServiceHandler + + done chan struct{} +} + +func (s *closeSignalPingServer) CountUp(_ context.Context, req *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { + defer close(s.done) + for i := int64(1); i <= req.GetNumber(); i++ { + if err := stream.Send(&pingv1.CountUpResponse{Number: i}); err != nil { + return err + } + } + return nil +} + +type pingServer struct { + respHeaders map[string]string + respTrailers map[string]string +} + +func (s pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { + if info, ok := connect.CallInfoForServerContext(ctx); ok { + for k, v := range s.respHeaders { + info.ResponseHeader().Set(k, v) + } + for k, v := range s.respTrailers { + info.ResponseTrailer().Set(k, v) + } + } + return &pingv1.PingResponse{Number: req.GetNumber(), Text: req.GetText()}, nil +} + +func (pingServer) Fail(_ context.Context, _ *pingv1.FailRequest) (*pingv1.FailResponse, error) { + return nil, connect.Errorf(connect.CodeUnimplemented, "Fail") +} + +func (pingServer) Sum(_ context.Context, stream pingv1connect.PingServiceSumServerStream) (*pingv1.SumResponse, error) { + var total int64 + for { + req, err := stream.Receive() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + total += req.GetNumber() + } + return &pingv1.SumResponse{Sum: total}, nil +} + +func (pingServer) CountUp(_ context.Context, req *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { + for i := int64(1); i <= req.GetNumber(); i++ { + if err := stream.Send(&pingv1.CountUpResponse{Number: i}); err != nil { + return err + } + } + return nil +} + +func (pingServer) CumSum(_ context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { + var sum int64 + for { + req, err := stream.Receive() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + sum += req.GetNumber() + if err := stream.Send(&pingv1.CumSumResponse{Sum: sum}); err != nil { + return err + } + } +} + +// panicPingServer panics in Ping to exercise unary panic recovery. +type panicPingServer struct{ pingServer } + +func (panicPingServer) Ping(context.Context, *pingv1.PingRequest) (*pingv1.PingResponse, error) { + panic("boom") //nolint:forbidigo // exercises the transport's panic recovery +} + +// remoteErrPingServer forwards an upstream error already marked remote. +type remoteErrPingServer struct{ pingServer } + +func (remoteErrPingServer) Ping(context.Context, *pingv1.PingRequest) (*pingv1.PingResponse, error) { + return nil, connect.NewError(connect.CodePermissionDenied, "upstream secret").WithRemote() +} + +type infiniteCountUpServer struct { + pingv1connect.UnimplementedPingServiceHandler + + done chan struct{} +} + +func (s *infiniteCountUpServer) CountUp(_ context.Context, _ *pingv1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { + defer close(s.done) + for i := int64(1); ; i++ { + if err := stream.Send(&pingv1.CountUpResponse{Number: i}); err != nil { + return err + } + } +} + +// TestCallInfoSpec verifies the transport sets Spec on both sides' CallInfo. +func TestCallInfoSpec(t *testing.T) { + t.Parallel() + var gotSpec connect.Spec + interceptor := func(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + info, _ := connect.CallInfoForServerContext(ctx) + gotSpec = info.Spec + return next(ctx, spec, stream) + } + } + server := connect.NewServer(interceptor) + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + client := pingv1connect.NewPingServiceClient(connect.NewClient(connectinprocess.New(server))) + + ctx, info := connect.NewClientContext(t.Context()) + if _, err := client.Ping(ctx, &pingv1.PingRequest{}); err != nil { + t.Fatalf("Ping: %v", err) + } + if got, want := info.Spec.Procedure, pingv1connect.PingServicePingProcedure; got != want { + t.Errorf("client Spec.Procedure = %q, want %q", got, want) + } + if got, want := gotSpec.Procedure, pingv1connect.PingServicePingProcedure; got != want { + t.Errorf("server Spec.Procedure = %q, want %q", got, want) + } +} diff --git a/connectinprocess/stream.go b/connectinprocess/stream.go new file mode 100644 index 00000000..80cc2770 --- /dev/null +++ b/connectinprocess/stream.go @@ -0,0 +1,255 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connectinprocess + +import ( + "context" + "errors" + "io" + "sync" + + "connectrpc.com/connect/v2" +) + +// streamPair wires the client- and server-side halves of a streaming +// in-process RPC together. +// +// Messages flow through unbuffered channels, providing natural backpressure: +// each Send blocks until the peer's Receive consumes the message. The server +// runs on its own goroutine, which is started lazily upon the first call to +// SendHeaders, Send, or Receive. +// +// Lifecycle channels: +// - closeSendCh: Closed by the client's CloseSend. The server's Receive +// subsequently returns [io.EOF] once requestCh has drained. +// - serverDone: Closed by the dispatch goroutine after the server returns. +// This carries the final server error and signals that response metadata +// and trailers have safely synced into the client's CallInfo. +// +// Clients read responseCh until [io.EOF] or call Close to cancel the stream. +// +// Note: Unary RPCs bypass this structure to use a synchronous fast path +// (see unary.go). +type streamPair struct { + t *transport + spec connect.Spec + clientInfo *connect.CallInfo + serverInfo *connect.CallInfo + + // ctx is the stream context, derived from the call context. The server + // runs on it. Close cancels it via cancel. + ctx context.Context + cancel context.CancelFunc + + requestCh chan any + responseCh chan any + closeSendCh chan struct{} + serverDone chan struct{} + + dispatchOnce sync.Once + serverErr error + + closeSendOnce sync.Once +} + +func newStreamPair(ctx context.Context, t *transport, spec connect.Spec) *streamPair { + ctx, cancel := context.WithCancel(ctx) + clientInfo, _ := connect.CallInfoForClientContext(ctx) + pair := &streamPair{ + t: t, + spec: spec, + clientInfo: clientInfo, + serverInfo: &connect.CallInfo{Spec: spec}, + ctx: ctx, + cancel: cancel, + requestCh: make(chan any), + responseCh: make(chan any), + closeSendCh: make(chan struct{}), + serverDone: make(chan struct{}), + } + if pair.clientInfo != nil { + pair.clientInfo.Spec = spec + } + return pair +} + +// dispatch lazily launches the server goroutine. Safe to call from +// any Send/Receive entry point; subsequent calls are no-ops. +func (p *streamPair) dispatch() { + p.dispatchOnce.Do(func() { + if p.clientInfo != nil { + syncHeader(p.serverInfo.RequestHeader(), p.clientInfo.RequestHeader()) + } + go p.run() + }) +} + +// run executes the server and finalises the stream. Closing responseCh +// signals io.EOF to client.Receive. The defer order matters: metadata sync +// must happen before either channel is closed. +func (p *streamPair) run() { + defer close(p.serverDone) + defer close(p.responseCh) + defer func() { + if r := recover(); r != nil { + p.serverErr = connect.Errorf(connect.CodeInternal, "panic in server: %v", r) + } + if p.clientInfo != nil { + syncHeader(p.clientInfo.ResponseHeader(), p.serverInfo.ResponseHeader()) + syncHeader(p.clientInfo.ResponseTrailer(), p.serverInfo.ResponseTrailer()) + } + }() + hs := &serverStream{p: p} + p.serverErr = p.t.server.Call(p.ctx, p.spec.Procedure, p.serverInfo, hs) +} + +// clientStream is the client-side half of a streamPair. +type clientStream struct { + p *streamPair +} + +func (s *clientStream) SendHeaders() error { + s.p.dispatch() + return nil +} + +func (s *clientStream) Send(msg any) error { + select { + case <-s.p.closeSendCh: + return io.EOF + default: + s.p.dispatch() + } + select { + case <-s.p.closeSendCh: + return io.EOF + case <-s.p.serverDone: + return io.EOF + case <-s.p.ctx.Done(): + return s.p.ctx.Err() + case s.p.requestCh <- msg: + return nil + } +} + +func (s *clientStream) CloseSend() error { + s.p.closeSendOnce.Do(func() { + close(s.p.closeSendCh) + }) + return nil +} + +func (s *clientStream) Receive(dst any) error { + s.p.dispatch() + select { + case <-s.p.ctx.Done(): + return s.p.ctx.Err() + case msg, ok := <-s.p.responseCh: + if !ok { + // Server closed, release the stream context. + s.p.cancel() + if s.p.serverErr != nil { + return asClientErr(s.p.serverErr) + } + return io.EOF + } + return s.p.t.copy(dst, msg) + } +} + +func (s *clientStream) Close() error { + err := s.CloseSend() + s.p.cancel() + return err +} + +// serverStream is the server-side half of a streamPair. +type serverStream struct { + p *streamPair +} + +// SendHeaders is a no-op for the in-process transport. +func (s *serverStream) SendHeaders() error { return nil } + +func (s *serverStream) Receive(dst any) error { + // An aborted RPC is a cancellation of the call context, so ctx.Done + // reports it. closeSendCh is the clean half-close: report io.EOF once + // requestCh has drained, draining a request that raced with CloseSend. + select { + case <-s.p.ctx.Done(): + return s.p.ctx.Err() + case msg := <-s.p.requestCh: + return s.p.t.copy(dst, msg) + case <-s.p.closeSendCh: + select { + case msg := <-s.p.requestCh: + return s.p.t.copy(dst, msg) + default: + return io.EOF + } + } +} + +func (s *serverStream) Send(msg any) error { + select { + case <-s.p.ctx.Done(): + return s.p.ctx.Err() + case s.p.responseCh <- msg: + return nil + } +} + +// asClientErr converts a server's returned error into the verdict the +// client observes, mirroring the serialization and deserialization +// of a wire transport. +// +// To accurately reflect the client-side state, it applies the following rules: +// - Remote errors forwarded from an upstream call are scrubbed to +// [CodeInternal] to prevent leaking their original code, message, +// and details as the server's own verdict (see [Error.IsRemote]). +// - Standard errors (non-[*Error]) are converted to [CodeUnknown], +// except for context cancellation and deadline expiry, which retain +// their respective codes. +// - The resulting error is marked as remote, mimicking a client-side +// decoded error. +// +// The server's original [*Error] is left untouched so it can be safely +// used for server-side logging. +func asClientErr(err error) error { + if err == nil { + return nil + } + var cerr *connect.Error + if errors.As(err, &cerr) { + if cerr.IsRemote() { + return connect.NewError(connect.CodeInternal, "").WithCause(err).WithRemote() + } + return cerr.WithRemote() + } + code := connect.CodeUnknown + switch { + case errors.Is(err, context.Canceled): + code = connect.CodeCanceled + case errors.Is(err, context.DeadlineExceeded): + code = connect.CodeDeadlineExceeded + } + return connect.NewError(code, "").WithCause(err).WithRemote() +} + +func syncHeader(dst, src *connect.Header) { + for key, vals := range src.All() { + dst.SetValues(key, vals) + } +} diff --git a/connectinprocess/unary.go b/connectinprocess/unary.go new file mode 100644 index 00000000..fad2fd91 --- /dev/null +++ b/connectinprocess/unary.go @@ -0,0 +1,163 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connectinprocess + +import ( + "context" + "errors" + "io" + + "connectrpc.com/connect/v2" +) + +// unaryClientStream is the synchronous fast path for in-process unary RPCs. +// +// Instead of spawning a separate goroutine, the server logic executes +// synchronously during the client's first Receive call. The flow is: +// 1. The client calls Send, capturing the request into a local slot. +// 2. The client calls Receive, triggering the server dispatch. +// 3. The server's Receive consumes the captured request slot. +// 4. The server's Send writes the response into a local response slot. +// 5. The client's Receive copies that response into the caller's dst. +// +// Streaming RPCs cannot use this layout because the server must run an +// indefinite loop that interleaves with the user's Send and Receive calls +// (see clientStream and serverStream in stream.go). +type unaryClientStream struct { + t *transport + spec connect.Spec + ctx context.Context //nolint:containedctx // the lazy dispatch in Receive runs the server on the call context + clientInfo *connect.CallInfo + serverInfo *connect.CallInfo + + requestMsg any + responseMsg any + serverErr error + + sentOnce bool + sendClosed bool + rxEnd bool +} + +func newUnaryClientStream(ctx context.Context, t *transport, spec connect.Spec) *unaryClientStream { + clientInfo, _ := connect.CallInfoForClientContext(ctx) + stream := &unaryClientStream{ + t: t, + spec: spec, + ctx: ctx, + clientInfo: clientInfo, + serverInfo: &connect.CallInfo{Spec: spec}, + } + if stream.clientInfo != nil { + stream.clientInfo.Spec = spec + } + return stream +} + +func (s *unaryClientStream) SendHeaders() error { return nil } + +func (s *unaryClientStream) Send(msg any) error { + if s.sendClosed { + return io.EOF + } + if s.sentOnce { + return errors.New("connectinprocess: unary stream sent more than once") + } + s.sentOnce = true + s.requestMsg = msg + return nil +} + +func (s *unaryClientStream) CloseSend() error { + s.sendClosed = true + return nil +} + +func (s *unaryClientStream) Close() error { + s.rxEnd = true + return nil +} + +func (s *unaryClientStream) Receive(dst any) error { + if s.rxEnd { + return io.EOF + } + if err := s.dispatch(s.ctx); err != nil { + s.rxEnd = true + return asClientErr(err) + } + if s.responseMsg == nil { + s.rxEnd = true + return connect.Errorf(connect.CodeUnimplemented, "unary stream has no message") + } + if err := s.t.copy(dst, s.responseMsg); err != nil { + s.rxEnd = true + return err + } + s.responseMsg = nil + s.rxEnd = true + return nil +} + +// dispatch runs the server synchronously. Receive gates it behind rxEnd, so +// it runs exactly once per stream. The server reads the captured request via +// unaryHandlerStream.Receive and writes its response via +// unaryHandlerStream.Send. +func (s *unaryClientStream) dispatch(ctx context.Context) error { + if s.clientInfo != nil { + syncHeader(s.serverInfo.RequestHeader(), s.clientInfo.RequestHeader()) + } + // Recover server panics into a CodeInternal error, matching the + // streaming path (streamPair.run) and connecthttp. Otherwise the panic + // would propagate synchronously into the client's calling goroutine. + func() { + defer func() { + if r := recover(); r != nil { + s.serverErr = connect.Errorf(connect.CodeInternal, "panic in server: %v", r) + } + }() + hs := unaryHandlerStream{s: s} + s.serverErr = s.t.server.Call(ctx, s.spec.Procedure, s.serverInfo, hs) + }() + if s.clientInfo != nil { + syncHeader(s.clientInfo.ResponseHeader(), s.serverInfo.ResponseHeader()) + syncHeader(s.clientInfo.ResponseTrailer(), s.serverInfo.ResponseTrailer()) + } + return s.serverErr +} + +// unaryHandlerStream is the server-side view used by dispatch. +type unaryHandlerStream struct { + s *unaryClientStream +} + +// SendHeaders is a no-op. +func (h unaryHandlerStream) SendHeaders() error { return nil } + +func (h unaryHandlerStream) Receive(dst any) error { + if h.s.requestMsg == nil { + return io.EOF + } + if err := h.s.t.copy(dst, h.s.requestMsg); err != nil { + return err + } + h.s.requestMsg = nil + return nil +} + +func (h unaryHandlerStream) Send(msg any) error { + h.s.responseMsg = msg + return nil +} diff --git a/connectproto/connectproto.go b/connectproto/connectproto.go new file mode 100644 index 00000000..50b9198c --- /dev/null +++ b/connectproto/connectproto.go @@ -0,0 +1,296 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package connectproto provides protobuf codecs for [connect]. +// +// [BinaryCodec] encodes messages using the binary protobuf wire format and is +// the default codec for [connectrpc.com/connect/v2/connecthttp] transports. +// +// [JSONCodec] encodes messages using the canonical protobuf JSON mapping +// and is registered alongside the binary codec by default. +package connectproto + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/internal/bufferpool" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoregistry" +) + +// ErrNotProtoMessage is returned when a value passed to [BinaryCodec] or +// [JSONCodec] is not a [proto.Message]. +var ErrNotProtoMessage = errors.New("connectproto: value does not implement proto.Message") + +// Compile-time interface checks. +var ( + _ connect.Codec = (*BinaryCodec)(nil) + _ connect.StableCodec = (*BinaryCodec)(nil) + _ connect.Codec = (*JSONCodec)(nil) + _ connect.StableCodec = (*JSONCodec)(nil) +) + +// Option configures [NewBinaryCodec] and [NewJSONCodec]. Other protobuf +// settings are available through the exported fields on the returned codec. +type Option interface { + apply(*options) +} + +// TypeResolver resolves protobuf message and extension types. +type TypeResolver interface { + protoregistry.MessageTypeResolver + protoregistry.ExtensionTypeResolver +} + +// WithTypeResolver sets the protobuf type resolver used for messages and +// extensions. Passing nil uses the default global registry. +func WithTypeResolver(res TypeResolver) Option { + return optionFunc(func(o *options) { o.resolver = res }) +} + +// BinaryCodec encodes protobuf messages in the binary protobuf wire format. +// +// BinaryCodec implements [connect.Codec] and [connect.StableCodec]. Configure +// the exported option fields before concurrent use. Do not mutate them while +// marshaling or unmarshaling. +type BinaryCodec struct { + // MarshalOptions configures binary protobuf marshaling. + MarshalOptions proto.MarshalOptions + // UnmarshalOptions configures binary protobuf unmarshaling. + UnmarshalOptions proto.UnmarshalOptions +} + +// NewBinaryCodec returns a binary protobuf codec. [WithTypeResolver] sets the +// resolver on [BinaryCodec.UnmarshalOptions]. Other settings can be configured +// by mutating the exported fields before use. +func NewBinaryCodec(opts ...Option) *BinaryCodec { + var o options + for _, opt := range opts { + opt.apply(&o) + } + return &BinaryCodec{ + UnmarshalOptions: proto.UnmarshalOptions{Resolver: o.resolver}, + } +} + +// Name returns "proto". +func (c *BinaryCodec) Name() string { return connect.CodecNameProto } + +// IsBinary reports true. +func (c *BinaryCodec) IsBinary() bool { return true } + +// MarshalWrite writes msg encoded as binary protobuf to dst. +func (c *BinaryCodec) MarshalWrite(_ context.Context, dst io.Writer, msg any) error { + return c.marshalBinary(dst, msg, false /* deterministic */) +} + +// MarshalWriteStable writes msg encoded as deterministic binary protobuf +// to dst. +func (c *BinaryCodec) MarshalWriteStable(_ context.Context, dst io.Writer, msg any) error { + return c.marshalBinary(dst, msg, true /* deterministic */) +} + +// UnmarshalRead decodes binary protobuf from src into msg. The payload is +// buffered into a pooled buffer before decoding. Protobuf binary decoding +// needs the whole message. +func (c *BinaryCodec) UnmarshalRead(_ context.Context, src io.Reader, msg any) error { + protoMsg, ok := msg.(proto.Message) + if !ok { + return fmt.Errorf("%w: got %T", ErrNotProtoMessage, msg) + } + return unmarshalReadAll(src, func(data []byte) error { + return c.UnmarshalOptions.Unmarshal(data, protoMsg) + }) +} + +// marshalBinary encodes msg to dst. It sizes the message once and reuses +// that cached size for the in-place append, so encoding is a single pass. +func (c *BinaryCodec) marshalBinary(dst io.Writer, msg any, deterministic bool) error { + protoMsg, ok := msg.(proto.Message) + if !ok { + return fmt.Errorf("%w: got %T", ErrNotProtoMessage, msg) + } + opts := c.MarshalOptions + if deterministic { + opts.Deterministic = deterministic + } + size := opts.Size(protoMsg) + opts.UseCachedSize = true + return marshalAppendWrite(dst, size, func(buf []byte) ([]byte, error) { + return opts.MarshalAppend(buf, protoMsg) + }) +} + +// JSONCodec encodes protobuf messages using the canonical protobuf JSON +// mapping. +// +// JSONCodec implements [connect.Codec] and [connect.StableCodec]. Configure +// the exported option fields before concurrent use. Do not mutate them while +// marshaling or unmarshaling. +type JSONCodec struct { + // MarshalOptions configures protobuf JSON marshaling. + MarshalOptions protojson.MarshalOptions + // UnmarshalOptions configures protobuf JSON unmarshaling. + UnmarshalOptions protojson.UnmarshalOptions +} + +// NewJSONCodec returns a protobuf JSON codec. [WithTypeResolver] sets the +// resolver on both [JSONCodec.MarshalOptions] and [JSONCodec.UnmarshalOptions]. +// Other settings can be configured by mutating the exported fields before use. +func NewJSONCodec(opts ...Option) *JSONCodec { + var resolved options + for _, opt := range opts { + opt.apply(&resolved) + } + return &JSONCodec{ + MarshalOptions: protojson.MarshalOptions{Resolver: resolved.resolver}, + UnmarshalOptions: protojson.UnmarshalOptions{ + Resolver: resolved.resolver, + DiscardUnknown: true, // Ensure difference schema versions unmarshal. + }, + } +} + +// Name returns "json". +func (c *JSONCodec) Name() string { return connect.CodecNameJSON } + +// IsBinary reports false. +func (c *JSONCodec) IsBinary() bool { return false } + +// MarshalWrite writes msg encoded as protobuf JSON to dst. +func (c *JSONCodec) MarshalWrite(_ context.Context, dst io.Writer, msg any) error { + protoMsg, ok := msg.(proto.Message) + if !ok { + return fmt.Errorf("%w: got %T", ErrNotProtoMessage, msg) + } + // protojson cannot size ahead of encoding, so pass no size hint. + return marshalAppendWrite(dst, -1, func(buf []byte) ([]byte, error) { + return c.MarshalOptions.MarshalAppend(buf, protoMsg) + }) +} + +// MarshalWriteStable writes msg encoded as compact protobuf JSON to dst. +// protojson's whitespace is nondeterministic, so this method compacts the output +// so equivalent messages produce identical bytes for Connect GET request +// caching. protojson emits fields in field-number order, but map entries are +// not sorted, so messages containing maps are not guaranteed to be stable. +func (c *JSONCodec) MarshalWriteStable(_ context.Context, dst io.Writer, msg any) error { + protoMsg, ok := msg.(proto.Message) + if !ok { + return fmt.Errorf("%w: got %T", ErrNotProtoMessage, msg) + } + raw, err := c.MarshalOptions.Marshal(protoMsg) + if err != nil { + return err + } + var buf bytes.Buffer + buf.Grow(len(raw)) + if err := json.Compact(&buf, raw); err != nil { + return err + } + _, err = dst.Write(buf.Bytes()) + return err +} + +// UnmarshalRead decodes protobuf JSON from src into msg. The payload is +// buffered into a pooled buffer before decoding. protojson does not yet +// support incremental input. +func (c *JSONCodec) UnmarshalRead(_ context.Context, src io.Reader, msg any) error { + protoMsg, ok := msg.(proto.Message) + if !ok { + return fmt.Errorf("%w: got %T", ErrNotProtoMessage, msg) + } + return unmarshalReadAll(src, func(data []byte) error { + if len(data) == 0 { + // protojson rejects empty input with a low-level syntax error; + // match the v1 codec's clearer message. + return errors.New("zero-length payload is not a valid JSON object") + } + return c.UnmarshalOptions.Unmarshal(data, protoMsg) + }) +} + +type options struct { + resolver TypeResolver +} + +type optionFunc func(*options) + +func (f optionFunc) apply(o *options) { f(o) } + +// availableBufferWriter is the in-place append fast path shared by +// [bytes.Buffer] and the writers connect transports pass to +// [connect.Codec.MarshalWrite]. Appending to AvailableBuffer and +// committing the result with Write encodes straight into the +// destination's spare capacity, skipping the staging buffer. +type availableBufferWriter interface { + io.Writer + AvailableBuffer() []byte +} + +// grower is implemented by [bytes.Buffer] and transport writers that can +// reserve capacity up front. Growing to the exact encoded size before an +// append-style marshal keeps the marshal from reallocating, which would +// break the AvailableBuffer aliasing and force Write to copy. +type grower interface { + Grow(n int) +} + +// marshalAppendWrite writes the output of an append-style marshal +// function to dst, encoding in place when dst supports it. sizeHint, if +// non-negative, is the exact encoded size. It pre-grows dst so the +// in-place fast path is a single allocation even on a cold buffer. +func marshalAppendWrite(dst io.Writer, sizeHint int, marshalAppend func(dst []byte) ([]byte, error)) error { + if buffered, ok := dst.(availableBufferWriter); ok { + if grower, ok := dst.(grower); ok && sizeHint >= 0 { + grower.Grow(sizeHint) + } + out, err := marshalAppend(buffered.AvailableBuffer()) + if err != nil { + return err + } + _, err = buffered.Write(out) + return err + } + buf := bufferpool.Get() + defer bufferpool.Put(buf) + if sizeHint >= 0 { + buf.Grow(sizeHint) + } + out, err := marshalAppend(buf.AvailableBuffer()) + if err != nil { + return err + } + _, err = dst.Write(out) + return err +} + +// unmarshalReadAll buffers src into a pooled buffer and decodes it with +// unmarshal. Read errors from src are returned as-is so typed transport +// errors (size caps, cancellation) keep their identity. +func unmarshalReadAll(src io.Reader, unmarshal func(data []byte) error) error { + buf := bufferpool.Get() + defer bufferpool.Put(buf) + if _, err := buf.ReadFrom(src); err != nil { + return err + } + return unmarshal(buf.Bytes()) +} diff --git a/connectproto/errordetail.go b/connectproto/errordetail.go new file mode 100644 index 00000000..29e8ec80 --- /dev/null +++ b/connectproto/errordetail.go @@ -0,0 +1,65 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connectproto + +import ( + "strings" + + "connectrpc.com/connect/v2" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" +) + +const anyResolverPrefix = "type.googleapis.com/" + +// NewErrorDetail packs msg into a [*connect.ErrorDetail]. An *anypb.Any's +// type and value are used as-is. +func NewErrorDetail(msg proto.Message) (*connect.ErrorDetail, error) { + if anyMsg, ok := msg.(*anypb.Any); ok { + return &connect.ErrorDetail{ + Type: typeNameForURL(anyMsg.GetTypeUrl()), + Value: anyMsg.GetValue(), + }, nil + } + value, err := proto.Marshal(msg) + if err != nil { + return nil, err + } + return &connect.ErrorDetail{ + Type: string(msg.ProtoReflect().Descriptor().FullName()), + Value: value, + }, nil +} + +// ErrorDetailToAny converts detail into an *anypb.Any. +func ErrorDetailToAny(detail *connect.ErrorDetail) *anypb.Any { + typeURL := detail.Type + if !strings.Contains(typeURL, "/") { + typeURL = anyResolverPrefix + typeURL + } + return &anypb.Any{TypeUrl: typeURL, Value: detail.Value} +} + +// UnmarshalErrorDetail decodes detail into a Protobuf message using the +// global type registry. Typically, callers use Go type assertions to cast +// from the proto.Message interface to concrete types. +func UnmarshalErrorDetail(detail *connect.ErrorDetail) (proto.Message, error) { + return ErrorDetailToAny(detail).UnmarshalNew() +} + +// typeNameForURL trims the type-URL prefix from an *anypb.Any type URL. +func typeNameForURL(url string) string { + return url[strings.LastIndexByte(url, '/')+1:] +} diff --git a/connectproto/errordetail_test.go b/connectproto/errordetail_test.go new file mode 100644 index 00000000..97bade79 --- /dev/null +++ b/connectproto/errordetail_test.go @@ -0,0 +1,133 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package connectproto + +import ( + "testing" + "time" + + "connectrpc.com/connect/v2" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func TestErrorDetailRoundTrip(t *testing.T) { + t.Parallel() + msg := durationpb.New(time.Second) + detail, err := NewErrorDetail(msg) + if err != nil { + t.Fatal(err) + } + if got, want := detail.Type, "google.protobuf.Duration"; got != want { + t.Errorf("Type = %q, want %q", got, want) + } + unmarshaled, err := UnmarshalErrorDetail(detail) + if err != nil { + t.Fatal(err) + } + if !proto.Equal(unmarshaled, msg) { + t.Errorf("round trip = %v, want %v", unmarshaled, msg) + } +} + +func TestErrorDetailAnyAsIs(t *testing.T) { + t.Parallel() + anyMsg, err := anypb.New(wrapperspb.String("hello")) + if err != nil { + t.Fatal(err) + } + detail, err := NewErrorDetail(anyMsg) + if err != nil { + t.Fatal(err) + } + if got, want := detail.Type, "google.protobuf.StringValue"; got != want { + t.Errorf("Type = %q, want %q", got, want) + } + if !proto.Equal(ErrorDetailToAny(detail), anyMsg) { + t.Errorf("ErrorDetailToAny = %v, want %v", ErrorDetailToAny(detail), anyMsg) + } +} + +func TestErrorDetailPackFailure(t *testing.T) { + t.Parallel() + if _, err := NewErrorDetail(wrapperspb.String("\xc3\x28")); err == nil { + t.Error("NewErrorDetail with invalid UTF-8 should fail") + } +} + +func TestErrorDetailToAnyPrefix(t *testing.T) { + t.Parallel() + bare := &connect.ErrorDetail{Type: "google.protobuf.StringValue"} + if got, want := ErrorDetailToAny(bare).GetTypeUrl(), anyResolverPrefix+"google.protobuf.StringValue"; got != want { + t.Errorf("TypeUrl = %q, want %q", got, want) + } + url := &connect.ErrorDetail{Type: "example.com/acme.v1.Custom"} + if got, want := ErrorDetailToAny(url).GetTypeUrl(), "example.com/acme.v1.Custom"; got != want { + t.Errorf("TypeUrl = %q, want %q", got, want) + } +} + +func TestTypeNameForURL(t *testing.T) { + t.Parallel() + testCases := []struct { + name string + url string + typeName string + }{ + { + name: "no-prefix", + url: "foo.bar.Baz", + typeName: "foo.bar.Baz", + }, + { + name: "standard-prefix", + url: anyResolverPrefix + "foo.bar.Baz", + typeName: "foo.bar.Baz", + }, + { + name: "different-hostname", + url: "abc.com/foo.bar.Baz", + typeName: "foo.bar.Baz", + }, + { + name: "additional-path-elements", + url: anyResolverPrefix + "abc/def/foo.bar.Baz", + typeName: "foo.bar.Baz", + }, + { + name: "full-url", + url: "https://abc.com/abc/def/foo.bar.Baz", + typeName: "foo.bar.Baz", + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + if got := typeNameForURL(testCase.url); got != testCase.typeName { + t.Errorf("typeNameForURL(%q) = %q, want %q", testCase.url, got, testCase.typeName) + } + }) + } +} + +func TestErrorDetailUnknownType(t *testing.T) { + t.Parallel() + detail := &connect.ErrorDetail{Type: "acme.user.v1.User", Value: []byte{0xde, 0xad}} + if _, err := UnmarshalErrorDetail(detail); err == nil { + t.Error("UnmarshalErrorDetail with unregistered type should fail") + } +} diff --git a/context.go b/context.go deleted file mode 100644 index 32f265de..00000000 --- a/context.go +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "context" - "net/http" -) - -// CallInfo represents information relevant to an RPC call. -type CallInfo interface { - // Spec returns a description of this call. - Spec() Spec - // Peer describes the other party for this call. - Peer() Peer - // RequestHeader returns the HTTP headers for this request. Headers beginning with - // "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC - // protocols: applications may read them but shouldn't write them. - RequestHeader() http.Header - // ResponseHeader returns the HTTP headers for this response. Headers beginning with - // "Connect-" and "Grpc-" are reserved for use by the Connect and gRPC - // protocols: applications may read them but shouldn't write them. - // On the client side, this method returns nil before - // the call is actually made. After the call is made, for streaming operations, - // this method will block for the server to actually return response headers. - ResponseHeader() http.Header - // ResponseTrailer returns the trailers for this response. Depending on the underlying - // RPC protocol, trailers may be sent as HTTP trailers or a protocol-specific - // block of in-body metadata. - // - // Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the - // Connect and gRPC protocols: applications may read them but shouldn't write - // them. - // - // On the client side, this method returns nil before the call is actually made. - // After the call is made, for streaming operations, this method will block - // for the server to actually return response trailers. - ResponseTrailer() http.Header - // HTTPMethod returns the HTTP method for this request. This is nearly always - // POST, but side-effect-free unary RPCs could be made via a GET. - // - // On a newly created request, via NewRequest, this will return the empty - // string until the actual request is actually sent and the HTTP method - // determined. This means that client interceptor functions will see the - // empty string until *after* they delegate to the handler they wrapped. It - // is even possible for this to return the empty string after such delegation, - // if the request was never actually sent to the server (and thus no - // determination ever made about the HTTP method). - HTTPMethod() string - - internalOnly() -} - -// NewClientContext creates a new client (i.e. outgoing) context for use from a -// client. When the returned context is passed to RPCs, the returned call info -// can be used to set request metadata before the RPC is invoked and to inspect -// response metadata after the RPC completes. -// -// The returned context may be re-used across RPCs as long as they are -// not concurrent. Results of all CallInfo methods other than -// RequestHeader() are undefined if the context is used with concurrent RPCs. -func NewClientContext(ctx context.Context) (context.Context, CallInfo) { - info := &clientCallInfo{} - return context.WithValue(ctx, clientCallInfoContextKey{}, info), info -} - -// CallInfoForHandlerContext returns the CallInfo for the given handler (i.e. incoming) context, if there is one. -func CallInfoForHandlerContext(ctx context.Context) (CallInfo, bool) { - value, ok := ctx.Value(handlerCallInfoContextKey{}).(CallInfo) - return value, ok -} - -// handlerCallInfo is a CallInfo implementation used for unary handlers. -type handlerCallInfo struct { - spec Spec - peer Peer - method string - requestHeader http.Header - responseHeader http.Header - responseTrailer http.Header -} - -func (c *handlerCallInfo) Spec() Spec { - return c.spec -} - -func (c *handlerCallInfo) Peer() Peer { - return c.peer -} - -func (c *handlerCallInfo) RequestHeader() http.Header { - if c.requestHeader == nil { - c.requestHeader = make(http.Header) - } - return c.requestHeader -} - -func (c *handlerCallInfo) ResponseHeader() http.Header { - if c.responseHeader == nil { - c.responseHeader = make(http.Header) - } - return c.responseHeader -} - -func (c *handlerCallInfo) ResponseTrailer() http.Header { - if c.responseTrailer == nil { - c.responseTrailer = make(http.Header) - } - return c.responseTrailer -} - -func (c *handlerCallInfo) HTTPMethod() string { - return c.method -} - -// internalOnly implements CallInfo. -func (c *handlerCallInfo) internalOnly() {} - -// streamingHandlerCallInfo is a CallInfo implementation used for streaming RPC handlers. -type streamingHandlerCallInfo struct { - conn StreamingHandlerConn -} - -func (c *streamingHandlerCallInfo) Spec() Spec { - return c.conn.Spec() -} - -func (c *streamingHandlerCallInfo) Peer() Peer { - return c.conn.Peer() -} - -func (c *streamingHandlerCallInfo) RequestHeader() http.Header { - return c.conn.RequestHeader() -} - -func (c *streamingHandlerCallInfo) ResponseHeader() http.Header { - return c.conn.ResponseHeader() -} - -func (c *streamingHandlerCallInfo) ResponseTrailer() http.Header { - return c.conn.ResponseTrailer() -} - -func (c *streamingHandlerCallInfo) HTTPMethod() string { - // All stream calls are POSTs - return http.MethodPost -} - -// internalOnly implements CallInfo. -func (c *streamingHandlerCallInfo) internalOnly() {} - -// clientCallInfo is a CallInfo implementation used for clients. -type clientCallInfo struct { - responseSource - - spec Spec - peer Peer - method string - requestHeader http.Header -} - -func (c *clientCallInfo) Spec() Spec { - return c.spec -} - -func (c *clientCallInfo) Peer() Peer { - return c.peer -} - -func (c *clientCallInfo) RequestHeader() http.Header { - if c.requestHeader == nil { - c.requestHeader = make(http.Header) - } - return c.requestHeader -} - -func (c *clientCallInfo) ResponseHeader() http.Header { - if c.responseSource == nil { - return nil - } - return c.responseSource.ResponseHeader() -} - -func (c *clientCallInfo) ResponseTrailer() http.Header { - if c.responseSource == nil { - return nil - } - return c.responseSource.ResponseTrailer() -} - -func (c *clientCallInfo) HTTPMethod() string { - return c.method -} - -// internalOnly implements CallInfo. -func (c *clientCallInfo) internalOnly() {} - -// clientCallInfoContextKey is the key used to store client call info in context. -type clientCallInfoContextKey struct{} - -// sentinelContextKey is the key used to store a copy of client call info in context -// when a request is made. -// Each step in an interceptor chain compares the actual call info with the -// sentinel call info. If the two values are different, the request will -// return an error in the interceptor. -// This protects against changing the call info in interceptors, which is prohibited -// as it would allow users to modify call info mid-flight independent of the actual -// request or response. -// Users who wish to modify call info data such as headers and trailers should instead -// use Connect [Request] and [Response] wrapper types. -type sentinelContextKey struct{} - -// handlerCallInfoContextKey is the key used to store handler call info in context. -type handlerCallInfoContextKey struct{} - -// responseSource indicates a type that manages response headers and trailers. -type responseSource interface { - ResponseHeader() http.Header - ResponseTrailer() http.Header -} - -// clientCallInfoForContext gets the call info from a client/outgoing context. -func clientCallInfoForContext(ctx context.Context) (*clientCallInfo, bool) { - info, ok := ctx.Value(clientCallInfoContextKey{}).(*clientCallInfo) - return info, ok -} - -// newHandlerContext creates a new handler/incoming context. -func newHandlerContext(ctx context.Context, info CallInfo) context.Context { - return context.WithValue(ctx, handlerCallInfoContextKey{}, info) -} diff --git a/docs/v2-guide.md b/docs/v2-guide.md new file mode 100644 index 00000000..de18f299 --- /dev/null +++ b/docs/v2-guide.md @@ -0,0 +1,702 @@ +# connect-go v2 + +`connect-go` v2 is a new major version with simpler generated code, a smaller core API, and a transport abstraction in place of a hard dependency on `net/http`. This document explains why v2 exists, what changed from v1, how the runtime fits together, and how to migrate. Read it top to bottom if `connect-go` is new to you, or skip to the [migration](#migration) section and [the migration guide](v2-migration.md) if you are coming from v1. + +`connect-go` v1 remains production-ready and supported. It will receive bug fixes and security patches on the v1 branch indefinitely. v2 is a new major version with a new module path, `connectrpc.com/connect/v2`, so the two coexist and you can migrate at your own pace. + +## Contents + +- [Why a new major version](#why-a-new-major-version) +- [Package layout](#package-layout) +- [A complete example](#a-complete-example) +- [Generated code](#generated-code) +- [The runtime model](#the-runtime-model) +- [Transports](#transports) +- [Streams](#streams) +- [Metadata](#metadata) +- [Interceptors](#interceptors) +- [Errors](#errors) +- [Codecs and compressors](#codecs-and-compressors) +- [Behavioral fixes](#behavioral-fixes) +- [Performance](#performance) +- [Migration](#migration) +- [Ecosystem](#ecosystem) +- [Versioning and support](#versioning-and-support) + +## Why a new major version + +`connect-go` shipped [just over four years ago](https://buf.build/blog/connect-a-better-grpc) as the first Connect library. Its core bet, a gRPC-compatible RPC framework that also speaks plain HTTP and JSON without dragging in a parallel networking stack, has held up well. However, four years of production use have also surfaced a handful of design decisions that we would make differently today, and that cannot be undone without changing exported types. + +A major version is disruptive, and we do not take it lightly. The case for v2 rests on four problems that share one root cause: each fix requires breaking an exported type or signature, so none of them can land in v1 without violating its compatibility promise. + +### Generics in the default API + +v1 generated code wraps every message in `connect.Request[T]` or `connect.Response[T]`. The intent was type-safe access to headers and trailers without reaching into `context.Context` untyped. In practice most RPCs never touch metadata, so the wrappers add noise to every signature and a value to allocate at every call site ([#451](https://github.com/connectrpc/connect-go/issues/451), [#257](https://github.com/connectrpc/connect-go/issues/257), [#848](https://github.com/connectrpc/connect-go/issues/848), [#851](https://github.com/connectrpc/connect-go/issues/851), [discussion #421](https://github.com/connectrpc/connect-go/discussions/421)). The shape also fights the grain of the wider Go RPC world. gRPC-Go, Twirp, and others settle on roughly `func(context.Context, *Request) (*Response, error)`, which is what most Go engineers expect and what makes migration mechanical. + +The wrappers carry a binary-size cost too. v1 instantiates `connect.NewClient[Req, Res]` with concrete message types, so Go's shape-based generic deduplication never kicks in. The compiler emits a separate client and method set for every RPC, plus the runtime metadata each copy needs, so the binary grows with the number of RPCs compiled in. As one example, the [Buf CLI](https://github.com/bufbuild/buf) compiles in roughly 110 RPCs, and moving it to v2 shrinks its stripped binary by roughly 10%. + +v1.19.0 added a `simple` generation flag that produces the unwrapped signatures. A flag can only add a second shape, not replace the first, so the generated API is now split in two: the generic default and the simple opt-in. Almost everyone who learns about the simple flag prefers it, and running two generated shapes side by side is its own source of confusion. The clean fix is to make the simple shape the only shape, which means regenerating against a new major version. + +### net/http as the core abstraction + +v1 defines its public boundary in terms of `net/http`. A generated client dispatches through an `http.Client`, and a service is served as an `http.Handler`. Using the idiomatic HTTP types was a deliberate choice, and for serving over HTTP it works well. The problem is that the HTTP types are the *only* boundary, so anything that is not an HTTP round trip is either impossible or a workaround. + +Two consequences motivated the redesign: + +- In-process calls are painful. Testing a service through a connect-go client means standing up a loopback HTTP server (typically `httptest.Server`), which is slower and flakier than it should be under load. We hit this in `connect-go`'s own CI as coverage grew. We built an in-memory HTTP client to work around it, but kept it internal because it was a workaround, not a clean boundary, and users have asked us to export it ([#694](https://github.com/connectrpc/connect-go/issues/694), [#740](https://github.com/connectrpc/connect-go/issues/740)). +- Non-HTTP transports have nowhere to plug in. We have wanted to offer WebSocket support, which is not a plain HTTP round trip, and other RPC systems such as [PluginRPC](https://github.com/pluginrpc) run over stdin and stdout with their own code generators. None of these can reuse connect-go generated code while the boundary is `net/http`. + +v2 shrinks the core package so that it no longer imports `net/http` and introduces a small [`Transport`](#transports) interface as the boundary. A new package, [`connecthttp`](#transports), implements that interface for the Connect, gRPC, and gRPC-Web protocols over `net/http`. For an HTTP client or server the caller experience barely changes: you add a `connecthttp` import for the transport and keep using `connect` for the client and server constructors. What you gain is room for in-process dispatch, test doubles, and third-party transports without regenerating code or growing the core API. + +### Interceptors that cannot observe the whole call + +v1's unary interceptor is `WrapUnary(UnaryFunc) UnaryFunc`, where `UnaryFunc` is `func(context.Context, AnyRequest) (AnyResponse, error)`. By definition it receives an already-decoded message, so its position in the pipeline, after decompression and unmarshaling, is fixed by its type. That position makes some ecosystem packages impossible to write correctly: + +- `connectrpc.com/authn` cannot be an interceptor. An auth check that runs after unmarshaling lets an unauthenticated client trigger decompression and decode work first, so authn has to be HTTP middleware instead. +- `connectrpc.com/otelconnect` measures unary and streaming RPCs at different points in the pipeline, and on-the-wire message sizes are not exposed at all, so its latency and size metrics are skewed ([#665](https://github.com/connectrpc/connect-go/issues/665)). + +Errors raised before interceptors run also bypass error masking, which a security review flagged ([#584](https://github.com/connectrpc/connect-go/issues/584)). This is not fixable in place: moving interceptors earlier changes what `UnaryFunc` receives, and adding a method to the exported `Interceptor` interface breaks every implementation. v2 replaces the interface with two function types that surround the entire call. See [Interceptors](#interceptors). + +### A single overloaded package + +v1 has accumulated APIs that work around the limits above. It exports roughly 180 constants, types, functions, and methods from one package, and useful internals such as the code-to-HTTP-status mapping cannot be exported without making that package larger still ([#781](https://github.com/connectrpc/connect-go/issues/781)). Stream types are concrete structs the library alone can construct, so handlers and interceptors that touch streams cannot be unit tested without a live connection ([#13](https://github.com/connectrpc/connect-go/issues/13), [#458](https://github.com/connectrpc/connect-go/issues/458), [#719](https://github.com/connectrpc/connect-go/issues/719)). Stream `Send` and `Receive` take no context, so per-call values such as loggers cannot reach them and there is no room to interrupt a blocked operation ([#264](https://github.com/connectrpc/connect-go/issues/264), [#735](https://github.com/connectrpc/connect-go/issues/735), [#823](https://github.com/connectrpc/connect-go/issues/823)). + +v2 splits the module into focused packages and makes the stream types interfaces. A new major version lets us make all of these changes once, coherently, and leave v1 untouched for programs that depend on it. + +## Package layout + +v2 is one module, `connectrpc.com/connect/v2`, split into a transport-agnostic core and a set of focused packages. The core no longer depends on `net/http`. + +| Package | Contents | +| --- | --- | +| `connectrpc.com/connect/v2` | Core types imported by generated code: `Client`, `Server`, `Transport`, streams, interceptors, `Spec`, `Method`, `CallInfo`, `Error`, `Code`, codec and compressor interfaces. | +| `connectrpc.com/connect/v2/connecthttp` | Connect, gRPC, and gRPC-Web over `net/http`: `NewTransport`, `Mount`, HTTP options, and HTTP-level call info. | +| `connectrpc.com/connect/v2/connectproto` | Protobuf binary and JSON codecs. | +| `connectrpc.com/connect/v2/connectgzip` | gzip compressor. | +| `connectrpc.com/connect/v2/connectinprocess` | In-process transport that dispatches directly to a `*connect.Server`. | + +The entire core lives in a single file, [`connect.go`](https://pkg.go.dev/connectrpc.com/connect/v2), and exports roughly 25 top-level types and functions, down from about 180 identifiers in v1. Most HTTP services need exactly two of these packages: `connect` for the constructors and `connecthttp` for the transport and server binding. `connectproto` and `connectgzip` supply the defaults that `connecthttp` uses, so you rarely import them directly. `connectinprocess` is useful for testing. + +## A complete example + +The schema is the [`PingService`](../internal/proto/connect/ping/v1/ping.proto), which has two unary methods (`Ping`, `Fail`), a client-streaming method (`Sum`), a server-streaming method (`CountUp`), and a bidi-streaming method (`CumSum`). Generated code lives in [`pingv1connect`](../internal/gen/connect/ping/v1/pingv1connect/ping.connect.go). + +The complete, runnable code is in [`internal/example`](../internal/example). + +### Server + +A service implementation is a plain struct whose methods take and return protobuf messages. Embedding `UnimplementedPingServiceHandler` returns `CodeUnimplemented` from any method you do not define, which keeps the implementation compiling as the schema grows. You register it on a `*connect.Server`, then hand the server to `connecthttp.Mount`, which installs one route per procedure on an `http.ServeMux`. Interceptors are passed to `connect.NewServer`; here a logging interceptor runs before any payload work. The full file at [internal/example/server/main.go](../internal/example/server/main.go) also implements the streaming methods. + +```go +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler +} + +func (pingServer) Ping(ctx context.Context, req *v1.PingRequest) (*v1.PingResponse, error) { + return &v1.PingResponse{Number: req.Number, Text: req.Text}, nil +} + +// serverLoggingInterceptor logs RPCs that fail. Interceptors are passed to +// connect.NewServer and run before any payload work. +func serverLoggingInterceptor(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + err := next(ctx, spec, stream) + if err != nil { + log.Printf("rpc failed: procedure=%s error=%v", spec.Procedure, err) + } + return err + } +} + +func main() { + server := connect.NewServer(serverLoggingInterceptor) + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + + mux := http.NewServeMux() + connecthttp.Mount(mux, server) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + // For gRPC clients, it is convenient to support HTTP/2 without TLS. + protocols.SetUnencryptedHTTP2(true) + httpServer := &http.Server{ + Addr: "localhost:8080", + Handler: mux, + Protocols: protocols, + } + log.Println("listening on", httpServer.Addr) + if err := httpServer.ListenAndServe(); err != nil { + log.Fatal(err) + } +} +``` + +Because `Mount` registers ordinary `http.Handler` values, Connect routes and plain HTTP routes such as `/healthz` share one mux, and standard middleware, timeouts, and h2c setup all apply normally. + +### Client + +A generated client holds a `*connect.Client`, which wraps a transport. For HTTP, the transport comes from `connecthttp.NewTransport`. Unary methods take and return messages directly, and streaming methods return a generated stream you receive from until `io.EOF`. The client below is wrapped with a logging interceptor; see the full file at [internal/example/client/main.go](../internal/example/client/main.go). + +```go +// clientLoggingInterceptor logs each call before the stream is opened. +// Interceptors are passed to connect.NewClient and run in argument order. +func clientLoggingInterceptor(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + log.Printf("calling %s", spec.Procedure) + return next(ctx, spec) + } +} + +func main() { + ctx := context.Background() + client := connect.NewClient( + connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080"), + clientLoggingInterceptor, + ) + pingClient := pingv1connect.NewPingServiceClient(client) + + res, err := pingClient.Ping(ctx, &v1.PingRequest{Number: 42, Text: "hello"}) + if err != nil { + log.Fatalf("Ping: %v", err) + } + log.Printf("Ping: number=%d text=%q", res.Number, res.Text) + + stream, err := pingClient.CountUp(ctx, &v1.CountUpRequest{Number: 3}) + if err != nil { + log.Fatalf("CountUp: %v", err) + } + for { + msg, err := stream.Receive() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + log.Fatalf("CountUp.Receive: %v", err) + } + log.Printf("CountUp: %d", msg.Number) + } +} +``` + +### Test + +Because the client is no longer tightly coupled to HTTP, tests can swap the transport for `connectinprocess.New`. This dispatches RPCs directly to the server in the same process, bypassing listeners and serialization entirely. The runnable version is at [internal/example/server/main_test.go](../internal/example/server/main_test.go); run it with `go test ./server`. + +```go +func newTestClient(tb testing.TB) pingv1connect.PingServiceClient { + tb.Helper() + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + return pingv1connect.NewPingServiceClient( + connect.NewClient(connectinprocess.New(server)), + ) +} + +func TestPing(t *testing.T) { + client := newTestClient(t) + res, err := client.Ping(t.Context(), &v1.PingRequest{Number: 42, Text: "hello"}) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if res.Number != 42 || res.Text != "hello" { + t.Errorf("got Number=%d Text=%q; want 42 %q", res.Number, res.Text, "hello") + } +} +``` + +## Generated code + +The clearest way to see v2 is to compare the generated client and handler against v1. The snippets below keep only signatures. + +v1 default output, with the generic wrappers: + +```go +type PingServiceClient interface { + Ping(context.Context, *connect.Request[v1.PingRequest]) (*connect.Response[v1.PingResponse], error) + Sum(context.Context) *connect.ClientStreamForClient[v1.SumRequest, v1.SumResponse] + CountUp(context.Context, *connect.Request[v1.CountUpRequest]) (*connect.ServerStreamForClient[v1.CountUpResponse], error) + CumSum(context.Context) *connect.BidiStreamForClient[v1.CumSumRequest, v1.CumSumResponse] +} + +func NewPingServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) PingServiceClient + +type PingServiceHandler interface { + Ping(context.Context, *connect.Request[v1.PingRequest]) (*connect.Response[v1.PingResponse], error) + Sum(context.Context, *connect.ClientStream[v1.SumRequest]) (*connect.Response[v1.SumResponse], error) + CountUp(context.Context, *connect.Request[v1.CountUpRequest], *connect.ServerStream[v1.CountUpResponse]) error + CumSum(context.Context, *connect.BidiStream[v1.CumSumRequest, v1.CumSumResponse]) error +} + +func NewPingServiceHandler(svc PingServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) +``` + +v2 output: + +```go +type PingServiceClient interface { + Ping(context.Context, *v1.PingRequest) (*v1.PingResponse, error) + Sum(context.Context) (PingServiceSumClientStream, error) + CountUp(context.Context, *v1.CountUpRequest) (PingServiceCountUpClientStream, error) + CumSum(context.Context) (PingServiceCumSumClientStream, error) +} + +func NewPingServiceClient(client *connect.Client) PingServiceClient + +type PingServiceHandler interface { + Ping(context.Context, *v1.PingRequest) (*v1.PingResponse, error) + Sum(context.Context, PingServiceSumServerStream) (*v1.SumResponse, error) + CountUp(context.Context, *v1.CountUpRequest, PingServiceCountUpServerStream) error + CumSum(context.Context, PingServiceCumSumServerStream) error +} + +func RegisterPingServiceHandler(server *connect.Server, svc PingServiceHandler) +``` + +Three things changed: + +Unary methods take a context and the request message and return the response message. They match the v1 `simple` output exactly, and they match the shape gRPC-Go and Twirp use. + +Streaming methods exchange a named stream type generated per RPC, such as `PingServiceSumServerStream` or `PingServiceCumSumClientStream`, instead of a generic instantiation. Each generated type is a thin wrapper over the [`connect.ClientStream`](#streams) or `connect.ServerStream` interface that exposes only the operations that RPC allows. A server-streaming handler stream exposes `Send` but not `Receive`; a client-streaming handler stream exposes `Receive` but not `Send`. We generate these wrappers rather than export generic stream functions to keep the core package small and to keep each stream's legal operations visible in its type. For example, the generated `Sum` client stream is: + +```go +type PingServiceSumClientStream struct { + stream connect.ClientStream +} + +func (s PingServiceSumClientStream) SendHeaders() error { return s.stream.SendHeaders() } + +func (s PingServiceSumClientStream) Send(req *v1.SumRequest) error { + return s.stream.Send(req) +} + +func (s PingServiceSumClientStream) CloseAndReceive() (*v1.SumResponse, error) { + if err := s.stream.CloseSend(); err != nil { + return nil, err + } + var res v1.SumResponse + if err := s.stream.Receive(&res); err != nil { + return nil, err + } + return &res, nil +} +``` + +The constructors define the code's boundary. A client is built from a `*connect.Client` and dispatches each method through one of its `Call` methods. A handler is registered on a `*connect.Server` through generated `connect.Method` values. Neither imports `net/http`, so the same generated code runs over any transport. + +The generator keeps the binary name `protoc-gen-connect-go`. Install it from the v2 module: + +```sh +go install connectrpc.com/connect/v2/cmd/protoc-gen-connect-go@latest +``` + +v2 generated code is always in the simple style, and the generator rejects the v1 `simple` option so a stale flag fails loudly instead of being ignored. + +## The runtime model + +Three core types carry an RPC: a `Client` opens it, a `Transport` delivers it, and a `Server` dispatches it on the far side. Generated code sits on top of the `Client` and `Server`; the `Transport` sits underneath. + +### Client + +A [`connect.Client`](https://pkg.go.dev/connectrpc.com/connect/v2#Client) bundles a `Transport` with the client-side interceptor chain. Generated service clients hold one `*connect.Client` and dispatch every RPC through one of its call methods: + +```go +func (c *Client) CallUnary(ctx context.Context, spec Spec, req, res any) error +func (c *Client) CallClientStream(ctx context.Context, spec Spec) (ClientStream, error) +func (c *Client) CallServerStream(ctx context.Context, spec Spec, req any) (ClientStream, error) +``` + +`CallUnary` opens a stream, sends the request, closes the send side, reads one response, and closes the stream before returning so the final protocol state is checked. The streaming calls return a `ClientStream` that the caller owns: reading to `io.EOF` releases its resources, and `Close` abandons a stream early. Multiple generated clients can share one `*connect.Client`. + +The interceptor chain is applied once, in `NewClient`, producing one prebuilt function per call shape rather than a closure per RPC. Per-call arguments reach the terminal functions through the context, so dispatch allocates nothing for the chain itself. + +```go +func NewClient(transport Transport, interceptors ...ClientInterceptor) *Client +``` + +### Server + +A [`connect.Server`](https://pkg.go.dev/connectrpc.com/connect/v2#Server) maps a procedure path to the function that serves it, and holds the server-side interceptor chain. + +```go +func NewServer(interceptors ...ServerInterceptor) *Server +func (s *Server) Register(methods ...Method) +func (s *Server) Specs() iter.Seq[Spec] +func (s *Server) Call(ctx context.Context, procedure string, info *CallInfo, stream ServerStream) error +func (s *Server) SetUnknownHandler(fn ServerFunc) +``` + +Generated registration functions build one `connect.Method` per RPC and pass them to `Register`, which wraps each handler with the interceptor chain so that `Call` is a map lookup plus one function call. A server transport dispatches an incoming RPC with `Call` and enumerates registered procedures with `Specs` to install per-procedure routes. `Call` returns `CodeUnimplemented` when no method is registered for a procedure; `SetUnknownHandler` replaces that fallback for callers such as proxies. The fallback receives a `Spec` with the requested procedure, `StreamTypeBidi`, and a nil `Schema`. + +### Spec and Method + +A [`Spec`](https://pkg.go.dev/connectrpc.com/connect/v2#Spec) describes one procedure, and a `Method` binds a `Spec` to its handler: + +```go +type Spec struct { + StreamType StreamType + IdempotencyLevel IdempotencyLevel + Schema any // protobuf stores a protoreflect.MethodDescriptor + Procedure string // "/package.Service/Method" +} + +type Method struct { + Spec Spec + Handler ServerFunc +} +``` + +`Schema` is `any` on purpose. Protobuf generated code stores a `protoreflect.MethodDescriptor`, but another schema system can store its own descriptor. Because the schema travels with the spec and stream messages are untyped (`any`), you can build a client or handler from a descriptor at runtime with no generated code at all ([#312](https://github.com/connectrpc/connect-go/issues/312), [#523](https://github.com/connectrpc/connect-go/issues/523)). + +## Transports + +A transport is the delivery mechanism that carries an RPC from a client to a server. In v1 this mechanism was fixed and hidden inside the generated `http.Client` dispatch and `http.Handler` serving. v2 makes it an explicit interface, supplied when you build a `*connect.Client`: + +```go +type Transport interface { + NewClientStream(ctx context.Context, spec Spec) (ClientStream, error) +} +``` + +The single method opens the client half of a stream for a spec. The context passed in carries the client-side `CallInfo`, which interceptors and stream operations read back through the context. The caller owns the returned stream and closes it when finished. The full contract, including context and ownership rules, is documented on [`Transport`](https://pkg.go.dev/connectrpc.com/connect/v2#Transport). + +A client call flows down through the generated client to the `*connect.Client`, which runs the client interceptor chain and hands the spec to the transport. The transport produces a `ClientStream`; on the server side, the matching transport builds a `ServerStream` from its wire input and calls `Server.Call`, which runs the server interceptor chain and the registered handler. The three layers stack with generated code on top, the `connect` core in the middle, and the transport underneath. The boundary between the core and the transport is the `Transport` interface on the client side and `Server.Call` plus `Server.Specs` on the server side. + +### connecthttp + +[`connecthttp`](https://pkg.go.dev/connectrpc.com/connect/v2/connecthttp) is the default transport. It speaks the Connect, gRPC, and gRPC-Web protocols over `net/http`, so a v2 client behaves exactly as a v1 client did on the wire. It has two entry points: + +```go +func NewTransport(httpClient connecthttp.HTTPClient, baseURL string, opts ...Option) connect.Transport +func Mount(mux connecthttp.ServeMux, server *connect.Server, opts ...Option) +``` + +`NewTransport` builds the client transport. `Mount` installs one `http.Handler` per registered procedure onto a mux, which is the per-procedure registration that [#924](https://github.com/connectrpc/connect-go/issues/924) asked for and lets Connect routes share a mux with sibling HTTP routes. It also installs a catch-all per service: RPC requests for unknown methods of a registered service route through `Server.Call`, failing with `CodeUnimplemented` unless a `SetUnknownHandler` fallback answers them. Non-RPC requests get a plain 404, and unknown services fall through to the mux. + +Both entry points share one `Option` type that covers protocol and codec selection, compression, message-size limits, and Connect GET support. Options that do not apply to a side are ignored, so passing a client-only option to `Mount` is harmless. In v1 these were core handler and client options; in v2 they live with the transport that reads them. + +```go +connecthttp.WithGRPC() // client only +connecthttp.WithReadMaxBytes(1024) +connecthttp.WithSendMaxBytes(2048) +connecthttp.WithCompressMinBytes(512) +connecthttp.WithHTTPGet() // client only +connecthttp.WithRequireConnectProtocolHeader() // server only +connecthttp.WithCodec(connectproto.NewJSONCodec()) // register; one per call +connecthttp.WithCompressor(connectgzip.New()) +``` + +By default `connecthttp` registers the protobuf binary and JSON codecs from `connectproto` and sends with the binary codec, so a transport built with no options is ready to use. + +### connectinprocess + +[`connectinprocess`](https://pkg.go.dev/connectrpc.com/connect/v2/connectinprocess) dispatches every RPC directly to a `*connect.Server` in the same process, bypassing the wire, codecs, and framing entirely. + +```go +func New(handler *connect.Server, opts ...Option) connect.Transport +``` + +The default message-transfer strategy is `ProtoCopy`, a safe deep copy that runs `proto.Reset` followed by `proto.Merge`, so the client and server never share message state. Callers that want a different trade-off can replace it with `WithCopyFunc`. + +The package is built for testing, where it replaces the loopback listener that v1 forced on stream and interceptor tests ([#13](https://github.com/connectrpc/connect-go/issues/13), [#740](https://github.com/connectrpc/connect-go/issues/740)). It is also suitable for production cases such as delegating a call from one service to another in the same binary. + +### Custom transports + +The `Transport` interface is open for third-party implementations. [`connectinprocess`](https://pkg.go.dev/connectrpc.com/connect/v2/connectinprocess) is an example that ships in this repository. It dispatches directly to a `*connect.Server` in the same process with minimal overhead. To confirm the interface is sufficient beyond HTTP, we also prototyped transports over WebSockets (multiplexing every RPC type over a single connection rather than one HTTP request per call) and over stdin and stdout for protocols like PluginRPC. + +Third-party packages can use this interface to support legacy clients or new protocols and map them onto their existing RPC services without regenerating code. + +## Streams + +Streaming RPCs operate on two interfaces. The client drives a [`ClientStream`](https://pkg.go.dev/connectrpc.com/connect/v2#ClientStream), and the handler receives a `ServerStream`: + +```go +type ClientStream interface { + SendHeaders() error + Send(msg any) error + CloseSend() error + Receive(msg any) error + Close() error +} + +type ServerStream interface { + Receive(msg any) error + SendHeaders() error + Send(msg any) error +} +``` + +Stream operations take no context: the call context is bound when the transport opens the stream, so per-call values such as loggers are available without threading a context through every `Send` and `Receive`. Clean receive-side completion is reported by an error matching `io.EOF` under `errors.Is`, which separates a clean end-of-stream from a network `io.EOF` that v1 conflated ([#774](https://github.com/connectrpc/connect-go/issues/774), [#397](https://github.com/connectrpc/connect-go/issues/397)). + +Ownership differs by side. A caller owns its `ClientStream`: reading to `io.EOF` releases the stream's resources, and canceling the call's context or calling `Close` abandons it early. `Close` is idempotent, so `defer stream.Close()` is always safe. A handler ends the RPC by returning, so `ServerStream` has no `Close`; the transport finalizes the response when the handler returns. Each stream allows one active send-side and one active receive-side operation at a time, and the two sides may run concurrently. + +These interfaces are the reason streams are now testable in isolation. Because they are interfaces rather than concrete library structs, a test or interceptor can wrap or fake them directly, which v1 could not do ([#458](https://github.com/connectrpc/connect-go/issues/458)). Generated code defines the typed wrappers shown in [Generated code](#generated-code) over these interfaces. + +A client-streaming handler receives messages until `io.EOF`, then returns its response. From the [server example](../internal/example/server/main.go): + +```go +func (pingServer) Sum(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*v1.SumResponse, error) { + var total int64 + for { + req, err := stream.Receive() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + total += req.Number + } + return &v1.SumResponse{Sum: total}, nil +} +``` + +## Metadata + +Headers and trailers travel on a [`CallInfo`](https://pkg.go.dev/connectrpc.com/connect/v2#CallInfo) carried by the context, which replaces the v1 `Request[T]` and `Response[T]` wrappers. There is one way to read or write metadata in v2. + +```go +type CallInfo struct { + Spec Spec // the procedure, stream type, and schema + PeerAddr string // remote peer address; empty for in-process calls + Protocol string // "connect", "grpc", or "grpcweb" + Codec string // "proto" or "json" + RequestEncoding string // request compression; "identity" when uncompressed + ResponseEncoding string // response compression + SendStats MessageStats // byte counts for the most recent Send + ReceiveStats MessageStats // byte counts for the most recent Receive +} + +func (c *CallInfo) RequestHeader() *Header +func (c *CallInfo) ResponseHeader() *Header +func (c *CallInfo) ResponseTrailer() *Header +``` + +Alongside the metadata, the transport populates the exported fields as the RPC runs, replacing the v1 `Peer` struct and giving telemetry the on-the-wire message sizes that v1 never exposed. + +A client attaches a `CallInfo` to the context with `connect.NewClientContext` before the call, sets request headers on it, and reads response headers and trailers from the same value after the call returns: + +```go +ctx, info := connect.NewClientContext(context.Background()) +info.RequestHeader().Set("X-Request-Id", "req-123") +if _, err := client.Ping(ctx, &pingv1.PingRequest{Text: "hello"}); err != nil { + return err +} +log.Println(info.ResponseHeader().Get("X-Server-Name")) +``` + +Calling `NewClientContext` is optional. A transport attaches a fresh `CallInfo` when the context does not carry one, so you only call it when you want the handle. + +A handler reads its `CallInfo` from the context with `connect.CallInfoForServerContext`, which the server attaches before interceptors and the handler run. The second return value reports whether a `CallInfo` is present; inside a handler it always is: + +```go +func (pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + info.ResponseHeader().Set("X-Server-Name", "ping-server") + info.ResponseTrailer().Set("X-Audit-Id", "audit-123") + return &pingv1.PingResponse{Number: req.Number, Text: req.Text}, nil +} +``` + +Client and server info use distinct context keys, so a handler that makes an outbound RPC does not leak its inbound metadata onto the outbound call. + +For HTTP-specific details such as the HTTP method, URL, response status, and TLS state, the transport sets `CallInfo.TransportInfo` to a `*connecthttp.ClientInfo` on the client side or a `*connecthttp.ServerInfo` on the server side. The `connecthttp.ClientInfoForContext` and `connecthttp.ServerInfoForContext` helpers look them up directly. They report false under a non-HTTP transport, and the accessors are nil-safe. + +```go +if httpInfo, _ := connecthttp.ServerInfoForContext(ctx); httpInfo.HTTPMethod() == http.MethodGet { + // Serving a Connect GET request, for example with an Etag cache. +} +``` + +## Interceptors + +v1 had one `Interceptor` interface with separate methods for unary calls, streaming clients, and streaming handlers. v2 replaces it with two function types that wrap every RPC shape uniformly: + +```go +type ClientFunc func(ctx context.Context, spec Spec) (ClientStream, error) +type ServerFunc func(ctx context.Context, spec Spec, stream ServerStream) error + +type ClientInterceptor func(next ClientFunc) ClientFunc +type ServerInterceptor func(next ServerFunc) ServerFunc +``` + +An interceptor wraps one function to produce another. It can return an error without calling `next` to short-circuit the RPC. A `ServerFunc` receives the stream, so a server interceptor wraps it before calling `next`. A `ClientFunc` returns the stream, so a client interceptor wraps the stream that `next` returns to observe messages or the end of the RPC. To see the end of every RPC, wrap both `Close` and `Receive`. A unary calls always `Close`, but a streaming caller may finish by reading to `io.EOF` without calling `Close`. Interceptors are passed to `NewClient` and `NewServer` and run in argument order, so the first interceptor wraps the outermost call. + +Client and server interceptors are separate types because the two sides differ in how they drive a stream and usually want different things. An auth interceptor attaches credentials on the client and verifies them on the server. Splitting the types keeps each side's semantics clean and avoids writing one interceptor that tries to do both. + +Because the chain is applied once per call shape in `NewClient` and `NewServer`, the interceptor function itself runs at construction, not per RPC. Keep per-RPC state in the function an interceptor returns or in a stream wrapper, never in the interceptor function. A returned function must also pass `next` a context derived from the one it received, since per-call state flows through the context. + +First, an interceptor can reject a call using only its headers, before any payload work, which is what makes authn be able to be an interceptor in v2 rather than HTTP middleware: + +```go +func newAuthInterceptor(verify func(token string) error) connect.ServerInterceptor { + return func(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + info, _ := connect.CallInfoForServerContext(ctx) + token := info.RequestHeader().Get("Authorization") + if token == "" { + return connect.NewError(connect.CodeUnauthenticated, "missing credentials") + } + if err := verify(token); err != nil { + return connect.NewError(connect.CodeUnauthenticated, "invalid credentials") + } + return next(ctx, spec, stream) + } + } +} +``` + +Second, an interceptor can observe individual messages by wrapping the stream before calling `next`, which is how `connectrpc.com/validate` validates every request message. Third, panic recovery is an ordinary interceptor, because a v2 interceptor surrounds the whole call: + +```go +func recoveryInterceptor(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) (err error) { + defer func() { + if r := recover(); r != nil { + err = connect.Errorf(connect.CodeInternal, "internal server error") + } + }() + return next(ctx, spec, stream) + } +} +``` + +v2 does not ship a `RecoveryInterceptor` or a `WithRecover` option. Recovery is a few lines with full access to the `Spec`, so the deprecation tracked in [#816](https://github.com/connectrpc/connect-go/issues/816) is resolved by the new shape rather than a dedicated API. + +## Errors + +v2 changes error behavior to make accidental leaks hard. A handler fails an RPC by returning an error, but only a locally authored `*connect.Error` is serialized to the wire. Any other error reaches the client as a bare code with no message — `CodeUnknown`, or `CodeCanceled` and `CodeDeadlineExceeded` for context errors — so text sent to callers is always written on purpose. + +```go +func NewError(code Code, message string) *Error +func Errorf(code Code, format string, args ...any) *Error + +func (e *Error) WithCause(err error) *Error // local only, never serialized +func (e *Error) WithDetail(detail *ErrorDetail) *Error // serialized +func (e *Error) WithRemote() *Error // marks a peer's verdict +func CodeOf(err error) Code +``` + +`NewError` and `Errorf` take a message string rather than an `error`, so there is no path that serializes an arbitrary error's text by accident. To keep an underlying error for local inspection, attach it as a cause with `WithCause`. Causes are visible to `errors.Is` and `errors.As` on the server but are never sent. +When a client receives an error, it is automatically marked as remote via `WithRemote`. This ensures that if a server makes a downstream call that fails, its transport will not accidentally forward the downstream error as the server's own verdict: a remote error returned from a handler is scrubbed to `CodeInternal` with no message. + +A handler shows each case: + +```go +func (pingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { + if req.Number < 0 { + // Public message, serialized to the wire. + return nil, connect.Errorf(connect.CodeInvalidArgument, "number must be positive, got %d", req.Number) + } + if req.Text == "leak" { + // Not a *connect.Error: the client sees CodeUnknown, no message. + return nil, errDiskFull + } + if err := store(ctx, req.Number); err != nil { + // Public message; the cause stays on the server. + return nil, connect.NewError(connect.CodeInternal, "store failed").WithCause(err) + } + return &pingv1.PingResponse{Number: req.Number, Text: req.Text}, nil +} +``` + +A caller branches on the code with `connect.CodeOf`, or inspects the full error with `errors.As` into `*connect.Error` for the message and details. This replaces the v1 `IsWireError` and masking knobs added to patch the same leaks ([#222](https://github.com/connectrpc/connect-go/issues/222), [#420](https://github.com/connectrpc/connect-go/issues/420), [#584](https://github.com/connectrpc/connect-go/issues/584)). + +Error details are a `connect.ErrorDetail`, a schema-neutral triple of the message's type name, its serialized bytes, and an optional human-readable `debug` form. This form matches the Connect wire encoding. + +```go +detail, err := connectproto.NewErrorDetail(&errdetails.RetryInfo{RetryDelay: durationpb.New(time.Second)}) +if err != nil { + return nil, connect.NewError(connect.CodeInternal, "pack detail") +} +return nil, connect.NewError(connect.CodeUnavailable, "try again later").WithDetail(detail) +``` + +## Codecs and compressors + +Both interfaces are now stream-oriented and context-aware: a codec marshals to an `io.Writer` and unmarshals from an `io.Reader`, and a compressor wraps a writer or reader rather than round-tripping whole buffers. + +```go +type Codec interface { + Name() string + MarshalWrite(ctx context.Context, dst io.Writer, msg any) error + UnmarshalRead(ctx context.Context, src io.Reader, msg any) error +} + +type Compressor interface { + Name() string + Compress(dst io.Writer) (io.WriteCloser, error) + Decompress(src io.Reader) (io.ReadCloser, error) +} +``` + +The codec methods take a context for per-call values. The transport enforces the decompressed-size limit by wrapping the reader returned from `Decompress`, so a decompression bomb fails before it is fully materialized, closing the v1 gap where discard limits went unenforced on oversized messages ([#620](https://github.com/connectrpc/connect-go/issues/620)). + +A `StableCodec` extends `Codec` with `MarshalWriteStable`, a deterministic encoding for features such as Connect GET request URLs, plus `IsBinary` so a binary stable encoding can be text-encoded before it goes in a URL. This exports what was a private interface in v1. + +`connectproto` provides the protobuf binary and JSON codecs, and `connectgzip` provides gzip. The compression token names (`identity`, `gzip`, `br`, `zstd`) are defined in the core package so transports and compressor packages agree on them, while the core package itself ships no compressor. + +## Behavioral fixes + +Several v1 behaviors are wrong but frozen by its compatibility promise. v2 fixes them as part of the redesign: + +- Authn runs before payload work, as an interceptor rather than HTTP middleware, so unauthenticated clients cannot trigger decompression and decode ([#584](https://github.com/connectrpc/connect-go/issues/584)). +- Telemetry sees the whole call and on-the-wire sizes, so otelconnect metrics are no longer skewed ([#665](https://github.com/connectrpc/connect-go/issues/665)). +- A clean end-of-stream is distinct from a network `io.EOF` ([#774](https://github.com/connectrpc/connect-go/issues/774), [#397](https://github.com/connectrpc/connect-go/issues/397)). +- Decompression enforces a size limit, so oversized messages fail early ([#620](https://github.com/connectrpc/connect-go/issues/620)). +- Errors are never serialized unless authored as `*connect.Error` ([#584](https://github.com/connectrpc/connect-go/issues/584)). + +## Performance + +The v2 implementation is based on the current v1 implementation. Performance is similar, but we expect to improve this going forward once we have stabilized the transition. +Removing the generic client instantiation reduces binary size, since the compiler no longer stamps out a client and method set per RPC. Moving the Buf CLI to v2 shrinks its stripped binary by roughly 10%. + +## Migration + +Migration is more than an import rename, but most of it is mechanical. The [migration guide](v2-migration.md) is the authoritative reference. + +A tool, `connectrpc.com/connect/v2/cmd/connect-go-v2-migrate`, automates the bulk of it: + +```sh +go install connectrpc.com/connect/v2/cmd/connect-go-v2-migrate@latest +connect-go-v2-migrate +``` + +It is a dry run by default, printing a diff per file and a warning for anything that isn't a clear AST transform. Pass `-w` to write changes and `-json` for a machine-readable report. Because most code changes depend on the regenerated v2 stubs, migration runs in two passes: + +1. Run `connect-go-v2-migrate -w`. While your v1 stubs are still in place it updates only the Buf templates (`buf.gen.yaml`) and prints the steps to switch to v2; it makes no Go source changes yet. +2. Move to v2 and regenerate: `go get -u` the v2 core (plus any generated SDKs at `@v2` and ecosystem modules), reinstall `protoc-gen-connect-go` from the v2 module, then run `buf generate`. +3. Run `connect-go-v2-migrate -w` again. With the stubs now on v2, it rewrites import paths, handler signatures, client call sites, metadata, and streaming, and reports anything that needs a manual change. +4. Run `go mod tidy`, then build and test. + +The tool handles the safe rewrites: import paths, removing `Request[T]` and `Response[T]`, server registration and client construction, headers and trailers moving to `CallInfo`, error-constructor rewrites that preserve the v1 wire message, the `simple` option removal, and the streaming send and receive reshaping. It warns and leaves for you the changes that need judgment: custom interceptors rewritten to the new function types, handler stream parameter types that become the generated named type, options that changed name or signature, and ecosystem-package calls (authn, otelconnect, validate, grpcreflect, vanguard) that move to their `/v2` APIs. Each warned pattern is documented in [the migration guide](v2-migration.md). + +## Ecosystem + +The official Connect ecosystem packages are being updated to fully support v2. Each package will release its own `/v2` module alongside the core framework. +To align with the new architecture, most of these packages have simplified their APIs to either register directly on a `*connect.Server` or provide distinct `ClientInterceptor` and `ServerInterceptor` implementations. + + +| v1 | v2 | +| --- | --- | +| `connectrpc.com/validate` | `connectrpc.com/validate/v2` | +| `connectrpc.com/otelconnect` | `connectrpc.com/otelconnect/v2` | +| `connectrpc.com/authn` | `connectrpc.com/authn/v2` | +| `connectrpc.com/grpcreflect` | `connectrpc.com/grpcreflect/v2` | +| `connectrpc.com/grpchealth` | `connectrpc.com/grpchealth/v2` | +| `connectrpc.com/vanguard` | `connectrpc.com/vanguard/v2` | +| `connectrpc.com/vanguard/vanguardgrpc` | `connectrpc.com/vanguard/v2/vanguardgrpc` | + + +## Versioning and support + +`connect-go` uses Go's semantic import versioning. The `/v2` suffix is part of the module path, `connectrpc.com/connect/v2`, so v1 and v2 are different modules and a program can depend on both during migration. + +The two major versions live in the same git repository: + +- v1 is on the v1 branch at module path `connectrpc.com/connect`. It will continue to receive fixes and security patches but not new features. +- v2 is on `main` at module path `connectrpc.com/connect/v2`. It follows semantic versioning within the v2 line. + +As part of validating v2, we kept the v1 test suite and updated it to use the v2 APIs. We have also prototyped a branch where v1 is implemented entirely on top of the v2 core. Adopting this unified implementation after the v2 release would let both major versions share a single implementation and reduce the long-term maintenance burden, though v1's public API would remain strictly unchanged either way. diff --git a/docs/v2-migration.md b/docs/v2-migration.md new file mode 100644 index 00000000..35f5e02d --- /dev/null +++ b/docs/v2-migration.md @@ -0,0 +1,767 @@ +# Connect v1 to v2 migration guide + +This document will walk you through all you need to know to migrate from v1 to v2. +connect-go v2 improves and simplifies some common APIs. + +To get started we will first install the migration tool. +This will help automate most of the mechanical translations. +Then go through examples, and any decisions that might show up and require oversight. + +> [!IMPORTANT] +> +> connect-go v1 remains supported. The v1 branch will receive fixes and security updates, so you can migrate at your own pace. + +## Running the migration tool + +We provide the tool `connect-go-v2-migrate` which takes care of plugin updates and +most code changes. First, install the tool: + +```sh +go install connectrpc.com/connect/v2/cmd/connect-go-v2-migrate@latest +``` + +Then run it from your module directory, or pass paths as arguments: + +```sh +connect-go-v2-migrate +``` + +The tool is safe to run. By default it is a dry run that prints a diff for each +file it would change, plus warnings for anything that needs manual work. Pass +`-w` to write the changes to disk, and `-json` for a machine-readable report. + +Most code changes depend on the re-generated v2 code, so the migration runs in +two passes: + +1. Run `connect-go-v2-migrate -w` to update `buf.gen.yaml` and apply any code changes that don't depend on generated code. +2. Re-generate your code with the v2 plugin (see [Re-generate code](#re-generate-code)). +3. Run `connect-go-v2-migrate -w` again to finish the changes that bind to the v2 generated code. This will update client calls and handler signatures. +4. Finally, run `go mod tidy` and then build and test addressing any manual operations warned from the report. + +The project won't compile between steps 1 and 3. Work through the sequence, +then fix any remaining warnings the tool printed. Services may be migrated one at a time to reduce the changes, both v1 and v2 libraries can be imported in the same module. + +The sections below show each change. Changes are marked: + +- ✅ `connect-go-v2-migrate` handles this. +- ⚠️ The tool prints a warning. Update the code by hand. + +## Update buf.gen.yaml + +The v2 generator keeps the plugin name `protoc-gen-connect-go`. The required +change depends on how `buf.gen.yaml` declares the plugin. + +For remote plugins, the reference is pinned to the v2 release: + +```diff +version: v2 +plugins: + - remote: buf.build/protocolbuffers/go + out: gen + opt: paths=source_relative +- - remote: buf.build/connectrpc/go:v1.18.1 ++ - remote: buf.build/connectrpc/go:v2.0.0 + out: gen + opt: paths=source_relative +``` + +✅ `connect-go-v2-migrate` handles this. It also replaces +`buf.build/connectrpc/gosimple`, since v2 makes the simple API the default +generator. + +> **Before v2.0.0 is released**, `buf.build/connectrpc/go:v2.0.0` is not +> published, so `buf generate` will fail against it. Use the local plugin until +> the release lands. The migration tool warns when it rewrites a remote entry. + +For local plugins (`local: protoc-gen-connect-go`), the `buf.gen.yaml` entry +stays the same because the v1 and v2 plugins share the binary name. +Reinstalling the binary from the v2 module switches generation to v2: + +```sh +go install connectrpc.com/connect/v2/cmd/protoc-gen-connect-go@latest +``` + +If the plugin runs through go.mod (`local: [go, tool, protoc-gen-connect-go]`), +update the tool dependency instead: + +```sh +go get -tool connectrpc.com/connect/v2/cmd/protoc-gen-connect-go +go mod tidy +``` + +⚠️ The tool can't install binaries or change go.mod, so it prints the command +to run. + +v2 generated code is always in the simple style, and the generator rejects the +v1 `simple` option: + +```diff +version: v2 +plugins: + - local: protoc-gen-connect-go + out: gen + opt: + - paths=source_relative +- - simple=true +``` + +✅ `connect-go-v2-migrate` handles this, for both the list form and the inline +form (`opt: paths=source_relative,simple=true`). + +## Re-generate code + +With `buf.gen.yaml` updated and the plugin installed, re-generate: + +```sh +buf generate +``` + +The migration tool never edits generated code, so this step is yours. After +re-generating, run `connect-go-v2-migrate -w` again to migrate the code that +depends on the generated packages. + +## Update dependencies + +The tool rewrites import paths in your code but does not edit go.mod. After +the final tool run, resolve the new modules: + +```sh +go mod tidy +``` + +The core module and the ecosystem packages move to `/v2` module paths: + +| v1 | v2 | +| --- | --- | +| `connectrpc.com/connect` | `connectrpc.com/connect/v2` | +| `connectrpc.com/validate` | `connectrpc.com/validate/v2` | +| `connectrpc.com/otelconnect` | `connectrpc.com/otelconnect/v2` | +| `connectrpc.com/authn` | `connectrpc.com/authn/v2` | +| `connectrpc.com/grpcreflect` | `connectrpc.com/grpcreflect/v2` | +| `connectrpc.com/grpchealth` | `connectrpc.com/grpchealth/v2` | +| `connectrpc.com/vanguard` | `connectrpc.com/vanguard/v2` | + +The ecosystem modules release separately, following the core module. If +`go mod tidy` can't resolve a `/v2` module yet, check the package's +repository for its v2 release status. The API changes in each package are +covered in [Ecosystem packages](#ecosystem-packages). + +v2 also splits the runtime into subpackages of the same module. The core +package no longer depends on `net/http`: + +| Package | Contents | +| --- | --- | +| `connectrpc.com/connect/v2` | Core types, imported by generated code | +| `connectrpc.com/connect/v2/connecthttp` | `net/http` server and client bindings | +| `connectrpc.com/connect/v2/connectproto` | Protobuf codecs | +| `connectrpc.com/connect/v2/connectgzip` | Gzip compression | +| `connectrpc.com/connect/v2/connectinprocess` | In-process transport | + +## Update your application code + +### Requests and responses + +v1 wrapped every message in `connect.Request[T]` or `connect.Response[T]`. +v2 generated code passes protobuf messages directly: + +```go +// v1 +func (s *pingServer) Ping( + ctx context.Context, + req *connect.Request[pingv1.PingRequest], +) (*connect.Response[pingv1.PingResponse], error) { + return connect.NewResponse(&pingv1.PingResponse{Text: req.Msg.Text}), nil +} +``` + +```go +// v2 +func (s *pingServer) Ping( + ctx context.Context, + req *pingv1.PingRequest, +) (*pingv1.PingResponse, error) { + return &pingv1.PingResponse{Text: req.Text}, nil +} +``` + +Client calls change the same way: no `connect.NewRequest` wrapper, and no +`.Msg` field access on the response. + +✅ `connect-go-v2-migrate` handles this. + +### Server construction + +v1 generated a constructor returning a path and an `http.Handler`. In v2, +register services on a `*connect.Server` and mount it with `connecthttp`: + +```go +// v1 +mux := http.NewServeMux() +mux.Handle(pingv1connect.NewPingServiceHandler( + &pingServer{}, + connect.WithInterceptors(validate.NewInterceptor()), +)) +``` + +```go +// v2 +mux := http.NewServeMux() +server := connect.NewServer(validate.NewServerInterceptor()) +pingv1connect.RegisterPingServiceHandler(server, &pingServer{}) +connecthttp.Mount(mux, server) +``` + +Interceptors move from `connect.WithInterceptors(...)` to arguments of +`connect.NewServer`. Other handler options move to `connecthttp.Mount`. + +In v2, interceptors apply to every service on a server; there is no +per-handler option. Consecutive `mux.Handle(...)` calls with identical +options share one server. Handlers with different interceptors keep their v1 +behavior by registering on separate servers mounted on the same mux. + +✅ `connect-go-v2-migrate` handles the inline `mux.Handle(...)` shape, grouping +consecutive handlers with identical options onto one server. +⚠️ Handlers with differing options get one server per group; the tool warns +so you can review whether they should share one. Other shapes, like assigning +the path and handler to variables first, are also warned and need a manual +update. + +### Client construction + +v1 clients took an HTTP client and a base URL. v2 clients take a +`*connect.Client`, which wraps a transport: + +```go +// v1 +client := pingv1connect.NewPingServiceClient(http.DefaultClient, "http://localhost:8080") +``` + +```go +// v2 +client := pingv1connect.NewPingServiceClient(connect.NewClient( + connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080"), +)) +``` + +Interceptors move to `connect.NewClient`. Other client options move to +`connecthttp.NewTransport`. + +✅ `connect-go-v2-migrate` handles this. + +### Headers and trailers + +With the wrapper types gone, metadata moves to a `*connect.CallInfo` carried +on the context. It exposes `RequestHeader()`, `ResponseHeader()`, and +`ResponseTrailer()`. + +Clients attach the info to the context before the call and read response +metadata from it after: + +```go +// v1 +req := connect.NewRequest(&pingv1.PingRequest{Text: "hello"}) +req.Header().Set("X-Request-Id", requestID) +res, err := client.Ping(ctx, req) +``` + +```go +// v2 +ctx, info := connect.NewClientContext(ctx) +info.RequestHeader().Set("X-Request-Id", requestID) +res, err := client.Ping(ctx, &pingv1.PingRequest{Text: "hello"}) +``` + +Handlers get the info from the handler's context: + +```go +// v1 +func (s *pingServer) Ping( + ctx context.Context, + req *connect.Request[pingv1.PingRequest], +) (*connect.Response[pingv1.PingResponse], error) { + token := req.Header().Get("Authorization") + res := connect.NewResponse(&pingv1.PingResponse{}) + res.Header().Set("X-Server-Name", "ping-server") + return res, nil +} +``` + +```go +// v2 +func (s *pingServer) Ping( + ctx context.Context, + req *pingv1.PingRequest, +) (*pingv1.PingResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + token := info.RequestHeader().Get("Authorization") + res := &pingv1.PingResponse{} + info.ResponseHeader().Set("X-Server-Name", "ping-server") + return res, nil +} +``` + +The v1 `connect.CallInfoForHandlerContext(ctx)` is renamed to +`connect.CallInfoForServerContext(ctx)`; both return `(CallInfo, bool)`, so +existing comma-ok call sites carry over unchanged. + +✅ `connect-go-v2-migrate` moves metadata access to the `CallInfo`: + +- A handler's request-header read (`req.Header()`) and response + header/trailer set (`res.Header()`/`res.Trailer()` on a `connect.NewResponse` + holder) become `info.*` on a seeded + `info, _ := connect.CallInfoForServerContext(ctx)`. +- A client's request header set on a `connect.NewRequest` and response + header/trailer read (`res.Header()`/`res.Trailer()`) become + `connect.NewClientContext(ctx)` plus `info.*`. +- `connect.CallInfoForHandlerContext` renames to `connect.CallInfoForServerContext`. + +⚠️ A function that both sets a client request header and reads a client response +header, or that touches several response holders, is warned rather than seeding +one shared context. + +### Errors + +`connect.NewError` takes a message string instead of an `error`. In v2 a +plain `error` returned from a handler is never serialized to the wire, so +internal details can't leak by accident. + +```go +// v1 +return nil, connect.NewError(connect.CodeInternal, err) +``` + +```go +// v2 +return nil, connect.NewError(connect.CodeInternal, err.Error()) +``` + +v1 sent the error's full string to the client, so the tool rewrites to +`err.Error()` to preserve what callers see today. Common argument shapes +collapse to simpler forms with the same wire message: + +```go +connect.NewError(code, errors.New("nope")) // -> connect.NewError(code, "nope") +connect.NewError(code, fmt.Errorf("x %s", a)) // -> connect.Errorf(code, "x %s", a) +connect.NewError(code, nil) // -> connect.NewError(code, "") +``` + +To hide the underlying error from clients, attach it as a cause instead. The +cause is visible to `errors.Is` and `errors.As` on the server but never sent +to the client: + +```go +return nil, connect.NewError(connect.CodeInternal, "something went wrong").WithCause(err) +``` + +Error details keep the v1 shape with the constructor moved to `connectproto`. +`AddDetail` becomes the cloning `WithDetail` builder, and `ErrorDetail.Value()` +becomes `connectproto.UnmarshalErrorDetail`: + +```go +// v1 +cErr := connect.NewError(connect.CodeInternal, err) +if detail, derr := connect.NewErrorDetail(info); derr == nil { + cErr.AddDetail(detail) +} +``` + +```go +// v2 +cErr := connect.NewError(connect.CodeInternal, err.Error()) +if detail, derr := connectproto.NewErrorDetail(info); derr == nil { + cErr = cErr.WithDetail(detail) +} +``` + +The wire-error helpers became `*connect.Error` methods: + +| v1 | v2 | +| --- | --- | +| `connect.NewWireError(code, err)` | `connect.NewError(code, msg).WithRemote()` | +| `connect.IsWireError(err)` | `errors.As(err, &cerr)` then `cerr.IsRemote()` | + +Error codes (`connect.CodeNotFound`, `connect.CodeOf`, and friends) are +unchanged. + +✅ `connect-go-v2-migrate` handles the `NewError` rewrite and retargets the +`NewErrorDetail`/`AddDetail` guard to `connectproto`, preserving the v1 wire +message. Switching to `WithCause` is a behavior change to make by hand, and +⚠️ the wire-error helpers and other `ErrorDetail` uses are warned for a manual +update. + +### Options moved to connecthttp + +HTTP-specific options moved from the core package to `connecthttp`. Most keep +their name and signature and only change package: + +```go +// v1 +connect.WithReadMaxBytes(1024) +connect.WithSendMaxBytes(2048) +connect.WithCompressMinBytes(512) +connect.WithRequireConnectProtocolHeader() +connect.WithSendGzip() +connect.WithHTTPGet() +connect.WithHTTPGetMaxURLSize(8192, true) +connect.WithProtoJSON() +connect.WithGRPC() +connect.WithGRPCWeb() +connect.WithCodec(codec) +connect.WithSendCompression("gzip") +``` + +```go +// v2 +connecthttp.WithReadMaxBytes(1024) +connecthttp.WithSendMaxBytes(2048) +connecthttp.WithCompressMinBytes(512) +connecthttp.WithRequireConnectProtocolHeader() +connecthttp.WithSendGzip() +connecthttp.WithHTTPGet() +connecthttp.WithHTTPGetMaxURLSize(8192, true) +connecthttp.WithProtoJSON() +connecthttp.WithGRPC() +connecthttp.WithGRPCWeb() +connecthttp.WithCodec(codec) +connecthttp.WithSendCompression("gzip") +``` + +The `ErrorWriter` type, `NewErrorWriter`, and `IsNotModifiedError` move to +`connecthttp` the same way, keeping their signatures. + +✅ `connect-go-v2-migrate` handles these. + +### Read limits are bounded by default + +In v1, the default read limit was unbounded. This meant any caller, +authenticated or not, could force a server to buffer a message of any size. To +improve safety, v2 introduces a default limit of 4 MiB per message, which can +be overridden using `WithReadMaxBytes`. Messages exceeding this limit fail with +the `CodeResourceExhausted` error code. + +To prevent unexpected runtime behavior changes during upgrades, the migration +tool preserves the v1 unbounded behavior by default: + +```go +// v2, as the tool writes it +connecthttp.NewTransport(httpClient, baseURL, connecthttp.WithReadMaxBytes(0)) +connecthttp.Mount(mux, server, connecthttp.WithReadMaxBytes(0)) +``` + +✅ `connect-go-v2-migrate` handles this, and ⚠️ warns at every call it pins. + +Options whose signature changed are warned with the v2 replacement: + +| v1 | v2 | +| --- | --- | +| `connect.WithCompression(name, dec, comp)` | `connecthttp.WithCompressor(connect.Compressor)` (register a `connectgzip`-style compressor) | +| `connect.WithAcceptCompression(name, dec, comp)` | `connecthttp.WithCompressor(...)` to register, then `connecthttp.WithAcceptCompression(name)` to advertise; `connecthttp.WithNoCompression()` to disable | +| `connect.WithConditionalHandlerOptions(fn)` | `connecthttp.WithConditionalOptions(func(connect.Spec) []connecthttp.Option)` (callback signature changed) | +| `connect.NewNotModifiedError(header)` | `connecthttp.NewNotModifiedError()` (no header argument) | + +⚠️ Update these by hand. + +### Interceptors + +v1 had one `Interceptor` interface with separate methods for unary calls, +streaming clients, and streaming handlers. v2 replaces it with two function +types, `connect.ClientInterceptor` and `connect.ServerInterceptor`, that wrap +every RPC type uniformly: + +```go +func NewServerInterceptor(logger *slog.Logger) connect.ServerInterceptor { + return func(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + err := next(ctx, spec, stream) + logger.InfoContext(ctx, "rpc completed", + slog.String("procedure", spec.Procedure), + slog.Any("error", err), + ) + return err + } + } +} +``` + +The tool maps ecosystem interceptor constructors where it can: + +- ✅ `validate.NewInterceptor()` becomes `validate.NewServerInterceptor()` or + `validate.NewClientInterceptor()`, depending on where it's used. +- ⚠️ `otelconnect.NewInterceptor()` becomes + `otelconnect.NewServerInterceptor()` or `otelconnect.NewClientInterceptor()`, + which return an error. Assign the interceptor before constructing the server + or client. +- ⚠️ Custom interceptors must be rewritten by hand to the new function types. + The tool warns at each one. + +### Streaming + +Streaming generated code defines a named stream type per RPC (for example, +`PingServiceCumSumClientStream`) instead of the v1 generics. Stream `Send`, `Receive`, +and `SendHeaders` keep their v1 shape and take no `context.Context`; the call's +context is bound when the stream is opened. + +Stream metadata moves off the stream and onto the `CallInfo`, since the +generated stream types no longer carry headers. A handler stream's +`RequestHeader()`/`ResponseHeader()`/`ResponseTrailer()` become `info.*` on a +seeded `info, _ := connect.CallInfoForServerContext(ctx)`; a client stream's +seed a `connect.NewClientContext(ctx)` and read from the returned `info`. + +✅ `connect-go-v2-migrate` handles both. + +The v1 receive loop, `Receive() bool` with `Msg()` and a trailing `Err()` +check, becomes a `Receive()` call returning the message and an error. `io.EOF` +marks the end of the stream: + +```go +// v1 +for stream.Receive() { + total += stream.Msg().Number +} +if err := stream.Err(); err != nil { + return nil, err +} +``` + +```go +// v2 +for { + msg, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, err + } + total += msg.Number +} +``` + +✅ `connect-go-v2-migrate` handles this. + +On the client side, stream constructors return an error and the close methods +are renamed: + +| v1 | v2 | +| --- | --- | +| `stream := client.CumSum(ctx)` | `stream, err := client.CumSum(ctx)` | +| `stream.CloseRequest()` | `stream.CloseSend()` | +| `stream.CloseResponse()` | `stream.Close()` | +| `res, err := stream.CloseAndReceive()` | `res, err := stream.CloseAndReceive()` (returns the bare message) | + +✅ `connect-go-v2-migrate` handles this. + +Handler stream parameters change the same way. A v1 handler takes a generic +like `*connect.BidiStream[Req, Res]`, while v2 passes a named type generated +per RPC (like `PingServiceCumSumServerStream`): + +```go +// v1 +func (s *pingServer) CumSum(ctx context.Context, stream *connect.BidiStream[pingv1.CumSumRequest, pingv1.CumSumResponse]) error + +// v2 +func (s *pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error +``` + +✅ `connect-go-v2-migrate` resolves the parameter type to the generated stream +type by matching the handler to its RPC. +⚠️ When several services share an RPC name and message types, the match is +ambiguous; the tool warns so you can pick the right generated type by hand. + +### Helper packages + +Some shared helpers need a new sibling API rather than an in-place rewrite, +for example a helper that returns a v1 `UnaryInterceptorFunc` becoming a v2 +`ServerInterceptor`. ⚠️ This is project-specific: add the v2 helper alongside +the v1 one, then switch call sites to it by hand. + +## Ecosystem packages + +Each ecosystem package releases its own `/v2` module. Beyond the import path, +most also reshape their API to register on a `*connect.Server`, so +interceptors and alternative transports cover them. The sections below show +each change. + +### validate + +`validate.NewInterceptor` splits into server and client forms: + +```go +// v1 +connect.WithInterceptors(validate.NewInterceptor()) +``` + +```go +// v2 +connect.NewServer(validate.NewServerInterceptor()) +``` + +✅ `connect-go-v2-migrate` handles this, picking the server or client form from +where the interceptor is used. + +### otelconnect + +`otelconnect.NewInterceptor` also splits into `NewServerInterceptor` and +`NewClientInterceptor`. Both still return an error, so assign them before +constructing the server or client: + +```go +// v1 +interceptor, err := otelconnect.NewInterceptor() +``` + +```go +// v2 +serverInterceptor, err := otelconnect.NewServerInterceptor() +``` + +⚠️ The tool can't tell the server side from the client side at the +assignment, so it warns and leaves the call for you to pick the right form. + +### authn + +Authentication moves from HTTP middleware to a server interceptor, and +`AuthFunc` no longer receives an `*http.Request`: + +```go +// v1 +type AuthFunc func(ctx context.Context, req *http.Request) (any, error) + +handler := authn.NewMiddleware(authenticate).Wrap(mux) +``` + +```go +// v2 +type AuthFunc func(ctx context.Context, spec connect.Spec, req *connect.Header) (any, error) + +server := connect.NewServer(authn.NewServerInterceptor(authenticate)) +``` + +`authn.GetInfo` is unchanged. An `AuthFunc` that read headers from the +request ports directly to `connect.Header`. HTTP-level details move to the +transport: read TLS state and the peer address with +`connecthttp.ServerInfoForContext(ctx)`. + +⚠️ Port the `AuthFunc` body and replace the middleware by hand. The tool +warns at each `authn.NewMiddleware` call. + +### grpchealth + +The health service registers on the server instead of wrapping a mux: + +```go +// v1 +mux.Handle(grpchealth.NewHandler(checker)) +``` + +```go +// v2 +grpchealth.Register(server, checker) +``` + +✅ `connect-go-v2-migrate` handles the inline `mux.Handle(...)` shape. A health +handler registered alongside your services with the same options joins their +server; with different options it gets its own server, keeping its v1 +interceptor behavior. + +v2 also adds `grpchealth.NewClient` for calling health checks; v1 had no +client. + +### grpcreflect + +Reflection registers on the server with one call. `Register` serves both the +v1 and v1alpha reflection APIs, and by default describes the services +registered on the server, so the static service list usually disappears: + +```go +// v1 +reflector := grpcreflect.NewStaticReflector("acme.user.v1.UserService") +mux.Handle(grpcreflect.NewHandlerV1(reflector)) +mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector)) +``` + +```go +// v2 +grpcreflect.Register(server) +``` + +To expose a different set of services, for example when proxying, pass +`grpcreflect.WithNamer`. + +⚠️ The tool warns at each `NewHandlerV1`, `NewHandlerV1Alpha`, and +`NewStaticReflector` call; collapse them to one `Register` by hand. + +The reflection client changes like the generated clients: + +```go +// v1 +client := grpcreflect.NewClient(http.DefaultClient, "http://localhost:8080") +``` + +```go +// v2 +client := grpcreflect.NewClient(connect.NewClient( + connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080"), +)) +``` + +✅ `connect-go-v2-migrate` handles this. + +### vanguard + +REST transcoding mounts the server's REST routes directly; the +`Transcoder` and `Service` types are gone. Methods registered on the server +whose descriptors carry a `google.api.http` annotation become REST endpoints: + +```go +// v1 +services := []*vanguard.Service{ + vanguard.NewService(pingv1connect.PingServiceName, handler), +} +transcoder, err := vanguard.NewTranscoder(services) +mux.Handle("/", transcoder) +``` + +```go +// v2 +err := vanguard.Mount(mux, server) +``` + +For gRPC servers, `vanguardgrpc.NewTranscoder(grpcServer)` becomes +`vanguardgrpc.NewServiceRegistrar(server)`, which registers gRPC service +implementations on a `*connect.Server`. + +⚠️ Update vanguard by hand. The tool warns at each v1 call site. + +## Testing with the in-process transport + +v2 clients are not tied to HTTP, so tests no longer need a listener or an +`httptest.Server`. The `connectinprocess` package connects a client directly +to a server in the same process: + +```go +func TestPingService(t *testing.T) { + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, &pingServer{}) + + client := connect.NewClient(connectinprocess.New(server)) + pingClient := pingv1connect.NewPingServiceClient(client) + + res, err := pingClient.Ping(t.Context(), &pingv1.PingRequest{Text: "hello"}) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if got, want := res.GetText(), "hello"; got != want { + t.Errorf("Text = %q, want %q", got, want) + } +} +``` + +This is not a required migration step, but in-process tests are faster and +less flaky than tests over a loopback listener. + +## Getting help + +If your migration hits a case not covered here, or the tool produces a result +you didn't expect, please [open an issue](https://github.com/connectrpc/connect-go/issues) +or ask in [Slack](https://buf.build/links/slack). diff --git a/error.go b/error.go deleted file mode 100644 index b6c81a3a..00000000 --- a/error.go +++ /dev/null @@ -1,471 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/url" - "os" - "strings" - "time" - - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/anypb" -) - -const ( - commonErrorsURL = "https://connectrpc.com/docs/go/common-errors" - defaultAnyResolverPrefix = "type.googleapis.com/" -) - -var ( - // errNotModified signals Connect-protocol responses to GET requests to use the - // 304 Not Modified HTTP error code. - errNotModified = errors.New("not modified") - // errNotModifiedClient wraps ErrNotModified for use client-side. - errNotModifiedClient = fmt.Errorf("HTTP 304: %w", errNotModified) -) - -// An ErrorDetail is a self-describing Protobuf message attached to an [*Error]. -// Error details are sent over the network to clients, which can then work with -// strongly-typed data rather than trying to parse a complex error message. For -// example, you might use details to send a localized error message or retry -// parameters to the client. -// -// The [google.golang.org/genproto/googleapis/rpc/errdetails] package contains a -// variety of Protobuf messages commonly used as error details. -type ErrorDetail struct { - pbAny *anypb.Any - pbInner proto.Message // if nil, must be extracted from pbAny - wireJSON string // preserve human-readable JSON -} - -// NewErrorDetail constructs a new error detail. If msg is an *[anypb.Any] then -// it is used as is. Otherwise, it is first marshalled into an *[anypb.Any] -// value. This returns an error if msg cannot be marshalled. -func NewErrorDetail(msg proto.Message) (*ErrorDetail, error) { - // If it's already an Any, don't wrap it inside another. - if pb, ok := msg.(*anypb.Any); ok { - return &ErrorDetail{pbAny: pb}, nil - } - pb, err := anypb.New(msg) - if err != nil { - return nil, err - } - return &ErrorDetail{pbAny: pb, pbInner: msg}, nil -} - -// Type is the fully-qualified name of the detail's Protobuf message (for -// example, acme.foo.v1.FooDetail). -func (d *ErrorDetail) Type() string { - // proto.Any tries to make messages self-describing by using type URLs rather - // than plain type names, but there aren't any descriptor registries - // deployed. With the current state of the `Any` code, it's not possible to - // build a useful type registry either. To hide this from users, we should - // trim the URL prefix is added to the type name. - // - // If we ever want to support remote registries, we can add an explicit - // `TypeURL` method. - return typeNameForURL(d.pbAny.GetTypeUrl()) -} - -// Bytes returns a copy of the Protobuf-serialized detail. -func (d *ErrorDetail) Bytes() []byte { - out := make([]byte, len(d.pbAny.GetValue())) - copy(out, d.pbAny.GetValue()) - return out -} - -// Value uses the Protobuf runtime's package-global registry to unmarshal the -// Detail into a strongly-typed message. Typically, clients use Go type -// assertions to cast from the proto.Message interface to concrete types. -func (d *ErrorDetail) Value() (proto.Message, error) { - if d.pbInner != nil { - // We clone it so that if the caller mutates the returned value, - // they don't inadvertently corrupt this error detail value. - return proto.Clone(d.pbInner), nil - } - return d.pbAny.UnmarshalNew() -} - -// An Error captures four key pieces of information: a [Code], an underlying Go -// error, a map of metadata, and an optional collection of arbitrary Protobuf -// messages called "details" (more on those below). Servers send the code, the -// underlying error's Error() output, the metadata, and details over the wire -// to clients. Remember that the underlying error's message will be sent to -// clients - take care not to leak sensitive information from public APIs! -// -// Service implementations and interceptors should return errors that can be -// cast to an [*Error] (using the standard library's [errors.As]). If the returned -// error can't be cast to an [*Error], connect will use [CodeUnknown] and the -// returned error's message. -// -// Error details are an optional mechanism for servers, interceptors, and -// proxies to attach arbitrary Protobuf messages to the error code and message. -// They're a clearer and more performant alternative to HTTP header -// microformats. See [the documentation on errors] for more details. -// -// [the documentation on errors]: https://connectrpc.com/docs/go/errors -type Error struct { - code Code - err error - details []*ErrorDetail - meta http.Header - wireErr bool -} - -// NewError annotates any Go error with a status code. -func NewError(c Code, underlying error) *Error { - return &Error{code: c, err: underlying} -} - -// NewWireError is similar to [NewError], but the resulting *Error returns true -// when tested with [IsWireError]. -// -// This is useful for clients trying to propagate partial failures from -// streaming RPCs. Often, these RPCs include error information in their -// response messages (for example, [gRPC server reflection] and -// OpenTelemetry's [OTLP]). Clients propagating these errors up the stack -// should use NewWireError to clarify that the error code, message, and details -// (if any) were explicitly sent by the server rather than inferred from a -// lower-level networking error or timeout. -// -// [gRPC server reflection]: https://github.com/grpc/grpc/blob/v1.49.2/src/proto/grpc/reflection/v1alpha/reflection.proto#L132-L136 -// [OTLP]: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/otlp.md#partial-success -func NewWireError(c Code, underlying error) *Error { - err := NewError(c, underlying) - err.wireErr = true - return err -} - -// IsWireError checks whether the error was returned by the server, as opposed -// to being synthesized by the client. -// -// Clients may find this useful when deciding how to propagate errors. For -// example, an RPC-to-HTTP proxy might expose a server-sent CodeUnknown as an -// HTTP 500 but a client-synthesized CodeUnknown as a 503. -// -// Handlers will strip [Error.Meta] headers propagated from wire errors to avoid -// leaking response headers. To propagate headers recreate the error as a -// non-wire error. -func IsWireError(err error) bool { - se := new(Error) - if !errors.As(err, &se) { - return false - } - return se.wireErr -} - -// NewNotModifiedError indicates that the requested resource hasn't changed. It -// should be used only when handlers wish to respond to conditional HTTP GET -// requests with a 304 Not Modified. In all other circumstances, including all -// RPCs using the gRPC or gRPC-Web protocols, it's equivalent to sending an -// error with [CodeUnknown]. The supplied headers should include Etag, -// Cache-Control, or any other headers required by [RFC 9110 § 15.4.5]. -// -// Clients should check for this error using [IsNotModifiedError]. -// -// [RFC 9110 § 15.4.5]: https://httpwg.org/specs/rfc9110.html#status.304 -func NewNotModifiedError(headers http.Header) *Error { - err := NewError(CodeUnknown, errNotModified) - if headers != nil { - err.meta = headers - } - return err -} - -func (e *Error) Error() string { - message := e.Message() - if message == "" { - return e.code.String() - } - return e.code.String() + ": " + message -} - -// Message returns the underlying error message. It may be empty if the -// original error was created with a status code and a nil error. -func (e *Error) Message() string { - if e.err != nil { - return e.err.Error() - } - return "" -} - -// Unwrap allows [errors.Is] and [errors.As] access to the underlying error. -func (e *Error) Unwrap() error { - return e.err -} - -// Code returns the error's status code. -func (e *Error) Code() Code { - return e.code -} - -// Details returns the error's details. -func (e *Error) Details() []*ErrorDetail { - return e.details -} - -// AddDetail appends to the error's details. -func (e *Error) AddDetail(d *ErrorDetail) { - e.details = append(e.details, d) -} - -// Meta allows the error to carry additional information as key-value pairs. -// -// Protocol-specific headers and trailers may be removed to avoid breaking -// protocol semantics. For example, Content-Length and Content-Type headers -// won't be propagated. See the documentation for each protocol for more -// datails. -// -// When clients receive errors, the metadata contains the union of the HTTP -// headers and the protocol-specific trailers (either HTTP trailers or in-body -// metadata). -func (e *Error) Meta() http.Header { - if e.meta == nil { - e.meta = make(http.Header) - } - return e.meta -} - -func (e *Error) detailsAsAny() []*anypb.Any { - anys := make([]*anypb.Any, 0, len(e.details)) - for _, detail := range e.details { - anys = append(anys, detail.pbAny) - } - return anys -} - -// IsNotModifiedError checks whether the supplied error indicates that the -// requested resource hasn't changed. It only returns true if the server used -// [NewNotModifiedError] in response to a Connect-protocol RPC made with an -// HTTP GET. -func IsNotModifiedError(err error) bool { - return errors.Is(err, errNotModified) -} - -// errorf calls fmt.Errorf with the supplied template and arguments, then wraps -// the resulting error. -func errorf(c Code, template string, args ...any) *Error { - return NewError(c, fmt.Errorf(template, args...)) -} - -// asError uses errors.As to unwrap any error and look for a connect *Error. -func asError(err error) (*Error, bool) { - var connectErr *Error - ok := errors.As(err, &connectErr) - return connectErr, ok -} - -// wrapIfUncoded ensures that all errors are wrapped. It leaves already-wrapped -// errors unchanged, uses wrapIfContextError to apply codes to context.Canceled -// and context.DeadlineExceeded, and falls back to wrapping other errors with -// CodeUnknown. -func wrapIfUncoded(err error) error { - if err == nil { - return nil - } - maybeCodedErr := wrapIfContextError(err) - if _, ok := asError(maybeCodedErr); ok { - return maybeCodedErr - } - return NewError(CodeUnknown, maybeCodedErr) -} - -// wrapIfContextError applies CodeCanceled or CodeDeadlineExceeded to Go's -// context.Canceled and context.DeadlineExceeded errors, but only if they -// haven't already been wrapped. -func wrapIfContextError(err error) error { - if err == nil { - return nil - } - if _, ok := asError(err); ok { - return err - } - if errors.Is(err, context.Canceled) { - return NewError(CodeCanceled, err) - } - if errors.Is(err, context.DeadlineExceeded) { - return NewError(CodeDeadlineExceeded, err) - } - // Ick, some dial errors can be returned as os.ErrDeadlineExceeded - // instead of context.DeadlineExceeded :( - // https://github.com/golang/go/issues/64449 - if errors.Is(err, os.ErrDeadlineExceeded) { - return NewError(CodeDeadlineExceeded, err) - } - return err -} - -// wrapIfContextDone wraps errors with CodeCanceled or CodeDeadlineExceeded -// if the context is done. It leaves already-wrapped errors unchanged. -func wrapIfContextDone(ctx context.Context, err error) error { - if err == nil { - return nil - } - err = wrapIfContextError(err) - if _, ok := asError(err); ok { - return err - } - ctxErr := ctx.Err() - if errors.Is(ctxErr, context.Canceled) { - return NewError(CodeCanceled, err) - } else if errors.Is(ctxErr, context.DeadlineExceeded) { - return NewError(CodeDeadlineExceeded, err) - } - return err -} - -// wrapIfLikelyH2CNotConfiguredError adds a wrapping error that has a message -// telling the caller that they likely need to use h2c but are using a raw http.Client{}. -// -// This happens when running a gRPC-only server. -// This is fragile and may break over time, and this should be considered a best-effort. -func wrapIfLikelyH2CNotConfiguredError(request *http.Request, err error) error { - if err == nil { - return nil - } - if _, ok := asError(err); ok { - return err - } - if url := request.URL; url != nil && url.Scheme != "http" { - // If the scheme is not http, we definitely do not have an h2c error, so just return. - return err - } - // net/http code has been investigated and there is no typing of any of these errors - // they are all created with fmt.Errorf - // grpc-go returns the first error 2/3-3/4 of the time, and the second error 1/4-1/3 of the time - if errString := err.Error(); strings.HasPrefix(errString, `Post "`) && - (strings.Contains(errString, `net/http: HTTP/1.x transport connection broken: malformed HTTP response`) || - strings.HasSuffix(errString, `write: broken pipe`)) { - return fmt.Errorf("possible h2c configuration issue when talking to gRPC server, see %s: %w", commonErrorsURL, err) - } - return err -} - -// wrapIfLikelyWithGRPCNotUsedError adds a wrapping error that has a message -// telling the caller that they likely forgot to use connect.WithGRPC(). -// -// This happens when running a gRPC-only server. -// This is fragile and may break over time, and this should be considered a best-effort. -func wrapIfLikelyWithGRPCNotUsedError(err error) error { - if err == nil { - return nil - } - if _, ok := asError(err); ok { - return err - } - // golang.org/x/net code has been investigated and there is no typing of this error - // it is created with fmt.Errorf - // http2/transport.go:573: return nil, fmt.Errorf("http2: Transport: cannot retry err [%v] after Request.Body was written; define Request.GetBody to avoid this error", err) - if errString := err.Error(); strings.HasPrefix(errString, `Post "`) && - strings.Contains(errString, `http2: Transport: cannot retry err`) && - strings.HasSuffix(errString, `after Request.Body was written; define Request.GetBody to avoid this error`) { - return fmt.Errorf("possible missing connect.WithGRPC() client option when talking to gRPC server, see %s: %w", commonErrorsURL, err) - } - return err -} - -// HTTP/2 has its own set of error codes, which it sends in RST_STREAM frames. -// When the server sends one of these errors, we should map it back into our -// RPC error codes following -// https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#http2-transport-mapping. -// -// This would be vastly simpler if we were using x/net/http2 directly, since -// the StreamError type is exported. When x/net/http2 gets vendored into -// net/http, though, all these types become unexported...so we're left with -// string munging. -func wrapIfRSTError(ctx context.Context, err error) error { - const ( - streamErrPrefix = "stream error: " - fromPeerSuffix = "; received from peer" - ) - if err == nil { - return nil - } - if _, ok := asError(err); ok { - return err - } - if urlErr := new(url.Error); errors.As(err, &urlErr) { - // If we get an RST_STREAM error from http.Client.Do, it's wrapped in a - // *url.Error. - err = urlErr.Unwrap() - } - msg := err.Error() - if !strings.HasPrefix(msg, streamErrPrefix) { - return err - } - if !strings.HasSuffix(msg, fromPeerSuffix) { - return err - } - msg = strings.TrimSuffix(msg, fromPeerSuffix) - i := strings.LastIndex(msg, ";") - if i < 0 || i >= len(msg)-1 { - return err - } - msg = msg[i+1:] - msg = strings.TrimSpace(msg) - switch msg { - case "NO_ERROR", "PROTOCOL_ERROR", "INTERNAL_ERROR", "FLOW_CONTROL_ERROR", - "SETTINGS_TIMEOUT", "FRAME_SIZE_ERROR", "COMPRESSION_ERROR", "CONNECT_ERROR": - return NewError(CodeInternal, err) - case "REFUSED_STREAM": - return NewError(CodeUnavailable, err) - case "CANCEL": - if deadline, ok := ctx.Deadline(); ok && time.Now().After(deadline) { - // Some server implementations will cancel the HTTP/2 stream with - // a RST_STREAM frame when they observe that the client's deadline - // has elapsed. - // We don't inspect ctx.Err() because we could be racing with the - // timer goroutine that is setting it. But there is no race when - // directly inspecting the context's deadline. In fact, if we get - // here, we have likely already examined ctx.Err() in a prior call - // to wrapIfContextError but observed a nil error and then fell - // through to here. - return NewError(CodeDeadlineExceeded, err) - } - return NewError(CodeCanceled, err) - case "ENHANCE_YOUR_CALM": - return NewError(CodeResourceExhausted, fmt.Errorf("bandwidth exhausted: %w", err)) - case "INADEQUATE_SECURITY": - return NewError(CodePermissionDenied, fmt.Errorf("transport protocol insecure: %w", err)) - default: - return err - } -} - -// wrapIfMaxBytesError wraps errors returned reading from a http.MaxBytesHandler -// whose limit has been exceeded. -func wrapIfMaxBytesError(err error, tmpl string, args ...any) error { - if err == nil { - return nil - } - if _, ok := asError(err); ok { - return err - } - var maxBytesErr *http.MaxBytesError - if ok := errors.As(err, &maxBytesErr); !ok { - return err - } - prefix := fmt.Sprintf(tmpl, args...) - return errorf(CodeResourceExhausted, "%s: exceeded %d byte http.MaxBytesReader limit", prefix, maxBytesErr.Limit) -} - -func typeNameForURL(url string) string { - return url[strings.LastIndexByte(url, '/')+1:] -} diff --git a/go.mod b/go.mod index 155e9e1c..450f83a1 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,7 @@ -module connectrpc.com/connect +module connectrpc.com/connect/v2 go 1.25.0 -retract ( - v1.10.0 // module cache poisoned, use v1.10.1 - v1.9.0 // module cache poisoned, use v1.9.1 -) - require ( github.com/google/go-cmp v0.7.0 google.golang.org/protobuf v1.36.11 diff --git a/handler.go b/handler.go deleted file mode 100644 index 5355329d..00000000 --- a/handler.go +++ /dev/null @@ -1,427 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "context" - "net/http" -) - -// A Handler is the server-side implementation of a single RPC defined by a -// service schema. -// -// By default, Handlers support the Connect, gRPC, and gRPC-Web protocols with -// the binary Protobuf and JSON codecs. They support gzip compression using the -// standard library's [compress/gzip]. -type Handler struct { - spec Spec - implementation StreamingHandlerFunc - protocolHandlers map[string][]protocolHandler // Method to protocol handlers - allowMethod string // Allow header - acceptPost string // Accept-Post header -} - -// NewUnaryHandler constructs a [Handler] for a request-response procedure. -func NewUnaryHandler[Req, Res any]( - procedure string, - unary func(context.Context, *Request[Req]) (*Response[Res], error), - options ...HandlerOption, -) *Handler { - // Wrap the strongly-typed implementation so we can apply interceptors. - untyped := UnaryFunc(func(ctx context.Context, request AnyRequest) (AnyResponse, error) { - if err := ctx.Err(); err != nil { - return nil, err - } - typed, ok := request.(*Request[Req]) - if !ok { - return nil, errorf(CodeInternal, "unexpected handler request type %T", request) - } - res, err := unary(ctx, typed) - if res == nil && err == nil { - // This is going to panic during serialization. Debugging is much easier - // if we panic here instead, so we can include the procedure name. - panic(procedure + " returned nil *connect.Response and nil error") //nolint: forbidigo - } - if res == nil { - // Avoid returning a typed nil (*Response[Res]) as an AnyResponse interface value. - return nil, err - } - return res, err - }) - config := newHandlerConfig(procedure, StreamTypeUnary, options) - if interceptor := config.Interceptor; interceptor != nil { - untyped = interceptor.WrapUnary(untyped) - } - // Given a stream, how should we call the unary function? - implementation := func(ctx context.Context, conn StreamingHandlerConn) error { - request, err := receiveUnaryRequest[Req](conn, config.Initializer) - if err != nil { - return err - } - // Add the request header to the context, and store the response header - // and trailer to propagate back to the caller. - info := &handlerCallInfo{ - peer: request.Peer(), - spec: request.Spec(), - method: request.HTTPMethod(), - requestHeader: request.Header(), - } - ctx = newHandlerContext(ctx, info) - response, err := untyped(ctx, request) - // Add response headers/trailers from the context callinfo into the conn if they exist - if info.responseHeader != nil { - mergeNonProtocolHeaders(conn.ResponseHeader(), info.responseHeader) - } - if info.responseTrailer != nil { - mergeNonProtocolHeaders(conn.ResponseTrailer(), info.responseTrailer) - } - if err != nil { - return err - } - - // Add response headers/trailers from the response into the conn if they exist - if len(response.Header()) != 0 { - mergeNonProtocolHeaders(conn.ResponseHeader(), response.Header()) - } - if len(response.Trailer()) != 0 { - mergeNonProtocolHeaders(conn.ResponseTrailer(), response.Trailer()) - } - return conn.Send(response.Any()) - } - - protocolHandlers := config.newProtocolHandlers() - return &Handler{ - spec: config.newSpec(), - implementation: implementation, - protocolHandlers: mappedMethodHandlers(protocolHandlers), - allowMethod: sortedAllowMethodValue(protocolHandlers), - acceptPost: sortedAcceptPostValue(protocolHandlers), - } -} - -// NewUnaryHandlerSimple constructs a [Handler] for a request-response procedure using the -// function signature associated with the "simple" generation option. -// -// This option eliminates the [Request] and [Response] wrappers, and instead uses the -// context.Context to propagate information such as headers. -func NewUnaryHandlerSimple[Req, Res any]( - procedure string, - unary func(context.Context, *Req) (*Res, error), - options ...HandlerOption, -) *Handler { - return NewUnaryHandler( - procedure, - func(ctx context.Context, request *Request[Req]) (*Response[Res], error) { - responseMsg, err := unary(ctx, request.Msg) - if err != nil { - return nil, err - } - return NewResponse(responseMsg), nil - }, - options..., - ) -} - -// NewClientStreamHandler constructs a [Handler] for a client streaming procedure. -func NewClientStreamHandler[Req, Res any]( - procedure string, - implementation func(context.Context, *ClientStream[Req]) (*Response[Res], error), - options ...HandlerOption, -) *Handler { - config := newHandlerConfig(procedure, StreamTypeClient, options) - return newStreamHandler( - config, - func(ctx context.Context, conn StreamingHandlerConn) error { - stream := &ClientStream[Req]{ - conn: conn, - initializer: config.Initializer, - } - ctx = newHandlerContext(ctx, &streamingHandlerCallInfo{ - conn: conn, - }) - res, err := implementation(ctx, stream) - if err != nil { - return err - } - if res == nil { - // This is going to panic during serialization. Debugging is much easier - // if we panic here instead, so we can include the procedure name. - panic(procedure + " returned nil *connect.Response and nil error") //nolint: forbidigo - } - mergeHeaders(conn.ResponseHeader(), res.header) - mergeHeaders(conn.ResponseTrailer(), res.trailer) - return conn.Send(res.Msg) - }, - ) -} - -// NewClientStreamHandlerSimple constructs a [Handler] for a request-streaming procedure -// using the function signature associated with the "simple" generation option. -// -// This option eliminates the [Response] wrapper, and instead uses the context.Context -// to propagate information such as headers. -func NewClientStreamHandlerSimple[Req, Res any]( - procedure string, - implementation func(context.Context, *ClientStream[Req]) (*Res, error), - options ...HandlerOption, -) *Handler { - return NewClientStreamHandler( - procedure, - func(ctx context.Context, stream *ClientStream[Req]) (*Response[Res], error) { - responseMsg, err := implementation(ctx, stream) - if err != nil { - return nil, err - } - return NewResponse(responseMsg), nil - }, - options..., - ) -} - -// NewServerStreamHandler constructs a [Handler] for a server streaming procedure. -func NewServerStreamHandler[Req, Res any]( - procedure string, - implementation func(context.Context, *Request[Req], *ServerStream[Res]) error, - options ...HandlerOption, -) *Handler { - config := newHandlerConfig(procedure, StreamTypeServer, options) - return newStreamHandler( - config, - func(ctx context.Context, conn StreamingHandlerConn) error { - req, err := receiveUnaryRequest[Req](conn, config.Initializer) - if err != nil { - return err - } - ctx = newHandlerContext(ctx, &streamingHandlerCallInfo{ - conn: conn, - }) - return implementation(ctx, req, &ServerStream[Res]{conn: conn}) - }, - ) -} - -// NewServerStreamHandlerSimple constructs a [Handler] a server streaming procedure using the function -// signature associated with the "simple" generation option. -// -// This option eliminates the [Request] wrapper, and instead uses the context.Context to -// propagate information such as headers. -func NewServerStreamHandlerSimple[Req, Res any]( - procedure string, - implementation func(context.Context, *Req, *ServerStream[Res]) error, - options ...HandlerOption, -) *Handler { - return NewServerStreamHandler( - procedure, - func(ctx context.Context, request *Request[Req], serverStream *ServerStream[Res]) error { - return implementation(ctx, request.Msg, serverStream) - }, - options..., - ) -} - -// NewBidiStreamHandler constructs a [Handler] for a bidirectional streaming procedure. -func NewBidiStreamHandler[Req, Res any]( - procedure string, - implementation func(context.Context, *BidiStream[Req, Res]) error, - options ...HandlerOption, -) *Handler { - config := newHandlerConfig(procedure, StreamTypeBidi, options) - return newStreamHandler( - config, - func(ctx context.Context, conn StreamingHandlerConn) error { - ctx = newHandlerContext(ctx, &streamingHandlerCallInfo{ - conn: conn, - }) - return implementation( - ctx, - &BidiStream[Req, Res]{ - conn: conn, - initializer: config.Initializer, - }, - ) - }, - ) -} - -// ServeHTTP implements [http.Handler]. -func (h *Handler) ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) { - // We don't need to defer functions to close the request body or read to - // EOF: the stream we construct later on already does that, and we only - // return early when dealing with misbehaving clients. In those cases, it's - // okay if we can't re-use the connection. - isBidi := (h.spec.StreamType & StreamTypeBidi) == StreamTypeBidi - if isBidi && request.ProtoMajor < 2 { - // Clients coded to expect full-duplex connections may hang if they've - // mistakenly negotiated HTTP/1.1. To unblock them, we must close the - // underlying TCP connection. - responseWriter.Header().Set("Connection", "close") - responseWriter.WriteHeader(http.StatusHTTPVersionNotSupported) - return - } - - protocolHandlers := h.protocolHandlers[request.Method] - if len(protocolHandlers) == 0 { - responseWriter.Header().Set("Allow", h.allowMethod) - responseWriter.WriteHeader(http.StatusMethodNotAllowed) - return - } - - contentType := canonicalizeContentType(getHeaderCanonical(request.Header, headerContentType)) - - // Find our implementation of the RPC protocol in use. - var protocolHandler protocolHandler - for _, handler := range protocolHandlers { - if handler.CanHandlePayload(request, contentType) { - protocolHandler = handler - break - } - } - if protocolHandler == nil { - responseWriter.Header().Set("Accept-Post", h.acceptPost) - responseWriter.WriteHeader(http.StatusUnsupportedMediaType) - return - } - - if request.Method == http.MethodGet { - // A body must not be present. - hasBody := request.ContentLength > 0 - if request.ContentLength < 0 { - // No content-length header. - // Test if body is empty by trying to read a single byte. - var b [1]byte - n, _ := request.Body.Read(b[:]) - hasBody = n > 0 - } - if hasBody { - responseWriter.WriteHeader(http.StatusUnsupportedMediaType) - return - } - _ = request.Body.Close() - } - - // Establish a stream and serve the RPC. - setHeaderCanonical(request.Header, headerContentType, contentType) - setHeaderCanonical(request.Header, headerHost, request.Host) - ctx, cancel, timeoutErr := protocolHandler.SetTimeout(request) //nolint: contextcheck - if timeoutErr != nil { - ctx = request.Context() - } - if cancel != nil { - defer cancel() - } - connCloser, ok := protocolHandler.NewConn( - responseWriter, - request.WithContext(ctx), - ) - if !ok { - // Failed to create stream, usually because client used an unknown - // compression algorithm. Nothing further to do. - return - } - if timeoutErr != nil { - _ = connCloser.Close(timeoutErr) - return - } - _ = connCloser.Close(h.implementation(ctx, connCloser)) -} - -type handlerConfig struct { - CompressionPools map[string]*compressionPool - CompressionNames []string - Codecs map[string]Codec - CompressMinBytes int - Interceptor Interceptor - Procedure string - Schema any - Initializer maybeInitializer - RequireConnectProtocolHeader bool - IdempotencyLevel IdempotencyLevel - BufferPool *bufferPool - ReadMaxBytes int - SendMaxBytes int - StreamType StreamType -} - -func newHandlerConfig(procedure string, streamType StreamType, options []HandlerOption) *handlerConfig { - protoPath := extractProtoPath(procedure) - config := handlerConfig{ - Procedure: protoPath, - CompressionPools: make(map[string]*compressionPool), - Codecs: make(map[string]Codec), - BufferPool: newBufferPool(), - StreamType: streamType, - } - withProtoBinaryCodec().applyToHandler(&config) - withProtoJSONCodecs().applyToHandler(&config) - withGzip().applyToHandler(&config) - for _, opt := range options { - opt.applyToHandler(&config) - } - return &config -} - -func (c *handlerConfig) newSpec() Spec { - return Spec{ - Procedure: c.Procedure, - Schema: c.Schema, - StreamType: c.StreamType, - IdempotencyLevel: c.IdempotencyLevel, - } -} - -func (c *handlerConfig) newProtocolHandlers() []protocolHandler { - protocols := []protocol{ - &protocolConnect{}, - &protocolGRPC{web: false}, - &protocolGRPC{web: true}, - } - handlers := make([]protocolHandler, 0, len(protocols)) - codecs := newReadOnlyCodecs(c.Codecs) - compressors := newReadOnlyCompressionPools( - c.CompressionPools, - c.CompressionNames, - ) - for _, protocol := range protocols { - handlers = append(handlers, protocol.NewHandler(&protocolHandlerParams{ - Spec: c.newSpec(), - Codecs: codecs, - CompressionPools: compressors, - CompressMinBytes: c.CompressMinBytes, - BufferPool: c.BufferPool, - ReadMaxBytes: c.ReadMaxBytes, - SendMaxBytes: c.SendMaxBytes, - RequireConnectProtocolHeader: c.RequireConnectProtocolHeader, - IdempotencyLevel: c.IdempotencyLevel, - })) - } - return handlers -} - -func newStreamHandler( - config *handlerConfig, - implementation StreamingHandlerFunc, -) *Handler { - if ic := config.Interceptor; ic != nil { - implementation = ic.WrapStreamingHandler(implementation) - } - protocolHandlers := config.newProtocolHandlers() - return &Handler{ - spec: config.newSpec(), - implementation: implementation, - protocolHandlers: mappedMethodHandlers(protocolHandlers), - allowMethod: sortedAllowMethodValue(protocolHandlers), - acceptPost: sortedAcceptPostValue(protocolHandlers), - } -} diff --git a/handler_stream.go b/handler_stream.go deleted file mode 100644 index 9fe9e20b..00000000 --- a/handler_stream.go +++ /dev/null @@ -1,205 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "errors" - "io" - "net/http" -) - -// ClientStream is the handler's view of a client streaming RPC. -// -// It's constructed as part of [Handler] invocation, but doesn't currently have -// an exported constructor. -// -// Receive is not safe to call concurrently. -type ClientStream[Req any] struct { - conn StreamingHandlerConn - initializer maybeInitializer - msg *Req - err error -} - -// Spec returns the specification for the RPC. -func (c *ClientStream[_]) Spec() Spec { - return c.conn.Spec() -} - -// Peer describes the client for this RPC. -func (c *ClientStream[_]) Peer() Peer { - return c.conn.Peer() -} - -// RequestHeader returns the headers received from the client. -func (c *ClientStream[Req]) RequestHeader() http.Header { - return c.conn.RequestHeader() -} - -// Receive advances the stream to the next message, which will then be -// available through the Msg method. It returns false when the stream stops, -// either by reaching the end or by encountering an unexpected error. After -// Receive returns false, the Err method will return any unexpected error -// encountered. -func (c *ClientStream[Req]) Receive() bool { - if c.err != nil { - return false - } - c.msg = new(Req) - if err := c.initializer.maybe(c.Spec(), c.msg); err != nil { - c.err = err - return false - } - c.err = c.conn.Receive(c.msg) - return c.err == nil -} - -// Msg returns the most recent message unmarshaled by a call to Receive. -func (c *ClientStream[Req]) Msg() *Req { - if c.msg == nil { - c.msg = new(Req) - } - return c.msg -} - -// Err returns the first non-EOF error that was encountered by Receive. -func (c *ClientStream[Req]) Err() error { - if c.err == nil || errors.Is(c.err, io.EOF) { - return nil - } - return c.err -} - -// Conn exposes the underlying StreamingHandlerConn. This may be useful if -// you'd prefer to wrap the connection in a different high-level API. -func (c *ClientStream[Req]) Conn() StreamingHandlerConn { - return c.conn -} - -// ServerStream is the handler's view of a server streaming RPC. -// -// It's constructed as part of [Handler] invocation, but doesn't currently have -// an exported constructor. -// -// Send is not safe to call concurrently. -type ServerStream[Res any] struct { - conn StreamingHandlerConn -} - -// ResponseHeader returns the response headers. Headers are sent with the first -// call to Send. -// -// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the -// Connect and gRPC protocols. Applications shouldn't write them. -func (s *ServerStream[Res]) ResponseHeader() http.Header { - return s.conn.ResponseHeader() -} - -// ResponseTrailer returns the response trailers. Handlers may write to the -// response trailers at any time before returning. -// -// Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the -// Connect and gRPC protocols. Applications shouldn't write them. -func (s *ServerStream[Res]) ResponseTrailer() http.Header { - return s.conn.ResponseTrailer() -} - -// Send a message to the client. The first call to Send also sends the response -// headers. -func (s *ServerStream[Res]) Send(msg *Res) error { - if msg == nil { - return s.conn.Send(nil) - } - return s.conn.Send(msg) -} - -// Conn exposes the underlying StreamingHandlerConn. This may be useful if -// you'd prefer to wrap the connection in a different high-level API. -func (s *ServerStream[Res]) Conn() StreamingHandlerConn { - return s.conn -} - -// BidiStream is the handler's view of a bidirectional streaming RPC. -// -// It's constructed as part of [Handler] invocation, but doesn't currently have -// an exported constructor. -// -// Send and Receive may be called from separate goroutines concurrently, but -// neither may be called concurrently with itself. -type BidiStream[Req, Res any] struct { - conn StreamingHandlerConn - initializer maybeInitializer -} - -// Spec returns the specification for the RPC. -func (b *BidiStream[_, _]) Spec() Spec { - return b.conn.Spec() -} - -// Peer describes the client for this RPC. -func (b *BidiStream[_, _]) Peer() Peer { - return b.conn.Peer() -} - -// RequestHeader returns the headers received from the client. -func (b *BidiStream[Req, Res]) RequestHeader() http.Header { - return b.conn.RequestHeader() -} - -// Receive a message. When the client is done sending messages, Receive will -// return an error that wraps [io.EOF]. -func (b *BidiStream[Req, Res]) Receive() (*Req, error) { - var req Req - if err := b.initializer.maybe(b.Spec(), &req); err != nil { - return nil, err - } - if err := b.conn.Receive(&req); err != nil { - return nil, err - } - return &req, nil -} - -// ResponseHeader returns the response headers. Headers are sent with the first -// call to Send. -// -// Headers beginning with "Connect-" and "Grpc-" are reserved for use by the -// Connect and gRPC protocols. Applications shouldn't write them. -func (b *BidiStream[Req, Res]) ResponseHeader() http.Header { - return b.conn.ResponseHeader() -} - -// ResponseTrailer returns the response trailers. Handlers may write to the -// response trailers at any time before returning. -// -// Trailers beginning with "Connect-" and "Grpc-" are reserved for use by the -// Connect and gRPC protocols. Applications shouldn't write them. -func (b *BidiStream[Req, Res]) ResponseTrailer() http.Header { - return b.conn.ResponseTrailer() -} - -// Send a message to the client. The first call to Send also sends the response -// headers. -func (b *BidiStream[Req, Res]) Send(msg *Res) error { - if msg == nil { - return b.conn.Send(nil) - } - return b.conn.Send(msg) -} - -// Conn exposes the underlying StreamingHandlerConn. This may be useful if -// you'd prefer to wrap the connection in a different high-level API. -func (b *BidiStream[Req, Res]) Conn() StreamingHandlerConn { - return b.conn -} diff --git a/handler_stream_test.go b/handler_stream_test.go deleted file mode 100644 index 1249c2ac..00000000 --- a/handler_stream_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "fmt" - "testing" - - "connectrpc.com/connect/internal/assert" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" -) - -func TestClientStreamIterator(t *testing.T) { - t.Parallel() - // The server's view of a client streaming RPC is an iterator. For safety, - // and to match grpc-go's behavior, we should allocate a new message for each - // iteration. - stream := &ClientStream[pingv1.PingRequest]{ - conn: &nopStreamingHandlerConn{}, - } - assert.True(t, stream.Receive()) - first := fmt.Sprintf("%p", stream.Msg()) - assert.True(t, stream.Receive()) - second := fmt.Sprintf("%p", stream.Msg()) - assert.NotEqual(t, first, second, assert.Sprintf("should allocate a new message for each iteration")) -} - -type nopStreamingHandlerConn struct { - StreamingHandlerConn -} - -func (nopStreamingHandlerConn) Receive(msg any) error { - return nil -} - -func (nopStreamingHandlerConn) Spec() Spec { - return Spec{} -} diff --git a/idempotency_level.go b/idempotency_level.go deleted file mode 100644 index 316ef12a..00000000 --- a/idempotency_level.go +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import "fmt" - -// An IdempotencyLevel is a value that declares how "idempotent" an RPC is. This -// value can affect RPC behaviors, such as determining whether it is safe to -// retry a request, or what kinds of request modalities are allowed for a given -// procedure. -type IdempotencyLevel int - -// NOTE: For simplicity, these should be kept in sync with the values of the -// google.protobuf.MethodOptions.IdempotencyLevel enumeration. - -const ( - // IdempotencyUnknown is the default idempotency level. A procedure with - // this idempotency level may not be idempotent. This is appropriate for - // any kind of procedure. - IdempotencyUnknown IdempotencyLevel = 0 - - // IdempotencyNoSideEffects is the idempotency level that specifies that a - // given call has no side-effects. This is equivalent to [RFC 9110 § 9.2.1] - // "safe" methods in terms of semantics. This procedure should not mutate - // any state. This idempotency level is appropriate for queries, or anything - // that would be suitable for an HTTP GET request. In addition, due to the - // lack of side-effects, such a procedure would be suitable to retry and - // expect that the results will not be altered by preceding attempts. - // - // [RFC 9110 § 9.2.1]: https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.1 - IdempotencyNoSideEffects IdempotencyLevel = 1 - - // IdempotencyIdempotent is the idempotency level that specifies that a - // given call is "idempotent", such that multiple instances of the same - // request to this procedure would have the same side-effects as a single - // request. This is equivalent to [RFC 9110 § 9.2.2] "idempotent" methods. - // This level is a subset of the previous level. This idempotency level is - // appropriate for any procedure that is safe to retry multiple times - // and be guaranteed that the response and side-effects will not be altered - // as a result of multiple attempts, for example, entity deletion requests. - // - // [RFC 9110 § 9.2.2]: https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2 - IdempotencyIdempotent IdempotencyLevel = 2 -) - -func (i IdempotencyLevel) String() string { - switch i { - case IdempotencyUnknown: - return "idempotency_unknown" - case IdempotencyNoSideEffects: - return "no_side_effects" - case IdempotencyIdempotent: - return "idempotent" - } - return fmt.Sprintf("idempotency_%d", i) -} diff --git a/interceptor.go b/interceptor.go deleted file mode 100644 index 3822c82b..00000000 --- a/interceptor.go +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "context" - "errors" - "slices" -) - -var ( - // errNewClientContextProhibited signals that a new client context was created - // in an interceptor, which is prohibited. - errNewClientContextProhibited = errors.New("creating a new context in an interceptor is prohibited") -) - -// UnaryFunc is the generic signature of a unary RPC. Interceptors may wrap -// Funcs. -// -// The type of the request and response structs depend on the codec being used. -// When using Protobuf, request.Any() and response.Any() will always be -// [proto.Message] implementations. -// -// On return, response is non-nil if and only if err is nil. -type UnaryFunc func(context.Context, AnyRequest) (AnyResponse, error) - -// StreamingClientFunc is the generic signature of a streaming RPC from the client's -// perspective. Interceptors may wrap StreamingClientFuncs. -type StreamingClientFunc func(context.Context, Spec) StreamingClientConn - -// StreamingHandlerFunc is the generic signature of a streaming RPC from the -// handler's perspective. Interceptors may wrap StreamingHandlerFuncs. -type StreamingHandlerFunc func(context.Context, StreamingHandlerConn) error - -// An Interceptor adds logic to a generated handler or client, like the -// decorators or middleware you may have seen in other libraries. Interceptors -// may mutate requests and responses, handle errors, retry, recover from panics, -// emit logs and metrics, or do nearly anything else. -// -// The returned functions must be safe to call concurrently. -type Interceptor interface { - WrapUnary(UnaryFunc) UnaryFunc - WrapStreamingClient(StreamingClientFunc) StreamingClientFunc - WrapStreamingHandler(StreamingHandlerFunc) StreamingHandlerFunc -} - -// UnaryInterceptorFunc is a simple Interceptor implementation that only -// wraps unary RPCs. It has no effect on streaming RPCs. -type UnaryInterceptorFunc func(UnaryFunc) UnaryFunc - -// WrapUnary implements [Interceptor] by applying the interceptor function. -func (f UnaryInterceptorFunc) WrapUnary(next UnaryFunc) UnaryFunc { return f(next) } - -// WrapStreamingClient implements [Interceptor] with a no-op. -func (f UnaryInterceptorFunc) WrapStreamingClient(next StreamingClientFunc) StreamingClientFunc { - return next -} - -// WrapStreamingHandler implements [Interceptor] with a no-op. -func (f UnaryInterceptorFunc) WrapStreamingHandler(next StreamingHandlerFunc) StreamingHandlerFunc { - return next -} - -// A chain composes multiple interceptors into one. -type chain struct { - interceptors []Interceptor -} - -// newChain composes multiple interceptors into one. -func newChain(interceptors []Interceptor) *chain { - // We usually wrap in reverse order to have the first interceptor from - // the slice act first. Rather than doing this dance repeatedly, reverse the - // interceptor order now. - var chain chain - for _, interceptor := range slices.Backward(interceptors) { - if interceptor != nil { - chain.interceptors = append(chain.interceptors, interceptor) - } - } - return &chain -} - -func (c *chain) WrapUnary(next UnaryFunc) UnaryFunc { - for _, interceptor := range c.interceptors { - next = unaryThunk(next) - next = interceptor.WrapUnary(next) - } - return next -} - -func (c *chain) WrapStreamingClient(next StreamingClientFunc) StreamingClientFunc { - for _, interceptor := range c.interceptors { - next = streamingClientThunk(next) - next = interceptor.WrapStreamingClient(next) - } - return next -} - -func (c *chain) WrapStreamingHandler(next StreamingHandlerFunc) StreamingHandlerFunc { - for _, interceptor := range c.interceptors { - next = interceptor.WrapStreamingHandler(next) - } - return next -} - -func unaryThunk(next UnaryFunc) UnaryFunc { - return func(ctx context.Context, req AnyRequest) (AnyResponse, error) { - if err := checkSentinel(ctx); err != nil { - return nil, err - } - return next(ctx, req) - } -} - -func streamingClientThunk(next StreamingClientFunc) StreamingClientFunc { - return func(ctx context.Context, spec Spec) StreamingClientConn { - if err := checkSentinel(ctx); err != nil { - return &errStreamingClientConn{err: err} - } - return next(ctx, spec) - } -} - -func checkSentinel(ctx context.Context) error { - if ctx.Value(clientCallInfoContextKey{}) != ctx.Value(sentinelContextKey{}) { - return errNewClientContextProhibited - } - return nil -} diff --git a/interceptor_example_test.go b/interceptor_example_test.go deleted file mode 100644 index db814416..00000000 --- a/interceptor_example_test.go +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect_test - -import ( - "context" - "log" - "os" - - connect "connectrpc.com/connect" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" -) - -func ExampleUnaryInterceptorFunc() { - logger := log.New(os.Stdout, "" /* prefix */, 0 /* flags */) - loggingInterceptor := connect.UnaryInterceptorFunc( - func(next connect.UnaryFunc) connect.UnaryFunc { - return connect.UnaryFunc(func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) { - logger.Println("calling:", request.Spec().Procedure) - logger.Println("request:", request.Any()) - response, err := next(ctx, request) - if err != nil { - logger.Println("error:", err) - } else { - logger.Println("response:", response.Any()) - } - return response, err - }) - }, - ) - client := pingv1connect.NewPingServiceClient( - examplePingServer.Client(), - examplePingServer.URL(), - connect.WithInterceptors(loggingInterceptor), - ) - if _, err := client.Ping(context.Background(), &pingv1.PingRequest{Number: 42}); err != nil { - logger.Println("error:", err) - return - } - - // Output: - // calling: /connect.ping.v1.PingService/Ping - // request: number:42 - // response: number:42 -} - -func ExampleWithInterceptors() { - logger := log.New(os.Stdout, "" /* prefix */, 0 /* flags */) - outer := connect.UnaryInterceptorFunc( - func(next connect.UnaryFunc) connect.UnaryFunc { - return connect.UnaryFunc(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - logger.Println("outer interceptor: before call") - res, err := next(ctx, req) - logger.Println("outer interceptor: after call") - return res, err - }) - }, - ) - inner := connect.UnaryInterceptorFunc( - func(next connect.UnaryFunc) connect.UnaryFunc { - return connect.UnaryFunc(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - logger.Println("inner interceptor: before call") - res, err := next(ctx, req) - logger.Println("inner interceptor: after call") - return res, err - }) - }, - ) - client := pingv1connect.NewPingServiceClient( - examplePingServer.Client(), - examplePingServer.URL(), - connect.WithInterceptors(outer, inner), - ) - if _, err := client.Ping(context.Background(), &pingv1.PingRequest{}); err != nil { - logger.Println("error:", err) - return - } - - // Output: - // outer interceptor: before call - // inner interceptor: before call - // inner interceptor: after call - // outer interceptor: after call -} - -func ExampleWithConditionalHandlerOptions() { - connect.WithConditionalHandlerOptions(func(spec connect.Spec) []connect.HandlerOption { - var options []connect.HandlerOption - if spec.Procedure == pingv1connect.PingServicePingProcedure { - options = append(options, connect.WithReadMaxBytes(1024)) - } - return options - }) - // Output: -} diff --git a/interceptor_ext_test.go b/interceptor_ext_test.go deleted file mode 100644 index 6b0c14f8..00000000 --- a/interceptor_ext_test.go +++ /dev/null @@ -1,996 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect_test - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "sync/atomic" - "testing" - - connect "connectrpc.com/connect" - "connectrpc.com/connect/internal/assert" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/generics/connect/ping/v1/pingv1connect" - pingv1connectsimple "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" - "connectrpc.com/connect/internal/memhttp" - "connectrpc.com/connect/internal/memhttp/memhttptest" -) - -const expectedContextErrorMessage = "creating a new context in an interceptor is prohibited" - -func TestNewClientContextInInterceptor(t *testing.T) { - t.Parallel() - t.Run("simple_api", func(t *testing.T) { - t.Parallel() - mux := http.NewServeMux() - mux.Handle( - pingv1connectsimple.NewPingServiceHandler( - pingServerSimple{}, - ), - ) - server := memhttptest.NewServer(t, mux) - t.Run("first_interceptor", func(t *testing.T) { - t.Parallel() - // Because we're creating a new context in the first interceptor, only the first interceptor should fire - createClient := func(counter1 *atomic.Int32, counter2 *atomic.Int32) pingv1connectsimple.PingServiceClient { - opts := connect.WithInterceptors( - &contextInterceptor{client: true, count: counter1, createNewContext: true}, - &contextInterceptor{client: true, count: counter2}, - ) - return pingv1connectsimple.NewPingServiceClient( - server.Client(), - server.URL(), - opts, - ) - } - t.Run("unary", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - resp, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 10}) - - assert.Nil(t, resp) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(0), clientCounter2.Load()) - }) - t.Run("server_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 10}) - - assert.Nil(t, stream) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(0), clientCounter2.Load()) - }) - t.Run("client_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - // With client-streaming and the simple API, the initial call fails. This differs from - // the generics API which requires a call to stream.Send first to receive an error. - stream, err := client.Sum(t.Context()) - assert.NotNil(t, err) - assert.Nil(t, stream) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(0), clientCounter2.Load()) - }) - t.Run("bidi_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - // With bidi-streaming and the simple API, the initial call fails. This differs from - // the generics API which requires a call to stream.Send first to receive an error. - stream, err := client.CumSum(t.Context()) - assert.NotNil(t, err) - assert.Nil(t, stream) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(0), clientCounter2.Load()) - }) - }) - t.Run("subsequent_interceptor", func(t *testing.T) { - t.Parallel() - // Because we're creating a new context in the last interceptor, all interceptors should fire - createClient := func(counter1 *atomic.Int32, counter2 *atomic.Int32) pingv1connectsimple.PingServiceClient { - opts := connect.WithInterceptors( - &contextInterceptor{client: true, count: counter1}, - &contextInterceptor{client: true, count: counter2, createNewContext: true}, - ) - return pingv1connectsimple.NewPingServiceClient( - server.Client(), - server.URL(), - opts, - ) - } - t.Run("unary", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - resp, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 10}) - assert.Nil(t, resp) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - t.Run("server_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 10}) - assert.Nil(t, stream) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - t.Run("client_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - // With client-streaming and the simple API, the initial call fails. This differs from - // the generics API which requires a call to stream.Send first to receive an error. - stream, err := client.Sum(t.Context()) - assert.NotNil(t, err) - assert.Nil(t, stream) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - t.Run("bidi_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - // With bidi-streaming and the simple API, the initial call fails. This differs from - // the generics API which requires a call to stream.Send first to receive an error. - stream, err := client.CumSum(t.Context()) - assert.NotNil(t, err) - assert.Nil(t, stream) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - }) - t.Run("sidequest_succeeds", func(t *testing.T) { - t.Parallel() - // These tests create a new context but it is used to issue a separate/new request and not reused in the - // interceptor chain. So, all interceptors should fire and no errors should be returned. - createClient := func(counter1 *atomic.Int32, counter2 *atomic.Int32) pingv1connectsimple.PingServiceClient { - opts := connect.WithInterceptors( - newSideQuestInterceptor(t, counter1, server), - newSideQuestInterceptor(t, counter2, server), - ) - return pingv1connectsimple.NewPingServiceClient( - server.Client(), - server.URL(), - opts, - ) - } - t.Run("unary", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - resp, err := client.Ping(t.Context(), &pingv1.PingRequest{Number: 10}) - assert.NotNil(t, resp) - assert.Nil(t, err) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - t.Run("server_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - stream, err := client.CountUp(t.Context(), &pingv1.CountUpRequest{Number: 10}) - assert.NotNil(t, stream) - assert.Nil(t, err) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - assert.Nil(t, stream.Close()) - }) - t.Run("client_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - stream, err := client.Sum(t.Context()) - assert.NotNil(t, stream) - assert.Nil(t, err) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - resp, err := stream.CloseAndReceive() - assert.Nil(t, err) - assert.NotNil(t, resp) - }) - t.Run("bidi_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - stream, err := client.CumSum(t.Context()) - assert.Nil(t, err) - assert.NotNil(t, stream) - - assert.Nil(t, stream.CloseRequest()) - assert.Nil(t, stream.CloseResponse()) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - }) - }) - t.Run("generics_api", func(t *testing.T) { - t.Parallel() - mux := http.NewServeMux() - mux.Handle( - pingv1connectsimple.NewPingServiceHandler( - pingServerSimple{}, - ), - ) - server := memhttptest.NewServer(t, mux) - t.Run("first_interceptor", func(t *testing.T) { - t.Parallel() - // Because we're creating a new context in the first interceptor, only the first interceptor should fire - createClient := func(counter1 *atomic.Int32, counter2 *atomic.Int32) pingv1connect.PingServiceClient { - opts := connect.WithInterceptors( - &contextInterceptor{client: true, count: counter1, createNewContext: true}, - &contextInterceptor{client: true, count: counter2}, - ) - return pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - opts, - ) - } - t.Run("unary", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - resp, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Number: 10})) - assert.Nil(t, resp) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(0), clientCounter2.Load()) - }) - t.Run("server_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - stream, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{Number: 10})) - assert.Nil(t, stream) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(0), clientCounter2.Load()) - }) - t.Run("client_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - stream := client.Sum(t.Context()) - assert.NotNil(t, stream) - - // With client-streaming and the generics API, a call to stream.Send is required to receive an error. - err := stream.Send(&pingv1.SumRequest{Number: int64(1)}) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - - // We should receive the same error when we try to close the stream - resp, err := stream.CloseAndReceive() - assert.Nil(t, resp) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(0), clientCounter2.Load()) - }) - //nolint:dupl // the test logic for bidi w/r/t generic and simple api looks the same, but it's subtly different - t.Run("bidi_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - stream := client.CumSum(t.Context()) - assert.NotNil(t, stream) - - // With bidi-streaming and the generics API, a call to stream.Send is required to receive an error. - err := stream.Send(&pingv1.CumSumRequest{Number: 1}) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - - // We should receive the same error when we try to close the send and receive parts of the stream - err = stream.CloseRequest() - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - - err = stream.CloseResponse() - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(0), clientCounter2.Load()) - }) - }) - - t.Run("subsequent_interceptor", func(t *testing.T) { - t.Parallel() - // Because we're creating a new context in the last interceptor, all interceptors should fire - createClient := func(counter1 *atomic.Int32, counter2 *atomic.Int32) pingv1connect.PingServiceClient { - opts := connect.WithInterceptors( - &contextInterceptor{client: true, count: counter1}, - &contextInterceptor{client: true, count: counter2, createNewContext: true}, - ) - return pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - opts, - ) - } - t.Run("unary", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - resp, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Number: 10})) - assert.Nil(t, resp) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - t.Run("server_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - stream, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{Number: 10})) - assert.Nil(t, stream) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - t.Run("client_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - stream := client.Sum(t.Context()) - assert.NotNil(t, stream) - - // With client-streaming and the generics API, a call to stream.Send is required to receive an error. - err := stream.Send(&pingv1.SumRequest{Number: int64(1)}) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - - // We should receive the same error when we try to close the stream - resp, err := stream.CloseAndReceive() - assert.Nil(t, resp) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - //nolint:dupl // the test logic for bidi w/r/t generic and simple api looks the same, but it's subtly different - t.Run("bidi_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - stream := client.CumSum(t.Context()) - assert.NotNil(t, stream) - - // With bidi-streaming and the generics API, a call to stream.Send is required to receive an error. - err := stream.Send(&pingv1.CumSumRequest{Number: 1}) - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - - // We should receive the same error when we try to close the send and receive parts of the stream - err = stream.CloseRequest() - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - - err = stream.CloseResponse() - assert.NotNil(t, err) - assert.Equal(t, err.Error(), expectedContextErrorMessage) - - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - }) - t.Run("sidequest_succeeds", func(t *testing.T) { - t.Parallel() - // These tests create a new context but it is used to issue a separate/new request and not reused in the - // interceptor chain. So, all interceptors should fire and no errors should be returned. - createClient := func(counter1 *atomic.Int32, counter2 *atomic.Int32) pingv1connect.PingServiceClient { - opts := connect.WithInterceptors( - newSideQuestInterceptor(t, counter1, server), - newSideQuestInterceptor(t, counter2, server), - ) - return pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - opts, - ) - } - t.Run("unary", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - resp, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Number: 10})) - assert.NotNil(t, resp) - assert.Nil(t, err) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - t.Run("server_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - - stream, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{Number: 10})) - assert.NotNil(t, stream) - assert.Nil(t, err) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - assert.Nil(t, stream.Close()) - }) - t.Run("client_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - stream := client.Sum(t.Context()) - assert.NotNil(t, stream) - - err := stream.Send(&pingv1.SumRequest{Number: int64(1)}) - assert.Nil(t, err) - resp, err := stream.CloseAndReceive() - assert.NotNil(t, resp) - assert.Nil(t, err) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - t.Run("bidi_stream", func(t *testing.T) { - t.Parallel() - var clientCounter1, clientCounter2 atomic.Int32 - client := createClient(&clientCounter1, &clientCounter2) - stream := client.CumSum(t.Context()) - assert.NotNil(t, stream) - - // The initial send should succeed - err := stream.Send(&pingv1.CumSumRequest{Number: 1}) - assert.Nil(t, err) - - // We should be able to successfully close the send part of the stream - assert.Nil(t, stream.CloseRequest()) - - // All receives should succeed - for { - msg, err := stream.Receive() - if errors.Is(err, io.EOF) { - break - } - assert.NotNil(t, msg) - assert.Nil(t, err) - } - // We should be able to successfully close the receive part of the stream - assert.Nil(t, stream.CloseResponse()) - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - }) - }) - }) -} - -func TestOnionOrderingEndToEnd(t *testing.T) { - t.Parallel() - // Helper function: returns a function that asserts that there's some value - // set for header "expect", and adds a value for header "add". - newInspector := func(expect, add string) func(connect.Spec, http.Header) { - return func(spec connect.Spec, header http.Header) { - if expect != "" { - assert.NotZero( - t, - header.Get(expect), - assert.Sprintf( - "%s (IsClient %v): header %q missing: %v", - spec.Procedure, - spec.IsClient, - expect, - header, - ), - ) - } - header.Set(add, "v") - } - } - // Helper function: asserts that there's a value present for header keys - // "one", "two", "three", and "four". - assertAllPresent := func(spec connect.Spec, header http.Header) { - for _, key := range []string{"one", "two", "three", "four"} { - assert.NotZero( - t, - header.Get(key), - assert.Sprintf( - "%s (IsClient %v): checking all headers, %q missing: %v", - spec.Procedure, - spec.IsClient, - key, - header, - ), - ) - } - } - - var clientCounter1, clientCounter2, clientCounter3, handlerCounter1, handlerCounter2, handlerCounter3 atomic.Int32 - - // The client and handler interceptor onions are the meat of the test. The - // order of interceptor execution must be the same for unary and streaming - // procedures. - // - // Requests should fall through the client onion from top to bottom, traverse - // the network, and then fall through the handler onion from top to bottom. - // Responses should climb up the handler onion, traverse the network, and - // then climb up the client onion. - // - // The request and response sides of this onion are numbered to make the - // intended order clear. - clientOnion := connect.WithInterceptors( - newHeaderInterceptor( - &clientCounter1, - // 1 (start). request: should see protocol-related headers - func(_ connect.Spec, h http.Header) { - assert.NotZero(t, h.Get("Content-Type")) - }, - // 12 (end). response: check "one"-"four" - assertAllPresent, - ), - newHeaderInterceptor( - &clientCounter2, - newInspector("", "one"), // 2. request: add header "one" - newInspector("three", "four"), // 11. response: check "three", add "four" - ), - newHeaderInterceptor( - &clientCounter3, - newInspector("one", "two"), // 3. request: check "one", add "two" - newInspector("two", "three"), // 10. response: check "two", add "three" - ), - ) - handlerOnion := connect.WithInterceptors( - newHeaderInterceptor( - &handlerCounter1, - newInspector("two", "three"), // 4. request: check "two", add "three" - newInspector("one", "two"), // 9. response: check "one", add "two" - ), - newHeaderInterceptor( - &handlerCounter2, - newInspector("three", "four"), // 5. request: check "three", add "four" - newInspector("", "one"), // 8. response: add "one" - ), - newHeaderInterceptor( - &handlerCounter3, - assertAllPresent, // 6. request: check "one"-"four" - nil, // 7. response: no-op - ), - ) - - mux := http.NewServeMux() - mux.Handle( - pingv1connect.NewPingServiceHandler( - pingServer{}, - handlerOnion, - ), - ) - server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - clientOnion, - ) - - _, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Number: 10})) - assert.Nil(t, err) - - // make sure the interceptors were actually invoked - assert.Equal(t, int32(1), clientCounter1.Load()) - assert.Equal(t, int32(1), clientCounter2.Load()) - assert.Equal(t, int32(1), clientCounter3.Load()) - assert.Equal(t, int32(1), handlerCounter1.Load()) - assert.Equal(t, int32(1), handlerCounter2.Load()) - assert.Equal(t, int32(1), handlerCounter3.Load()) - - responses, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{Number: 10})) - assert.Nil(t, err) - var sum int64 - for responses.Receive() { - sum += responses.Msg().GetNumber() - } - assert.Equal(t, sum, 55) - assert.Nil(t, responses.Close()) - - // make sure the interceptors were invoked again - assert.Equal(t, int32(2), clientCounter1.Load()) - assert.Equal(t, int32(2), clientCounter2.Load()) - assert.Equal(t, int32(2), clientCounter3.Load()) - assert.Equal(t, int32(2), handlerCounter1.Load()) - assert.Equal(t, int32(2), handlerCounter2.Load()) - assert.Equal(t, int32(2), handlerCounter3.Load()) -} - -func TestEmptyUnaryInterceptorFunc(t *testing.T) { - t.Parallel() - mux := http.NewServeMux() - interceptor := connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { - return func(ctx context.Context, request connect.AnyRequest) (connect.AnyResponse, error) { - return next(ctx, request) - } - }) - mux.Handle(pingv1connect.NewPingServiceHandler(pingServer{}, connect.WithInterceptors(interceptor))) - server := memhttptest.NewServer(t, mux) - connectClient := pingv1connect.NewPingServiceClient(server.Client(), server.URL(), connect.WithInterceptors(interceptor)) - _, err := connectClient.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{})) - assert.Nil(t, err) - sumStream := connectClient.Sum(t.Context()) - assert.Nil(t, sumStream.Send(&pingv1.SumRequest{Number: 1})) - resp, err := sumStream.CloseAndReceive() - assert.Nil(t, err) - assert.NotNil(t, resp) - countUpStream, err := connectClient.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{})) - assert.Nil(t, err) - for countUpStream.Receive() { - assert.NotNil(t, countUpStream.Msg()) - } - assert.Nil(t, countUpStream.Close()) -} - -func TestInterceptorFuncAccessingHTTPMethod(t *testing.T) { - t.Parallel() - clientChecker := &httpMethodChecker{client: true} - handlerChecker := &httpMethodChecker{} - - mux := http.NewServeMux() - mux.Handle( - pingv1connect.NewPingServiceHandler( - pingServer{}, - connect.WithInterceptors(handlerChecker), - ), - ) - server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - connect.WithInterceptors(clientChecker), - ) - - pingReq := connect.NewRequest(&pingv1.PingRequest{Number: 10}) - assert.Equal(t, "", pingReq.HTTPMethod()) - _, err := client.Ping(t.Context(), pingReq) - assert.Nil(t, err) - assert.Equal(t, http.MethodPost, pingReq.HTTPMethod()) - - // make sure interceptor was invoked - assert.Equal(t, int32(1), clientChecker.count.Load()) - assert.Equal(t, int32(1), handlerChecker.count.Load()) - - countUpReq := connect.NewRequest(&pingv1.CountUpRequest{Number: 10}) - assert.Equal(t, "", countUpReq.HTTPMethod()) - responses, err := client.CountUp(t.Context(), countUpReq) - assert.Nil(t, err) - var sum int64 - for responses.Receive() { - sum += responses.Msg().GetNumber() - } - assert.Equal(t, sum, 55) - assert.Nil(t, responses.Close()) - assert.Equal(t, http.MethodPost, countUpReq.HTTPMethod()) - - // make sure interceptor was invoked again - assert.Equal(t, int32(2), clientChecker.count.Load()) - assert.Equal(t, int32(2), handlerChecker.count.Load()) -} - -func TestHandlerErrorResponseNilInInterceptor(t *testing.T) { - t.Parallel() - handlerErr := connect.NewError(connect.CodeInternal, errors.New("handler error")) - var interceptorSawNilResponse bool - checkNilInterceptor := connect.UnaryInterceptorFunc( - func(next connect.UnaryFunc) connect.UnaryFunc { - return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - res, err := next(ctx, req) - // res must be nil when err is non-nil; a typed nil stored in an - // interface would make this check incorrectly report non-nil. - interceptorSawNilResponse = res == nil - return res, err - } - }, - ) - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler( - &pluggablePingServer{ - ping: func(_ context.Context, _ *connect.Request[pingv1.PingRequest]) (*connect.Response[pingv1.PingResponse], error) { - return nil, handlerErr - }, - }, - connect.WithInterceptors(checkNilInterceptor), - )) - server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient(server.Client(), server.URL()) - _, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{})) - assert.NotNil(t, err) - assert.True(t, interceptorSawNilResponse) -} - -// headerInterceptor makes it easier to write interceptors that inspect or -// mutate HTTP headers. It applies the same logic to unary and streaming -// procedures, wrapping the send or receive side of the stream as appropriate. -// -// It's useful as a testing harness to make sure that we're chaining -// interceptors in the correct order. -type headerInterceptor struct { - counter *atomic.Int32 - inspectRequestHeader func(connect.Spec, http.Header) - inspectResponseHeader func(connect.Spec, http.Header) -} - -// newHeaderInterceptor constructs a headerInterceptor. Nil function pointers -// are treated as no-ops. -func newHeaderInterceptor( - counter *atomic.Int32, - inspectRequestHeader func(connect.Spec, http.Header), - inspectResponseHeader func(connect.Spec, http.Header), -) *headerInterceptor { - interceptor := headerInterceptor{ - counter: counter, - inspectRequestHeader: inspectRequestHeader, - inspectResponseHeader: inspectResponseHeader, - } - if interceptor.inspectRequestHeader == nil { - interceptor.inspectRequestHeader = func(_ connect.Spec, _ http.Header) {} - } - if interceptor.inspectResponseHeader == nil { - interceptor.inspectResponseHeader = func(_ connect.Spec, _ http.Header) {} - } - return &interceptor -} - -func (h *headerInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { - return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - h.counter.Add(1) - h.inspectRequestHeader(req.Spec(), req.Header()) - res, err := next(ctx, req) - if err != nil { - return nil, err - } - h.inspectResponseHeader(req.Spec(), res.Header()) - return res, nil - } -} - -func (h *headerInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { - return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { - h.counter.Add(1) - return &headerInspectingClientConn{ - StreamingClientConn: next(ctx, spec), - inspectRequestHeader: h.inspectRequestHeader, - inspectResponseHeader: h.inspectResponseHeader, - } - } -} - -func (h *headerInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { - return func(ctx context.Context, conn connect.StreamingHandlerConn) error { - h.counter.Add(1) - h.inspectRequestHeader(conn.Spec(), conn.RequestHeader()) - return next(ctx, &headerInspectingHandlerConn{ - StreamingHandlerConn: conn, - inspectResponseHeader: h.inspectResponseHeader, - }) - } -} - -type headerInspectingHandlerConn struct { - connect.StreamingHandlerConn - - inspectedResponse bool - inspectResponseHeader func(connect.Spec, http.Header) -} - -func (hc *headerInspectingHandlerConn) Send(msg any) error { - if !hc.inspectedResponse { - hc.inspectResponseHeader(hc.Spec(), hc.ResponseHeader()) - hc.inspectedResponse = true - } - return hc.StreamingHandlerConn.Send(msg) -} - -type headerInspectingClientConn struct { - connect.StreamingClientConn - - inspectedRequest bool - inspectRequestHeader func(connect.Spec, http.Header) - inspectedResponse bool - inspectResponseHeader func(connect.Spec, http.Header) -} - -func (cc *headerInspectingClientConn) Send(msg any) error { - if !cc.inspectedRequest { - cc.inspectRequestHeader(cc.Spec(), cc.RequestHeader()) - cc.inspectedRequest = true - } - return cc.StreamingClientConn.Send(msg) -} - -func (cc *headerInspectingClientConn) Receive(msg any) error { - err := cc.StreamingClientConn.Receive(msg) - if !cc.inspectedResponse { - cc.inspectResponseHeader(cc.Spec(), cc.ResponseHeader()) - cc.inspectedResponse = true - } - return err -} - -type httpMethodChecker struct { - client bool - count atomic.Int32 -} - -func (h *httpMethodChecker) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { - return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - h.count.Add(1) - if h.client { - // should be blank until after we make request - if req.HTTPMethod() != "" { - return nil, fmt.Errorf("expected blank HTTP method but instead got %q", req.HTTPMethod()) - } - } else { - // server interceptors see method from the start - // NB: In theory, the method could also be GET, not just POST. But for the - // configuration under test, it will always be POST. - if req.HTTPMethod() != http.MethodPost { - return nil, fmt.Errorf("expected HTTP method %s but instead got %q", http.MethodPost, req.HTTPMethod()) - } - } - resp, err := next(ctx, req) - // NB: In theory, the method could also be GET, not just POST. But for the - // configuration under test, it will always be POST. - if req.HTTPMethod() != http.MethodPost { - return nil, fmt.Errorf("expected HTTP method %s but instead got %q", http.MethodPost, req.HTTPMethod()) - } - return resp, err - } -} - -func (h *httpMethodChecker) WrapStreamingClient(clientFunc connect.StreamingClientFunc) connect.StreamingClientFunc { - return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { - // method not exposed to streaming interceptor, but that's okay because it's always POST for streams - h.count.Add(1) - return clientFunc(ctx, spec) - } -} - -func (h *httpMethodChecker) WrapStreamingHandler(handlerFunc connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { - return func(ctx context.Context, conn connect.StreamingHandlerConn) error { - // method not exposed to streaming interceptor, but that's okay because it's always POST for streams - h.count.Add(1) - return handlerFunc(ctx, conn) - } -} - -type contextInterceptor struct { - client bool - count *atomic.Int32 - // Whether the interceptor should attempt to create a new context (which will cause next() to return an error) - createNewContext bool -} - -func (h *contextInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { - return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - h.count.Add(1) - if h.createNewContext { - // This will cause next to return an error - ctx, _ = connect.NewClientContext(ctx) - } - return next(ctx, req) - } -} - -func (h *contextInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { - return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { - h.count.Add(1) - if h.createNewContext { - // This will cause next to return an error - ctx, _ = connect.NewClientContext(ctx) - } - return next(ctx, spec) - } -} - -func (h *contextInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { - return func(ctx context.Context, conn connect.StreamingHandlerConn) error { - h.count.Add(1) - return next(ctx, conn) - } -} - -type sideQuestInterceptor struct { - count *atomic.Int32 - client pingv1connect.PingServiceClient - t *testing.T -} - -func newSideQuestInterceptor( //nolint:thelper - t *testing.T, - counter *atomic.Int32, - server *memhttp.Server, -) *sideQuestInterceptor { - client := pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - ) - return &sideQuestInterceptor{t: t, client: client, count: counter} -} - -func (h *sideQuestInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { - return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - h.count.Add(1) - num := int64(42) - // Create a new client context for the side quest Ping. This should succeed because we aren't - // sending this on through the interceptor chain and reusing this context - newCtx, _ := connect.NewClientContext(ctx) - resp, err := h.client.Ping(newCtx, connect.NewRequest(&pingv1.PingRequest{Number: num})) - assert.Nil(h.t, err) - assert.Equal(h.t, resp.Msg.Number, num) - - return next(ctx, req) - } -} - -func (h *sideQuestInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { - return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { - h.count.Add(1) - // Create a new context for the side quest CountUp. This should succeed because we aren't - // sending this on through the interceptor chain and reusing this context - newCtx, _ := connect.NewClientContext(ctx) - responses, err := h.client.CountUp(newCtx, connect.NewRequest(&pingv1.CountUpRequest{Number: 3})) - assert.Nil(h.t, err) - var sum int64 - for responses.Receive() { - sum += responses.Msg().GetNumber() - } - assert.Equal(h.t, sum, 6) - assert.Nil(h.t, responses.Close()) - return next(ctx, spec) - } -} - -func (h *sideQuestInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { - return func(ctx context.Context, conn connect.StreamingHandlerConn) error { - return next(ctx, conn) - } -} diff --git a/internal/bufferpool/bufferpool.go b/internal/bufferpool/bufferpool.go new file mode 100644 index 00000000..0f6a66d0 --- /dev/null +++ b/internal/bufferpool/bufferpool.go @@ -0,0 +1,51 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package bufferpool provides a pool of reusable byte buffers shared by +// the codecs and transport. +package bufferpool + +import ( + "bytes" + "sync" +) + +const ( + initialBufferSize = 1024 + maxRecycleBufferSize = 8 * 1024 * 1024 // if >8MiB, don't recycle +) + +//nolint:gochecknoglobals // package-level pool is the standard sync.Pool idiom +var pool = sync.Pool{ + New: func() any { return bytes.NewBuffer(make([]byte, 0, initialBufferSize)) }, +} + +// Get returns a reset buffer ready for use. +func Get() *bytes.Buffer { + buf, ok := pool.Get().(*bytes.Buffer) + if !ok { + buf = bytes.NewBuffer(make([]byte, 0, initialBufferSize)) + } + buf.Reset() + return buf +} + +// Put returns a buffer to the pool, dropping it if it grew past the +// recycle ceiling. +func Put(buf *bytes.Buffer) { + if buf.Cap() > maxRecycleBufferSize { + return + } + pool.Put(buf) +} diff --git a/cmd/protoc-gen-connect-go/internal/testdata/simple/buf.gen.yaml b/internal/conformance/buf.gen.yaml similarity index 53% rename from cmd/protoc-gen-connect-go/internal/testdata/simple/buf.gen.yaml rename to internal/conformance/buf.gen.yaml index 80cd8d12..87642f2b 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/simple/buf.gen.yaml +++ b/internal/conformance/buf.gen.yaml @@ -3,14 +3,12 @@ managed: enabled: true override: - file_option: go_package_prefix - value: connectrpc.com/connect/cmd/protoc-gen-connect-go/internal/testdata/simple/gen + value: connectrpc.com/connect/v2/internal/conformance/internal/gen plugins: - local: protoc-gen-go - out: gen + out: internal/gen opt: paths=source_relative - local: protoc-gen-connect-go - out: gen - opt: - - paths=source_relative - - simple + out: internal/gen + opt: paths=source_relative clean: true diff --git a/cmd/protoc-gen-connect-go/internal/testdata/simple/simple.proto b/internal/conformance/cmd/referenceclient/main.go similarity index 64% rename from cmd/protoc-gen-connect-go/internal/testdata/simple/simple.proto rename to internal/conformance/cmd/referenceclient/main.go index 253dba87..0ef7fde1 100644 --- a/cmd/protoc-gen-connect-go/internal/testdata/simple/simple.proto +++ b/internal/conformance/cmd/referenceclient/main.go @@ -12,17 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -syntax = "proto3"; +package main -package connect.test.simple; +import ( + "context" + "log" + "os" -message Request {} + "connectrpc.com/connect/v2/internal/conformance/internal/app/referenceclient" +) -message Response {} - -service TestService { - rpc Method(Request) returns (Response) {} - rpc MethodClientStream(stream Request) returns (Response) {} - rpc MethodServerStream(Request) returns (stream Response) {} - rpc MethodBidiStream(Request) returns (stream Response) {} +func main() { + err := referenceclient.Run(context.Background(), os.Args, os.Stdin, os.Stdout, os.Stderr) + if err != nil { + log.Fatalf("an error occurred running the reference client: %s", err.Error()) + } } diff --git a/internal/proto/connectext/grpc/status/v1/status.proto b/internal/conformance/cmd/referenceserver/main.go similarity index 51% rename from internal/proto/connectext/grpc/status/v1/status.proto rename to internal/conformance/cmd/referenceserver/main.go index 68adc66b..d16c363a 100644 --- a/internal/proto/connectext/grpc/status/v1/status.proto +++ b/internal/conformance/cmd/referenceserver/main.go @@ -12,20 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -syntax = "proto3"; +package main -// This package is for internal use by Connect, and provides no backward -// compatibility guarantees whatsoever. -package grpc.status.v1; +import ( + "context" + "log" + "os" -import "google/protobuf/any.proto"; + "connectrpc.com/connect/v2/internal/conformance/internal/app/referenceserver" +) -// See https://cloud.google.com/apis/design/errors. -// -// This struct must remain binary-compatible with -// https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto. -message Status { - int32 code = 1; // a google.rpc.Code - string message = 2; // developer-facing, English (localize in details or client-side) - repeated google.protobuf.Any details = 3; +func main() { + err := referenceserver.Run(context.Background(), os.Args, os.Stdin, os.Stdout, os.Stderr) + if err != nil { + log.Fatalf("an error occurred running the reference server: %s", err.Error()) + } } diff --git a/internal/conformance/config.yaml b/internal/conformance/config.yaml index e399e3be..de209cdc 100644 --- a/internal/conformance/config.yaml +++ b/internal/conformance/config.yaml @@ -10,12 +10,7 @@ features: codecs: - CODEC_PROTO - CODEC_JSON - - CODEC_TEXT compressions: - COMPRESSION_IDENTITY - COMPRESSION_GZIP - - COMPRESSION_BR - - COMPRESSION_ZSTD - - COMPRESSION_DEFLATE - - COMPRESSION_SNAPPY supportsTlsClientCerts: true diff --git a/internal/conformance/go.mod b/internal/conformance/go.mod index 9b5f6d30..97b69401 100644 --- a/internal/conformance/go.mod +++ b/internal/conformance/go.mod @@ -1,43 +1,45 @@ -module connectrpc.com/connect/internal/conformance +module connectrpc.com/connect/v2/internal/conformance -go 1.25.0 +go 1.26.7 -require connectrpc.com/conformance v1.0.5 +require ( + connectrpc.com/conformance v1.0.5 + connectrpc.com/connect/v2 v2.0.0-00010101000000-000000000000 + github.com/quic-go/quic-go v0.60.0 + github.com/rs/cors v1.11.1 + golang.org/x/sync v0.22.0 + google.golang.org/protobuf v1.36.11 +) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 // indirect - buf.build/go/protovalidate v0.11.0 // indirect - buf.build/go/protoyaml v0.6.0 // indirect - cel.dev/expr v0.25.1 // indirect - connectrpc.com/connect v1.19.1 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect + buf.build/go/protovalidate v1.2.0 // indirect + buf.build/go/protoyaml v0.7.0 // indirect + cel.dev/expr v0.25.2 // indirect + connectrpc.com/connect v1.20.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/cenkalti/backoff/v4 v4.1.1 // indirect - github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/desertbit/timer v1.0.1 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/cel-go v0.29.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/improbable-eng/grpc-web v0.15.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/compress v1.18.1 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.1 // indirect - github.com/rs/cors v1.11.1 // indirect - github.com/spf13/cobra v1.10.1 // indirect + github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect google.golang.org/grpc v1.82.1 // indirect - google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - nhooyr.io/websocket v1.8.6 // indirect + nhooyr.io/websocket v1.8.17 // indirect ) -replace connectrpc.com/connect => ../../ +replace connectrpc.com/connect/v2 => ../../ diff --git a/internal/conformance/go.sum b/internal/conformance/go.sum index 47d4a57c..e909abce 100644 --- a/internal/conformance/go.sum +++ b/internal/conformance/go.sum @@ -1,15 +1,17 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1 h1:YhMSc48s25kr7kv31Z8vf7sPUIq5YJva9z1mn/hAt0M= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.6-20250425153114-8976f5be98c1.1/go.mod h1:avRlCjnFzl98VPaeCtJ24RrV/wwHFzB8sWXhj26+n/U= -buf.build/go/protovalidate v0.11.0 h1:qmX+1Z/t5lqlxQW6bNHnCGE9kX6yXBNul0jYFjD2r3Q= -buf.build/go/protovalidate v0.11.0/go.mod h1:Ryhm9EyqOxO/jdqEBpH4mI/FUk2ULyYeuhR+QRhOgqc= -buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w= -buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q= -cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= +buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= +buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= +buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= connectrpc.com/conformance v1.0.5 h1:w5d+gBj0uUX/DAN+hInQMeUxYTmec5m1Vvi2s/6V56s= connectrpc.com/conformance v1.0.5/go.mod h1:kdJQQZIcltgF2XXxX5Oc3PIK7x21QTr5dH89OnVhdko= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= @@ -23,8 +25,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= @@ -40,10 +42,13 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= -github.com/cenkalti/backoff/v4 v4.1.1 h1:G2HAfAmvm/GcKan2oOQpBXOd2tT2G57ZnZGWa1PxPBQ= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -62,8 +67,9 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f h1:U5y3Y5UE0w7amNe7Z5G/twsBW0KEalRQXZzf8ufSh9I= github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f/go.mod h1:xH/i4TFMt8koVQZ6WFms69WAsDWr2XsYL3Hkl7jkoLE= +github.com/desertbit/timer v1.0.1 h1:yRpYNn5Vaaj6QXecdLMPMJsW81JLiI1eokUft5nBmeo= +github.com/desertbit/timer v1.0.1/go.mod h1:htRrYeY5V/t4iu1xCJ5XsQvp4xve8QulXXctAzxqcwE= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= @@ -75,16 +81,12 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= -github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -98,19 +100,13 @@ github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= -github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= -github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= -github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -159,7 +155,6 @@ github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51 github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.2.2/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI= @@ -200,7 +195,6 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= @@ -209,8 +203,8 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -221,7 +215,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= @@ -229,7 +222,6 @@ github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0Q github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -242,10 +234,8 @@ github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0Qu github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= @@ -308,11 +298,15 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.3.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= -github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= @@ -335,8 +329,8 @@ github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9 github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -354,9 +348,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= -github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= @@ -399,13 +391,13 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= -golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= -golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -436,8 +428,8 @@ golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -446,8 +438,8 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -475,14 +467,14 @@ golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -517,10 +509,10 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98 google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= -google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs= +google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= @@ -567,7 +559,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -577,7 +568,8 @@ honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -nhooyr.io/websocket v1.8.6 h1:s+C3xAMLwGmlI31Nyn/eAehUlZPwfYZu2JXM621Q5/k= nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= +nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= +nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= diff --git a/internal/conformance/internal/app/referenceclient/client.go b/internal/conformance/internal/app/referenceclient/client.go new file mode 100644 index 00000000..520ee414 --- /dev/null +++ b/internal/conformance/internal/app/referenceclient/client.go @@ -0,0 +1,208 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package referenceclient + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + + "connectrpc.com/connect/v2/connectgzip" + "connectrpc.com/connect/v2/connecthttp" + "connectrpc.com/connect/v2/internal/conformance/internal" + conformancev1 "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1" + "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1/conformancev1connect" + "golang.org/x/sync/semaphore" +) + +// Run runs the client according to a client config read from the 'in' reader. The result of the run +// is written to the 'out' writer, including any errors encountered during the actual run. Any error +// returned from this function is indicative of an issue with the reader or writer and should not be related +// to the actual run. +func Run(ctx context.Context, args []string, inReader io.ReadCloser, outWriter, errWriter io.WriteCloser) error { + return run(ctx, args, inReader, outWriter, errWriter) +} + +func run(ctx context.Context, args []string, inReader io.ReadCloser, outWriter, _ io.WriteCloser) (retErr error) { + flags := flag.NewFlagSet(args[0], flag.ContinueOnError) + json := flags.Bool("json", false, "whether to use the JSON format for marshaling / unmarshaling messages") + parallel := flags.Uint("p", uint(runtime.GOMAXPROCS(0))*4, "the number of parallel RPCs to issue") + showVersion := flags.Bool("version", false, "show version and exit") + + if err := flags.Parse(args[1:]); err != nil { + return err + } + if *showVersion { + _, _ = fmt.Fprintf(outWriter, "%s %s\n", filepath.Base(args[0]), internal.Version) + return nil + } + if flags.NArg() != 0 { + return errors.New("this command does not accept any positional arguments") + } + if *parallel == 0 { + return errors.New("invalid parallelism; must be greater than zero") + } + + codec := internal.NewCodec(*json) + decoder := codec.NewDecoder(inReader) + encoder := codec.NewEncoder(outWriter) + var encoderMu sync.Mutex + + var failure atomic.Pointer[error] + defer func() { + // if we're about to return nil error, but a goroutine reported + // a failure, return that failure as the error + if errPtr := failure.Load(); errPtr != nil && retErr == nil { + retErr = *errPtr + } + }() + + var wg sync.WaitGroup + defer wg.Wait() + sema := semaphore.NewWeighted(int64(*parallel)) + + var transports transports + + for { + var req conformancev1.ClientCompatRequest + err := decoder.DecodeNext(&req) + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + + if err := sema.Acquire(ctx, 1); err != nil { + return err + } + if errPtr := failure.Load(); errPtr != nil { + // If there's already been a terminal failure, don't spawn + // anymore goroutines. + return *errPtr + } + + wg.Add(1) + go func() { + defer wg.Done() + defer sema.Release(1) + + result, err := invoke(ctx, &transports, &req) + + // Build the result for the out writer. + resp := &conformancev1.ClientCompatResponse{ + TestName: req.TestName, + } + // If an error was returned, it was a runtime / unexpected internal error so + // the written response should contain an error result, not a response with + // any RPC information + if err != nil { + resp.Result = &conformancev1.ClientCompatResponse_Error{ + Error: &conformancev1.ClientErrorResult{ + Message: err.Error(), + }, + } + } else { + // clear out reference-mode-specific details + result.HttpStatusCode = nil + result.Feedback = nil + resp.Result = &conformancev1.ClientCompatResponse_Response{ + Response: result, + } + } + + // Marshal the response and write the output + func() { + encoderMu.Lock() + defer encoderMu.Unlock() + if err := encoder.Encode(resp); err != nil { + failure.CompareAndSwap(nil, &err) + } + }() + }() + } +} + +// Invokes a ClientCompatRequest, returning either the result of the invocation or an error. The error +// returned from this function indicates a runtime/unexpected internal error and is not indicative of a +// Connect error returned from calling an RPC. Any error (i.e. a Connect error) that _is_ returned from +// the actual RPC invocation will be present in the returned ClientResponseResult. +func invoke(ctx context.Context, transports *transports, req *conformancev1.ClientCompatRequest) (*conformancev1.ClientResponseResult, error) { + transport, serverURL, err := transports.get(req) + if err != nil { + return nil, err + } + + // Create client options based on protocol of the implementation + clientOptions := []connecthttp.Option{connecthttp.WithHTTPGet()} + switch req.Protocol { + case conformancev1.Protocol_PROTOCOL_GRPC: + clientOptions = append(clientOptions, connecthttp.WithGRPC()) + case conformancev1.Protocol_PROTOCOL_GRPC_WEB: + clientOptions = append(clientOptions, connecthttp.WithGRPCWeb()) + case conformancev1.Protocol_PROTOCOL_CONNECT: + // Do nothing + case conformancev1.Protocol_PROTOCOL_UNSPECIFIED: + return nil, errors.New("a protocol must be specified") + } + + switch req.Codec { + case conformancev1.Codec_CODEC_PROTO: + // this is the default, no option needed + case conformancev1.Codec_CODEC_JSON: + jsonCodec := internal.NewStrictJSONCodec() + clientOptions = append(clientOptions, + connecthttp.WithCodec(jsonCodec), + connecthttp.WithSendCodec(jsonCodec.Name()), + ) + case conformancev1.Codec_CODEC_TEXT: //nolint:staticcheck // staticcheck complains because this const is deprecated + return nil, fmt.Errorf("%s is deprecated and should not be used", req.Codec) + default: + return nil, errors.New("a codec must be specified") + } + + switch req.Compression { + case conformancev1.Compression_COMPRESSION_GZIP: + // Connect clients send uncompressed requests and ask for gzipped responses by default + // As a result, specifying a compression of gzip for a client indicates it should also + // send gzipped requests + gzipCompressor := connectgzip.New() + clientOptions = append(clientOptions, + connecthttp.WithCompressor(gzipCompressor), + connecthttp.WithSendCompression(gzipCompressor.Name()), + ) + case conformancev1.Compression_COMPRESSION_IDENTITY, conformancev1.Compression_COMPRESSION_UNSPECIFIED: + // No compression; do nothing + default: + return nil, fmt.Errorf("compression %v is not supported (the v2 reference client supports gzip only)", req.Compression) + } + + // A zero limit means unlimited, which is what the conformance suite expects + // when it does not set one. + clientOptions = append(clientOptions, connecthttp.WithReadMaxBytes(int(req.MessageReceiveLimit))) + + switch req.GetService() { + case conformancev1connect.ConformanceServiceName: + return newInvoker(transport, serverURL, clientOptions).Invoke(ctx, req) + default: + return nil, fmt.Errorf("service name %s is not a valid service", req.GetService()) + } +} diff --git a/internal/conformance/internal/app/referenceclient/impl.go b/internal/conformance/internal/app/referenceclient/impl.go new file mode 100644 index 00000000..7404f09a --- /dev/null +++ b/internal/conformance/internal/app/referenceclient/impl.go @@ -0,0 +1,571 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package referenceclient + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + "connectrpc.com/connect/v2/internal/conformance/internal" + conformancev1 "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1" + "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1/conformancev1connect" + "google.golang.org/protobuf/proto" +) + +const clientName = "connectconformance-referenceclient" + +type invoker struct { + client conformancev1connect.ConformanceServiceClient +} + +// Creates a new invoker around a ConformanceServiceClient. +func newInvoker(transport http.RoundTripper, url *url.URL, opts []connecthttp.Option) *invoker { + httpTransport := connecthttp.NewTransport(&http.Client{Transport: transport}, url.String(), opts...) + client := conformancev1connect.NewConformanceServiceClient( + connect.NewClient(httpTransport, userAgentClientInterceptor, checkDeadlineInterceptor), + ) + return &invoker{client: client} +} + +func (i *invoker) Invoke( + ctx context.Context, + req *conformancev1.ClientCompatRequest, +) (*conformancev1.ClientResponseResult, error) { + // If a timeout was specified, create a derived context with that deadline + if req.TimeoutMs != nil { + deadlineCtx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Duration(*req.TimeoutMs)*time.Millisecond)) + ctx = deadlineCtx + defer cancel() + } + + switch req.GetMethod() { + case "Unary": + if len(req.RequestMessages) != 1 { + return nil, errors.New("unary calls must specify exactly one request message") + } + resp, err := i.unary(ctx, req) + if err != nil { + return nil, err + } + return resp, nil + case "IdempotentUnary": + if len(req.RequestMessages) != 1 { + return nil, errors.New("unary calls must specify exactly one request message") + } + resp, err := i.idempotentUnary(ctx, req) + if err != nil { + return nil, err + } + return resp, nil + case "ServerStream": + if len(req.RequestMessages) != 1 { + return nil, errors.New("server streaming calls must specify exactly one request message") + } + resp, err := i.serverStream(ctx, req) + if err != nil { + return nil, err + } + return resp, nil + case "ClientStream": + resp, err := i.clientStream(ctx, req) + if err != nil { + return nil, err + } + return resp, nil + case "BidiStream": + resp, err := i.bidiStream(ctx, req) + if err != nil { + return nil, err + } + return resp, nil + case "Unimplemented": + resp, err := i.unimplemented(ctx, req) + if err != nil { + return nil, err + } + return resp, nil + default: + return nil, fmt.Errorf("method name %s does not exist on service %s", req.GetMethod(), req.GetService()) + } +} + +func (i *invoker) unary( + ctx context.Context, + req *conformancev1.ClientCompatRequest, +) (*conformancev1.ClientResponseResult, error) { + return doUnary(ctx, req, i, i.client.Unary, + func(resp *conformancev1.UnaryResponse) *conformancev1.ConformancePayload { + return resp.Payload + }) +} + +func (i *invoker) idempotentUnary( + ctx context.Context, + req *conformancev1.ClientCompatRequest, +) (*conformancev1.ClientResponseResult, error) { + return doUnary(ctx, req, i, i.client.IdempotentUnary, + func(resp *conformancev1.IdempotentUnaryResponse) *conformancev1.ConformancePayload { + return resp.Payload + }) +} + +type pointerMessage[T any] interface { + *T + proto.Message +} + +func doUnary[ReqT, RespT any, Req pointerMessage[ReqT]]( + ctx context.Context, + req *conformancev1.ClientCompatRequest, + inv *invoker, + stub func(context.Context, *ReqT) (*RespT, error), + getPayload func(*RespT) *conformancev1.ConformancePayload, +) (*conformancev1.ClientResponseResult, error) { + timing, err := internal.GetCancelTiming(req.Cancel) + if err != nil { + return nil, err + } + + msg := req.RequestMessages[0] + rpcReq := new(ReqT) + if err := msg.UnmarshalTo(Req(rpcReq)); err != nil { + return nil, err + } + + request := rpcReq + // Add the specified request headers to the request. + ctx, info := connect.NewClientContext(ctx) + internal.AddHeaders(req.RequestHeaders, info.RequestHeader()) + + var protoErr *conformancev1.Error + payloads := make([]*conformancev1.ConformancePayload, 0, 1) + + if timing.AfterCloseSendMs >= 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + time.AfterFunc(time.Duration(timing.AfterCloseSendMs)*time.Millisecond, cancel) + } + // Invoke the Unary call + resp, err := stub(ctx, request) + + // Headers and trailers are carried on the call info in v2. + headers := internal.ConvertToProtoHeader(info.ResponseHeader()) + trailers := internal.ConvertToProtoHeader(info.ResponseTrailer()) + if err != nil { + protoErr = internal.ConvertErrorToProtoError(err) + } else { + // If the call was successful, get the returned payload. + payloads = append(payloads, getPayload(resp)) + } + + return &conformancev1.ClientResponseResult{ + ResponseHeaders: headers, + ResponseTrailers: trailers, + Payloads: payloads, + Error: protoErr, + }, nil +} + +func (i *invoker) serverStream( + ctx context.Context, + req *conformancev1.ClientCompatRequest, +) (result *conformancev1.ClientResponseResult, _ error) { + timing, err := internal.GetCancelTiming(req.Cancel) + if err != nil { + return nil, err + } + + msg := req.RequestMessages[0] + ssr := &conformancev1.ServerStreamRequest{} + if err := msg.UnmarshalTo(ssr); err != nil { + return nil, err + } + + request := ssr + // Add the specified request headers to the request. + ctx, info := connect.NewClientContext(ctx) + internal.AddHeaders(req.RequestHeaders, info.RequestHeader()) + + result = &conformancev1.ClientResponseResult{} + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + stream, err := i.client.ServerStream(ctx, request) + if err != nil { + return &conformancev1.ClientResponseResult{ + ResponseHeaders: internal.ConvertToProtoHeader(info.ResponseHeader()), + ResponseTrailers: internal.ConvertToProtoHeader(info.ResponseTrailer()), + Error: internal.ConvertErrorToProtoError(err), + }, nil + } + defer func() { + // Always make sure stream is closed on exit. + closeErr := stream.Close() + if result.Error == nil && closeErr != nil { + result.Error = internal.ConvertErrorToProtoError(closeErr) + } + // Headers and trailers are carried on the call info in v2. + result.ResponseHeaders = internal.ConvertToProtoHeader(info.ResponseHeader()) + result.ResponseTrailers = internal.ConvertToProtoHeader(info.ResponseTrailer()) + }() + + if timing.AfterCloseSendMs >= 0 { + time.Sleep(time.Duration(timing.AfterCloseSendMs) * time.Millisecond) + cancel() + } + + if ssr.ResponseDefinition != nil { + result.Payloads = make([]*conformancev1.ConformancePayload, 0, len(ssr.ResponseDefinition.ResponseData)) + } + + totalRcvd := 0 + for { + msg, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + result.Error = internal.ConvertErrorToProtoError(err) + break + } + totalRcvd++ + // On successful receive, get the returned payload. + result.Payloads = append(result.Payloads, msg.Payload) + + // If AfterNumResponses is specified, it will be a number > 0 here. + // If it wasn't specified, it will be -1, which means the totalRcvd + // will never be equal and we won't cancel. + if totalRcvd == timing.AfterNumResponses { + cancel() + } + } + + return result, nil +} + +func (i *invoker) clientStream( + ctx context.Context, + req *conformancev1.ClientCompatRequest, +) (*conformancev1.ClientResponseResult, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Add the specified request headers to the request. + ctx, info := connect.NewClientContext(ctx) + internal.AddHeaders(req.RequestHeaders, info.RequestHeader()) + + stream, err := i.client.ClientStream(ctx) + if err != nil { + return nil, err + } + var numUnsent int + + for i, msg := range req.RequestMessages { + csr := &conformancev1.ClientStreamRequest{} + if err := msg.UnmarshalTo(csr); err != nil { + return nil, err + } + + // Sleep for any specified delay + time.Sleep(time.Duration(req.RequestDelayMs) * time.Millisecond) + + if err := stream.Send(csr); err != nil && errors.Is(err, io.EOF) { + numUnsent = len(req.RequestMessages) - i + break + } + } + + var protoErr *conformancev1.Error + payloads := make([]*conformancev1.ConformancePayload, 0, 1) + + // Cancellation timing + timing, err := internal.GetCancelTiming(req.Cancel) + if err != nil { + return nil, err + } + if timing.BeforeCloseSend != nil { + cancel() + } else if timing.AfterCloseSendMs >= 0 { + time.AfterFunc(time.Duration(timing.AfterCloseSendMs)*time.Millisecond, cancel) + } + resp, err := stream.CloseAndReceive() + if err != nil { + protoErr = internal.ConvertErrorToProtoError(err) + } else { + // If the call was successful, get the returned payload. + payloads = append(payloads, resp.Payload) + } + + return &conformancev1.ClientResponseResult{ + ResponseHeaders: internal.ConvertToProtoHeader(info.ResponseHeader()), + ResponseTrailers: internal.ConvertToProtoHeader(info.ResponseTrailer()), + Payloads: payloads, + NumUnsentRequests: int32(numUnsent), + Error: protoErr, + }, nil +} + +func (i *invoker) bidiStream( + ctx context.Context, + req *conformancev1.ClientCompatRequest, +) (result *conformancev1.ClientResponseResult, err error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + result = &conformancev1.ClientResponseResult{} + + // Add the specified request headers to the request. + ctx, info := connect.NewClientContext(ctx) + internal.AddHeaders(req.RequestHeaders, info.RequestHeader()) + + stream, err := i.client.BidiStream(ctx) + if err != nil { + return nil, err + } + defer func() { + // Always make sure stream is closed on exit. + closeErr := stream.Close() + if result.Error == nil && closeErr != nil { + result.Error = internal.ConvertErrorToProtoError(closeErr) + } + // Headers and trailers are carried on the call info in v2. + result.ResponseHeaders = internal.ConvertToProtoHeader(info.ResponseHeader()) + result.ResponseTrailers = internal.ConvertToProtoHeader(info.ResponseTrailer()) + }() + + fullDuplex := req.StreamType == conformancev1.StreamType_STREAM_TYPE_FULL_DUPLEX_BIDI_STREAM + + // Cancellation timing + timing, err := internal.GetCancelTiming(req.Cancel) + if err != nil { + return nil, err + } + + var protoErr *conformancev1.Error + totalRcvd := 0 + for i, msg := range req.RequestMessages { + bsr := &conformancev1.BidiStreamRequest{} + if err := msg.UnmarshalTo(bsr); err != nil { + // Return the error and nil result because this is an + // unmarshalling error unrelated to the RPC + return nil, err + } + + // Sleep for any specified delay + time.Sleep(time.Duration(req.RequestDelayMs) * time.Millisecond) + + if err := stream.Send(bsr); err != nil && errors.Is(err, io.EOF) { + // Call receive to get the error and convert it to a proto error + if _, recvErr := stream.Receive(); recvErr != nil { + protoErr = internal.ConvertErrorToProtoError(recvErr) + } else { + // Just in case the receive call doesn't return the error, + // use the error returned from Send. Note this should never + // happen, but is here as a safeguard. + protoErr = internal.ConvertErrorToProtoError(err) + } + // Break the send loop + result.NumUnsentRequests = int32(len(req.RequestMessages) - i) + break + } + if fullDuplex { + // If this is a full duplex stream, receive a response for each request + msg, err := stream.Receive() + if err != nil { + if !errors.Is(err, io.EOF) { + // If an error was returned that is not an EOF, convert it + // to a proto Error. If the error was an EOF, that just means + // reads are done. + protoErr = internal.ConvertErrorToProtoError(err) + } + // Reads are done either because we received an error or an EOF + // In either case, break the outer loop + break + } + // On successful receive, get the returned payload. + result.Payloads = append(result.Payloads, msg.Payload) + totalRcvd++ + if totalRcvd == timing.AfterNumResponses { + cancel() + } + } + } + + if timing.BeforeCloseSend != nil { + cancel() + } + + // Sends are done, close the send side of the stream + if err := stream.CloseSend(); err != nil { + return nil, err + } + + if timing.AfterCloseSendMs >= 0 { + time.Sleep(time.Duration(timing.AfterCloseSendMs) * time.Millisecond) + cancel() + } + + // If we received an error in any of the send logic or full-duplex reads, then exit + if protoErr != nil { + result.Error = protoErr + return result, nil + } + + // Receive any remaining responses + for { + msg, err := stream.Receive() + if err != nil { + if !errors.Is(err, io.EOF) { + // If an error was returned that is not an EOF, convert it + // to a proto Error. If the error was an EOF, that just means + // reads are done. + protoErr = internal.ConvertErrorToProtoError(err) + } + break + } + // On successful receive, get the returned payload. + result.Payloads = append(result.Payloads, msg.Payload) + totalRcvd++ + if totalRcvd == timing.AfterNumResponses { + cancel() + } + } + + if protoErr != nil { + result.Error = protoErr + } + return result, nil +} + +func (i *invoker) unimplemented( + ctx context.Context, + req *conformancev1.ClientCompatRequest, +) (*conformancev1.ClientResponseResult, error) { + return doUnary(ctx, req, i, i.client.Unimplemented, + func(_ *conformancev1.UnimplementedResponse) *conformancev1.ConformancePayload { + return nil + }) +} + +// userAgentClientInterceptor adds to the user-agent header on outgoing requests. +func userAgentClientInterceptor(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + if info, ok := connect.CallInfoForClientContext(ctx); ok { + // decorate user-agent with the program name and version + existing := info.RequestHeader().Get("User-Agent") + info.RequestHeader().Set("User-Agent", strings.TrimSpace(fmt.Sprintf("%s %s/%s", existing, clientName, internal.Version))) + } + return next(ctx, spec) + } +} + +// checkDeadlineInterceptor can translate misattributed HTTP/2 stream +// CANCEL errors from "canceled" into "deadline exceeded". This can happen +// because when the deadline is reached, there is a race between the client +// timer function, which cancels the context and sets the "deadline exceeded" +// error, and the server, which cancels the HTTP/2 stream. +func checkDeadlineInterceptor(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + stream, err := next(ctx, spec) + if err != nil { + return nil, err + } + return &checkDeadlineStream{ClientStream: stream, ctx: ctx}, nil + } +} + +func checkDeadlineError(ctx context.Context, err error) error { + if err == nil { + return nil + } + if connect.CodeOf(err) != connect.CodeCanceled || errors.Is(ctx.Err(), context.Canceled) { + // No need to change code attribution. + return err + } + if deadline, ok := ctx.Deadline(); !ok || time.Now().Before(deadline) { + // No deadline or deadline not reached, so no change to attribution. + return err + } + // If we get here, we've got a "canceled" code, but we've reached the context + // deadline, so it likely should be "deadline exceeded" instead. This + // misattribution can happen because the timer function that cancels the + // context after the deadline is reached hadn't yet run. It's non-deterministic, + // and the original attribution of the "canceled" code races with it. + // + // However, we don't want to unconditionally change the code at this time to + // "deadline exceeded". It is possible that the server actually returned a + // "canceled" error. So we only want to change the code when we see that the + // underlying error was an HTTP/2 stream CANCEL frame. + // + // This is gnarly, but this is the same way that the connect-go library does + // this. This code was largely copied from connect.wrapIfRSTError. + const ( + streamErrPrefix = "stream error: " + fromPeerSuffix = "; received from peer" + ) + if connectErr := (*connect.Error)(nil); errors.As(err, &connectErr) { + err = connectErr.Unwrap() + } + if urlErr := (*url.Error)(nil); errors.As(err, &urlErr) { + // If we get an RST_STREAM error from http.Client.Do, it's wrapped in a + // *url.Error. + err = urlErr.Unwrap() + } + msg := err.Error() + if !strings.HasPrefix(msg, streamErrPrefix) { + return err + } + if !strings.HasSuffix(msg, fromPeerSuffix) { + return err + } + msg = strings.TrimSuffix(msg, fromPeerSuffix) + i := strings.LastIndex(msg, ";") + if i < 0 || i >= len(msg)-1 { + return err + } + msg = msg[i+1:] + msg = strings.TrimSpace(msg) + if msg != "CANCEL" { + return err + } + // The underlying error is an HTTP/2 stream cancelation. So now + // it's safe to change the error code attribution. + return connect.NewError(connect.CodeDeadlineExceeded, err.Error()) +} + +// checkDeadlineStream is a ClientStream decorator that can translate errors +// from "canceled" to "deadline exceeded" if they are misattributed. +type checkDeadlineStream struct { + connect.ClientStream + + ctx context.Context //nolint:containedctx +} + +func (s *checkDeadlineStream) Send(msg any) error { + return checkDeadlineError(s.ctx, s.ClientStream.Send(msg)) +} + +func (s *checkDeadlineStream) Receive(msg any) error { + return checkDeadlineError(s.ctx, s.ClientStream.Receive(msg)) +} diff --git a/internal/conformance/internal/app/referenceclient/transports.go b/internal/conformance/internal/app/referenceclient/transports.go new file mode 100644 index 00000000..944816c6 --- /dev/null +++ b/internal/conformance/internal/app/referenceclient/transports.go @@ -0,0 +1,212 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package referenceclient + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "connectrpc.com/connect/v2/internal/conformance/internal" + conformancev1 "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1" + "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1/conformancev1connect" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" +) + +type transportSpec struct { + httpVersion conformancev1.HTTPVersion + serverTLSCert string + clientTLSCert string + clientTLSKey string +} + +type transports struct { + cache sync.Map // map[transportSpec]http.RoundTripper +} + +func (t *transports) get(req *conformancev1.ClientCompatRequest) (http.RoundTripper, *url.URL, error) { + tlsConf, err := createTLSConfig(req) + if err != nil { + return nil, nil, err + } + var scheme string + if tlsConf != nil { + scheme = "https://" + } else { + scheme = "http://" + } + urlString := scheme + net.JoinHostPort(req.Host, strconv.Itoa(int(req.Port))) + serverURL, err := url.ParseRequestURI(urlString) + if err != nil { + return nil, nil, fmt.Errorf("invalid url: %s", urlString) + } + + spec := transportSpec{ + httpVersion: req.GetHttpVersion(), + serverTLSCert: string(req.GetServerTlsCert()), + clientTLSCert: string(req.GetClientTlsCreds().GetCert()), + clientTLSKey: string(req.GetClientTlsCreds().GetKey()), + } + + // Optimistically skip logic if it's already cached. We will still do an + // atomic store to share the transport in all cases even if this misses. + if tr, ok := t.cache.Load(spec); ok { + return tr.(http.RoundTripper), serverURL, nil //nolint:errcheck,forcetypeassert + } + + var transport http.RoundTripper + switch req.HttpVersion { + case conformancev1.HTTPVersion_HTTP_VERSION_1: + if tlsConf != nil { + tlsConf.NextProtos = []string{"http/1.1"} + } + tx := &http.Transport{ + DisableCompression: true, + TLSClientConfig: tlsConf, + } + transport = roundTripperFunc(func(req *http.Request) (*http.Response, error) { + resp, err := tx.RoundTrip(req) + if resp != nil && + strings.HasSuffix(req.URL.Path, conformancev1connect.ConformanceServiceBidiStreamProcedure) { + // To force support for bidirectional RPC over HTTP 1.1 (for half-duplex testing), + // we "trick" the client into thinking this is HTTP/2. We have to do this because + // otherwise, connect-go refuses to support bidi streams over HTTP 1.1. + resp.ProtoMajor, resp.ProtoMinor = 2, 0 + } + return resp, err + }) + case conformancev1.HTTPVersion_HTTP_VERSION_2: + var prots *http.Protocols + forceAttemptHTTP2 := false + if tlsConf != nil { + tlsConf.NextProtos = []string{"h2"} + forceAttemptHTTP2 = true + } else { + prots = &http.Protocols{} + prots.SetUnencryptedHTTP2(true) + } + transport = &http.Transport{ + DisableCompression: true, + TLSClientConfig: tlsConf, + ForceAttemptHTTP2: forceAttemptHTTP2, + Protocols: prots, + } + case conformancev1.HTTPVersion_HTTP_VERSION_3: + if tlsConf == nil { + return nil, nil, errors.New("HTTP/3 indicated in request but no TLS info provided") + } + transport = &contextFixTransport{http3.Transport{ + DisableCompression: true, + TLSClientConfig: tlsConf, + QUICConfig: &quic.Config{MaxIdleTimeout: 20 * time.Second, KeepAlivePeriod: 5 * time.Second}, + }} + case conformancev1.HTTPVersion_HTTP_VERSION_UNSPECIFIED: + return nil, nil, errors.New("an HTTP version must be specified") + default: + return nil, nil, fmt.Errorf("unknown HTTP version specified :%d", req.HttpVersion) + } + + // Even if two requests for the same spec make it here, they will use the same connection. + actual, _ := t.cache.LoadOrStore(spec, transport) + return actual.(http.RoundTripper), serverURL, nil //nolint:errcheck,forcetypeassert +} + +// contextFixTransport wraps an HTTP/3 transport so that context errors can be correctly +// classified by the connect-go framework. This is a work-around until a fix +// can be implemented in connect-go and/or quic-go. +// See: https://github.com/quic-go/quic-go/issues/4196 +type contextFixTransport struct { + http3.Transport +} + +func (t *contextFixTransport) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + resp, err := t.Transport.RoundTrip(req) + if err != nil { + return nil, maybeWrapContextError(ctx, err) + } + resp.Body = &contextFixReader{ctx: ctx, r: resp.Body} + return resp, nil +} + +type contextFixReader struct { + ctx context.Context //nolint:containedctx + r io.ReadCloser +} + +func (r *contextFixReader) Read(data []byte) (int, error) { + n, err := r.r.Read(data) + return n, maybeWrapContextError(r.ctx, err) +} + +func (r *contextFixReader) Close() error { + return maybeWrapContextError(r.ctx, r.r.Close()) +} + +func maybeWrapContextError(ctx context.Context, err error) error { + if err == nil { + return nil + } + ctxErr := ctx.Err() + if ctxErr == nil { + return err + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return &contextFixError{timeout: true, error: err} + } + var httpErr *http3.Error + if errors.As(err, &httpErr) && httpErr.ErrorCode == http3.ErrCodeRequestCanceled { + return &contextFixError{timeout: errors.Is(ctxErr, context.DeadlineExceeded), error: err} + } + return err +} + +type contextFixError struct { + timeout bool + error +} + +//nolint:goerr113 +func (e *contextFixError) Is(err error) bool { + return (e.timeout && err == context.DeadlineExceeded) || + (!e.timeout && err == context.Canceled) +} + +func createTLSConfig(req *conformancev1.ClientCompatRequest) (*tls.Config, error) { + if req.ServerTlsCert == nil { + if req.ClientTlsCreds != nil { + return nil, errors.New("request indicated TLS client credentials but not server TLS cert provided") + } + return nil, nil //nolint:nilnil + } + return internal.NewClientTLSConfig(req.ServerTlsCert, req.ClientTlsCreds.GetCert(), req.ClientTlsCreds.GetKey()) +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/internal/conformance/internal/app/referenceserver/impl.go b/internal/conformance/internal/app/referenceserver/impl.go new file mode 100644 index 00000000..8e276c33 --- /dev/null +++ b/internal/conformance/internal/app/referenceserver/impl.go @@ -0,0 +1,508 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package referenceserver + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "strings" + "time" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + "connectrpc.com/connect/v2/internal/conformance/internal" + conformancev1 "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1" + "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1/conformancev1connect" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" +) + +const serverName = "connectconformance-referenceserver" + +// ConformanceRequest is a general interface for all conformance requests (UnaryRequest, ServerStreamRequest, etc.) +type ConformanceRequest interface { + GetResponseHeaders() []*conformancev1.Header + GetResponseTrailers() []*conformancev1.Header +} + +type conformanceServer struct { + conformancev1connect.UnimplementedConformanceServiceHandler +} + +func (s *conformanceServer) Unary( + ctx context.Context, + req *conformancev1.UnaryRequest, +) (*conformancev1.UnaryResponse, error) { + return doUnary(ctx, req, func(payload *conformancev1.ConformancePayload) *conformancev1.UnaryResponse { + return &conformancev1.UnaryResponse{ + Payload: payload, + } + }) +} + +func (s *conformanceServer) IdempotentUnary( + ctx context.Context, + req *conformancev1.IdempotentUnaryRequest, +) (*conformancev1.IdempotentUnaryResponse, error) { + return doUnary(ctx, req, func(payload *conformancev1.ConformancePayload) *conformancev1.IdempotentUnaryResponse { + return &conformancev1.IdempotentUnaryResponse{ + Payload: payload, + } + }) +} + +type hasUnaryResponseDefinition[T any] interface { + *T + proto.Message + GetResponseDefinition() *conformancev1.UnaryResponseDefinition +} + +func doUnary[ReqT, RespT any, Req hasUnaryResponseDefinition[ReqT]]( + ctx context.Context, + req *ReqT, + makeResp func(payload *conformancev1.ConformancePayload) *RespT, +) (*RespT, error) { + info, _ := connect.CallInfoForServerContext(ctx) + msg := Req(req) + msgAsAny, err := asAny(msg) + if err != nil { + return nil, err + } + payload, connectErr := parseUnaryResponseDefinition( + ctx, + msg.GetResponseDefinition(), + info, + queryParamsFromContext(ctx), + []*anypb.Any{msgAsAny}, + ) + if connectErr != nil { + return nil, connectErr + } + + if msg.GetResponseDefinition() != nil { + internal.AddHeaders(msg.GetResponseDefinition().ResponseHeaders, info.ResponseHeader()) + internal.AddHeaders(msg.GetResponseDefinition().ResponseTrailers, info.ResponseTrailer()) + + // If a response delay was specified, sleep for that amount of ms before responding + responseDelay := time.Duration(msg.GetResponseDefinition().ResponseDelayMs) * time.Millisecond + time.Sleep(responseDelay) + } + + return makeResp(payload), nil +} + +func (s *conformanceServer) ClientStream( + ctx context.Context, + stream conformancev1connect.ConformanceServiceClientStreamServerStream, +) (*conformancev1.ClientStreamResponse, error) { + info, _ := connect.CallInfoForServerContext(ctx) + var responseDefinition *conformancev1.UnaryResponseDefinition + firstRecv := true + var reqs []*anypb.Any + for { + msg, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + // If this is the first message received on the stream, save off the response definition we need to send + if firstRecv { + responseDefinition = msg.ResponseDefinition + firstRecv = false + } + // Record all the requests received + msgAsAny, err := asAny(msg) + if err != nil { + return nil, err + } + reqs = append(reqs, msgAsAny) + } + + payload, err := parseUnaryResponseDefinition( + ctx, + responseDefinition, + info, + queryParamsFromContext(ctx), + reqs, + ) + if err != nil { + return nil, err + } + + if responseDefinition != nil { + internal.AddHeaders(responseDefinition.ResponseHeaders, info.ResponseHeader()) + internal.AddHeaders(responseDefinition.ResponseTrailers, info.ResponseTrailer()) + + // If a response delay was specified, sleep for that amount of ms before responding + responseDelay := time.Duration(responseDefinition.ResponseDelayMs) * time.Millisecond + time.Sleep(responseDelay) + } + + return &conformancev1.ClientStreamResponse{Payload: payload}, nil +} + +func (s *conformanceServer) ServerStream( + ctx context.Context, + req *conformancev1.ServerStreamRequest, + stream conformancev1connect.ConformanceServiceServerStreamServerStream, +) error { + info, _ := connect.CallInfoForServerContext(ctx) + // Convert the request to an Any so that it can be recorded in the payload + msgAsAny, err := asAny(req) + if err != nil { + return err + } + + respNum := 0 + + responseDefinition := req.ResponseDefinition + if responseDefinition != nil { //nolint:nestif + internal.AddHeaders(responseDefinition.ResponseHeaders, info.ResponseHeader()) + internal.AddHeaders(responseDefinition.ResponseTrailers, info.ResponseTrailer()) + + if len(responseDefinition.ResponseData) > 0 { + // Immediately send the headers/trailers on the stream so that they can be read by the client + if err := stream.SendHeaders(); err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error sending on stream: %w", err).Error()) + } + } + + // Calculate the response delay if specified + responseDelay := time.Duration(responseDefinition.ResponseDelayMs) * time.Millisecond + + for _, data := range responseDefinition.ResponseData { + resp := &conformancev1.ServerStreamResponse{ + Payload: &conformancev1.ConformancePayload{ + Data: data, + }, + } + + // Only set the request info if this is the first response being sent back + // because for server streams, nothing in the request info will change + // after the first response. + if respNum == 0 { + resp.Payload.RequestInfo = createRequestInfo(ctx, info.RequestHeader(), queryParamsFromContext(ctx), []*anypb.Any{msgAsAny}) + } + + // If a response delay was specified, sleep for that amount of ms before responding + time.Sleep(responseDelay) + + if err := stream.Send(resp); err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error sending on stream: %w", err).Error()) + } + respNum++ + } + + if responseDefinition.Error != nil { + if respNum == 0 { + // We've sent no responses and are returning an error, so build a + // RequestInfo message and append to the error details + reqInfo := createRequestInfo(ctx, info.RequestHeader(), queryParamsFromContext(ctx), []*anypb.Any{msgAsAny}) + reqInfoAny, err := anypb.New(reqInfo) + if err != nil { + return connect.NewError(connect.CodeInternal, err.Error()) + } + responseDefinition.Error.Details = append(responseDefinition.Error.Details, reqInfoAny) + } + return internal.ConvertProtoToConnectError(responseDefinition.Error) + } + } + + return nil +} + +func (s *conformanceServer) BidiStream( + ctx context.Context, + stream conformancev1connect.ConformanceServiceBidiStreamServerStream, +) error { + info, _ := connect.CallInfoForServerContext(ctx) + var responseDefinition *conformancev1.StreamResponseDefinition + var responseDelay time.Duration + fullDuplex := false + firstRecv := true + respNum := 0 + var reqs []*anypb.Any + for { + if err := ctx.Err(); err != nil { + return err + } + req, err := stream.Receive() + if err != nil { + if errors.Is(err, io.EOF) { + // Reads are done, break the receive loop and send any remaining responses + break + } + return fmt.Errorf("receive request: %w", err) + } + + // Record all requests received + msgAsAny, err := asAny(req) + if err != nil { + return err + } + reqs = append(reqs, msgAsAny) + + // If this is the first message in the stream, save off the total responses we need to send + // plus whether this should be full or half duplex + if firstRecv { //nolint:nestif + responseDefinition = req.ResponseDefinition + fullDuplex = req.FullDuplex + firstRecv = false + + // If a response definition was provided, add the headers and trailers + if responseDefinition != nil { + internal.AddHeaders(responseDefinition.ResponseHeaders, info.ResponseHeader()) + internal.AddHeaders(responseDefinition.ResponseTrailers, info.ResponseTrailer()) + + if fullDuplex && len(responseDefinition.ResponseData) > 0 { + // Immediately send the headers on the stream so that they can be read by the client. + // We can only do this for full-duplex. For half-duplex operation, we must let client + // complete its upload before trying to send anything. + if err := stream.SendHeaders(); err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error sending on stream: %w", err).Error()) + } + } + + // Calculate a response delay if specified + responseDelay = time.Duration(responseDefinition.ResponseDelayMs) * time.Millisecond + } + } + + // If fullDuplex, then send one of the desired responses each time we get a message on the stream + if fullDuplex { + if respNum >= len(responseDefinition.GetResponseData()) { + // If there are no responses to send, then break the receive loop + // and throw the error specified + break + } + + resp := &conformancev1.BidiStreamResponse{ + Payload: &conformancev1.ConformancePayload{ + Data: responseDefinition.ResponseData[respNum], + }, + } + var requestInfo *conformancev1.ConformancePayload_RequestInfo + if respNum == 0 { + // Only send the full request info (including headers and timeouts) + // in the first response + requestInfo = createRequestInfo(ctx, info.RequestHeader(), queryParamsFromContext(ctx), reqs) + } else { + // All responses after the first should only include the requests + // since that is the only thing that will change between responses + // for a full duplex stream + requestInfo = &conformancev1.ConformancePayload_RequestInfo{ + Requests: reqs, + } + } + resp.Payload.RequestInfo = requestInfo + + // If a response delay was specified, sleep for that amount of ms before responding + time.Sleep(responseDelay) + + if err := stream.Send(resp); err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error sending on stream: %w", err).Error()) + } + respNum++ + reqs = nil + } + } + + if !fullDuplex && len(responseDefinition.GetResponseData()) > 0 { + // Now that upload is complete, we can immediately send headers for half-duplex calls. + if err := stream.SendHeaders(); err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error sending on stream: %w", err).Error()) + } + } + + // If we still have responses left to send, flush them now. This accommodates + // both scenarios of half duplex (we haven't sent any responses yet) or full duplex + // where the requested responses are greater than the total requests. + if responseDefinition != nil { //nolint:nestif + for ; respNum < len(responseDefinition.ResponseData); respNum++ { + if err := ctx.Err(); err != nil { + return err + } + resp := &conformancev1.BidiStreamResponse{ + Payload: &conformancev1.ConformancePayload{ + Data: responseDefinition.ResponseData[respNum], + }, + } + // Only set the request info if this is the first response being sent back + // because for half duplex streams, nothing in the request info will change + // after the first response (this includes the requests since they've all + // been received by this point) + if respNum == 0 { + resp.Payload.RequestInfo = createRequestInfo(ctx, info.RequestHeader(), queryParamsFromContext(ctx), reqs) + } + + // If a response delay was specified, sleep for that amount of ms before responding + time.Sleep(responseDelay) + + if err := stream.Send(resp); err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error sending on stream: %w", err).Error()) + } + } + + if responseDefinition.Error != nil { + if respNum == 0 { + // We've sent no responses and are returning an error, so build a + // RequestInfo message and append to the error details + reqInfo := createRequestInfo(ctx, info.RequestHeader(), queryParamsFromContext(ctx), reqs) + reqInfoAny, err := anypb.New(reqInfo) + if err != nil { + return connect.NewError(connect.CodeInternal, err.Error()) + } + responseDefinition.Error.Details = append(responseDefinition.Error.Details, reqInfoAny) + } + return internal.ConvertProtoToConnectError(responseDefinition.Error) + } + } + + return nil +} + +// Parses the given unary response definition and returns either +// a built payload or a connect error based on the definition. +func parseUnaryResponseDefinition( + ctx context.Context, + def *conformancev1.UnaryResponseDefinition, + info *connect.CallInfo, + queryParams url.Values, + reqs []*anypb.Any, +) (*conformancev1.ConformancePayload, *connect.Error) { + reqInfo := createRequestInfo(ctx, info.RequestHeader(), queryParams, reqs) + if def == nil { + // If the definition is not set at all, there's nothing to respond with. + // Just return a payload with the request info + return &conformancev1.ConformancePayload{ + RequestInfo: reqInfo, + }, nil + } + + switch respType := def.Response.(type) { + case *conformancev1.UnaryResponseDefinition_Error: + // The server should add the request info to the error details + // for unary responses that return an error. + reqInfoAny, err := anypb.New(reqInfo) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err.Error()) + } + respType.Error.Details = append(respType.Error.Details, reqInfoAny) + + connectErr := internal.ConvertProtoToConnectError(respType.Error) + + // Set the response headers and trailers on the call info. + internal.AddHeaders(def.GetResponseHeaders(), info.ResponseHeader()) + internal.AddHeaders(def.GetResponseTrailers(), info.ResponseTrailer()) + + return nil, connectErr + + case *conformancev1.UnaryResponseDefinition_ResponseData, nil: + payload := &conformancev1.ConformancePayload{ + RequestInfo: reqInfo, + } + + // If response data was provided, set that in the payload response + if respType, ok := respType.(*conformancev1.UnaryResponseDefinition_ResponseData); ok { + payload.Data = respType.ResponseData + } + return payload, nil + default: + return nil, connect.Errorf(connect.CodeInvalidArgument, "provided UnaryRequest.Response has an unexpected type %T", respType) + } +} + +// Creates request info for a conformance payload. +func createRequestInfo( + ctx context.Context, + headers *connect.Header, + queryParams url.Values, + reqs []*anypb.Any, +) *conformancev1.ConformancePayload_RequestInfo { + headerInfo := internal.ConvertToProtoHeader(headers) + + var connectGetInfo *conformancev1.ConformancePayload_ConnectGetInfo + if len(queryParams) > 0 { + queryParamInfo := make([]*conformancev1.Header, 0, len(queryParams)) + for name, values := range queryParams { + queryParamInfo = append(queryParamInfo, &conformancev1.Header{ + Name: name, + Value: values, + }) + } + connectGetInfo = &conformancev1.ConformancePayload_ConnectGetInfo{ + QueryParams: queryParamInfo, + } + } + + var timeoutMs *int64 + if timeout, ok := timeoutFromContext(ctx); ok { + timeoutMs = proto.Int64(timeout.Milliseconds()) + } + + // Set all observed request headers and requests in the response payload + return &conformancev1.ConformancePayload_RequestInfo{ + RequestHeaders: headerInfo, + Requests: reqs, + TimeoutMs: timeoutMs, + ConnectGetInfo: connectGetInfo, + } +} + +// queryParamsFromContext returns the Connect GET query parameters for the +// in-flight request, or nil when it was not sent as an HTTP GET. +func queryParamsFromContext(ctx context.Context) url.Values { + info, _ := connecthttp.ServerInfoForContext(ctx) + return info.HTTPGetQueryParams() +} + +func timeoutFromContext(ctx context.Context) (time.Duration, bool) { + if deadline, ok := ctx.Deadline(); ok { + return time.Until(deadline), true + } + return 0, false +} + +// Converts the given message to an Any. +func asAny(msg proto.Message) (*anypb.Any, error) { + msgAsAny, err := anypb.New(msg) + if err != nil { + return nil, connect.NewError( + connect.CodeInternal, + fmt.Errorf("unable to convert message: %w", err).Error(), + ) + } + return msgAsAny, nil +} + +// serverNameHandlerInterceptor adds a "server" header on outgoing responses. +func serverNameHandlerInterceptor(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + if info, ok := connect.CallInfoForServerContext(ctx); ok { + // decorate server with the program name and version + existing := info.ResponseHeader().Get("Server") + info.ResponseHeader().Set("Server", strings.TrimSpace(fmt.Sprintf("%s %s/%s", existing, serverName, internal.Version))) + } + return next(ctx, spec, stream) + } +} diff --git a/internal/conformance/internal/app/referenceserver/server.go b/internal/conformance/internal/app/referenceserver/server.go new file mode 100644 index 00000000..687d22a5 --- /dev/null +++ b/internal/conformance/internal/app/referenceserver/server.go @@ -0,0 +1,384 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package referenceserver + +import ( + "context" + "crypto/tls" + "errors" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectgzip" + "connectrpc.com/connect/v2/connecthttp" + "connectrpc.com/connect/v2/internal/conformance/internal" + conformancev1 "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1" + "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1/conformancev1connect" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/rs/cors" +) + +// Run runs the server according to server config read from the 'in' reader. +func Run(ctx context.Context, args []string, inReader io.ReadCloser, outWriter, errWriter io.WriteCloser) error { + return run(ctx, args, inReader, outWriter, errWriter) +} + +func run(ctx context.Context, args []string, inReader io.ReadCloser, outWriter, _ io.WriteCloser) error { + flags := flag.NewFlagSet(args[0], flag.ContinueOnError) + json := flags.Bool("json", false, "whether to use the JSON format for marshaling / unmarshaling messages") + host := flags.String("bind", internal.DefaultHost, "the bind address for the conformance server") + port := flags.Int("port", internal.DefaultPort, "the port for the conformance server") + tlsCert := flags.String("cert", "", "the path to a PEM-encoded TLS certificate file to use instead of generating self-signed") + tlsKey := flags.String("key", "", "the path to a PEM-encoded TLS key file to use instead of generating self-signed") + showVersion := flags.Bool("version", false, "show version and exit") + + if err := flags.Parse(args[1:]); err != nil { + return err + } + if *showVersion { + _, _ = fmt.Fprintf(outWriter, "%s %s\n", filepath.Base(args[0]), internal.Version) + return nil + } + if flags.NArg() != 0 { + return errors.New("this command does not accept any positional arguments") + } + if (*tlsCert == "") != (*tlsKey == "") { + return errors.New("-cert and -key must both be provided") + } + + codec := internal.NewCodec(*json) + + // Read the server config from the in reader + req := &conformancev1.ServerCompatRequest{} + if err := codec.NewDecoder(inReader).DecodeNext(req); err != nil { + return err + } + + // Create an HTTP server based on the request + server, certBytes, err := createServer(req, net.JoinHostPort(*host, strconv.Itoa(*port)), *tlsCert, *tlsKey) + if err != nil { + return err + } + + actualHost, actualPortStr, err := net.SplitHostPort(server.Addr()) + if err != nil { + return err + } + actualPort, err := strconv.Atoi(actualPortStr) + if err != nil { + return err + } + if actualHost == "" || actualHost == "0.0.0.0" { + actualHost = internal.DefaultHost + } + + // Start the server + var serveError error + serveDone := make(chan struct{}) + go func() { + defer close(serveDone) + serveError = server.Serve() + }() + // Give the above goroutine a chance to start the server and potentially + // abort if it could not be started. + time.Sleep(200 * time.Millisecond) + select { + case <-serveDone: + return serveError + default: + } + + resp := &conformancev1.ServerCompatResponse{ + Host: actualHost, + Port: uint32(actualPort), + PemCert: certBytes, + } + if err := codec.NewEncoder(outWriter).Encode(resp); err != nil { + return err + } + + select { + case <-serveDone: + return serveError + case <-ctx.Done(): + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + // If it takes too long to shutdown gracefully, force it. + // TODO: Log an error about graceful shutdown taking longer than 5s? + _ = server.Close() + } else { + return fmt.Errorf("failed to gracefully shutdown HTTP server: %w", err) + } + } + return nil + } +} + +type httpServer interface { + Serve() error + Shutdown(context.Context) error + Close() error + Addr() string +} + +type stdHTTPServer struct { + svr *http.Server + lis net.Listener +} + +func (s *stdHTTPServer) Serve() error { + if s.svr.TLSConfig != nil { + return s.svr.ServeTLS(s.lis, "", "") + } + return s.svr.Serve(s.lis) +} + +func (s *stdHTTPServer) Shutdown(ctx context.Context) error { + return s.svr.Shutdown(ctx) +} + +func (s *stdHTTPServer) Close() error { + return s.svr.Close() +} + +func (s *stdHTTPServer) Addr() string { + return s.lis.Addr().String() +} + +const ( + grpcContentType = "application/grpc" + grpcContentTypePrefix = grpcContentType + "+" +) + +// Creates an HTTP server using the provided ServerCompatRequest. +func createServer(req *conformancev1.ServerCompatRequest, listenAddr, tlsCertFile, tlsKeyFile string) (httpServer, []byte, error) { + mux := http.NewServeMux() + opts := []connecthttp.Option{ + connecthttp.WithCompressor(connectgzip.New()), + connecthttp.WithCodec(internal.NewStrictJSONCodec()), + } + // A zero limit means unlimited, which is what the conformance suite expects + // when it does not set one. + opts = append(opts, connecthttp.WithReadMaxBytes(int(req.MessageReceiveLimit))) + srv := connect.NewServer(serverNameHandlerInterceptor) + + conformancev1connect.RegisterConformanceServiceHandler(srv, &conformanceServer{}) + connecthttp.Mount(mux, srv, opts...) + + handler := http.Handler(http.HandlerFunc(func(respWriter http.ResponseWriter, req *http.Request) { + if strings.HasSuffix(req.URL.Path, conformancev1connect.ConformanceServiceBidiStreamProcedure) && + req.ProtoMajor == 1 { + // To force support for bidirectional RPC over HTTP 1.1 (for half-duplex testing), + // we "trick" the handler into thinking this is HTTP/2. We have to do this because + // otherwise, connect-go refuses to handle bidi streams over HTTP 1.1. + req.ProtoMajor, req.ProtoMinor = 2, 0 + } + mux.ServeHTTP(respWriter, req) + })) + // We may handle a test case defined with a "raw HTTP request", sent by the + // reference client. The underlying connect-go implementation has a hard + // requirement that gRPC requests include a "TE: trailers" header, so we + // reject any that don't before they reach the handler. + orig := handler + handler = http.HandlerFunc(func(respWriter http.ResponseWriter, req *http.Request) { + contentType := req.Header.Get("Content-Type") + if (contentType == grpcContentType || strings.HasPrefix(contentType, grpcContentTypePrefix)) && + req.Header.Get("TE") != "trailers" { + errWriter := connecthttp.NewErrorWriter() + _ = errWriter.Write(respWriter, req, connect.NewError(connect.CodeUnknown, "missing 'TE: trailers' header")) + return + } + orig.ServeHTTP(respWriter, req) + }) + // The server needs a lenient cors setup so that it can handle testing + // browser clients. + handler = cors.New(cors.Options{ + // In case TLS client certs are used. + AllowCredentials: true, + // If credentials are used, default "allow all origins" doesn't work since + // it echos back "*" in the "Access-Control-Allow-Origin" header. But asterisk + // isn't accepted by clients when credentials are used. So we have to allow + // all this way: + AllowOriginFunc: func(string) bool { return true }, + AllowedMethods: []string{ + http.MethodHead, + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + }, + // Note that rs/cors does not return `Access-Control-Allow-Headers: *` + // in response to preflight requests with the following configuration. + // It simply mirrors all headers listed in the `Access-Control-Request-Headers` + // preflight request header. + AllowedHeaders: []string{"*"}, + // Expose all headers + ExposedHeaders: []string{"*"}, + }).Handler(handler) + + // Create servers + var tlsConf *tls.Config + var certBytes []byte + if req.UseTls { //nolint:nestif + var keyBytes []byte + var err error + switch { + case tlsCertFile != "": + certBytes, err = os.ReadFile(tlsCertFile) + if err != nil { + return nil, nil, fmt.Errorf("could not load TLS cert: %w", err) + } + keyBytes, err = os.ReadFile(tlsKeyFile) + if err != nil { + return nil, nil, fmt.Errorf("could not load TLS key: %w", err) + } + case req.ServerCreds != nil: + certBytes = req.ServerCreds.Cert + keyBytes = req.ServerCreds.Key + default: + // This generally shouldn't happen. If we're using TLS, test framework should provide one we can use. + certBytes, keyBytes, err = internal.NewServerCert() + if err != nil { + return nil, nil, fmt.Errorf("could not generate TLS cert: %w", err) + } + } + cert, err := internal.ParseServerCert(certBytes, keyBytes) + if err != nil { + return nil, nil, fmt.Errorf("could not parse TLS certificate and key: %w", err) + } + clientCertMode := tls.NoClientCert + if len(req.ClientTlsCert) > 0 { + clientCertMode = tls.RequireAndVerifyClientCert + } + tlsConf, err = internal.NewServerTLSConfig(cert, clientCertMode, req.ClientTlsCert) + if err != nil { + return nil, nil, fmt.Errorf("could not create TLS configuration: %w", err) + } + } + var server httpServer + var err error + switch req.HttpVersion { + case conformancev1.HTTPVersion_HTTP_VERSION_1: + server, err = newH1Server(handler, listenAddr, tlsConf) + case conformancev1.HTTPVersion_HTTP_VERSION_2: + server, err = newH2Server(handler, listenAddr, tlsConf) + case conformancev1.HTTPVersion_HTTP_VERSION_3: + server, err = newH3Server(handler, listenAddr, tlsConf) + case conformancev1.HTTPVersion_HTTP_VERSION_UNSPECIFIED: + err = errors.New("an HTTP version must be specified") + } + if err != nil { + return nil, nil, err + } + + return server, certBytes, nil +} + +// newH1Server creates a new HTTP/1.1 server. +func newH1Server(handler http.Handler, listenAddr string, tlsConf *tls.Config) (httpServer, error) { + h1Server := &http.Server{ + Addr: listenAddr, + Handler: handler, + TLSConfig: tlsConf, + ReadHeaderTimeout: 5 * time.Second, + ErrorLog: nopLogger(), + // We disable automatic HTTP/2 support by setting this to non-nil + TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, + } + lis, err := net.Listen("tcp", listenAddr) + if err != nil { + return nil, err + } + return &stdHTTPServer{svr: h1Server, lis: lis}, nil +} + +// newH2Server creates a new HTTP/2 server. +func newH2Server(handler http.Handler, listenAddr string, tlsConf *tls.Config) (httpServer, error) { + h2Server := &http.Server{ + Addr: listenAddr, + Handler: handler, + TLSConfig: tlsConf, + ReadHeaderTimeout: 5 * time.Second, + ErrorLog: nopLogger(), + } + var protocols http.Protocols + protocols.SetUnencryptedHTTP2(true) + protocols.SetHTTP2(true) + h2Server.Protocols = &protocols + lis, err := net.Listen("tcp", listenAddr) + if err != nil { + return nil, err + } + return &stdHTTPServer{svr: h2Server, lis: lis}, nil +} + +// Create a new HTTP/3 server. +func newH3Server(handler http.Handler, listenAddr string, tlsConf *tls.Config) (httpServer, error) { + if tlsConf == nil { + return nil, errors.New("request indicated HTTP/3 without TLS, which is not possible") + } + tlsConf = http3.ConfigureTLSConfig(tlsConf) + h3Server := &http3.Server{ + Addr: listenAddr, + Handler: handler, + TLSConfig: tlsConf, + } + lis, err := quic.ListenAddrEarly(listenAddr, tlsConf, &quic.Config{MaxIdleTimeout: 20 * time.Second, KeepAlivePeriod: 5 * time.Second}) + if err != nil { + return nil, err + } + return &http3Server{svr: h3Server, lis: lis}, nil +} + +type http3Server struct { + svr *http3.Server + lis http3.QUICListener +} + +func (s *http3Server) Serve() error { + return s.svr.ServeListener(s.lis) +} + +func (s *http3Server) Shutdown(ctx context.Context) error { + return s.svr.Shutdown(ctx) +} + +func (s *http3Server) Close() error { + return s.svr.Close() +} + +func (s *http3Server) Addr() string { + return s.lis.Addr().String() +} + +//nolint:forbidigo // must refer to log package in order to suppress it in net/http server +func nopLogger() *log.Logger { + // TODO: enable logging via -v option or env variable? + return log.New(io.Discard, "", 0) +} diff --git a/internal/conformance/internal/cancellation.go b/internal/conformance/internal/cancellation.go new file mode 100644 index 00000000..69918f53 --- /dev/null +++ b/internal/conformance/internal/cancellation.go @@ -0,0 +1,57 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "fmt" + + conformancev1 "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1" + "google.golang.org/protobuf/types/known/emptypb" +) + +type CancelTiming struct { + BeforeCloseSend *emptypb.Empty + AfterCloseSendMs int + AfterNumResponses int +} + +// GetCancelTiming evaluates a Cancel setting and returns a struct with the +// appropriate value set. +func GetCancelTiming(cancel *conformancev1.ClientCompatRequest_Cancel) (*CancelTiming, error) { + var beforeCloseSend *emptypb.Empty + afterCloseSendMs := -1 + afterNumResponses := -1 + if cancel != nil { + switch cancelTiming := cancel.CancelTiming.(type) { + case *conformancev1.ClientCompatRequest_Cancel_BeforeCloseSend: + beforeCloseSend = cancelTiming.BeforeCloseSend + case *conformancev1.ClientCompatRequest_Cancel_AfterCloseSendMs: + afterCloseSendMs = int(cancelTiming.AfterCloseSendMs) + case *conformancev1.ClientCompatRequest_Cancel_AfterNumResponses: + afterNumResponses = int(cancelTiming.AfterNumResponses) + case nil: + // If cancel is non-nil, but none of timing values are set, it should + // be treated as if afterCloseSendMs was set to 0 + afterCloseSendMs = 0 + default: + return nil, fmt.Errorf("provided CancelTiming has an unexpected type %T", cancelTiming) + } + } + return &CancelTiming{ + BeforeCloseSend: beforeCloseSend, + AfterCloseSendMs: afterCloseSendMs, + AfterNumResponses: afterNumResponses, + }, nil +} diff --git a/internal/conformance/internal/codec.go b/internal/conformance/internal/codec.go new file mode 100644 index 00000000..414551a3 --- /dev/null +++ b/internal/conformance/internal/codec.go @@ -0,0 +1,179 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectproto" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +// StreamDecoder is used to decode messages from a stream. This is used +// when the input contains a sequence of messages, not just one. +type StreamDecoder interface { + DecodeNext(msg proto.Message) error +} + +// StreamEncoder is used to encode messages to a stream. This is used +// when the output will contain a sequence of messages, not just one. +type StreamEncoder interface { + Encode(msg proto.Message) error +} + +// Codec describes anything that can marshal and unmarshal proto messages. +type Codec interface { + NewDecoder(io.Reader) StreamDecoder + NewEncoder(io.Writer) StreamEncoder +} + +// NewCodec returns a new Codec. +func NewCodec(json bool) Codec { + if json { + return &jsonCodec{MarshalOptions: protojson.MarshalOptions{Multiline: true}} + } + return &protoCodec{} +} + +// jsonCodec marshals and unmarshals the JSON format. +type jsonCodec struct { + protojson.MarshalOptions + protojson.UnmarshalOptions +} + +func (c *jsonCodec) NewDecoder(in io.Reader) StreamDecoder { + dec := json.NewDecoder(in) + return &jsonDecoder{ + opts: c.UnmarshalOptions, + decoder: dec, + } +} + +func (c *jsonCodec) NewEncoder(out io.Writer) StreamEncoder { + return &jsonEncoder{ + opts: c.MarshalOptions, + out: out, + } +} + +type jsonDecoder struct { + opts protojson.UnmarshalOptions + decoder *json.Decoder +} + +func (j *jsonDecoder) DecodeNext(msg proto.Message) error { + var msgData json.RawMessage + if err := j.decoder.Decode(&msgData); err != nil { + if errors.Is(err, io.EOF) { + return err + } + return fmt.Errorf("failed to decode JSON message from input: %w", err) + } + if err := j.opts.Unmarshal(msgData, msg); err != nil { + return fmt.Errorf("failed to unmarshal JSON message: %w", err) + } + return nil +} + +type jsonEncoder struct { + opts protojson.MarshalOptions + out io.Writer +} + +func (j *jsonEncoder) Encode(msg proto.Message) error { + data, err := j.opts.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message to JSON: %w", err) + } + if _, err := j.out.Write(data); err != nil { + return fmt.Errorf("failed to write message to output: %w", err) + } + if len(data) > 0 || data[len(data)-1] != '\n' { + _, _ = j.out.Write([]byte{'\n'}) // best effort newline between JSON outputs + } + return nil +} + +// protoCodec marshals and unmarshals the Protobuf binary format. +type protoCodec struct { + proto.MarshalOptions + proto.UnmarshalOptions +} + +func (c *protoCodec) NewDecoder(in io.Reader) StreamDecoder { + return &protoDecoder{ + opts: c.UnmarshalOptions, + in: in, + } +} + +func (c *protoCodec) NewEncoder(out io.Writer) StreamEncoder { + return &protoEncoder{ + opts: c.MarshalOptions, + out: out, + } +} + +type protoDecoder struct { + opts proto.UnmarshalOptions + in io.Reader +} + +func (p *protoDecoder) DecodeNext(msg proto.Message) error { + var lenBuffer [4]byte + if _, err := io.ReadFull(p.in, lenBuffer[:]); err != nil { + return err + } + data := make([]byte, binary.BigEndian.Uint32(lenBuffer[:])) + if _, err := io.ReadFull(p.in, data); err != nil { + if errors.Is(err, io.EOF) { + err = io.ErrUnexpectedEOF + } + return err + } + if err := p.opts.Unmarshal(data, msg); err != nil { + return fmt.Errorf("failed to unmarshal binary message: %w", err) + } + return nil +} + +type protoEncoder struct { + opts proto.MarshalOptions + out io.Writer +} + +func (p *protoEncoder) Encode(msg proto.Message) error { + data, err := p.opts.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal response to binary: %w", err) + } + return writeDelimitedMessageRaw(p.out, data) +} + +// NewStrictJSONCodec returns a JSON [connect.Codec] that rejects unrecognized +// fields. Connect's builtin JSON codec discards unknown fields, but the +// conformance suite needs the stricter behavior, so we build on connectproto +// and turn DiscardUnknown off. +func NewStrictJSONCodec() connect.Codec { + codec := connectproto.NewJSONCodec() + codec.UnmarshalOptions.DiscardUnknown = false + return codec +} diff --git a/internal/conformance/internal/compression/compression.go b/internal/conformance/internal/compression/compression.go new file mode 100644 index 00000000..bc7ac7d0 --- /dev/null +++ b/internal/conformance/internal/compression/compression.go @@ -0,0 +1,75 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package compression + +import ( + "fmt" + "io" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectgzip" + conformancev1 "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1" +) + +// The IANA names for supported compression algorithms. +const ( + Identity = "identity" + Gzip = "gzip" + Brotli = "br" + Deflate = "deflate" + Snappy = "snappy" + Zstd = "zstd" +) + +// GetCompressor returns a [connect.Compressor] for the given compression +// algorithm. The v2 reference supports identity and gzip; the remaining +// algorithms are not yet ported. +func GetCompressor(compression conformancev1.Compression) (connect.Compressor, error) { + switch compression { + case conformancev1.Compression_COMPRESSION_UNSPECIFIED, conformancev1.Compression_COMPRESSION_IDENTITY: + return identityCompressor{}, nil + case conformancev1.Compression_COMPRESSION_GZIP: + return connectgzip.New(), nil + default: + return nil, fmt.Errorf("unsupported compression scheme %v", compression) + } +} + +// GetDecompressor returns a [connect.Compressor] for the given compression +// algorithm. In v2 a single type both compresses and decompresses, so this +// mirrors [GetCompressor]. +func GetDecompressor(compression conformancev1.Compression) (connect.Compressor, error) { + return GetCompressor(compression) +} + +// identityCompressor is a no-op [connect.Compressor] that passes bytes through +// unchanged. +type identityCompressor struct{} + +func (identityCompressor) Name() string { return Identity } + +func (identityCompressor) Compress(dst io.Writer) (io.WriteCloser, error) { + return nopWriteCloser{dst}, nil +} + +func (identityCompressor) Decompress(src io.Reader) (io.ReadCloser, error) { + return io.NopCloser(src), nil +} + +type nopWriteCloser struct { + io.Writer +} + +func (nopWriteCloser) Close() error { return nil } diff --git a/internal/conformance/internal/config.go b/internal/conformance/internal/config.go new file mode 100644 index 00000000..4316a420 --- /dev/null +++ b/internal/conformance/internal/config.go @@ -0,0 +1,29 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1/conformancev1connect" + +const ( + // DefaultHost is the default host to use for the server. + DefaultHost = "127.0.0.1" + // DefaultPort is the default port to use for the server. We choose 0 so that + // an ephemeral port is selected by the OS if no port is specified. + DefaultPort = 0 + // The fully-qualified service name for the Conformance Service. + ConformanceServiceName = conformancev1connect.ConformanceServiceName + // The prefix for type URLs used in Any messages. + DefaultAnyResolverPrefix = "type.googleapis.com/" +) diff --git a/internal/conformance/internal/delimited.go b/internal/conformance/internal/delimited.go new file mode 100644 index 00000000..18a74dd6 --- /dev/null +++ b/internal/conformance/internal/delimited.go @@ -0,0 +1,159 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "sync" + "time" + + "google.golang.org/protobuf/proto" +) + +// ReadDelimitedMessage reads the next message from in. This first reads a +// fixed four byte preface, which is a network-encoded (i.e. big-endian) +// 32-bit integer that represents the message size. This then reads a +// number of bytes equal to that size and unmarshals it into msg. +func ReadDelimitedMessage[T proto.Message](in io.Reader, msg T, source string, timeout time.Duration, maxSize int) error { + reader := timeoutDelimitedReader{ + in: in, + source: source, + timeout: timeout, + maxSize: maxSize, + readDone: make(chan struct{}), + } + data, err := reader.readDelimitedMessageRaw() + if err != nil { + return err + } + if err := proto.Unmarshal(data, msg); err != nil { + return fmt.Errorf("failed to unmarshal message: %w", err) + } + return nil +} + +// WriteDelimitedMessage writes msg to out in a way that can be read by ReadDelimitedMessage. +func WriteDelimitedMessage[T proto.Message](out io.Writer, msg T) error { + data, err := proto.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + return writeDelimitedMessageRaw(out, data) +} + +func writeDelimitedMessageRaw(out io.Writer, data []byte) error { + var lenBuffer [4]byte + binary.BigEndian.PutUint32(lenBuffer[:], uint32(len(data))) + if _, err := out.Write(lenBuffer[:]); err != nil { + return err + } + if _, err := out.Write(data); err != nil { + return err + } + return nil +} + +type timeoutDelimitedReader struct { + in io.Reader + source string + timeout time.Duration + maxSize int + readDone chan struct{} + mu sync.Mutex + prefixDone bool + bytesRead, bytesExpecting int +} + +func (r *timeoutDelimitedReader) readDelimitedMessageRaw() ([]byte, error) { + var msgBytes []byte + var readErr error + readDone := make(chan struct{}) + + r.bytesExpecting = 4 // prefix length + go func() { + defer close(readDone) + var data []byte + data, readErr = r.read(4) + if readErr != nil { + return + } + msgSize := int(binary.BigEndian.Uint32(data)) + if msgSize > r.maxSize { + readErr = fmt.Errorf("%s result indicates message size of %d bytes, but should not exceed %d", + r.source, msgSize, r.maxSize) + return + } + r.mu.Lock() + r.prefixDone, r.bytesRead, r.bytesExpecting = true, 0, msgSize + r.mu.Unlock() + msgBytes, readErr = r.read(msgSize) + if errors.Is(readErr, io.EOF) { + readErr = io.ErrUnexpectedEOF + } + }() + + select { + case <-readDone: + return msgBytes, readErr + case <-time.After(r.timeout): + } + r.mu.Lock() + prefixDone, bytesRead, bytesExpecting := r.prefixDone, r.bytesRead, r.bytesExpecting + r.mu.Unlock() + if prefixDone && bytesRead == bytesExpecting { + // Read is actually complete and we are just racing with goroutine closing readDone. + <-readDone + return msgBytes, readErr + } + if !prefixDone && bytesRead == 0 { + // we've read nothing at all, so no details to include + return nil, fmt.Errorf("timed out waiting for result from %s", r.source) + } + var what string + if prefixDone { + what = "message" + } else { + what = "length prefix" + } + return nil, fmt.Errorf("timed out waiting for result from %s: read %d/%d bytes of %s", + r.source, bytesRead, bytesExpecting, what) +} + +func (r *timeoutDelimitedReader) read(numBytes int) ([]byte, error) { + data := make([]byte, numBytes) + var offs int + for { + numRead, err := r.in.Read(data[offs:]) + if offs+numRead == numBytes { + // Done! If n > 0 and err != nil, we can + // ignore the error and subsequent attempt + // to read from in will return it. + return data, nil + } + offs += numRead + r.mu.Lock() + r.bytesRead = offs // update progress as we go + r.mu.Unlock() + if err != nil { + if errors.Is(err, io.EOF) && offs > 0 { + err = io.ErrUnexpectedEOF + } + return nil, err + } + } +} diff --git a/internal/conformance/internal/errors.go b/internal/conformance/internal/errors.go new file mode 100644 index 00000000..8167c36e --- /dev/null +++ b/internal/conformance/internal/errors.go @@ -0,0 +1,104 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "errors" + "fmt" + "strings" + + conformancev1 "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectproto" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" +) + +// ConvertErrorToConnectError converts the given error to a Connect error +// If err is nil, function will also return nil. If err is not +// of type connect.Error, a Connect error of code Unknown is returned. +func ConvertErrorToConnectError(err error) *connect.Error { + if err == nil { + return nil + } + connectErr := new(connect.Error) + if !errors.As(err, &connectErr) { + connectErr = connect.NewError(connect.CodeUnknown, err.Error()) + } + return connectErr +} + +// ConvertErrorToProtoError converts the given error to a proto Error +// If err is nil, function will also return nil. If err is not +// of type connect.Error, a code representing Unknown is returned. +func ConvertErrorToProtoError(err error) *conformancev1.Error { + if err == nil { + return nil + } + connectErr := new(connect.Error) + if !errors.As(err, &connectErr) { + return &conformancev1.Error{ + Code: conformancev1.Code_CODE_UNKNOWN, + Message: proto.String(err.Error()), + } + } + return ConvertConnectToProtoError(connectErr) +} + +// ConvertConnectToProtoError converts the given Connect error to a +// proto Error message. If err is nil, the function will also +// return nil. +func ConvertConnectToProtoError(err *connect.Error) *conformancev1.Error { + if err == nil { + return nil + } + protoErr := &conformancev1.Error{ + Code: conformancev1.Code(int32(err.Code())), + Message: proto.String(err.Message()), + } + details := make([]*anypb.Any, 0, len(err.Details())) + for _, detail := range err.Details() { + details = append(details, connectproto.ErrorDetailToAny(detail)) + } + protoErr.Details = details + return protoErr +} + +// ConvertProtoToConnectError creates a Connect error from the given proto Error message. +func ConvertProtoToConnectError(err *conformancev1.Error) *connect.Error { + if err == nil { + return nil + } + connectErr := connect.Errorf(connect.Code(err.Code), "%s", err.GetMessage()) + for _, detail := range err.Details { + errorDetail, detailErr := connectproto.NewErrorDetail(detail) + if detailErr != nil { + continue + } + connectErr = connectErr.WithDetail(errorDetail) + } + return connectErr +} + +// EnsureFileName ensures that the given error includes the given filename. If it +// does not, it wraps the error in one that does include the filename. This is +// used to ensure that file-system-specific errors have good messages and +// unambiguously indicate which file was the cause of the error. +func EnsureFileName(err error, filename string) error { + if strings.Contains(err.Error(), filename) { + return err // already contains filename, nothing else to do + } + return fmt.Errorf("%s: %w", filename, err) +} diff --git a/internal/conformance/internal/gen/connectrpc/conformance/v1/client_compat.pb.go b/internal/conformance/internal/gen/connectrpc/conformance/v1/client_compat.pb.go new file mode 100644 index 00000000..d03200d1 --- /dev/null +++ b/internal/conformance/internal/gen/connectrpc/conformance/v1/client_compat.pb.go @@ -0,0 +1,945 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: connectrpc/conformance/v1/client_compat.proto + +package conformancev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + anypb "google.golang.org/protobuf/types/known/anypb" + emptypb "google.golang.org/protobuf/types/known/emptypb" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Describes one call the client should make. The client reads +// these from stdin and, for each one, invokes an RPC as directed +// and writes the results (in the form of a ClientCompatResponse +// message) to stdout. +type ClientCompatRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The name of the test that this request is performing. + // When writing test cases, this is a required field. + TestName string `protobuf:"bytes,1,opt,name=test_name,json=testName,proto3" json:"test_name,omitempty"` + // Test suite YAML definitions should NOT set values for these next + // nine fields (fields 2 - 10). They are automatically populated by the test + // runner. If a test is specific to one of these values, it should instead be + // indicated in the test suite itself (where it defines the required + // features and relevant values for these fields). + // + // The HTTP version to use for the test (i.e. HTTP/1.1, HTTP/2, HTTP/3). + HttpVersion HTTPVersion `protobuf:"varint,2,opt,name=http_version,json=httpVersion,proto3,enum=connectrpc.conformance.v1.HTTPVersion" json:"http_version,omitempty"` + // The protocol to use for the test (i.e. Connect, gRPC, gRPC-web). + Protocol Protocol `protobuf:"varint,3,opt,name=protocol,proto3,enum=connectrpc.conformance.v1.Protocol" json:"protocol,omitempty"` + // The codec to use for the test (i.e. JSON, proto/binary). + Codec Codec `protobuf:"varint,4,opt,name=codec,proto3,enum=connectrpc.conformance.v1.Codec" json:"codec,omitempty"` + // The compression to use for the test (i.e. brotli, gzip, identity). + Compression Compression `protobuf:"varint,5,opt,name=compression,proto3,enum=connectrpc.conformance.v1.Compression" json:"compression,omitempty"` + // The server host that this request will be sent to. + Host string `protobuf:"bytes,6,opt,name=host,proto3" json:"host,omitempty"` + // The server port that this request will be sent to. + Port uint32 `protobuf:"varint,7,opt,name=port,proto3" json:"port,omitempty"` + // If non-empty, the server is using TLS. The bytes are the + // server's PEM-encoded certificate, which the client should + // verify and trust. + ServerTlsCert []byte `protobuf:"bytes,8,opt,name=server_tls_cert,json=serverTlsCert,proto3" json:"server_tls_cert,omitempty"` + // If present, the client certificate credentials to use to + // authenticate with the server. This will only be present + // when server_tls_cert is non-empty. + ClientTlsCreds *TLSCreds `protobuf:"bytes,9,opt,name=client_tls_creds,json=clientTlsCreds,proto3" json:"client_tls_creds,omitempty"` + // If non-zero, indicates the maximum size in bytes for a message. + // If the server sends anything larger, the client should reject it. + MessageReceiveLimit uint32 `protobuf:"varint,10,opt,name=message_receive_limit,json=messageReceiveLimit,proto3" json:"message_receive_limit,omitempty"` + // The fully-qualified name of the service this test will interact with. + // If specified, method must also be specified. + // If not specified, defaults to "connectrpc.conformance.v1.ConformanceService". + Service *string `protobuf:"bytes,11,opt,name=service,proto3,oneof" json:"service,omitempty"` + // The method on `service` that will be called. + // If specified, service must also be specified. + // If not specified, the test runner will auto-populate this field based on the stream_type. + Method *string `protobuf:"bytes,12,opt,name=method,proto3,oneof" json:"method,omitempty"` + // The stream type of `method` (i.e. unary, client stream, server stream, full-duplex bidi + // stream, or half-duplex bidi stream). + // When writing test cases, this is a required field. + StreamType StreamType `protobuf:"varint,13,opt,name=stream_type,json=streamType,proto3,enum=connectrpc.conformance.v1.StreamType" json:"stream_type,omitempty"` + // If protocol indicates Connect and stream type indicates + // Unary, this instructs the client to use a GET HTTP method + // when making the request. + UseGetHttpMethod bool `protobuf:"varint,14,opt,name=use_get_http_method,json=useGetHttpMethod,proto3" json:"use_get_http_method,omitempty"` + // Any request headers that should be sent as part of the request. + // These include only custom header metadata. Headers that are + // part of the relevant protocol (such as "content-type", etc) should + // not be stated here. + RequestHeaders []*Header `protobuf:"bytes,15,rep,name=request_headers,json=requestHeaders,proto3" json:"request_headers,omitempty"` + // The actual request messages that will sent to the server. + // The type URL for all entries should be equal to the request type of the + // method. + // There must be exactly one for unary and server stream methods but + // can be zero or more for client and bidi stream methods. + // For client and bidi stream methods, all entries will have the + // same type URL. + RequestMessages []*anypb.Any `protobuf:"bytes,16,rep,name=request_messages,json=requestMessages,proto3" json:"request_messages,omitempty"` + // The timeout, in milliseconds, for the request. This is equivalent to a + // deadline for the request. If unset, there will be no timeout. + TimeoutMs *uint32 `protobuf:"varint,17,opt,name=timeout_ms,json=timeoutMs,proto3,oneof" json:"timeout_ms,omitempty"` + // Wait this many milliseconds before sending a request message. + // For client or bidi stream methods, this delay should be + // applied before each request sent. + RequestDelayMs uint32 `protobuf:"varint,18,opt,name=request_delay_ms,json=requestDelayMs,proto3" json:"request_delay_ms,omitempty"` + // If present, the client should cancel the RPC instead of + // allowing to complete normally. + Cancel *ClientCompatRequest_Cancel `protobuf:"bytes,19,opt,name=cancel,proto3" json:"cancel,omitempty"` + // The following field is only used by the reference client. If + // you are implementing a client under test, you may ignore it + // or respond with an error if the client receives a request where + // it is set. + // + // When this field is present, it defines the actual HTTP request + // that will be sent. The above group of fields must still be + // provided and valid so that the reference client knows how it + // should try to interpret the server's response. + RawRequest *RawHTTPRequest `protobuf:"bytes,20,opt,name=raw_request,json=rawRequest,proto3" json:"raw_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientCompatRequest) Reset() { + *x = ClientCompatRequest{} + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientCompatRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientCompatRequest) ProtoMessage() {} + +func (x *ClientCompatRequest) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientCompatRequest.ProtoReflect.Descriptor instead. +func (*ClientCompatRequest) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_client_compat_proto_rawDescGZIP(), []int{0} +} + +func (x *ClientCompatRequest) GetTestName() string { + if x != nil { + return x.TestName + } + return "" +} + +func (x *ClientCompatRequest) GetHttpVersion() HTTPVersion { + if x != nil { + return x.HttpVersion + } + return HTTPVersion_HTTP_VERSION_UNSPECIFIED +} + +func (x *ClientCompatRequest) GetProtocol() Protocol { + if x != nil { + return x.Protocol + } + return Protocol_PROTOCOL_UNSPECIFIED +} + +func (x *ClientCompatRequest) GetCodec() Codec { + if x != nil { + return x.Codec + } + return Codec_CODEC_UNSPECIFIED +} + +func (x *ClientCompatRequest) GetCompression() Compression { + if x != nil { + return x.Compression + } + return Compression_COMPRESSION_UNSPECIFIED +} + +func (x *ClientCompatRequest) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *ClientCompatRequest) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *ClientCompatRequest) GetServerTlsCert() []byte { + if x != nil { + return x.ServerTlsCert + } + return nil +} + +func (x *ClientCompatRequest) GetClientTlsCreds() *TLSCreds { + if x != nil { + return x.ClientTlsCreds + } + return nil +} + +func (x *ClientCompatRequest) GetMessageReceiveLimit() uint32 { + if x != nil { + return x.MessageReceiveLimit + } + return 0 +} + +func (x *ClientCompatRequest) GetService() string { + if x != nil && x.Service != nil { + return *x.Service + } + return "" +} + +func (x *ClientCompatRequest) GetMethod() string { + if x != nil && x.Method != nil { + return *x.Method + } + return "" +} + +func (x *ClientCompatRequest) GetStreamType() StreamType { + if x != nil { + return x.StreamType + } + return StreamType_STREAM_TYPE_UNSPECIFIED +} + +func (x *ClientCompatRequest) GetUseGetHttpMethod() bool { + if x != nil { + return x.UseGetHttpMethod + } + return false +} + +func (x *ClientCompatRequest) GetRequestHeaders() []*Header { + if x != nil { + return x.RequestHeaders + } + return nil +} + +func (x *ClientCompatRequest) GetRequestMessages() []*anypb.Any { + if x != nil { + return x.RequestMessages + } + return nil +} + +func (x *ClientCompatRequest) GetTimeoutMs() uint32 { + if x != nil && x.TimeoutMs != nil { + return *x.TimeoutMs + } + return 0 +} + +func (x *ClientCompatRequest) GetRequestDelayMs() uint32 { + if x != nil { + return x.RequestDelayMs + } + return 0 +} + +func (x *ClientCompatRequest) GetCancel() *ClientCompatRequest_Cancel { + if x != nil { + return x.Cancel + } + return nil +} + +func (x *ClientCompatRequest) GetRawRequest() *RawHTTPRequest { + if x != nil { + return x.RawRequest + } + return nil +} + +// The outcome of one ClientCompatRequest. +type ClientCompatResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The test name that this response applies to. + TestName string `protobuf:"bytes,1,opt,name=test_name,json=testName,proto3" json:"test_name,omitempty"` + // These fields determine the outcome of the request. + // + // With regards to errors, any unexpected errors that prevent the client from + // issuing the RPC and following the instructions implied by the request can + // be reported as an error. These would be errors creating an RPC client from + // the request parameters or unsupported/illegal values in the request + // (e.g. a unary request that defines zero or multiple request messages). + // + // However, once the RPC is issued, any resulting error should instead be encoded in response. + // + // Types that are valid to be assigned to Result: + // + // *ClientCompatResponse_Response + // *ClientCompatResponse_Error + Result isClientCompatResponse_Result `protobuf_oneof:"result"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientCompatResponse) Reset() { + *x = ClientCompatResponse{} + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientCompatResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientCompatResponse) ProtoMessage() {} + +func (x *ClientCompatResponse) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientCompatResponse.ProtoReflect.Descriptor instead. +func (*ClientCompatResponse) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_client_compat_proto_rawDescGZIP(), []int{1} +} + +func (x *ClientCompatResponse) GetTestName() string { + if x != nil { + return x.TestName + } + return "" +} + +func (x *ClientCompatResponse) GetResult() isClientCompatResponse_Result { + if x != nil { + return x.Result + } + return nil +} + +func (x *ClientCompatResponse) GetResponse() *ClientResponseResult { + if x != nil { + if x, ok := x.Result.(*ClientCompatResponse_Response); ok { + return x.Response + } + } + return nil +} + +func (x *ClientCompatResponse) GetError() *ClientErrorResult { + if x != nil { + if x, ok := x.Result.(*ClientCompatResponse_Error); ok { + return x.Error + } + } + return nil +} + +type isClientCompatResponse_Result interface { + isClientCompatResponse_Result() +} + +type ClientCompatResponse_Response struct { + Response *ClientResponseResult `protobuf:"bytes,2,opt,name=response,proto3,oneof"` +} + +type ClientCompatResponse_Error struct { + Error *ClientErrorResult `protobuf:"bytes,3,opt,name=error,proto3,oneof"` +} + +func (*ClientCompatResponse_Response) isClientCompatResponse_Result() {} + +func (*ClientCompatResponse_Error) isClientCompatResponse_Result() {} + +// The result of a ClientCompatRequest, which may or may not be successful. +// The client will build this message and return it back to the test runner. +type ClientResponseResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // All response headers read from the response. + ResponseHeaders []*Header `protobuf:"bytes,1,rep,name=response_headers,json=responseHeaders,proto3" json:"response_headers,omitempty"` + // Servers should echo back payloads that they received as part of the request. + // This field should contain all the payloads the server echoed back. Note that + // There will be zero-to-one for unary and client stream methods and + // zero-to-many for server and bidi stream methods. + Payloads []*ConformancePayload `protobuf:"bytes,2,rep,name=payloads,proto3" json:"payloads,omitempty"` + // The error received from the actual RPC invocation. Note this is not representative + // of a runtime error and should always be the proto equivalent of a Connect + // or gRPC error. + Error *Error `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + // All response headers read from the response. + ResponseTrailers []*Header `protobuf:"bytes,4,rep,name=response_trailers,json=responseTrailers,proto3" json:"response_trailers,omitempty"` + // The number of messages that were present in the request but that could not be + // sent because an error occurred before finishing the upload. + NumUnsentRequests int32 `protobuf:"varint,5,opt,name=num_unsent_requests,json=numUnsentRequests,proto3" json:"num_unsent_requests,omitempty"` + // The following field is only set by the reference client. It communicates + // the underlying HTTP status code of the server's response. + // If you are implementing a client-under-test, you should ignore this field + // and leave it unset. + HttpStatusCode *int32 `protobuf:"varint,6,opt,name=http_status_code,json=httpStatusCode,proto3,oneof" json:"http_status_code,omitempty"` + // This field is used only by the reference client, and it can be used + // to provide additional feedback about problems observed in the server + // response or in client processing of the response. If non-empty, the test + // case is considered failed even if the result above matches all expectations. + // If you are implementing a client-under-test, you should ignore this field + // and leave it unset. + Feedback []string `protobuf:"bytes,7,rep,name=feedback,proto3" json:"feedback,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientResponseResult) Reset() { + *x = ClientResponseResult{} + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientResponseResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientResponseResult) ProtoMessage() {} + +func (x *ClientResponseResult) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientResponseResult.ProtoReflect.Descriptor instead. +func (*ClientResponseResult) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_client_compat_proto_rawDescGZIP(), []int{2} +} + +func (x *ClientResponseResult) GetResponseHeaders() []*Header { + if x != nil { + return x.ResponseHeaders + } + return nil +} + +func (x *ClientResponseResult) GetPayloads() []*ConformancePayload { + if x != nil { + return x.Payloads + } + return nil +} + +func (x *ClientResponseResult) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +func (x *ClientResponseResult) GetResponseTrailers() []*Header { + if x != nil { + return x.ResponseTrailers + } + return nil +} + +func (x *ClientResponseResult) GetNumUnsentRequests() int32 { + if x != nil { + return x.NumUnsentRequests + } + return 0 +} + +func (x *ClientResponseResult) GetHttpStatusCode() int32 { + if x != nil && x.HttpStatusCode != nil { + return *x.HttpStatusCode + } + return 0 +} + +func (x *ClientResponseResult) GetFeedback() []string { + if x != nil { + return x.Feedback + } + return nil +} + +// The client is not able to fulfill the ClientCompatRequest. This may be due +// to a runtime error or an unexpected internal error such as the requested protocol +// not being supported. This is completely independent of the actual RPC invocation. +type ClientErrorResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + // A message describing the error that occurred. This string will be shown to + // users running conformance tests so it should include any relevant details + // that may help troubleshoot or remedy the error. + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientErrorResult) Reset() { + *x = ClientErrorResult{} + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientErrorResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientErrorResult) ProtoMessage() {} + +func (x *ClientErrorResult) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientErrorResult.ProtoReflect.Descriptor instead. +func (*ClientErrorResult) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_client_compat_proto_rawDescGZIP(), []int{3} +} + +func (x *ClientErrorResult) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// Details about various values as observed on the wire. This message is used +// only by the reference client when reporting results and should not be populated +// by clients under test. +type WireDetails struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The HTTP status code of the response. + ActualStatusCode int32 `protobuf:"varint,1,opt,name=actual_status_code,json=actualStatusCode,proto3" json:"actual_status_code,omitempty"` + // When processing an error from a Connect server, this should contain + // the actual JSON received on the wire. + ConnectErrorRaw *structpb.Struct `protobuf:"bytes,2,opt,name=connect_error_raw,json=connectErrorRaw,proto3" json:"connect_error_raw,omitempty"` + // Any HTTP trailers observed after the response body. These do NOT + // include trailers that conveyed via the body, as done in the gRPC-Web + // and Connect streaming protocols. + ActualHttpTrailers []*Header `protobuf:"bytes,3,rep,name=actual_http_trailers,json=actualHttpTrailers,proto3" json:"actual_http_trailers,omitempty"` + // Any trailers that were transmitted in the final message of the + // response body for a gRPC-Web response. This could differ from the + // ClientResponseResult.response_trailers field since the RPC client + // library might canonicalize keys and it might choose to remove + // "grpc-status" et al from the set of metadata. This field will + // capture all of the entries and their exact on-the-wire spelling + // and formatting. + ActualGrpcwebTrailers *string `protobuf:"bytes,4,opt,name=actual_grpcweb_trailers,json=actualGrpcwebTrailers,proto3,oneof" json:"actual_grpcweb_trailers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WireDetails) Reset() { + *x = WireDetails{} + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WireDetails) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WireDetails) ProtoMessage() {} + +func (x *WireDetails) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WireDetails.ProtoReflect.Descriptor instead. +func (*WireDetails) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_client_compat_proto_rawDescGZIP(), []int{4} +} + +func (x *WireDetails) GetActualStatusCode() int32 { + if x != nil { + return x.ActualStatusCode + } + return 0 +} + +func (x *WireDetails) GetConnectErrorRaw() *structpb.Struct { + if x != nil { + return x.ConnectErrorRaw + } + return nil +} + +func (x *WireDetails) GetActualHttpTrailers() []*Header { + if x != nil { + return x.ActualHttpTrailers + } + return nil +} + +func (x *WireDetails) GetActualGrpcwebTrailers() string { + if x != nil && x.ActualGrpcwebTrailers != nil { + return *x.ActualGrpcwebTrailers + } + return "" +} + +type ClientCompatRequest_Cancel struct { + state protoimpl.MessageState `protogen:"open.v1"` + // These fields determine the timing of cancellation. + // If none are present, the client should cancel immediately + // after all request messages are sent and the send side is + // closed (as if the after_close_send_ms field were present + // and zero). + // + // Types that are valid to be assigned to CancelTiming: + // + // *ClientCompatRequest_Cancel_BeforeCloseSend + // *ClientCompatRequest_Cancel_AfterCloseSendMs + // *ClientCompatRequest_Cancel_AfterNumResponses + CancelTiming isClientCompatRequest_Cancel_CancelTiming `protobuf_oneof:"cancel_timing"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientCompatRequest_Cancel) Reset() { + *x = ClientCompatRequest_Cancel{} + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientCompatRequest_Cancel) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientCompatRequest_Cancel) ProtoMessage() {} + +func (x *ClientCompatRequest_Cancel) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_client_compat_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientCompatRequest_Cancel.ProtoReflect.Descriptor instead. +func (*ClientCompatRequest_Cancel) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_client_compat_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *ClientCompatRequest_Cancel) GetCancelTiming() isClientCompatRequest_Cancel_CancelTiming { + if x != nil { + return x.CancelTiming + } + return nil +} + +func (x *ClientCompatRequest_Cancel) GetBeforeCloseSend() *emptypb.Empty { + if x != nil { + if x, ok := x.CancelTiming.(*ClientCompatRequest_Cancel_BeforeCloseSend); ok { + return x.BeforeCloseSend + } + } + return nil +} + +func (x *ClientCompatRequest_Cancel) GetAfterCloseSendMs() uint32 { + if x != nil { + if x, ok := x.CancelTiming.(*ClientCompatRequest_Cancel_AfterCloseSendMs); ok { + return x.AfterCloseSendMs + } + } + return 0 +} + +func (x *ClientCompatRequest_Cancel) GetAfterNumResponses() uint32 { + if x != nil { + if x, ok := x.CancelTiming.(*ClientCompatRequest_Cancel_AfterNumResponses); ok { + return x.AfterNumResponses + } + } + return 0 +} + +type isClientCompatRequest_Cancel_CancelTiming interface { + isClientCompatRequest_Cancel_CancelTiming() +} + +type ClientCompatRequest_Cancel_BeforeCloseSend struct { + // When present, the client should cancel *instead of* + // closing the send side of the stream, after all requests + // have been sent. + // + // This applies only to client and bidi stream RPCs. + BeforeCloseSend *emptypb.Empty `protobuf:"bytes,1,opt,name=before_close_send,json=beforeCloseSend,proto3,oneof"` +} + +type ClientCompatRequest_Cancel_AfterCloseSendMs struct { + // When present, the client should delay for this many + // milliseconds after closing the send side of the stream + // and then cancel. + // + // This applies to all types of RPCs. + // + // For unary and server stream RPCs, where the API usually + // does not allow explicitly closing the send side, the + // cancellation should be done immediately after invoking + // the RPC (which should implicitly send the one-and-only + // request and then close the send-side). + // + // For APIs where unary RPCs block until the response + // is received, there is no point after the request is + // sent but before a response is received to cancel. So + // the client must arrange for the RPC to be canceled + // asynchronously before invoking the blocking unary call. + AfterCloseSendMs uint32 `protobuf:"varint,2,opt,name=after_close_send_ms,json=afterCloseSendMs,proto3,oneof"` +} + +type ClientCompatRequest_Cancel_AfterNumResponses struct { + // When present, the client should cancel right after + // reading this number of response messages from the stream. + // When present, this will be greater than zero. + // + // This applies only to server and bidi stream RPCs. + AfterNumResponses uint32 `protobuf:"varint,3,opt,name=after_num_responses,json=afterNumResponses,proto3,oneof"` +} + +func (*ClientCompatRequest_Cancel_BeforeCloseSend) isClientCompatRequest_Cancel_CancelTiming() {} + +func (*ClientCompatRequest_Cancel_AfterCloseSendMs) isClientCompatRequest_Cancel_CancelTiming() {} + +func (*ClientCompatRequest_Cancel_AfterNumResponses) isClientCompatRequest_Cancel_CancelTiming() {} + +var File_connectrpc_conformance_v1_client_compat_proto protoreflect.FileDescriptor + +const file_connectrpc_conformance_v1_client_compat_proto_rawDesc = "" + + "\n" + + "-connectrpc/conformance/v1/client_compat.proto\x12\x19connectrpc.conformance.v1\x1a&connectrpc/conformance/v1/config.proto\x1a'connectrpc/conformance/v1/service.proto\x1a\x19google/protobuf/any.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xa7\n" + + "\n" + + "\x13ClientCompatRequest\x12\x1b\n" + + "\ttest_name\x18\x01 \x01(\tR\btestName\x12I\n" + + "\fhttp_version\x18\x02 \x01(\x0e2&.connectrpc.conformance.v1.HTTPVersionR\vhttpVersion\x12?\n" + + "\bprotocol\x18\x03 \x01(\x0e2#.connectrpc.conformance.v1.ProtocolR\bprotocol\x126\n" + + "\x05codec\x18\x04 \x01(\x0e2 .connectrpc.conformance.v1.CodecR\x05codec\x12H\n" + + "\vcompression\x18\x05 \x01(\x0e2&.connectrpc.conformance.v1.CompressionR\vcompression\x12\x12\n" + + "\x04host\x18\x06 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\a \x01(\rR\x04port\x12&\n" + + "\x0fserver_tls_cert\x18\b \x01(\fR\rserverTlsCert\x12M\n" + + "\x10client_tls_creds\x18\t \x01(\v2#.connectrpc.conformance.v1.TLSCredsR\x0eclientTlsCreds\x122\n" + + "\x15message_receive_limit\x18\n" + + " \x01(\rR\x13messageReceiveLimit\x12\x1d\n" + + "\aservice\x18\v \x01(\tH\x00R\aservice\x88\x01\x01\x12\x1b\n" + + "\x06method\x18\f \x01(\tH\x01R\x06method\x88\x01\x01\x12F\n" + + "\vstream_type\x18\r \x01(\x0e2%.connectrpc.conformance.v1.StreamTypeR\n" + + "streamType\x12-\n" + + "\x13use_get_http_method\x18\x0e \x01(\bR\x10useGetHttpMethod\x12J\n" + + "\x0frequest_headers\x18\x0f \x03(\v2!.connectrpc.conformance.v1.HeaderR\x0erequestHeaders\x12?\n" + + "\x10request_messages\x18\x10 \x03(\v2\x14.google.protobuf.AnyR\x0frequestMessages\x12\"\n" + + "\n" + + "timeout_ms\x18\x11 \x01(\rH\x02R\ttimeoutMs\x88\x01\x01\x12(\n" + + "\x10request_delay_ms\x18\x12 \x01(\rR\x0erequestDelayMs\x12M\n" + + "\x06cancel\x18\x13 \x01(\v25.connectrpc.conformance.v1.ClientCompatRequest.CancelR\x06cancel\x12J\n" + + "\vraw_request\x18\x14 \x01(\v2).connectrpc.conformance.v1.RawHTTPRequestR\n" + + "rawRequest\x1a\xc2\x01\n" + + "\x06Cancel\x12D\n" + + "\x11before_close_send\x18\x01 \x01(\v2\x16.google.protobuf.EmptyH\x00R\x0fbeforeCloseSend\x12/\n" + + "\x13after_close_send_ms\x18\x02 \x01(\rH\x00R\x10afterCloseSendMs\x120\n" + + "\x13after_num_responses\x18\x03 \x01(\rH\x00R\x11afterNumResponsesB\x0f\n" + + "\rcancel_timingB\n" + + "\n" + + "\b_serviceB\t\n" + + "\a_methodB\r\n" + + "\v_timeout_ms\"\xd2\x01\n" + + "\x14ClientCompatResponse\x12\x1b\n" + + "\ttest_name\x18\x01 \x01(\tR\btestName\x12M\n" + + "\bresponse\x18\x02 \x01(\v2/.connectrpc.conformance.v1.ClientResponseResultH\x00R\bresponse\x12D\n" + + "\x05error\x18\x03 \x01(\v2,.connectrpc.conformance.v1.ClientErrorResultH\x00R\x05errorB\b\n" + + "\x06result\"\xc7\x03\n" + + "\x14ClientResponseResult\x12L\n" + + "\x10response_headers\x18\x01 \x03(\v2!.connectrpc.conformance.v1.HeaderR\x0fresponseHeaders\x12I\n" + + "\bpayloads\x18\x02 \x03(\v2-.connectrpc.conformance.v1.ConformancePayloadR\bpayloads\x126\n" + + "\x05error\x18\x03 \x01(\v2 .connectrpc.conformance.v1.ErrorR\x05error\x12N\n" + + "\x11response_trailers\x18\x04 \x03(\v2!.connectrpc.conformance.v1.HeaderR\x10responseTrailers\x12.\n" + + "\x13num_unsent_requests\x18\x05 \x01(\x05R\x11numUnsentRequests\x12-\n" + + "\x10http_status_code\x18\x06 \x01(\x05H\x00R\x0ehttpStatusCode\x88\x01\x01\x12\x1a\n" + + "\bfeedback\x18\a \x03(\tR\bfeedbackB\x13\n" + + "\x11_http_status_code\"-\n" + + "\x11ClientErrorResult\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\"\xae\x02\n" + + "\vWireDetails\x12,\n" + + "\x12actual_status_code\x18\x01 \x01(\x05R\x10actualStatusCode\x12C\n" + + "\x11connect_error_raw\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x0fconnectErrorRaw\x12S\n" + + "\x14actual_http_trailers\x18\x03 \x03(\v2!.connectrpc.conformance.v1.HeaderR\x12actualHttpTrailers\x12;\n" + + "\x17actual_grpcweb_trailers\x18\x04 \x01(\tH\x00R\x15actualGrpcwebTrailers\x88\x01\x01B\x1a\n" + + "\x18_actual_grpcweb_trailersB\x9d\x02\n" + + "\x1dcom.connectrpc.conformance.v1B\x11ClientCompatProtoP\x01Zcconnectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1;conformancev1\xa2\x02\x03CCX\xaa\x02\x19Connectrpc.Conformance.V1\xca\x02\x19Connectrpc\\Conformance\\V1\xe2\x02%Connectrpc\\Conformance\\V1\\GPBMetadata\xea\x02\x1bConnectrpc::Conformance::V1b\x06proto3" + +var ( + file_connectrpc_conformance_v1_client_compat_proto_rawDescOnce sync.Once + file_connectrpc_conformance_v1_client_compat_proto_rawDescData []byte +) + +func file_connectrpc_conformance_v1_client_compat_proto_rawDescGZIP() []byte { + file_connectrpc_conformance_v1_client_compat_proto_rawDescOnce.Do(func() { + file_connectrpc_conformance_v1_client_compat_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_connectrpc_conformance_v1_client_compat_proto_rawDesc), len(file_connectrpc_conformance_v1_client_compat_proto_rawDesc))) + }) + return file_connectrpc_conformance_v1_client_compat_proto_rawDescData +} + +var file_connectrpc_conformance_v1_client_compat_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_connectrpc_conformance_v1_client_compat_proto_goTypes = []any{ + (*ClientCompatRequest)(nil), // 0: connectrpc.conformance.v1.ClientCompatRequest + (*ClientCompatResponse)(nil), // 1: connectrpc.conformance.v1.ClientCompatResponse + (*ClientResponseResult)(nil), // 2: connectrpc.conformance.v1.ClientResponseResult + (*ClientErrorResult)(nil), // 3: connectrpc.conformance.v1.ClientErrorResult + (*WireDetails)(nil), // 4: connectrpc.conformance.v1.WireDetails + (*ClientCompatRequest_Cancel)(nil), // 5: connectrpc.conformance.v1.ClientCompatRequest.Cancel + (HTTPVersion)(0), // 6: connectrpc.conformance.v1.HTTPVersion + (Protocol)(0), // 7: connectrpc.conformance.v1.Protocol + (Codec)(0), // 8: connectrpc.conformance.v1.Codec + (Compression)(0), // 9: connectrpc.conformance.v1.Compression + (*TLSCreds)(nil), // 10: connectrpc.conformance.v1.TLSCreds + (StreamType)(0), // 11: connectrpc.conformance.v1.StreamType + (*Header)(nil), // 12: connectrpc.conformance.v1.Header + (*anypb.Any)(nil), // 13: google.protobuf.Any + (*RawHTTPRequest)(nil), // 14: connectrpc.conformance.v1.RawHTTPRequest + (*ConformancePayload)(nil), // 15: connectrpc.conformance.v1.ConformancePayload + (*Error)(nil), // 16: connectrpc.conformance.v1.Error + (*structpb.Struct)(nil), // 17: google.protobuf.Struct + (*emptypb.Empty)(nil), // 18: google.protobuf.Empty +} +var file_connectrpc_conformance_v1_client_compat_proto_depIdxs = []int32{ + 6, // 0: connectrpc.conformance.v1.ClientCompatRequest.http_version:type_name -> connectrpc.conformance.v1.HTTPVersion + 7, // 1: connectrpc.conformance.v1.ClientCompatRequest.protocol:type_name -> connectrpc.conformance.v1.Protocol + 8, // 2: connectrpc.conformance.v1.ClientCompatRequest.codec:type_name -> connectrpc.conformance.v1.Codec + 9, // 3: connectrpc.conformance.v1.ClientCompatRequest.compression:type_name -> connectrpc.conformance.v1.Compression + 10, // 4: connectrpc.conformance.v1.ClientCompatRequest.client_tls_creds:type_name -> connectrpc.conformance.v1.TLSCreds + 11, // 5: connectrpc.conformance.v1.ClientCompatRequest.stream_type:type_name -> connectrpc.conformance.v1.StreamType + 12, // 6: connectrpc.conformance.v1.ClientCompatRequest.request_headers:type_name -> connectrpc.conformance.v1.Header + 13, // 7: connectrpc.conformance.v1.ClientCompatRequest.request_messages:type_name -> google.protobuf.Any + 5, // 8: connectrpc.conformance.v1.ClientCompatRequest.cancel:type_name -> connectrpc.conformance.v1.ClientCompatRequest.Cancel + 14, // 9: connectrpc.conformance.v1.ClientCompatRequest.raw_request:type_name -> connectrpc.conformance.v1.RawHTTPRequest + 2, // 10: connectrpc.conformance.v1.ClientCompatResponse.response:type_name -> connectrpc.conformance.v1.ClientResponseResult + 3, // 11: connectrpc.conformance.v1.ClientCompatResponse.error:type_name -> connectrpc.conformance.v1.ClientErrorResult + 12, // 12: connectrpc.conformance.v1.ClientResponseResult.response_headers:type_name -> connectrpc.conformance.v1.Header + 15, // 13: connectrpc.conformance.v1.ClientResponseResult.payloads:type_name -> connectrpc.conformance.v1.ConformancePayload + 16, // 14: connectrpc.conformance.v1.ClientResponseResult.error:type_name -> connectrpc.conformance.v1.Error + 12, // 15: connectrpc.conformance.v1.ClientResponseResult.response_trailers:type_name -> connectrpc.conformance.v1.Header + 17, // 16: connectrpc.conformance.v1.WireDetails.connect_error_raw:type_name -> google.protobuf.Struct + 12, // 17: connectrpc.conformance.v1.WireDetails.actual_http_trailers:type_name -> connectrpc.conformance.v1.Header + 18, // 18: connectrpc.conformance.v1.ClientCompatRequest.Cancel.before_close_send:type_name -> google.protobuf.Empty + 19, // [19:19] is the sub-list for method output_type + 19, // [19:19] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name +} + +func init() { file_connectrpc_conformance_v1_client_compat_proto_init() } +func file_connectrpc_conformance_v1_client_compat_proto_init() { + if File_connectrpc_conformance_v1_client_compat_proto != nil { + return + } + file_connectrpc_conformance_v1_config_proto_init() + file_connectrpc_conformance_v1_service_proto_init() + file_connectrpc_conformance_v1_client_compat_proto_msgTypes[0].OneofWrappers = []any{} + file_connectrpc_conformance_v1_client_compat_proto_msgTypes[1].OneofWrappers = []any{ + (*ClientCompatResponse_Response)(nil), + (*ClientCompatResponse_Error)(nil), + } + file_connectrpc_conformance_v1_client_compat_proto_msgTypes[2].OneofWrappers = []any{} + file_connectrpc_conformance_v1_client_compat_proto_msgTypes[4].OneofWrappers = []any{} + file_connectrpc_conformance_v1_client_compat_proto_msgTypes[5].OneofWrappers = []any{ + (*ClientCompatRequest_Cancel_BeforeCloseSend)(nil), + (*ClientCompatRequest_Cancel_AfterCloseSendMs)(nil), + (*ClientCompatRequest_Cancel_AfterNumResponses)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_connectrpc_conformance_v1_client_compat_proto_rawDesc), len(file_connectrpc_conformance_v1_client_compat_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_connectrpc_conformance_v1_client_compat_proto_goTypes, + DependencyIndexes: file_connectrpc_conformance_v1_client_compat_proto_depIdxs, + MessageInfos: file_connectrpc_conformance_v1_client_compat_proto_msgTypes, + }.Build() + File_connectrpc_conformance_v1_client_compat_proto = out.File + file_connectrpc_conformance_v1_client_compat_proto_goTypes = nil + file_connectrpc_conformance_v1_client_compat_proto_depIdxs = nil +} diff --git a/internal/conformance/internal/gen/connectrpc/conformance/v1/config.pb.go b/internal/conformance/internal/gen/connectrpc/conformance/v1/config.pb.go new file mode 100644 index 00000000..ae0ab62c --- /dev/null +++ b/internal/conformance/internal/gen/connectrpc/conformance/v1/config.pb.go @@ -0,0 +1,984 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: connectrpc/conformance/v1/config.proto + +package conformancev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type HTTPVersion int32 + +const ( + HTTPVersion_HTTP_VERSION_UNSPECIFIED HTTPVersion = 0 + HTTPVersion_HTTP_VERSION_1 HTTPVersion = 1 + HTTPVersion_HTTP_VERSION_2 HTTPVersion = 2 + HTTPVersion_HTTP_VERSION_3 HTTPVersion = 3 +) + +// Enum value maps for HTTPVersion. +var ( + HTTPVersion_name = map[int32]string{ + 0: "HTTP_VERSION_UNSPECIFIED", + 1: "HTTP_VERSION_1", + 2: "HTTP_VERSION_2", + 3: "HTTP_VERSION_3", + } + HTTPVersion_value = map[string]int32{ + "HTTP_VERSION_UNSPECIFIED": 0, + "HTTP_VERSION_1": 1, + "HTTP_VERSION_2": 2, + "HTTP_VERSION_3": 3, + } +) + +func (x HTTPVersion) Enum() *HTTPVersion { + p := new(HTTPVersion) + *p = x + return p +} + +func (x HTTPVersion) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (HTTPVersion) Descriptor() protoreflect.EnumDescriptor { + return file_connectrpc_conformance_v1_config_proto_enumTypes[0].Descriptor() +} + +func (HTTPVersion) Type() protoreflect.EnumType { + return &file_connectrpc_conformance_v1_config_proto_enumTypes[0] +} + +func (x HTTPVersion) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use HTTPVersion.Descriptor instead. +func (HTTPVersion) EnumDescriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_config_proto_rawDescGZIP(), []int{0} +} + +type Protocol int32 + +const ( + Protocol_PROTOCOL_UNSPECIFIED Protocol = 0 + Protocol_PROTOCOL_CONNECT Protocol = 1 + Protocol_PROTOCOL_GRPC Protocol = 2 + Protocol_PROTOCOL_GRPC_WEB Protocol = 3 +) + +// Enum value maps for Protocol. +var ( + Protocol_name = map[int32]string{ + 0: "PROTOCOL_UNSPECIFIED", + 1: "PROTOCOL_CONNECT", + 2: "PROTOCOL_GRPC", + 3: "PROTOCOL_GRPC_WEB", + } + Protocol_value = map[string]int32{ + "PROTOCOL_UNSPECIFIED": 0, + "PROTOCOL_CONNECT": 1, + "PROTOCOL_GRPC": 2, + "PROTOCOL_GRPC_WEB": 3, + } +) + +func (x Protocol) Enum() *Protocol { + p := new(Protocol) + *p = x + return p +} + +func (x Protocol) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Protocol) Descriptor() protoreflect.EnumDescriptor { + return file_connectrpc_conformance_v1_config_proto_enumTypes[1].Descriptor() +} + +func (Protocol) Type() protoreflect.EnumType { + return &file_connectrpc_conformance_v1_config_proto_enumTypes[1] +} + +func (x Protocol) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Protocol.Descriptor instead. +func (Protocol) EnumDescriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_config_proto_rawDescGZIP(), []int{1} +} + +type Codec int32 + +const ( + Codec_CODEC_UNSPECIFIED Codec = 0 + Codec_CODEC_PROTO Codec = 1 + Codec_CODEC_JSON Codec = 2 + // Deprecated: Marked as deprecated in connectrpc/conformance/v1/config.proto. + Codec_CODEC_TEXT Codec = 3 // not used; will be ignored +) + +// Enum value maps for Codec. +var ( + Codec_name = map[int32]string{ + 0: "CODEC_UNSPECIFIED", + 1: "CODEC_PROTO", + 2: "CODEC_JSON", + 3: "CODEC_TEXT", + } + Codec_value = map[string]int32{ + "CODEC_UNSPECIFIED": 0, + "CODEC_PROTO": 1, + "CODEC_JSON": 2, + "CODEC_TEXT": 3, + } +) + +func (x Codec) Enum() *Codec { + p := new(Codec) + *p = x + return p +} + +func (x Codec) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Codec) Descriptor() protoreflect.EnumDescriptor { + return file_connectrpc_conformance_v1_config_proto_enumTypes[2].Descriptor() +} + +func (Codec) Type() protoreflect.EnumType { + return &file_connectrpc_conformance_v1_config_proto_enumTypes[2] +} + +func (x Codec) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Codec.Descriptor instead. +func (Codec) EnumDescriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_config_proto_rawDescGZIP(), []int{2} +} + +type Compression int32 + +const ( + Compression_COMPRESSION_UNSPECIFIED Compression = 0 + Compression_COMPRESSION_IDENTITY Compression = 1 + Compression_COMPRESSION_GZIP Compression = 2 + Compression_COMPRESSION_BR Compression = 3 + Compression_COMPRESSION_ZSTD Compression = 4 + Compression_COMPRESSION_DEFLATE Compression = 5 + Compression_COMPRESSION_SNAPPY Compression = 6 +) + +// Enum value maps for Compression. +var ( + Compression_name = map[int32]string{ + 0: "COMPRESSION_UNSPECIFIED", + 1: "COMPRESSION_IDENTITY", + 2: "COMPRESSION_GZIP", + 3: "COMPRESSION_BR", + 4: "COMPRESSION_ZSTD", + 5: "COMPRESSION_DEFLATE", + 6: "COMPRESSION_SNAPPY", + } + Compression_value = map[string]int32{ + "COMPRESSION_UNSPECIFIED": 0, + "COMPRESSION_IDENTITY": 1, + "COMPRESSION_GZIP": 2, + "COMPRESSION_BR": 3, + "COMPRESSION_ZSTD": 4, + "COMPRESSION_DEFLATE": 5, + "COMPRESSION_SNAPPY": 6, + } +) + +func (x Compression) Enum() *Compression { + p := new(Compression) + *p = x + return p +} + +func (x Compression) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Compression) Descriptor() protoreflect.EnumDescriptor { + return file_connectrpc_conformance_v1_config_proto_enumTypes[3].Descriptor() +} + +func (Compression) Type() protoreflect.EnumType { + return &file_connectrpc_conformance_v1_config_proto_enumTypes[3] +} + +func (x Compression) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Compression.Descriptor instead. +func (Compression) EnumDescriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_config_proto_rawDescGZIP(), []int{3} +} + +type StreamType int32 + +const ( + StreamType_STREAM_TYPE_UNSPECIFIED StreamType = 0 + StreamType_STREAM_TYPE_UNARY StreamType = 1 + StreamType_STREAM_TYPE_CLIENT_STREAM StreamType = 2 + StreamType_STREAM_TYPE_SERVER_STREAM StreamType = 3 + StreamType_STREAM_TYPE_HALF_DUPLEX_BIDI_STREAM StreamType = 4 + StreamType_STREAM_TYPE_FULL_DUPLEX_BIDI_STREAM StreamType = 5 +) + +// Enum value maps for StreamType. +var ( + StreamType_name = map[int32]string{ + 0: "STREAM_TYPE_UNSPECIFIED", + 1: "STREAM_TYPE_UNARY", + 2: "STREAM_TYPE_CLIENT_STREAM", + 3: "STREAM_TYPE_SERVER_STREAM", + 4: "STREAM_TYPE_HALF_DUPLEX_BIDI_STREAM", + 5: "STREAM_TYPE_FULL_DUPLEX_BIDI_STREAM", + } + StreamType_value = map[string]int32{ + "STREAM_TYPE_UNSPECIFIED": 0, + "STREAM_TYPE_UNARY": 1, + "STREAM_TYPE_CLIENT_STREAM": 2, + "STREAM_TYPE_SERVER_STREAM": 3, + "STREAM_TYPE_HALF_DUPLEX_BIDI_STREAM": 4, + "STREAM_TYPE_FULL_DUPLEX_BIDI_STREAM": 5, + } +) + +func (x StreamType) Enum() *StreamType { + p := new(StreamType) + *p = x + return p +} + +func (x StreamType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (StreamType) Descriptor() protoreflect.EnumDescriptor { + return file_connectrpc_conformance_v1_config_proto_enumTypes[4].Descriptor() +} + +func (StreamType) Type() protoreflect.EnumType { + return &file_connectrpc_conformance_v1_config_proto_enumTypes[4] +} + +func (x StreamType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use StreamType.Descriptor instead. +func (StreamType) EnumDescriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_config_proto_rawDescGZIP(), []int{4} +} + +type Code int32 + +const ( + Code_CODE_UNSPECIFIED Code = 0 + Code_CODE_CANCELED Code = 1 + Code_CODE_UNKNOWN Code = 2 + Code_CODE_INVALID_ARGUMENT Code = 3 + Code_CODE_DEADLINE_EXCEEDED Code = 4 + Code_CODE_NOT_FOUND Code = 5 + Code_CODE_ALREADY_EXISTS Code = 6 + Code_CODE_PERMISSION_DENIED Code = 7 + Code_CODE_RESOURCE_EXHAUSTED Code = 8 + Code_CODE_FAILED_PRECONDITION Code = 9 + Code_CODE_ABORTED Code = 10 + Code_CODE_OUT_OF_RANGE Code = 11 + Code_CODE_UNIMPLEMENTED Code = 12 + Code_CODE_INTERNAL Code = 13 + Code_CODE_UNAVAILABLE Code = 14 + Code_CODE_DATA_LOSS Code = 15 + Code_CODE_UNAUTHENTICATED Code = 16 +) + +// Enum value maps for Code. +var ( + Code_name = map[int32]string{ + 0: "CODE_UNSPECIFIED", + 1: "CODE_CANCELED", + 2: "CODE_UNKNOWN", + 3: "CODE_INVALID_ARGUMENT", + 4: "CODE_DEADLINE_EXCEEDED", + 5: "CODE_NOT_FOUND", + 6: "CODE_ALREADY_EXISTS", + 7: "CODE_PERMISSION_DENIED", + 8: "CODE_RESOURCE_EXHAUSTED", + 9: "CODE_FAILED_PRECONDITION", + 10: "CODE_ABORTED", + 11: "CODE_OUT_OF_RANGE", + 12: "CODE_UNIMPLEMENTED", + 13: "CODE_INTERNAL", + 14: "CODE_UNAVAILABLE", + 15: "CODE_DATA_LOSS", + 16: "CODE_UNAUTHENTICATED", + } + Code_value = map[string]int32{ + "CODE_UNSPECIFIED": 0, + "CODE_CANCELED": 1, + "CODE_UNKNOWN": 2, + "CODE_INVALID_ARGUMENT": 3, + "CODE_DEADLINE_EXCEEDED": 4, + "CODE_NOT_FOUND": 5, + "CODE_ALREADY_EXISTS": 6, + "CODE_PERMISSION_DENIED": 7, + "CODE_RESOURCE_EXHAUSTED": 8, + "CODE_FAILED_PRECONDITION": 9, + "CODE_ABORTED": 10, + "CODE_OUT_OF_RANGE": 11, + "CODE_UNIMPLEMENTED": 12, + "CODE_INTERNAL": 13, + "CODE_UNAVAILABLE": 14, + "CODE_DATA_LOSS": 15, + "CODE_UNAUTHENTICATED": 16, + } +) + +func (x Code) Enum() *Code { + p := new(Code) + *p = x + return p +} + +func (x Code) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Code) Descriptor() protoreflect.EnumDescriptor { + return file_connectrpc_conformance_v1_config_proto_enumTypes[5].Descriptor() +} + +func (Code) Type() protoreflect.EnumType { + return &file_connectrpc_conformance_v1_config_proto_enumTypes[5] +} + +func (x Code) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Code.Descriptor instead. +func (Code) EnumDescriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_config_proto_rawDescGZIP(), []int{5} +} + +// Config defines the configuration for running conformance tests. +// This enumerates all of the "flavors" of the test suite to run. +type Config struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The features supported by the client or server under test. + // This is used to filter the set of test cases that are run. + // If absent, an empty message is used. See Features for more + // on how empty/absent fields are interpreted. + Features *Features `protobuf:"bytes,1,opt,name=features,proto3" json:"features,omitempty"` + // This can indicate additional permutations that are supported + // that might otherwise be excluded based on the above features. + IncludeCases []*ConfigCase `protobuf:"bytes,2,rep,name=include_cases,json=includeCases,proto3" json:"include_cases,omitempty"` + // This can indicates permutations that are not supported even + // though their support might be implied by the above features. + ExcludeCases []*ConfigCase `protobuf:"bytes,3,rep,name=exclude_cases,json=excludeCases,proto3" json:"exclude_cases,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_connectrpc_conformance_v1_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetFeatures() *Features { + if x != nil { + return x.Features + } + return nil +} + +func (x *Config) GetIncludeCases() []*ConfigCase { + if x != nil { + return x.IncludeCases + } + return nil +} + +func (x *Config) GetExcludeCases() []*ConfigCase { + if x != nil { + return x.ExcludeCases + } + return nil +} + +// Features define the feature set that a client or server supports. They are +// used to determine the server configurations and test cases that +// will be run. They are defined in YAML files and are specified as part of the +// --conf flag to the test runner. +type Features struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Supported HTTP versions. + // If empty, HTTP 1.1 and HTTP/2 are assumed. + Versions []HTTPVersion `protobuf:"varint,1,rep,packed,name=versions,proto3,enum=connectrpc.conformance.v1.HTTPVersion" json:"versions,omitempty"` + // Supported protocols. + // If empty, all three are assumed: Connect, gRPC, and gRPC-Web. + Protocols []Protocol `protobuf:"varint,2,rep,packed,name=protocols,proto3,enum=connectrpc.conformance.v1.Protocol" json:"protocols,omitempty"` + // Supported codecs. + // If empty, "proto" and "json" are assumed. + Codecs []Codec `protobuf:"varint,3,rep,packed,name=codecs,proto3,enum=connectrpc.conformance.v1.Codec" json:"codecs,omitempty"` + // Supported compression algorithms. + // If empty, "identity" and "gzip" are assumed. + Compressions []Compression `protobuf:"varint,4,rep,packed,name=compressions,proto3,enum=connectrpc.conformance.v1.Compression" json:"compressions,omitempty"` + // Supported stream types. + // If empty, all stream types are assumed. This is usually for + // clients, since some client environments may not be able to + // support certain kinds of streaming operations, especially + // bidirectional streams. + StreamTypes []StreamType `protobuf:"varint,5,rep,packed,name=stream_types,json=streamTypes,proto3,enum=connectrpc.conformance.v1.StreamType" json:"stream_types,omitempty"` + // Whether H2C (unencrypted, non-TLS HTTP/2 over cleartext) is supported. + // If absent, true is assumed. + SupportsH2C *bool `protobuf:"varint,6,opt,name=supports_h2c,json=supportsH2c,proto3,oneof" json:"supports_h2c,omitempty"` + // Whether TLS is supported. + // If absent, true is assumed. + SupportsTls *bool `protobuf:"varint,7,opt,name=supports_tls,json=supportsTls,proto3,oneof" json:"supports_tls,omitempty"` + // Whether the client supports TLS certificates. + // If absent, false is assumed. This should not be set if + // supports_tls is false. + SupportsTlsClientCerts *bool `protobuf:"varint,8,opt,name=supports_tls_client_certs,json=supportsTlsClientCerts,proto3,oneof" json:"supports_tls_client_certs,omitempty"` + // Whether trailers are supported. + // If absent, true is assumed. If false, implies that gRPC protocol is not allowed. + SupportsTrailers *bool `protobuf:"varint,9,opt,name=supports_trailers,json=supportsTrailers,proto3,oneof" json:"supports_trailers,omitempty"` + // Whether half duplex bidi streams are supported over HTTP/1.1. + // If absent, false is assumed. + SupportsHalfDuplexBidiOverHttp1 *bool `protobuf:"varint,10,opt,name=supports_half_duplex_bidi_over_http1,json=supportsHalfDuplexBidiOverHttp1,proto3,oneof" json:"supports_half_duplex_bidi_over_http1,omitempty"` + // Whether Connect via GET is supported. + // If absent, true is assumed. + SupportsConnectGet *bool `protobuf:"varint,11,opt,name=supports_connect_get,json=supportsConnectGet,proto3,oneof" json:"supports_connect_get,omitempty"` + // Whether a message receive limit is supported. + // If absent, true is assumed. + SupportsMessageReceiveLimit *bool `protobuf:"varint,12,opt,name=supports_message_receive_limit,json=supportsMessageReceiveLimit,proto3,oneof" json:"supports_message_receive_limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Features) Reset() { + *x = Features{} + mi := &file_connectrpc_conformance_v1_config_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Features) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Features) ProtoMessage() {} + +func (x *Features) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_config_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Features.ProtoReflect.Descriptor instead. +func (*Features) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_config_proto_rawDescGZIP(), []int{1} +} + +func (x *Features) GetVersions() []HTTPVersion { + if x != nil { + return x.Versions + } + return nil +} + +func (x *Features) GetProtocols() []Protocol { + if x != nil { + return x.Protocols + } + return nil +} + +func (x *Features) GetCodecs() []Codec { + if x != nil { + return x.Codecs + } + return nil +} + +func (x *Features) GetCompressions() []Compression { + if x != nil { + return x.Compressions + } + return nil +} + +func (x *Features) GetStreamTypes() []StreamType { + if x != nil { + return x.StreamTypes + } + return nil +} + +func (x *Features) GetSupportsH2C() bool { + if x != nil && x.SupportsH2C != nil { + return *x.SupportsH2C + } + return false +} + +func (x *Features) GetSupportsTls() bool { + if x != nil && x.SupportsTls != nil { + return *x.SupportsTls + } + return false +} + +func (x *Features) GetSupportsTlsClientCerts() bool { + if x != nil && x.SupportsTlsClientCerts != nil { + return *x.SupportsTlsClientCerts + } + return false +} + +func (x *Features) GetSupportsTrailers() bool { + if x != nil && x.SupportsTrailers != nil { + return *x.SupportsTrailers + } + return false +} + +func (x *Features) GetSupportsHalfDuplexBidiOverHttp1() bool { + if x != nil && x.SupportsHalfDuplexBidiOverHttp1 != nil { + return *x.SupportsHalfDuplexBidiOverHttp1 + } + return false +} + +func (x *Features) GetSupportsConnectGet() bool { + if x != nil && x.SupportsConnectGet != nil { + return *x.SupportsConnectGet + } + return false +} + +func (x *Features) GetSupportsMessageReceiveLimit() bool { + if x != nil && x.SupportsMessageReceiveLimit != nil { + return *x.SupportsMessageReceiveLimit + } + return false +} + +// ConfigCase represents a single resolved configuration case. When tests are +// run, the Config and the supported features therein are used to compute all +// of the cases relevant to the implementation under test. These configuration +// cases are then used to select which test cases are applicable. +type ConfigCase struct { + state protoimpl.MessageState `protogen:"open.v1"` + // If unspecified, indicates cases for all versions. + Version HTTPVersion `protobuf:"varint,1,opt,name=version,proto3,enum=connectrpc.conformance.v1.HTTPVersion" json:"version,omitempty"` + // If unspecified, indicates cases for all protocols. + Protocol Protocol `protobuf:"varint,2,opt,name=protocol,proto3,enum=connectrpc.conformance.v1.Protocol" json:"protocol,omitempty"` + // If unspecified, indicates cases for all codecs. + Codec Codec `protobuf:"varint,3,opt,name=codec,proto3,enum=connectrpc.conformance.v1.Codec" json:"codec,omitempty"` + // If unspecified, indicates cases for all compression algorithms. + Compression Compression `protobuf:"varint,4,opt,name=compression,proto3,enum=connectrpc.conformance.v1.Compression" json:"compression,omitempty"` + // If unspecified, indicates cases for all stream types. + StreamType StreamType `protobuf:"varint,5,opt,name=stream_type,json=streamType,proto3,enum=connectrpc.conformance.v1.StreamType" json:"stream_type,omitempty"` + // If absent, indicates cases for plaintext (no TLS) but also for + // TLS if features indicate that TLS is supported. + UseTls *bool `protobuf:"varint,6,opt,name=use_tls,json=useTls,proto3,oneof" json:"use_tls,omitempty"` + // If absent, indicates cases without client certs but also cases + // that use client certs if features indicate they are supported. + UseTlsClientCerts *bool `protobuf:"varint,7,opt,name=use_tls_client_certs,json=useTlsClientCerts,proto3,oneof" json:"use_tls_client_certs,omitempty"` + // If absent, indicates cases that do not test message receive + // limits but also cases that do test message receive limits if + // features indicate they are supported. + UseMessageReceiveLimit *bool `protobuf:"varint,8,opt,name=use_message_receive_limit,json=useMessageReceiveLimit,proto3,oneof" json:"use_message_receive_limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigCase) Reset() { + *x = ConfigCase{} + mi := &file_connectrpc_conformance_v1_config_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigCase) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigCase) ProtoMessage() {} + +func (x *ConfigCase) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_config_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConfigCase.ProtoReflect.Descriptor instead. +func (*ConfigCase) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_config_proto_rawDescGZIP(), []int{2} +} + +func (x *ConfigCase) GetVersion() HTTPVersion { + if x != nil { + return x.Version + } + return HTTPVersion_HTTP_VERSION_UNSPECIFIED +} + +func (x *ConfigCase) GetProtocol() Protocol { + if x != nil { + return x.Protocol + } + return Protocol_PROTOCOL_UNSPECIFIED +} + +func (x *ConfigCase) GetCodec() Codec { + if x != nil { + return x.Codec + } + return Codec_CODEC_UNSPECIFIED +} + +func (x *ConfigCase) GetCompression() Compression { + if x != nil { + return x.Compression + } + return Compression_COMPRESSION_UNSPECIFIED +} + +func (x *ConfigCase) GetStreamType() StreamType { + if x != nil { + return x.StreamType + } + return StreamType_STREAM_TYPE_UNSPECIFIED +} + +func (x *ConfigCase) GetUseTls() bool { + if x != nil && x.UseTls != nil { + return *x.UseTls + } + return false +} + +func (x *ConfigCase) GetUseTlsClientCerts() bool { + if x != nil && x.UseTlsClientCerts != nil { + return *x.UseTlsClientCerts + } + return false +} + +func (x *ConfigCase) GetUseMessageReceiveLimit() bool { + if x != nil && x.UseMessageReceiveLimit != nil { + return *x.UseMessageReceiveLimit + } + return false +} + +// TLSCreds represents credentials for TLS. It includes both a +// certificate and corresponding private key. Both are encoded +// in PEM format. +type TLSCreds struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cert []byte `protobuf:"bytes,1,opt,name=cert,proto3" json:"cert,omitempty"` + Key []byte `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TLSCreds) Reset() { + *x = TLSCreds{} + mi := &file_connectrpc_conformance_v1_config_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TLSCreds) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TLSCreds) ProtoMessage() {} + +func (x *TLSCreds) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_config_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TLSCreds.ProtoReflect.Descriptor instead. +func (*TLSCreds) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_config_proto_rawDescGZIP(), []int{3} +} + +func (x *TLSCreds) GetCert() []byte { + if x != nil { + return x.Cert + } + return nil +} + +func (x *TLSCreds) GetKey() []byte { + if x != nil { + return x.Key + } + return nil +} + +var File_connectrpc_conformance_v1_config_proto protoreflect.FileDescriptor + +const file_connectrpc_conformance_v1_config_proto_rawDesc = "" + + "\n" + + "&connectrpc/conformance/v1/config.proto\x12\x19connectrpc.conformance.v1\"\xe1\x01\n" + + "\x06Config\x12?\n" + + "\bfeatures\x18\x01 \x01(\v2#.connectrpc.conformance.v1.FeaturesR\bfeatures\x12J\n" + + "\rinclude_cases\x18\x02 \x03(\v2%.connectrpc.conformance.v1.ConfigCaseR\fincludeCases\x12J\n" + + "\rexclude_cases\x18\x03 \x03(\v2%.connectrpc.conformance.v1.ConfigCaseR\fexcludeCases\"\xb3\a\n" + + "\bFeatures\x12B\n" + + "\bversions\x18\x01 \x03(\x0e2&.connectrpc.conformance.v1.HTTPVersionR\bversions\x12A\n" + + "\tprotocols\x18\x02 \x03(\x0e2#.connectrpc.conformance.v1.ProtocolR\tprotocols\x128\n" + + "\x06codecs\x18\x03 \x03(\x0e2 .connectrpc.conformance.v1.CodecR\x06codecs\x12J\n" + + "\fcompressions\x18\x04 \x03(\x0e2&.connectrpc.conformance.v1.CompressionR\fcompressions\x12H\n" + + "\fstream_types\x18\x05 \x03(\x0e2%.connectrpc.conformance.v1.StreamTypeR\vstreamTypes\x12&\n" + + "\fsupports_h2c\x18\x06 \x01(\bH\x00R\vsupportsH2c\x88\x01\x01\x12&\n" + + "\fsupports_tls\x18\a \x01(\bH\x01R\vsupportsTls\x88\x01\x01\x12>\n" + + "\x19supports_tls_client_certs\x18\b \x01(\bH\x02R\x16supportsTlsClientCerts\x88\x01\x01\x120\n" + + "\x11supports_trailers\x18\t \x01(\bH\x03R\x10supportsTrailers\x88\x01\x01\x12R\n" + + "$supports_half_duplex_bidi_over_http1\x18\n" + + " \x01(\bH\x04R\x1fsupportsHalfDuplexBidiOverHttp1\x88\x01\x01\x125\n" + + "\x14supports_connect_get\x18\v \x01(\bH\x05R\x12supportsConnectGet\x88\x01\x01\x12H\n" + + "\x1esupports_message_receive_limit\x18\f \x01(\bH\x06R\x1bsupportsMessageReceiveLimit\x88\x01\x01B\x0f\n" + + "\r_supports_h2cB\x0f\n" + + "\r_supports_tlsB\x1c\n" + + "\x1a_supports_tls_client_certsB\x14\n" + + "\x12_supports_trailersB'\n" + + "%_supports_half_duplex_bidi_over_http1B\x17\n" + + "\x15_supports_connect_getB!\n" + + "\x1f_supports_message_receive_limit\"\xb0\x04\n" + + "\n" + + "ConfigCase\x12@\n" + + "\aversion\x18\x01 \x01(\x0e2&.connectrpc.conformance.v1.HTTPVersionR\aversion\x12?\n" + + "\bprotocol\x18\x02 \x01(\x0e2#.connectrpc.conformance.v1.ProtocolR\bprotocol\x126\n" + + "\x05codec\x18\x03 \x01(\x0e2 .connectrpc.conformance.v1.CodecR\x05codec\x12H\n" + + "\vcompression\x18\x04 \x01(\x0e2&.connectrpc.conformance.v1.CompressionR\vcompression\x12F\n" + + "\vstream_type\x18\x05 \x01(\x0e2%.connectrpc.conformance.v1.StreamTypeR\n" + + "streamType\x12\x1c\n" + + "\ause_tls\x18\x06 \x01(\bH\x00R\x06useTls\x88\x01\x01\x124\n" + + "\x14use_tls_client_certs\x18\a \x01(\bH\x01R\x11useTlsClientCerts\x88\x01\x01\x12>\n" + + "\x19use_message_receive_limit\x18\b \x01(\bH\x02R\x16useMessageReceiveLimit\x88\x01\x01B\n" + + "\n" + + "\b_use_tlsB\x17\n" + + "\x15_use_tls_client_certsB\x1c\n" + + "\x1a_use_message_receive_limit\"0\n" + + "\bTLSCreds\x12\x12\n" + + "\x04cert\x18\x01 \x01(\fR\x04cert\x12\x10\n" + + "\x03key\x18\x02 \x01(\fR\x03key*g\n" + + "\vHTTPVersion\x12\x1c\n" + + "\x18HTTP_VERSION_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eHTTP_VERSION_1\x10\x01\x12\x12\n" + + "\x0eHTTP_VERSION_2\x10\x02\x12\x12\n" + + "\x0eHTTP_VERSION_3\x10\x03*d\n" + + "\bProtocol\x12\x18\n" + + "\x14PROTOCOL_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10PROTOCOL_CONNECT\x10\x01\x12\x11\n" + + "\rPROTOCOL_GRPC\x10\x02\x12\x15\n" + + "\x11PROTOCOL_GRPC_WEB\x10\x03*S\n" + + "\x05Codec\x12\x15\n" + + "\x11CODEC_UNSPECIFIED\x10\x00\x12\x0f\n" + + "\vCODEC_PROTO\x10\x01\x12\x0e\n" + + "\n" + + "CODEC_JSON\x10\x02\x12\x12\n" + + "\n" + + "CODEC_TEXT\x10\x03\x1a\x02\b\x01*\xb5\x01\n" + + "\vCompression\x12\x1b\n" + + "\x17COMPRESSION_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14COMPRESSION_IDENTITY\x10\x01\x12\x14\n" + + "\x10COMPRESSION_GZIP\x10\x02\x12\x12\n" + + "\x0eCOMPRESSION_BR\x10\x03\x12\x14\n" + + "\x10COMPRESSION_ZSTD\x10\x04\x12\x17\n" + + "\x13COMPRESSION_DEFLATE\x10\x05\x12\x16\n" + + "\x12COMPRESSION_SNAPPY\x10\x06*\xd0\x01\n" + + "\n" + + "StreamType\x12\x1b\n" + + "\x17STREAM_TYPE_UNSPECIFIED\x10\x00\x12\x15\n" + + "\x11STREAM_TYPE_UNARY\x10\x01\x12\x1d\n" + + "\x19STREAM_TYPE_CLIENT_STREAM\x10\x02\x12\x1d\n" + + "\x19STREAM_TYPE_SERVER_STREAM\x10\x03\x12'\n" + + "#STREAM_TYPE_HALF_DUPLEX_BIDI_STREAM\x10\x04\x12'\n" + + "#STREAM_TYPE_FULL_DUPLEX_BIDI_STREAM\x10\x05*\x94\x03\n" + + "\x04Code\x12\x14\n" + + "\x10CODE_UNSPECIFIED\x10\x00\x12\x11\n" + + "\rCODE_CANCELED\x10\x01\x12\x10\n" + + "\fCODE_UNKNOWN\x10\x02\x12\x19\n" + + "\x15CODE_INVALID_ARGUMENT\x10\x03\x12\x1a\n" + + "\x16CODE_DEADLINE_EXCEEDED\x10\x04\x12\x12\n" + + "\x0eCODE_NOT_FOUND\x10\x05\x12\x17\n" + + "\x13CODE_ALREADY_EXISTS\x10\x06\x12\x1a\n" + + "\x16CODE_PERMISSION_DENIED\x10\a\x12\x1b\n" + + "\x17CODE_RESOURCE_EXHAUSTED\x10\b\x12\x1c\n" + + "\x18CODE_FAILED_PRECONDITION\x10\t\x12\x10\n" + + "\fCODE_ABORTED\x10\n" + + "\x12\x15\n" + + "\x11CODE_OUT_OF_RANGE\x10\v\x12\x16\n" + + "\x12CODE_UNIMPLEMENTED\x10\f\x12\x11\n" + + "\rCODE_INTERNAL\x10\r\x12\x14\n" + + "\x10CODE_UNAVAILABLE\x10\x0e\x12\x12\n" + + "\x0eCODE_DATA_LOSS\x10\x0f\x12\x18\n" + + "\x14CODE_UNAUTHENTICATED\x10\x10B\x97\x02\n" + + "\x1dcom.connectrpc.conformance.v1B\vConfigProtoP\x01Zcconnectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1;conformancev1\xa2\x02\x03CCX\xaa\x02\x19Connectrpc.Conformance.V1\xca\x02\x19Connectrpc\\Conformance\\V1\xe2\x02%Connectrpc\\Conformance\\V1\\GPBMetadata\xea\x02\x1bConnectrpc::Conformance::V1b\x06proto3" + +var ( + file_connectrpc_conformance_v1_config_proto_rawDescOnce sync.Once + file_connectrpc_conformance_v1_config_proto_rawDescData []byte +) + +func file_connectrpc_conformance_v1_config_proto_rawDescGZIP() []byte { + file_connectrpc_conformance_v1_config_proto_rawDescOnce.Do(func() { + file_connectrpc_conformance_v1_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_connectrpc_conformance_v1_config_proto_rawDesc), len(file_connectrpc_conformance_v1_config_proto_rawDesc))) + }) + return file_connectrpc_conformance_v1_config_proto_rawDescData +} + +var file_connectrpc_conformance_v1_config_proto_enumTypes = make([]protoimpl.EnumInfo, 6) +var file_connectrpc_conformance_v1_config_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_connectrpc_conformance_v1_config_proto_goTypes = []any{ + (HTTPVersion)(0), // 0: connectrpc.conformance.v1.HTTPVersion + (Protocol)(0), // 1: connectrpc.conformance.v1.Protocol + (Codec)(0), // 2: connectrpc.conformance.v1.Codec + (Compression)(0), // 3: connectrpc.conformance.v1.Compression + (StreamType)(0), // 4: connectrpc.conformance.v1.StreamType + (Code)(0), // 5: connectrpc.conformance.v1.Code + (*Config)(nil), // 6: connectrpc.conformance.v1.Config + (*Features)(nil), // 7: connectrpc.conformance.v1.Features + (*ConfigCase)(nil), // 8: connectrpc.conformance.v1.ConfigCase + (*TLSCreds)(nil), // 9: connectrpc.conformance.v1.TLSCreds +} +var file_connectrpc_conformance_v1_config_proto_depIdxs = []int32{ + 7, // 0: connectrpc.conformance.v1.Config.features:type_name -> connectrpc.conformance.v1.Features + 8, // 1: connectrpc.conformance.v1.Config.include_cases:type_name -> connectrpc.conformance.v1.ConfigCase + 8, // 2: connectrpc.conformance.v1.Config.exclude_cases:type_name -> connectrpc.conformance.v1.ConfigCase + 0, // 3: connectrpc.conformance.v1.Features.versions:type_name -> connectrpc.conformance.v1.HTTPVersion + 1, // 4: connectrpc.conformance.v1.Features.protocols:type_name -> connectrpc.conformance.v1.Protocol + 2, // 5: connectrpc.conformance.v1.Features.codecs:type_name -> connectrpc.conformance.v1.Codec + 3, // 6: connectrpc.conformance.v1.Features.compressions:type_name -> connectrpc.conformance.v1.Compression + 4, // 7: connectrpc.conformance.v1.Features.stream_types:type_name -> connectrpc.conformance.v1.StreamType + 0, // 8: connectrpc.conformance.v1.ConfigCase.version:type_name -> connectrpc.conformance.v1.HTTPVersion + 1, // 9: connectrpc.conformance.v1.ConfigCase.protocol:type_name -> connectrpc.conformance.v1.Protocol + 2, // 10: connectrpc.conformance.v1.ConfigCase.codec:type_name -> connectrpc.conformance.v1.Codec + 3, // 11: connectrpc.conformance.v1.ConfigCase.compression:type_name -> connectrpc.conformance.v1.Compression + 4, // 12: connectrpc.conformance.v1.ConfigCase.stream_type:type_name -> connectrpc.conformance.v1.StreamType + 13, // [13:13] is the sub-list for method output_type + 13, // [13:13] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name +} + +func init() { file_connectrpc_conformance_v1_config_proto_init() } +func file_connectrpc_conformance_v1_config_proto_init() { + if File_connectrpc_conformance_v1_config_proto != nil { + return + } + file_connectrpc_conformance_v1_config_proto_msgTypes[1].OneofWrappers = []any{} + file_connectrpc_conformance_v1_config_proto_msgTypes[2].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_connectrpc_conformance_v1_config_proto_rawDesc), len(file_connectrpc_conformance_v1_config_proto_rawDesc)), + NumEnums: 6, + NumMessages: 4, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_connectrpc_conformance_v1_config_proto_goTypes, + DependencyIndexes: file_connectrpc_conformance_v1_config_proto_depIdxs, + EnumInfos: file_connectrpc_conformance_v1_config_proto_enumTypes, + MessageInfos: file_connectrpc_conformance_v1_config_proto_msgTypes, + }.Build() + File_connectrpc_conformance_v1_config_proto = out.File + file_connectrpc_conformance_v1_config_proto_goTypes = nil + file_connectrpc_conformance_v1_config_proto_depIdxs = nil +} diff --git a/internal/conformance/internal/gen/connectrpc/conformance/v1/conformancev1connect/service.connect.go b/internal/conformance/internal/gen/connectrpc/conformance/v1/conformancev1connect/service.connect.go new file mode 100644 index 00000000..d1afe04b --- /dev/null +++ b/internal/conformance/internal/gen/connectrpc/conformance/v1/conformancev1connect/service.connect.go @@ -0,0 +1,638 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: connectrpc/conformance/v1/service.proto + +package conformancev1connect + +import ( + connect "connectrpc.com/connect/v2" + v1 "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1" + context "context" + sync "sync" +) + +const ( + // ConformanceServiceName is the fully-qualified name of the ConformanceService service. + ConformanceServiceName = "connectrpc.conformance.v1.ConformanceService" +) + +// These constants are the procedure names of the RPCs defined in this package. They're exposed at +// runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // ConformanceServiceUnaryProcedure is the procedure name of the ConformanceService's Unary RPC. + ConformanceServiceUnaryProcedure = "/connectrpc.conformance.v1.ConformanceService/Unary" + // ConformanceServiceServerStreamProcedure is the procedure name of the ConformanceService's + // ServerStream RPC. + ConformanceServiceServerStreamProcedure = "/connectrpc.conformance.v1.ConformanceService/ServerStream" + // ConformanceServiceClientStreamProcedure is the procedure name of the ConformanceService's + // ClientStream RPC. + ConformanceServiceClientStreamProcedure = "/connectrpc.conformance.v1.ConformanceService/ClientStream" + // ConformanceServiceBidiStreamProcedure is the procedure name of the ConformanceService's + // BidiStream RPC. + ConformanceServiceBidiStreamProcedure = "/connectrpc.conformance.v1.ConformanceService/BidiStream" + // ConformanceServiceUnimplementedProcedure is the procedure name of the ConformanceService's + // Unimplemented RPC. + ConformanceServiceUnimplementedProcedure = "/connectrpc.conformance.v1.ConformanceService/Unimplemented" + // ConformanceServiceIdempotentUnaryProcedure is the procedure name of the ConformanceService's + // IdempotentUnary RPC. + ConformanceServiceIdempotentUnaryProcedure = "/connectrpc.conformance.v1.ConformanceService/IdempotentUnary" +) + +var ( + conformanceServiceUnarySpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: v1.File_connectrpc_conformance_v1_service_proto.Services().ByName("ConformanceService").Methods().ByName("Unary"), + Procedure: ConformanceServiceUnaryProcedure, + } + }) + conformanceServiceServerStreamSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeServer, + Schema: v1.File_connectrpc_conformance_v1_service_proto.Services().ByName("ConformanceService").Methods().ByName("ServerStream"), + Procedure: ConformanceServiceServerStreamProcedure, + } + }) + conformanceServiceClientStreamSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeClient, + Schema: v1.File_connectrpc_conformance_v1_service_proto.Services().ByName("ConformanceService").Methods().ByName("ClientStream"), + Procedure: ConformanceServiceClientStreamProcedure, + } + }) + conformanceServiceBidiStreamSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeBidi, + Schema: v1.File_connectrpc_conformance_v1_service_proto.Services().ByName("ConformanceService").Methods().ByName("BidiStream"), + Procedure: ConformanceServiceBidiStreamProcedure, + } + }) + conformanceServiceUnimplementedSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: v1.File_connectrpc_conformance_v1_service_proto.Services().ByName("ConformanceService").Methods().ByName("Unimplemented"), + Procedure: ConformanceServiceUnimplementedProcedure, + } + }) + conformanceServiceIdempotentUnarySpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: v1.File_connectrpc_conformance_v1_service_proto.Services().ByName("ConformanceService").Methods().ByName("IdempotentUnary"), + Procedure: ConformanceServiceIdempotentUnaryProcedure, + IdempotencyLevel: connect.IdempotencyNoSideEffects, + } + }) +) + +// ConformanceServiceClient is a client for the connectrpc.conformance.v1.ConformanceService +// service. +type ConformanceServiceClient interface { + // A unary operation. The request indicates the response headers and trailers + // and also indicates either a response message or an error to send back. + // + // Response message data is specified as bytes. The service should echo back + // request properties in the ConformancePayload and then include the message + // data in the data field. + // + // If the response_delay_ms duration is specified, the server should wait the + // given duration after reading the request before sending the corresponding + // response. + // + // Servers should allow the response definition to be unset in the request and + // if it is, set no response headers or trailers and return no response data. + // The returned payload should only contain the request info. + Unary(context.Context, *v1.UnaryRequest) (*v1.UnaryResponse, error) + // A server-streaming operation. The request indicates the response headers, + // response messages, trailers, and an optional error to send back. The + // response data should be sent in the order indicated, and the server should + // wait between sending response messages as indicated. + // + // Response message data is specified as bytes. The service should echo back + // request properties in the first ConformancePayload, and then include the + // message data in the data field. Subsequent messages after the first one + // should contain only the data field. + // + // Servers should immediately send response headers on the stream before sleeping + // for any specified response delay and/or sending the first message so that + // clients can be unblocked reading response headers. + // + // If a response definition is not specified OR is specified, but response data + // is empty, the server should skip sending anything on the stream. When there + // are no responses to send, servers should throw an error if one is provided + // and return without error if one is not. Stream headers and trailers should + // still be set on the stream if provided regardless of whether a response is + // sent or an error is thrown. + ServerStream(context.Context, *v1.ServerStreamRequest) (ConformanceServiceServerStreamClientStream, error) + // A client-streaming operation. The first request indicates the response + // headers and trailers and also indicates either a response message or an + // error to send back. + // + // Response message data is specified as bytes. The service should echo back + // request properties, including all request messages in the order they were + // received, in the ConformancePayload and then include the message data in + // the data field. + // + // If the input stream is empty, the server's response will include no data, + // only the request properties (headers, timeout). + // + // Servers should only read the response definition from the first message in + // the stream and should ignore any definition set in subsequent messages. + // + // Servers should allow the response definition to be unset in the request and + // if it is, set no response headers or trailers and return no response data. + // The returned payload should only contain the request info. + ClientStream(context.Context) (ConformanceServiceClientStreamClientStream, error) + // A bidirectional-streaming operation. The first request indicates the response + // headers, response messages, trailers, and an optional error to send back. + // The response data should be sent in the order indicated, and the server + // should wait between sending response messages as indicated. + // + // Response message data is specified as bytes and should be included in the + // data field of the ConformancePayload in each response. + // + // Servers should send responses indicated according to the rules of half duplex + // vs. full duplex streams. Once all responses are sent, the server should either + // return an error if specified or close the stream without error. + // + // Servers should immediately send response headers on the stream before sleeping + // for any specified response delay and/or sending the first message so that + // clients can be unblocked reading response headers. + // + // If a response definition is not specified OR is specified, but response data + // is empty, the server should skip sending anything on the stream. Stream + // headers and trailers should always be set on the stream if provided + // regardless of whether a response is sent or an error is thrown. + // + // If the full_duplex field is true: + // - the handler should read one request and then send back one response, and + // then alternate, reading another request and then sending back another response, etc. + // + // - if the server receives a request and has no responses to send, it + // should throw the error specified in the request. + // + // - the service should echo back all request properties in the first response + // including the last received request. Subsequent responses should only + // echo back the last received request. + // + // - if the response_delay_ms duration is specified, the server should wait the given + // duration after reading the request before sending the corresponding + // response. + // + // If the full_duplex field is false: + // - the handler should read all requests until the client is done sending. + // Once all requests are read, the server should then send back any responses + // specified in the response definition. + // + // - the server should echo back all request properties, including all request + // messages in the order they were received, in the first response. Subsequent + // responses should only include the message data in the data field. + // + // - if the response_delay_ms duration is specified, the server should wait that + // long in between sending each response message. + // + BidiStream(context.Context) (ConformanceServiceBidiStreamClientStream, error) + // A unary endpoint that the server should not implement and should instead + // return an unimplemented error when invoked. + Unimplemented(context.Context, *v1.UnimplementedRequest) (*v1.UnimplementedResponse, error) + // A unary endpoint denoted as having no side effects (i.e. idempotent). + // Implementations should use an HTTP GET when invoking this endpoint and + // leverage query parameters to send data. + IdempotentUnary(context.Context, *v1.IdempotentUnaryRequest) (*v1.IdempotentUnaryResponse, error) +} + +// NewConformanceServiceClient constructs a client for the +// connectrpc.conformance.v1.ConformanceService service. Multiple service clients may share a single +// connect.Client. +func NewConformanceServiceClient(client *connect.Client) ConformanceServiceClient { + return &conformanceServiceClient{client: client} +} + +// ConformanceServiceServerStreamClientStream is the client stream for the ConformanceService's +// ServerStream RPC. +type ConformanceServiceServerStreamClientStream struct { + stream connect.ClientStream +} + +// Receive returns the next response message from the server. +func (s ConformanceServiceServerStreamClientStream) Receive() (*v1.ServerStreamResponse, error) { + var res v1.ServerStreamResponse + if err := s.stream.Receive(&res); err != nil { + return nil, err + } + return &res, nil +} + +// Close releases the stream's resources. It is idempotent and is typically deferred to clean up a +// stream abandoned before io.EOF. +func (s ConformanceServiceServerStreamClientStream) Close() error { + return s.stream.Close() +} + +// ConformanceServiceClientStreamClientStream is the client stream for the ConformanceService's +// ClientStream RPC. +type ConformanceServiceClientStreamClientStream struct { + stream connect.ClientStream +} + +// SendHeaders opens the stream and flushes the request headers without a message. The first Send or +// Receive does this implicitly. +func (s ConformanceServiceClientStreamClientStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a request message to the server. +func (s ConformanceServiceClientStreamClientStream) Send(req *v1.ClientStreamRequest) error { + return s.stream.Send(req) +} + +// CloseAndReceive closes the request side of the stream and returns the single response message. It +// reads the stream to completion to release its resources. +func (s ConformanceServiceClientStreamClientStream) CloseAndReceive() (*v1.ClientStreamResponse, error) { + if err := s.stream.CloseSend(); err != nil { + return nil, err + } + var res v1.ClientStreamResponse + if err := s.stream.Receive(&res); err != nil { + return nil, err + } + return &res, nil +} + +// ConformanceServiceBidiStreamClientStream is the client stream for the ConformanceService's +// BidiStream RPC. +type ConformanceServiceBidiStreamClientStream struct { + stream connect.ClientStream +} + +// SendHeaders opens the stream and flushes the request headers without a message. The first Send or +// Receive does this implicitly. +func (s ConformanceServiceBidiStreamClientStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a request message to the server. +func (s ConformanceServiceBidiStreamClientStream) Send(req *v1.BidiStreamRequest) error { + return s.stream.Send(req) +} + +// CloseSend closes the request side of the stream. +func (s ConformanceServiceBidiStreamClientStream) CloseSend() error { + return s.stream.CloseSend() +} + +// Receive returns the next response message from the server. +func (s ConformanceServiceBidiStreamClientStream) Receive() (*v1.BidiStreamResponse, error) { + var res v1.BidiStreamResponse + if err := s.stream.Receive(&res); err != nil { + return nil, err + } + return &res, nil +} + +// Close releases the stream's resources. It is idempotent and is typically deferred to clean up a +// stream abandoned before io.EOF. +func (s ConformanceServiceBidiStreamClientStream) Close() error { + return s.stream.Close() +} + +// ConformanceServiceHandler is an implementation of the +// connectrpc.conformance.v1.ConformanceService service. +type ConformanceServiceHandler interface { + // A unary operation. The request indicates the response headers and trailers + // and also indicates either a response message or an error to send back. + // + // Response message data is specified as bytes. The service should echo back + // request properties in the ConformancePayload and then include the message + // data in the data field. + // + // If the response_delay_ms duration is specified, the server should wait the + // given duration after reading the request before sending the corresponding + // response. + // + // Servers should allow the response definition to be unset in the request and + // if it is, set no response headers or trailers and return no response data. + // The returned payload should only contain the request info. + Unary(context.Context, *v1.UnaryRequest) (*v1.UnaryResponse, error) + // A server-streaming operation. The request indicates the response headers, + // response messages, trailers, and an optional error to send back. The + // response data should be sent in the order indicated, and the server should + // wait between sending response messages as indicated. + // + // Response message data is specified as bytes. The service should echo back + // request properties in the first ConformancePayload, and then include the + // message data in the data field. Subsequent messages after the first one + // should contain only the data field. + // + // Servers should immediately send response headers on the stream before sleeping + // for any specified response delay and/or sending the first message so that + // clients can be unblocked reading response headers. + // + // If a response definition is not specified OR is specified, but response data + // is empty, the server should skip sending anything on the stream. When there + // are no responses to send, servers should throw an error if one is provided + // and return without error if one is not. Stream headers and trailers should + // still be set on the stream if provided regardless of whether a response is + // sent or an error is thrown. + ServerStream(context.Context, *v1.ServerStreamRequest, ConformanceServiceServerStreamServerStream) error + // A client-streaming operation. The first request indicates the response + // headers and trailers and also indicates either a response message or an + // error to send back. + // + // Response message data is specified as bytes. The service should echo back + // request properties, including all request messages in the order they were + // received, in the ConformancePayload and then include the message data in + // the data field. + // + // If the input stream is empty, the server's response will include no data, + // only the request properties (headers, timeout). + // + // Servers should only read the response definition from the first message in + // the stream and should ignore any definition set in subsequent messages. + // + // Servers should allow the response definition to be unset in the request and + // if it is, set no response headers or trailers and return no response data. + // The returned payload should only contain the request info. + ClientStream(context.Context, ConformanceServiceClientStreamServerStream) (*v1.ClientStreamResponse, error) + // A bidirectional-streaming operation. The first request indicates the response + // headers, response messages, trailers, and an optional error to send back. + // The response data should be sent in the order indicated, and the server + // should wait between sending response messages as indicated. + // + // Response message data is specified as bytes and should be included in the + // data field of the ConformancePayload in each response. + // + // Servers should send responses indicated according to the rules of half duplex + // vs. full duplex streams. Once all responses are sent, the server should either + // return an error if specified or close the stream without error. + // + // Servers should immediately send response headers on the stream before sleeping + // for any specified response delay and/or sending the first message so that + // clients can be unblocked reading response headers. + // + // If a response definition is not specified OR is specified, but response data + // is empty, the server should skip sending anything on the stream. Stream + // headers and trailers should always be set on the stream if provided + // regardless of whether a response is sent or an error is thrown. + // + // If the full_duplex field is true: + // - the handler should read one request and then send back one response, and + // then alternate, reading another request and then sending back another response, etc. + // + // - if the server receives a request and has no responses to send, it + // should throw the error specified in the request. + // + // - the service should echo back all request properties in the first response + // including the last received request. Subsequent responses should only + // echo back the last received request. + // + // - if the response_delay_ms duration is specified, the server should wait the given + // duration after reading the request before sending the corresponding + // response. + // + // If the full_duplex field is false: + // - the handler should read all requests until the client is done sending. + // Once all requests are read, the server should then send back any responses + // specified in the response definition. + // + // - the server should echo back all request properties, including all request + // messages in the order they were received, in the first response. Subsequent + // responses should only include the message data in the data field. + // + // - if the response_delay_ms duration is specified, the server should wait that + // long in between sending each response message. + // + BidiStream(context.Context, ConformanceServiceBidiStreamServerStream) error + // A unary endpoint that the server should not implement and should instead + // return an unimplemented error when invoked. + Unimplemented(context.Context, *v1.UnimplementedRequest) (*v1.UnimplementedResponse, error) + // A unary endpoint denoted as having no side effects (i.e. idempotent). + // Implementations should use an HTTP GET when invoking this endpoint and + // leverage query parameters to send data. + IdempotentUnary(context.Context, *v1.IdempotentUnaryRequest) (*v1.IdempotentUnaryResponse, error) +} + +// RegisterConformanceServiceHandler registers svc as the +// connectrpc.conformance.v1.ConformanceService implementation on server. +func RegisterConformanceServiceHandler(server *connect.Server, svc ConformanceServiceHandler) { + adapter := conformanceServiceHandler{svc: svc} + server.Register( + connect.Method{Spec: conformanceServiceUnarySpec(), Handler: adapter.unary}, + connect.Method{Spec: conformanceServiceServerStreamSpec(), Handler: adapter.serverStream}, + connect.Method{Spec: conformanceServiceClientStreamSpec(), Handler: adapter.clientStream}, + connect.Method{Spec: conformanceServiceBidiStreamSpec(), Handler: adapter.bidiStream}, + connect.Method{Spec: conformanceServiceUnimplementedSpec(), Handler: adapter.unimplemented}, + connect.Method{Spec: conformanceServiceIdempotentUnarySpec(), Handler: adapter.idempotentUnary}, + ) +} + +// ConformanceServiceServerStreamServerStream is the server stream for the ConformanceService's +// ServerStream RPC. +type ConformanceServiceServerStreamServerStream struct { + stream connect.ServerStream +} + +// SendHeaders flushes the response headers without a message. The first Send does this implicitly. +func (s ConformanceServiceServerStreamServerStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a response message to the client. +func (s ConformanceServiceServerStreamServerStream) Send(res *v1.ServerStreamResponse) error { + return s.stream.Send(res) +} + +// ConformanceServiceClientStreamServerStream is the server stream for the ConformanceService's +// ClientStream RPC. +type ConformanceServiceClientStreamServerStream struct { + stream connect.ServerStream +} + +// Receive returns the next request message from the client. +func (s ConformanceServiceClientStreamServerStream) Receive() (*v1.ClientStreamRequest, error) { + var req v1.ClientStreamRequest + if err := s.stream.Receive(&req); err != nil { + return nil, err + } + return &req, nil +} + +// ConformanceServiceBidiStreamServerStream is the server stream for the ConformanceService's +// BidiStream RPC. +type ConformanceServiceBidiStreamServerStream struct { + stream connect.ServerStream +} + +// Receive returns the next request message from the client. +func (s ConformanceServiceBidiStreamServerStream) Receive() (*v1.BidiStreamRequest, error) { + var req v1.BidiStreamRequest + if err := s.stream.Receive(&req); err != nil { + return nil, err + } + return &req, nil +} + +// SendHeaders flushes the response headers without a message. The first Send does this implicitly. +func (s ConformanceServiceBidiStreamServerStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a response message to the client. +func (s ConformanceServiceBidiStreamServerStream) Send(res *v1.BidiStreamResponse) error { + return s.stream.Send(res) +} + +// UnimplementedConformanceServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedConformanceServiceHandler struct{} + +func (UnimplementedConformanceServiceHandler) Unary(context.Context, *v1.UnaryRequest) (*v1.UnaryResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connectrpc.conformance.v1.ConformanceService.Unary is not implemented") +} + +func (UnimplementedConformanceServiceHandler) ServerStream(context.Context, *v1.ServerStreamRequest, ConformanceServiceServerStreamServerStream) error { + return connect.NewError(connect.CodeUnimplemented, "connectrpc.conformance.v1.ConformanceService.ServerStream is not implemented") +} + +func (UnimplementedConformanceServiceHandler) ClientStream(context.Context, ConformanceServiceClientStreamServerStream) (*v1.ClientStreamResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connectrpc.conformance.v1.ConformanceService.ClientStream is not implemented") +} + +func (UnimplementedConformanceServiceHandler) BidiStream(context.Context, ConformanceServiceBidiStreamServerStream) error { + return connect.NewError(connect.CodeUnimplemented, "connectrpc.conformance.v1.ConformanceService.BidiStream is not implemented") +} + +func (UnimplementedConformanceServiceHandler) Unimplemented(context.Context, *v1.UnimplementedRequest) (*v1.UnimplementedResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connectrpc.conformance.v1.ConformanceService.Unimplemented is not implemented") +} + +func (UnimplementedConformanceServiceHandler) IdempotentUnary(context.Context, *v1.IdempotentUnaryRequest) (*v1.IdempotentUnaryResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connectrpc.conformance.v1.ConformanceService.IdempotentUnary is not implemented") +} + +type conformanceServiceClient struct { + client *connect.Client +} + +func (c *conformanceServiceClient) Unary(ctx context.Context, req *v1.UnaryRequest) (*v1.UnaryResponse, error) { + var res v1.UnaryResponse + if err := c.client.CallUnary(ctx, conformanceServiceUnarySpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c *conformanceServiceClient) ServerStream(ctx context.Context, req *v1.ServerStreamRequest) (ConformanceServiceServerStreamClientStream, error) { + stream, err := c.client.CallServerStream(ctx, conformanceServiceServerStreamSpec(), req) + if err != nil { + return ConformanceServiceServerStreamClientStream{}, err + } + return ConformanceServiceServerStreamClientStream{stream: stream}, nil +} + +func (c *conformanceServiceClient) ClientStream(ctx context.Context) (ConformanceServiceClientStreamClientStream, error) { + stream, err := c.client.CallClientStream(ctx, conformanceServiceClientStreamSpec()) + if err != nil { + return ConformanceServiceClientStreamClientStream{}, err + } + return ConformanceServiceClientStreamClientStream{stream: stream}, nil +} + +func (c *conformanceServiceClient) BidiStream(ctx context.Context) (ConformanceServiceBidiStreamClientStream, error) { + stream, err := c.client.CallClientStream(ctx, conformanceServiceBidiStreamSpec()) + if err != nil { + return ConformanceServiceBidiStreamClientStream{}, err + } + return ConformanceServiceBidiStreamClientStream{stream: stream}, nil +} + +func (c *conformanceServiceClient) Unimplemented(ctx context.Context, req *v1.UnimplementedRequest) (*v1.UnimplementedResponse, error) { + var res v1.UnimplementedResponse + if err := c.client.CallUnary(ctx, conformanceServiceUnimplementedSpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c *conformanceServiceClient) IdempotentUnary(ctx context.Context, req *v1.IdempotentUnaryRequest) (*v1.IdempotentUnaryResponse, error) { + var res v1.IdempotentUnaryResponse + if err := c.client.CallUnary(ctx, conformanceServiceIdempotentUnarySpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +type conformanceServiceHandler struct{ svc ConformanceServiceHandler } + +func (h conformanceServiceHandler) unary(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.UnaryRequest + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.Unary(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) +} + +func (h conformanceServiceHandler) serverStream(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.ServerStreamRequest + if err := stream.Receive(&req); err != nil { + return err + } + return h.svc.ServerStream(ctx, &req, ConformanceServiceServerStreamServerStream{stream: stream}) +} + +func (h conformanceServiceHandler) clientStream(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + res, err := h.svc.ClientStream(ctx, ConformanceServiceClientStreamServerStream{stream: stream}) + if err != nil { + return err + } + return stream.Send(res) +} + +func (h conformanceServiceHandler) bidiStream(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + return h.svc.BidiStream(ctx, ConformanceServiceBidiStreamServerStream{stream: stream}) +} + +func (h conformanceServiceHandler) unimplemented(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.UnimplementedRequest + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.Unimplemented(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) +} + +func (h conformanceServiceHandler) idempotentUnary(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.IdempotentUnaryRequest + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.IdempotentUnary(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) +} diff --git a/internal/conformance/internal/gen/connectrpc/conformance/v1/server_compat.pb.go b/internal/conformance/internal/gen/connectrpc/conformance/v1/server_compat.pb.go new file mode 100644 index 00000000..e5363d3c --- /dev/null +++ b/internal/conformance/internal/gen/connectrpc/conformance/v1/server_compat.pb.go @@ -0,0 +1,316 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: connectrpc/conformance/v1/server_compat.proto + +package conformancev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Describes one configuration for an RPC server. The server is +// expected to expose the connectrpc.conformance.v1.ConformanceService +// RPC service. The configuration does not include a port. The +// process should pick an available port, which is typically +// done by using port zero (0) when creating a network listener +// so that the OS selects an available ephemeral port. +// +// These properties are read from stdin. Once the server is +// listening, details about the server, in the form of a +// ServerCompatResponse, are written to stdout. +// +// Each test process is expected to start only one RPC server. +// When testing multiple configurations, multiple test processes +// will be started, each with different properties. +type ServerCompatRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Signals to the server that it must support at least this protocol. Note + // that it is fine to support others. + // For example if `PROTOCOL_CONNECT` is specified, the server _must_ support + // at least Connect, but _may_ also support gRPC or gRPC-web. + Protocol Protocol `protobuf:"varint,1,opt,name=protocol,proto3,enum=connectrpc.conformance.v1.Protocol" json:"protocol,omitempty"` + // Signals to the server the minimum HTTP version to support. As with + // `protocol`, it is fine to support other versions. For example, if + // `HTTP_VERSION_2` is specified, the server _must_ support HTTP/2, but _may_ also + // support HTTP/1.1 or HTTP/3. + HttpVersion HTTPVersion `protobuf:"varint,2,opt,name=http_version,json=httpVersion,proto3,enum=connectrpc.conformance.v1.HTTPVersion" json:"http_version,omitempty"` + // If true, generate a certificate that clients will be configured to trust + // when connecting and return it in the `pem_cert` field of the `ServerCompatResponse`. + // The certificate can be any TLS certificate where the subject matches the + // value sent back in the `host` field of the `ServerCompatResponse`. + // Self-signed certificates (and `localhost` as the subject) are allowed. + // If false, the server should not use TLS and instead use + // a plain-text/unencrypted socket. + UseTls bool `protobuf:"varint,4,opt,name=use_tls,json=useTls,proto3" json:"use_tls,omitempty"` + // If non-empty, the clients will use certificates to authenticate + // themselves. This value is a PEM-encoded cert that should be + // trusted by the server. When non-empty, the server should require + // that clients provide certificates and they should validate that + // the certificate presented is valid. + // + // This will always be empty if use_tls is false. + ClientTlsCert []byte `protobuf:"bytes,5,opt,name=client_tls_cert,json=clientTlsCert,proto3" json:"client_tls_cert,omitempty"` + // If non-zero, indicates the maximum size in bytes for a message. + // If the client sends anything larger, the server should reject it. + MessageReceiveLimit uint32 `protobuf:"varint,6,opt,name=message_receive_limit,json=messageReceiveLimit,proto3" json:"message_receive_limit,omitempty"` + // If use_tls is true, this provides details for a self-signed TLS + // cert that the server may use. + // + // The provided certificate is only good for loopback communication: + // it uses "localhost" and "127.0.0.1" as the IP and DNS names in + // the certificate's subject. If the server needs a different subject + // or the client is in an environment where configuring trust of a + // self-signed certificate is difficult or infeasible. + // + // If the server implementation chooses to use these credentials, + // it must echo back the certificate in the ServerCompatResponse and + // should also leave the host field empty or explicitly set to + // "127.0.0.1". + // + // If it chooses to use a different certificate and key, it must send + // back the corresponding certificate in the ServerCompatResponse. + ServerCreds *TLSCreds `protobuf:"bytes,7,opt,name=server_creds,json=serverCreds,proto3" json:"server_creds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServerCompatRequest) Reset() { + *x = ServerCompatRequest{} + mi := &file_connectrpc_conformance_v1_server_compat_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServerCompatRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServerCompatRequest) ProtoMessage() {} + +func (x *ServerCompatRequest) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_server_compat_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServerCompatRequest.ProtoReflect.Descriptor instead. +func (*ServerCompatRequest) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_server_compat_proto_rawDescGZIP(), []int{0} +} + +func (x *ServerCompatRequest) GetProtocol() Protocol { + if x != nil { + return x.Protocol + } + return Protocol_PROTOCOL_UNSPECIFIED +} + +func (x *ServerCompatRequest) GetHttpVersion() HTTPVersion { + if x != nil { + return x.HttpVersion + } + return HTTPVersion_HTTP_VERSION_UNSPECIFIED +} + +func (x *ServerCompatRequest) GetUseTls() bool { + if x != nil { + return x.UseTls + } + return false +} + +func (x *ServerCompatRequest) GetClientTlsCert() []byte { + if x != nil { + return x.ClientTlsCert + } + return nil +} + +func (x *ServerCompatRequest) GetMessageReceiveLimit() uint32 { + if x != nil { + return x.MessageReceiveLimit + } + return 0 +} + +func (x *ServerCompatRequest) GetServerCreds() *TLSCreds { + if x != nil { + return x.ServerCreds + } + return nil +} + +// The outcome of one ServerCompatRequest. +type ServerCompatResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The host where the server is running. This should usually be `127.0.0.1`, + // unless your program actually starts a remote server to which the client + // should connect. + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + // The port where the server is listening. + Port uint32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"` + // The TLS certificate, in PEM format, if `use_tls` was set + // to `true`. Clients will verify this certificate when connecting via TLS. + // If `use_tls` was set to `false`, this should always be empty. + PemCert []byte `protobuf:"bytes,3,opt,name=pem_cert,json=pemCert,proto3" json:"pem_cert,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServerCompatResponse) Reset() { + *x = ServerCompatResponse{} + mi := &file_connectrpc_conformance_v1_server_compat_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServerCompatResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServerCompatResponse) ProtoMessage() {} + +func (x *ServerCompatResponse) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_server_compat_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServerCompatResponse.ProtoReflect.Descriptor instead. +func (*ServerCompatResponse) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_server_compat_proto_rawDescGZIP(), []int{1} +} + +func (x *ServerCompatResponse) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *ServerCompatResponse) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *ServerCompatResponse) GetPemCert() []byte { + if x != nil { + return x.PemCert + } + return nil +} + +var File_connectrpc_conformance_v1_server_compat_proto protoreflect.FileDescriptor + +const file_connectrpc_conformance_v1_server_compat_proto_rawDesc = "" + + "\n" + + "-connectrpc/conformance/v1/server_compat.proto\x12\x19connectrpc.conformance.v1\x1a&connectrpc/conformance/v1/config.proto\"\xde\x02\n" + + "\x13ServerCompatRequest\x12?\n" + + "\bprotocol\x18\x01 \x01(\x0e2#.connectrpc.conformance.v1.ProtocolR\bprotocol\x12I\n" + + "\fhttp_version\x18\x02 \x01(\x0e2&.connectrpc.conformance.v1.HTTPVersionR\vhttpVersion\x12\x17\n" + + "\ause_tls\x18\x04 \x01(\bR\x06useTls\x12&\n" + + "\x0fclient_tls_cert\x18\x05 \x01(\fR\rclientTlsCert\x122\n" + + "\x15message_receive_limit\x18\x06 \x01(\rR\x13messageReceiveLimit\x12F\n" + + "\fserver_creds\x18\a \x01(\v2#.connectrpc.conformance.v1.TLSCredsR\vserverCreds\"Y\n" + + "\x14ServerCompatResponse\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x12\n" + + "\x04port\x18\x02 \x01(\rR\x04port\x12\x19\n" + + "\bpem_cert\x18\x03 \x01(\fR\apemCertB\x9d\x02\n" + + "\x1dcom.connectrpc.conformance.v1B\x11ServerCompatProtoP\x01Zcconnectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1;conformancev1\xa2\x02\x03CCX\xaa\x02\x19Connectrpc.Conformance.V1\xca\x02\x19Connectrpc\\Conformance\\V1\xe2\x02%Connectrpc\\Conformance\\V1\\GPBMetadata\xea\x02\x1bConnectrpc::Conformance::V1b\x06proto3" + +var ( + file_connectrpc_conformance_v1_server_compat_proto_rawDescOnce sync.Once + file_connectrpc_conformance_v1_server_compat_proto_rawDescData []byte +) + +func file_connectrpc_conformance_v1_server_compat_proto_rawDescGZIP() []byte { + file_connectrpc_conformance_v1_server_compat_proto_rawDescOnce.Do(func() { + file_connectrpc_conformance_v1_server_compat_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_connectrpc_conformance_v1_server_compat_proto_rawDesc), len(file_connectrpc_conformance_v1_server_compat_proto_rawDesc))) + }) + return file_connectrpc_conformance_v1_server_compat_proto_rawDescData +} + +var file_connectrpc_conformance_v1_server_compat_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_connectrpc_conformance_v1_server_compat_proto_goTypes = []any{ + (*ServerCompatRequest)(nil), // 0: connectrpc.conformance.v1.ServerCompatRequest + (*ServerCompatResponse)(nil), // 1: connectrpc.conformance.v1.ServerCompatResponse + (Protocol)(0), // 2: connectrpc.conformance.v1.Protocol + (HTTPVersion)(0), // 3: connectrpc.conformance.v1.HTTPVersion + (*TLSCreds)(nil), // 4: connectrpc.conformance.v1.TLSCreds +} +var file_connectrpc_conformance_v1_server_compat_proto_depIdxs = []int32{ + 2, // 0: connectrpc.conformance.v1.ServerCompatRequest.protocol:type_name -> connectrpc.conformance.v1.Protocol + 3, // 1: connectrpc.conformance.v1.ServerCompatRequest.http_version:type_name -> connectrpc.conformance.v1.HTTPVersion + 4, // 2: connectrpc.conformance.v1.ServerCompatRequest.server_creds:type_name -> connectrpc.conformance.v1.TLSCreds + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_connectrpc_conformance_v1_server_compat_proto_init() } +func file_connectrpc_conformance_v1_server_compat_proto_init() { + if File_connectrpc_conformance_v1_server_compat_proto != nil { + return + } + file_connectrpc_conformance_v1_config_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_connectrpc_conformance_v1_server_compat_proto_rawDesc), len(file_connectrpc_conformance_v1_server_compat_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_connectrpc_conformance_v1_server_compat_proto_goTypes, + DependencyIndexes: file_connectrpc_conformance_v1_server_compat_proto_depIdxs, + MessageInfos: file_connectrpc_conformance_v1_server_compat_proto_msgTypes, + }.Build() + File_connectrpc_conformance_v1_server_compat_proto = out.File + file_connectrpc_conformance_v1_server_compat_proto_goTypes = nil + file_connectrpc_conformance_v1_server_compat_proto_depIdxs = nil +} diff --git a/internal/conformance/internal/gen/connectrpc/conformance/v1/service.pb.go b/internal/conformance/internal/gen/connectrpc/conformance/v1/service.pb.go new file mode 100644 index 00000000..d5ae675b --- /dev/null +++ b/internal/conformance/internal/gen/connectrpc/conformance/v1/service.pb.go @@ -0,0 +1,1967 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: connectrpc/conformance/v1/service.proto + +package conformancev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + anypb "google.golang.org/protobuf/types/known/anypb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// A definition of a response to be sent from a single-response endpoint. +// Can be used to define a response for unary or client-streaming calls. +type UnaryResponseDefinition struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Response headers to send + ResponseHeaders []*Header `protobuf:"bytes,1,rep,name=response_headers,json=responseHeaders,proto3" json:"response_headers,omitempty"` + // Types that are valid to be assigned to Response: + // + // *UnaryResponseDefinition_ResponseData + // *UnaryResponseDefinition_Error + Response isUnaryResponseDefinition_Response `protobuf_oneof:"response"` + // Response trailers to send - together with the error if present + ResponseTrailers []*Header `protobuf:"bytes,4,rep,name=response_trailers,json=responseTrailers,proto3" json:"response_trailers,omitempty"` + // Wait this many milliseconds before sending a response message + ResponseDelayMs uint32 `protobuf:"varint,6,opt,name=response_delay_ms,json=responseDelayMs,proto3" json:"response_delay_ms,omitempty"` + // This field is only used by the reference server. If you are implementing a + // server under test, you can ignore this field or respond with an error if the + // server receives a request where it is set. + // + // For test definitions, this field should be used instead of the above fields. + RawResponse *RawHTTPResponse `protobuf:"bytes,5,opt,name=raw_response,json=rawResponse,proto3" json:"raw_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnaryResponseDefinition) Reset() { + *x = UnaryResponseDefinition{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnaryResponseDefinition) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnaryResponseDefinition) ProtoMessage() {} + +func (x *UnaryResponseDefinition) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnaryResponseDefinition.ProtoReflect.Descriptor instead. +func (*UnaryResponseDefinition) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{0} +} + +func (x *UnaryResponseDefinition) GetResponseHeaders() []*Header { + if x != nil { + return x.ResponseHeaders + } + return nil +} + +func (x *UnaryResponseDefinition) GetResponse() isUnaryResponseDefinition_Response { + if x != nil { + return x.Response + } + return nil +} + +func (x *UnaryResponseDefinition) GetResponseData() []byte { + if x != nil { + if x, ok := x.Response.(*UnaryResponseDefinition_ResponseData); ok { + return x.ResponseData + } + } + return nil +} + +func (x *UnaryResponseDefinition) GetError() *Error { + if x != nil { + if x, ok := x.Response.(*UnaryResponseDefinition_Error); ok { + return x.Error + } + } + return nil +} + +func (x *UnaryResponseDefinition) GetResponseTrailers() []*Header { + if x != nil { + return x.ResponseTrailers + } + return nil +} + +func (x *UnaryResponseDefinition) GetResponseDelayMs() uint32 { + if x != nil { + return x.ResponseDelayMs + } + return 0 +} + +func (x *UnaryResponseDefinition) GetRawResponse() *RawHTTPResponse { + if x != nil { + return x.RawResponse + } + return nil +} + +type isUnaryResponseDefinition_Response interface { + isUnaryResponseDefinition_Response() +} + +type UnaryResponseDefinition_ResponseData struct { + // Response data to send + ResponseData []byte `protobuf:"bytes,2,opt,name=response_data,json=responseData,proto3,oneof"` +} + +type UnaryResponseDefinition_Error struct { + // Error to raise instead of response message + // Servers should build a RequestInfo and append it to the details of the + // requested error. + Error *Error `protobuf:"bytes,3,opt,name=error,proto3,oneof"` +} + +func (*UnaryResponseDefinition_ResponseData) isUnaryResponseDefinition_Response() {} + +func (*UnaryResponseDefinition_Error) isUnaryResponseDefinition_Response() {} + +// A definition of responses to be sent from a streaming endpoint. +// Can be used to define responses for server-streaming or bidi-streaming calls. +type StreamResponseDefinition struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Response headers to send + ResponseHeaders []*Header `protobuf:"bytes,1,rep,name=response_headers,json=responseHeaders,proto3" json:"response_headers,omitempty"` + // Response data to send + ResponseData [][]byte `protobuf:"bytes,2,rep,name=response_data,json=responseData,proto3" json:"response_data,omitempty"` + // Wait this many milliseconds before sending each response message + ResponseDelayMs uint32 `protobuf:"varint,3,opt,name=response_delay_ms,json=responseDelayMs,proto3" json:"response_delay_ms,omitempty"` + // Optional error to raise, but only after sending any response messages. + // In the event an immediate error is thrown before any responses are sent, + // (i.e. the equivalent of a trailers-only response), then servers should + // build a RequestInfo message with available information and append that to + // the error details. + Error *Error `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + // Response trailers to send - together with the error if present + ResponseTrailers []*Header `protobuf:"bytes,5,rep,name=response_trailers,json=responseTrailers,proto3" json:"response_trailers,omitempty"` + // This field is only used by the reference server. If you are implementing a + // server under test, you can ignore this field or respond with an error if the + // server receives a request where it is set. + // + // For test definitions, this field should be used instead of the above fields. + RawResponse *RawHTTPResponse `protobuf:"bytes,6,opt,name=raw_response,json=rawResponse,proto3" json:"raw_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamResponseDefinition) Reset() { + *x = StreamResponseDefinition{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamResponseDefinition) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamResponseDefinition) ProtoMessage() {} + +func (x *StreamResponseDefinition) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamResponseDefinition.ProtoReflect.Descriptor instead. +func (*StreamResponseDefinition) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{1} +} + +func (x *StreamResponseDefinition) GetResponseHeaders() []*Header { + if x != nil { + return x.ResponseHeaders + } + return nil +} + +func (x *StreamResponseDefinition) GetResponseData() [][]byte { + if x != nil { + return x.ResponseData + } + return nil +} + +func (x *StreamResponseDefinition) GetResponseDelayMs() uint32 { + if x != nil { + return x.ResponseDelayMs + } + return 0 +} + +func (x *StreamResponseDefinition) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +func (x *StreamResponseDefinition) GetResponseTrailers() []*Header { + if x != nil { + return x.ResponseTrailers + } + return nil +} + +func (x *StreamResponseDefinition) GetRawResponse() *RawHTTPResponse { + if x != nil { + return x.RawResponse + } + return nil +} + +type UnaryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The response definition which should be returned in the conformance payload + ResponseDefinition *UnaryResponseDefinition `protobuf:"bytes,1,opt,name=response_definition,json=responseDefinition,proto3" json:"response_definition,omitempty"` + // Additional data. Only used to pad the request size to test large request messages. + RequestData []byte `protobuf:"bytes,2,opt,name=request_data,json=requestData,proto3" json:"request_data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnaryRequest) Reset() { + *x = UnaryRequest{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnaryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnaryRequest) ProtoMessage() {} + +func (x *UnaryRequest) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnaryRequest.ProtoReflect.Descriptor instead. +func (*UnaryRequest) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{2} +} + +func (x *UnaryRequest) GetResponseDefinition() *UnaryResponseDefinition { + if x != nil { + return x.ResponseDefinition + } + return nil +} + +func (x *UnaryRequest) GetRequestData() []byte { + if x != nil { + return x.RequestData + } + return nil +} + +type UnaryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The conformance payload to respond with. + Payload *ConformancePayload `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnaryResponse) Reset() { + *x = UnaryResponse{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnaryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnaryResponse) ProtoMessage() {} + +func (x *UnaryResponse) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnaryResponse.ProtoReflect.Descriptor instead. +func (*UnaryResponse) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{3} +} + +func (x *UnaryResponse) GetPayload() *ConformancePayload { + if x != nil { + return x.Payload + } + return nil +} + +type IdempotentUnaryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The response definition which should be returned in the conformance payload + ResponseDefinition *UnaryResponseDefinition `protobuf:"bytes,1,opt,name=response_definition,json=responseDefinition,proto3" json:"response_definition,omitempty"` + // Additional data. Only used to pad the request size to test large request messages. + RequestData []byte `protobuf:"bytes,2,opt,name=request_data,json=requestData,proto3" json:"request_data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IdempotentUnaryRequest) Reset() { + *x = IdempotentUnaryRequest{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IdempotentUnaryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IdempotentUnaryRequest) ProtoMessage() {} + +func (x *IdempotentUnaryRequest) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IdempotentUnaryRequest.ProtoReflect.Descriptor instead. +func (*IdempotentUnaryRequest) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{4} +} + +func (x *IdempotentUnaryRequest) GetResponseDefinition() *UnaryResponseDefinition { + if x != nil { + return x.ResponseDefinition + } + return nil +} + +func (x *IdempotentUnaryRequest) GetRequestData() []byte { + if x != nil { + return x.RequestData + } + return nil +} + +type IdempotentUnaryResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The conformance payload to respond with. + Payload *ConformancePayload `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IdempotentUnaryResponse) Reset() { + *x = IdempotentUnaryResponse{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IdempotentUnaryResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IdempotentUnaryResponse) ProtoMessage() {} + +func (x *IdempotentUnaryResponse) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IdempotentUnaryResponse.ProtoReflect.Descriptor instead. +func (*IdempotentUnaryResponse) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{5} +} + +func (x *IdempotentUnaryResponse) GetPayload() *ConformancePayload { + if x != nil { + return x.Payload + } + return nil +} + +type ServerStreamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The response definition which should be returned in the conformance payload. + ResponseDefinition *StreamResponseDefinition `protobuf:"bytes,1,opt,name=response_definition,json=responseDefinition,proto3" json:"response_definition,omitempty"` + // Additional data. Only used to pad the request size to test large request messages. + RequestData []byte `protobuf:"bytes,2,opt,name=request_data,json=requestData,proto3" json:"request_data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServerStreamRequest) Reset() { + *x = ServerStreamRequest{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServerStreamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServerStreamRequest) ProtoMessage() {} + +func (x *ServerStreamRequest) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServerStreamRequest.ProtoReflect.Descriptor instead. +func (*ServerStreamRequest) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{6} +} + +func (x *ServerStreamRequest) GetResponseDefinition() *StreamResponseDefinition { + if x != nil { + return x.ResponseDefinition + } + return nil +} + +func (x *ServerStreamRequest) GetRequestData() []byte { + if x != nil { + return x.RequestData + } + return nil +} + +type ServerStreamResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The conformance payload to respond with + Payload *ConformancePayload `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServerStreamResponse) Reset() { + *x = ServerStreamResponse{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServerStreamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServerStreamResponse) ProtoMessage() {} + +func (x *ServerStreamResponse) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServerStreamResponse.ProtoReflect.Descriptor instead. +func (*ServerStreamResponse) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{7} +} + +func (x *ServerStreamResponse) GetPayload() *ConformancePayload { + if x != nil { + return x.Payload + } + return nil +} + +type ClientStreamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Tells the server how to reply once all client messages are + // complete. Required in the first message in the stream, but + // should be ignored in subsequent messages. + ResponseDefinition *UnaryResponseDefinition `protobuf:"bytes,1,opt,name=response_definition,json=responseDefinition,proto3" json:"response_definition,omitempty"` + // Additional data for subsequent messages in the stream. Also + // used to pad the request size to test large request messages. + RequestData []byte `protobuf:"bytes,2,opt,name=request_data,json=requestData,proto3" json:"request_data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientStreamRequest) Reset() { + *x = ClientStreamRequest{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientStreamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientStreamRequest) ProtoMessage() {} + +func (x *ClientStreamRequest) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientStreamRequest.ProtoReflect.Descriptor instead. +func (*ClientStreamRequest) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{8} +} + +func (x *ClientStreamRequest) GetResponseDefinition() *UnaryResponseDefinition { + if x != nil { + return x.ResponseDefinition + } + return nil +} + +func (x *ClientStreamRequest) GetRequestData() []byte { + if x != nil { + return x.RequestData + } + return nil +} + +type ClientStreamResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The conformance payload to respond with + Payload *ConformancePayload `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientStreamResponse) Reset() { + *x = ClientStreamResponse{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientStreamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientStreamResponse) ProtoMessage() {} + +func (x *ClientStreamResponse) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientStreamResponse.ProtoReflect.Descriptor instead. +func (*ClientStreamResponse) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{9} +} + +func (x *ClientStreamResponse) GetPayload() *ConformancePayload { + if x != nil { + return x.Payload + } + return nil +} + +type BidiStreamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Tells the server how to reply; required in the first message + // in the stream. Should be ignored in subsequent messages. + ResponseDefinition *StreamResponseDefinition `protobuf:"bytes,1,opt,name=response_definition,json=responseDefinition,proto3" json:"response_definition,omitempty"` + // Tells the server whether it should wait for each request + // before sending a response. + // + // If true, it indicates the server should effectively interleave the + // stream so messages are sent in request->response pairs. + // + // If false, then the response stream will be sent once all request messages + // are finished sending with the only delays between messages + // being the optional fixed milliseconds defined in the response + // definition. + // + // This field is only relevant in the first message in the stream + // and should be ignored in subsequent messages. + FullDuplex bool `protobuf:"varint,2,opt,name=full_duplex,json=fullDuplex,proto3" json:"full_duplex,omitempty"` + // Additional data for subsequent messages in the stream. Also + // used to pad the request size to test large request messages. + RequestData []byte `protobuf:"bytes,3,opt,name=request_data,json=requestData,proto3" json:"request_data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BidiStreamRequest) Reset() { + *x = BidiStreamRequest{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BidiStreamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BidiStreamRequest) ProtoMessage() {} + +func (x *BidiStreamRequest) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BidiStreamRequest.ProtoReflect.Descriptor instead. +func (*BidiStreamRequest) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{10} +} + +func (x *BidiStreamRequest) GetResponseDefinition() *StreamResponseDefinition { + if x != nil { + return x.ResponseDefinition + } + return nil +} + +func (x *BidiStreamRequest) GetFullDuplex() bool { + if x != nil { + return x.FullDuplex + } + return false +} + +func (x *BidiStreamRequest) GetRequestData() []byte { + if x != nil { + return x.RequestData + } + return nil +} + +type BidiStreamResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The conformance payload to respond with + Payload *ConformancePayload `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BidiStreamResponse) Reset() { + *x = BidiStreamResponse{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BidiStreamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BidiStreamResponse) ProtoMessage() {} + +func (x *BidiStreamResponse) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BidiStreamResponse.ProtoReflect.Descriptor instead. +func (*BidiStreamResponse) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{11} +} + +func (x *BidiStreamResponse) GetPayload() *ConformancePayload { + if x != nil { + return x.Payload + } + return nil +} + +type UnimplementedRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnimplementedRequest) Reset() { + *x = UnimplementedRequest{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnimplementedRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnimplementedRequest) ProtoMessage() {} + +func (x *UnimplementedRequest) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnimplementedRequest.ProtoReflect.Descriptor instead. +func (*UnimplementedRequest) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{12} +} + +type UnimplementedResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnimplementedResponse) Reset() { + *x = UnimplementedResponse{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnimplementedResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnimplementedResponse) ProtoMessage() {} + +func (x *UnimplementedResponse) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnimplementedResponse.ProtoReflect.Descriptor instead. +func (*UnimplementedResponse) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{13} +} + +type ConformancePayload struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Any response data specified in the response definition to the server should be + // echoed back here. + Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + // Echoes back information about the request stream observed so far. + RequestInfo *ConformancePayload_RequestInfo `protobuf:"bytes,2,opt,name=request_info,json=requestInfo,proto3" json:"request_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConformancePayload) Reset() { + *x = ConformancePayload{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConformancePayload) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConformancePayload) ProtoMessage() {} + +func (x *ConformancePayload) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConformancePayload.ProtoReflect.Descriptor instead. +func (*ConformancePayload) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{14} +} + +func (x *ConformancePayload) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *ConformancePayload) GetRequestInfo() *ConformancePayload_RequestInfo { + if x != nil { + return x.RequestInfo + } + return nil +} + +// An error definition used for specifying a desired error response +type Error struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The error code. + // For a list of Connect error codes see: https://connectrpc.com/docs/protocol#error-codes + Code Code `protobuf:"varint,1,opt,name=code,proto3,enum=connectrpc.conformance.v1.Code" json:"code,omitempty"` + // If this value is absent in a test case response definition, the contents of the + // actual error message will not be checked. This is useful for certain kinds of + // error conditions where the exact message to be used is not specified, only the + // code. + Message *string `protobuf:"bytes,2,opt,name=message,proto3,oneof" json:"message,omitempty"` + // Errors in Connect and gRPC protocols can have arbitrary messages + // attached to them, which are known as error details. + Details []*anypb.Any `protobuf:"bytes,3,rep,name=details,proto3" json:"details,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Error) Reset() { + *x = Error{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Error) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Error) ProtoMessage() {} + +func (x *Error) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Error.ProtoReflect.Descriptor instead. +func (*Error) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{15} +} + +func (x *Error) GetCode() Code { + if x != nil { + return x.Code + } + return Code_CODE_UNSPECIFIED +} + +func (x *Error) GetMessage() string { + if x != nil && x.Message != nil { + return *x.Message + } + return "" +} + +func (x *Error) GetDetails() []*anypb.Any { + if x != nil { + return x.Details + } + return nil +} + +// A tuple of name and values (ASCII) for a header or trailer entry. +type Header struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Header/trailer name (key). + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Header/trailer value. This is repeated to explicitly support headers and + // trailers where a key is repeated. In such a case, these values must be in + // the same order as which values appeared in the header or trailer block. + Value []string `protobuf:"bytes,2,rep,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Header) Reset() { + *x = Header{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Header) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Header) ProtoMessage() {} + +func (x *Header) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Header.ProtoReflect.Descriptor instead. +func (*Header) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{16} +} + +func (x *Header) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Header) GetValue() []string { + if x != nil { + return x.Value + } + return nil +} + +// RawHTTPRequest models a raw HTTP request. This can be used to craft +// custom requests with odd properties (including certain kinds of +// malformed requests) to test edge cases in servers. +type RawHTTPRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The HTTP verb (i.e. GET , POST). + Verb string `protobuf:"bytes,1,opt,name=verb,proto3" json:"verb,omitempty"` + // The URI to send the request to. + Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` + // Any headers to set on the request. + Headers []*Header `protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty"` + // These query params will be encoded and added to the uri before + // the request is sent. + RawQueryParams []*Header `protobuf:"bytes,4,rep,name=raw_query_params,json=rawQueryParams,proto3" json:"raw_query_params,omitempty"` + // This provides an easier way to define a complex binary query param + // than having to write literal base64-encoded bytes in raw_query_params. + EncodedQueryParams []*RawHTTPRequest_EncodedQueryParam `protobuf:"bytes,5,rep,name=encoded_query_params,json=encodedQueryParams,proto3" json:"encoded_query_params,omitempty"` + // Types that are valid to be assigned to Body: + // + // *RawHTTPRequest_Unary + // *RawHTTPRequest_Stream + Body isRawHTTPRequest_Body `protobuf_oneof:"body"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RawHTTPRequest) Reset() { + *x = RawHTTPRequest{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RawHTTPRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RawHTTPRequest) ProtoMessage() {} + +func (x *RawHTTPRequest) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RawHTTPRequest.ProtoReflect.Descriptor instead. +func (*RawHTTPRequest) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{17} +} + +func (x *RawHTTPRequest) GetVerb() string { + if x != nil { + return x.Verb + } + return "" +} + +func (x *RawHTTPRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *RawHTTPRequest) GetHeaders() []*Header { + if x != nil { + return x.Headers + } + return nil +} + +func (x *RawHTTPRequest) GetRawQueryParams() []*Header { + if x != nil { + return x.RawQueryParams + } + return nil +} + +func (x *RawHTTPRequest) GetEncodedQueryParams() []*RawHTTPRequest_EncodedQueryParam { + if x != nil { + return x.EncodedQueryParams + } + return nil +} + +func (x *RawHTTPRequest) GetBody() isRawHTTPRequest_Body { + if x != nil { + return x.Body + } + return nil +} + +func (x *RawHTTPRequest) GetUnary() *MessageContents { + if x != nil { + if x, ok := x.Body.(*RawHTTPRequest_Unary); ok { + return x.Unary + } + } + return nil +} + +func (x *RawHTTPRequest) GetStream() *StreamContents { + if x != nil { + if x, ok := x.Body.(*RawHTTPRequest_Stream); ok { + return x.Stream + } + } + return nil +} + +type isRawHTTPRequest_Body interface { + isRawHTTPRequest_Body() +} + +type RawHTTPRequest_Unary struct { + // The body is a single message. + Unary *MessageContents `protobuf:"bytes,6,opt,name=unary,proto3,oneof"` +} + +type RawHTTPRequest_Stream struct { + // The body is a stream, encoded using a five-byte + // prefix before each item in the stream. + Stream *StreamContents `protobuf:"bytes,7,opt,name=stream,proto3,oneof"` +} + +func (*RawHTTPRequest_Unary) isRawHTTPRequest_Body() {} + +func (*RawHTTPRequest_Stream) isRawHTTPRequest_Body() {} + +// MessageContents represents a message in a request body. +type MessageContents struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The message data can be defined in one of three ways. + // + // Types that are valid to be assigned to Data: + // + // *MessageContents_Binary + // *MessageContents_Text + // *MessageContents_BinaryMessage + Data isMessageContents_Data `protobuf_oneof:"data"` + // If specified and not identity, the above data will be + // compressed using the given algorithm. + Compression Compression `protobuf:"varint,4,opt,name=compression,proto3,enum=connectrpc.conformance.v1.Compression" json:"compression,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MessageContents) Reset() { + *x = MessageContents{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MessageContents) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MessageContents) ProtoMessage() {} + +func (x *MessageContents) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MessageContents.ProtoReflect.Descriptor instead. +func (*MessageContents) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{18} +} + +func (x *MessageContents) GetData() isMessageContents_Data { + if x != nil { + return x.Data + } + return nil +} + +func (x *MessageContents) GetBinary() []byte { + if x != nil { + if x, ok := x.Data.(*MessageContents_Binary); ok { + return x.Binary + } + } + return nil +} + +func (x *MessageContents) GetText() string { + if x != nil { + if x, ok := x.Data.(*MessageContents_Text); ok { + return x.Text + } + } + return "" +} + +func (x *MessageContents) GetBinaryMessage() *anypb.Any { + if x != nil { + if x, ok := x.Data.(*MessageContents_BinaryMessage); ok { + return x.BinaryMessage + } + } + return nil +} + +func (x *MessageContents) GetCompression() Compression { + if x != nil { + return x.Compression + } + return Compression_COMPRESSION_UNSPECIFIED +} + +type isMessageContents_Data interface { + isMessageContents_Data() +} + +type MessageContents_Binary struct { + // Arbitrary bytes. + Binary []byte `protobuf:"bytes,1,opt,name=binary,proto3,oneof"` +} + +type MessageContents_Text struct { + // Arbitrary text. + Text string `protobuf:"bytes,2,opt,name=text,proto3,oneof"` +} + +type MessageContents_BinaryMessage struct { + // An actual message. The message inside the Any will be + // serialized to the protobuf binary formats, and the + // resulting bytes will be the contents. + BinaryMessage *anypb.Any `protobuf:"bytes,3,opt,name=binary_message,json=binaryMessage,proto3,oneof"` +} + +func (*MessageContents_Binary) isMessageContents_Data() {} + +func (*MessageContents_Text) isMessageContents_Data() {} + +func (*MessageContents_BinaryMessage) isMessageContents_Data() {} + +// StreamContents represents a sequence of messages in a request body. +type StreamContents struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The messages in the stream. + Items []*StreamContents_StreamItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamContents) Reset() { + *x = StreamContents{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamContents) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamContents) ProtoMessage() {} + +func (x *StreamContents) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamContents.ProtoReflect.Descriptor instead. +func (*StreamContents) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{19} +} + +func (x *StreamContents) GetItems() []*StreamContents_StreamItem { + if x != nil { + return x.Items + } + return nil +} + +// RawHTTPResponse models a raw HTTP response. This can be used to craft +// custom responses with odd properties (including certain kinds of +// malformed responses) to test edge cases in clients. +type RawHTTPResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // If status code is not specified, it will default to a 200 response code. + StatusCode uint32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` + // Headers to be set on the response. + Headers []*Header `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty"` + // Types that are valid to be assigned to Body: + // + // *RawHTTPResponse_Unary + // *RawHTTPResponse_Stream + Body isRawHTTPResponse_Body `protobuf_oneof:"body"` + // Trailers to be set on the response. + Trailers []*Header `protobuf:"bytes,5,rep,name=trailers,proto3" json:"trailers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RawHTTPResponse) Reset() { + *x = RawHTTPResponse{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RawHTTPResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RawHTTPResponse) ProtoMessage() {} + +func (x *RawHTTPResponse) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RawHTTPResponse.ProtoReflect.Descriptor instead. +func (*RawHTTPResponse) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{20} +} + +func (x *RawHTTPResponse) GetStatusCode() uint32 { + if x != nil { + return x.StatusCode + } + return 0 +} + +func (x *RawHTTPResponse) GetHeaders() []*Header { + if x != nil { + return x.Headers + } + return nil +} + +func (x *RawHTTPResponse) GetBody() isRawHTTPResponse_Body { + if x != nil { + return x.Body + } + return nil +} + +func (x *RawHTTPResponse) GetUnary() *MessageContents { + if x != nil { + if x, ok := x.Body.(*RawHTTPResponse_Unary); ok { + return x.Unary + } + } + return nil +} + +func (x *RawHTTPResponse) GetStream() *StreamContents { + if x != nil { + if x, ok := x.Body.(*RawHTTPResponse_Stream); ok { + return x.Stream + } + } + return nil +} + +func (x *RawHTTPResponse) GetTrailers() []*Header { + if x != nil { + return x.Trailers + } + return nil +} + +type isRawHTTPResponse_Body interface { + isRawHTTPResponse_Body() +} + +type RawHTTPResponse_Unary struct { + // The body is a single message. + Unary *MessageContents `protobuf:"bytes,3,opt,name=unary,proto3,oneof"` +} + +type RawHTTPResponse_Stream struct { + // The body is a stream, encoded using a five-byte + // prefix before each item in the stream. + Stream *StreamContents `protobuf:"bytes,4,opt,name=stream,proto3,oneof"` +} + +func (*RawHTTPResponse_Unary) isRawHTTPResponse_Body() {} + +func (*RawHTTPResponse_Stream) isRawHTTPResponse_Body() {} + +type ConformancePayload_RequestInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The server echos back the request headers it observed here. + RequestHeaders []*Header `protobuf:"bytes,1,rep,name=request_headers,json=requestHeaders,proto3" json:"request_headers,omitempty"` + // The timeout observed that was included in the request. Other timeouts use a + // type of uint32, but we want to be lenient here to allow whatever value the RPC + // server observes, even if it's outside the range of uint32. + TimeoutMs *int64 `protobuf:"varint,2,opt,name=timeout_ms,json=timeoutMs,proto3,oneof" json:"timeout_ms,omitempty"` + // The server should echo back all requests received. + // For unary and server-streaming requests, this should always contain a single request + // For client-streaming and half-duplex bidi-streaming, this should contain + // all client requests in the order received and be present in each response. + // For full-duplex bidirectional-streaming, this should contain all requests in the order + // they were received since the last sent response. + Requests []*anypb.Any `protobuf:"bytes,3,rep,name=requests,proto3" json:"requests,omitempty"` + // If present, the request used the Connect protocol and a GET method. This + // captures other relevant information about the request. If a server implementation + // is unable to populate this (due to the server framework not exposing all of these + // details to application code), it may be an empty message. This implies that the + // server framework, at a minimum, at least expose to application code whether the + // request used GET vs. POST. + ConnectGetInfo *ConformancePayload_ConnectGetInfo `protobuf:"bytes,4,opt,name=connect_get_info,json=connectGetInfo,proto3" json:"connect_get_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConformancePayload_RequestInfo) Reset() { + *x = ConformancePayload_RequestInfo{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConformancePayload_RequestInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConformancePayload_RequestInfo) ProtoMessage() {} + +func (x *ConformancePayload_RequestInfo) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConformancePayload_RequestInfo.ProtoReflect.Descriptor instead. +func (*ConformancePayload_RequestInfo) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{14, 0} +} + +func (x *ConformancePayload_RequestInfo) GetRequestHeaders() []*Header { + if x != nil { + return x.RequestHeaders + } + return nil +} + +func (x *ConformancePayload_RequestInfo) GetTimeoutMs() int64 { + if x != nil && x.TimeoutMs != nil { + return *x.TimeoutMs + } + return 0 +} + +func (x *ConformancePayload_RequestInfo) GetRequests() []*anypb.Any { + if x != nil { + return x.Requests + } + return nil +} + +func (x *ConformancePayload_RequestInfo) GetConnectGetInfo() *ConformancePayload_ConnectGetInfo { + if x != nil { + return x.ConnectGetInfo + } + return nil +} + +type ConformancePayload_ConnectGetInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The query params observed in the request URL. + QueryParams []*Header `protobuf:"bytes,1,rep,name=query_params,json=queryParams,proto3" json:"query_params,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConformancePayload_ConnectGetInfo) Reset() { + *x = ConformancePayload_ConnectGetInfo{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConformancePayload_ConnectGetInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConformancePayload_ConnectGetInfo) ProtoMessage() {} + +func (x *ConformancePayload_ConnectGetInfo) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConformancePayload_ConnectGetInfo.ProtoReflect.Descriptor instead. +func (*ConformancePayload_ConnectGetInfo) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{14, 1} +} + +func (x *ConformancePayload_ConnectGetInfo) GetQueryParams() []*Header { + if x != nil { + return x.QueryParams + } + return nil +} + +type RawHTTPRequest_EncodedQueryParam struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Query param name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Query param value. + Value *MessageContents `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + // If true, the message contents will be base64-encoded and the + // resulting string used as the query parameter value. + Base64Encode bool `protobuf:"varint,3,opt,name=base64_encode,json=base64Encode,proto3" json:"base64_encode,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RawHTTPRequest_EncodedQueryParam) Reset() { + *x = RawHTTPRequest_EncodedQueryParam{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RawHTTPRequest_EncodedQueryParam) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RawHTTPRequest_EncodedQueryParam) ProtoMessage() {} + +func (x *RawHTTPRequest_EncodedQueryParam) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RawHTTPRequest_EncodedQueryParam.ProtoReflect.Descriptor instead. +func (*RawHTTPRequest_EncodedQueryParam) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{17, 0} +} + +func (x *RawHTTPRequest_EncodedQueryParam) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *RawHTTPRequest_EncodedQueryParam) GetValue() *MessageContents { + if x != nil { + return x.Value + } + return nil +} + +func (x *RawHTTPRequest_EncodedQueryParam) GetBase64Encode() bool { + if x != nil { + return x.Base64Encode + } + return false +} + +type StreamContents_StreamItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + Flags uint32 `protobuf:"varint,1,opt,name=flags,proto3" json:"flags,omitempty"` // must be in the range 0 to 255. + Length *uint32 `protobuf:"varint,2,opt,name=length,proto3,oneof" json:"length,omitempty"` // if absent use actual length of payload + Payload *MessageContents `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamContents_StreamItem) Reset() { + *x = StreamContents_StreamItem{} + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamContents_StreamItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamContents_StreamItem) ProtoMessage() {} + +func (x *StreamContents_StreamItem) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_service_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamContents_StreamItem.ProtoReflect.Descriptor instead. +func (*StreamContents_StreamItem) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_service_proto_rawDescGZIP(), []int{19, 0} +} + +func (x *StreamContents_StreamItem) GetFlags() uint32 { + if x != nil { + return x.Flags + } + return 0 +} + +func (x *StreamContents_StreamItem) GetLength() uint32 { + if x != nil && x.Length != nil { + return *x.Length + } + return 0 +} + +func (x *StreamContents_StreamItem) GetPayload() *MessageContents { + if x != nil { + return x.Payload + } + return nil +} + +var File_connectrpc_conformance_v1_service_proto protoreflect.FileDescriptor + +const file_connectrpc_conformance_v1_service_proto_rawDesc = "" + + "\n" + + "'connectrpc/conformance/v1/service.proto\x12\x19connectrpc.conformance.v1\x1a&connectrpc/conformance/v1/config.proto\x1a\x19google/protobuf/any.proto\"\x9f\x03\n" + + "\x17UnaryResponseDefinition\x12L\n" + + "\x10response_headers\x18\x01 \x03(\v2!.connectrpc.conformance.v1.HeaderR\x0fresponseHeaders\x12%\n" + + "\rresponse_data\x18\x02 \x01(\fH\x00R\fresponseData\x128\n" + + "\x05error\x18\x03 \x01(\v2 .connectrpc.conformance.v1.ErrorH\x00R\x05error\x12N\n" + + "\x11response_trailers\x18\x04 \x03(\v2!.connectrpc.conformance.v1.HeaderR\x10responseTrailers\x12*\n" + + "\x11response_delay_ms\x18\x06 \x01(\rR\x0fresponseDelayMs\x12M\n" + + "\fraw_response\x18\x05 \x01(\v2*.connectrpc.conformance.v1.RawHTTPResponseR\vrawResponseB\n" + + "\n" + + "\bresponse\"\x90\x03\n" + + "\x18StreamResponseDefinition\x12L\n" + + "\x10response_headers\x18\x01 \x03(\v2!.connectrpc.conformance.v1.HeaderR\x0fresponseHeaders\x12#\n" + + "\rresponse_data\x18\x02 \x03(\fR\fresponseData\x12*\n" + + "\x11response_delay_ms\x18\x03 \x01(\rR\x0fresponseDelayMs\x126\n" + + "\x05error\x18\x04 \x01(\v2 .connectrpc.conformance.v1.ErrorR\x05error\x12N\n" + + "\x11response_trailers\x18\x05 \x03(\v2!.connectrpc.conformance.v1.HeaderR\x10responseTrailers\x12M\n" + + "\fraw_response\x18\x06 \x01(\v2*.connectrpc.conformance.v1.RawHTTPResponseR\vrawResponse\"\x96\x01\n" + + "\fUnaryRequest\x12c\n" + + "\x13response_definition\x18\x01 \x01(\v22.connectrpc.conformance.v1.UnaryResponseDefinitionR\x12responseDefinition\x12!\n" + + "\frequest_data\x18\x02 \x01(\fR\vrequestData\"X\n" + + "\rUnaryResponse\x12G\n" + + "\apayload\x18\x01 \x01(\v2-.connectrpc.conformance.v1.ConformancePayloadR\apayload\"\xa0\x01\n" + + "\x16IdempotentUnaryRequest\x12c\n" + + "\x13response_definition\x18\x01 \x01(\v22.connectrpc.conformance.v1.UnaryResponseDefinitionR\x12responseDefinition\x12!\n" + + "\frequest_data\x18\x02 \x01(\fR\vrequestData\"b\n" + + "\x17IdempotentUnaryResponse\x12G\n" + + "\apayload\x18\x01 \x01(\v2-.connectrpc.conformance.v1.ConformancePayloadR\apayload\"\x9e\x01\n" + + "\x13ServerStreamRequest\x12d\n" + + "\x13response_definition\x18\x01 \x01(\v23.connectrpc.conformance.v1.StreamResponseDefinitionR\x12responseDefinition\x12!\n" + + "\frequest_data\x18\x02 \x01(\fR\vrequestData\"_\n" + + "\x14ServerStreamResponse\x12G\n" + + "\apayload\x18\x01 \x01(\v2-.connectrpc.conformance.v1.ConformancePayloadR\apayload\"\x9d\x01\n" + + "\x13ClientStreamRequest\x12c\n" + + "\x13response_definition\x18\x01 \x01(\v22.connectrpc.conformance.v1.UnaryResponseDefinitionR\x12responseDefinition\x12!\n" + + "\frequest_data\x18\x02 \x01(\fR\vrequestData\"_\n" + + "\x14ClientStreamResponse\x12G\n" + + "\apayload\x18\x01 \x01(\v2-.connectrpc.conformance.v1.ConformancePayloadR\apayload\"\xbd\x01\n" + + "\x11BidiStreamRequest\x12d\n" + + "\x13response_definition\x18\x01 \x01(\v23.connectrpc.conformance.v1.StreamResponseDefinitionR\x12responseDefinition\x12\x1f\n" + + "\vfull_duplex\x18\x02 \x01(\bR\n" + + "fullDuplex\x12!\n" + + "\frequest_data\x18\x03 \x01(\fR\vrequestData\"]\n" + + "\x12BidiStreamResponse\x12G\n" + + "\apayload\x18\x01 \x01(\v2-.connectrpc.conformance.v1.ConformancePayloadR\apayload\"\x16\n" + + "\x14UnimplementedRequest\"\x17\n" + + "\x15UnimplementedResponse\"\x87\x04\n" + + "\x12ConformancePayload\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\x12\\\n" + + "\frequest_info\x18\x02 \x01(\v29.connectrpc.conformance.v1.ConformancePayload.RequestInfoR\vrequestInfo\x1a\xa6\x02\n" + + "\vRequestInfo\x12J\n" + + "\x0frequest_headers\x18\x01 \x03(\v2!.connectrpc.conformance.v1.HeaderR\x0erequestHeaders\x12\"\n" + + "\n" + + "timeout_ms\x18\x02 \x01(\x03H\x00R\ttimeoutMs\x88\x01\x01\x120\n" + + "\brequests\x18\x03 \x03(\v2\x14.google.protobuf.AnyR\brequests\x12f\n" + + "\x10connect_get_info\x18\x04 \x01(\v2<.connectrpc.conformance.v1.ConformancePayload.ConnectGetInfoR\x0econnectGetInfoB\r\n" + + "\v_timeout_ms\x1aV\n" + + "\x0eConnectGetInfo\x12D\n" + + "\fquery_params\x18\x01 \x03(\v2!.connectrpc.conformance.v1.HeaderR\vqueryParams\"\x97\x01\n" + + "\x05Error\x123\n" + + "\x04code\x18\x01 \x01(\x0e2\x1f.connectrpc.conformance.v1.CodeR\x04code\x12\x1d\n" + + "\amessage\x18\x02 \x01(\tH\x00R\amessage\x88\x01\x01\x12.\n" + + "\adetails\x18\x03 \x03(\v2\x14.google.protobuf.AnyR\adetailsB\n" + + "\n" + + "\b_message\"2\n" + + "\x06Header\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05value\x18\x02 \x03(\tR\x05value\"\xd1\x04\n" + + "\x0eRawHTTPRequest\x12\x12\n" + + "\x04verb\x18\x01 \x01(\tR\x04verb\x12\x10\n" + + "\x03uri\x18\x02 \x01(\tR\x03uri\x12;\n" + + "\aheaders\x18\x03 \x03(\v2!.connectrpc.conformance.v1.HeaderR\aheaders\x12K\n" + + "\x10raw_query_params\x18\x04 \x03(\v2!.connectrpc.conformance.v1.HeaderR\x0erawQueryParams\x12m\n" + + "\x14encoded_query_params\x18\x05 \x03(\v2;.connectrpc.conformance.v1.RawHTTPRequest.EncodedQueryParamR\x12encodedQueryParams\x12B\n" + + "\x05unary\x18\x06 \x01(\v2*.connectrpc.conformance.v1.MessageContentsH\x00R\x05unary\x12C\n" + + "\x06stream\x18\a \x01(\v2).connectrpc.conformance.v1.StreamContentsH\x00R\x06stream\x1a\x8e\x01\n" + + "\x11EncodedQueryParam\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12@\n" + + "\x05value\x18\x02 \x01(\v2*.connectrpc.conformance.v1.MessageContentsR\x05value\x12#\n" + + "\rbase64_encode\x18\x03 \x01(\bR\fbase64EncodeB\x06\n" + + "\x04body\"\xd2\x01\n" + + "\x0fMessageContents\x12\x18\n" + + "\x06binary\x18\x01 \x01(\fH\x00R\x06binary\x12\x14\n" + + "\x04text\x18\x02 \x01(\tH\x00R\x04text\x12=\n" + + "\x0ebinary_message\x18\x03 \x01(\v2\x14.google.protobuf.AnyH\x00R\rbinaryMessage\x12H\n" + + "\vcompression\x18\x04 \x01(\x0e2&.connectrpc.conformance.v1.CompressionR\vcompressionB\x06\n" + + "\x04data\"\xef\x01\n" + + "\x0eStreamContents\x12J\n" + + "\x05items\x18\x01 \x03(\v24.connectrpc.conformance.v1.StreamContents.StreamItemR\x05items\x1a\x90\x01\n" + + "\n" + + "StreamItem\x12\x14\n" + + "\x05flags\x18\x01 \x01(\rR\x05flags\x12\x1b\n" + + "\x06length\x18\x02 \x01(\rH\x00R\x06length\x88\x01\x01\x12D\n" + + "\apayload\x18\x03 \x01(\v2*.connectrpc.conformance.v1.MessageContentsR\apayloadB\t\n" + + "\a_length\"\xbf\x02\n" + + "\x0fRawHTTPResponse\x12\x1f\n" + + "\vstatus_code\x18\x01 \x01(\rR\n" + + "statusCode\x12;\n" + + "\aheaders\x18\x02 \x03(\v2!.connectrpc.conformance.v1.HeaderR\aheaders\x12B\n" + + "\x05unary\x18\x03 \x01(\v2*.connectrpc.conformance.v1.MessageContentsH\x00R\x05unary\x12C\n" + + "\x06stream\x18\x04 \x01(\v2).connectrpc.conformance.v1.StreamContentsH\x00R\x06stream\x12=\n" + + "\btrailers\x18\x05 \x03(\v2!.connectrpc.conformance.v1.HeaderR\btrailersB\x06\n" + + "\x04body2\xb8\x05\n" + + "\x12ConformanceService\x12Z\n" + + "\x05Unary\x12'.connectrpc.conformance.v1.UnaryRequest\x1a(.connectrpc.conformance.v1.UnaryResponse\x12q\n" + + "\fServerStream\x12..connectrpc.conformance.v1.ServerStreamRequest\x1a/.connectrpc.conformance.v1.ServerStreamResponse0\x01\x12q\n" + + "\fClientStream\x12..connectrpc.conformance.v1.ClientStreamRequest\x1a/.connectrpc.conformance.v1.ClientStreamResponse(\x01\x12m\n" + + "\n" + + "BidiStream\x12,.connectrpc.conformance.v1.BidiStreamRequest\x1a-.connectrpc.conformance.v1.BidiStreamResponse(\x010\x01\x12r\n" + + "\rUnimplemented\x12/.connectrpc.conformance.v1.UnimplementedRequest\x1a0.connectrpc.conformance.v1.UnimplementedResponse\x12}\n" + + "\x0fIdempotentUnary\x121.connectrpc.conformance.v1.IdempotentUnaryRequest\x1a2.connectrpc.conformance.v1.IdempotentUnaryResponse\"\x03\x90\x02\x01B\x98\x02\n" + + "\x1dcom.connectrpc.conformance.v1B\fServiceProtoP\x01Zcconnectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1;conformancev1\xa2\x02\x03CCX\xaa\x02\x19Connectrpc.Conformance.V1\xca\x02\x19Connectrpc\\Conformance\\V1\xe2\x02%Connectrpc\\Conformance\\V1\\GPBMetadata\xea\x02\x1bConnectrpc::Conformance::V1b\x06proto3" + +var ( + file_connectrpc_conformance_v1_service_proto_rawDescOnce sync.Once + file_connectrpc_conformance_v1_service_proto_rawDescData []byte +) + +func file_connectrpc_conformance_v1_service_proto_rawDescGZIP() []byte { + file_connectrpc_conformance_v1_service_proto_rawDescOnce.Do(func() { + file_connectrpc_conformance_v1_service_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_connectrpc_conformance_v1_service_proto_rawDesc), len(file_connectrpc_conformance_v1_service_proto_rawDesc))) + }) + return file_connectrpc_conformance_v1_service_proto_rawDescData +} + +var file_connectrpc_conformance_v1_service_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_connectrpc_conformance_v1_service_proto_goTypes = []any{ + (*UnaryResponseDefinition)(nil), // 0: connectrpc.conformance.v1.UnaryResponseDefinition + (*StreamResponseDefinition)(nil), // 1: connectrpc.conformance.v1.StreamResponseDefinition + (*UnaryRequest)(nil), // 2: connectrpc.conformance.v1.UnaryRequest + (*UnaryResponse)(nil), // 3: connectrpc.conformance.v1.UnaryResponse + (*IdempotentUnaryRequest)(nil), // 4: connectrpc.conformance.v1.IdempotentUnaryRequest + (*IdempotentUnaryResponse)(nil), // 5: connectrpc.conformance.v1.IdempotentUnaryResponse + (*ServerStreamRequest)(nil), // 6: connectrpc.conformance.v1.ServerStreamRequest + (*ServerStreamResponse)(nil), // 7: connectrpc.conformance.v1.ServerStreamResponse + (*ClientStreamRequest)(nil), // 8: connectrpc.conformance.v1.ClientStreamRequest + (*ClientStreamResponse)(nil), // 9: connectrpc.conformance.v1.ClientStreamResponse + (*BidiStreamRequest)(nil), // 10: connectrpc.conformance.v1.BidiStreamRequest + (*BidiStreamResponse)(nil), // 11: connectrpc.conformance.v1.BidiStreamResponse + (*UnimplementedRequest)(nil), // 12: connectrpc.conformance.v1.UnimplementedRequest + (*UnimplementedResponse)(nil), // 13: connectrpc.conformance.v1.UnimplementedResponse + (*ConformancePayload)(nil), // 14: connectrpc.conformance.v1.ConformancePayload + (*Error)(nil), // 15: connectrpc.conformance.v1.Error + (*Header)(nil), // 16: connectrpc.conformance.v1.Header + (*RawHTTPRequest)(nil), // 17: connectrpc.conformance.v1.RawHTTPRequest + (*MessageContents)(nil), // 18: connectrpc.conformance.v1.MessageContents + (*StreamContents)(nil), // 19: connectrpc.conformance.v1.StreamContents + (*RawHTTPResponse)(nil), // 20: connectrpc.conformance.v1.RawHTTPResponse + (*ConformancePayload_RequestInfo)(nil), // 21: connectrpc.conformance.v1.ConformancePayload.RequestInfo + (*ConformancePayload_ConnectGetInfo)(nil), // 22: connectrpc.conformance.v1.ConformancePayload.ConnectGetInfo + (*RawHTTPRequest_EncodedQueryParam)(nil), // 23: connectrpc.conformance.v1.RawHTTPRequest.EncodedQueryParam + (*StreamContents_StreamItem)(nil), // 24: connectrpc.conformance.v1.StreamContents.StreamItem + (Code)(0), // 25: connectrpc.conformance.v1.Code + (*anypb.Any)(nil), // 26: google.protobuf.Any + (Compression)(0), // 27: connectrpc.conformance.v1.Compression +} +var file_connectrpc_conformance_v1_service_proto_depIdxs = []int32{ + 16, // 0: connectrpc.conformance.v1.UnaryResponseDefinition.response_headers:type_name -> connectrpc.conformance.v1.Header + 15, // 1: connectrpc.conformance.v1.UnaryResponseDefinition.error:type_name -> connectrpc.conformance.v1.Error + 16, // 2: connectrpc.conformance.v1.UnaryResponseDefinition.response_trailers:type_name -> connectrpc.conformance.v1.Header + 20, // 3: connectrpc.conformance.v1.UnaryResponseDefinition.raw_response:type_name -> connectrpc.conformance.v1.RawHTTPResponse + 16, // 4: connectrpc.conformance.v1.StreamResponseDefinition.response_headers:type_name -> connectrpc.conformance.v1.Header + 15, // 5: connectrpc.conformance.v1.StreamResponseDefinition.error:type_name -> connectrpc.conformance.v1.Error + 16, // 6: connectrpc.conformance.v1.StreamResponseDefinition.response_trailers:type_name -> connectrpc.conformance.v1.Header + 20, // 7: connectrpc.conformance.v1.StreamResponseDefinition.raw_response:type_name -> connectrpc.conformance.v1.RawHTTPResponse + 0, // 8: connectrpc.conformance.v1.UnaryRequest.response_definition:type_name -> connectrpc.conformance.v1.UnaryResponseDefinition + 14, // 9: connectrpc.conformance.v1.UnaryResponse.payload:type_name -> connectrpc.conformance.v1.ConformancePayload + 0, // 10: connectrpc.conformance.v1.IdempotentUnaryRequest.response_definition:type_name -> connectrpc.conformance.v1.UnaryResponseDefinition + 14, // 11: connectrpc.conformance.v1.IdempotentUnaryResponse.payload:type_name -> connectrpc.conformance.v1.ConformancePayload + 1, // 12: connectrpc.conformance.v1.ServerStreamRequest.response_definition:type_name -> connectrpc.conformance.v1.StreamResponseDefinition + 14, // 13: connectrpc.conformance.v1.ServerStreamResponse.payload:type_name -> connectrpc.conformance.v1.ConformancePayload + 0, // 14: connectrpc.conformance.v1.ClientStreamRequest.response_definition:type_name -> connectrpc.conformance.v1.UnaryResponseDefinition + 14, // 15: connectrpc.conformance.v1.ClientStreamResponse.payload:type_name -> connectrpc.conformance.v1.ConformancePayload + 1, // 16: connectrpc.conformance.v1.BidiStreamRequest.response_definition:type_name -> connectrpc.conformance.v1.StreamResponseDefinition + 14, // 17: connectrpc.conformance.v1.BidiStreamResponse.payload:type_name -> connectrpc.conformance.v1.ConformancePayload + 21, // 18: connectrpc.conformance.v1.ConformancePayload.request_info:type_name -> connectrpc.conformance.v1.ConformancePayload.RequestInfo + 25, // 19: connectrpc.conformance.v1.Error.code:type_name -> connectrpc.conformance.v1.Code + 26, // 20: connectrpc.conformance.v1.Error.details:type_name -> google.protobuf.Any + 16, // 21: connectrpc.conformance.v1.RawHTTPRequest.headers:type_name -> connectrpc.conformance.v1.Header + 16, // 22: connectrpc.conformance.v1.RawHTTPRequest.raw_query_params:type_name -> connectrpc.conformance.v1.Header + 23, // 23: connectrpc.conformance.v1.RawHTTPRequest.encoded_query_params:type_name -> connectrpc.conformance.v1.RawHTTPRequest.EncodedQueryParam + 18, // 24: connectrpc.conformance.v1.RawHTTPRequest.unary:type_name -> connectrpc.conformance.v1.MessageContents + 19, // 25: connectrpc.conformance.v1.RawHTTPRequest.stream:type_name -> connectrpc.conformance.v1.StreamContents + 26, // 26: connectrpc.conformance.v1.MessageContents.binary_message:type_name -> google.protobuf.Any + 27, // 27: connectrpc.conformance.v1.MessageContents.compression:type_name -> connectrpc.conformance.v1.Compression + 24, // 28: connectrpc.conformance.v1.StreamContents.items:type_name -> connectrpc.conformance.v1.StreamContents.StreamItem + 16, // 29: connectrpc.conformance.v1.RawHTTPResponse.headers:type_name -> connectrpc.conformance.v1.Header + 18, // 30: connectrpc.conformance.v1.RawHTTPResponse.unary:type_name -> connectrpc.conformance.v1.MessageContents + 19, // 31: connectrpc.conformance.v1.RawHTTPResponse.stream:type_name -> connectrpc.conformance.v1.StreamContents + 16, // 32: connectrpc.conformance.v1.RawHTTPResponse.trailers:type_name -> connectrpc.conformance.v1.Header + 16, // 33: connectrpc.conformance.v1.ConformancePayload.RequestInfo.request_headers:type_name -> connectrpc.conformance.v1.Header + 26, // 34: connectrpc.conformance.v1.ConformancePayload.RequestInfo.requests:type_name -> google.protobuf.Any + 22, // 35: connectrpc.conformance.v1.ConformancePayload.RequestInfo.connect_get_info:type_name -> connectrpc.conformance.v1.ConformancePayload.ConnectGetInfo + 16, // 36: connectrpc.conformance.v1.ConformancePayload.ConnectGetInfo.query_params:type_name -> connectrpc.conformance.v1.Header + 18, // 37: connectrpc.conformance.v1.RawHTTPRequest.EncodedQueryParam.value:type_name -> connectrpc.conformance.v1.MessageContents + 18, // 38: connectrpc.conformance.v1.StreamContents.StreamItem.payload:type_name -> connectrpc.conformance.v1.MessageContents + 2, // 39: connectrpc.conformance.v1.ConformanceService.Unary:input_type -> connectrpc.conformance.v1.UnaryRequest + 6, // 40: connectrpc.conformance.v1.ConformanceService.ServerStream:input_type -> connectrpc.conformance.v1.ServerStreamRequest + 8, // 41: connectrpc.conformance.v1.ConformanceService.ClientStream:input_type -> connectrpc.conformance.v1.ClientStreamRequest + 10, // 42: connectrpc.conformance.v1.ConformanceService.BidiStream:input_type -> connectrpc.conformance.v1.BidiStreamRequest + 12, // 43: connectrpc.conformance.v1.ConformanceService.Unimplemented:input_type -> connectrpc.conformance.v1.UnimplementedRequest + 4, // 44: connectrpc.conformance.v1.ConformanceService.IdempotentUnary:input_type -> connectrpc.conformance.v1.IdempotentUnaryRequest + 3, // 45: connectrpc.conformance.v1.ConformanceService.Unary:output_type -> connectrpc.conformance.v1.UnaryResponse + 7, // 46: connectrpc.conformance.v1.ConformanceService.ServerStream:output_type -> connectrpc.conformance.v1.ServerStreamResponse + 9, // 47: connectrpc.conformance.v1.ConformanceService.ClientStream:output_type -> connectrpc.conformance.v1.ClientStreamResponse + 11, // 48: connectrpc.conformance.v1.ConformanceService.BidiStream:output_type -> connectrpc.conformance.v1.BidiStreamResponse + 13, // 49: connectrpc.conformance.v1.ConformanceService.Unimplemented:output_type -> connectrpc.conformance.v1.UnimplementedResponse + 5, // 50: connectrpc.conformance.v1.ConformanceService.IdempotentUnary:output_type -> connectrpc.conformance.v1.IdempotentUnaryResponse + 45, // [45:51] is the sub-list for method output_type + 39, // [39:45] is the sub-list for method input_type + 39, // [39:39] is the sub-list for extension type_name + 39, // [39:39] is the sub-list for extension extendee + 0, // [0:39] is the sub-list for field type_name +} + +func init() { file_connectrpc_conformance_v1_service_proto_init() } +func file_connectrpc_conformance_v1_service_proto_init() { + if File_connectrpc_conformance_v1_service_proto != nil { + return + } + file_connectrpc_conformance_v1_config_proto_init() + file_connectrpc_conformance_v1_service_proto_msgTypes[0].OneofWrappers = []any{ + (*UnaryResponseDefinition_ResponseData)(nil), + (*UnaryResponseDefinition_Error)(nil), + } + file_connectrpc_conformance_v1_service_proto_msgTypes[15].OneofWrappers = []any{} + file_connectrpc_conformance_v1_service_proto_msgTypes[17].OneofWrappers = []any{ + (*RawHTTPRequest_Unary)(nil), + (*RawHTTPRequest_Stream)(nil), + } + file_connectrpc_conformance_v1_service_proto_msgTypes[18].OneofWrappers = []any{ + (*MessageContents_Binary)(nil), + (*MessageContents_Text)(nil), + (*MessageContents_BinaryMessage)(nil), + } + file_connectrpc_conformance_v1_service_proto_msgTypes[20].OneofWrappers = []any{ + (*RawHTTPResponse_Unary)(nil), + (*RawHTTPResponse_Stream)(nil), + } + file_connectrpc_conformance_v1_service_proto_msgTypes[21].OneofWrappers = []any{} + file_connectrpc_conformance_v1_service_proto_msgTypes[24].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_connectrpc_conformance_v1_service_proto_rawDesc), len(file_connectrpc_conformance_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 25, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_connectrpc_conformance_v1_service_proto_goTypes, + DependencyIndexes: file_connectrpc_conformance_v1_service_proto_depIdxs, + MessageInfos: file_connectrpc_conformance_v1_service_proto_msgTypes, + }.Build() + File_connectrpc_conformance_v1_service_proto = out.File + file_connectrpc_conformance_v1_service_proto_goTypes = nil + file_connectrpc_conformance_v1_service_proto_depIdxs = nil +} diff --git a/internal/conformance/internal/gen/connectrpc/conformance/v1/suite.pb.go b/internal/conformance/internal/gen/connectrpc/conformance/v1/suite.pb.go new file mode 100644 index 00000000..7884cb56 --- /dev/null +++ b/internal/conformance/internal/gen/connectrpc/conformance/v1/suite.pb.go @@ -0,0 +1,585 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: connectrpc/conformance/v1/suite.proto + +package conformancev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type TestSuite_TestMode int32 + +const ( + // Used when the test suite does not apply to a particular mode. Such tests + // are run, regardless of the current test mode, to verify both clients and + // servers under test. + TestSuite_TEST_MODE_UNSPECIFIED TestSuite_TestMode = 0 + // Indicates tests that are intended to be used only for a client-under-test. + // These cases can induce very particular and/or aberrant responses from the + // reference server, to verify how the client reacts to such responses. + TestSuite_TEST_MODE_CLIENT TestSuite_TestMode = 1 + // Indicates tests that are intended to be used only for a server-under-test. + // These cases can induce very particular and/or aberrant requests from the + // reference client, to verify how the server reacts to such requests. + TestSuite_TEST_MODE_SERVER TestSuite_TestMode = 2 +) + +// Enum value maps for TestSuite_TestMode. +var ( + TestSuite_TestMode_name = map[int32]string{ + 0: "TEST_MODE_UNSPECIFIED", + 1: "TEST_MODE_CLIENT", + 2: "TEST_MODE_SERVER", + } + TestSuite_TestMode_value = map[string]int32{ + "TEST_MODE_UNSPECIFIED": 0, + "TEST_MODE_CLIENT": 1, + "TEST_MODE_SERVER": 2, + } +) + +func (x TestSuite_TestMode) Enum() *TestSuite_TestMode { + p := new(TestSuite_TestMode) + *p = x + return p +} + +func (x TestSuite_TestMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TestSuite_TestMode) Descriptor() protoreflect.EnumDescriptor { + return file_connectrpc_conformance_v1_suite_proto_enumTypes[0].Descriptor() +} + +func (TestSuite_TestMode) Type() protoreflect.EnumType { + return &file_connectrpc_conformance_v1_suite_proto_enumTypes[0] +} + +func (x TestSuite_TestMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TestSuite_TestMode.Descriptor instead. +func (TestSuite_TestMode) EnumDescriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_suite_proto_rawDescGZIP(), []int{0, 0} +} + +type TestSuite_ConnectVersionMode int32 + +const ( + // Used when the suite is agnostic to the server's validation + // behavior. + TestSuite_CONNECT_VERSION_MODE_UNSPECIFIED TestSuite_ConnectVersionMode = 0 + // Used when the suite relies on the server validating the presence + // and correctness of the Connect version header or query param. + TestSuite_CONNECT_VERSION_MODE_REQUIRE TestSuite_ConnectVersionMode = 1 + // Used when the suite relies on the server ignore any Connect + // header or query param. + TestSuite_CONNECT_VERSION_MODE_IGNORE TestSuite_ConnectVersionMode = 2 +) + +// Enum value maps for TestSuite_ConnectVersionMode. +var ( + TestSuite_ConnectVersionMode_name = map[int32]string{ + 0: "CONNECT_VERSION_MODE_UNSPECIFIED", + 1: "CONNECT_VERSION_MODE_REQUIRE", + 2: "CONNECT_VERSION_MODE_IGNORE", + } + TestSuite_ConnectVersionMode_value = map[string]int32{ + "CONNECT_VERSION_MODE_UNSPECIFIED": 0, + "CONNECT_VERSION_MODE_REQUIRE": 1, + "CONNECT_VERSION_MODE_IGNORE": 2, + } +) + +func (x TestSuite_ConnectVersionMode) Enum() *TestSuite_ConnectVersionMode { + p := new(TestSuite_ConnectVersionMode) + *p = x + return p +} + +func (x TestSuite_ConnectVersionMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TestSuite_ConnectVersionMode) Descriptor() protoreflect.EnumDescriptor { + return file_connectrpc_conformance_v1_suite_proto_enumTypes[1].Descriptor() +} + +func (TestSuite_ConnectVersionMode) Type() protoreflect.EnumType { + return &file_connectrpc_conformance_v1_suite_proto_enumTypes[1] +} + +func (x TestSuite_ConnectVersionMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TestSuite_ConnectVersionMode.Descriptor instead. +func (TestSuite_ConnectVersionMode) EnumDescriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_suite_proto_rawDescGZIP(), []int{0, 1} +} + +// TestSuite represents a set of conformance test cases. This is also the schema +// used for the structure of a YAML test file. Each YAML file represents a test +// suite, which can contain numerous cases. Each test suite has various properties +// that indicate the kinds of features that are tested. Test suites may be skipped +// based on whether the client or server under test implements these features. +type TestSuite struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Test suite name. When writing test suites, this is a required field. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // The mode (client or server) that this test suite applies to. This is used + // in conjunction with the `--mode` flag passed to the conformance runner + // binary. If the mode on the suite is set to client, the tests will only be + // run if `--mode client` is set on the command to the test runner. + // Likewise if mode is server. If this is unset, the test case will be run in both modes. + Mode TestSuite_TestMode `protobuf:"varint,2,opt,name=mode,proto3,enum=connectrpc.conformance.v1.TestSuite_TestMode" json:"mode,omitempty"` + // The actual test cases in the suite. + TestCases []*TestCase `protobuf:"bytes,3,rep,name=test_cases,json=testCases,proto3" json:"test_cases,omitempty"` + // If non-empty, the protocols to which this suite applies. If empty, + // this suite applies to all protocols. + RelevantProtocols []Protocol `protobuf:"varint,4,rep,packed,name=relevant_protocols,json=relevantProtocols,proto3,enum=connectrpc.conformance.v1.Protocol" json:"relevant_protocols,omitempty"` + // If non-empty, the HTTP versions to which this suite applies. If empty, + // this suite applies to all HTTP versions. + RelevantHttpVersions []HTTPVersion `protobuf:"varint,5,rep,packed,name=relevant_http_versions,json=relevantHttpVersions,proto3,enum=connectrpc.conformance.v1.HTTPVersion" json:"relevant_http_versions,omitempty"` + // If non-empty, the codecs to which this suite applies. If empty, this + // suite applies to all codecs. + RelevantCodecs []Codec `protobuf:"varint,6,rep,packed,name=relevant_codecs,json=relevantCodecs,proto3,enum=connectrpc.conformance.v1.Codec" json:"relevant_codecs,omitempty"` + // If non-empty, the compression encodings to which this suite applies. + // If empty, this suite applies to all encodings. + RelevantCompressions []Compression `protobuf:"varint,7,rep,packed,name=relevant_compressions,json=relevantCompressions,proto3,enum=connectrpc.conformance.v1.Compression" json:"relevant_compressions,omitempty"` + // Indicates the Connect version validation behavior that this suite + // relies on. + ConnectVersionMode TestSuite_ConnectVersionMode `protobuf:"varint,8,opt,name=connect_version_mode,json=connectVersionMode,proto3,enum=connectrpc.conformance.v1.TestSuite_ConnectVersionMode" json:"connect_version_mode,omitempty"` + // If true, the cases in this suite rely on TLS and will only be run against + // TLS server configurations. + ReliesOnTls bool `protobuf:"varint,9,opt,name=relies_on_tls,json=reliesOnTls,proto3" json:"relies_on_tls,omitempty"` + // If true, the cases in this suite rely on the client using TLS + // certificates to authenticate with the server. (Should only be + // true if relies_on_tls is also true.) + ReliesOnTlsClientCerts bool `protobuf:"varint,10,opt,name=relies_on_tls_client_certs,json=reliesOnTlsClientCerts,proto3" json:"relies_on_tls_client_certs,omitempty"` + // If true, the cases in this suite rely on the Connect GET protocol. + ReliesOnConnectGet bool `protobuf:"varint,11,opt,name=relies_on_connect_get,json=reliesOnConnectGet,proto3" json:"relies_on_connect_get,omitempty"` + // If true, the cases in this suite rely on support for limiting the + // size of received messages. When true, mode should be set to indicate + // whether it is the client or the server that must support the limit. + ReliesOnMessageReceiveLimit bool `protobuf:"varint,12,opt,name=relies_on_message_receive_limit,json=reliesOnMessageReceiveLimit,proto3" json:"relies_on_message_receive_limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestSuite) Reset() { + *x = TestSuite{} + mi := &file_connectrpc_conformance_v1_suite_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestSuite) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestSuite) ProtoMessage() {} + +func (x *TestSuite) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_suite_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestSuite.ProtoReflect.Descriptor instead. +func (*TestSuite) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_suite_proto_rawDescGZIP(), []int{0} +} + +func (x *TestSuite) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TestSuite) GetMode() TestSuite_TestMode { + if x != nil { + return x.Mode + } + return TestSuite_TEST_MODE_UNSPECIFIED +} + +func (x *TestSuite) GetTestCases() []*TestCase { + if x != nil { + return x.TestCases + } + return nil +} + +func (x *TestSuite) GetRelevantProtocols() []Protocol { + if x != nil { + return x.RelevantProtocols + } + return nil +} + +func (x *TestSuite) GetRelevantHttpVersions() []HTTPVersion { + if x != nil { + return x.RelevantHttpVersions + } + return nil +} + +func (x *TestSuite) GetRelevantCodecs() []Codec { + if x != nil { + return x.RelevantCodecs + } + return nil +} + +func (x *TestSuite) GetRelevantCompressions() []Compression { + if x != nil { + return x.RelevantCompressions + } + return nil +} + +func (x *TestSuite) GetConnectVersionMode() TestSuite_ConnectVersionMode { + if x != nil { + return x.ConnectVersionMode + } + return TestSuite_CONNECT_VERSION_MODE_UNSPECIFIED +} + +func (x *TestSuite) GetReliesOnTls() bool { + if x != nil { + return x.ReliesOnTls + } + return false +} + +func (x *TestSuite) GetReliesOnTlsClientCerts() bool { + if x != nil { + return x.ReliesOnTlsClientCerts + } + return false +} + +func (x *TestSuite) GetReliesOnConnectGet() bool { + if x != nil { + return x.ReliesOnConnectGet + } + return false +} + +func (x *TestSuite) GetReliesOnMessageReceiveLimit() bool { + if x != nil { + return x.ReliesOnMessageReceiveLimit + } + return false +} + +type TestCase struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Defines the RPC that the client should invoke. The first eight fields + // are not fully specified. Instead the first field, test_name, must be + // present but is a prefix -- other characteristics that identify one + // permutation of the test case will be appended to this name. The next + // seven fields (http_version, protocol, codec, compression, host, port, + // and server_tls_cert) must not be present. They are all populated by + // the test harness based on the test environment (e.g. actual server and + // port to use) and characteristics of a single permutation. + Request *ClientCompatRequest `protobuf:"bytes,1,opt,name=request,proto3" json:"request,omitempty"` + // To support extremely large messages, as well as very precisely-sized + // messages, without having to encode them fully or perfectly in YAML + // test cases, this value can be specified. When non-empty, this value + // should have no more entries than there are messages in the request + // stream. The first value is applied to the first request message, and + // so on. For each entry, if the size is present, it is used to expand + // the data field in the request (which is actually part of the response + // definition). The specified size is added to the current limit on + // message size that the server will accept. That sum is the size of the + // the serialized message that will be sent, and the data field will be + // padded as needed to reach that size. + ExpandRequests []*TestCase_ExpandedSize `protobuf:"bytes,2,rep,name=expand_requests,json=expandRequests,proto3" json:"expand_requests,omitempty"` + // Defines the expected response to the above RPC. The expected response for + // a test is auto-generated based on the request details. The conformance runner + // will determine what the response should be according to the values specified + // in the test suite and individual test cases. + // + // This value can also be specified explicitly in the test case YAML. However, + // this is typically only needed for exception test cases. If the expected + // response is mostly re-stating the response definition that appears in the + // requests, test cases should rely on the auto-generation if possible. + // Otherwise, specifying an expected response can make the test YAML overly + // verbose and harder to read, write, and maintain. + // + // If the test induces behavior that prevents the server from sending or client + // from receiving the full response definition, it will be necessary to define + // the expected response explicitly. Timeouts, cancellations, and exceeding + // message size limits are good examples of this. + // + // Specifying an expected response explicitly in test definitions will override + // the auto-generation of the test runner. + ExpectedResponse *ClientResponseResult `protobuf:"bytes,3,opt,name=expected_response,json=expectedResponse,proto3" json:"expected_response,omitempty"` + // When expected_response indicates that an error is expected, in some cases, the + // actual error code returned may be flexible. In that case, this field provides + // other acceptable error codes, in addition to the one indicated in the + // expected_response. As long as the actual error's code matches any of these, the + // error is considered conformant, and the test case can pass. + OtherAllowedErrorCodes []Code `protobuf:"varint,4,rep,packed,name=other_allowed_error_codes,json=otherAllowedErrorCodes,proto3,enum=connectrpc.conformance.v1.Code" json:"other_allowed_error_codes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestCase) Reset() { + *x = TestCase{} + mi := &file_connectrpc_conformance_v1_suite_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestCase) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestCase) ProtoMessage() {} + +func (x *TestCase) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_suite_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestCase.ProtoReflect.Descriptor instead. +func (*TestCase) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_suite_proto_rawDescGZIP(), []int{1} +} + +func (x *TestCase) GetRequest() *ClientCompatRequest { + if x != nil { + return x.Request + } + return nil +} + +func (x *TestCase) GetExpandRequests() []*TestCase_ExpandedSize { + if x != nil { + return x.ExpandRequests + } + return nil +} + +func (x *TestCase) GetExpectedResponse() *ClientResponseResult { + if x != nil { + return x.ExpectedResponse + } + return nil +} + +func (x *TestCase) GetOtherAllowedErrorCodes() []Code { + if x != nil { + return x.OtherAllowedErrorCodes + } + return nil +} + +type TestCase_ExpandedSize struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The size, in bytes, relative to the limit. For example, to expand to a + // size that is exactly equal to the limit, this should be set to zero. + // Any value greater than zero indicates that the request size will be that + // many bytes over the limit. + SizeRelativeToLimit *int32 `protobuf:"varint,1,opt,name=size_relative_to_limit,json=sizeRelativeToLimit,proto3,oneof" json:"size_relative_to_limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestCase_ExpandedSize) Reset() { + *x = TestCase_ExpandedSize{} + mi := &file_connectrpc_conformance_v1_suite_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestCase_ExpandedSize) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestCase_ExpandedSize) ProtoMessage() {} + +func (x *TestCase_ExpandedSize) ProtoReflect() protoreflect.Message { + mi := &file_connectrpc_conformance_v1_suite_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TestCase_ExpandedSize.ProtoReflect.Descriptor instead. +func (*TestCase_ExpandedSize) Descriptor() ([]byte, []int) { + return file_connectrpc_conformance_v1_suite_proto_rawDescGZIP(), []int{1, 0} +} + +func (x *TestCase_ExpandedSize) GetSizeRelativeToLimit() int32 { + if x != nil && x.SizeRelativeToLimit != nil { + return *x.SizeRelativeToLimit + } + return 0 +} + +var File_connectrpc_conformance_v1_suite_proto protoreflect.FileDescriptor + +const file_connectrpc_conformance_v1_suite_proto_rawDesc = "" + + "\n" + + "%connectrpc/conformance/v1/suite.proto\x12\x19connectrpc.conformance.v1\x1a-connectrpc/conformance/v1/client_compat.proto\x1a&connectrpc/conformance/v1/config.proto\"\x96\b\n" + + "\tTestSuite\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12A\n" + + "\x04mode\x18\x02 \x01(\x0e2-.connectrpc.conformance.v1.TestSuite.TestModeR\x04mode\x12B\n" + + "\n" + + "test_cases\x18\x03 \x03(\v2#.connectrpc.conformance.v1.TestCaseR\ttestCases\x12R\n" + + "\x12relevant_protocols\x18\x04 \x03(\x0e2#.connectrpc.conformance.v1.ProtocolR\x11relevantProtocols\x12\\\n" + + "\x16relevant_http_versions\x18\x05 \x03(\x0e2&.connectrpc.conformance.v1.HTTPVersionR\x14relevantHttpVersions\x12I\n" + + "\x0frelevant_codecs\x18\x06 \x03(\x0e2 .connectrpc.conformance.v1.CodecR\x0erelevantCodecs\x12[\n" + + "\x15relevant_compressions\x18\a \x03(\x0e2&.connectrpc.conformance.v1.CompressionR\x14relevantCompressions\x12i\n" + + "\x14connect_version_mode\x18\b \x01(\x0e27.connectrpc.conformance.v1.TestSuite.ConnectVersionModeR\x12connectVersionMode\x12\"\n" + + "\rrelies_on_tls\x18\t \x01(\bR\vreliesOnTls\x12:\n" + + "\x1arelies_on_tls_client_certs\x18\n" + + " \x01(\bR\x16reliesOnTlsClientCerts\x121\n" + + "\x15relies_on_connect_get\x18\v \x01(\bR\x12reliesOnConnectGet\x12D\n" + + "\x1frelies_on_message_receive_limit\x18\f \x01(\bR\x1breliesOnMessageReceiveLimit\"Q\n" + + "\bTestMode\x12\x19\n" + + "\x15TEST_MODE_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10TEST_MODE_CLIENT\x10\x01\x12\x14\n" + + "\x10TEST_MODE_SERVER\x10\x02\"}\n" + + "\x12ConnectVersionMode\x12$\n" + + " CONNECT_VERSION_MODE_UNSPECIFIED\x10\x00\x12 \n" + + "\x1cCONNECT_VERSION_MODE_REQUIRE\x10\x01\x12\x1f\n" + + "\x1bCONNECT_VERSION_MODE_IGNORE\x10\x02\"\xce\x03\n" + + "\bTestCase\x12H\n" + + "\arequest\x18\x01 \x01(\v2..connectrpc.conformance.v1.ClientCompatRequestR\arequest\x12Y\n" + + "\x0fexpand_requests\x18\x02 \x03(\v20.connectrpc.conformance.v1.TestCase.ExpandedSizeR\x0eexpandRequests\x12\\\n" + + "\x11expected_response\x18\x03 \x01(\v2/.connectrpc.conformance.v1.ClientResponseResultR\x10expectedResponse\x12Z\n" + + "\x19other_allowed_error_codes\x18\x04 \x03(\x0e2\x1f.connectrpc.conformance.v1.CodeR\x16otherAllowedErrorCodes\x1ac\n" + + "\fExpandedSize\x128\n" + + "\x16size_relative_to_limit\x18\x01 \x01(\x05H\x00R\x13sizeRelativeToLimit\x88\x01\x01B\x19\n" + + "\x17_size_relative_to_limitB\x96\x02\n" + + "\x1dcom.connectrpc.conformance.v1B\n" + + "SuiteProtoP\x01Zcconnectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1;conformancev1\xa2\x02\x03CCX\xaa\x02\x19Connectrpc.Conformance.V1\xca\x02\x19Connectrpc\\Conformance\\V1\xe2\x02%Connectrpc\\Conformance\\V1\\GPBMetadata\xea\x02\x1bConnectrpc::Conformance::V1b\x06proto3" + +var ( + file_connectrpc_conformance_v1_suite_proto_rawDescOnce sync.Once + file_connectrpc_conformance_v1_suite_proto_rawDescData []byte +) + +func file_connectrpc_conformance_v1_suite_proto_rawDescGZIP() []byte { + file_connectrpc_conformance_v1_suite_proto_rawDescOnce.Do(func() { + file_connectrpc_conformance_v1_suite_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_connectrpc_conformance_v1_suite_proto_rawDesc), len(file_connectrpc_conformance_v1_suite_proto_rawDesc))) + }) + return file_connectrpc_conformance_v1_suite_proto_rawDescData +} + +var file_connectrpc_conformance_v1_suite_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_connectrpc_conformance_v1_suite_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_connectrpc_conformance_v1_suite_proto_goTypes = []any{ + (TestSuite_TestMode)(0), // 0: connectrpc.conformance.v1.TestSuite.TestMode + (TestSuite_ConnectVersionMode)(0), // 1: connectrpc.conformance.v1.TestSuite.ConnectVersionMode + (*TestSuite)(nil), // 2: connectrpc.conformance.v1.TestSuite + (*TestCase)(nil), // 3: connectrpc.conformance.v1.TestCase + (*TestCase_ExpandedSize)(nil), // 4: connectrpc.conformance.v1.TestCase.ExpandedSize + (Protocol)(0), // 5: connectrpc.conformance.v1.Protocol + (HTTPVersion)(0), // 6: connectrpc.conformance.v1.HTTPVersion + (Codec)(0), // 7: connectrpc.conformance.v1.Codec + (Compression)(0), // 8: connectrpc.conformance.v1.Compression + (*ClientCompatRequest)(nil), // 9: connectrpc.conformance.v1.ClientCompatRequest + (*ClientResponseResult)(nil), // 10: connectrpc.conformance.v1.ClientResponseResult + (Code)(0), // 11: connectrpc.conformance.v1.Code +} +var file_connectrpc_conformance_v1_suite_proto_depIdxs = []int32{ + 0, // 0: connectrpc.conformance.v1.TestSuite.mode:type_name -> connectrpc.conformance.v1.TestSuite.TestMode + 3, // 1: connectrpc.conformance.v1.TestSuite.test_cases:type_name -> connectrpc.conformance.v1.TestCase + 5, // 2: connectrpc.conformance.v1.TestSuite.relevant_protocols:type_name -> connectrpc.conformance.v1.Protocol + 6, // 3: connectrpc.conformance.v1.TestSuite.relevant_http_versions:type_name -> connectrpc.conformance.v1.HTTPVersion + 7, // 4: connectrpc.conformance.v1.TestSuite.relevant_codecs:type_name -> connectrpc.conformance.v1.Codec + 8, // 5: connectrpc.conformance.v1.TestSuite.relevant_compressions:type_name -> connectrpc.conformance.v1.Compression + 1, // 6: connectrpc.conformance.v1.TestSuite.connect_version_mode:type_name -> connectrpc.conformance.v1.TestSuite.ConnectVersionMode + 9, // 7: connectrpc.conformance.v1.TestCase.request:type_name -> connectrpc.conformance.v1.ClientCompatRequest + 4, // 8: connectrpc.conformance.v1.TestCase.expand_requests:type_name -> connectrpc.conformance.v1.TestCase.ExpandedSize + 10, // 9: connectrpc.conformance.v1.TestCase.expected_response:type_name -> connectrpc.conformance.v1.ClientResponseResult + 11, // 10: connectrpc.conformance.v1.TestCase.other_allowed_error_codes:type_name -> connectrpc.conformance.v1.Code + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_connectrpc_conformance_v1_suite_proto_init() } +func file_connectrpc_conformance_v1_suite_proto_init() { + if File_connectrpc_conformance_v1_suite_proto != nil { + return + } + file_connectrpc_conformance_v1_client_compat_proto_init() + file_connectrpc_conformance_v1_config_proto_init() + file_connectrpc_conformance_v1_suite_proto_msgTypes[2].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_connectrpc_conformance_v1_suite_proto_rawDesc), len(file_connectrpc_conformance_v1_suite_proto_rawDesc)), + NumEnums: 2, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_connectrpc_conformance_v1_suite_proto_goTypes, + DependencyIndexes: file_connectrpc_conformance_v1_suite_proto_depIdxs, + EnumInfos: file_connectrpc_conformance_v1_suite_proto_enumTypes, + MessageInfos: file_connectrpc_conformance_v1_suite_proto_msgTypes, + }.Build() + File_connectrpc_conformance_v1_suite_proto = out.File + file_connectrpc_conformance_v1_suite_proto_goTypes = nil + file_connectrpc_conformance_v1_suite_proto_depIdxs = nil +} diff --git a/internal/conformance/internal/headers.go b/internal/conformance/internal/headers.go new file mode 100644 index 00000000..5ae6679c --- /dev/null +++ b/internal/conformance/internal/headers.go @@ -0,0 +1,56 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "connectrpc.com/connect/v2" + conformancev1 "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1" +) + +// AddHeaders adds all header values in src to dest. +func AddHeaders( + src []*conformancev1.Header, + dest *connect.Header, +) { + for _, header := range src { + for _, val := range header.Value { + dest.Add(header.Name, val) + } + } +} + +// AddTrailers adds all header values in src to dest. In v2 trailers are carried +// in their own metadata, so this is equivalent to [AddHeaders]. +func AddTrailers( + src []*conformancev1.Header, + dest *connect.Header, +) { + AddHeaders(src, dest) +} + +// ConvertToProtoHeader converts metadata to a slice of proto Headers. +func ConvertToProtoHeader(src *connect.Header) []*conformancev1.Header { + if src == nil { + return nil + } + headerInfo := make([]*conformancev1.Header, 0) + for key, value := range src.All() { + headerInfo = append(headerInfo, &conformancev1.Header{ + Name: key, + Value: value, + }) + } + return headerInfo +} diff --git a/internal/conformance/internal/printer.go b/internal/conformance/internal/printer.go new file mode 100644 index 00000000..33cc7a3d --- /dev/null +++ b/internal/conformance/internal/printer.go @@ -0,0 +1,104 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "fmt" + "io" + "strings" + "sync" +) + +// Printer is a simple interface for formatting human-readable messages. +type Printer interface { + // Printf formats the given message and arguments. A newline + // is automatically added, so it is not necessary to include + // an explicit "\n" at the end. + Printf(msg string, args ...any) + // PrefixPrintf is just like Printf except it will print + // the given prefix followed by ": " before printing the + // messages and arguments. + PrefixPrintf(prefix, msg string, args ...any) +} + +// NewPrinter returns a thread-safe printer that prints messages +// to the given writer. The returned printer may safely be used +// from concurrent goroutines, even if the given writer is not +// safe for concurrent use. +func NewPrinter(w io.Writer) Printer { + return &safePrinter{w: &peekWriter{w: w}} +} + +// SimplePrinter is a non-thread-safe printer that stores the +// printed messages in a slice. +type SimplePrinter struct { + Messages []string +} + +func (l *SimplePrinter) Printf(msg string, args ...any) { + line := fmt.Sprintf(msg, args...) + if !strings.HasSuffix(line, "\n") { + line += "\n" + } + l.Messages = append(l.Messages, line) +} + +func (l *SimplePrinter) PrefixPrintf(prefix, msg string, args ...any) { + msg = fmt.Sprintf(msg, args...) + line := fmt.Sprintf("%s: %s", prefix, msg) + if !strings.HasSuffix(line, "\n") { + line += "\n" + } + l.Messages = append(l.Messages, line) +} + +// safePrinter is a thread-safe printer. +type safePrinter struct { + mu sync.Mutex + w *peekWriter +} + +func (p *safePrinter) Printf(msg string, args ...any) { + p.mu.Lock() + defer p.mu.Unlock() + _, _ = fmt.Fprintf(p.w, msg, args...) + if p.w.last != '\n' { + _, _ = p.w.Write([]byte{'\n'}) + } +} + +func (p *safePrinter) PrefixPrintf(prefix, msg string, args ...any) { + p.mu.Lock() + defer p.mu.Unlock() + _, _ = fmt.Fprintf(p.w, "%s: ", prefix) + _, _ = fmt.Fprintf(p.w, msg, args...) + if p.w.last != '\n' { + _, _ = p.w.Write([]byte{'\n'}) + } +} + +// peekWriter is a writer that can peek at the last byte written. +type peekWriter struct { + w io.Writer + last byte +} + +func (p *peekWriter) Write(data []byte) (int, error) { + n, err := p.w.Write(data) + if n > 0 { + p.last = data[n-1] + } + return n, err +} diff --git a/internal/conformance/internal/raw_http_body.go b/internal/conformance/internal/raw_http_body.go new file mode 100644 index 00000000..463263e2 --- /dev/null +++ b/internal/conformance/internal/raw_http_body.go @@ -0,0 +1,93 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + + "connectrpc.com/connect/v2/internal/conformance/internal/compression" + conformancev1 "connectrpc.com/connect/v2/internal/conformance/internal/gen/connectrpc/conformance/v1" +) + +// WriteRawMessageContents writes the given message contents to the given writer. +func WriteRawMessageContents(contents *conformancev1.MessageContents, writer io.Writer) error { + var msgBytes []byte + switch data := contents.Data.(type) { + case nil: + // empty, so nothing to write + return nil + case *conformancev1.MessageContents_Binary: + msgBytes = data.Binary + case *conformancev1.MessageContents_BinaryMessage: + msgBytes = data.BinaryMessage.Value + case *conformancev1.MessageContents_Text: + msgBytes = []byte(data.Text) + default: + return fmt.Errorf("invalid message contents data type: %T", data) + } + + compressor, err := compression.GetCompressor(contents.Compression) + if err != nil { + return err + } + writeCloser, err := compressor.Compress(writer) + if err != nil { + return err + } + _, err = writeCloser.Write(msgBytes) + if err == nil { + err = writeCloser.Close() + } + return err +} + +// WriteRawStreamContents writes the given stream contents to the given writer. +func WriteRawStreamContents(contents *conformancev1.StreamContents, writer io.Writer) error { + for i, item := range contents.Items { + var prefix [5]byte + if item.Flags > 255 { + return fmt.Errorf("message #%d: flags is out of range: %d, should be [0,255]", i+1, item.Flags) + } + prefix[0] = byte(item.Flags) + if item.Length != nil { + binary.BigEndian.PutUint32(prefix[1:], item.GetLength()) + _, err := writer.Write(prefix[:]) + if err == nil { + err = WriteRawMessageContents(item.Payload, writer) + } + if err != nil { + return fmt.Errorf("message #%d: %w", i+1, err) + } + continue + } + + var buf bytes.Buffer + if err := WriteRawMessageContents(item.Payload, &buf); err != nil { + return fmt.Errorf("message #%d: %w", i+1, err) + } + binary.BigEndian.PutUint32(prefix[1:], uint32(buf.Len())) + _, err := writer.Write(prefix[:]) + if err == nil { + _, err = buf.WriteTo(writer) + } + if err != nil { + return fmt.Errorf("message #%d: %w", i+1, err) + } + } + return nil +} diff --git a/internal/conformance/internal/tls.go b/internal/conformance/internal/tls.go new file mode 100644 index 00000000..71d47c95 --- /dev/null +++ b/internal/conformance/internal/tls.go @@ -0,0 +1,169 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "time" +) + +const ( + ClientCertName = "Conformance Client" + ServerCertName = "Conformance Server" +) + +// NewClientTLSConfig returns a TLS configuration for an RPC client that uses +// the given PEM-encoded certs/keys. The caCert parameter must not be empty, and +// is the server certificate to trust (or a CA cert for the issuer of the server +// cert). The clientCert and clientKey parameters are optional. If one is provided +// then both must be present. They enable the use of a client certificate during +// the TLS handshake, for mutually-authenticated TLS. +func NewClientTLSConfig(caCert, clientCert, clientKey []byte) (*tls.Config, error) { + if len(caCert) == 0 { + return nil, errors.New("caCert is empty") + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, errors.New("failed to parse CA cert from given data") + } + + hasClientCert := len(clientCert) != 0 + hasClientKey := len(clientKey) != 0 + var certs []tls.Certificate + switch { + case hasClientCert && hasClientKey: + certPair, err := ParseServerCert(clientCert, clientKey) + if err != nil { + return nil, err + } + certs = []tls.Certificate{certPair} + case hasClientCert: + return nil, errors.New("clientCert is not empty but clientKey is") + case hasClientKey: + return nil, errors.New("clientKey is not empty but clientCert is") + } + + return &tls.Config{ + RootCAs: caCertPool, + Certificates: certs, + MinVersion: tls.VersionTLS12, + }, nil +} + +// NewServerTLSConfig returns a TLS configuration for an RPC server that uses +// the given PEM-encoded cert/key. If the cert and key parameters are required. +// The clientCACert parameter is required unless clientCerts is tls.NoClientCert. +func NewServerTLSConfig(cert tls.Certificate, clientCertMode tls.ClientAuthType, clientCACert []byte) (*tls.Config, error) { + if clientCertMode != tls.NoClientCert && len(clientCACert) == 0 { + return nil, errors.New("clientCertMode indicates client certs supported but CACert is empty") + } + var caCertPool *x509.CertPool + if len(clientCACert) > 0 { + caCertPool = x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(clientCACert) { + return nil, errors.New("failed to parse client CA cert from given data") + } + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientCAs: caCertPool, + ClientAuth: clientCertMode, + MinVersion: tls.VersionTLS12, + }, nil +} + +// ParseServerCert parses the given PEM-encoded cert and key. +func ParseServerCert(cert, key []byte) (tls.Certificate, error) { + if len(cert) == 0 { + return tls.Certificate{}, errors.New("cert is empty") + } + if len(key) == 0 { + return tls.Certificate{}, errors.New("key is empty") + } + certPair, err := tls.X509KeyPair(cert, key) + if err != nil { + return tls.Certificate{}, err + } + certPair.Leaf, err = x509.ParseCertificate(certPair.Certificate[0]) + if err != nil { + return tls.Certificate{}, err + } + return certPair, nil +} + +// NewServerCert generates a new self-signed certificate. The first +// return value is usable with a *tls.Config. The next two values are +// the PEM-encoded certificate (which must be shared with clients for +// them to trust the server) and key (which should not be shared). +// All three will be zero values if the returned error is not nil. +func NewServerCert() (certBytes, keyBytes []byte, err error) { + return newCert(false) +} + +// NewClientCert is like NewServerCert, but it produces a certificate that +// is intended for client authentication. +func NewClientCert() (certBytes, keyBytes []byte, err error) { + return newCert(true) +} + +func newCert(isClientCert bool) ([]byte, []byte, error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, fmt.Errorf("failed to generated RSA key: %w", err) + } + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate serial number: %w", err) + } + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"ConnectRPC"}, + }, + NotBefore: time.Now().Add(-time.Hour * 24), + NotAfter: time.Now().Add(time.Hour * 24 * 7), + + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + } + if isClientCert { + template.Subject.CommonName = ClientCertName + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} + } else { + template.Subject.CommonName = ServerCertName + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} + template.IPAddresses = []net.IP{net.IPv6loopback, net.IPv4(127, 0, 0, 1)} + template.DNSNames = []string{"localhost"} + } + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return nil, nil, fmt.Errorf("failed to create certificate: %w", err) + } + + certBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + keyBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + return certBytes, keyBytes, nil +} diff --git a/internal/conformance/internal/version.go b/internal/conformance/internal/version.go new file mode 100644 index 00000000..eb82d6f8 --- /dev/null +++ b/internal/conformance/internal/version.go @@ -0,0 +1,25 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +//nolint:gochecknoglobals +var ( + // NB: These are vars instead of consts so they can be changed via -X ldflags. + buildVersion = "v1.0.5" + buildVersionSuffix = "" + + // Version is the version to report from all binaries. + Version = buildVersion + buildVersionSuffix +) diff --git a/internal/conformance/runconformance.sh b/internal/conformance/runconformance.sh index db4a5f5b..ef756c19 100755 --- a/internal/conformance/runconformance.sh +++ b/internal/conformance/runconformance.sh @@ -6,13 +6,16 @@ BINDIR="../../.tmp/bin" mkdir -p $BINDIR GO="${GO:-go}" -# These will get built using current HEAD of this connect-go repo -# thanks to replace directive in go.mod. So by testing the reference -# implementations (which are written with connect-go), we are effectively -# testing changes in this repo. +# connectconformance is the upstream test runner. The reference client and +# server are built from this repo's local copies (./cmd/...) because they've +# been ported to connect-go v2 and aren't upstreamed yet; via the go.mod +# replace directive they exercise this repo's HEAD, so running them effectively +# tests changes in this repo. Once the v2 reference implementations are +# upstreamed, these will return to being built from +# connectrpc.com/conformance/cmd/reference{client,server}. $GO build -o $BINDIR/connectconformance connectrpc.com/conformance/cmd/connectconformance -$GO build -o $BINDIR/referenceclient connectrpc.com/conformance/cmd/referenceclient -$GO build -o $BINDIR/referenceserver connectrpc.com/conformance/cmd/referenceserver +$GO build -o $BINDIR/referenceclient ./cmd/referenceclient +$GO build -o $BINDIR/referenceserver ./cmd/referenceserver echo "Running conformance tests against client..." $BINDIR/connectconformance --mode client --conf config.yaml -v --trace -- $BINDIR/referenceclient diff --git a/internal/conformance/tools.go b/internal/conformance/tools.go index 5b2b76d5..77c4b080 100644 --- a/internal/conformance/tools.go +++ b/internal/conformance/tools.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build tools + package tools import ( diff --git a/internal/example/README.md b/internal/example/README.md index caf5b871..b1986a7d 100644 --- a/internal/example/README.md +++ b/internal/example/README.md @@ -1,14 +1,14 @@ # Simple example -This is the example from the [README.md](../../.README.md). +This is the example from the [README](../../README.md), runnable end to end. -In one terminal: +In one terminal, start the server: ```bash go run ./server ``` -In another terminal: +In another, run the client: ```bash go run ./client diff --git a/internal/example/client/main.go b/internal/example/client/main.go index 45e6ee94..bb4f3269 100644 --- a/internal/example/client/main.go +++ b/internal/example/client/main.go @@ -16,22 +16,52 @@ package main import ( "context" + "errors" + "io" "log" "net/http" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + v1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + pingv1connect "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" ) +// clientLoggingInterceptor logs each call before the stream is opened. +// Interceptors are passed to connect.NewClient and run in argument order. +func clientLoggingInterceptor(next connect.ClientFunc) connect.ClientFunc { + return func(ctx context.Context, spec connect.Spec) (connect.ClientStream, error) { + log.Printf("calling %s", spec.Procedure) + return next(ctx, spec) + } +} + func main() { - client := pingv1connect.NewPingServiceClient( - http.DefaultClient, - "http://localhost:8080/", + ctx := context.Background() + client := connect.NewClient( + connecthttp.NewTransport(http.DefaultClient, "http://localhost:8080"), + clientLoggingInterceptor, ) - req := &pingv1.PingRequest{Number: 42} - res, err := client.Ping(context.Background(), req) + pingClient := pingv1connect.NewPingServiceClient(client) + + res, err := pingClient.Ping(ctx, &v1.PingRequest{Number: 42, Text: "hello"}) if err != nil { - log.Fatalln(err) + log.Fatalf("Ping: %v", err) + } + log.Printf("Ping: number=%d text=%q", res.Number, res.Text) + + stream, err := pingClient.CountUp(ctx, &v1.CountUpRequest{Number: 3}) + if err != nil { + log.Fatalf("CountUp: %v", err) + } + for { + msg, err := stream.Receive() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + log.Fatalf("CountUp.Receive: %v", err) + } + log.Printf("CountUp: %d", msg.Number) } - log.Println(res) } diff --git a/internal/example/go.mod b/internal/example/go.mod index 6e9063d1..b23624fe 100644 --- a/internal/example/go.mod +++ b/internal/example/go.mod @@ -1,22 +1,9 @@ -module connectrpc.com/connect/internal/example +module connectrpc.com/connect/v2/internal/example go 1.25.0 -require ( - connectrpc.com/connect v1.19.0 - connectrpc.com/validate v0.6.0 -) +require connectrpc.com/connect/v2 v2.0.0-00010101000000-000000000000 -require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 // indirect - buf.build/go/protovalidate v1.0.0 // indirect - cel.dev/expr v0.25.1 // indirect - github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/google/cel-go v0.29.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect - golang.org/x/text v0.29.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 // indirect - google.golang.org/protobuf v1.36.10 // indirect -) +require google.golang.org/protobuf v1.36.11 // indirect + +replace connectrpc.com/connect/v2 => ../../ diff --git a/internal/example/go.sum b/internal/example/go.sum index 3bd30cd0..296be183 100644 --- a/internal/example/go.sum +++ b/internal/example/go.sum @@ -1,43 +1,4 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 h1:DQLS/rRxLHuugVzjJU5AvOwD57pdFl9he/0O7e5P294= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1/go.mod h1:aY3zbkNan5F+cGm9lITDP6oxJIwu0dn9KjJuJjWaHkg= -buf.build/go/protovalidate v1.0.0 h1:IAG1etULddAy93fiBsFVhpj7es5zL53AfB/79CVGtyY= -buf.build/go/protovalidate v1.0.0/go.mod h1:KQmEUrcQuC99hAw+juzOEAmILScQiKBP1Oc36vvCLW8= -cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -connectrpc.com/connect v1.19.0 h1:LuqUbq01PqbtL0o7vn0WMRXzR2nNsiINe5zfcJ24pJM= -connectrpc.com/connect v1.19.0/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= -connectrpc.com/validate v0.6.0 h1:DcrgDKt2ZScrUs/d/mh9itD2yeEa0UbBBa+i0mwzx+4= -connectrpc.com/validate v0.6.0/go.mod h1:ihrpI+8gVbLH1fvVWJL1I3j0CfWnF8P/90LsmluRiZs= -github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= -github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4= -github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= -golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9 h1:jm6v6kMRpTYKxBRrDkYAitNJegUeO1Mf3Kt80obv0gg= -google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9/go.mod h1:LmwNphe5Afor5V3R5BppOULHOnt2mCIf+NxMd4XiygE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/internal/example/server/main.go b/internal/example/server/main.go index 9bfd1c92..f08ee4f4 100644 --- a/internal/example/server/main.go +++ b/internal/example/server/main.go @@ -16,46 +16,107 @@ package main import ( "context" + "errors" + "io" "log" "net/http" - "connectrpc.com/connect" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/simple/connect/ping/v1/pingv1connect" - "connectrpc.com/validate" + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connecthttp" + v1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + pingv1connect "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" ) -type PingServer struct { - pingv1connect.UnimplementedPingServiceHandler // returns errors from all methods +// pingServer implements pingv1connect.PingServiceHandler. Embedding +// UnimplementedPingServiceHandler returns CodeUnimplemented from any +// method the implementation does not define, keeping it forward +// compatible as the service schema grows. +type pingServer struct { + pingv1connect.UnimplementedPingServiceHandler } -func (ps *PingServer) Ping(ctx context.Context, req *pingv1.PingRequest) (*pingv1.PingResponse, error) { - return &pingv1.PingResponse{ - Number: req.Number, - }, nil +func (pingServer) Ping(ctx context.Context, req *v1.PingRequest) (*v1.PingResponse, error) { + return &v1.PingResponse{Number: req.Number, Text: req.Text}, nil +} + +func (pingServer) Fail(ctx context.Context, req *v1.FailRequest) (*v1.FailResponse, error) { + return &v1.FailResponse{}, nil +} + +func (pingServer) Sum(ctx context.Context, stream pingv1connect.PingServiceSumServerStream) (*v1.SumResponse, error) { + var total int64 + for { + req, err := stream.Receive() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + total += req.Number + } + return &v1.SumResponse{Sum: total}, nil +} + +func (pingServer) CountUp(ctx context.Context, req *v1.CountUpRequest, stream pingv1connect.PingServiceCountUpServerStream) error { + for i := int64(1); i <= req.Number; i++ { + if err := stream.Send(&v1.CountUpResponse{Number: i}); err != nil { + return err + } + } + return nil +} + +func (pingServer) CumSum(ctx context.Context, stream pingv1connect.PingServiceCumSumServerStream) error { + var total int64 + for { + req, err := stream.Receive() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + total += req.Number + if err := stream.Send(&v1.CumSumResponse{Sum: total}); err != nil { + return err + } + } +} + +// serverLoggingInterceptor logs RPCs that fail. Interceptors are passed to +// connect.NewServer and run before any payload work. +func serverLoggingInterceptor(next connect.ServerFunc) connect.ServerFunc { + return func(ctx context.Context, spec connect.Spec, stream connect.ServerStream) error { + err := next(ctx, spec, stream) + if err != nil { + log.Printf("rpc failed: procedure=%s error=%v", spec.Procedure, err) + } + return err + } } func main() { + server := connect.NewServer(serverLoggingInterceptor) + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + mux := http.NewServeMux() - // The generated constructors return a path and a plain net/http - // handler. - mux.Handle( - pingv1connect.NewPingServiceHandler( - &PingServer{}, - // Validation via Protovalidate is almost always recommended - connect.WithInterceptors(validate.NewInterceptor()), - ), - ) - p := new(http.Protocols) - p.SetHTTP1(true) - // For gRPC clients, it's convenient to support HTTP/2 without TLS. - p.SetUnencryptedHTTP2(true) - s := &http.Server{ + connecthttp.Mount(mux, server) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + // For gRPC clients, it is convenient to support HTTP/2 without TLS. + protocols.SetUnencryptedHTTP2(true) + httpServer := &http.Server{ Addr: "localhost:8080", Handler: mux, - Protocols: p, + Protocols: protocols, } - if err := s.ListenAndServe(); err != nil { - log.Fatalf("listen failed: %v", err) + log.Println("listening on", httpServer.Addr) + if err := httpServer.ListenAndServe(); err != nil { + log.Fatal(err) } } diff --git a/internal/example/server/main_test.go b/internal/example/server/main_test.go new file mode 100644 index 00000000..f9d97004 --- /dev/null +++ b/internal/example/server/main_test.go @@ -0,0 +1,46 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "connectrpc.com/connect/v2" + "connectrpc.com/connect/v2/connectinprocess" + v1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + pingv1connect "connectrpc.com/connect/v2/internal/gen/connect/ping/v1/pingv1connect" +) + +// newTestClient builds a client that dispatches directly to the server in the +// same process, with no listener and no serialization. +func newTestClient(tb testing.TB) pingv1connect.PingServiceClient { + tb.Helper() + server := connect.NewServer() + pingv1connect.RegisterPingServiceHandler(server, pingServer{}) + return pingv1connect.NewPingServiceClient( + connect.NewClient(connectinprocess.New(server)), + ) +} + +func TestPing(t *testing.T) { + client := newTestClient(t) + res, err := client.Ping(t.Context(), &v1.PingRequest{Number: 42, Text: "hello"}) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if res.Number != 42 || res.Text != "hello" { + t.Errorf("got Number=%d Text=%q; want 42 %q", res.Number, res.Text, "hello") + } +} diff --git a/internal/gen/connect/collide/v1/collide.pb.go b/internal/gen/connect/collide/v1/collide.pb.go index 6df71d4f..53c32e0d 100644 --- a/internal/gen/connect/collide/v1/collide.pb.go +++ b/internal/gen/connect/collide/v1/collide.pb.go @@ -115,8 +115,8 @@ const file_connect_collide_v1_collide_proto_rawDesc = "" + "\rImportRequest\"\x10\n" + "\x0eImportResponse2c\n" + "\x0eCollideService\x12Q\n" + - "\x06Import\x12!.connect.collide.v1.ImportRequest\x1a\".connect.collide.v1.ImportResponse\"\x00B\xd2\x01\n" + - "\x16com.connect.collide.v1B\fCollideProtoP\x01Z@connectrpc.com/connect/internal/gen/connect/collide/v1;collidev1\xa2\x02\x03CCX\xaa\x02\x12Connect.Collide.V1\xca\x02\x12Connect\\Collide\\V1\xe2\x02\x1eConnect\\Collide\\V1\\GPBMetadata\xea\x02\x14Connect::Collide::V1b\x06proto3" + "\x06Import\x12!.connect.collide.v1.ImportRequest\x1a\".connect.collide.v1.ImportResponse\"\x00B\xd5\x01\n" + + "\x16com.connect.collide.v1B\fCollideProtoP\x01ZCconnectrpc.com/connect/v2/internal/gen/connect/collide/v1;collidev1\xa2\x02\x03CCX\xaa\x02\x12Connect.Collide.V1\xca\x02\x12Connect\\Collide\\V1\xe2\x02\x1eConnect\\Collide\\V1\\GPBMetadata\xea\x02\x14Connect::Collide::V1b\x06proto3" var ( file_connect_collide_v1_collide_proto_rawDescOnce sync.Once diff --git a/internal/gen/connect/collide/v1/collidev1connect/collide.connect.go b/internal/gen/connect/collide/v1/collidev1connect/collide.connect.go new file mode 100644 index 00000000..045aa259 --- /dev/null +++ b/internal/gen/connect/collide/v1/collidev1connect/collide.connect.go @@ -0,0 +1,111 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: connect/collide/v1/collide.proto + +package collidev1connect + +import ( + connect "connectrpc.com/connect/v2" + v1 "connectrpc.com/connect/v2/internal/gen/connect/collide/v1" + context "context" + sync "sync" +) + +const ( + // CollideServiceName is the fully-qualified name of the CollideService service. + CollideServiceName = "connect.collide.v1.CollideService" +) + +// These constants are the procedure names of the RPCs defined in this package. They're exposed at +// runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // CollideServiceImportProcedure is the procedure name of the CollideService's Import RPC. + CollideServiceImportProcedure = "/connect.collide.v1.CollideService/Import" +) + +var ( + collideServiceImportSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: v1.File_connect_collide_v1_collide_proto.Services().ByName("CollideService").Methods().ByName("Import"), + Procedure: CollideServiceImportProcedure, + } + }) +) + +// CollideServiceClient is a client for the connect.collide.v1.CollideService service. +type CollideServiceClient interface { + Import(context.Context, *v1.ImportRequest) (*v1.ImportResponse, error) +} + +// NewCollideServiceClient constructs a client for the connect.collide.v1.CollideService service. +// Multiple service clients may share a single connect.Client. +func NewCollideServiceClient(client *connect.Client) CollideServiceClient { + return &collideServiceClient{client: client} +} + +// CollideServiceHandler is an implementation of the connect.collide.v1.CollideService service. +type CollideServiceHandler interface { + Import(context.Context, *v1.ImportRequest) (*v1.ImportResponse, error) +} + +// RegisterCollideServiceHandler registers svc as the connect.collide.v1.CollideService +// implementation on server. +func RegisterCollideServiceHandler(server *connect.Server, svc CollideServiceHandler) { + adapter := collideServiceHandler{svc: svc} + server.Register( + connect.Method{Spec: collideServiceImportSpec(), Handler: adapter._import}, + ) +} + +// UnimplementedCollideServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedCollideServiceHandler struct{} + +func (UnimplementedCollideServiceHandler) Import(context.Context, *v1.ImportRequest) (*v1.ImportResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connect.collide.v1.CollideService.Import is not implemented") +} + +type collideServiceClient struct { + client *connect.Client +} + +func (c *collideServiceClient) Import(ctx context.Context, req *v1.ImportRequest) (*v1.ImportResponse, error) { + var res v1.ImportResponse + if err := c.client.CallUnary(ctx, collideServiceImportSpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +type collideServiceHandler struct{ svc CollideServiceHandler } + +func (h collideServiceHandler) _import(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.ImportRequest + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.Import(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) +} diff --git a/internal/gen/connect/import/v1/import.pb.go b/internal/gen/connect/import/v1/import.pb.go index 1e7394ac..878628c6 100644 --- a/internal/gen/connect/import/v1/import.pb.go +++ b/internal/gen/connect/import/v1/import.pb.go @@ -39,8 +39,8 @@ var File_connect_import_v1_import_proto protoreflect.FileDescriptor const file_connect_import_v1_import_proto_rawDesc = "" + "\n" + "\x1econnect/import/v1/import.proto\x12\x11connect.import.v12\x0f\n" + - "\rImportServiceB\xca\x01\n" + - "\x15com.connect.import.v1B\vImportProtoP\x01Z>connectrpc.com/connect/internal/gen/connect/import/v1;importv1\xa2\x02\x03CIX\xaa\x02\x11Connect.Import.V1\xca\x02\x11Connect\\Import\\V1\xe2\x02\x1dConnect\\Import\\V1\\GPBMetadata\xea\x02\x13Connect::Import::V1b\x06proto3" + "\rImportServiceB\xcd\x01\n" + + "\x15com.connect.import.v1B\vImportProtoP\x01ZAconnectrpc.com/connect/v2/internal/gen/connect/import/v1;importv1\xa2\x02\x03CIX\xaa\x02\x11Connect.Import.V1\xca\x02\x11Connect\\Import\\V1\xe2\x02\x1dConnect\\Import\\V1\\GPBMetadata\xea\x02\x13Connect::Import::V1b\x06proto3" var file_connect_import_v1_import_proto_goTypes = []any{} var file_connect_import_v1_import_proto_depIdxs = []int32{ diff --git a/internal/gen/connect/import/v1/importv1connect/import.connect.go b/internal/gen/connect/import/v1/importv1connect/import.connect.go new file mode 100644 index 00000000..5c5029fb --- /dev/null +++ b/internal/gen/connect/import/v1/importv1connect/import.connect.go @@ -0,0 +1,58 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: connect/import/v1/import.proto + +package importv1connect + +import ( + connect "connectrpc.com/connect/v2" + _ "connectrpc.com/connect/v2/internal/gen/connect/import/v1" +) + +const ( + // ImportServiceName is the fully-qualified name of the ImportService service. + ImportServiceName = "connect.import.v1.ImportService" +) + +// ImportServiceClient is a client for the connect.import.v1.ImportService service. +type ImportServiceClient interface { +} + +// NewImportServiceClient constructs a client for the connect.import.v1.ImportService service. +// Multiple service clients may share a single connect.Client. +func NewImportServiceClient(client *connect.Client) ImportServiceClient { + return &importServiceClient{client: client} +} + +// ImportServiceHandler is an implementation of the connect.import.v1.ImportService service. +type ImportServiceHandler interface { +} + +// RegisterImportServiceHandler registers svc as the connect.import.v1.ImportService implementation +// on server. +func RegisterImportServiceHandler(server *connect.Server, svc ImportServiceHandler) { + server.Register() +} + +// UnimplementedImportServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedImportServiceHandler struct{} + +type importServiceClient struct { + client *connect.Client +} + +type importServiceHandler struct{ svc ImportServiceHandler } diff --git a/internal/gen/connect/ping/v1/ping.pb.go b/internal/gen/connect/ping/v1/ping.pb.go index 19864928..cc28cdb2 100644 --- a/internal/gen/connect/ping/v1/ping.pb.go +++ b/internal/gen/connect/ping/v1/ping.pb.go @@ -521,8 +521,8 @@ const file_connect_ping_v1_ping_proto_rawDesc = "" + "\x04Fail\x12\x1c.connect.ping.v1.FailRequest\x1a\x1d.connect.ping.v1.FailResponse\"\x00\x12D\n" + "\x03Sum\x12\x1b.connect.ping.v1.SumRequest\x1a\x1c.connect.ping.v1.SumResponse\"\x00(\x01\x12P\n" + "\aCountUp\x12\x1f.connect.ping.v1.CountUpRequest\x1a .connect.ping.v1.CountUpResponse\"\x000\x01\x12O\n" + - "\x06CumSum\x12\x1e.connect.ping.v1.CumSumRequest\x1a\x1f.connect.ping.v1.CumSumResponse\"\x00(\x010\x01B\xba\x01\n" + - "\x13com.connect.ping.v1B\tPingProtoP\x01Z:connectrpc.com/connect/internal/gen/connect/ping/v1;pingv1\xa2\x02\x03CPX\xaa\x02\x0fConnect.Ping.V1\xca\x02\x0fConnect\\Ping\\V1\xe2\x02\x1bConnect\\Ping\\V1\\GPBMetadata\xea\x02\x11Connect::Ping::V1b\x06proto3" + "\x06CumSum\x12\x1e.connect.ping.v1.CumSumRequest\x1a\x1f.connect.ping.v1.CumSumResponse\"\x00(\x010\x01B\xbd\x01\n" + + "\x13com.connect.ping.v1B\tPingProtoP\x01Z=connectrpc.com/connect/v2/internal/gen/connect/ping/v1;pingv1\xa2\x02\x03CPX\xaa\x02\x0fConnect.Ping.V1\xca\x02\x0fConnect\\Ping\\V1\xe2\x02\x1bConnect\\Ping\\V1\\GPBMetadata\xea\x02\x11Connect::Ping::V1b\x06proto3" var ( file_connect_ping_v1_ping_proto_rawDescOnce sync.Once diff --git a/internal/gen/connect/ping/v1/pingv1connect/ping.connect.go b/internal/gen/connect/ping/v1/pingv1connect/ping.connect.go new file mode 100644 index 00000000..22e6da3b --- /dev/null +++ b/internal/gen/connect/ping/v1/pingv1connect/ping.connect.go @@ -0,0 +1,393 @@ +// Copyright 2021-2026 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The canonical location for this file is +// https://github.com/connectrpc/connect-go/blob/main/internal/proto/connect/ping/v1/ping.proto. + +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: connect/ping/v1/ping.proto + +// The connect.ping.v1 package contains an echo service designed to test the +// connect-go implementation. +package pingv1connect + +import ( + connect "connectrpc.com/connect/v2" + v1 "connectrpc.com/connect/v2/internal/gen/connect/ping/v1" + context "context" + sync "sync" +) + +const ( + // PingServiceName is the fully-qualified name of the PingService service. + PingServiceName = "connect.ping.v1.PingService" +) + +// These constants are the procedure names of the RPCs defined in this package. They're exposed at +// runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // PingServicePingProcedure is the procedure name of the PingService's Ping RPC. + PingServicePingProcedure = "/connect.ping.v1.PingService/Ping" + // PingServiceFailProcedure is the procedure name of the PingService's Fail RPC. + PingServiceFailProcedure = "/connect.ping.v1.PingService/Fail" + // PingServiceSumProcedure is the procedure name of the PingService's Sum RPC. + PingServiceSumProcedure = "/connect.ping.v1.PingService/Sum" + // PingServiceCountUpProcedure is the procedure name of the PingService's CountUp RPC. + PingServiceCountUpProcedure = "/connect.ping.v1.PingService/CountUp" + // PingServiceCumSumProcedure is the procedure name of the PingService's CumSum RPC. + PingServiceCumSumProcedure = "/connect.ping.v1.PingService/CumSum" +) + +var ( + pingServicePingSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: v1.File_connect_ping_v1_ping_proto.Services().ByName("PingService").Methods().ByName("Ping"), + Procedure: PingServicePingProcedure, + IdempotencyLevel: connect.IdempotencyNoSideEffects, + } + }) + pingServiceFailSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeUnary, + Schema: v1.File_connect_ping_v1_ping_proto.Services().ByName("PingService").Methods().ByName("Fail"), + Procedure: PingServiceFailProcedure, + } + }) + pingServiceSumSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeClient, + Schema: v1.File_connect_ping_v1_ping_proto.Services().ByName("PingService").Methods().ByName("Sum"), + Procedure: PingServiceSumProcedure, + } + }) + pingServiceCountUpSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeServer, + Schema: v1.File_connect_ping_v1_ping_proto.Services().ByName("PingService").Methods().ByName("CountUp"), + Procedure: PingServiceCountUpProcedure, + } + }) + pingServiceCumSumSpec = sync.OnceValue(func() connect.Spec { + return connect.Spec{ + StreamType: connect.StreamTypeBidi, + Schema: v1.File_connect_ping_v1_ping_proto.Services().ByName("PingService").Methods().ByName("CumSum"), + Procedure: PingServiceCumSumProcedure, + } + }) +) + +// PingServiceClient is a client for the connect.ping.v1.PingService service. +type PingServiceClient interface { + // Ping sends a ping to the server to determine if it's reachable. + Ping(context.Context, *v1.PingRequest) (*v1.PingResponse, error) + // Fail always fails. + Fail(context.Context, *v1.FailRequest) (*v1.FailResponse, error) + // Sum calculates the sum of the numbers sent on the stream. + Sum(context.Context) (PingServiceSumClientStream, error) + // CountUp returns a stream of the numbers up to the given request. + CountUp(context.Context, *v1.CountUpRequest) (PingServiceCountUpClientStream, error) + // CumSum determines the cumulative sum of all the numbers sent on the stream. + CumSum(context.Context) (PingServiceCumSumClientStream, error) +} + +// NewPingServiceClient constructs a client for the connect.ping.v1.PingService service. Multiple +// service clients may share a single connect.Client. +func NewPingServiceClient(client *connect.Client) PingServiceClient { + return &pingServiceClient{client: client} +} + +// PingServiceSumClientStream is the client stream for the PingService's Sum RPC. +type PingServiceSumClientStream struct { + stream connect.ClientStream +} + +// SendHeaders opens the stream and flushes the request headers without a message. The first Send or +// Receive does this implicitly. +func (s PingServiceSumClientStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a request message to the server. +func (s PingServiceSumClientStream) Send(req *v1.SumRequest) error { + return s.stream.Send(req) +} + +// CloseAndReceive closes the request side of the stream and returns the single response message. It +// reads the stream to completion to release its resources. +func (s PingServiceSumClientStream) CloseAndReceive() (*v1.SumResponse, error) { + if err := s.stream.CloseSend(); err != nil { + return nil, err + } + var res v1.SumResponse + if err := s.stream.Receive(&res); err != nil { + return nil, err + } + return &res, nil +} + +// PingServiceCountUpClientStream is the client stream for the PingService's CountUp RPC. +type PingServiceCountUpClientStream struct { + stream connect.ClientStream +} + +// Receive returns the next response message from the server. +func (s PingServiceCountUpClientStream) Receive() (*v1.CountUpResponse, error) { + var res v1.CountUpResponse + if err := s.stream.Receive(&res); err != nil { + return nil, err + } + return &res, nil +} + +// Close releases the stream's resources. It is idempotent and is typically deferred to clean up a +// stream abandoned before io.EOF. +func (s PingServiceCountUpClientStream) Close() error { + return s.stream.Close() +} + +// PingServiceCumSumClientStream is the client stream for the PingService's CumSum RPC. +type PingServiceCumSumClientStream struct { + stream connect.ClientStream +} + +// SendHeaders opens the stream and flushes the request headers without a message. The first Send or +// Receive does this implicitly. +func (s PingServiceCumSumClientStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a request message to the server. +func (s PingServiceCumSumClientStream) Send(req *v1.CumSumRequest) error { + return s.stream.Send(req) +} + +// CloseSend closes the request side of the stream. +func (s PingServiceCumSumClientStream) CloseSend() error { + return s.stream.CloseSend() +} + +// Receive returns the next response message from the server. +func (s PingServiceCumSumClientStream) Receive() (*v1.CumSumResponse, error) { + var res v1.CumSumResponse + if err := s.stream.Receive(&res); err != nil { + return nil, err + } + return &res, nil +} + +// Close releases the stream's resources. It is idempotent and is typically deferred to clean up a +// stream abandoned before io.EOF. +func (s PingServiceCumSumClientStream) Close() error { + return s.stream.Close() +} + +// PingServiceHandler is an implementation of the connect.ping.v1.PingService service. +type PingServiceHandler interface { + // Ping sends a ping to the server to determine if it's reachable. + Ping(context.Context, *v1.PingRequest) (*v1.PingResponse, error) + // Fail always fails. + Fail(context.Context, *v1.FailRequest) (*v1.FailResponse, error) + // Sum calculates the sum of the numbers sent on the stream. + Sum(context.Context, PingServiceSumServerStream) (*v1.SumResponse, error) + // CountUp returns a stream of the numbers up to the given request. + CountUp(context.Context, *v1.CountUpRequest, PingServiceCountUpServerStream) error + // CumSum determines the cumulative sum of all the numbers sent on the stream. + CumSum(context.Context, PingServiceCumSumServerStream) error +} + +// RegisterPingServiceHandler registers svc as the connect.ping.v1.PingService implementation on +// server. +func RegisterPingServiceHandler(server *connect.Server, svc PingServiceHandler) { + adapter := pingServiceHandler{svc: svc} + server.Register( + connect.Method{Spec: pingServicePingSpec(), Handler: adapter.ping}, + connect.Method{Spec: pingServiceFailSpec(), Handler: adapter.fail}, + connect.Method{Spec: pingServiceSumSpec(), Handler: adapter.sum}, + connect.Method{Spec: pingServiceCountUpSpec(), Handler: adapter.countUp}, + connect.Method{Spec: pingServiceCumSumSpec(), Handler: adapter.cumSum}, + ) +} + +// PingServiceSumServerStream is the server stream for the PingService's Sum RPC. +type PingServiceSumServerStream struct { + stream connect.ServerStream +} + +// Receive returns the next request message from the client. +func (s PingServiceSumServerStream) Receive() (*v1.SumRequest, error) { + var req v1.SumRequest + if err := s.stream.Receive(&req); err != nil { + return nil, err + } + return &req, nil +} + +// PingServiceCountUpServerStream is the server stream for the PingService's CountUp RPC. +type PingServiceCountUpServerStream struct { + stream connect.ServerStream +} + +// SendHeaders flushes the response headers without a message. The first Send does this implicitly. +func (s PingServiceCountUpServerStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a response message to the client. +func (s PingServiceCountUpServerStream) Send(res *v1.CountUpResponse) error { + return s.stream.Send(res) +} + +// PingServiceCumSumServerStream is the server stream for the PingService's CumSum RPC. +type PingServiceCumSumServerStream struct { + stream connect.ServerStream +} + +// Receive returns the next request message from the client. +func (s PingServiceCumSumServerStream) Receive() (*v1.CumSumRequest, error) { + var req v1.CumSumRequest + if err := s.stream.Receive(&req); err != nil { + return nil, err + } + return &req, nil +} + +// SendHeaders flushes the response headers without a message. The first Send does this implicitly. +func (s PingServiceCumSumServerStream) SendHeaders() error { + return s.stream.SendHeaders() +} + +// Send sends a response message to the client. +func (s PingServiceCumSumServerStream) Send(res *v1.CumSumResponse) error { + return s.stream.Send(res) +} + +// UnimplementedPingServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedPingServiceHandler struct{} + +func (UnimplementedPingServiceHandler) Ping(context.Context, *v1.PingRequest) (*v1.PingResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connect.ping.v1.PingService.Ping is not implemented") +} + +func (UnimplementedPingServiceHandler) Fail(context.Context, *v1.FailRequest) (*v1.FailResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connect.ping.v1.PingService.Fail is not implemented") +} + +func (UnimplementedPingServiceHandler) Sum(context.Context, PingServiceSumServerStream) (*v1.SumResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, "connect.ping.v1.PingService.Sum is not implemented") +} + +func (UnimplementedPingServiceHandler) CountUp(context.Context, *v1.CountUpRequest, PingServiceCountUpServerStream) error { + return connect.NewError(connect.CodeUnimplemented, "connect.ping.v1.PingService.CountUp is not implemented") +} + +func (UnimplementedPingServiceHandler) CumSum(context.Context, PingServiceCumSumServerStream) error { + return connect.NewError(connect.CodeUnimplemented, "connect.ping.v1.PingService.CumSum is not implemented") +} + +type pingServiceClient struct { + client *connect.Client +} + +func (c *pingServiceClient) Ping(ctx context.Context, req *v1.PingRequest) (*v1.PingResponse, error) { + var res v1.PingResponse + if err := c.client.CallUnary(ctx, pingServicePingSpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c *pingServiceClient) Fail(ctx context.Context, req *v1.FailRequest) (*v1.FailResponse, error) { + var res v1.FailResponse + if err := c.client.CallUnary(ctx, pingServiceFailSpec(), req, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (c *pingServiceClient) Sum(ctx context.Context) (PingServiceSumClientStream, error) { + stream, err := c.client.CallClientStream(ctx, pingServiceSumSpec()) + if err != nil { + return PingServiceSumClientStream{}, err + } + return PingServiceSumClientStream{stream: stream}, nil +} + +func (c *pingServiceClient) CountUp(ctx context.Context, req *v1.CountUpRequest) (PingServiceCountUpClientStream, error) { + stream, err := c.client.CallServerStream(ctx, pingServiceCountUpSpec(), req) + if err != nil { + return PingServiceCountUpClientStream{}, err + } + return PingServiceCountUpClientStream{stream: stream}, nil +} + +func (c *pingServiceClient) CumSum(ctx context.Context) (PingServiceCumSumClientStream, error) { + stream, err := c.client.CallClientStream(ctx, pingServiceCumSumSpec()) + if err != nil { + return PingServiceCumSumClientStream{}, err + } + return PingServiceCumSumClientStream{stream: stream}, nil +} + +type pingServiceHandler struct{ svc PingServiceHandler } + +func (h pingServiceHandler) ping(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.PingRequest + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.Ping(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) +} + +func (h pingServiceHandler) fail(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.FailRequest + if err := stream.Receive(&req); err != nil { + return err + } + res, err := h.svc.Fail(ctx, &req) + if err != nil { + return err + } + return stream.Send(res) +} + +func (h pingServiceHandler) sum(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + res, err := h.svc.Sum(ctx, PingServiceSumServerStream{stream: stream}) + if err != nil { + return err + } + return stream.Send(res) +} + +func (h pingServiceHandler) countUp(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + var req v1.CountUpRequest + if err := stream.Receive(&req); err != nil { + return err + } + return h.svc.CountUp(ctx, &req, PingServiceCountUpServerStream{stream: stream}) +} + +func (h pingServiceHandler) cumSum(ctx context.Context, _ connect.Spec, stream connect.ServerStream) error { + return h.svc.CumSum(ctx, PingServiceCumSumServerStream{stream: stream}) +} diff --git a/internal/gen/connectext/grpc/status/v1/status.pb.go b/internal/gen/connectext/grpc/status/v1/status.pb.go deleted file mode 100644 index d7bf60de..00000000 --- a/internal/gen/connectext/grpc/status/v1/status.pb.go +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: connectext/grpc/status/v1/status.proto - -// This package is for internal use by Connect, and provides no backward -// compatibility guarantees whatsoever. - -package statusv1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - anypb "google.golang.org/protobuf/types/known/anypb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// See https://cloud.google.com/apis/design/errors. -// -// This struct must remain binary-compatible with -// https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto. -type Status struct { - state protoimpl.MessageState `protogen:"open.v1"` - Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"` // a google.rpc.Code - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // developer-facing, English (localize in details or client-side) - Details []*anypb.Any `protobuf:"bytes,3,rep,name=details,proto3" json:"details,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Status) Reset() { - *x = Status{} - mi := &file_connectext_grpc_status_v1_status_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Status) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Status) ProtoMessage() {} - -func (x *Status) ProtoReflect() protoreflect.Message { - mi := &file_connectext_grpc_status_v1_status_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Status.ProtoReflect.Descriptor instead. -func (*Status) Descriptor() ([]byte, []int) { - return file_connectext_grpc_status_v1_status_proto_rawDescGZIP(), []int{0} -} - -func (x *Status) GetCode() int32 { - if x != nil { - return x.Code - } - return 0 -} - -func (x *Status) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -func (x *Status) GetDetails() []*anypb.Any { - if x != nil { - return x.Details - } - return nil -} - -var File_connectext_grpc_status_v1_status_proto protoreflect.FileDescriptor - -const file_connectext_grpc_status_v1_status_proto_rawDesc = "" + - "\n" + - "&connectext/grpc/status/v1/status.proto\x12\x0egrpc.status.v1\x1a\x19google/protobuf/any.proto\"f\n" + - "\x06Status\x12\x12\n" + - "\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\x12.\n" + - "\adetails\x18\x03 \x03(\v2\x14.google.protobuf.AnyR\adetailsB\xc3\x01\n" + - "\x12com.grpc.status.v1B\vStatusProtoP\x01ZFconnectrpc.com/connect/internal/gen/connectext/grpc/status/v1;statusv1\xa2\x02\x03GSX\xaa\x02\x0eGrpc.Status.V1\xca\x02\x0eGrpc\\Status\\V1\xe2\x02\x1aGrpc\\Status\\V1\\GPBMetadata\xea\x02\x10Grpc::Status::V1b\x06proto3" - -var ( - file_connectext_grpc_status_v1_status_proto_rawDescOnce sync.Once - file_connectext_grpc_status_v1_status_proto_rawDescData []byte -) - -func file_connectext_grpc_status_v1_status_proto_rawDescGZIP() []byte { - file_connectext_grpc_status_v1_status_proto_rawDescOnce.Do(func() { - file_connectext_grpc_status_v1_status_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_connectext_grpc_status_v1_status_proto_rawDesc), len(file_connectext_grpc_status_v1_status_proto_rawDesc))) - }) - return file_connectext_grpc_status_v1_status_proto_rawDescData -} - -var file_connectext_grpc_status_v1_status_proto_msgTypes = make([]protoimpl.MessageInfo, 1) -var file_connectext_grpc_status_v1_status_proto_goTypes = []any{ - (*Status)(nil), // 0: grpc.status.v1.Status - (*anypb.Any)(nil), // 1: google.protobuf.Any -} -var file_connectext_grpc_status_v1_status_proto_depIdxs = []int32{ - 1, // 0: grpc.status.v1.Status.details:type_name -> google.protobuf.Any - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name -} - -func init() { file_connectext_grpc_status_v1_status_proto_init() } -func file_connectext_grpc_status_v1_status_proto_init() { - if File_connectext_grpc_status_v1_status_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_connectext_grpc_status_v1_status_proto_rawDesc), len(file_connectext_grpc_status_v1_status_proto_rawDesc)), - NumEnums: 0, - NumMessages: 1, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_connectext_grpc_status_v1_status_proto_goTypes, - DependencyIndexes: file_connectext_grpc_status_v1_status_proto_depIdxs, - MessageInfos: file_connectext_grpc_status_v1_status_proto_msgTypes, - }.Build() - File_connectext_grpc_status_v1_status_proto = out.File - file_connectext_grpc_status_v1_status_proto_goTypes = nil - file_connectext_grpc_status_v1_status_proto_depIdxs = nil -} diff --git a/internal/gen/generics/connect/collide/v1/collidev1connect/collide.connect.go b/internal/gen/generics/connect/collide/v1/collidev1connect/collide.connect.go deleted file mode 100644 index 32c973c7..00000000 --- a/internal/gen/generics/connect/collide/v1/collidev1connect/collide.connect.go +++ /dev/null @@ -1,122 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Code generated by protoc-gen-connect-go. DO NOT EDIT. -// -// Source: connect/collide/v1/collide.proto - -package collidev1connect - -import ( - connect "connectrpc.com/connect" - v1 "connectrpc.com/connect/internal/gen/connect/collide/v1" - context "context" - errors "errors" - http "net/http" - strings "strings" -) - -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - -const ( - // CollideServiceName is the fully-qualified name of the CollideService service. - CollideServiceName = "connect.collide.v1.CollideService" -) - -// These constants are the fully-qualified names of the RPCs defined in this package. They're -// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. -// -// Note that these are different from the fully-qualified method names used by -// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to -// reflection-formatted method names, remove the leading slash and convert the remaining slash to a -// period. -const ( - // CollideServiceImportProcedure is the fully-qualified name of the CollideService's Import RPC. - CollideServiceImportProcedure = "/connect.collide.v1.CollideService/Import" -) - -// CollideServiceClient is a client for the connect.collide.v1.CollideService service. -type CollideServiceClient interface { - Import(context.Context, *connect.Request[v1.ImportRequest]) (*connect.Response[v1.ImportResponse], error) -} - -// NewCollideServiceClient constructs a client for the connect.collide.v1.CollideService service. By -// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, -// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the -// connect.WithGRPC() or connect.WithGRPCWeb() options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewCollideServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) CollideServiceClient { - baseURL = strings.TrimRight(baseURL, "/") - collideServiceMethods := v1.File_connect_collide_v1_collide_proto.Services().ByName("CollideService").Methods() - return &collideServiceClient{ - _import: connect.NewClient[v1.ImportRequest, v1.ImportResponse]( - httpClient, - baseURL+CollideServiceImportProcedure, - connect.WithSchema(collideServiceMethods.ByName("Import")), - connect.WithClientOptions(opts...), - ), - } -} - -// collideServiceClient implements CollideServiceClient. -type collideServiceClient struct { - _import *connect.Client[v1.ImportRequest, v1.ImportResponse] -} - -// Import calls connect.collide.v1.CollideService.Import. -func (c *collideServiceClient) Import(ctx context.Context, req *connect.Request[v1.ImportRequest]) (*connect.Response[v1.ImportResponse], error) { - return c._import.CallUnary(ctx, req) -} - -// CollideServiceHandler is an implementation of the connect.collide.v1.CollideService service. -type CollideServiceHandler interface { - Import(context.Context, *connect.Request[v1.ImportRequest]) (*connect.Response[v1.ImportResponse], error) -} - -// NewCollideServiceHandler builds an HTTP handler from the service implementation. It returns the -// path on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewCollideServiceHandler(svc CollideServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - collideServiceMethods := v1.File_connect_collide_v1_collide_proto.Services().ByName("CollideService").Methods() - collideServiceImportHandler := connect.NewUnaryHandler( - CollideServiceImportProcedure, - svc.Import, - connect.WithSchema(collideServiceMethods.ByName("Import")), - connect.WithHandlerOptions(opts...), - ) - return "/connect.collide.v1.CollideService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case CollideServiceImportProcedure: - collideServiceImportHandler.ServeHTTP(w, r) - default: - http.NotFound(w, r) - } - }) -} - -// UnimplementedCollideServiceHandler returns CodeUnimplemented from all methods. -type UnimplementedCollideServiceHandler struct{} - -func (UnimplementedCollideServiceHandler) Import(context.Context, *connect.Request[v1.ImportRequest]) (*connect.Response[v1.ImportResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("connect.collide.v1.CollideService.Import is not implemented")) -} diff --git a/internal/gen/generics/connect/import/v1/importv1connect/import.connect.go b/internal/gen/generics/connect/import/v1/importv1connect/import.connect.go deleted file mode 100644 index c5c7bba2..00000000 --- a/internal/gen/generics/connect/import/v1/importv1connect/import.connect.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Code generated by protoc-gen-connect-go. DO NOT EDIT. -// -// Source: connect/import/v1/import.proto - -package importv1connect - -import ( - connect "connectrpc.com/connect" - _ "connectrpc.com/connect/internal/gen/connect/import/v1" - http "net/http" -) - -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - -const ( - // ImportServiceName is the fully-qualified name of the ImportService service. - ImportServiceName = "connect.import.v1.ImportService" -) - -// ImportServiceClient is a client for the connect.import.v1.ImportService service. -type ImportServiceClient interface { -} - -// NewImportServiceClient constructs a client for the connect.import.v1.ImportService service. By -// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, -// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the -// connect.WithGRPC() or connect.WithGRPCWeb() options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewImportServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ImportServiceClient { - return &importServiceClient{} -} - -// importServiceClient implements ImportServiceClient. -type importServiceClient struct { -} - -// ImportServiceHandler is an implementation of the connect.import.v1.ImportService service. -type ImportServiceHandler interface { -} - -// NewImportServiceHandler builds an HTTP handler from the service implementation. It returns the -// path on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewImportServiceHandler(svc ImportServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - return "/connect.import.v1.ImportService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - default: - http.NotFound(w, r) - } - }) -} - -// UnimplementedImportServiceHandler returns CodeUnimplemented from all methods. -type UnimplementedImportServiceHandler struct{} diff --git a/internal/gen/simple/connect/collide/v1/collidev1connect/collide.connect.go b/internal/gen/simple/connect/collide/v1/collidev1connect/collide.connect.go deleted file mode 100644 index 73067d32..00000000 --- a/internal/gen/simple/connect/collide/v1/collidev1connect/collide.connect.go +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Code generated by protoc-gen-connect-go. DO NOT EDIT. -// -// Source: connect/collide/v1/collide.proto - -package collidev1connect - -import ( - connect "connectrpc.com/connect" - v1 "connectrpc.com/connect/internal/gen/connect/collide/v1" - context "context" - errors "errors" - http "net/http" - strings "strings" -) - -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - -const ( - // CollideServiceName is the fully-qualified name of the CollideService service. - CollideServiceName = "connect.collide.v1.CollideService" -) - -// These constants are the fully-qualified names of the RPCs defined in this package. They're -// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. -// -// Note that these are different from the fully-qualified method names used by -// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to -// reflection-formatted method names, remove the leading slash and convert the remaining slash to a -// period. -const ( - // CollideServiceImportProcedure is the fully-qualified name of the CollideService's Import RPC. - CollideServiceImportProcedure = "/connect.collide.v1.CollideService/Import" -) - -// CollideServiceClient is a client for the connect.collide.v1.CollideService service. -type CollideServiceClient interface { - Import(context.Context, *v1.ImportRequest) (*v1.ImportResponse, error) -} - -// NewCollideServiceClient constructs a client for the connect.collide.v1.CollideService service. By -// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, -// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the -// connect.WithGRPC() or connect.WithGRPCWeb() options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewCollideServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) CollideServiceClient { - baseURL = strings.TrimRight(baseURL, "/") - collideServiceMethods := v1.File_connect_collide_v1_collide_proto.Services().ByName("CollideService").Methods() - return &collideServiceClient{ - _import: connect.NewClient[v1.ImportRequest, v1.ImportResponse]( - httpClient, - baseURL+CollideServiceImportProcedure, - connect.WithSchema(collideServiceMethods.ByName("Import")), - connect.WithClientOptions(opts...), - ), - } -} - -// collideServiceClient implements CollideServiceClient. -type collideServiceClient struct { - _import *connect.Client[v1.ImportRequest, v1.ImportResponse] -} - -// Import calls connect.collide.v1.CollideService.Import. -func (c *collideServiceClient) Import(ctx context.Context, req *v1.ImportRequest) (*v1.ImportResponse, error) { - response, err := c._import.CallUnary(ctx, connect.NewRequest(req)) - if response != nil { - return response.Msg, err - } - return nil, err -} - -// CollideServiceHandler is an implementation of the connect.collide.v1.CollideService service. -type CollideServiceHandler interface { - Import(context.Context, *v1.ImportRequest) (*v1.ImportResponse, error) -} - -// NewCollideServiceHandler builds an HTTP handler from the service implementation. It returns the -// path on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewCollideServiceHandler(svc CollideServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - collideServiceMethods := v1.File_connect_collide_v1_collide_proto.Services().ByName("CollideService").Methods() - collideServiceImportHandler := connect.NewUnaryHandlerSimple( - CollideServiceImportProcedure, - svc.Import, - connect.WithSchema(collideServiceMethods.ByName("Import")), - connect.WithHandlerOptions(opts...), - ) - return "/connect.collide.v1.CollideService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case CollideServiceImportProcedure: - collideServiceImportHandler.ServeHTTP(w, r) - default: - http.NotFound(w, r) - } - }) -} - -// UnimplementedCollideServiceHandler returns CodeUnimplemented from all methods. -type UnimplementedCollideServiceHandler struct{} - -func (UnimplementedCollideServiceHandler) Import(context.Context, *v1.ImportRequest) (*v1.ImportResponse, error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("connect.collide.v1.CollideService.Import is not implemented")) -} diff --git a/internal/gen/simple/connect/import/v1/importv1connect/import.connect.go b/internal/gen/simple/connect/import/v1/importv1connect/import.connect.go deleted file mode 100644 index c5c7bba2..00000000 --- a/internal/gen/simple/connect/import/v1/importv1connect/import.connect.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Code generated by protoc-gen-connect-go. DO NOT EDIT. -// -// Source: connect/import/v1/import.proto - -package importv1connect - -import ( - connect "connectrpc.com/connect" - _ "connectrpc.com/connect/internal/gen/connect/import/v1" - http "net/http" -) - -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - -const ( - // ImportServiceName is the fully-qualified name of the ImportService service. - ImportServiceName = "connect.import.v1.ImportService" -) - -// ImportServiceClient is a client for the connect.import.v1.ImportService service. -type ImportServiceClient interface { -} - -// NewImportServiceClient constructs a client for the connect.import.v1.ImportService service. By -// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, -// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the -// connect.WithGRPC() or connect.WithGRPCWeb() options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewImportServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ImportServiceClient { - return &importServiceClient{} -} - -// importServiceClient implements ImportServiceClient. -type importServiceClient struct { -} - -// ImportServiceHandler is an implementation of the connect.import.v1.ImportService service. -type ImportServiceHandler interface { -} - -// NewImportServiceHandler builds an HTTP handler from the service implementation. It returns the -// path on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewImportServiceHandler(svc ImportServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - return "/connect.import.v1.ImportService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - default: - http.NotFound(w, r) - } - }) -} - -// UnimplementedImportServiceHandler returns CodeUnimplemented from all methods. -type UnimplementedImportServiceHandler struct{} diff --git a/internal/memhttp/memhttp_test.go b/internal/memhttp/memhttp_test.go index b23705de..3b8e2458 100644 --- a/internal/memhttp/memhttp_test.go +++ b/internal/memhttp/memhttp_test.go @@ -24,9 +24,9 @@ import ( "testing" "time" - "connectrpc.com/connect/internal/assert" - "connectrpc.com/connect/internal/memhttp" - "connectrpc.com/connect/internal/memhttp/memhttptest" + "connectrpc.com/connect/v2/internal/assert" + "connectrpc.com/connect/v2/internal/memhttp" + "connectrpc.com/connect/v2/internal/memhttp/memhttptest" ) func TestServerTransport(t *testing.T) { diff --git a/internal/memhttp/memhttptest/http.go b/internal/memhttp/memhttptest/http.go index 3ec218e2..e10a7bed 100644 --- a/internal/memhttp/memhttptest/http.go +++ b/internal/memhttp/memhttptest/http.go @@ -19,7 +19,7 @@ import ( "net/http" "testing" - "connectrpc.com/connect/internal/memhttp" + "connectrpc.com/connect/v2/internal/memhttp" ) // NewServer constructs a [memhttp.Server] with defaults suitable for tests: diff --git a/internal/memhttp/memhttptest/http_test.go b/internal/memhttp/memhttptest/http_test.go index c92d94db..f1709c87 100644 --- a/internal/memhttp/memhttptest/http_test.go +++ b/internal/memhttp/memhttptest/http_test.go @@ -38,9 +38,9 @@ import ( "testing" "testing/synctest" - "connectrpc.com/connect/internal/assert" - "connectrpc.com/connect/internal/memhttp" - "connectrpc.com/connect/internal/memhttp/memhttptest" + "connectrpc.com/connect/v2/internal/assert" + "connectrpc.com/connect/v2/internal/memhttp" + "connectrpc.com/connect/v2/internal/memhttp/memhttptest" ) // TestMemhttpWithSynctest verifies that memhttp works correctly with synctest. diff --git a/option.go b/option.go deleted file mode 100644 index 7945c9b2..00000000 --- a/option.go +++ /dev/null @@ -1,647 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "compress/gzip" - "context" - "io" - "net/http" -) - -// A ClientOption configures a [Client]. -// -// In addition to any options grouped in the documentation below, remember that -// any [Option] is also a valid ClientOption. -type ClientOption interface { - applyToClient(*clientConfig) -} - -// WithAcceptCompression makes a compression algorithm available to a client. -// Clients ask servers to compress responses using any of the registered -// algorithms. The first registered algorithm is treated as the least -// preferred, and the last registered algorithm is the most preferred. -// -// It's safe to use this option liberally: servers will ignore any -// compression algorithms they don't support. To compress requests, pair this -// option with [WithSendCompression]. To remove support for a -// previously-registered compression algorithm, use WithAcceptCompression with -// nil decompressor and compressor constructors. -// -// Clients accept gzipped responses by default, using a compressor backed by the -// standard library's [gzip] package with the default compression level. Use -// [WithSendGzip] to compress requests with gzip. -// -// Calling WithAcceptCompression with an empty name is a no-op. -func WithAcceptCompression( - name string, - newDecompressor func() Decompressor, - newCompressor func() Compressor, -) ClientOption { - return &compressionOption{ - Name: name, - CompressionPool: newCompressionPool(newDecompressor, newCompressor), - } -} - -// WithClientOptions composes multiple ClientOptions into one. -func WithClientOptions(options ...ClientOption) ClientOption { - return &clientOptionsOption{options} -} - -// WithGRPC configures clients to use the HTTP/2 gRPC protocol. -func WithGRPC() ClientOption { - return &grpcOption{web: false} -} - -// WithGRPCWeb configures clients to use the gRPC-Web protocol. -func WithGRPCWeb() ClientOption { - return &grpcOption{web: true} -} - -// WithProtoJSON configures a client to send JSON-encoded data instead of -// binary Protobuf. It uses the standard Protobuf JSON mapping as implemented -// by [google.golang.org/protobuf/encoding/protojson]: fields are named using -// lowerCamelCase, zero values are omitted, missing required fields are errors, -// enums are emitted as strings, etc. -func WithProtoJSON() ClientOption { - return WithCodec(&protoJSONCodec{codecNameJSON}) -} - -// WithSendCompression configures the client to use the specified algorithm to -// compress request messages. If the algorithm has not been registered using -// [WithAcceptCompression], the client will return errors at runtime. -// -// Because some servers don't support compression, clients default to sending -// uncompressed requests. -func WithSendCompression(name string) ClientOption { - return &sendCompressionOption{Name: name} -} - -// WithSendGzip configures the client to gzip requests. Since clients have -// access to a gzip compressor by default, WithSendGzip doesn't require -// [WithSendCompression]. -// -// Some servers don't support gzip, so clients default to sending uncompressed -// requests. -func WithSendGzip() ClientOption { - return WithSendCompression(compressionGzip) -} - -// A HandlerOption configures a [Handler]. -// -// In addition to any options grouped in the documentation below, remember that -// any [Option] is also a HandlerOption. -type HandlerOption interface { - applyToHandler(*handlerConfig) -} - -// WithCompression configures handlers to support a compression algorithm. -// Clients may send messages compressed with that algorithm and/or request -// compressed responses. The [Compressor] and [Decompressor] produced by the -// supplied constructors must use the same algorithm. Internally, Connect pools -// compressors and decompressors. -// -// By default, handlers support gzip using the standard library's -// [compress/gzip] package at the default compression level. To remove support for -// a previously-registered compression algorithm, use WithCompression with nil -// decompressor and compressor constructors. -// -// Calling WithCompression with an empty name is a no-op. -func WithCompression( - name string, - newDecompressor func() Decompressor, - newCompressor func() Compressor, -) HandlerOption { - return &compressionOption{ - Name: name, - CompressionPool: newCompressionPool(newDecompressor, newCompressor), - } -} - -// WithHandlerOptions composes multiple HandlerOptions into one. -func WithHandlerOptions(options ...HandlerOption) HandlerOption { - return &handlerOptionsOption{options} -} - -// WithRecover adds an interceptor that recovers from panics. The supplied -// function receives the context, [Spec], request headers, and the recovered -// value (which may be nil). It must return an error to send back to the -// client. It may also log the panic, emit metrics, or execute other -// error-handling logic. Handler functions must be safe to call concurrently. -// -// To preserve compatibility with [net/http]'s semantics, this interceptor -// doesn't handle panics with [http.ErrAbortHandler]. -// -// By default, handlers don't recover from panics. Because the standard -// library's [http.Server] recovers from panics by default, this option isn't -// usually necessary to prevent crashes. Instead, it helps servers collect -// RPC-specific data during panics and send a more detailed error to -// clients. -func WithRecover(handle func(context.Context, Spec, http.Header, any) error) HandlerOption { - return WithInterceptors(&recoverHandlerInterceptor{handle: handle}) -} - -// WithRequireConnectProtocolHeader configures the Handler to require requests -// using the Connect RPC protocol to include the Connect-Protocol-Version -// header. This ensures that HTTP proxies and net/http middleware can easily -// identify valid Connect requests, even if they use a common Content-Type like -// application/json. However, it makes ad-hoc requests with tools like cURL -// more laborious. Streaming requests are not affected by this option. -// -// This option has no effect if the client uses the gRPC or gRPC-Web protocols. -func WithRequireConnectProtocolHeader() HandlerOption { - return &requireConnectProtocolHeaderOption{} -} - -// WithConditionalHandlerOptions allows procedures in the same service to have -// different configurations: for example, one procedure may need a much larger -// WithReadMaxBytes setting than the others. -// -// WithConditionalHandlerOptions takes a function which may inspect each -// procedure's Spec before deciding which options to apply. Returning a nil -// slice is safe. -func WithConditionalHandlerOptions(conditional func(spec Spec) []HandlerOption) HandlerOption { - return &conditionalHandlerOptions{conditional: conditional} -} - -// Option implements both [ClientOption] and [HandlerOption], so it can be -// applied both client-side and server-side. -type Option interface { - ClientOption - HandlerOption -} - -// WithSchema provides a parsed representation of the schema for an RPC to a -// client or handler. The supplied schema is exposed as [Spec].Schema. This -// option is typically added by generated code. -// -// For services using protobuf schemas, the supplied schema should be a -// [google.golang.org/protobuf/reflect/protoreflect.MethodDescriptor]. -func WithSchema(schema any) Option { - return &schemaOption{Schema: schema} -} - -// WithRequestInitializer provides a function that initializes a new message. -// It may be used to dynamically construct request messages. It is called on -// server receives to construct the message to be unmarshaled into. The message -// will be a non nil pointer to the type created by the handler. Use the Schema -// field of the [Spec] to determine the type of the message. -func WithRequestInitializer(initializer func(spec Spec, message any) error) HandlerOption { - return &initializerOption{Initializer: initializer} -} - -// WithResponseInitializer provides a function that initializes a new message. -// It may be used to dynamically construct response messages. It is called on -// client receives to construct the message to be unmarshaled into. The message -// will be a non nil pointer to the type created by the client. Use the Schema -// field of the [Spec] to determine the type of the message. -func WithResponseInitializer(initializer func(spec Spec, message any) error) ClientOption { - return &initializerOption{Initializer: initializer} -} - -// WithCodec registers a serialization method with a client or handler. -// Handlers may have multiple codecs registered, and use whichever the client -// chooses. Clients may only have a single codec. -// -// By default, handlers and clients support binary Protocol Buffer data using -// [google.golang.org/protobuf/proto]. Handlers also support JSON by default, -// using the standard Protobuf JSON mapping. Users with more specialized needs -// may override the default codecs by registering a new codec under the "proto" -// or "json" names. When supplying a custom "proto" codec, keep in mind that -// some unexported, protocol-specific messages are serialized using Protobuf - -// take care to fall back to the standard Protobuf implementation if -// necessary. -// -// Registering a codec with an empty name is a no-op. -func WithCodec(codec Codec) Option { - return &codecOption{Codec: codec} -} - -// WithCompressMinBytes sets a minimum size threshold for compression: -// regardless of compressor configuration, messages smaller than the configured -// minimum are sent uncompressed. -// -// The default minimum is zero. Setting a minimum compression threshold may -// improve overall performance, because the CPU cost of compressing very small -// messages usually isn't worth the small reduction in network I/O. -func WithCompressMinBytes(minBytes int) Option { - return &compressMinBytesOption{Min: minBytes} -} - -// WithReadMaxBytes limits the performance impact of pathologically large -// messages sent by the other party. For handlers, WithReadMaxBytes limits the size -// of a message that the client can send. For clients, WithReadMaxBytes limits the -// size of a message that the server can respond with. Limits apply to each Protobuf -// message, not to the stream as a whole. -// -// Setting WithReadMaxBytes to zero allows any message size. Both clients and -// handlers default to allowing any request size. -// -// Handlers may also use [http.MaxBytesHandler] to limit the total size of the -// HTTP request stream (rather than the per-message size). Connect handles -// [http.MaxBytesError] specially, so clients still receive errors with the -// appropriate error code and informative messages. -func WithReadMaxBytes(maxBytes int) Option { - return &readMaxBytesOption{Max: maxBytes} -} - -// WithSendMaxBytes prevents sending messages too large for the client/handler -// to handle without significant performance overhead. For handlers, WithSendMaxBytes -// limits the size of a message that the handler can respond with. For clients, -// WithSendMaxBytes limits the size of a message that the client can send. Limits -// apply to each message, not to the stream as a whole. -// -// Setting WithSendMaxBytes to zero allows any message size. Both clients and -// handlers default to allowing any message size. -func WithSendMaxBytes(maxBytes int) Option { - return &sendMaxBytesOption{Max: maxBytes} -} - -// WithIdempotency declares the idempotency of the procedure. This can determine -// whether a procedure call can safely be retried, and may affect which request -// modalities are allowed for a given procedure call. -// -// In most cases, you should not need to manually set this. It is normally set -// by the code generator for your schema. For protobuf schemas, it can be set like this: -// -// rpc Ping(PingRequest) returns (PingResponse) { -// option idempotency_level = NO_SIDE_EFFECTS; -// } -func WithIdempotency(idempotencyLevel IdempotencyLevel) Option { - return &idempotencyOption{idempotencyLevel: idempotencyLevel} -} - -// WithHTTPGet allows Connect-protocol clients to use HTTP GET requests for -// side-effect free unary RPC calls. Typically, the service schema indicates -// which procedures are idempotent (see [WithIdempotency] for an example -// protobuf schema). The gRPC and gRPC-Web protocols are POST-only, so this -// option has no effect when combined with [WithGRPC] or [WithGRPCWeb]. -// -// Using HTTP GET requests makes it easier to take advantage of CDNs, caching -// reverse proxies, and browsers' built-in caching. Note, however, that servers -// don't automatically set any cache headers; you can set cache headers using -// interceptors or by adding headers in individual procedure implementations. -// -// By default, all requests are made as HTTP POSTs. -func WithHTTPGet() ClientOption { - return &enableGet{} -} - -// WithInterceptors configures a client or handler's interceptor stack. Repeated -// WithInterceptors options are applied in order, so -// -// WithInterceptors(A) + WithInterceptors(B, C) == WithInterceptors(A, B, C) -// -// Unary interceptors compose like an onion. The first interceptor provided is -// the outermost layer of the onion: it acts first on the context and request, -// and last on the response and error. -// -// Stream interceptors also behave like an onion: the first interceptor -// provided is the outermost wrapper for the [StreamingClientConn] or -// [StreamingHandlerConn]. It's the first to see sent messages and the last to -// see received messages. -// -// Applied to client and handler, WithInterceptors(A, B, ..., Y, Z) produces: -// -// client.Send() client.Receive() -// | ^ -// v | -// A --- --- A -// B --- --- B -// : ... ... : -// Y --- --- Y -// Z --- --- Z -// | ^ -// v | -// = = = = = = = = = = = = = = = = -// network -// = = = = = = = = = = = = = = = = -// | ^ -// v | -// A --- --- A -// B --- --- B -// : ... ... : -// Y --- --- Y -// Z --- --- Z -// | ^ -// v | -// handler.Receive() handler.Send() -// | ^ -// | | -// '-> handler logic >-' -// -// Note that in clients, Send handles the request message(s) and Receive -// handles the response message(s). For handlers, it's the reverse. Depending -// on your interceptor's logic, you may need to wrap one method in clients and -// the other in handlers. -func WithInterceptors(interceptors ...Interceptor) Option { - return &interceptorsOption{interceptors} -} - -// WithOptions composes multiple Options into one. -func WithOptions(options ...Option) Option { - return &optionsOption{options} -} - -type schemaOption struct { - Schema any -} - -func (o *schemaOption) applyToClient(config *clientConfig) { - config.Schema = o.Schema -} - -func (o *schemaOption) applyToHandler(config *handlerConfig) { - config.Schema = o.Schema -} - -type initializerOption struct { - Initializer func(spec Spec, message any) error -} - -func (o *initializerOption) applyToHandler(config *handlerConfig) { - config.Initializer = maybeInitializer{initializer: o.Initializer} -} - -func (o *initializerOption) applyToClient(config *clientConfig) { - config.Initializer = maybeInitializer{initializer: o.Initializer} -} - -type maybeInitializer struct { - initializer func(spec Spec, message any) error -} - -func (o maybeInitializer) maybe(spec Spec, message any) error { - if o.initializer != nil { - return o.initializer(spec, message) - } - return nil -} - -type clientOptionsOption struct { - options []ClientOption -} - -func (o *clientOptionsOption) applyToClient(config *clientConfig) { - for _, option := range o.options { - option.applyToClient(config) - } -} - -type codecOption struct { - Codec Codec -} - -func (o *codecOption) applyToClient(config *clientConfig) { - if o.Codec == nil || o.Codec.Name() == "" { - return - } - config.Codec = o.Codec -} - -func (o *codecOption) applyToHandler(config *handlerConfig) { - if o.Codec == nil || o.Codec.Name() == "" { - return - } - config.Codecs[o.Codec.Name()] = o.Codec -} - -type compressionOption struct { - Name string - CompressionPool *compressionPool -} - -func (o *compressionOption) applyToClient(config *clientConfig) { - o.apply(&config.CompressionNames, config.CompressionPools) -} - -func (o *compressionOption) applyToHandler(config *handlerConfig) { - o.apply(&config.CompressionNames, config.CompressionPools) -} - -func (o *compressionOption) apply(configuredNames *[]string, configuredPools map[string]*compressionPool) { - if o.Name == "" { - return - } - if o.CompressionPool == nil { - delete(configuredPools, o.Name) - var names []string - for _, name := range *configuredNames { - if name == o.Name { - continue - } - names = append(names, name) - } - *configuredNames = names - return - } - configuredPools[o.Name] = o.CompressionPool - *configuredNames = append(*configuredNames, o.Name) -} - -type compressMinBytesOption struct { - Min int -} - -func (o *compressMinBytesOption) applyToClient(config *clientConfig) { - config.CompressMinBytes = o.Min -} - -func (o *compressMinBytesOption) applyToHandler(config *handlerConfig) { - config.CompressMinBytes = o.Min -} - -type readMaxBytesOption struct { - Max int -} - -func (o *readMaxBytesOption) applyToClient(config *clientConfig) { - config.ReadMaxBytes = o.Max -} - -func (o *readMaxBytesOption) applyToHandler(config *handlerConfig) { - config.ReadMaxBytes = o.Max -} - -type sendMaxBytesOption struct { - Max int -} - -func (o *sendMaxBytesOption) applyToClient(config *clientConfig) { - config.SendMaxBytes = o.Max -} - -func (o *sendMaxBytesOption) applyToHandler(config *handlerConfig) { - config.SendMaxBytes = o.Max -} - -type handlerOptionsOption struct { - options []HandlerOption -} - -func (o *handlerOptionsOption) applyToHandler(config *handlerConfig) { - for _, option := range o.options { - option.applyToHandler(config) - } -} - -type requireConnectProtocolHeaderOption struct{} - -func (o *requireConnectProtocolHeaderOption) applyToHandler(config *handlerConfig) { - config.RequireConnectProtocolHeader = true -} - -type idempotencyOption struct { - idempotencyLevel IdempotencyLevel -} - -func (o *idempotencyOption) applyToClient(config *clientConfig) { - config.IdempotencyLevel = o.idempotencyLevel -} - -func (o *idempotencyOption) applyToHandler(config *handlerConfig) { - config.IdempotencyLevel = o.idempotencyLevel -} - -type grpcOption struct { - web bool -} - -func (o *grpcOption) applyToClient(config *clientConfig) { - config.Protocol = &protocolGRPC{web: o.web} -} - -type enableGet struct{} - -func (o *enableGet) applyToClient(config *clientConfig) { - config.EnableGet = true -} - -// WithHTTPGetMaxURLSize sets the maximum allowable URL length for GET requests -// made using the Connect protocol. It has no effect on gRPC or gRPC-Web -// clients, since those protocols are POST-only. -// -// Limiting the URL size is useful as most user agents, proxies, and servers -// have limits on the allowable length of a URL. For example, Apache and Nginx -// limit the size of a request line to around 8 KiB, meaning that maximum -// length of a URL is a bit smaller than this. If you run into URL size -// limitations imposed by your network infrastructure and don't know the -// maximum allowable size, or if you'd prefer to be cautious from the start, a -// 4096 byte (4 KiB) limit works with most common proxies and CDNs. -// -// If fallback is set to true and the URL would be longer than the configured -// maximum value, the request will be sent as an HTTP POST instead. If fallback -// is set to false, the request will fail with [CodeResourceExhausted]. -// -// By default, Connect-protocol clients with GET requests enabled may send a -// URL of any size. -func WithHTTPGetMaxURLSize(bytes int, fallback bool) ClientOption { - return &getURLMaxBytes{Max: bytes, Fallback: fallback} -} - -type getURLMaxBytes struct { - Max int - Fallback bool -} - -func (o *getURLMaxBytes) applyToClient(config *clientConfig) { - config.GetURLMaxBytes = o.Max - config.GetUseFallback = o.Fallback -} - -type interceptorsOption struct { - Interceptors []Interceptor -} - -func (o *interceptorsOption) applyToClient(config *clientConfig) { - config.Interceptor = o.chainWith(config.Interceptor) -} - -func (o *interceptorsOption) applyToHandler(config *handlerConfig) { - config.Interceptor = o.chainWith(config.Interceptor) -} - -func (o *interceptorsOption) chainWith(current Interceptor) Interceptor { - if len(o.Interceptors) == 0 { - return current - } - if current == nil && len(o.Interceptors) == 1 { - return o.Interceptors[0] - } - if current == nil && len(o.Interceptors) > 1 { - return newChain(o.Interceptors) - } - return newChain(append([]Interceptor{current}, o.Interceptors...)) -} - -type optionsOption struct { - options []Option -} - -func (o *optionsOption) applyToClient(config *clientConfig) { - for _, option := range o.options { - option.applyToClient(config) - } -} - -func (o *optionsOption) applyToHandler(config *handlerConfig) { - for _, option := range o.options { - option.applyToHandler(config) - } -} - -type sendCompressionOption struct { - Name string -} - -func (o *sendCompressionOption) applyToClient(config *clientConfig) { - config.RequestCompressionName = o.Name -} - -func withGzip() Option { - return &compressionOption{ - Name: compressionGzip, - CompressionPool: newCompressionPool( - func() Decompressor { return &gzip.Reader{} }, - func() Compressor { return gzip.NewWriter(io.Discard) }, - ), - } -} - -func withProtoBinaryCodec() Option { - return WithCodec(&protoBinaryCodec{}) -} - -func withProtoJSONCodecs() HandlerOption { - return WithHandlerOptions( - WithCodec(&protoJSONCodec{codecNameJSON}), - WithCodec(&protoJSONCodec{codecNameJSONCharsetUTF8}), - ) -} - -type conditionalHandlerOptions struct { - conditional func(spec Spec) []HandlerOption -} - -func (o *conditionalHandlerOptions) applyToHandler(config *handlerConfig) { - spec := config.newSpec() - if spec.Procedure == "" { - return // ignore empty specs - } - for _, option := range o.conditional(spec) { - option.applyToHandler(config) - } -} diff --git a/recover.go b/recover.go deleted file mode 100644 index d59705d4..00000000 --- a/recover.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect - -import ( - "context" - "net/http" -) - -// recoverHandlerInterceptor lets handlers trap panics, perform side effects -// (like emitting logs or metrics), and present a friendlier error message to -// clients. -type recoverHandlerInterceptor struct { - Interceptor - - handle func(context.Context, Spec, http.Header, any) error -} - -func (i *recoverHandlerInterceptor) WrapUnary(next UnaryFunc) UnaryFunc { - return func(ctx context.Context, req AnyRequest) (_ AnyResponse, retErr error) { - if req.Spec().IsClient { - return next(ctx, req) - } - defer func() { - if r := recover(); r != nil { - // net/http checks for ErrAbortHandler with ==, so we should too. - if r == http.ErrAbortHandler { //nolint:errorlint,err113 - panic(r) //nolint:forbidigo - } - retErr = i.handle(ctx, req.Spec(), req.Header(), r) - } - }() - res, err := next(ctx, req) - return res, err - } -} - -func (i *recoverHandlerInterceptor) WrapStreamingHandler(next StreamingHandlerFunc) StreamingHandlerFunc { - return func(ctx context.Context, conn StreamingHandlerConn) (retErr error) { - defer func() { - if r := recover(); r != nil { - // net/http checks for ErrAbortHandler with ==, so we should too. - if r == http.ErrAbortHandler { //nolint:errorlint,err113 - panic(r) //nolint:forbidigo - } - retErr = i.handle(ctx, conn.Spec(), conn.RequestHeader(), r) - } - }() - err := next(ctx, conn) - return err - } -} diff --git a/recover_ext_test.go b/recover_ext_test.go deleted file mode 100644 index d273d7c0..00000000 --- a/recover_ext_test.go +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright 2021-2026 The Connect Authors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package connect_test - -import ( - "context" - "fmt" - "net/http" - "testing" - - connect "connectrpc.com/connect" - "connectrpc.com/connect/internal/assert" - pingv1 "connectrpc.com/connect/internal/gen/connect/ping/v1" - "connectrpc.com/connect/internal/gen/generics/connect/ping/v1/pingv1connect" - "connectrpc.com/connect/internal/memhttp/memhttptest" -) - -type panicPingServer struct { - pingv1connect.UnimplementedPingServiceHandler - - panicWith any -} - -func (s *panicPingServer) Ping( - context.Context, - *connect.Request[pingv1.PingRequest], -) (*connect.Response[pingv1.PingResponse], error) { - panic(s.panicWith) //nolint:forbidigo -} - -func (s *panicPingServer) CountUp( - _ context.Context, - _ *connect.Request[pingv1.CountUpRequest], - stream *connect.ServerStream[pingv1.CountUpResponse], -) error { - if err := stream.Send(&pingv1.CountUpResponse{}); err != nil { - return err - } - panic(s.panicWith) //nolint:forbidigo -} - -func TestWithRecover(t *testing.T) { - t.Parallel() - handle := func(_ context.Context, _ connect.Spec, _ http.Header, r any) error { - return connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("panic: %v", r)) - } - assertHandled := func(err error) { - t.Helper() - assert.NotNil(t, err) - assert.Equal(t, connect.CodeOf(err), connect.CodeFailedPrecondition) - } - assertNotHandled := func(err error) { - t.Helper() - // When HTTP/2 handlers panic, net/http sends an RST_STREAM frame with code - // INTERNAL_ERROR. We should be mapping this back to CodeInternal. - assert.Equal(t, connect.CodeOf(err), connect.CodeInternal) - } - drainStream := func(stream *connect.ServerStreamForClient[pingv1.CountUpResponse]) error { - t.Helper() - defer stream.Close() - assert.True(t, stream.Receive()) // expect one response msg - assert.False(t, stream.Receive()) // expect panic before second response msg - return stream.Err() - } - pinger := &panicPingServer{} - mux := http.NewServeMux() - mux.Handle(pingv1connect.NewPingServiceHandler(pinger, connect.WithRecover(handle))) - server := memhttptest.NewServer(t, mux) - client := pingv1connect.NewPingServiceClient( - server.Client(), - server.URL(), - ) - - for _, panicWith := range []any{42, nil} { - pinger.panicWith = panicWith - - _, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{})) - assertHandled(err) - - stream, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{})) - assert.Nil(t, err) - assertHandled(drainStream(stream)) - } - - pinger.panicWith = http.ErrAbortHandler - - _, err := client.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{})) - assertNotHandled(err) - - stream, err := client.CountUp(t.Context(), connect.NewRequest(&pingv1.CountUpRequest{})) - assert.Nil(t, err) - assertNotHandled(drainStream(stream)) -}