From dcdfd854fcb2db6ff6047ec817517c46cf3fab26 Mon Sep 17 00:00:00 2001 From: Jasmin Oster Date: Tue, 13 Feb 2024 12:11:38 +0100 Subject: [PATCH] SIANXSVC-1222: Add a binary file API to go-e5e Closes SIANXSVC-1222 --- example_binary_files_test.go | 32 +++++ file.go | 128 ++++++++++++++++++ file_test.go | 125 +++++++++++++++++ .../binary_request_with_multiple_files.json | 28 ++++ 4 files changed, 313 insertions(+) create mode 100644 example_binary_files_test.go create mode 100644 file.go create mode 100644 file_test.go create mode 100644 testdata/binary_request_with_multiple_files.json diff --git a/example_binary_files_test.go b/example_binary_files_test.go new file mode 100644 index 0000000..b820156 --- /dev/null +++ b/example_binary_files_test.go @@ -0,0 +1,32 @@ +package e5e_test + +import ( + "context" + + "go.anx.io/e5e/v2" +) + +func BinaryInverse(_ context.Context, request e5e.Request[e5e.File, any]) (*e5e.Result, error) { + var outputBinary []byte + + inputBinary := request.Data().Bytes() + for _, inputByte := range inputBinary { + outputBinary = append(outputBinary, inputByte^255) + } + + outputFile := e5e.File{ + Name: "output.blob", + ContentType: "x-my-first-function/blob", + } + outputFile.Write(outputBinary) + + return &e5e.Result{ + Type: e5e.ResultDataTypeBinary, + Data: outputFile, + }, nil +} + +func Example_binaryContent() { + e5e.AddHandlerFunc("MyFunction", BinaryInverse) + e5e.Start(context.Background()) +} diff --git a/file.go b/file.go new file mode 100644 index 0000000..f7b0bab --- /dev/null +++ b/file.go @@ -0,0 +1,128 @@ +package e5e + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// File contains information about a received or sent file. +// It is commonly used with "mixed" or "binary" requests/responses. +type File struct { + // The contents of the file, encoded in [Charset]. + content []byte + + // The type of this binary, usually just "binary". + Type string `json:"type"` + + // The size of the file in bytes. + // If it cannot be determined reliably, just leave it at the default value. + SizeInBytes int64 `json:"size,omitempty"` + + // The optional filename of the file. + Name string `json:"name,omitempty"` + + // The content type of the file. + // For responses, the Content-Type heaader is set automatically be the E5E engine to this value. + ContentType string `json:"content_type,omitempty"` + + // The charset of the file. Should be set to the recommended value "utf-8". + Charset string `json:"charset,omitempty"` +} + +// SetText sets the content of this file to the encoded version of text. +// It further enforces the content type to "text/plain". The file size and the charset are set, +// if they haven't been set already modified by the user. +func (f *File) SetPlainText(text string) error { + _, err := f.Write([]byte(text)) + if err != nil { + return err + } + f.ContentType = "text/plain" + return nil +} + +// Bytes returns the raw bytes of this file. +func (f File) Bytes() []byte { return f.content } + +// Read implements io.Reader. +func (f File) Read(p []byte) (n int, err error) { return copy(p, f.content), io.EOF } + +// Write implements io.Writer. +// It further sets the content type to the output of [http.DetectContentType], +// the file size and the charset, if none of those properties have been set before. +func (f *File) Write(p []byte) (n int, err error) { + // Set a copy of the slice as the content, so we don't keep + // a reference to the original. + f.content = p[:] + if f.Charset == "" { + f.Charset = "utf-8" + } + if f.ContentType == "" { + // If the content type appends the charset, we remove it. + // This happens for content types like "text/plain; charset=utf-8" + f.ContentType, _, _ = strings.Cut(http.DetectContentType(p), "; ") + } + if f.SizeInBytes == 0 { + f.SizeInBytes = int64(len(p)) + } + return len(p), nil +} + +// rawFile describes the structure that we receive from e5e. +// It is just used for internal decoding. +type rawFile struct { + Base64Encoded string `json:"binary"` + Type string `json:"type"` + FileSizeInBytes int64 `json:"size,omitempty"` + Filename string `json:"name,omitempty"` + ContentType string `json:"content_type,omitempty"` + Charset string `json:"charset,omitempty"` +} + +// MarshalJSON implements json.Marshaler. +func (f File) MarshalJSON() ([]byte, error) { + if f.Type == "" { + f.Type = "binary" + } + + file := rawFile{ + Base64Encoded: base64.StdEncoding.EncodeToString(f.content), + Type: f.Type, + FileSizeInBytes: f.SizeInBytes, + Filename: f.Name, + ContentType: f.ContentType, + Charset: f.Charset, + } + return json.Marshal(file) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (f *File) UnmarshalJSON(data []byte) error { + var file rawFile + if err := json.Unmarshal(data, &file); err != nil { + return err + } + + fileBytes, err := base64.StdEncoding.DecodeString(file.Base64Encoded) + if err != nil { + return fmt.Errorf("%q attribute does not contain a valid base64 string: %w", "binary", err) + } + + f.content = fileBytes + f.Type = file.Type + f.SizeInBytes = file.FileSizeInBytes + f.Name = file.Filename + f.ContentType = file.ContentType + f.Charset = file.Charset + return nil +} + +// compile-time check for certain interfaces +var _ io.Reader = File{} +var _ io.Writer = &File{} +var _ json.Unmarshaler = &File{} +var _ json.Marshaler = File{} diff --git a/file_test.go b/file_test.go new file mode 100644 index 0000000..afa7e97 --- /dev/null +++ b/file_test.go @@ -0,0 +1,125 @@ +package e5e_test + +import ( + _ "embed" + "encoding/json" + "io" + "strings" + "testing" + + "go.anx.io/e5e/v2" +) + +//go:embed testdata/binary_request_with_multiple_files.json +var binaryRequestWithMultipleFiles []byte + +func TestFile(t *testing.T) { + t.Parallel() + t.Run("SetText encodes the content properly", func(t *testing.T) { + t.Parallel() + f := &e5e.File{} + _ = f.SetPlainText("Hello world!") + + Equal(t, "utf-8", f.Charset, "Charset does not match") + Equal(t, 12, int(f.SizeInBytes), "file size does not match") + Equal(t, "text/plain", f.ContentType, "content type does not match") + + var encodedBytes = []byte{72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33} + DeepEqual(t, encodedBytes, f.Bytes(), "bytes do not match") + }) + t.Run("JSON serialization matches expectation", func(t *testing.T) { + t.Parallel() + var expect = `{"binary":"SGVsbG8gd29ybGQh","type":"binary","size":12,"content_type":"text/plain","charset":"utf-8"}` + + f := &e5e.File{} + _, err := f.Write([]byte("Hello world!")) + if err != nil { + t.Errorf("expected no write error, got: %v", err) + } + + // One test with the pointer + actual, err := json.Marshal(f) + if err != nil { + t.Errorf("JSON marshalling failed: %v", err) + } + + Equal(t, expect, string(actual), "JSON does not match") + + // And one without it + actual, err = json.Marshal(*f) + if err != nil { + t.Errorf("JSON marshalling failed: %v", err) + } + + Equal(t, expect, string(actual), "JSON does not match") + }) + t.Run("JSON deserialization works", func(t *testing.T) { + t.Parallel() + var expected = e5e.File{ + Type: "binary", + SizeInBytes: 12, + Name: "my-file-1.name", + ContentType: "application/my-content-type-1", + Charset: "utf-8", + } + expected.SetPlainText("Hello world!") + expected.ContentType = "application/my-content-type-1" + + const input = `{ + "binary": "SGVsbG8gd29ybGQh", + "type": "binary", + "name": "my-file-1.name", + "size": 12, + "content_type": "application/my-content-type-1", + "charset": "utf-8" + }` + var actual e5e.File + if err := json.Unmarshal([]byte(input), &actual); err != nil { + t.Errorf("JSON unmarshaling failed: %v", err) + } + DeepEqual(t, expected, actual, "files do not match") + }) + t.Run("original slice is ignored", func(t *testing.T) { + var original = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9} + var modified = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9} + f := &e5e.File{} + n, _ := f.Write(modified) + Equal(t, 9, n, "written bytes do not match") + modified = append(modified, 10) + DeepEqual(t, f.Bytes(), original, "slice got passed by reference") + }) + t.Run("request can be deserialized", func(t *testing.T) { + request := e5e.Request[[]e5e.File, any]{} + if err := json.Unmarshal(binaryRequestWithMultipleFiles, &request); err != nil { + t.Errorf("JSON unmarshaling failed: %v", err) + } + + Equal(t, 2, len(request.Data()), "expected two files") + for _, file := range request.Data() { + Equal(t, "binary", file.Type, "file type does not match") + Equal(t, 12, file.SizeInBytes, "file size does not match") + Equal(t, "utf-8", file.Charset, "charset does not match") + if !strings.HasPrefix(file.ContentType, "application/my-content-type") { + t.Errorf("invalid content type prefix, got: %s", file.ContentType) + } + if !strings.HasPrefix(file.Name, "my-file-") { + t.Errorf("invalid name prefix, got: %s", file.Name) + } + } + }) + t.Run("file can be read", func(t *testing.T) { + t.Parallel() + file := &e5e.File{} + if err := file.SetPlainText("Hello world!"); err != nil { + t.Errorf("setting file content failed: %v", err) + } + + var buf strings.Builder + n, err := io.Copy(&buf, file) + if err != nil { + t.Errorf("copying failed: %v", err) + } + Equal(t, 12, n, "read bytes do not match") + Equal(t, "Hello world!", buf.String(), "file content does not match") + }) +} diff --git a/testdata/binary_request_with_multiple_files.json b/testdata/binary_request_with_multiple_files.json new file mode 100644 index 0000000..675b88f --- /dev/null +++ b/testdata/binary_request_with_multiple_files.json @@ -0,0 +1,28 @@ +{ + "context": { + "type": "integration-test", + "async": true, + "date": "2024-01-01T00:00:00Z" + }, + "event": { + "type": "mixed", + "data": [ + { + "binary": "SGVsbG8gd29ybGQh", + "type": "binary", + "name": "my-file-1.name", + "size": 12, + "content_type": "application/my-content-type-1", + "charset": "utf-8" + }, + { + "binary": "SGVsbG8gd29ybGQh", + "type": "binary", + "name": "my-file-2.name", + "size": 12, + "content_type": "application/my-content-type-2", + "charset": "utf-8" + } + ] + } +}