events/validators.go (214 lines of code) (raw):
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package events
import (
"encoding/json"
"fmt"
"reflect"
"time"
cloudevents "github.com/cloudevents/sdk-go/v2"
"github.com/google/go-cmp/cmp"
)
// ValidationInfo contains information about a particular validation step, including a reason why
// the validation for this event and type was skipped or the relevant error.
type ValidationInfo struct {
Name string
Errs []error
SkippedReason string
}
// PrintValidationInfos takes a list of ValidationInfos and collapses them into a single error and
// a single log line recording which events were validation, which skipped, and why.
func PrintValidationInfos(vis []*ValidationInfo) (string, error) {
errStr := "Validation errors:"
logStr := "Events tried:"
errsOccurred := false
for _, vi := range vis {
// Collect errors into one string.
if vi.Errs != nil {
errsOccurred = true
viErrStr := fmt.Sprintf("%s:", vi.Name)
for _, err := range vi.Errs {
viErrStr = fmt.Sprintf("%s\n\t\t- %v", viErrStr, err)
}
errStr = fmt.Sprintf("%s\n\t- %s", errStr, viErrStr)
logStr = fmt.Sprintf("%s\n\t- %s (FAILED)", logStr, vi.Name)
continue
}
// Collect events run and skipped into one string.
if vi.SkippedReason != "" {
logStr = fmt.Sprintf("%s\n\t- %s (SKIPPED: %s)", logStr, vi.Name, vi.SkippedReason)
} else {
logStr = fmt.Sprintf("%s\n\t- %s (PASSED)", logStr, vi.Name)
}
}
if errsOccurred {
return logStr, fmt.Errorf(errStr)
}
return logStr, nil
}
// ValidateEvent validates that a particular function output matches the expected contents.
func ValidateEvent(name string, it EventType, ot EventType, got []byte) *ValidationInfo {
want := OutputData(name, ot, it != ot)
// If validating CloudEvent to CloudEvent (no event conversions),
// the output data should be exactly the same as the input data.
if it == CloudEvent && ot == CloudEvent {
want = InputData(name, it)
}
if want == nil {
// Include the possibilities in the error.
return &ValidationInfo{
Name: name,
SkippedReason: fmt.Sprintf("no expected output value of type %s", ot),
}
}
switch ot {
case LegacyEvent:
return validateLegacyEvent(name, got, want)
case CloudEvent:
return validateCloudEvent(name, got, want)
}
// Should be unreachable.
return nil
}
func validateLegacyEvent(name string, gotBytes, wantBytes []byte) *ValidationInfo {
vi := &ValidationInfo{
Name: name,
}
got := make(map[string]interface{})
err := json.Unmarshal(gotBytes, &got)
if err != nil {
vi.Errs = append(vi.Errs, fmt.Errorf("unmarshalling received legacy event %q: %v", name, err))
}
want := make(map[string]interface{})
err = json.Unmarshal(wantBytes, &want)
if err != nil {
vi.Errs = append(vi.Errs, fmt.Errorf("unmarshalling expected legacy event %q: %v", name, err))
}
// If there were issues extracting the data, bail early.
if vi.Errs != nil {
return vi
}
gotContext := got["context"].(map[string]interface{})
wantContext := want["context"].(map[string]interface{})
// For some fields in the context, they can be written in more than one way. Check all.
type eventFields struct {
name string
gotValue interface{}
wantValue interface{}
}
gotTimestamp, err := time.Parse(time.RFC3339, gotContext["timestamp"].(string))
if err != nil {
vi.Errs = append(vi.Errs, fmt.Errorf("parsing timestamp of received legacy event: %v", err))
return vi
}
wantTimestamp, err := time.Parse(time.RFC3339, wantContext["timestamp"].(string))
if err != nil {
vi.Errs = append(vi.Errs, fmt.Errorf("parsing timestamp of expected legacy event: %v", err))
return vi
}
fields := []eventFields{
{
name: "ID",
gotValue: getMaybeSnakeCaseField(gotContext, "eventId"),
wantValue: wantContext["eventId"],
},
{
name: "type",
gotValue: getMaybeSnakeCaseField(gotContext, "eventType"),
wantValue: wantContext["eventType"],
},
{
name: "timestamp",
gotValue: gotTimestamp,
wantValue: wantTimestamp,
},
{
name: "resource",
gotValue: gotContext["resource"],
wantValue: wantContext["resource"],
},
{
name: "data",
gotValue: got["data"],
wantValue: want["data"],
},
}
for _, field := range fields {
if !reflect.DeepEqual(field.gotValue, field.wantValue) {
vi.Errs = append(vi.Errs, fmt.Errorf("unexpected %q in event %q:\ngot %+v,\nwant %+v", field.name, name, field.gotValue, field.wantValue))
}
}
return vi
}
// Some fields can present with either a CamelCase or a snake_case key. Both are acceptable.
func getMaybeSnakeCaseField(gotContext map[string]interface{}, field string) interface{} {
if gotVal, ok := gotContext[field]; ok {
return gotVal
}
var lowerField string
if field == "eventId" {
lowerField = "event_id"
}
if field == "eventType" {
lowerField = "event_type"
}
if gotVal, ok := gotContext[lowerField]; lowerField != "" && ok {
return gotVal
}
return nil
}
func validateCloudEvent(name string, gotBytes, wantBytes []byte) *ValidationInfo {
vi := &ValidationInfo{
Name: name,
}
got := &cloudevents.Event{}
err := json.Unmarshal(gotBytes, got)
if err != nil {
vi.Errs = append(vi.Errs, fmt.Errorf("unmarshalling function-received version of cloud event %q: %v", name, err))
}
want := &cloudevents.Event{}
err = json.Unmarshal(wantBytes, want)
if err != nil {
vi.Errs = append(vi.Errs, fmt.Errorf("unmarshalling expected contents of cloud event %q: %v", name, err))
}
// If there were issues extracting the data, bail early.
if vi.Errs != nil {
return vi
}
fields := []struct {
name string
gotValue interface{}
wantValue interface{}
}{
{
name: "ID",
gotValue: got.ID(),
wantValue: want.ID(),
},
{
name: "source",
gotValue: got.Source(),
wantValue: want.Source(),
},
{
name: "subject",
gotValue: got.Subject(),
wantValue: want.Subject(),
},
{
name: "type",
gotValue: got.Type(),
wantValue: want.Type(),
},
{
name: "time",
gotValue: got.Time(),
wantValue: want.Time(),
},
{
name: "datacontenttype",
gotValue: got.DataContentType(),
wantValue: want.DataContentType(),
},
{
name: "data",
gotValue: unmarshalMap(got.Data(), vi),
wantValue: unmarshalMap(want.Data(), vi),
},
}
for _, field := range fields {
if !cmp.Equal(field.gotValue, field.wantValue) {
vi.Errs = append(vi.Errs, fmt.Errorf("unexpected %q field in %q: got %v, want %v", field.name, name, field.gotValue, field.wantValue))
}
}
return vi
}
func unmarshalMap(data []byte, vi *ValidationInfo) (dataMap map[string]interface{}) {
if err := json.Unmarshal(data, &dataMap); err != nil {
vi.Errs = append(vi.Errs, fmt.Errorf("could not parse CloudEvent data as map: %v", err))
}
return
}