diff --git a/analytics/aggregate_test.go b/analytics/aggregate_test.go index 77ec7624e..24240b2b6 100644 --- a/analytics/aggregate_test.go +++ b/analytics/aggregate_test.go @@ -1,7 +1,6 @@ package analytics import ( - "encoding/base64" "fmt" "testing" "time" @@ -90,8 +89,6 @@ func TestTrimTag(t *testing.T) { } func TestAggregateGraphData(t *testing.T) { - query := `{"query":"query{\n characters(filter: {\n \n }){\n info{\n count\n }\n }\n}"}` - rawResponse := `{"data":{"characters":{"info":{"count":758}}}}` sampleRecord := AnalyticsRecord{ TimeStamp: time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), Method: "POST", @@ -100,8 +97,6 @@ func TestAggregateGraphData(t *testing.T) { RawPath: "/", APIName: "test-api", APIID: "test-api", - ApiSchema: base64.StdEncoding.EncodeToString([]byte(sampleSchema)), - Tags: []string{PredefinedTagGraphAnalytics}, ResponseCode: 200, Day: 1, Month: 1, @@ -111,8 +106,16 @@ func TestAggregateGraphData(t *testing.T) { APIKey: "test-key", TrackPath: true, OauthID: "test-id", - RawRequest: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(requestTemplate, len(query), query))), - RawResponse: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(responseTemplate, len(rawResponse), rawResponse))), + GraphQLStats: GraphQLStats{ + IsGraphQL: true, + Types: map[string][]string{ + "Characters": {"info"}, + "Info": {"count"}, + }, + RootFields: []string{"characters"}, + OperationType: OperationQuery, + HasErrors: false, + }, } compareFields := func(r *require.Assertions, expected, actual map[string]*Counter) { @@ -164,7 +167,7 @@ func TestAggregateGraphData(t *testing.T) { for i := range records { record := sampleRecord if i == 1 { - record.Tags = []string{} + record.GraphQLStats.IsGraphQL = false } records[i] = record } @@ -193,8 +196,12 @@ func TestAggregateGraphData(t *testing.T) { for i := range records { record := sampleRecord if i == 1 { - response := graphErrorResponse - record.RawResponse = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(responseTemplate, len(response), response))) + record.GraphQLStats.HasErrors = true + record.GraphQLStats.Errors = []GraphError{ + { + Message: "Name for character with ID 1002 could not be fetched.", + }, + } } records[i] = record } @@ -274,24 +281,27 @@ func TestAggregateGraphData_Dimension(t *testing.T) { RawPath: "/", APIName: "test-api", APIID: "test-api", - ApiSchema: base64.StdEncoding.EncodeToString([]byte(sampleSchema)), - Tags: []string{PredefinedTagGraphAnalytics}, ResponseCode: 200, Day: 1, Month: 1, Year: 2022, Hour: 0, OrgID: "test-org", + GraphQLStats: GraphQLStats{ + IsGraphQL: true, + Types: map[string][]string{ + "Characters": {"info"}, + "Info": {"count"}, + }, + RootFields: []string{"characters"}, + OperationType: OperationQuery, + HasErrors: false, + }, } records := make([]interface{}, 3) for i := range records { - record := sampleRecord - query := `{"query":"query{\n characters(filter: {\n \n }){\n info{\n count\n }\n }\n}"}` - response := `{"data":{"characters":{"info":{"count":758}}}}` - record.RawRequest = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(requestTemplate, len(query), query))) - record.RawResponse = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(responseTemplate, len(response), response))) - records[i] = record + records[i] = sampleRecord } responsesCheck := map[string][]string{ @@ -374,14 +384,18 @@ func TestAggregateData_SkipGraphRecords(t *testing.T) { }, { OrgID: "777-graph", - Tags: []string{"tag_1", "tag_2", PredefinedTagGraphAnalytics}, + GraphQLStats: GraphQLStats{ + IsGraphQL: true, + }, }, { OrgID: "987", }, { OrgID: "555-graph", - Tags: []string{PredefinedTagGraphAnalytics}, + GraphQLStats: GraphQLStats{ + IsGraphQL: true, + }, }, }, 2, diff --git a/analytics/analytics.go b/analytics/analytics.go index ce8b1c257..79c1036e1 100644 --- a/analytics/analytics.go +++ b/analytics/analytics.go @@ -74,7 +74,7 @@ type AnalyticsRecord struct { ExpireAt time.Time `bson:"expireAt" json:"expireAt"` ApiSchema string `json:"api_schema" bson:"-" gorm:"-:all"` //nolint - GraphQLStats GraphQLStats `json:"-" bson:"-" gorm:"-:all"` + GraphQLStats GraphQLStats `json:"graphql_stats" bson:"-" gorm:"-:all"` CollectionName string `json:"-" bson:"-" gorm:"-:all"` } @@ -374,17 +374,7 @@ func GeoIPLookup(ipStr string, GeoIPDB *maxminddb.Reader) (*GeoData, error) { } func (a *AnalyticsRecord) IsGraphRecord() bool { - if len(a.Tags) == 0 { - return false - } - - for _, tag := range a.Tags { - if tag == PredefinedTagGraphAnalytics { - return true - } - } - - return false + return a.GraphQLStats.IsGraphQL } func (a *AnalyticsRecord) RemoveIgnoredFields(ignoreFields []string) { diff --git a/analytics/analytics_test.go b/analytics/analytics_test.go index 49b40da56..3b334cdcc 100644 --- a/analytics/analytics_test.go +++ b/analytics/analytics_test.go @@ -23,9 +23,11 @@ func TestAnalyticsRecord_IsGraphRecord(t *testing.T) { assert.False(t, record.IsGraphRecord()) }) - t.Run("should return true when tags contain the graph analytics tag", func(t *testing.T) { + t.Run("should return true with graph stats", func(t *testing.T) { record := AnalyticsRecord{ - Tags: []string{"tag_1", "tag_2", PredefinedTagGraphAnalytics, "tag_4", "tag_5"}, + GraphQLStats: GraphQLStats{ + IsGraphQL: true, + }, } assert.True(t, record.IsGraphRecord()) }) diff --git a/analytics/graph_record.go b/analytics/graph_record.go index 160353713..40a1b14f9 100644 --- a/analytics/graph_record.go +++ b/analytics/graph_record.go @@ -1,22 +1,6 @@ package analytics import ( - "bufio" - "bytes" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "net/http" - - "github.com/buger/jsonparser" - - "github.com/TykTechnologies/graphql-go-tools/pkg/ast" - "github.com/TykTechnologies/graphql-go-tools/pkg/astnormalization" - "github.com/TykTechnologies/graphql-go-tools/pkg/astparser" - gql "github.com/TykTechnologies/graphql-go-tools/pkg/graphql" - "github.com/TykTechnologies/graphql-go-tools/pkg/operationreport" "github.com/TykTechnologies/storage/persistent/model" ) @@ -58,334 +42,31 @@ func (*GraphRecord) SetObjectID(model.ObjectID) { // empty } -// parseRequest reads the raw encoded request and schema, extracting the type information -// operation information and root field operations -// if an error is encountered it simply breaks the operation regardless of how far along it is. -func (g *GraphRecord) parseRequest(encodedRequest, encodedSchema string) { - if encodedRequest == "" || encodedSchema == "" { - log.Warn("empty request/schema") - return - } - rawRequest, err := base64.StdEncoding.DecodeString(encodedRequest) - if err != nil { - log.WithError(err).Error("error decoding raw request") - return - } - - schemaBody, err := base64.StdEncoding.DecodeString(encodedSchema) - if err != nil { - log.WithError(err).Error("error decoding schema") - return - } - - request, schema, operationName, err := generateNormalizedDocuments(rawRequest, schemaBody) - if err != nil { - log.WithError(err).Error("error generating document") - return - } - - if len(request.Input.Variables) != 0 && string(request.Input.Variables) != "null" { - g.Variables = base64.StdEncoding.EncodeToString(request.Input.Variables) - } - - // get the operation ref - operationRef := 0 - if operationName != "" { - for i := range request.OperationDefinitions { - if request.OperationDefinitionNameString(i) == operationName { - operationRef = i - break - } - } - } else if len(request.OperationDefinitions) > 1 { - log.Warn("no operation name specified") - return - } - - // get operation type - switch request.OperationDefinitions[operationRef].OperationType { - case ast.OperationTypeMutation: - g.OperationType = string(ast.DefaultMutationTypeName) - case ast.OperationTypeSubscription: - g.OperationType = string(ast.DefaultSubscriptionTypeName) - case ast.OperationTypeQuery: - g.OperationType = string(ast.DefaultQueryTypeName) - } - - // get the selection set types to start with - fieldTypeList, err := extractOperationSelectionSetTypes(operationRef, &g.RootFields, request, schema) - if err != nil { - log.WithError(err).Error("error extracting selection set types") - return - } - typesToFieldsMap := make(map[string][]string) - for fieldRef, typeDefRef := range fieldTypeList { - if typeDefRef == ast.InvalidRef { - err = errors.New("invalid selection set field type") - log.Warn("invalid type found") - continue - } - extractTypesAndFields(fieldRef, typeDefRef, typesToFieldsMap, request, schema) - } - g.Types = typesToFieldsMap -} - -// parseResponse looks through the encoded response string and parses information like -// the errors -func (g *GraphRecord) parseResponse(encodedResponse string) { - if encodedResponse == "" { - log.Warn("empty response body") - return - } - - responseDecoded, err := base64.StdEncoding.DecodeString(encodedResponse) - if err != nil { - log.WithError(err).Error("error decoding response") - return - } - resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(responseDecoded)), nil) - if err != nil { - log.WithError(err).Error("error reading raw response") - return - } - defer resp.Body.Close() - - dat, err := ioutil.ReadAll(resp.Body) - if err != nil { - log.WithError(err).Error("error reading response body") - return - } - errBytes, t, _, err := jsonparser.Get(dat, "errors") - // check if the errors key exists in the response - if err != nil && err != jsonparser.KeyPathNotFoundError { - // we got an unexpected error parsing te response - log.WithError(err).Error("error getting response errors") - return - } - if t != jsonparser.NotExist { - // errors key exists so unmarshal it - if err := json.Unmarshal(errBytes, &g.Errors); err != nil { - log.WithError(err).Error("error parsing graph errors") - return - } - g.HasErrors = true - } -} - func (a *AnalyticsRecord) ToGraphRecord() GraphRecord { + if !a.IsGraphRecord() { + return GraphRecord{} + } + opType := "" + switch a.GraphQLStats.OperationType { + case OperationQuery: + opType = "Query" + case OperationMutation: + opType = "Mutation" + case OperationSubscription: + opType = "Subscription" + default: + } record := GraphRecord{ AnalyticsRecord: *a, - RootFields: make([]string, 0), - Types: make(map[string][]string), - Errors: make([]GraphError, 0), + RootFields: a.GraphQLStats.RootFields, + Types: a.GraphQLStats.Types, + Errors: a.GraphQLStats.Errors, + HasErrors: a.GraphQLStats.HasErrors, + Variables: a.GraphQLStats.Variables, + OperationType: opType, } if a.ResponseCode >= 400 { record.HasErrors = true } - - record.parseRequest(a.RawRequest, a.ApiSchema) - - record.parseResponse(a.RawResponse) - return record } - -// extractOperationSelectionSetTypes extracts all type names of the selection sets in the operation -// it returns a map of the FieldRef in the req to the type Definition in the schema -func extractOperationSelectionSetTypes(operationRef int, rootFields *[]string, req, schema *ast.Document) (map[int]int, error) { - fieldTypeMap := make(map[int]int) - operationDef := req.OperationDefinitions[operationRef] - if !operationDef.HasSelections { - return nil, errors.New("operation has no selection set") - } - - for _, selRef := range req.SelectionSets[operationDef.SelectionSet].SelectionRefs { - sel := req.Selections[selRef] - if sel.Kind != ast.SelectionKindField { - continue - } - // get selection field def - selFieldDefRef, err := getOperationSelectionFieldDefinition(operationDef.OperationType, req.FieldNameString(sel.Ref), schema) - if selFieldDefRef == ast.InvalidRef || err != nil { - if err != nil { - log.WithError(err).Error("error getting operation field definition") - } - return nil, errors.New("error getting selection set") - } - - *rootFields = append(*rootFields, req.FieldNameString(sel.Ref)) - - typeRef := schema.ResolveUnderlyingType(schema.FieldDefinitions[selFieldDefRef].Type) - if schema.TypeIsScalar(typeRef, schema) || schema.TypeIsEnum(typeRef, schema) { - continue - } - fieldTypeMap[sel.Ref] = getObjectTypeRefWithName(schema.TypeNameString(typeRef), schema) - } - return fieldTypeMap, nil -} - -// extractTypesAndFields extracts all types and type fields used in this request -func extractTypesAndFields(fieldRef, typeDef int, resp map[string][]string, req, schema *ast.Document) { - field := req.Fields[fieldRef] - fieldListForType := make([]string, 0) - - if !field.HasSelections { - return - } - for _, selRef := range req.SelectionSets[field.SelectionSet].SelectionRefs { - sel := req.Selections[selRef] - if sel.Kind != ast.SelectionKindField { - continue - } - fieldListForType = append(fieldListForType, req.FieldNameString(sel.Ref)) - - // get the field definition and run this function on it - fieldDefRef := getObjectFieldRefWithName(req.FieldNameString(sel.Ref), typeDef, schema) - if fieldDefRef == ast.InvalidRef { - continue - } - - fieldDefType := schema.ResolveUnderlyingType(schema.FieldDefinitions[fieldDefRef].Type) - if schema.TypeIsScalar(fieldDefType, schema) || schema.TypeIsEnum(fieldDefType, schema) { - continue - } - - objTypeRef := getObjectTypeRefWithName(schema.TypeNameString(fieldDefType), schema) - if objTypeRef == ast.InvalidRef { - continue - } - - extractTypesAndFields(sel.Ref, objTypeRef, resp, req, schema) - } - - objectTypeName := schema.ObjectTypeDefinitionNameString(typeDef) - _, ok := resp[objectTypeName] - if ok { - resp[objectTypeName] = append(resp[objectTypeName], fieldListForType...) - } else { - resp[objectTypeName] = fieldListForType - } - - resp[objectTypeName] = fieldListForType -} - -// getObjectFieldRefWithName gets the object field reference from the object type using the name from the schame -func getObjectFieldRefWithName(name string, objTypeRef int, schema *ast.Document) int { - objectTypeDefinition := schema.ObjectTypeDefinitions[objTypeRef] - if !objectTypeDefinition.HasFieldDefinitions { - return ast.InvalidRef - } - for _, r := range objectTypeDefinition.FieldsDefinition.Refs { - if schema.FieldDefinitionNameString(r) == name { - return r - } - } - return ast.InvalidRef -} - -// getObjectTypeRefWithName gets the ref of the type from the schema using the name -func getObjectTypeRefWithName(name string, schema *ast.Document) int { - n, ok := schema.Index.FirstNodeByNameStr(name) - if !ok { - return ast.InvalidRef - } - if n.Kind != ast.NodeKindObjectTypeDefinition { - return ast.InvalidRef - } - return n.Ref -} - -// generateNormalizedDocuments generates and normalizes the ast documents from the raw request and the raw schema -func generateNormalizedDocuments(requestRaw, schemaRaw []byte) (r, s *ast.Document, operationName string, err error) { - httpRequest, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(requestRaw))) - if err != nil { - log.WithError(err).Error("error parsing request") - return - } - var gqlRequest gql.Request - err = gql.UnmarshalRequest(httpRequest.Body, &gqlRequest) - if err != nil { - log.WithError(err).Error("error unmarshalling request") - return - } - operationName = gqlRequest.OperationName - - schema, err := gql.NewSchemaFromString(string(schemaRaw)) - if err != nil { - return - } - schemaDoc, operationReport := astparser.ParseGraphqlDocumentBytes(schema.Document()) - if operationReport.HasErrors() { - err = operationReport - return - } - s = &schemaDoc - - requestDoc, operationReport := astparser.ParseGraphqlDocumentString(gqlRequest.Query) - if operationReport.HasErrors() { - err = operationReport - log.WithError(err).Error("error parsing request document") - return - } - r = &requestDoc - r.Input.Variables = gqlRequest.Variables - normalizer := astnormalization.NewWithOpts( - astnormalization.WithRemoveFragmentDefinitions(), - ) - - var report operationreport.Report - if operationName != "" { - normalizer.NormalizeNamedOperation(r, s, []byte(operationName), &report) - } else { - normalizer.NormalizeOperation(r, s, &report) - } - if report.HasErrors() { - log.WithError(report).Error("error normalizing") - err = report - return - } - return -} - -// getOperationSelectionFieldDefinition gets the schema's field definition ref for the selection set of the operation type in question -func getOperationSelectionFieldDefinition(operationType ast.OperationType, opSelectionName string, schema *ast.Document) (int, error) { - var ( - node ast.Node - found bool - ) - switch operationType { - case ast.OperationTypeQuery: - node, found = schema.Index.FirstNodeByNameBytes(schema.Index.QueryTypeName) - if !found { - return ast.InvalidRef, fmt.Errorf("missing query type declaration") - } - case ast.OperationTypeMutation: - node, found = schema.Index.FirstNodeByNameBytes(schema.Index.MutationTypeName) - if !found { - return ast.InvalidRef, fmt.Errorf("missing mutation type declaration") - } - case ast.OperationTypeSubscription: - node, found = schema.Index.FirstNodeByNameBytes(schema.Index.SubscriptionTypeName) - if !found { - return ast.InvalidRef, fmt.Errorf("missing subscription type declaration") - } - default: - return ast.InvalidRef, fmt.Errorf("unknown operation") - } - if node.Kind != ast.NodeKindObjectTypeDefinition { - return ast.InvalidRef, fmt.Errorf("invalid node type") - } - - operationObjDefinition := schema.ObjectTypeDefinitions[node.Ref] - if !operationObjDefinition.HasFieldDefinitions { - return ast.InvalidRef, nil - } - - for _, fieldRef := range operationObjDefinition.FieldsDefinition.Refs { - if opSelectionName == schema.FieldDefinitionNameString(fieldRef) { - return fieldRef, nil - } - } - - return ast.InvalidRef, fmt.Errorf("field not found") -} diff --git a/analytics/graph_record_test.go b/analytics/graph_record_test.go index 8b851226e..c882bb09d 100644 --- a/analytics/graph_record_test.go +++ b/analytics/graph_record_test.go @@ -2,16 +2,11 @@ package analytics import ( "encoding/base64" - "fmt" "testing" "time" - "github.com/TykTechnologies/graphql-go-tools/pkg/ast" - "github.com/TykTechnologies/graphql-go-tools/pkg/astparser" - gql "github.com/TykTechnologies/graphql-go-tools/pkg/graphql" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - "github.com/stretchr/testify/assert" ) const ( @@ -19,55 +14,6 @@ const ( responseTemplate = "HTTP/0.0 200 OK\r\nContent-Length: %d\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n%s" ) -const subgraphSchema = ` -directive @extends on OBJECT - -directive @external on FIELD_DEFINITION - -directive @key(fields: _FieldSet!) on OBJECT | INTERFACE - -directive @provides(fields: _FieldSet!) on FIELD_DEFINITION - -directive @requires(fields: _FieldSet!) on FIELD_DEFINITION - -type Entity { - findProductByUpc(upc: String!): Product! - findUserByID(id: ID!): User! -} - -type Product { - upc: String! - reviews: [Review] -} - -type Query { - _entities(representations: [_Any!]!): [_Entity]! - _service: _Service! -} - -type Review { - body: String! - author: User! - product: Product! -} - -type User { - id: ID! - username: String! - reviews: [Review] -} - -scalar _Any - -union _Entity = Product | User - -scalar _FieldSet - -type _Service { - sdl: String -} -` - const sampleSchema = ` type Query { characters(filter: FilterCharacter, page: Int): Characters @@ -108,19 +54,8 @@ type Character { type EmptyType{ }` -func getSampleSchema() (*ast.Document, error) { - schema, err := gql.NewSchemaFromString(string(sampleSchema)) - if err != nil { - return nil, err - } - schemaDoc, operationReport := astparser.ParseGraphqlDocumentBytes(schema.Document()) - if operationReport.HasErrors() { - return nil, operationReport - } - return &schemaDoc, nil -} - -func TestAnalyticsRecord_ToGraphRecord(t *testing.T) { +// TODO fix test coverage +func TestAnalyticsRecord_ToGraphRecordNew(t *testing.T) { recordSample := AnalyticsRecord{ TimeStamp: time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), Method: "POST", @@ -136,470 +71,111 @@ func TestAnalyticsRecord_ToGraphRecord(t *testing.T) { Year: 2022, Hour: 0, } - graphRecordSample := GraphRecord{ - AnalyticsRecord: recordSample, - Types: make(map[string][]string), - RootFields: make([]string, 0), - Errors: make([]GraphError, 0), - } testCases := []struct { - expected func() GraphRecord - modifyRecord func(a AnalyticsRecord) AnalyticsRecord - title string - request string - response string + name string + graphStats GraphQLStats + expected GraphRecord }{ { - title: "no error", - request: `{"query":"query{\n characters(filter: {\n \n }){\n info{\n count\n }\n }\n}"}`, - response: `{"data":{"characters":{"info":{"count":758}}}}`, - expected: func() GraphRecord { - g := graphRecordSample - g.HasErrors = false - g.Types = map[string][]string{ + name: "should convert to graph record", + graphStats: GraphQLStats{ + IsGraphQL: true, + HasErrors: false, + Types: map[string][]string{ "Characters": {"info"}, - "Info": {"count"}, - } - g.OperationType = "Query" - g.RootFields = []string{"characters"} - return g + "Info": {"firstField", "secondField"}, + }, + RootFields: []string{"characters"}, + Variables: `{"id":"hello"}`, }, - }, - { - title: "multiple query operations", - request: `{"query":"query {\r\n characters(filter: {}) {\r\n info {\r\n count\r\n }\r\n }\r\n listCharacters {\r\n info {\r\n count\r\n }\r\n }\r\n}\r\n"}`, - response: `{"data":{"characters":{"info":{"count":758}}}}`, - expected: func() GraphRecord { - g := graphRecordSample - g.HasErrors = false - g.Types = map[string][]string{ + expected: GraphRecord{ + Types: map[string][]string{ "Characters": {"info"}, - "Info": {"count"}, - } - g.OperationType = "Query" - g.RootFields = []string{"characters", "listCharacters"} - return g - }, - }, - { - title: "subgraph request", - request: `{"query":"query($representations: [_Any!]!){_entities(representations: $representations){... on User {reviews {body}}}}","variables":{"representations":[{"id":"1234","__typename":"User"}]}}`, - response: `{"data":{"_entities":[{"reviews":[{"body":"A highly effective form of birth control."},{"body":"Fedoras are one of the most fashionable hats around and can look great with a variety of outfits."}]}]}}`, - expected: func() GraphRecord { - variables := `{"representations":[{"id":"1234","__typename":"User"}]}` - g := graphRecordSample - g.OperationType = "Query" - g.Variables = base64.StdEncoding.EncodeToString([]byte(variables)) - g.RootFields = []string{"_entities"} - return g - }, - modifyRecord: func(a AnalyticsRecord) AnalyticsRecord { - a.ApiSchema = base64.StdEncoding.EncodeToString([]byte(subgraphSchema)) - return a - }, - }, - { - title: "no error mutation", - request: `{"query":"mutation{\n changeCharacter()\n}"}`, - response: `{"data":{"characters":{"info":{"count":758}}}}`, - expected: func() GraphRecord { - g := graphRecordSample - g.HasErrors = false - g.OperationType = "Mutation" - g.RootFields = []string{"changeCharacter"} - return g + "Info": {"firstField", "secondField"}, + }, + RootFields: []string{"characters"}, + Variables: `{"id":"hello"}`, + HasErrors: false, }, }, { - title: "no error subscription", - request: `{"query":"subscription{\n listenCharacter(){\n info{\n count\n }\n }\n}"}`, - response: `{"data":{"characters":{"info":{"count":758}}}}`, - expected: func() GraphRecord { - g := graphRecordSample - g.HasErrors = false - g.Types = map[string][]string{ + name: "isn't graphql record", + graphStats: GraphQLStats{ + IsGraphQL: false, + HasErrors: false, + Types: map[string][]string{ "Characters": {"info"}, - "Info": {"count"}, - } - g.OperationType = "Subscription" - g.RootFields = []string{"listenCharacter"} - return g + "Info": {"firstField", "secondField"}, + }, + RootFields: []string{"characters"}, + Variables: `{"id":"hello"}`, }, }, { - title: "bad document", - request: `{"query":"subscriptiona{\n listenCharacter(){\n info{\n count\n }\n }\n}"}`, - response: `{"errors":[{"message":"invalid document error"}]}`, - expected: func() GraphRecord { - doc := graphRecordSample - doc.HasErrors = true - doc.Errors = []GraphError{ - { - Message: "invalid document error", - }, - } - return doc - }, - }, - { - title: "no error list operation", - request: `{"query":"query{\n listCharacters(){\n info{\n count\n }\n }\n}"}`, - response: `{"data":{"characters":{"info":{"count":758}}}}`, - expected: func() GraphRecord { - g := graphRecordSample - g.HasErrors = false - g.Types = map[string][]string{ + name: "has error", + graphStats: GraphQLStats{ + IsGraphQL: true, + HasErrors: true, + Types: map[string][]string{ "Characters": {"info"}, - "Info": {"count"}, - } - g.OperationType = "Query" - g.RootFields = []string{"listCharacters"} - return g + "Info": {"firstField", "secondField"}, + }, + RootFields: []string{"characters"}, + Variables: `{"id":"hello"}`, }, - }, - { - title: "has variables", - request: `{"query":"query{\n characters(filter: {\n \n }){\n info{\n count\n }\n }\n}","variables":{"a":"test"}}`, - response: `{"data":{"characters":{"info":{"count":758}}}}`, - expected: func() GraphRecord { - g := graphRecordSample - g.HasErrors = false - g.Types = map[string][]string{ + expected: GraphRecord{ + Types: map[string][]string{ "Characters": {"info"}, - "Info": {"count"}, - } - g.OperationType = "Query" - g.RootFields = []string{"characters"} - g.Variables = base64.StdEncoding.EncodeToString([]byte(`{"a":"test"}`)) - return g + "Info": {"firstField", "secondField"}, + }, + RootFields: []string{"characters"}, + Variables: `{"id":"hello"}`, + HasErrors: true, }, }, { - title: "no operation", - request: `{"query":"query main {\ncharacters {\ninfo\n}\n}\n\nquery second {\nlistCharacters{\ninfo\n}\n}","variables":null,"operationName":""}`, - response: `{"errors":[{"message":"no operation specified"}]}`, - expected: func() GraphRecord { - doc := graphRecordSample - doc.HasErrors = true - doc.Errors = []GraphError{ + name: "has error with error", + graphStats: GraphQLStats{ + IsGraphQL: true, + HasErrors: true, + Errors: []GraphError{ { - Message: "no operation specified", + Message: "sample error", }, - } - return doc - }, - }, - { - title: "operation name specified", - request: `{"query":"query main {\ncharacters {\ninfo\n}\n}\n\nquery second {\nlistCharacters{\ninfo\n secondInfo}\n}","variables":null,"operationName":"second"}`, - response: `{"data":{"characters":{"info":{"count":758}}}}`, - expected: func() GraphRecord { - g := graphRecordSample - g.HasErrors = false - g.Types = map[string][]string{ - "Characters": {"info", "secondInfo"}, - } - g.OperationType = "Query" - g.RootFields = []string{"listCharacters"} - return g - }, - }, - { - title: "has errors", - request: `{"query":"query{\n characters(filter: {\n \n }){\n info{\n count\n }\n }\n}"}`, - response: `{ - "errors": [ - { - "message": "Name for character with ID 1002 could not be fetched.", - "locations": [{ "line": 6, "column": 7 }], - "path": ["hero", "heroFriends", 1, "name"] - } - ] -}`, - expected: func() GraphRecord { - g := graphRecordSample - g.HasErrors = true - g.Types = map[string][]string{ + }, + Types: map[string][]string{ "Characters": {"info"}, - "Info": {"count"}, - } - g.OperationType = "Query" - g.Errors = append(g.Errors, GraphError{ - Message: "Name for character with ID 1002 could not be fetched.", - Path: []interface{}{"hero", "heroFriends", float64(1), "name"}, - }) - g.RootFields = []string{"characters"} - return g + "Info": {"firstField", "secondField"}, + }, + RootFields: []string{"characters"}, + Variables: `{"id":"hello"}`, }, - }, - { - title: "corrupted raw request ", - modifyRecord: func(a AnalyticsRecord) AnalyticsRecord { - a.RawRequest = "this isn't a base64 is it?" - return a - }, - expected: func() GraphRecord { - return graphRecordSample - }, - }, - { - title: "corrupted raw response ", - request: `{"query":"query{\n characters(filter: {\n \n }){\n info{\n count\n }\n }\n}"}`, - modifyRecord: func(a AnalyticsRecord) AnalyticsRecord { - a.RawResponse = "this isn't a base64 is it?" - return a - }, - expected: func() GraphRecord { - g := graphRecordSample - g.Types = map[string][]string{ - "Characters": {"info"}, - "Info": {"count"}, - } - g.OperationType = "Query" - g.RootFields = []string{"characters"} - return g - }, - }, - { - title: "invalid response json ", - request: `{"query":"query{\n characters(filter: {\n \n }){\n info{\n count\n }\n }\n}"}`, - response: "invalid json", - expected: func() GraphRecord { - g := graphRecordSample - g.Types = map[string][]string{ + expected: GraphRecord{ + Types: map[string][]string{ "Characters": {"info"}, - "Info": {"count"}, - } - g.OperationType = "Query" - g.RootFields = []string{"characters"} - return g - }, - }, - { - title: "corrupted schema should error out", - request: `{"query":"query main {\ncharacters {\ninfo\n}\n}\n\nquery second {\nlistCharacters{\ninfo\n}\n}","variables":null,"operationName":""}`, - response: `{"errors":[{"message":"no operation specified"}]}`, - modifyRecord: func(a AnalyticsRecord) AnalyticsRecord { - a.ApiSchema = "this isn't a base64 is it?" - return a - }, - expected: func() GraphRecord { - rec := graphRecordSample - rec.Errors = []GraphError{{Message: "no operation specified"}} - rec.HasErrors = true - return rec - }, - }, - { - title: "error in request", - request: `{"query":"query{\n characters(filter: {\n \n }){\n info{\n counts\n }\n }\n}"}`, - response: `{"errors":[{"message":"illegal field"}]}`, - expected: func() GraphRecord { - g := graphRecordSample - g.HasErrors = true - g.Errors = append(g.Errors, GraphError{ - Message: "illegal field", - }) - return g - }, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.title, func(t *testing.T) { - a := recordSample - a.RawRequest = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf( - requestTemplate, - len(testCase.request), - testCase.request, - ))) - a.RawResponse = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf( - responseTemplate, - len(testCase.response), - testCase.response, - ))) - if testCase.modifyRecord != nil { - a = testCase.modifyRecord(a) - } - expected := testCase.expected() - expected.AnalyticsRecord = a - gotten := a.ToGraphRecord() - if diff := cmp.Diff(expected, gotten, cmpopts.IgnoreFields(AnalyticsRecord{}, "RawRequest", "RawResponse"), cmpopts.IgnoreUnexported(AnalyticsRecord{})); diff != "" { - t.Fatal(diff) - } - }) - } -} - -func Test_getObjectTypeRefWithName(t *testing.T) { - schema, err := getSampleSchema() - assert.NoError(t, err) - - testCases := []struct { - name string - typeName string - expectedRef int - }{ - { - name: "fail", - typeName: "invalidType", - expectedRef: -1, - }, - { - name: "successful", - typeName: "Character", - expectedRef: 5, - }, - { - name: "invalid because input", - typeName: "FilterCharacter", - expectedRef: -1, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - ref := getObjectTypeRefWithName(tc.typeName, schema) - assert.Equal(t, tc.expectedRef, ref) - }) - } -} - -func Test_getObjectFieldRefWithName(t *testing.T) { - schema, err := getSampleSchema() - assert.NoError(t, err) - - testCases := []struct { - name string - fieldName string - objectName string - expectedRef int - }{ - { - name: "successful run", - fieldName: "info", - objectName: "Characters", - expectedRef: 8, - }, - { - name: "failed run due to invalid field", - fieldName: "infos", - objectName: "Characters", - expectedRef: -1, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - objRef := getObjectTypeRefWithName(tc.objectName, schema) - assert.NotEqual(t, -1, objRef) - ref := getObjectFieldRefWithName(tc.fieldName, objRef, schema) - assert.Equal(t, tc.expectedRef, ref) - }) - } -} - -func Test_generateNormalizedDocuments(t *testing.T) { - rQuery := `{"query":"mutation{\n changeCharacter()\n}"}` - sampleQuery := []byte(fmt.Sprintf(requestTemplate, len(rQuery), rQuery)) - - t.Run("test valid request", func(t *testing.T) { - _, _, _, err := generateNormalizedDocuments(sampleQuery, []byte(sampleSchema)) - assert.NoError(t, err) - }) - t.Run("test invalid request", func(t *testing.T) { - _, _, _, err := generateNormalizedDocuments(sampleQuery[:10], []byte(sampleSchema)) - assert.ErrorContains(t, err, `malformed HTTP version "HTT"`) - }) - t.Run("invalid schema", func(t *testing.T) { - _, _, _, err := generateNormalizedDocuments(sampleQuery, []byte(`type Test{`)) - assert.Error(t, err) - }) - t.Run("invalid request for normalization", func(t *testing.T) { - query := `{"query":"mutation{\n changeCharactersss()\n}"}` - _, _, _, err := generateNormalizedDocuments([]byte(fmt.Sprintf(requestTemplate, len(query), query)), []byte(sampleSchema)) - assert.Error(t, err) - }) -} - -func Test_getOperationSelectionFieldDefinition(t *testing.T) { - schema, err := getSampleSchema() - assert.NoError(t, err) - - testCases := []struct { - modifySchema func(ast.Document) *ast.Document - name string - operationName string - expectedErr string - expectedRef int - operationType ast.OperationType - }{ - { - name: "successful query", - operationType: ast.OperationTypeQuery, - operationName: "characters", - expectedRef: 0, - expectedErr: "", - }, - { - name: "invalid query", - operationType: ast.OperationTypeQuery, - operationName: "invalidQuery", - expectedRef: -1, - expectedErr: "field not found", - }, - { - name: "invalid query type name", - operationType: ast.OperationTypeQuery, - operationName: "testOperation", - expectedRef: -1, - expectedErr: "missing query type declaration", - modifySchema: func(document ast.Document) *ast.Document { - document.Index.QueryTypeName = ast.ByteSlice("Querys") - return &document - }, - }, - { - name: "invalid mutation type name", - operationType: ast.OperationTypeMutation, - operationName: "testOperation", - expectedRef: -1, - expectedErr: "missing mutation type declaration", - modifySchema: func(document ast.Document) *ast.Document { - document.Index.MutationTypeName = ast.ByteSlice("Mutations") - return &document - }, - }, - { - name: "invalid subscription type name", - operationType: ast.OperationTypeSubscription, - operationName: "testOperation", - expectedRef: -1, - expectedErr: "missing subscription type declaration", - modifySchema: func(document ast.Document) *ast.Document { - document.Index.SubscriptionTypeName = ast.ByteSlice("Subscriptions") - return &document + "Info": {"firstField", "secondField"}, + }, + RootFields: []string{"characters"}, + Variables: `{"id":"hello"}`, + HasErrors: true, + Errors: []GraphError{ + { + Message: "sample error", + }, + }, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - var sc *ast.Document - if tc.modifySchema != nil { - sc = tc.modifySchema(*schema) - } else { - sc = schema - } - ref, err := getOperationSelectionFieldDefinition(tc.operationType, tc.operationName, sc) - if tc.expectedErr != "" { - assert.ErrorContains(t, err, tc.expectedErr) - } else { - assert.NoError(t, err) + record := recordSample + record.GraphQLStats = tc.graphStats + gotten := record.ToGraphRecord() + if diff := cmp.Diff(tc.expected, gotten, cmpopts.IgnoreFields(GraphRecord{}, "AnalyticsRecord")); diff != "" { + t.Fatal(diff) } - - assert.Equal(t, tc.expectedRef, ref) }) } } diff --git a/pumps/graph_mongo.go b/pumps/graph_mongo.go index a22d7fcc1..4bad39799 100644 --- a/pumps/graph_mongo.go +++ b/pumps/graph_mongo.go @@ -116,7 +116,7 @@ func (g *GraphMongoPump) WriteData(ctx context.Context, data []interface{}) erro gr analytics.GraphRecord err error ) - if r.RawRequest == "" || r.RawResponse == "" || r.ApiSchema == "" { + if !r.GraphQLStats.IsGraphQL { g.log.Warn("skipping record parsing") gr = analytics.GraphRecord{AnalyticsRecord: *r} } else { diff --git a/pumps/graph_mongo_test.go b/pumps/graph_mongo_test.go index 4e6bd752a..74410708a 100644 --- a/pumps/graph_mongo_test.go +++ b/pumps/graph_mongo_test.go @@ -2,7 +2,6 @@ package pumps import ( "context" - "encoding/base64" "testing" "github.com/TykTechnologies/tyk-pump/analytics" @@ -11,139 +10,6 @@ import ( "github.com/stretchr/testify/assert" ) -const rawGQLRequest = `POST / HTTP/1.1 -Host: localhost:8181 -User-Agent: PostmanRuntime/7.29.2 -Content-Length: 58 -Accept: */* -Accept-Encoding: gzip, deflate, br -Content-Type: application/json -Postman-Token: e6d4bc44-3268-40ae-888b-d84bb5ea07fd - -{"query":"{\n country(code: \"NGN\"){\n code\n }\n}"}` - -const rawGQLResponse = `HTTP/0.0 200 OK -Content-Length: 25 -Connection: close -Content-Type: application/json -X-Ratelimit-Limit: 0 -X-Ratelimit-Remaining: 0 -X-Ratelimit-Reset: 0 - -{"data":{"country":null}}` - -const rawGQLResponseWithError = `HTTP/0.0 200 OK -Content-Length: 61 -Connection: close -Content-Type: application/json -X-Ratelimit-Limit: 0 -X-Ratelimit-Remaining: 0 -X-Ratelimit-Reset: 0 - -{"data":{"country":null},"errors":[{"message":"test error"}]}` - -const schema = `type Query { - countries(filter: CountryFilterInput): [Country!]! - country(code: ID!): Country - continents(filter: ContinentFilterInput): [Continent!]! - continent(code: ID!): Continent - languages(filter: LanguageFilterInput): [Language!]! - language(code: ID!): Language -} - -type Country { - code: ID! - name: String! - native: String! - phone: String! - continent: Continent! - capital: String - currency: String - languages: [Language!]! - emoji: String! - emojiU: String! - states: [State!]! -} - -type Continent { - code: ID! - name: String! - countries: [Country!]! -} - -type Language { - code: ID! - name: String - native: String - rtl: Boolean! -} - -type State { - code: String - name: String! - country: Country! -} - -input StringQueryOperatorInput { - eq: String - ne: String - in: [String] - nin: [String] - regex: String - glob: String -} - -input CountryFilterInput { - code: StringQueryOperatorInput - currency: StringQueryOperatorInput - continent: StringQueryOperatorInput -} - -input ContinentFilterInput { - code: StringQueryOperatorInput -} - -input LanguageFilterInput { - code: StringQueryOperatorInput -}` - -const rawHTTPReq = `GET /get HTTP/1.1 -Host: localhost:8181 -User-Agent: PostmanRuntime/7.29.2 -Accept: */* -Accept-Encoding: gzip, deflate, br -Postman-Token: a67c3054-aa1a-47f3-9bca-5dbde04c8565 -` - -const rawHTTPResponse = ` -HTTP/1.1 200 OK -Content-Length: 376 -Access-Control-Allow-Credentials: true -Access-Control-Allow-Origin: * -Connection: close -Content-Type: application/json -Date: Tue, 04 Oct 2022 06:33:23 GMT -Server: gunicorn/19.9.0 -X-Ratelimit-Limit: 0 -X-Ratelimit-Remaining: 0 -X-Ratelimit-Reset: 0 - -{ - "args": {}, - "headers": { - "Accept": "*/*", - "Accept-Encoding": "gzip, deflate, br", - "Host": "httpbin.org", - "Postman-Token": "a67c3054-aa1a-47f3-9bca-5dbde04c8565", - "User-Agent": "PostmanRuntime/7.29.2", - "X-Amzn-Trace-Id": "Root=1-633bd3b3-6345504724f3295b68d7dcd3" - }, - "origin": "::1, 102.89.45.253", - "url": "http://httpbin.org/get" -} - -` - func TestGraphMongoPump_WriteData(t *testing.T) { conf := defaultConf() pump := GraphMongoPump{ @@ -158,42 +24,48 @@ func TestGraphMongoPump_WriteData(t *testing.T) { pump.connect() - type customRecord struct { - rawRequest string - rawResponse string - schema string - tags []string - responseCode int + sampleRecord := analytics.AnalyticsRecord{ + APIName: "Test API", + Path: "POST", } - testCases := []struct { expectedError string name string + modifyRecord func() []interface{} expectedGraphRecords []analytics.GraphRecord - records []customRecord }{ { name: "all records written", - records: []customRecord{ - { - rawRequest: rawGQLRequest, - rawResponse: rawGQLResponse, - schema: schema, - tags: []string{analytics.PredefinedTagGraphAnalytics}, - }, - { - rawRequest: rawGQLRequest, - rawResponse: rawGQLResponseWithError, - schema: schema, - tags: []string{analytics.PredefinedTagGraphAnalytics}, - }, - { - rawRequest: rawGQLRequest, - rawResponse: rawGQLResponse, - schema: schema, - tags: []string{analytics.PredefinedTagGraphAnalytics}, - responseCode: 500, - }, + modifyRecord: func() []interface{} { + records := make([]interface{}, 3) + stats := analytics.GraphQLStats{ + IsGraphQL: true, + Types: map[string][]string{ + "Country": {"code"}, + }, + RootFields: []string{"country"}, + HasErrors: false, + OperationType: analytics.OperationQuery, + } + for i := range records { + record := sampleRecord + record.GraphQLStats = stats + switch i { + case 0: + record.GraphQLStats.HasErrors = false + case 1: + record.GraphQLStats.HasErrors = true + record.GraphQLStats.Errors = []analytics.GraphError{ + { + Message: "test error", + }, + } + default: + record.GraphQLStats.HasErrors = true + } + records[i] = record + } + return records }, expectedGraphRecords: []analytics.GraphRecord{ { @@ -232,17 +104,26 @@ func TestGraphMongoPump_WriteData(t *testing.T) { }, { name: "contains non graph records", - records: []customRecord{ - { - rawRequest: rawGQLRequest, - rawResponse: rawGQLResponse, - schema: schema, - tags: []string{analytics.PredefinedTagGraphAnalytics}, - }, - { - rawRequest: rawHTTPReq, - rawResponse: rawHTTPResponse, - }, + modifyRecord: func() []interface{} { + records := make([]interface{}, 2) + stats := analytics.GraphQLStats{ + IsGraphQL: true, + Types: map[string][]string{ + "Country": {"code"}, + }, + RootFields: []string{"country"}, + HasErrors: false, + OperationType: analytics.OperationQuery, + } + for i := range records { + record := sampleRecord + record.GraphQLStats = stats + if i == 1 { + record.GraphQLStats.IsGraphQL = false + } + records[i] = record + } + return records }, expectedGraphRecords: []analytics.GraphRecord{ { @@ -256,66 +137,12 @@ func TestGraphMongoPump_WriteData(t *testing.T) { }, }, }, - { - name: "should be empty on empty request response", - records: []customRecord{ - { - rawRequest: "", - rawResponse: rawGQLResponse, - schema: schema, - tags: []string{analytics.PredefinedTagGraphAnalytics}, - }, - { - rawResponse: "", - rawRequest: rawGQLRequest, - schema: schema, - tags: []string{analytics.PredefinedTagGraphAnalytics}, - }, - { - rawRequest: rawGQLRequest, - rawResponse: rawGQLResponse, - tags: []string{analytics.PredefinedTagGraphAnalytics}, - }, - }, - expectedGraphRecords: []analytics.GraphRecord{ - { - Types: map[string][]string{}, - Errors: []analytics.GraphError{}, - RootFields: []string{}, - }, - { - Types: map[string][]string{}, - Errors: []analytics.GraphError{}, - RootFields: []string{}, - }, - { - Types: map[string][]string{}, - Errors: []analytics.GraphError{}, - RootFields: []string{}, - }, - }, - }, } // clean db before start for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - records := make([]interface{}, 0) - for _, cr := range tc.records { - r := analytics.AnalyticsRecord{ - APIName: "Test API", - Path: "POST", - RawRequest: base64.StdEncoding.EncodeToString([]byte(cr.rawRequest)), - RawResponse: base64.StdEncoding.EncodeToString([]byte(cr.rawResponse)), - ApiSchema: base64.StdEncoding.EncodeToString([]byte(cr.schema)), - Tags: cr.tags, - } - if cr.responseCode != 0 { - r.ResponseCode = cr.responseCode - } - records = append(records, r) - } - + records := tc.modifyRecord() err := pump.WriteData(context.Background(), records) if tc.expectedError != "" { assert.ErrorContains(t, err, tc.expectedError) diff --git a/pumps/graph_sql_aggregate_test.go b/pumps/graph_sql_aggregate_test.go index 7ebfd4d2d..36514fb45 100644 --- a/pumps/graph_sql_aggregate_test.go +++ b/pumps/graph_sql_aggregate_test.go @@ -204,6 +204,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits int success int error int + apiID string } testCases := []struct { @@ -215,12 +216,19 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { name: "default", recordGenerator: func() []interface{} { records := make([]interface{}, 3) + stats := analytics.GraphQLStats{ + IsGraphQL: true, + Types: map[string][]string{ + "Characters": {"info"}, + "Info": {"count"}, + }, + RootFields: []string{"characters"}, + HasErrors: false, + OperationType: analytics.OperationQuery, + } for i := range records { record := sampleRecord - query := `{"query":"query{\n characters(filter: {\n \n }){\n info{\n count\n }\n }\n}"}` - response := `{"data":{"characters":{"info":{"count":758}}}}` - record.RawRequest = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(requestTemplate, len(query), query))) - record.RawResponse = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(responseTemplate, len(response), response))) + record.GraphQLStats = stats records[i] = record } return records @@ -233,6 +241,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 0, success: 3, + apiID: "test-api", }, { orgID: "test-org", @@ -241,6 +250,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 0, success: 3, + apiID: "test-api", }, { orgID: "test-org", @@ -249,6 +259,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 0, success: 3, + apiID: "test-api", }, { orgID: "test-org", @@ -257,6 +268,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 0, success: 3, + apiID: "test-api", }, { orgID: "test-org", @@ -265,6 +277,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 0, success: 3, + apiID: "test-api", }, { orgID: "test-org", @@ -273,21 +286,118 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 0, success: 3, + apiID: "test-api", }, }, }, { - name: "skip non graph records", + name: "default with different api ID", recordGenerator: func() []interface{} { records := make([]interface{}, 3) + stats := analytics.GraphQLStats{ + IsGraphQL: true, + Types: map[string][]string{ + "Characters": {"info"}, + "Info": {"count"}, + }, + RootFields: []string{"characters"}, + HasErrors: false, + OperationType: analytics.OperationQuery, + } for i := range records { record := sampleRecord - query := `{"query":"query{\n characters(filter: {\n \n }){\n info{\n count\n }\n }\n}"}` - response := `{"data":{"characters":{"info":{"count":758}}}}` - record.RawRequest = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(requestTemplate, len(query), query))) - record.RawResponse = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(responseTemplate, len(response), response))) + record.GraphQLStats = stats if i == 1 { - record.Tags = []string{} + record.APIID = "second-api" + } + records[i] = record + } + return records + }, + expectedResults: []expectedResponseCheck{ + { + orgID: "test-org", + dimension: "types", + name: "Characters", + hits: 1, + error: 0, + success: 1, + apiID: "second-api", + }, + { + orgID: "test-org", + dimension: "types", + name: "Characters", + hits: 2, + error: 0, + success: 2, + apiID: "test-api", + }, + { + orgID: "test-org", + dimension: "types", + name: "Info", + hits: 2, + error: 0, + success: 2, + apiID: "test-api", + }, + { + orgID: "test-org", + dimension: "fields", + name: "Characters_info", + hits: 2, + error: 0, + success: 2, + apiID: "test-api", + }, + { + orgID: "test-org", + dimension: "fields", + name: "Info_count", + hits: 2, + error: 0, + success: 2, + apiID: "test-api", + }, + { + orgID: "test-org", + dimension: "rootfields", + name: "characters", + hits: 2, + error: 0, + success: 2, + apiID: "test-api", + }, + { + orgID: "test-org", + dimension: "operation", + name: "Query", + hits: 2, + error: 0, + success: 2, + apiID: "test-api", + }, + }, + }, + { + name: "skip non graph records", + recordGenerator: func() []interface{} { + stats := analytics.GraphQLStats{ + IsGraphQL: true, + OperationType: analytics.OperationQuery, + Types: map[string][]string{ + "Characters": {"info"}, + "Info": {"count"}, + }, + RootFields: []string{"characters"}, + HasErrors: false, + } + records := make([]interface{}, 3) + for i := range records { + record := sampleRecord + if i != 1 { + record.GraphQLStats = stats } records[i] = record } @@ -301,6 +411,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 2, error: 0, success: 2, + apiID: "test-api", }, { orgID: "test-org", @@ -309,6 +420,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 2, error: 0, success: 2, + apiID: "test-api", }, { orgID: "test-org", @@ -317,6 +429,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 2, error: 0, success: 2, + apiID: "test-api", }, { orgID: "test-org", @@ -325,6 +438,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 2, error: 0, success: 2, + apiID: "test-api", }, { orgID: "test-org", @@ -333,6 +447,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 2, error: 0, success: 2, + apiID: "test-api", }, { orgID: "test-org", @@ -341,22 +456,35 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 2, error: 0, success: 2, + apiID: "test-api", }, }, }, { name: "has errors", recordGenerator: func() []interface{} { + stats := analytics.GraphQLStats{ + IsGraphQL: true, + Types: map[string][]string{ + "Characters": {"info"}, + "Info": {"count"}, + }, + RootFields: []string{"characters"}, + HasErrors: false, + OperationType: analytics.OperationQuery, + } records := make([]interface{}, 3) for i := range records { record := sampleRecord - query := `{"query":"query{\n characters(filter: {\n \n }){\n info{\n count\n }\n }\n}"}` - response := `{"data":{"characters":{"info":{"count":758}}}}` + record.GraphQLStats = stats if i == 1 { - response = graphErrorResponse + record.GraphQLStats.HasErrors = true + record.GraphQLStats.Errors = []analytics.GraphError{ + { + Message: "Name for character with ID 1002 could not be fetched", + }, + } } - record.RawRequest = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(requestTemplate, len(query), query))) - record.RawResponse = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(responseTemplate, len(response), response))) records[i] = record } return records @@ -369,6 +497,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 1, success: 2, + apiID: "test-api", }, { orgID: "test-org", @@ -377,6 +506,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 1, success: 2, + apiID: "test-api", }, { orgID: "test-org", @@ -385,6 +515,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 1, success: 2, + apiID: "test-api", }, { orgID: "test-org", @@ -393,6 +524,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 1, success: 2, + apiID: "test-api", }, { orgID: "test-org", @@ -401,6 +533,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 1, success: 2, + apiID: "test-api", }, { orgID: "test-org", @@ -409,6 +542,7 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { hits: 3, error: 1, success: 2, + apiID: "test-api", }, }, }, @@ -429,14 +563,20 @@ func TestSqlGraphAggregatePump_WriteData(t *testing.T) { for _, expected := range tc.expectedResults { resp := make([]analytics.SQLAnalyticsRecordAggregate, 0) tx := pump.db.Table(analytics.AggregateGraphSQLTable).Where( - "org_id = ? AND dimension = ? AND dimension_value = ? AND counter_hits = ? AND counter_success = ? AND counter_error = ?", - expected.orgID, expected.dimension, expected.name, expected.hits, expected.success, expected.error, + "org_id = ? AND dimension = ? AND dimension_value = ? AND counter_hits = ? AND counter_success = ? AND counter_error = ? AND api_id = ?", + expected.orgID, expected.dimension, expected.name, expected.hits, expected.success, expected.error, expected.apiID, ).Find(&resp) r.NoError(tx.Error) if len(resp) < 1 { t.Errorf( - "couldn't find record with fields: org_id: %s, dimension: %s, dimension_value: %s, counter_hits: %d, counter_success: %d, counter_error: %d", - expected.orgID, expected.dimension, expected.name, expected.hits, expected.success, expected.error, + "couldn't find record with fields: api_id: %s, org_id: %s, dimension: %s, dimension_value: %s, counter_hits: %d, counter_success: %d, counter_error: %d", + expected.apiID, + expected.orgID, + expected.dimension, + expected.name, + expected.hits, + expected.success, + expected.error, ) } } @@ -464,8 +604,18 @@ func TestGraphSQLAggregatePump_WriteData_Sharded(t *testing.T) { Year: 2022, Hour: 0, OrgID: "test-org", - RawRequest: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(requestTemplate, len(sampleQuery), sampleQuery))), - RawResponse: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(responseTemplate, len(sampleResponse), sampleResponse))), + GraphQLStats: analytics.GraphQLStats{ + IsGraphQL: true, + Types: map[string][]string{ + "Characters": {"info"}, + "Info": {"count"}, + }, + RootFields: []string{ + "characters", + }, + OperationType: analytics.OperationQuery, + HasErrors: false, + }, } t.Run("should shard successfully", func(t *testing.T) { diff --git a/pumps/graph_sql_test.go b/pumps/graph_sql_test.go index 6e4eb4ff8..ea175a2e7 100644 --- a/pumps/graph_sql_test.go +++ b/pumps/graph_sql_test.go @@ -2,7 +2,6 @@ package pumps import ( "context" - "encoding/base64" "fmt" "os" "testing" @@ -113,12 +112,7 @@ func TestGraphSQLPump_Init(t *testing.T) { }) } -func convToBase64(raw string) string { - return base64.StdEncoding.EncodeToString([]byte(raw)) -} - func TestGraphSQLPump_WriteData(t *testing.T) { - r := require.New(t) conf := GraphSQLConf{ SQLConf: SQLConf{ Type: "sqlite", @@ -127,97 +121,117 @@ func TestGraphSQLPump_WriteData(t *testing.T) { TableName: "test-table", } - type customRecord struct { - response string - tags []string - responseCode int - isHTTP bool - } type customResponses struct { types map[string][]string operationType string expectedErr []analytics.GraphError operations []string + variables string } testCases := []struct { - name string - records []customRecord - responses []customResponses - hasError bool + name string + graphStats []analytics.GraphQLStats + responses []customResponses + hasError bool }{ { name: "default case", - records: []customRecord{ + graphStats: []analytics.GraphQLStats{ { - isHTTP: false, - tags: []string{analytics.PredefinedTagGraphAnalytics}, - responseCode: 200, - response: rawGQLResponse, + IsGraphQL: true, + HasErrors: false, + Types: map[string][]string{ + "Character": {"info", "age"}, + "Info": {"height"}, + }, + RootFields: []string{"character"}, + OperationType: analytics.OperationQuery, }, { - isHTTP: false, - tags: []string{analytics.PredefinedTagGraphAnalytics}, - responseCode: 200, - response: rawGQLResponseWithError, + IsGraphQL: true, + HasErrors: true, + Types: map[string][]string{ + "Character": {"info", "age"}, + "Info": {"height"}, + }, + RootFields: []string{"character"}, + OperationType: analytics.OperationSubscription, + Errors: []analytics.GraphError{ + { + Message: "sample error", + }, + }, }, { - isHTTP: false, - tags: []string{analytics.PredefinedTagGraphAnalytics}, - responseCode: 500, - response: "", + IsGraphQL: true, + HasErrors: false, + Types: map[string][]string{ + "Character": {"info", "age"}, + "Info": {"height"}, + }, + RootFields: []string{"character"}, + OperationType: analytics.OperationQuery, + Variables: `{"in":"hello"}`, }, }, + // TODO location info in errors responses: []customResponses{ { types: map[string][]string{ - "Country": {"code"}, + "Character": {"info", "age"}, + "Info": {"height"}, }, operationType: "Query", - operations: []string{"country"}, + operations: []string{"character"}, }, { types: map[string][]string{ - "Country": {"code"}, + "Character": {"info", "age"}, + "Info": {"height"}, }, - operationType: "Query", + operationType: "Subscription", expectedErr: []analytics.GraphError{ { - Message: "test error", + Message: "sample error", Path: []interface{}{}, }, }, - operations: []string{"country"}, + operations: []string{"character"}, }, { types: map[string][]string{ - "Country": {"code"}, + "Character": {"info", "age"}, + "Info": {"height"}, }, operationType: "Query", expectedErr: []analytics.GraphError{}, - operations: []string{"country"}, + operations: []string{"character"}, + variables: `{"in":"hello"}`, }, }, hasError: false, }, { name: "skip record", - records: []customRecord{ - { - isHTTP: false, - tags: []string{analytics.PredefinedTagGraphAnalytics}, - responseCode: 200, - response: rawGQLResponse, - }, + graphStats: []analytics.GraphQLStats{ { - isHTTP: true, - responseCode: 200, - response: rawHTTPResponse, + IsGraphQL: true, + HasErrors: false, + Types: map[string][]string{ + "Country": {"code"}, + }, + RootFields: []string{"country"}, + OperationType: analytics.OperationQuery, }, { - isHTTP: false, - responseCode: 200, - response: rawGQLResponse, + IsGraphQL: false, + HasErrors: false, + Types: map[string][]string{ + "Country": {"code"}, + }, + RootFields: []string{"country"}, + OperationType: analytics.OperationQuery, }, }, responses: []customResponses{ @@ -245,22 +259,11 @@ func TestGraphSQLPump_WriteData(t *testing.T) { records := make([]interface{}, 0) expectedResponses := make([]analytics.GraphRecord, 0) // create the records to passed to the pump - for _, item := range tc.records { + for _, item := range tc.graphStats { r := analytics.AnalyticsRecord{ - APIName: "Test API", - Path: "POST", - Tags: item.tags, - } - if !item.isHTTP { - r.RawRequest = convToBase64(rawGQLRequest) - r.ApiSchema = convToBase64(schema) - } else { - r.RawRequest = convToBase64(rawHTTPReq) - r.RawResponse = convToBase64(rawHTTPResponse) - } - r.RawResponse = convToBase64(item.response) - if item.responseCode != 0 { - r.ResponseCode = item.responseCode + APIName: "Test API", + Path: "POST", + GraphQLStats: item, } records = append(records, r) } @@ -271,8 +274,9 @@ func TestGraphSQLPump_WriteData(t *testing.T) { Types: item.types, OperationType: item.operationType, Errors: []analytics.GraphError{}, + Variables: item.variables, } - if item.expectedErr == nil { + if len(item.expectedErr) == 0 { r.Errors = []analytics.GraphError{} } else { r.Errors = item.expectedErr @@ -289,15 +293,15 @@ func TestGraphSQLPump_WriteData(t *testing.T) { err := pump.WriteData(context.Background(), records) if !tc.hasError { - r.NoError(err) + require.NoError(t, err) } else { - r.Error(err) + require.Error(t, err) } var resultRecords []analytics.GraphRecord tx := pump.db.Table(conf.TableName).Find(&resultRecords) - r.NoError(tx.Error) - r.Equalf(len(tc.responses), len(resultRecords), "responses count do no match") + require.NoError(t, tx.Error) + require.Equalf(t, len(tc.responses), len(resultRecords), "responses count do no match") if diff := cmp.Diff(expectedResponses, resultRecords, cmpopts.IgnoreFields(analytics.GraphRecord{}, "AnalyticsRecord")); diff != "" { t.Error(diff) } @@ -321,13 +325,18 @@ func TestGraphSQLPump_Sharded(t *testing.T) { baseRecord := analytics.AnalyticsRecord{ APIID: "test-api", Path: "/test-api", - RawRequest: convToBase64(rawGQLRequest), - RawResponse: convToBase64(rawGQLResponse), - ApiSchema: convToBase64(schema), - Tags: []string{analytics.PredefinedTagGraphAnalytics}, APIName: "test-api", ResponseCode: 200, Method: "POST", + GraphQLStats: analytics.GraphQLStats{ + IsGraphQL: true, + Types: map[string][]string{ + "Country": {"code"}, + }, + RootFields: []string{"country"}, + OperationType: analytics.OperationQuery, + HasErrors: false, + }, } expectedTables := make([]string, 0) diff --git a/pumps/mongo_test.go b/pumps/mongo_test.go index 00a63a452..d0a0f888b 100644 --- a/pumps/mongo_test.go +++ b/pumps/mongo_test.go @@ -626,7 +626,7 @@ func TestMongoPump_WriteData(t *testing.T) { // ensure the length and content are the same assert.Equal(t, len(data), len(results)) - if diff := cmp.Diff(data, results, cmpopts.IgnoreFields(analytics.AnalyticsRecord{}, "id", "ApiSchema")); diff != "" { + if diff := cmp.Diff(data, results, cmpopts.IgnoreFields(analytics.AnalyticsRecord{}, "id", "ApiSchema", "GraphQLStats")); diff != "" { t.Error(diff) } } @@ -645,10 +645,12 @@ func TestMongoPump_WriteData(t *testing.T) { for i := range records { record := sampleRecord if i%2 == 0 { - record.RawRequest = rawGQLRequest - record.RawResponse = rawGQLResponse - record.ApiSchema = schema - record.Tags = []string{analytics.PredefinedTagGraphAnalytics} + record.GraphQLStats.IsGraphQL = true + record.GraphQLStats.Types = map[string][]string{ + "Country": {"code"}, + } + record.GraphQLStats.RootFields = []string{"country"} + record.GraphQLStats.HasErrors = false } records[i] = record }