Skip to content

Commit

Permalink
Merge pull request #8 from anexia/feature/SIANXSVC-1222-add-binary-fi…
Browse files Browse the repository at this point in the history
…le-api

SIANXSVC-1222: Add binary file API
  • Loading branch information
beachmachine authored Feb 28, 2024
2 parents 8a159d0 + 40ddb00 commit 3276520
Show file tree
Hide file tree
Showing 4 changed files with 313 additions and 0 deletions.
32 changes: 32 additions & 0 deletions example_binary_files_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
128 changes: 128 additions & 0 deletions file.go
Original file line number Diff line number Diff line change
@@ -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{}
125 changes: 125 additions & 0 deletions file_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
28 changes: 28 additions & 0 deletions testdata/binary_request_with_multiple_files.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}

0 comments on commit 3276520

Please sign in to comment.