Skip to content

Commit

Permalink
Connect HTTP Get support (connectrpc#478)
Browse files Browse the repository at this point in the history
Co-authored-by: Akshay Shah <[email protected]>
  • Loading branch information
jchadwick-buf and akshayjshah authored Apr 6, 2023
1 parent 64dbe18 commit c80677b
Show file tree
Hide file tree
Showing 19 changed files with 849 additions and 85 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ compression, and content type negotiation. It also generates an idiomatic,
type-safe client. Handlers and clients support three protocols: gRPC, gRPC-Web,
and Connect's own protocol.

The [Connect protocol][protocol] is a simple, POST-only protocol that works
over HTTP/1.1 or HTTP/2. It takes the best portions of gRPC and gRPC-Web,
including streaming, and packages them into a protocol that works equally well
in browsers, monoliths, and microservices. Calling a Connect API is as easy as
The [Connect protocol][protocol] is a simple protocol that works over HTTP/1.1
or HTTP/2. It takes the best portions of gRPC and gRPC-Web, including
streaming, and packages them into a protocol that works equally well in
browsers, monoliths, and microservices. Calling a Connect API is as easy as
using `curl`. Try it with our live demo:

```
Expand Down
14 changes: 11 additions & 3 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ func NewClient[Req, Res any](httpClient HTTPClient, url string, options ...Clien
BufferPool: config.BufferPool,
ReadMaxBytes: config.ReadMaxBytes,
SendMaxBytes: config.SendMaxBytes,
EnableGet: config.EnableGet,
GetURLMaxBytes: config.GetURLMaxBytes,
GetUseFallback: config.GetUseFallback,
},
)
if protocolErr != nil {
Expand Down Expand Up @@ -188,6 +191,10 @@ type clientConfig struct {
BufferPool *bufferPool
ReadMaxBytes int
SendMaxBytes int
EnableGet bool
GetURLMaxBytes int
GetUseFallback bool
IdempotencyLevel IdempotencyLevel
}

func newClientConfig(rawURL string, options []ClientOption) (*clientConfig, *Error) {
Expand Down Expand Up @@ -235,9 +242,10 @@ func (c *clientConfig) protobuf() Codec {

func (c *clientConfig) newSpec(t StreamType) Spec {
return Spec{
StreamType: t,
Procedure: c.Procedure,
IsClient: true,
StreamType: t,
Procedure: c.Procedure,
IsClient: true,
IdempotencyLevel: c.IdempotencyLevel,
}
}

Expand Down
14 changes: 13 additions & 1 deletion client_ext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/bufbuild/connect-go"
Expand Down Expand Up @@ -89,8 +90,12 @@ func TestClientPeer(t *testing.T) {
)
ctx := context.Background()
// unary
_, err := client.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{}))
_, err := client.Ping(ctx, connect.NewRequest[pingv1.PingRequest](nil))
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.Text, text)
// client streaming
clientStream := client.Sum(ctx)
t.Cleanup(func() {
Expand Down Expand Up @@ -123,6 +128,13 @@ func TestClientPeer(t *testing.T) {
t.Parallel()
run(t)
})
t.Run("connect+get", func(t *testing.T) {
t.Parallel()
run(t,
connect.WithHTTPGet(),
connect.WithSendGzip(),
)
})
t.Run("grpc", func(t *testing.T) {
t.Parallel()
run(t, connect.WithGRPC())
Expand Down
62 changes: 62 additions & 0 deletions client_get_fallback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2021-2023 Buf Technologies, Inc.
//
// 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"
"net/http/httptest"
"strings"
"testing"

"github.com/bufbuild/connect-go/internal/assert"
pingv1 "github.com/bufbuild/connect-go/internal/gen/connect/ping/v1"
)

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.Number,
Text: r.Msg.Text,
}), nil
},
WithIdempotency(IdempotencyNoSideEffects),
))
server := httptest.NewUnstartedServer(mux)
server.EnableHTTP2 = true
server.StartTLS()
t.Cleanup(server.Close)

client := NewClient[pingv1.PingRequest, pingv1.PingResponse](
server.Client(),
server.URL+"/connect.ping.v1.PingService/Ping",
WithHTTPGet(),
withHTTPGetMaxURLSize(1, true),
WithSendGzip(),
)
ctx := context.Background()

_, 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.Text, text)
}
57 changes: 54 additions & 3 deletions cmd/protoc-gen-connect-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ func main() {
)
}

func needsWithIdempotency(file *protogen.File) bool {
for _, service := range file.Services {
for _, method := range service.Methods {
if methodIdempotency(method) != connect.IdempotencyUnknown {
return true
}
}
}
return false
}

func generate(plugin *protogen.Plugin, file *protogen.File) {
if len(file.Services) == 0 {
return
Expand Down Expand Up @@ -163,7 +174,11 @@ func generatePreamble(g *protogen.GeneratedFile, file *protogen.File) {
"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("IsAtLeastVersion0_1_0"))
if needsWithIdempotency(file) {
g.P("const _ = ", connectPackage.Ident("IsAtLeastVersion1_6_0"))
} else {
g.P("const _ = ", connectPackage.Ident("IsAtLeastVersion0_1_0"))
}
g.P()
}

Expand Down Expand Up @@ -262,7 +277,17 @@ func generateClientImplementation(g *protogen.GeneratedFile, service *protogen.S
)
g.P("httpClient,")
g.P(`baseURL + `, procedureConstName(method), `,`)
g.P("opts...,")
idempotency := methodIdempotency(method)
switch idempotency {
case connect.IdempotencyNoSideEffects:
g.P(connectPackage.Ident("WithIdempotency"), "(", connectPackage.Ident("IdempotencyNoSideEffects"), "),")
g.P(connectPackage.Ident("WithClientOptions"), "(opts...),")
case connect.IdempotencyIdempotent:
g.P(connectPackage.Ident("WithIdempotency"), "(", connectPackage.Ident("IdempotencyIdempotent"), "),")
g.P(connectPackage.Ident("WithClientOptions"), "(opts...),")
case connect.IdempotencyUnknown:
g.P("opts...,")
}
g.P("),")
}
g.P("}")
Expand Down Expand Up @@ -376,6 +401,7 @@ func generateServerConstructor(g *protogen.GeneratedFile, service *protogen.Serv
for _, method := range service.Methods {
isStreamingServer := method.Desc.IsStreamingServer()
isStreamingClient := method.Desc.IsStreamingClient()
idempotency := methodIdempotency(method)
switch {
case isStreamingClient && !isStreamingServer:
g.P(`mux.Handle(`, procedureConstName(method), `, `, connectPackage.Ident("NewClientStreamHandler"), "(")
Expand All @@ -388,7 +414,16 @@ func generateServerConstructor(g *protogen.GeneratedFile, service *protogen.Serv
}
g.P(procedureConstName(method), `,`)
g.P("svc.", method.GoName, ",")
g.P("opts...,")
switch idempotency {
case connect.IdempotencyNoSideEffects:
g.P(connectPackage.Ident("WithIdempotency"), "(", connectPackage.Ident("IdempotencyNoSideEffects"), "),")
g.P(connectPackage.Ident("WithHandlerOptions"), "(opts...),")
case connect.IdempotencyIdempotent:
g.P(connectPackage.Ident("WithIdempotency"), "(", connectPackage.Ident("IdempotencyIdempotent"), "),")
g.P(connectPackage.Ident("WithHandlerOptions"), "(opts...),")
case connect.IdempotencyUnknown:
g.P("opts...,")
}
g.P("))")
}
g.P(`return "/`, reflectionName(service), `/", mux`)
Expand Down Expand Up @@ -477,6 +512,22 @@ 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
}
switch methodOptions.GetIdempotencyLevel() {
case descriptorpb.MethodOptions_NO_SIDE_EFFECTS:
return connect.IdempotencyNoSideEffects
case descriptorpb.MethodOptions_IDEMPOTENT:
return connect.IdempotencyIdempotent
case descriptorpb.MethodOptions_IDEMPOTENCY_UNKNOWN:
return connect.IdempotencyUnknown
}
return connect.IdempotencyUnknown
}

// Raggedy comments in the generated code are driving me insane. This
// word-wrapping function is ruinously inefficient, but it gets the job done.
func wrapComments(g *protogen.GeneratedFile, elems ...any) {
Expand Down
67 changes: 67 additions & 0 deletions codec.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
package connect

import (
"bytes"
"encoding/json"
"errors"
"fmt"

Expand Down Expand Up @@ -51,6 +53,32 @@ type Codec interface {
Unmarshal([]byte, any) 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)
Expand All @@ -73,6 +101,24 @@ func (c *protoBinaryCodec) Unmarshal(data []byte, message any) error {
return proto.Unmarshal(data, protoMessage)
}

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
}
Expand Down Expand Up @@ -102,6 +148,27 @@ func (c *protoJSONCodec) Unmarshal(binary []byte, message any) error {
return options.Unmarshal(binary, protoMessage)
}

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.
Expand Down
Loading

0 comments on commit c80677b

Please sign in to comment.