From 9cc1a4cd53f36fa42755f991ccaf2a31ab59cc48 Mon Sep 17 00:00:00 2001 From: Scott Nichols Date: Wed, 3 Apr 2019 16:14:00 -0700 Subject: [PATCH] Adding testing to support work to do outbound quote support. Signed-off-by: Scott Nichols --- alias.go | 9 +- pkg/cloudevents/event.go | 11 +- pkg/cloudevents/transport/http/encoding.go | 11 + pkg/cloudevents/transport/http/options.go | 29 ++ pkg/cloudevents/transport/http/transport.go | 16 +- test/http/loopback.go | 150 +++++++++++ test/http/loopback_v01_test.go | 214 +++++++++++++++ test/http/loopback_v02_test.go | 276 ++++++++++++++++++++ test/http/loopback_v03_test.go | 214 +++++++++++++++ test/http/tap_handler.go | 99 +++++++ test/http/validation.go | 68 +++++ 11 files changed, 1090 insertions(+), 7 deletions(-) create mode 100644 test/http/loopback.go create mode 100644 test/http/loopback_v01_test.go create mode 100644 test/http/loopback_v02_test.go create mode 100644 test/http/loopback_v03_test.go create mode 100644 test/http/tap_handler.go create mode 100644 test/http/validation.go diff --git a/alias.go b/alias.go index a880564fa..ff723fd37 100644 --- a/alias.go +++ b/alias.go @@ -25,7 +25,7 @@ type EventResponse = cloudevents.EventResponse type EventContext = cloudevents.EventContext type EventContextV01 = cloudevents.EventContextV01 type EventContextV02 = cloudevents.EventContextV02 -type EventContextV3 = cloudevents.EventContextV03 +type EventContextV03 = cloudevents.EventContextV03 // Custom Types @@ -40,6 +40,13 @@ type HTTPTransportResponseContext = http.TransportResponseContext type HTTPEncoding = http.Encoding var ( + // ContentType Helpers + + StringOfApplicationJSON = cloudevents.StringOfApplicationJSON + StringOfApplicationXML = cloudevents.StringOfApplicationXML + StringOfApplicationCloudEventsJSON = cloudevents.StringOfApplicationCloudEventsJSON + StringOfApplicationCloudEventsBatchJSON = cloudevents.StringOfApplicationCloudEventsBatchJSON + // Client Creation NewClient = client.New diff --git a/pkg/cloudevents/event.go b/pkg/cloudevents/event.go index 367c333b7..96848e615 100644 --- a/pkg/cloudevents/event.go +++ b/pkg/cloudevents/event.go @@ -166,7 +166,16 @@ func (e Event) String() string { b.WriteString("Data,\n ") if strings.HasPrefix(e.DataContentType(), "application/json") { var prettyJSON bytes.Buffer - err := json.Indent(&prettyJSON, e.Data.([]byte), " ", " ") + + data, ok := e.Data.([]byte) + if !ok { + var err error + data, err = json.Marshal(e.Data) + if err != nil { + data = []byte(err.Error()) + } + } + err := json.Indent(&prettyJSON, data, " ", " ") if err != nil { b.Write(e.Data.([]byte)) } else { diff --git a/pkg/cloudevents/transport/http/encoding.go b/pkg/cloudevents/transport/http/encoding.go index ca62e5b5d..d7bb4f777 100644 --- a/pkg/cloudevents/transport/http/encoding.go +++ b/pkg/cloudevents/transport/http/encoding.go @@ -24,6 +24,17 @@ const ( Unknown ) +type Quoting int32 + +const ( + // Unquoted does not use a wrapping for string header values + Unquoted Quoting = iota + // SingleQuoted uses ' for wrapping string header values + SingleQuoted + // DoubleQuoted uses " for wrapping string header values + DoubleQuoted +) + // String pretty-prints the encoding as a string. func (e Encoding) String() string { switch e { diff --git a/pkg/cloudevents/transport/http/options.go b/pkg/cloudevents/transport/http/options.go index 226bb9255..256ae83d5 100644 --- a/pkg/cloudevents/transport/http/options.go +++ b/pkg/cloudevents/transport/http/options.go @@ -131,6 +131,35 @@ func WithBinaryEncoding() Option { } } +// WithQuotingHeaderMode sets the HTTP binary mode outbound value quote mode. +func WithQuotingHeaderMode(mode Quoting) Option { + return func(t *Transport) error { + if t == nil { + return fmt.Errorf("http quoting header mode option can not set nil transport") + } + + t.Quoting = mode + return nil + } +} + +// WithUnquotedHeaderValues sets outbound header string quote mode to Unquoted. +func WithUnquotedHeaderValues() Option { + return WithQuotingHeaderMode(Unquoted) +} + +// WithUnquotedHeaderValues sets outbound header string quote mode to +// SingleQuoted. +func WithSingleQuoteHeaderValues() Option { + return WithQuotingHeaderMode(SingleQuoted) +} + +// WithUnquotedHeaderValues sets outbound header string quote mode to +// DoubleQuoted. +func WithDoubleQuoteHeaderValues() Option { + return WithQuotingHeaderMode(DoubleQuoted) +} + // WithStructuredEncoding sets the encoding selection strategy for // default encoding selections based on Event, the encoded event will be the // given version in Structured form. diff --git a/pkg/cloudevents/transport/http/transport.go b/pkg/cloudevents/transport/http/transport.go index 5deeb69e5..d1f7e6323 100644 --- a/pkg/cloudevents/transport/http/transport.go +++ b/pkg/cloudevents/transport/http/transport.go @@ -9,6 +9,7 @@ import ( "log" "net" "net/http" + "strconv" "strings" "sync" "time" @@ -32,6 +33,9 @@ const ( type Transport struct { // The encoding used to select the codec for outbound events. Encoding Encoding + + Quoting Quoting + // DefaultEncodingSelectionFn allows for other encoding selection strategies to be injected. DefaultEncodingSelectionFn EncodingSelector @@ -407,6 +411,7 @@ func (t *Transport) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.Error() return } + if resp != nil { if t.Req != nil { copyHeaders(t.Req.Header, w.Header()) @@ -414,17 +419,18 @@ func (t *Transport) ServeHTTP(w http.ResponseWriter, req *http.Request) { if len(resp.Header) > 0 { copyHeaders(resp.Header, w.Header()) } - status := http.StatusAccepted - if resp.StatusCode >= 200 && resp.StatusCode < 600 { - status = resp.StatusCode - } - w.WriteHeader(status) + w.Header().Add("Content-Length", strconv.Itoa(len(resp.Body))) if len(resp.Body) > 0 { if _, err := w.Write(resp.Body); err != nil { r.Error() return } } + status := http.StatusAccepted + if resp.StatusCode >= 200 && resp.StatusCode < 600 { + status = resp.StatusCode + } + w.WriteHeader(status) r.OK() return diff --git a/test/http/loopback.go b/test/http/loopback.go new file mode 100644 index 000000000..4905c7c85 --- /dev/null +++ b/test/http/loopback.go @@ -0,0 +1,150 @@ +package http + +import ( + "context" + "github.com/cloudevents/sdk-go" + "github.com/cloudevents/sdk-go/pkg/cloudevents/client" + cehttp "github.com/cloudevents/sdk-go/pkg/cloudevents/transport/http" + "github.com/cloudevents/sdk-go/pkg/cloudevents/types" + "github.com/google/uuid" + "net/http/httptest" + "testing" + "time" +) + +// Loopback Test: + +// Obj -> Send -> Wire Format -> Receive -> Got +// Given: ^ ^ ^==Want +// Obj is an event of a version. +// Client is a set to binary or + +func AlwaysThen(then time.Time) client.EventDefaulter { + return func(event cloudevents.Event) cloudevents.Event { + if event.Context != nil { + switch event.Context.GetSpecVersion() { + case "0.1": + ec := event.Context.AsV01() + ec.EventTime = &types.Timestamp{Time: then} + event.Context = ec + case "0.2": + ec := event.Context.AsV02() + ec.Time = &types.Timestamp{Time: then} + event.Context = ec + case "0.3": + ec := event.Context.AsV03() + ec.Time = &types.Timestamp{Time: then} + event.Context = ec + } + } + return event + } +} + +type TapTest struct { + now time.Time + event *cloudevents.Event + resp *cloudevents.Event + want *cloudevents.Event + asSent *TapValidation + asRecv *TapValidation +} + +type TapTestCases map[string]TapTest + +func ClientLoopback(t *testing.T, tc TapTest, topts ...cehttp.Option) { + tap := NewTap() + server := httptest.NewServer(tap) + defer server.Close() + + if len(topts) == 0 { + topts = append(topts, cloudevents.WithBinaryEncoding()) + } + topts = append(topts, cloudevents.WithTarget(server.URL)) + transport, err := cloudevents.NewHTTPTransport( + topts..., + ) + if err != nil { + t.Fatal(err) + } + + tap.handler = transport + + ce, err := cloudevents.NewClient( + transport, + cloudevents.WithEventDefaulter(AlwaysThen(tc.now)), + ) + if err != nil { + t.Fatal(err) + } + + testID := uuid.New().String() + ctx := cloudevents.ContextWithHeader(context.Background(), unitTestIDKey, testID) + + recvCtx, recvCancel := context.WithCancel(context.Background()) + + go func() { + _ = ce.StartReceiver(recvCtx, func(resp *cloudevents.EventResponse) { + if tc.resp != nil { + resp.RespondWith(200, tc.resp) + } + }) + }() + + got, err := ce.Send(ctx, *tc.event) + if err != nil { + t.Fatal(err) + } + recvCancel() + + assertEventEquality(t, "response event", tc.want, got) + + if req, ok := tap.req[testID]; ok { + assertTappedEquality(t, "http request", tc.asSent, &req) + } + + if resp, ok := tap.resp[testID]; ok { + assertTappedEquality(t, "http response", tc.asRecv, &resp) + } +} + +// To help with debug, if needed. +func printTap(t *testing.T, tap *tapHandler, testID string) { + if r, ok := tap.req[testID]; ok { + t.Log("tap request ", r.URI, r.Method) + if r.ContentLength > 0 { + t.Log(" .body: ", r.Body) + } else { + t.Log("tap request had no body.") + } + + if len(r.Header) > 0 { + for h, vs := range r.Header { + for _, v := range vs { + t.Logf(" .header %s: %s", h, v) + } + } + } else { + t.Log("tap request had no headers.") + } + } + + if r, ok := tap.resp[testID]; ok { + t.Log("tap response.status: ", r.Status) + if r.ContentLength > 0 { + t.Log(" .body: ", r.Body) + } else { + t.Log("tap response had no body.") + } + + if len(r.Header) > 0 { + for h, vs := range r.Header { + for _, v := range vs { + t.Logf(" .header %s: %s", h, v) + } + } + } else { + t.Log("tap response had no headers.") + } + } +} diff --git a/test/http/loopback_v01_test.go b/test/http/loopback_v01_test.go new file mode 100644 index 000000000..f77a16a88 --- /dev/null +++ b/test/http/loopback_v01_test.go @@ -0,0 +1,214 @@ +package http + +import ( + "github.com/cloudevents/sdk-go" + "testing" + "time" +) + +func TestClientLoopback_binary_v01tov01(t *testing.T) { + now := time.Now() + + testCases := TapTestCases{ + "Loopback v0.1 -> v0.1": { + now: now, + event: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "ABC-123", + Type: "unit.test.client.sent", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV01(), + Data: map[string]string{"hello": "unittest"}, + }, + resp: &cloudevents.Event{ + Context: cloudevents.EventContextV01{ + EventID: "321-CBA", + EventType: "unit.test.client.response", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV01(), + Data: map[string]string{"unittest": "response"}, + }, + want: &cloudevents.Event{ + Context: cloudevents.EventContextV01{ + EventID: "321-CBA", + EventType: "unit.test.client.response", + EventTime: &cloudevents.Timestamp{Time: now}, + Source: *cloudevents.ParseURLRef("/unit/test/client"), + ContentType: cloudevents.StringOfApplicationJSON(), + }.AsV01(), + Data: map[string]string{"unittest": "response"}, + }, + asSent: &TapValidation{ + Method: "POST", + URI: "/", + Header: map[string][]string{ + "ce-cloudeventsversion": {"0.1"}, + "ce-eventid": {"ABC-123"}, + "ce-eventtime": {now.UTC().Format(time.RFC3339Nano)}, + "ce-eventtype": {"unit.test.client.sent"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"hello":"unittest"}`, + ContentLength: 20, + }, + asRecv: &TapValidation{ + Header: map[string][]string{ + "ce-cloudeventsversion": {"0.1"}, + "ce-eventid": {"321-CBA"}, + "ce-eventtime": {now.UTC().Format(time.RFC3339Nano)}, + "ce-eventtype": {"unit.test.client.response"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"unittest":"response"}`, + Status: "200 OK", + ContentLength: 23, + }, + }, + } + + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + ClientLoopback(t, tc) + }) + } +} + +func TestClientLoopback_binary_v01tov02(t *testing.T) { + now := time.Now() + + testCases := TapTestCases{ + "Loopback v0.2 -> v0.2": { + now: now, + event: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "ABC-123", + Type: "unit.test.client.sent", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV01(), + Data: map[string]string{"hello": "unittest"}, + }, + resp: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "321-CBA", + Type: "unit.test.client.response", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV02(), + Data: map[string]string{"unittest": "response"}, + }, + want: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "321-CBA", + Type: "unit.test.client.response", + Time: &cloudevents.Timestamp{Time: now}, + Source: *cloudevents.ParseURLRef("/unit/test/client"), + ContentType: cloudevents.StringOfApplicationJSON(), + }.AsV02(), + Data: map[string]string{"unittest": "response"}, + }, + asSent: &TapValidation{ + Method: "POST", + URI: "/", + Header: map[string][]string{ + "ce-cloudeventsversion": {"0.1"}, + "ce-eventid": {"ABC-123"}, + "ce-eventtime": {now.UTC().Format(time.RFC3339Nano)}, + "ce-eventtype": {"unit.test.client.sent"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"hello":"unittest"}`, + ContentLength: 20, + }, + asRecv: &TapValidation{ + Header: map[string][]string{ + "ce-specversion": {"0.2"}, + "ce-id": {"321-CBA"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.response"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"unittest":"response"}`, + Status: "200 OK", + ContentLength: 23, + }, + }, + } + + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + ClientLoopback(t, tc) + }) + } +} + +func TestClientLoopback_binary_v01tov03(t *testing.T) { + now := time.Now() + + testCases := TapTestCases{ + "Loopback v0.2 -> v0.3": { + now: now, + event: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "ABC-123", + Type: "unit.test.client.sent", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV01(), + Data: map[string]string{"hello": "unittest"}, + }, + resp: &cloudevents.Event{ + Context: cloudevents.EventContextV03{ + ID: "321-CBA", + Type: "unit.test.client.response", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV03(), + Data: map[string]string{"unittest": "response"}, + }, + want: &cloudevents.Event{ + Context: cloudevents.EventContextV03{ + ID: "321-CBA", + Type: "unit.test.client.response", + Time: &cloudevents.Timestamp{Time: now}, + Source: *cloudevents.ParseURLRef("/unit/test/client"), + DataContentType: cloudevents.StringOfApplicationJSON(), + }.AsV03(), + Data: map[string]string{"unittest": "response"}, + }, + asSent: &TapValidation{ + Method: "POST", + URI: "/", + Header: map[string][]string{ + "ce-cloudeventsversion": {"0.1"}, + "ce-eventid": {"ABC-123"}, + "ce-eventtime": {now.UTC().Format(time.RFC3339Nano)}, + "ce-eventtype": {"unit.test.client.sent"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"hello":"unittest"}`, + ContentLength: 20, + }, + asRecv: &TapValidation{ + Header: map[string][]string{ + "ce-specversion": {"0.3"}, + "ce-id": {"321-CBA"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.response"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"unittest":"response"}`, + Status: "200 OK", + ContentLength: 23, + }, + }, + } + + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + ClientLoopback(t, tc) + }) + } +} diff --git a/test/http/loopback_v02_test.go b/test/http/loopback_v02_test.go new file mode 100644 index 000000000..bd1d61c1d --- /dev/null +++ b/test/http/loopback_v02_test.go @@ -0,0 +1,276 @@ +package http + +import ( + "fmt" + "github.com/cloudevents/sdk-go" + "testing" + "time" +) + +func TestClientLoopback_binary_v02tov01(t *testing.T) { + now := time.Now() + + testCases := TapTestCases{ + "Loopback v0.2 -> v0.1": { + now: now, + event: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "ABC-123", + Type: "unit.test.client.sent", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV02(), + Data: map[string]string{"hello": "unittest"}, + }, + resp: &cloudevents.Event{ + Context: cloudevents.EventContextV01{ + EventID: "321-CBA", + EventType: "unit.test.client.response", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV01(), + Data: map[string]string{"unittest": "response"}, + }, + want: &cloudevents.Event{ + Context: cloudevents.EventContextV01{ + EventID: "321-CBA", + EventType: "unit.test.client.response", + EventTime: &cloudevents.Timestamp{Time: now}, + Source: *cloudevents.ParseURLRef("/unit/test/client"), + ContentType: cloudevents.StringOfApplicationJSON(), + }.AsV01(), + Data: map[string]string{"unittest": "response"}, + }, + asSent: &TapValidation{ + Method: "POST", + URI: "/", + Header: map[string][]string{ + "ce-specversion": {"0.2"}, + "ce-id": {"ABC-123"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.sent"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"hello":"unittest"}`, + ContentLength: 20, + }, + asRecv: &TapValidation{ + Header: map[string][]string{ + "ce-cloudeventsversion": {"0.1"}, + "ce-eventid": {"321-CBA"}, + "ce-eventtime": {now.UTC().Format(time.RFC3339Nano)}, + "ce-eventtype": {"unit.test.client.response"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"unittest":"response"}`, + Status: "200 OK", + ContentLength: 23, + }, + }, + } + + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + ClientLoopback(t, tc) + }) + } +} + +func TestClientLoopback_binary_v02tov02(t *testing.T) { + now := time.Now() + + testCases := TapTestCases{ + "Loopback v0.2 -> v0.2": { + now: now, + event: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "ABC-123", + Type: "unit.test.client.sent", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV02(), + Data: map[string]string{"hello": "unittest"}, + }, + resp: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "321-CBA", + Type: "unit.test.client.response", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV02(), + Data: map[string]string{"unittest": "response"}, + }, + want: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "321-CBA", + Type: "unit.test.client.response", + Time: &cloudevents.Timestamp{Time: now}, + Source: *cloudevents.ParseURLRef("/unit/test/client"), + ContentType: cloudevents.StringOfApplicationJSON(), + }.AsV02(), + Data: map[string]string{"unittest": "response"}, + }, + asSent: &TapValidation{ + Method: "POST", + URI: "/", + Header: map[string][]string{ + "ce-specversion": {"0.2"}, + "ce-id": {"ABC-123"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.sent"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"hello":"unittest"}`, + ContentLength: 20, + }, + asRecv: &TapValidation{ + Header: map[string][]string{ + "ce-specversion": {"0.2"}, + "ce-id": {"321-CBA"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.response"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"unittest":"response"}`, + Status: "200 OK", + ContentLength: 23, + }, + }, + } + + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + ClientLoopback(t, tc) + }) + } +} + +func TestClientLoopback_structured_v02tov02(t *testing.T) { + now := time.Now() + + testCases := TapTestCases{ + "Loopback v0.2 -> v0.2": { + now: now, + event: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "ABC-123", + Type: "unit.test.client.sent", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV02(), + Data: map[string]string{"hello": "unittest"}, + }, + resp: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "321-CBA", + Type: "unit.test.client.response", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV02(), + Data: map[string]string{"unittest": "response"}, + }, + want: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "321-CBA", + Type: "unit.test.client.response", + Time: &cloudevents.Timestamp{Time: now}, + Source: *cloudevents.ParseURLRef("/unit/test/client"), + ContentType: cloudevents.StringOfApplicationJSON(), + }.AsV02(), + Data: map[string]string{"unittest": "response"}, + }, + asSent: &TapValidation{ + Method: "POST", + URI: "/", + Header: map[string][]string{ + "content-type": {"application/cloudevents+json"}, + }, + Body: fmt.Sprintf(`{"contenttype":"application/json","data":{"hello":"unittest"},"id":"ABC-123","source":"/unit/test/client","specversion":"0.2","time":%q,"type":"unit.test.client.sent"}`, now.UTC().Format(time.RFC3339Nano)), + }, + asRecv: &TapValidation{ + Header: map[string][]string{ + "content-type": {"application/cloudevents+json"}, + }, + Body: fmt.Sprintf(`{"contenttype":"application/json","data":{"unittest":"response"},"id":"321-CBA","source":"/unit/test/client","specversion":"0.2","time":%q,"type":"unit.test.client.response"}`, now.UTC().Format(time.RFC3339Nano)), + Status: "200 OK", + }, + }, + } + + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + // Time can change the length... + tc.asSent.ContentLength = int64(len(tc.asSent.Body)) + tc.asRecv.ContentLength = int64(len(tc.asRecv.Body)) + + ClientLoopback(t, tc, cloudevents.WithStructuredEncoding()) + }) + } +} + +func TestClientLoopback_binary_v02tov03(t *testing.T) { + now := time.Now() + + testCases := TapTestCases{ + "Loopback v0.2 -> v0.3": { + now: now, + event: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "ABC-123", + Type: "unit.test.client.sent", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV02(), + Data: map[string]string{"hello": "unittest"}, + }, + resp: &cloudevents.Event{ + Context: cloudevents.EventContextV03{ + ID: "321-CBA", + Type: "unit.test.client.response", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV03(), + Data: map[string]string{"unittest": "response"}, + }, + want: &cloudevents.Event{ + Context: cloudevents.EventContextV03{ + ID: "321-CBA", + Type: "unit.test.client.response", + Time: &cloudevents.Timestamp{Time: now}, + Source: *cloudevents.ParseURLRef("/unit/test/client"), + DataContentType: cloudevents.StringOfApplicationJSON(), + }.AsV03(), + Data: map[string]string{"unittest": "response"}, + }, + asSent: &TapValidation{ + Method: "POST", + URI: "/", + Header: map[string][]string{ + "ce-specversion": {"0.2"}, + "ce-id": {"ABC-123"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.sent"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"hello":"unittest"}`, + ContentLength: 20, + }, + asRecv: &TapValidation{ + Header: map[string][]string{ + "ce-specversion": {"0.3"}, + "ce-id": {"321-CBA"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.response"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"unittest":"response"}`, + Status: "200 OK", + ContentLength: 23, + }, + }, + } + + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + ClientLoopback(t, tc) + }) + } +} diff --git a/test/http/loopback_v03_test.go b/test/http/loopback_v03_test.go new file mode 100644 index 000000000..0f90cadc4 --- /dev/null +++ b/test/http/loopback_v03_test.go @@ -0,0 +1,214 @@ +package http + +import ( + "github.com/cloudevents/sdk-go" + "testing" + "time" +) + +func TestClientLoopback_binary_v03tov01(t *testing.T) { + now := time.Now() + + testCases := TapTestCases{ + "Loopback v0.2 -> v0.1": { + now: now, + event: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "ABC-123", + Type: "unit.test.client.sent", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV03(), + Data: map[string]string{"hello": "unittest"}, + }, + resp: &cloudevents.Event{ + Context: cloudevents.EventContextV01{ + EventID: "321-CBA", + EventType: "unit.test.client.response", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV01(), + Data: map[string]string{"unittest": "response"}, + }, + want: &cloudevents.Event{ + Context: cloudevents.EventContextV01{ + EventID: "321-CBA", + EventType: "unit.test.client.response", + EventTime: &cloudevents.Timestamp{Time: now}, + Source: *cloudevents.ParseURLRef("/unit/test/client"), + ContentType: cloudevents.StringOfApplicationJSON(), + }.AsV01(), + Data: map[string]string{"unittest": "response"}, + }, + asSent: &TapValidation{ + Method: "POST", + URI: "/", + Header: map[string][]string{ + "ce-specversion": {"0.3"}, + "ce-id": {"ABC-123"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.sent"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"hello":"unittest"}`, + ContentLength: 20, + }, + asRecv: &TapValidation{ + Header: map[string][]string{ + "ce-cloudeventsversion": {"0.1"}, + "ce-eventid": {"321-CBA"}, + "ce-eventtime": {now.UTC().Format(time.RFC3339Nano)}, + "ce-eventtype": {"unit.test.client.response"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"unittest":"response"}`, + Status: "200 OK", + ContentLength: 23, + }, + }, + } + + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + ClientLoopback(t, tc) + }) + } +} + +func TestClientLoopback_binary_v03tov02(t *testing.T) { + now := time.Now() + + testCases := TapTestCases{ + "Loopback v0.2 -> v0.2": { + now: now, + event: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "ABC-123", + Type: "unit.test.client.sent", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV03(), + Data: map[string]string{"hello": "unittest"}, + }, + resp: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "321-CBA", + Type: "unit.test.client.response", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV02(), + Data: map[string]string{"unittest": "response"}, + }, + want: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "321-CBA", + Type: "unit.test.client.response", + Time: &cloudevents.Timestamp{Time: now}, + Source: *cloudevents.ParseURLRef("/unit/test/client"), + ContentType: cloudevents.StringOfApplicationJSON(), + }.AsV02(), + Data: map[string]string{"unittest": "response"}, + }, + asSent: &TapValidation{ + Method: "POST", + URI: "/", + Header: map[string][]string{ + "ce-specversion": {"0.3"}, + "ce-id": {"ABC-123"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.sent"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"hello":"unittest"}`, + ContentLength: 20, + }, + asRecv: &TapValidation{ + Header: map[string][]string{ + "ce-specversion": {"0.2"}, + "ce-id": {"321-CBA"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.response"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"unittest":"response"}`, + Status: "200 OK", + ContentLength: 23, + }, + }, + } + + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + ClientLoopback(t, tc) + }) + } +} + +func TestClientLoopback_binary_v03tov03(t *testing.T) { + now := time.Now() + + testCases := TapTestCases{ + "Loopback v0.2 -> v0.3": { + now: now, + event: &cloudevents.Event{ + Context: cloudevents.EventContextV02{ + ID: "ABC-123", + Type: "unit.test.client.sent", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV03(), + Data: map[string]string{"hello": "unittest"}, + }, + resp: &cloudevents.Event{ + Context: cloudevents.EventContextV03{ + ID: "321-CBA", + Type: "unit.test.client.response", + Source: *cloudevents.ParseURLRef("/unit/test/client"), + }.AsV03(), + Data: map[string]string{"unittest": "response"}, + }, + want: &cloudevents.Event{ + Context: cloudevents.EventContextV03{ + ID: "321-CBA", + Type: "unit.test.client.response", + Time: &cloudevents.Timestamp{Time: now}, + Source: *cloudevents.ParseURLRef("/unit/test/client"), + DataContentType: cloudevents.StringOfApplicationJSON(), + }.AsV03(), + Data: map[string]string{"unittest": "response"}, + }, + asSent: &TapValidation{ + Method: "POST", + URI: "/", + Header: map[string][]string{ + "ce-specversion": {"0.3"}, + "ce-id": {"ABC-123"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.sent"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"hello":"unittest"}`, + ContentLength: 20, + }, + asRecv: &TapValidation{ + Header: map[string][]string{ + "ce-specversion": {"0.3"}, + "ce-id": {"321-CBA"}, + "ce-time": {now.UTC().Format(time.RFC3339Nano)}, + "ce-type": {"unit.test.client.response"}, + "ce-source": {"/unit/test/client"}, + "content-type": {"application/json"}, + }, + Body: `{"unittest":"response"}`, + Status: "200 OK", + ContentLength: 23, + }, + }, + } + + for n, tc := range testCases { + t.Run(n, func(t *testing.T) { + ClientLoopback(t, tc) + }) + } +} diff --git a/test/http/tap_handler.go b/test/http/tap_handler.go new file mode 100644 index 000000000..b7821986e --- /dev/null +++ b/test/http/tap_handler.go @@ -0,0 +1,99 @@ +package http + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" +) + +type TapValidation struct { + Method string + URI string + Header http.Header + Body string + Status string + ContentLength int64 +} + +type tapHandler struct { + handler http.Handler + + req map[string]TapValidation + resp map[string]TapValidation +} + +func NewTap() *tapHandler { + return &tapHandler{ + req: make(map[string]TapValidation, 10), + resp: make(map[string]TapValidation, 10), + } +} + +const ( + unitTestIDKey = "Test-Ce-Id" +) + +func (t *tapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + id := r.Header.Get(unitTestIDKey) + + // Make a copy of the request. + body, err := ioutil.ReadAll(r.Body) + if err != nil { + fmt.Printf("failed to read the request body") + } + // Set the body back + r.Body = ioutil.NopCloser(bytes.NewReader(body)) + + t.req[id] = TapValidation{ + Method: r.Method, + URI: r.RequestURI, + Header: copyHeaders(r.Header), + Body: string(body), + ContentLength: r.ContentLength, + } + + if t.handler == nil { + w.WriteHeader(500) + return + } + + rec := httptest.NewRecorder() + t.handler.ServeHTTP(rec, r) + + resp := rec.Result() + for k, vs := range resp.Header { + for _, v := range vs { + w.Header().Set(k, v) + } + } + body, err = ioutil.ReadAll(resp.Body) + if err != nil { + fmt.Printf("failed to read the resp body") + } + _, _ = w.Write(body) + _ = resp.Body.Close() + + t.resp[id] = TapValidation{ + Status: resp.Status, + Header: copyHeaders(resp.Header), + Body: string(body), + ContentLength: resp.ContentLength, + } +} + +func copyHeaders(from http.Header) http.Header { + to := http.Header{} + if from == nil || to == nil { + return to + } + for header, values := range from { + for _, value := range values { + to.Add(header, value) + } + } + return to +} diff --git a/test/http/validation.go b/test/http/validation.go new file mode 100644 index 000000000..9f433ec0d --- /dev/null +++ b/test/http/validation.go @@ -0,0 +1,68 @@ +package http + +import ( + "github.com/cloudevents/sdk-go" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "strings" + "testing" +) + +var ( + // Headers that are added to the response, but we don't want to check in our assertions. + unimportantHeaders = []string{ + "accept-encoding", + "content-length", + "user-agent", + "connection", + "test-ce-id", + } +) + +func assertEventEquality(t *testing.T, ctx string, expected, actual *cloudevents.Event) { + if diff := cmp.Diff(expected, actual, cmpopts.IgnoreFields(cloudevents.Event{}, "Data")); diff != "" { + t.Errorf("Unexpected difference in %s (-want, +got): %v", ctx, diff) + } + data := make(map[string]string, 0) + err := actual.DataAs(&data) + if err != nil { + t.Error(err) + } + if diff := cmp.Diff(expected.Data, data); diff != "" { + t.Errorf("Unexpected data difference in %s (-want, +got): %v", ctx, diff) + } +} + +func assertTappedEquality(t *testing.T, ctx string, expected, actual *TapValidation) { + canonicalizeHeaders(expected, actual) + if diff := cmp.Diff(expected, actual); diff != "" { + t.Errorf("Unexpected difference in %s (-want, +got): %v", ctx, diff) + } +} + +func canonicalizeHeaders(rvs ...*TapValidation) { + // HTTP header names are case-insensitive, so normalize them to lower case for comparison. + for _, rv := range rvs { + if rv == nil || rv.Header == nil { + continue + } + header := rv.Header + for n, v := range header { + delete(header, n) + ln := strings.ToLower(n) + + if isImportantHeader(ln) { + header[ln] = v + } + } + } +} + +func isImportantHeader(h string) bool { + for _, v := range unimportantHeaders { + if v == h { + return false + } + } + return true +}