From c68ed0e77640d1afdd04ba8c58714791fab1255f Mon Sep 17 00:00:00 2001 From: dan-j <5727701+dan-j@users.noreply.github.com> Date: Fri, 19 Jan 2024 23:23:12 +0000 Subject: [PATCH] Support content types following structured syntax suffixes This enables the encoding/decoding of custom content types which use the `+json` or `+xml` suffix to indicate that their encoding is JSON or XML respectively. Users can also add their own suffixes to the registry in a similar fashion to how `datacodec.AddDecoder()` and `dataodec.AddEncoder()` work, but via the `AddStructuredSuffixDecoder` and `AddStructuredSuffixEncoder` functions. Signed-off-by: dan-j <5727701+dan-j@users.noreply.github.com> --- v2/event/datacodec/codec.go | 59 +++++++++++++++ v2/event/datacodec/codec_test.go | 123 +++++++++++++++++++++++++++---- 2 files changed, 168 insertions(+), 14 deletions(-) diff --git a/v2/event/datacodec/codec.go b/v2/event/datacodec/codec.go index 3e077740b..6f5d1f4c5 100644 --- a/v2/event/datacodec/codec.go +++ b/v2/event/datacodec/codec.go @@ -8,6 +8,7 @@ package datacodec import ( "context" "fmt" + "strings" "github.com/cloudevents/sdk-go/v2/event/datacodec/json" "github.com/cloudevents/sdk-go/v2/event/datacodec/text" @@ -26,9 +27,20 @@ type Encoder func(ctx context.Context, in interface{}) ([]byte, error) var decoder map[string]Decoder var encoder map[string]Encoder +// ssDecoder is a map of content-type structured suffixes as defined in +// [Structured Syntax Suffixes](https://www.iana.org/assignments/media-type-structured-suffix/media-type-structured-suffix.xhtml), +// which may be used to match content types such as application/vnd.custom-app+json +var ssDecoder map[string]Decoder + +// ssEncoder is a map of content-type structured suffixes similar to ssDecoder. +var ssEncoder map[string]Encoder + func init() { decoder = make(map[string]Decoder, 10) + ssDecoder = make(map[string]Decoder, 10) + encoder = make(map[string]Encoder, 10) + ssEncoder = make(map[string]Encoder, 10) AddDecoder("", json.Decode) AddDecoder("application/json", json.Decode) @@ -37,12 +49,18 @@ func init() { AddDecoder("text/xml", xml.Decode) AddDecoder("text/plain", text.Decode) + AddStructuredSuffixDecoder("json", json.Decode) + AddStructuredSuffixDecoder("xml", xml.Decode) + AddEncoder("", json.Encode) AddEncoder("application/json", json.Encode) AddEncoder("text/json", json.Encode) AddEncoder("application/xml", xml.Encode) AddEncoder("text/xml", xml.Encode) AddEncoder("text/plain", text.Encode) + + AddStructuredSuffixEncoder("json", json.Encode) + AddStructuredSuffixEncoder("xml", xml.Encode) } // AddDecoder registers a decoder for a given content type. The codecs will use @@ -51,12 +69,34 @@ func AddDecoder(contentType string, fn Decoder) { decoder[contentType] = fn } +// AddStructuredSuffixDecoder registers a decoder for content-types which match the given structured +// syntax suffix as defined by +// [Structured Syntax Suffixes](https://www.iana.org/assignments/media-type-structured-suffix/media-type-structured-suffix.xhtml). +// This allows users to register custom decoders for non-standard content types which follow the +// structured syntax suffix standard (e.g. application/vnd.custom-app+json). +// +// Suffix should not include the "+" character, and "json" and "xml" are registered by default. +func AddStructuredSuffixDecoder(suffix string, fn Decoder) { + ssDecoder[suffix] = fn +} + // AddEncoder registers an encoder for a given content type. The codecs will // use these to encode the data payload for a cloudevent.Event object. func AddEncoder(contentType string, fn Encoder) { encoder[contentType] = fn } +// AddStructuredSuffixEncoder registers an encoder for content-types which match the given +// structured syntax suffix as defined by +// [Structured Syntax Suffixes](https://www.iana.org/assignments/media-type-structured-suffix/media-type-structured-suffix.xhtml). +// This allows users to register custom encoders for non-standard content types which follow the +// structured syntax suffix standard (e.g. application/vnd.custom-app+json). +// +// Suffix should not include the "+" character, and "json" and "xml" are registered by default. +func AddStructuredSuffixEncoder(suffix string, fn Encoder) { + ssEncoder[suffix] = fn +} + // Decode looks up and invokes the decoder registered for the given content // type. An error is returned if no decoder is registered for the given // content type. @@ -64,6 +104,11 @@ func Decode(ctx context.Context, contentType string, in []byte, out interface{}) if fn, ok := decoder[contentType]; ok { return fn(ctx, in, out) } + + if fn, ok := ssDecoder[structuredSuffix(contentType)]; ok { + return fn(ctx, in, out) + } + return fmt.Errorf("[decode] unsupported content type: %q", contentType) } @@ -74,5 +119,19 @@ func Encode(ctx context.Context, contentType string, in interface{}) ([]byte, er if fn, ok := encoder[contentType]; ok { return fn(ctx, in) } + + if fn, ok := ssEncoder[structuredSuffix(contentType)]; ok { + return fn(ctx, in) + } + return nil, fmt.Errorf("[encode] unsupported content type: %q", contentType) } + +func structuredSuffix(contentType string) string { + parts := strings.Split(contentType, "+") + if len(parts) >= 2 { + return parts[len(parts)-1] + } + + return "" +} diff --git a/v2/event/datacodec/codec_test.go b/v2/event/datacodec/codec_test.go index 0fd96ef5d..bc6ec3558 100644 --- a/v2/event/datacodec/codec_test.go +++ b/v2/event/datacodec/codec_test.go @@ -11,9 +11,10 @@ import ( "strings" "testing" + "github.com/google/go-cmp/cmp" + "github.com/cloudevents/sdk-go/v2/event/datacodec" "github.com/cloudevents/sdk-go/v2/types" - "github.com/google/go-cmp/cmp" ) func strptr(s string) *string { return &s } @@ -25,11 +26,12 @@ type Example struct { func TestCodecDecode(t *testing.T) { testCases := map[string]struct { - contentType string - decoder datacodec.Decoder - in []byte - want interface{} - wantErr string + contentType string + decoder datacodec.Decoder + structuredSuffix string + in []byte + want interface{} + wantErr string }{ "empty": {}, "invalid content type": { @@ -50,12 +52,24 @@ func TestCodecDecode(t *testing.T) { "b": "banana", }, }, + "application/vnd.custom-type+json": { + contentType: "application/vnd.custom-type+json", + in: []byte(`{"a":"apple","b":"banana"}`), + want: &map[string]string{ + "a": "apple", + "b": "banana", + }, + }, "application/xml": { contentType: "application/xml", in: []byte(`7Hello, Structured Encoding v1.0!`), want: &Example{Sequence: 7, Message: "Hello, Structured Encoding v1.0!"}, }, - + "application/vnd.custom-type+xml": { + contentType: "application/vnd.custom-type+xml", + in: []byte(`7Hello, Structured Encoding v1.0!`), + want: &Example{Sequence: 7, Message: "Hello, Structured Encoding v1.0!"}, + }, "custom content type": { contentType: "unit/testing", in: []byte("Hello, Testing"), @@ -82,12 +96,44 @@ func TestCodecDecode(t *testing.T) { }, wantErr: "expecting unit test error", }, + "custom structured suffix": { + contentType: "unit/testing+custom", + structuredSuffix: "custom", + in: []byte("Hello, Testing"), + decoder: func(ctx context.Context, in []byte, out interface{}) error { + if s, k := out.(*map[string]string); k { + if (*s) == nil { + (*s) = make(map[string]string) + } + (*s)["upper"] = strings.ToUpper(string(in)) + (*s)["lower"] = strings.ToLower(string(in)) + } + return nil + }, + want: &map[string]string{ + "upper": "HELLO, TESTING", + "lower": "hello, testing", + }, + }, + "custom structured suffix error": { + contentType: "unit/testing+custom", + structuredSuffix: "custom", + in: []byte("Hello, Testing"), + decoder: func(ctx context.Context, in []byte, out interface{}) error { + return fmt.Errorf("expecting unit test error") + }, + wantErr: "expecting unit test error", + }, } for n, tc := range testCases { t.Run(n, func(t *testing.T) { if tc.decoder != nil { - datacodec.AddDecoder(tc.contentType, tc.decoder) + if tc.structuredSuffix == "" { + datacodec.AddDecoder(tc.contentType, tc.decoder) + } else { + datacodec.AddStructuredSuffixDecoder(tc.structuredSuffix, tc.decoder) + } } got, _ := types.Allocate(tc.want) @@ -111,11 +157,12 @@ func TestCodecDecode(t *testing.T) { func TestCodecEncode(t *testing.T) { testCases := map[string]struct { - contentType string - encoder datacodec.Encoder - in interface{} - want []byte - wantErr string + contentType string + structuredSuffix string + encoder datacodec.Encoder + in interface{} + want []byte + wantErr string }{ "empty": {}, "invalid content type": { @@ -138,11 +185,24 @@ func TestCodecEncode(t *testing.T) { }, want: []byte(`{"a":"apple","b":"banana"}`), }, + "application/vnd.custom-type+json": { + contentType: "application/vnd.custom-type+json", + in: map[string]string{ + "a": "apple", + "b": "banana", + }, + want: []byte(`{"a":"apple","b":"banana"}`), + }, "application/xml": { contentType: "application/xml", in: &Example{Sequence: 7, Message: "Hello, Structured Encoding v1.0!"}, want: []byte(`7Hello, Structured Encoding v1.0!`), }, + "application/vnd.custom-type+xml": { + contentType: "application/vnd.custom-type+xml", + in: &Example{Sequence: 7, Message: "Hello, Structured Encoding v1.0!"}, + want: []byte(`7Hello, Structured Encoding v1.0!`), + }, "custom content type": { contentType: "unit/testing", @@ -173,12 +233,47 @@ func TestCodecEncode(t *testing.T) { }, wantErr: "expecting unit test error", }, + "custom structured suffix": { + contentType: "unit/testing+custom", + structuredSuffix: "custom", + in: []string{ + "Hello,", + "Testing", + }, + encoder: func(ctx context.Context, in interface{}) ([]byte, error) { + if s, ok := in.([]string); ok { + sb := strings.Builder{} + for _, v := range s { + if sb.Len() > 0 { + sb.WriteString(" ") + } + sb.WriteString(v) + } + return []byte(sb.String()), nil + } + return nil, fmt.Errorf("don't get here") + }, + want: []byte("Hello, Testing"), + }, + "custom structured suffix error": { + contentType: "unit/testing+custom", + structuredSuffix: "custom", + in: []byte("Hello, Testing"), + encoder: func(ctx context.Context, in interface{}) ([]byte, error) { + return nil, fmt.Errorf("expecting unit test error") + }, + wantErr: "expecting unit test error", + }, } for n, tc := range testCases { t.Run(n, func(t *testing.T) { if tc.encoder != nil { - datacodec.AddEncoder(tc.contentType, tc.encoder) + if tc.structuredSuffix == "" { + datacodec.AddEncoder(tc.contentType, tc.encoder) + } else { + datacodec.AddStructuredSuffixEncoder(tc.structuredSuffix, tc.encoder) + } } got, err := datacodec.Encode(context.TODO(), tc.contentType, tc.in)