diff --git a/.gitignore b/.gitignore index 3afed1e..dd5987f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ vendor default.etcd/ coverage.txt profile.out +tests/testing.etcd* diff --git a/docs/api.md b/docs/api.md index 7c73db2..160d849 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,23 +16,19 @@ for invoking function. By default Events API runs on `:4000` port. ### Event Definition -All data that passes through the Event Gateway is formatted as an Event, based on our default Event schema: - -* `event` - `string` - the event name -* `id` - `string` - the event's instance universally unique ID (provided by the event gateway) -* `receivedAt` - `number` - the time (milliseconds) when the Event was received by the Event Gateway (provided by the event gateway) -* `data` - type depends on `dataType` - the event payload -* `dataType` - `string` - the mime type of `data` payload +All data that passes through the Event Gateway is formatted as a CloudEvent, based on [CloudEvents v0.1 schema](https://github.com/cloudevents/spec/blob/master/spec.md): Example: ```json { - "event": "myapp.user.created", - "id": "66dfc31d-6844-42fd-b1a7-a489a49f65f3", - "receivedAt": 1500897327098, + "eventType": "myapp.user.created", + "eventID": "66dfc31d-6844-42fd-b1a7-a489a49f65f3", + "cloudEventsVersion": "0.1", + "source": "https://serverless.com/event-gateway/#transformationVersion=0.1", + "eventTime": "1990-12-31T23:59:60Z", "data": { "foo": "bar" }, - "dataType": "application/json" + "contentType": "application/json" } ``` diff --git a/event/event.go b/event/event.go index 4cd65ef..c409d01 100644 --- a/event/event.go +++ b/event/event.go @@ -2,57 +2,150 @@ package event import ( "encoding/json" + "errors" "strings" "time" "go.uber.org/zap/zapcore" uuid "github.com/satori/go.uuid" + "github.com/serverless/event-gateway/internal/zap" + validator "gopkg.in/go-playground/validator.v9" ) -// Event is a default event structure. All data that passes through the Event Gateway is formatted as an Event, based on -// this schema. +// Type uniquely identifies an event type. +type Type string + +const ( + // TypeInvoke is a special type of event for sync function invocation. + TypeInvoke = Type("invoke") + // TypeHTTP is a special type of event for sync http subscriptions. + TypeHTTP = Type("http") +) + +// TransformationVersion is indicative of the revision of how Event Gateway transforms a request into CloudEvents format. +const ( + TransformationVersion = "0.1" +) + +const ( + mimeJSON = "application/json" + mimeFormMultipart = "multipart/form-data" + mimeFormURLEncoded = "application/x-www-form-urlencoded" +) + +// Event is a default event structure. All data that passes through the Event Gateway +// is formatted to a format defined CloudEvents v0.1 spec. type Event struct { - Type Type `json:"event"` - ID string `json:"id"` - ReceivedAt uint64 `json:"receivedAt"` - Data interface{} `json:"data"` - DataType string `json:"dataType"` + EventType Type `json:"eventType" validate:"required"` + EventTypeVersion string `json:"eventTypeVersion,omitempty"` + CloudEventsVersion string `json:"cloudEventsVersion" validate:"required"` + Source string `json:"source" validate:"url,required"` + EventID string `json:"eventID" validate:"required"` + EventTime time.Time `json:"eventTime,omitempty"` + SchemaURL string `json:"schemaURL,omitempty"` + Extensions zap.MapStringInterface `json:"extensions,omitempty"` + ContentType string `json:"contentType,omitempty"` + Data interface{} `json:"data"` } // New return new instance of Event. func New(eventType Type, mime string, payload interface{}) *Event { - return &Event{ - Type: eventType, - ID: uuid.NewV4().String(), - ReceivedAt: uint64(time.Now().UnixNano() / int64(time.Millisecond)), - DataType: mime, - Data: payload, + event := &Event{ + EventType: eventType, + CloudEventsVersion: "0.1", + Source: "https://serverless.com/event-gateway/#transformationVersion=" + TransformationVersion, + EventID: uuid.NewV4().String(), + EventTime: time.Now(), + ContentType: mime, + Data: payload, } -} -// Type uniquely identifies an event type. -type Type string + // it's a custom event, possibly CloudEvent + if eventType != TypeHTTP && eventType != TypeInvoke { + cloudEvent, err := parseAsCloudEvent(eventType, mime, payload) + if err == nil { + event = cloudEvent + } else { + event.Extensions = zap.MapStringInterface{ + "eventgateway": map[string]interface{}{ + "transformed": true, + "transformation-version": TransformationVersion, + }, + } + } + } -// TypeInvoke is a special type of event for sync function invocation. -const TypeInvoke = Type("invoke") + // Because event.Data is []bytes here, it will be base64 encoded by default when being sent to remote function, + // which is why we change the event.Data type to "string" for forms, so that, it is left intact. + if eventBody, ok := event.Data.([]byte); ok && len(eventBody) > 0 { + switch { + case mime == mimeJSON: + json.Unmarshal(eventBody, &event.Data) + case strings.HasPrefix(mime, mimeFormMultipart), mime == mimeFormURLEncoded: + event.Data = string(eventBody) + } + } -// TypeHTTP is a special type of event for sync http subscriptions. -const TypeHTTP = Type("http") + return event +} // MarshalLogObject is a part of zapcore.ObjectMarshaler interface func (e Event) MarshalLogObject(enc zapcore.ObjectEncoder) error { - enc.AddString("type", string(e.Type)) - enc.AddString("id", e.ID) - enc.AddUint64("receivedAt", e.ReceivedAt) + enc.AddString("eventType", string(e.EventType)) + if e.EventTypeVersion != "" { + enc.AddString("eventTypeVersion", e.EventTypeVersion) + } + enc.AddString("cloudEventsVersion", e.CloudEventsVersion) + enc.AddString("source", e.Source) + enc.AddString("eventID", e.EventID) + enc.AddString("eventTime", e.EventTime.String()) + if e.SchemaURL != "" { + enc.AddString("schemaURL", e.SchemaURL) + } + if e.ContentType != "" { + enc.AddString("contentType", e.ContentType) + } + if e.Extensions != nil { + e.Extensions.MarshalLogObject(enc) + } + payload, _ := json.Marshal(e.Data) enc.AddString("data", string(payload)) - enc.AddString("dataType", e.DataType) return nil } -// IsSystem indicates if th event is a system event. +// IsSystem indicates if the event is a system event. func (e Event) IsSystem() bool { - return strings.HasPrefix(string(e.Type), "gateway.") + return strings.HasPrefix(string(e.EventType), "gateway.") +} + +func parseAsCloudEvent(eventType Type, mime string, payload interface{}) (*Event, error) { + if mime != mimeJSON { + return nil, errors.New("content type is not json") + } + body, ok := payload.([]byte) + if ok { + validate := validator.New() + + customEvent := &Event{} + err := json.Unmarshal(body, customEvent) + if err != nil { + return nil, err + } + + err = validate.Struct(customEvent) + if err != nil { + return nil, err + } + + if eventType != customEvent.EventType { + return nil, errors.New("wrong event type") + } + + return customEvent, nil + } + + return nil, errors.New("couldn't cast to []byte") } diff --git a/event/event_test.go b/event/event_test.go new file mode 100644 index 0000000..4660a3c --- /dev/null +++ b/event/event_test.go @@ -0,0 +1,180 @@ +package event_test + +import ( + "testing" + + eventpkg "github.com/serverless/event-gateway/event" + "github.com/stretchr/testify/assert" + "github.com/serverless/event-gateway/internal/zap" +) + +func TestNew(t *testing.T) { + for _, testCase := range newTests { + result := eventpkg.New(testCase.eventType, testCase.mime, testCase.payload) + + assert.NotEqual(t, result.EventID, "") + assert.Equal(t, testCase.expectedEvent.EventType, result.EventType) + assert.Equal(t, testCase.expectedEvent.CloudEventsVersion, result.CloudEventsVersion) + assert.Equal(t, testCase.expectedEvent.Source, result.Source) + assert.Equal(t, testCase.expectedEvent.ContentType, result.ContentType) + assert.Equal(t, testCase.expectedEvent.Data, result.Data) + assert.Equal(t, testCase.expectedEvent.Extensions, result.Extensions) + } +} + +func TestNew_Encoding(t *testing.T) { + for _, testCase := range encodingTests { + result := eventpkg.New(eventpkg.Type("test.event"), testCase.contentType, testCase.body) + + assert.Equal(t, testCase.expectedBody, result.Data) + } +} + +var newTests = []struct { + eventType eventpkg.Type + mime string + payload interface{} + expectedEvent eventpkg.Event +}{ + { // not CloudEvent + eventpkg.Type("user.created"), + "application/json", + []byte("test"), + eventpkg.Event{ + EventType: eventpkg.Type("user.created"), + CloudEventsVersion: eventpkg.TransformationVersion, + Source: "https://serverless.com/event-gateway/#transformationVersion=0.1", + ContentType: "application/json", + Data: []byte("test"), + Extensions: zap.MapStringInterface{ + "eventgateway": map[string]interface{}{ + "transformed": true, + "transformation-version": eventpkg.TransformationVersion, + }, + }, + }, + }, + { // System event + eventpkg.Type("user.created"), + "application/json", + eventpkg.SystemEventReceivedData{}, + eventpkg.Event{ + EventType: eventpkg.Type("user.created"), + CloudEventsVersion: eventpkg.TransformationVersion, + Source: "https://serverless.com/event-gateway/#transformationVersion=0.1", + ContentType: "application/json", + Data: eventpkg.SystemEventReceivedData{}, + Extensions: zap.MapStringInterface{ + "eventgateway": map[string]interface{}{ + "transformed": true, + "transformation-version": eventpkg.TransformationVersion, + }, + }, + }, + }, + { + // valid CloudEvent + eventpkg.Type("user.created"), + "application/json", + []byte(`{ + "eventType": "user.created", + "cloudEventsVersion": "`+ eventpkg.TransformationVersion +`", + "source": "https://example.com/", + "eventID": "6f6ada3b-0aa2-4b3c-989a-91ffc6405f11", + "contentType": "text/plain", + "data": "test" + }`), + eventpkg.Event{ + EventType: eventpkg.Type("user.created"), + CloudEventsVersion: eventpkg.TransformationVersion, + Source: "https://example.com/", + ContentType: "text/plain", + Data: "test", + }, + }, + { + // type mismatch + eventpkg.Type("user.deleted"), + "application/json", + []byte(`{ + "eventType": "user.created", + "cloudEventsVersion": "`+ eventpkg.TransformationVersion +`", + "source": "https://example.com/", + "eventID": "6f6ada3b-0aa2-4b3c-989a-91ffc6405f11", + "contentType": "text/plain", + "data": "test" + }`), + eventpkg.Event{ + EventType: eventpkg.Type("user.deleted"), + CloudEventsVersion: eventpkg.TransformationVersion, + Source: "https://serverless.com/event-gateway/#transformationVersion=0.1", + ContentType: "application/json", + Data: map[string]interface{}{ + "eventType": "user.created", + "cloudEventsVersion": eventpkg.TransformationVersion, + "source": "https://example.com/", + "eventID": "6f6ada3b-0aa2-4b3c-989a-91ffc6405f11", + "contentType": "text/plain", + "data": "test", + }, + Extensions: zap.MapStringInterface{ + "eventgateway": map[string]interface{}{ + "transformed": true, + "transformation-version": eventpkg.TransformationVersion, + }, + }, + }, + }, + { + // invalid CloudEvent (missing required fields) + eventpkg.Type("user.created"), + "application/json", + []byte(`{ + "eventType": "user.created", + "cloudEventsVersion": "0.1" + }`), + eventpkg.Event{ + EventType: eventpkg.Type("user.created"), + CloudEventsVersion: eventpkg.TransformationVersion, + Source: "https://serverless.com/event-gateway/#transformationVersion=0.1", + ContentType: "application/json", + Data: map[string]interface{}{ + "eventType": "user.created", + "cloudEventsVersion": eventpkg.TransformationVersion, + }, + Extensions: zap.MapStringInterface{ + "eventgateway": map[string]interface{}{ + "transformed": true, + "transformation-version": eventpkg.TransformationVersion, + }, + }, + }, + }, +} + +var encodingTests = []struct { + body []byte + contentType string + expectedBody interface{} +}{ + { + []byte("some=thing"), + "application/octet-stream", + []byte("some=thing"), + }, + { + []byte("some=thing"), + "application/x-www-form-urlencoded", + "some=thing", + }, + { + []byte("--X-INSOMNIA-BOUNDARY\r\nContent-Disposition: form-data; name=\"some\"\r\n\r\nthing\r\n--X-INSOMNIA-BOUNDARY--\r\n"), + "multipart/form-data; boundary=X-INSOMNIA-BOUNDARY", + "--X-INSOMNIA-BOUNDARY\r\nContent-Disposition: form-data; name=\"some\"\r\n\r\nthing\r\n--X-INSOMNIA-BOUNDARY--\r\n", + }, + { + []byte(`{"hello": "world"}`), + "application/json", + map[string]interface{}{"hello": "world"}, + }, +} diff --git a/event/http.go b/event/http.go index bc572ca..2a84ba0 100644 --- a/event/http.go +++ b/event/http.go @@ -1,5 +1,11 @@ package event +import ( + "net/http" + + ihttp "github.com/serverless/event-gateway/internal/http" +) + // HTTPEvent is a event schema used for sending events to HTTP subscriptions. type HTTPEvent struct { Headers map[string]string `json:"headers"` @@ -10,3 +16,17 @@ type HTTPEvent struct { Method string `json:"method"` Params map[string]string `json:"params"` } + +// NewHTTPEvent returns a new instance of HTTPEvent +func NewHTTPEvent(r *http.Request, eventData interface{}) *HTTPEvent { + headers := ihttp.FlattenHeader(r.Header) + + return &HTTPEvent{ + Headers: headers, + Query: r.URL.Query(), + Body: eventData, + Host: r.Host, + Path: r.URL.Path, + Method: r.Method, + } +} diff --git a/internal/http/headers.go b/internal/http/headers.go new file mode 100644 index 0000000..9e94dac --- /dev/null +++ b/internal/http/headers.go @@ -0,0 +1,21 @@ +package http + +import ( + httppkg "net/http" + "strings" +) + +// FlattenHeader takes http.Header and flatten value array +// (map[string][]string -> map[string]string) so it's easier +// to access headers by user. +func FlattenHeader(req httppkg.Header) map[string]string { + headers := map[string]string{} + for key, header := range req { + headers[key] = header[0] + if len(header) > 1 { + headers[key] = strings.Join(header, ", ") + } + } + + return headers +} diff --git a/internal/http/headers_test.go b/internal/http/headers_test.go new file mode 100644 index 0000000..b5c84a4 --- /dev/null +++ b/internal/http/headers_test.go @@ -0,0 +1,29 @@ +package http_test + +import ( + "net/http" + "testing" + + ihttp "github.com/serverless/event-gateway/internal/http" + "github.com/stretchr/testify/assert" +) + +func TestFlattenHeader(t *testing.T) { + for _, testCase := range flattenHeaderTests { + assert.Equal(t, testCase.result, ihttp.FlattenHeader(testCase.header)) + } +} + +var flattenHeaderTests = []struct { + header http.Header + result map[string]string +}{ + { + map[string][]string{"CustomHeader": []string{"value"}}, + map[string]string{"CustomHeader": "value"}, + }, + { + map[string][]string{"CustomHeader": []string{"value1", "value2"}}, + map[string]string{"CustomHeader": "value1, value2"}, + }, +} diff --git a/internal/zap/strings.go b/internal/zap/strings.go index abdc821..f922e59 100644 --- a/internal/zap/strings.go +++ b/internal/zap/strings.go @@ -1,6 +1,9 @@ package zap -import "go.uber.org/zap/zapcore" +import ( + "encoding/json" + "go.uber.org/zap/zapcore" +) // Strings is a string array that implements MarshalLogArray. type Strings []string @@ -12,3 +15,19 @@ func (ss Strings) MarshalLogArray(enc zapcore.ArrayEncoder) error { } return nil } + +// MapStringInterface is a map that implements MarshalLogObject. +type MapStringInterface map[string]interface{} + +// MarshalLogObject implementation +func (msi MapStringInterface) MarshalLogObject(enc zapcore.ObjectEncoder) error { + for key, val := range msi { + v, err := json.Marshal(val) + if err != nil { + enc.AddString(key, string(v)) + } else { + return err + } + } + return nil +} diff --git a/plugin/example/plugin.go b/plugin/example/plugin.go index 4113be9..e706a1f 100644 --- a/plugin/example/plugin.go +++ b/plugin/example/plugin.go @@ -29,10 +29,10 @@ func (s *Simple) Subscriptions() []plugin.Subscription { // React is called for every event that plugin subscribed to. func (s *Simple) React(instance event.Event) error { - switch instance.Type { + switch instance.EventType { case event.SystemEventReceivedType: received := instance.Data.(event.SystemEventReceivedData) - log.Printf("received gateway.received.event for event: %q", received.Event.Type) + log.Printf("received gateway.received.event for event: %q", received.Event.EventType) break } diff --git a/plugin/manager.go b/plugin/manager.go index c333692..8a58679 100644 --- a/plugin/manager.go +++ b/plugin/manager.go @@ -88,7 +88,7 @@ func (m *Manager) Kill() { func (m *Manager) React(event *event.Event) error { for _, plugin := range m.Plugins { for _, subscription := range plugin.Subscriptions { - if subscription.EventType == event.Type { + if subscription.EventType == event.EventType { err := plugin.Reacter.React(*event) if err != nil { m.Log.Debug("Plugin returned error.", diff --git a/router/event.go b/router/event.go index f51a2cb..157aa63 100644 --- a/router/event.go +++ b/router/event.go @@ -1,8 +1,6 @@ package router import ( - "encoding/json" - "errors" "io/ioutil" "net/http" "regexp" @@ -19,12 +17,6 @@ type HTTPResponse struct { Body string `json:"body"` } -const ( - mimeJSON = "application/json" - mimeFormMultipart = "multipart/form-data" - mimeFormURLEncoded = "application/x-www-form-urlencoded" -) - func isHTTPEvent(r *http.Request) bool { // is request with custom event if r.Header.Get("event") != "" { @@ -48,7 +40,6 @@ func isHTTPEvent(r *http.Request) bool { func (router *Router) eventFromRequest(r *http.Request) (*eventpkg.Event, string, error) { path := extractPath(r.Host, r.URL.Path) eventType := extractEventType(r) - headers := transformHeaders(r.Header) mime := r.Header.Get("Content-Type") if mime == "" { @@ -65,34 +56,12 @@ func (router *Router) eventFromRequest(r *http.Request) (*eventpkg.Event, string } event := eventpkg.New(eventType, mime, body) - - // Because event.Data is []bytes here, it will be base64 encoded by default when being sent to remote function, - // which is why we change the event.Data type to "string" for forms, so that, it is left intact. - if len(body) > 0 { - switch { - case mime == mimeJSON: - err := json.Unmarshal(body, &event.Data) - if err != nil { - return nil, "", errors.New("malformed JSON body") - } - case strings.HasPrefix(mime, mimeFormMultipart), mime == mimeFormURLEncoded: - event.Data = string(body) - } - } - - if event.Type == eventpkg.TypeHTTP { - event.Data = &eventpkg.HTTPEvent{ - Headers: headers, - Query: r.URL.Query(), - Body: event.Data, - Host: r.Host, - Path: r.URL.Path, - Method: r.Method, - } + if eventType == eventpkg.TypeHTTP { + event.Data = eventpkg.NewHTTPEvent(r, event.Data) } router.log.Debug("Event received.", zap.String("path", path), zap.Object("event", event)) - err = router.emitSystemEventReceived(path, *event, headers) + err = router.emitSystemEventReceived(path, *event, r.Header) if err != nil { router.log.Debug("Event processing stopped because sync plugin subscription returned an error.", zap.Object("event", event), @@ -120,17 +89,3 @@ func extractEventType(r *http.Request) eventpkg.Type { } return eventType } - -// transformHeaders takes http.Header and flatten value array (map[string][]string -> map[string]string) so it's easier -// to access headers by user. -func transformHeaders(req http.Header) map[string]string { - headers := map[string]string{} - for key, header := range req { - headers[key] = header[0] - if len(header) > 1 { - headers[key] = strings.Join(header, ", ") - } - } - - return headers -} diff --git a/router/router.go b/router/router.go index 06e029b..e92752f 100644 --- a/router/router.go +++ b/router/router.go @@ -15,9 +15,14 @@ import ( eventpkg "github.com/serverless/event-gateway/event" "github.com/serverless/event-gateway/function" "github.com/serverless/event-gateway/httpapi" + ihttp "github.com/serverless/event-gateway/internal/http" "github.com/serverless/event-gateway/plugin" ) +const ( + mimeJSON = "application/json" +) + // Router calls a target function when an endpoint is hit, and handles pubsub message delivery. type Router struct { sync.Mutex @@ -87,7 +92,7 @@ func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - if event.Type == eventpkg.TypeInvoke { + if event.EventType == eventpkg.TypeInvoke { functionID := function.ID(r.Header.Get(headerFunctionID)) space := r.Header.Get(headerSpace) if space == "" { @@ -100,7 +105,7 @@ func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { metricEventsProcessed.WithLabelValues(space, "invoke").Inc() } else if !event.IsSystem() { - reportReceivedEvent(event.ID) + reportReceivedEvent(event.EventID) router.enqueueWork(path, event) w.WriteHeader(http.StatusAccepted) @@ -447,9 +452,9 @@ func (router *Router) loop() { // processEvent call all functions subscribed for an event func (router *Router) processEvent(e backlogEvent) { - reportEventOutOfQueue(e.event.ID) + reportEventOutOfQueue(e.event.EventID) - subscribers := router.targetCache.SubscribersOfEvent(e.path, e.event.Type) + subscribers := router.targetCache.SubscribersOfEvent(e.path, e.event.EventType) for _, subscriber := range subscribers { router.callFunction(subscriber.Space, subscriber.ID, e.event) } @@ -457,11 +462,11 @@ func (router *Router) processEvent(e backlogEvent) { metricEventsProcessed.WithLabelValues("", "custom").Inc() } -func (router *Router) emitSystemEventReceived(path string, event eventpkg.Event, headers map[string]string) error { +func (router *Router) emitSystemEventReceived(path string, event eventpkg.Event, header http.Header) error { system := eventpkg.New( eventpkg.SystemEventReceivedType, mimeJSON, - eventpkg.SystemEventReceivedData{Path: path, Event: event, Headers: headers}, + eventpkg.SystemEventReceivedData{Path: path, Event: event, Headers: ihttp.FlattenHeader(header)}, ) router.enqueueWork("/", system) return router.plugins.React(system) diff --git a/router/router_test.go b/router/router_test.go index 44808c7..ad10ab3 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -88,38 +88,23 @@ func TestRouterServeHTTP_InvokeEventDefaultSpace(t *testing.T) { router.ServeHTTP(recorder, req) } -func TestRouterServeHTTP_ErrorMalformedCustomEventJSONRequest(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - target := mock.NewMockTargeter(ctrl) - router := testrouter(target) - - req, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader("not json")) - req.Header.Set("content-type", "application/json") - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, req) - - assert.Equal(t, http.StatusBadRequest, recorder.Code) - assert.Equal(t, `{"errors":[{"message":"malformed JSON body"}]}`+"\n", recorder.Body.String()) -} - func TestRouterServeHTTP_Encoding(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() tests := []map[string]string{ { - "body": "some=thing", - "expected": "c29tZT10aGluZw==", + "body": "some=thing", + "expected": "c29tZT10aGluZw==", "content-type": "", }, { - "body": "some=thing", - "expected": "some=thing", + "body": "some=thing", + "expected": "some=thing", "content-type": "application/x-www-form-urlencoded", }, { - "body": "--X-INSOMNIA-BOUNDARY\r\nContent-Disposition: form-data; name=\"some\"\r\n\r\nthing\r\n--X-INSOMNIA-BOUNDARY--\r\n", - "expected": "--X-INSOMNIA-BOUNDARY\r\nContent-Disposition: form-data; name=\"some\"\r\n\r\nthing\r\n--X-INSOMNIA-BOUNDARY--\r\n", + "body": "--X-INSOMNIA-BOUNDARY\r\nContent-Disposition: form-data; name=\"some\"\r\n\r\nthing\r\n--X-INSOMNIA-BOUNDARY--\r\n", + "expected": "--X-INSOMNIA-BOUNDARY\r\nContent-Disposition: form-data; name=\"some\"\r\n\r\nthing\r\n--X-INSOMNIA-BOUNDARY--\r\n", "content-type": "multipart/form-data; boundary=X-INSOMNIA-BOUNDARY", }, }